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({ />
-
+