多くのサイクリンググループには定期的なライドがあります - 月曜朝の通勤グループ、月の第1土曜日のアドベンチャーライド、または水曜夜のソーシャルスピンなど。これまで、主催者は各イベントを手動で作成する必要がありました。新しい定期パーティー機能により、スケジュールを一度設定するだけで、Party Onbiciが自動的にインスタンスを生成します。

単発イベントの問題点

コミュニティサイクリンググループには通常、予測可能なスケジュールがあります:

  • 「毎週火曜日と木曜日の午前6時30分」
  • 「毎月第1日曜日」
  • 「隔週土曜日の朝」

これらのイベントを手動で作成するのは面倒で、間違いが起きやすく、各回で一貫したルートや設定を維持することが難しくなります。

iCalendar RRULEの導入

私たちはiCalendar RRULE標準(RFC 5545)に基づいて定期イベントシステムを構築しました。これはGoogle Calendar、Apple Calendar、Outlookで使用されているのと同じ形式で、より広いカレンダーエコシステムとの互換性を確保しています。

繰り返しパターンの例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
  → 毎週月曜日、水曜日、金曜日

RRULE:FREQ=MONTHLY;BYDAY=1SA
  → 毎月第1土曜日

RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=SU
  → 隔週日曜日

RRULE:FREQ=DAILY;COUNT=10
  → 10回分の日次

データモデル

RecurringPartyPartyインスタンスを生成するテンプレートとして機能します:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class RecurringParty(models.Model):
    name = models.CharField(max_length=255)

    # Schedule configuration
    start_date = models.DateField()
    end_date = models.DateField(null=True, blank=True)
    max_occurrences = models.PositiveIntegerField(null=True, blank=True)
    recurrence = RecurrenceField()  # RRULE pattern

    # Template fields (copied to each instance)
    departure_time = models.TimeField()
    arrival_time = models.TimeField()
    origin = PointField()
    destination = PointField()
    route = models.JSONField()
    difficulty = models.CharField(choices=DIFFICULTY_CHOICES)
    # ... other party attributes

    is_active = models.BooleanField(default=True)

生成された各Partyインスタンスは親にリンクします:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Party(models.Model):
    # Existing fields...

    # Recurring party support
    scheduled_date = models.DateField(null=True, blank=True)
    recurring_parent = models.ForeignKey(
        RecurringParty,
        on_delete=models.SET_NULL,
        null=True,
        related_name="instances"
    )
    is_cancelled = models.BooleanField(default=False)

インスタンス生成

定期パーティーが作成されるか、スケジュールが変更されると、今後の期間のインスタンスを生成します:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def generate_instances(self, lookahead_days: int = 30) -> list[Party]:
    """Generate party instances for upcoming occurrences."""
    occurrences = self.recurrence.between(
        datetime.now().date(),
        datetime.now().date() + timedelta(days=lookahead_days),
    )

    created = []
    for date in occurrences:
        # Skip if instance already exists
        if self.instances.filter(scheduled_date=date).exists():
            continue

        # Create new party instance from template
        party = Party.objects.create(
            name=f"{self.name} - {date.strftime('%B %d')}",
            scheduled_date=date,
            recurring_parent=self,
            departure_time=self.departure_time,
            arrival_time=self.arrival_time,
            origin=self.origin,
            destination=self.destination,
            route=self.route,
            # ... copy other template fields
        )
        created.append(party)

    return created

Celery Beatによる自動生成

日次のCeleryタスクがインスタンスを最新の状態に保ちます:

1
2
3
4
5
6
7
8
9
@shared_task
def generate_recurring_party_instances():
    """Generate party instances for all active recurring parties."""
    for recurring_party in RecurringParty.objects.filter(is_active=True):
        created = recurring_party.generate_instances(lookahead_days=30)
        if created:
            logger.info(
                f"Generated {len(created)} instances for {recurring_party.name}"
            )

Celery Beatスケジュールはこれを深夜に実行します:

1
2
3
4
5
6
CELERY_BEAT_SCHEDULE = {
    "generate-recurring-party-instances": {
        "task": "party_onbici.apps.party.tasks.generate_recurring_party_instances",
        "schedule": crontab(hour=0, minute=0),  # Daily at midnight
    },
}

ユーザーインターフェース

定期パーティーの作成

フォームはdjango-recurrenceウィジェットを使用してRRULEパターンを構築します:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class RecurringPartyForm(forms.ModelForm):
    recurrence = RecurrenceField(
        label="Recurrence pattern",
        help_text="Set up when this party should repeat.",
        required=True,
    )

    class Meta:
        model = RecurringParty
        fields = [
            "name", "start_date", "end_date", "recurrence",
            "departure_time", "arrival_time",
            "origin", "destination", "description",
            "difficulty", "gender", "privacy",
        ]

ウィジェットは一般的なパターン用の直感的なインターフェースを提供します:

  • 日次 / 週次 / 月次 / 年次
  • 特定の曜日
  • 序数パターン(第1、第2、最終)
  • カスタム間隔(2週間ごと)
  • 終了条件(特定の日まで、N回後、または無期限)

インスタンスの管理

主催者はすべての生成されたインスタンスを表示し、以下のことができます:

  • 個別のインスタンスを編集 - 1回分のルートや時間を変更
  • インスタンスをキャンセル - 特定の日付をキャンセル済みとしてマーク
  • インスタンスを再生成 - 次の30日間の生成を手動でトリガー

エッジケースの処理

タイムゾーン対応

すべての日付は主催者の設定されたタイムゾーンで保存され、閲覧者のローカル時間で表示されます:

1
2
3
# Generate instances using timezone-aware dates
local_tz = get_current_timezone()
today = timezone.now().astimezone(local_tz).date()

祝日と例外の処理

ユーザーは定期パターンに影響を与えずに個別のインスタンスをキャンセルできます:

1
2
3
party = recurring_party.instances.get(scheduled_date=holiday_date)
party.is_cancelled = True
party.save()

キャンセルされたインスタンスは視覚的なインジケーター付きでリストに表示されたままになり、混乱を防ぎます。

孤立したインスタンス

定期パーティーが削除された場合、生成されたインスタンスはオプションで保持できます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def form_valid(self, form):
    delete_instances = request.POST.get("delete_instances") == "true"

    if delete_instances:
        # Soft-delete future instances
        recurring_party.instances.filter(
            scheduled_date__gte=date.today(),
            deleted_at__isnull=True,
        ).update(deleted_at=timezone.now())

    recurring_party.delete()

ユーザー体験

週次ライドの作成は数ステップで完了します:

  1. ダッシュボード → 定期パーティー → 作成に移動
  2. パーティーの詳細を設定(ルート、時間、難易度)
  3. 繰り返しパターンを設定(例:「毎週土曜日午前8時」)
  4. 開始日とオプションの終了日を設定
  5. 保存 - インスタンスが自動的に生成されます!

システムは30日先までのパーティーインスタンスを作成し、Celeryが時間の経過とともに新しいものを生成し続けます。

サイクリングコミュニティへのメリット

  • 設定したら忘れる - 毎週のイベント作成は不要
  • 一貫性 - 同じルート、同じ時間、同じ設定
  • 柔軟性 - 個別の回を編集またはキャンセル
  • 発見 - ライダーは定期的なグループライドを簡単に見つけられる
  • カレンダー連携 - カレンダーアプリで定期イベントを購読

定期ライドを設定する準備はできましたか?定期パーティーを作成して、スケジューリングは私たちにお任せください!