From 34d2c7372d3d30b6ebee85d6f814be1f98dd15f5 Mon Sep 17 00:00:00 2001 From: joshchen-dev Date: Tue, 6 Oct 2026 15:23:48 +0000 Subject: [PATCH] fix: move open dates when the raid time changes Saving the schedule only changed the template for future periods, so the open period kept showing the old raid time on the web and in Discord. Saving a new start/end time or timezone now also moves every date that hasn't started in the team's open periods to the new time on the same local day, keeping its votes and status. Weekday changes still start with the next period, because removing a date would discard its votes; the schedule page now says so. After a schedule or roster save, every open timetable is re-rendered in Discord, not just the latest one. Release v0.0.15. --- README.md | 3 +- backend/internal/discord/poll.go | 14 ++-- backend/internal/postgres/store.go | 73 ++++++++++++++++++- backend/internal/postgres/store_test.go | 77 ++++++++++++++++++-- backend/internal/team/team.go | 32 +++++--- backend/internal/web/api.go | 7 +- web/e2e/web.spec.ts | 2 + web/package-lock.json | 4 +- web/package.json | 2 +- web/src/features/schedule/schedule-panel.tsx | 9 ++- 10 files changed, 190 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index c424d34..75551ad 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Raidy is a raid scheduling project for Discord statics: the web console is where everyone plans, and Discord carries the announcements. One Go process runs the Discord bot, web API, scheduler, and production web assets, backed by PostgreSQL. -**Status:** `v0.0.14` is an early development snapshot. +**Status:** `v0.0.15` is an early development snapshot. ```text backend/ Go service, Discord interactions, web API, PostgreSQL migrations @@ -19,6 +19,7 @@ deployment/ local PostgreSQL and Kubernetes deployment manifests - Members mark the dates they can attend on the availability grid; omitted future dates become unavailable. - Leaders confirm, cancel, or reopen dates. A confirmed date becomes `Attention required` when availability drops. - Active polls follow roster additions and removals immediately; closed poll history stays unchanged. +- Changing the raid time or timezone moves upcoming dates in open periods to the new time on the same day and keeps their votes; changing raid days applies from the next period. - Polls become read-only history when their final occurrence starts. - Discord is announcement-only: each period is posted with its dates in every reader's own timezone, live availability counts and status, and an **Open in Raidy** link. The post is edited in place as votes and decisions change. - `/raidy` replies privately with a link to your team's page. diff --git a/backend/internal/discord/poll.go b/backend/internal/discord/poll.go index 4bfd7a5..00eef14 100644 --- a/backend/internal/discord/poll.go +++ b/backend/internal/discord/poll.go @@ -67,15 +67,17 @@ func (b *Bot) editPoll(ctx context.Context, pollID int64, active bool) error { return err } -func (b *Bot) RefreshTeamPoll(ctx context.Context, teamID int64) error { - view, err := b.Store.LatestPoll(ctx, teamID) - if errors.Is(err, postgres.ErrNotFound) { - return nil - } +// RefreshOpenPolls re-renders every open timetable of a team in the +// background, after a roster or schedule change. +func (b *Bot) RefreshOpenPolls(ctx context.Context, teamID int64) error { + ids, err := b.Store.OpenPollIDs(ctx, teamID) if err != nil { return err } - return b.editPoll(ctx, view.Poll.ID, true) + for _, id := range ids { + b.refresher.queue(id) + } + return nil } func renderAnnouncement(view team.PollView, active bool, baseURL string) ([]*discordgo.MessageEmbed, []discordgo.MessageComponent) { diff --git a/backend/internal/postgres/store.go b/backend/internal/postgres/store.go index 0376745..aa278fa 100644 --- a/backend/internal/postgres/store.go +++ b/backend/internal/postgres/store.go @@ -263,7 +263,10 @@ func (s *Store) UpdateTeam(ctx context.Context, teamID int64, leaderID, name, ti return tx.Commit(ctx) } -func (s *Store) SaveSchedule(ctx context.Context, draft team.ScheduleDraft) error { +// SaveSchedule stores the team's recurring template. A new raid time also +// moves upcoming dates in open periods; weekday changes start with the next +// period because removing a date would discard its votes. +func (s *Store) SaveSchedule(ctx context.Context, draft team.ScheduleDraft, now time.Time) error { if draft.CadenceDays != 7 && draft.CadenceDays != 14 { return ValidationError{"cadence must be weekly or biweekly"} } @@ -284,6 +287,10 @@ func (s *Store) SaveSchedule(ctx context.Context, draft team.ScheduleDraft) erro if err := requireLeader(ctx, tx, draft.TeamID, draft.UserID); err != nil { return err } + var previousTimezone string + if err := tx.QueryRow(ctx, `SELECT timezone FROM teams WHERE id=$1`, draft.TeamID).Scan(&previousTimezone); err != nil { + return err + } if draft.Timezone != "" { if _, err := time.LoadLocation(draft.Timezone); err != nil { return ValidationError{"invalid timezone"} @@ -313,9 +320,63 @@ func (s *Store) SaveSchedule(ctx context.Context, draft team.ScheduleDraft) erro return err } } + if err := moveUpcomingOccurrences(ctx, tx, draft, previousTimezone, now); err != nil { + return err + } return tx.Commit(ctx) } +// moveUpcomingOccurrences gives every not-yet-started date in the team's open +// periods the schedule's raid time on the same local day, keeping its votes. +func moveUpcomingOccurrences(ctx context.Context, tx pgx.Tx, draft team.ScheduleDraft, previousTimezone string, now time.Time) error { + timezone := draft.Timezone + if timezone == "" { + timezone = previousTimezone + } + from, err := time.LoadLocation(previousTimezone) + if err != nil { + return err + } + to, err := time.LoadLocation(timezone) + if err != nil { + return err + } + rows, err := tx.Query(ctx, ` + SELECT o.id, o.starts_at FROM poll_occurrences o + JOIN schedule_polls p ON p.id=o.poll_id + WHERE p.team_id=$1 AND p.closed_at IS NULL AND o.starts_at>$2`, draft.TeamID, now.UTC()) + if err != nil { + return err + } + type upcoming struct { + id int64 + startsAt time.Time + } + var occurrences []upcoming + for rows.Next() { + var value upcoming + if err := rows.Scan(&value.id, &value.startsAt); err != nil { + rows.Close() + return err + } + occurrences = append(occurrences, value) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + for _, occurrence := range occurrences { + start, end, err := team.RaidTimes(occurrence.startsAt.In(from), draft.StartMinutes, draft.EndMinutes, to) + if err != nil { + return ValidationError{err.Error()} + } + if _, err := tx.Exec(ctx, `UPDATE poll_occurrences SET starts_at=$2, ends_at=$3 WHERE id=$1`, occurrence.id, start, end); err != nil { + return err + } + } + return nil +} + func validPublishLead(days int) bool { for _, allowed := range []int{1, 2, 3, 5, 7, 10, 14} { if days == allowed { @@ -773,7 +834,8 @@ func (s *Store) LatestPoll(ctx context.Context, teamID int64) (team.PollView, er // OpenPolls returns every voting period that is still open, earliest first, so // the current week stays visible after the next week is published early. -func (s *Store) OpenPolls(ctx context.Context, teamID int64) ([]team.PollView, error) { +// OpenPollIDs lists the team's open periods, earliest first. +func (s *Store) OpenPollIDs(ctx context.Context, teamID int64) ([]int64, error) { rows, err := s.pool.Query(ctx, `SELECT id FROM schedule_polls WHERE team_id=$1 AND closed_at IS NULL ORDER BY period_start`, teamID) if err != nil { return nil, err @@ -788,7 +850,12 @@ func (s *Store) OpenPolls(ctx context.Context, teamID int64) ([]team.PollView, e ids = append(ids, id) } rows.Close() - if err := rows.Err(); err != nil { + return ids, rows.Err() +} + +func (s *Store) OpenPolls(ctx context.Context, teamID int64) ([]team.PollView, error) { + ids, err := s.OpenPollIDs(ctx, teamID) + if err != nil { return nil, err } views := make([]team.PollView, 0, len(ids)) diff --git a/backend/internal/postgres/store_test.go b/backend/internal/postgres/store_test.go index e75a188..3461d07 100644 --- a/backend/internal/postgres/store_test.go +++ b/backend/internal/postgres/store_test.go @@ -49,7 +49,7 @@ func TestForcedPublishDoesNotSkipPeriods(t *testing.T) { TeamID: created.ID, UserID: "leader", CadenceDays: 7, Weekdays: []time.Weekday{time.Wednesday}, StartMinutes: 21 * 60, EndMinutes: 23 * 60, FirstPeriodStart: periodStart, FirstPublishAt: periodStart.Add(-3 * 24 * time.Hour), ChannelID: "channel", - }); err != nil { + }, now); err != nil { t.Fatal(err) } if _, created, err := store.PublishNext(ctx, created.ID, true, now); err != nil || !created { @@ -113,7 +113,7 @@ func TestCatchUpSkipsElapsedPeriods(t *testing.T) { TeamID: created.ID, UserID: "leader", CadenceDays: 7, Weekdays: []time.Weekday{time.Wednesday}, StartMinutes: 21 * 60, EndMinutes: 23 * 60, FirstPeriodStart: firstPeriod, FirstPublishAt: firstPeriod.Add(-3 * 24 * time.Hour), ChannelID: "channel", - }); err != nil { + }, firstPeriod); err != nil { t.Fatal(err) } now := time.Date(2026, 9, 29, 12, 0, 0, 0, location) @@ -199,7 +199,7 @@ func TestOpenPollsListsEveryActivePeriodInOrder(t *testing.T) { if err := store.SaveSchedule(ctx, team.ScheduleDraft{ TeamID: created.ID, UserID: "leader", CadenceDays: 7, Weekdays: []time.Weekday{time.Wednesday}, StartMinutes: 21 * 60, EndMinutes: 23 * 60, FirstPeriodStart: periodStart, FirstPublishAt: publishAt, ChannelID: "channel", - }); err != nil { + }, publishAt); err != nil { t.Fatal(err) } if _, created, err := store.PublishNext(ctx, created.ID, false, publishAt); err != nil || !created { @@ -222,6 +222,71 @@ func TestOpenPollsListsEveryActivePeriodInOrder(t *testing.T) { } } +// Regression: changing the raid time only affected future periods, so the +// open period kept showing the old time. +func TestSavingScheduleMovesUpcomingDatesInOpenPeriods(t *testing.T) { + store, _ := openTestStore(t) + ctx := context.Background() + created, err := store.CreateTeam(ctx, "guild", "Raid Night", "leader", "The Echo", "Asia/Tokyo", nil) + if err != nil { + t.Fatal(err) + } + tokyo, _ := time.LoadLocation("Asia/Tokyo") + periodStart := time.Date(2026, 10, 5, 0, 0, 0, 0, tokyo) + publishAt := periodStart.Add(-3 * 24 * time.Hour) + draft := team.ScheduleDraft{ + TeamID: created.ID, UserID: "leader", CadenceDays: 7, Weekdays: []time.Weekday{time.Monday, time.Wednesday}, + StartMinutes: 21 * 60, EndMinutes: 23 * 60, FirstPeriodStart: periodStart, FirstPublishAt: publishAt, ChannelID: "channel", + } + if err := store.SaveSchedule(ctx, draft, publishAt); err != nil { + t.Fatal(err) + } + poll, _, err := store.PublishNext(ctx, created.ID, false, publishAt) + if err != nil { + t.Fatal(err) + } + monday, wednesday := poll.Occurrences[0], poll.Occurrences[1] + if err := store.SetAvailability(ctx, poll.ID, "leader", []int64{monday.ID, wednesday.ID}, publishAt); err != nil { + t.Fatal(err) + } + + // Monday's raid has started; the leader moves the raid to 20:00–22:30 + // and drops Monday from the template. + now := time.Date(2026, 10, 5, 21, 30, 0, 0, tokyo) + draft.Weekdays = []time.Weekday{time.Wednesday} + draft.StartMinutes, draft.EndMinutes = 20*60, 22*60+30 + draft.FirstPeriodStart = periodStart.AddDate(0, 0, 7) + draft.FirstPublishAt = publishAt.AddDate(0, 0, 7) + if err := store.SaveSchedule(ctx, draft, now); err != nil { + t.Fatal(err) + } + + view, err := store.PollView(ctx, poll.ID) + if err != nil { + t.Fatal(err) + } + if len(view.Poll.Occurrences) != 2 { + t.Fatalf("occurrences = %d, want the open period's dates kept", len(view.Poll.Occurrences)) + } + byID := map[int64]team.Occurrence{} + for _, occurrence := range view.Poll.Occurrences { + byID[occurrence.ID] = occurrence + } + if got := byID[monday.ID].StartsAt.In(tokyo).Format("Mon 15:04"); got != "Mon 21:00" { + t.Fatalf("started Monday moved to %s, want it unchanged", got) + } + moved := byID[wednesday.ID] + if got := moved.StartsAt.In(tokyo).Format("2006-01-02 15:04"); got != "2026-10-07 20:00" { + t.Fatalf("Wednesday starts %s, want 2026-10-07 20:00", got) + } + if got := moved.EndsAt.In(tokyo).Format("2006-01-02 15:04"); got != "2026-10-07 22:30" { + t.Fatalf("Wednesday ends %s, want 2026-10-07 22:30", got) + } + if !view.Availability[wednesday.ID]["leader"] { + t.Fatal("moving a date must keep its votes") + } +} + func periodStarts(views []team.PollView) []string { result := make([]string, len(views)) for i, view := range views { @@ -250,7 +315,7 @@ func TestTeamErrorsAreTyped(t *testing.T) { if _, err := store.CreateTeam(ctx, "guild", "Raid Night", "leader", "Too Big", "Asia/Tokyo", []string{"1", "2", "3", "4", "5", "6", "7", "8"}); !errors.As(err, &validation) { t.Fatalf("oversized roster error = %v, want ValidationError", err) } - if err := store.SaveSchedule(ctx, team.ScheduleDraft{TeamID: second.ID, UserID: "leader", CadenceDays: 3}); !errors.As(err, &validation) { + if err := store.SaveSchedule(ctx, team.ScheduleDraft{TeamID: second.ID, UserID: "leader", CadenceDays: 3}, time.Now()); !errors.As(err, &validation) { t.Fatalf("invalid cadence error = %v, want ValidationError", err) } } @@ -281,7 +346,7 @@ func TestSchedulingLifecycle(t *testing.T) { ChannelID: "channel", ChannelName: "raid-schedule", } - if err := store.SaveSchedule(ctx, draft); err != nil { + if err := store.SaveSchedule(ctx, draft, publishAt); err != nil { t.Fatal(err) } savedSchedule, err := store.Schedule(ctx, created.ID) @@ -303,7 +368,7 @@ func TestSchedulingLifecycle(t *testing.T) { if _, generated, err := store.PublishNext(ctx, created.ID, false, publishAt); err != nil || generated { t.Fatalf("second PublishNext() generated=%v err=%v", generated, err) } - if err := store.SaveSchedule(ctx, draft); err != nil { + if err := store.SaveSchedule(ctx, draft, publishAt); err != nil { t.Fatal(err) } if _, advanced, err := store.PublishNext(ctx, created.ID, false, publishAt); err != nil || !advanced { diff --git a/backend/internal/team/team.go b/backend/internal/team/team.go index 0e4575c..255c9bf 100644 --- a/backend/internal/team/team.go +++ b/backend/internal/team/team.go @@ -128,22 +128,32 @@ func GenerateOccurrences(s Schedule) ([]Occurrence, error) { if !wanted[date.Weekday()] { continue } - start := localDateTime(date, s.StartMinutes, loc) - if start.Hour()*60+start.Minute() != s.StartMinutes { - return nil, fmt.Errorf("%s %s does not exist in %s due to a clock change", date.Format("2006-01-02"), FormatClock(s.StartMinutes), s.Timezone) + start, end, err := RaidTimes(date, s.StartMinutes, s.EndMinutes, loc) + if err != nil { + return nil, err } - end := localDateTime(date, s.EndMinutes, loc) - if !end.After(start) { - end = end.AddDate(0, 0, 1) - } - if end.Hour()*60+end.Minute() != s.EndMinutes { - return nil, fmt.Errorf("end time %s does not exist in %s due to a clock change", FormatClock(s.EndMinutes), s.Timezone) - } - occurrences = append(occurrences, Occurrence{StartsAt: start.UTC(), EndsAt: end.UTC(), Status: "proposed"}) + occurrences = append(occurrences, Occurrence{StartsAt: start, EndsAt: end, Status: "proposed"}) } return occurrences, nil } +// RaidTimes returns a raid's UTC start and end for a local date. A raid whose +// end time is not after its start time ends the next day. +func RaidTimes(date time.Time, startMinutes, endMinutes int, loc *time.Location) (time.Time, time.Time, error) { + start := localDateTime(date, startMinutes, loc) + if start.Hour()*60+start.Minute() != startMinutes { + return time.Time{}, time.Time{}, fmt.Errorf("%s %s does not exist in %s due to a clock change", date.Format("2006-01-02"), FormatClock(startMinutes), loc) + } + end := localDateTime(date, endMinutes, loc) + if !end.After(start) { + end = end.AddDate(0, 0, 1) + } + if end.Hour()*60+end.Minute() != endMinutes { + return time.Time{}, time.Time{}, fmt.Errorf("end time %s does not exist in %s due to a clock change", FormatClock(endMinutes), loc) + } + return start.UTC(), end.UTC(), nil +} + func localDateTime(date time.Time, minutes int, loc *time.Location) time.Time { y, m, d := date.Date() return time.Date(y, m, d, minutes/60, minutes%60, 0, 0, loc) diff --git a/backend/internal/web/api.go b/backend/internal/web/api.go index 66aa459..29b2b32 100644 --- a/backend/internal/web/api.go +++ b/backend/internal/web/api.go @@ -122,7 +122,7 @@ func (h *Handler) updateTeam(w http.ResponseWriter, r *http.Request) { h.fail(w, r, err) return } - if err := h.bot.RefreshTeamPoll(r.Context(), value.ID); err != nil { + if err := h.bot.RefreshOpenPolls(r.Context(), value.ID); err != nil { h.log.Error("poll refresh after roster update failed", "team_id", value.ID, "error", err) } updated, err := h.store.Team(r.Context(), value.ID) @@ -414,10 +414,13 @@ func (h *Handler) saveSchedule(w http.ResponseWriter, r *http.Request) { return } draft.ChannelName = channel.Name - if err := h.store.SaveSchedule(r.Context(), draft); err != nil { + if err := h.store.SaveSchedule(r.Context(), draft, time.Now()); err != nil { h.fail(w, r, err) return } + if err := h.bot.RefreshOpenPolls(r.Context(), value.ID); err != nil { + h.log.Error("poll refresh after schedule update failed", "team_id", value.ID, "error", err) + } saved, err := h.store.Schedule(r.Context(), value.ID) if err != nil { h.fail(w, r, err) diff --git a/web/e2e/web.spec.ts b/web/e2e/web.spec.ts index b29feed..a57ffe0 100644 --- a/web/e2e/web.spec.ts +++ b/web/e2e/web.spec.ts @@ -55,6 +55,8 @@ test("schedule edits show an unsaved-changes bar that can be discarded", async ( await mockSignedInLeader(page); await page.goto("/app"); await page.getByRole("button", { name: "Schedule" }).click(); + // Saving a new time moves the open period too; the page must say so. + await expect(page.getByText(/moves upcoming dates in open periods/)).toBeVisible(); await expect(page.getByText("You have unsaved changes")).toHaveCount(0); await page .getByRole("radio", { name: "Mon" }) diff --git a/web/package-lock.json b/web/package-lock.json index d47fadc..bc8ff71 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "raidy-web", - "version": "0.0.14", + "version": "0.0.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "raidy-web", - "version": "0.0.14", + "version": "0.0.15", "dependencies": { "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", diff --git a/web/package.json b/web/package.json index 0d00efe..fd7b93b 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "raidy-web", "private": true, - "version": "0.0.14", + "version": "0.0.15", "type": "module", "scripts": { "dev": "vite", diff --git a/web/src/features/schedule/schedule-panel.tsx b/web/src/features/schedule/schedule-panel.tsx index b5d78a3..c7fc81a 100644 --- a/web/src/features/schedule/schedule-panel.tsx +++ b/web/src/features/schedule/schedule-panel.tsx @@ -219,7 +219,14 @@ export function SchedulePanel({ />
-
+