Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
14 changes: 8 additions & 6 deletions backend/internal/discord/poll.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
73 changes: 70 additions & 3 deletions backend/internal/postgres/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
}
Expand All @@ -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"}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down
77 changes: 71 additions & 6 deletions backend/internal/postgres/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
32 changes: 21 additions & 11 deletions backend/internal/team/team.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions backend/internal/web/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions web/e2e/web.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down
4 changes: 2 additions & 2 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "raidy-web",
"private": true,
"version": "0.0.14",
"version": "0.0.15",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
9 changes: 8 additions & 1 deletion web/src/features/schedule/schedule-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,14 @@ export function SchedulePanel({
/>
<form className="grid gap-x-12 lg:grid-cols-[minmax(0,1fr)_300px]" onSubmit={submit}>
<div>
<Section title="When" description="One time slot, repeated on the days you pick.">
<Section
title="When"
description={
saved
? "A new time or timezone also moves upcoming dates in open periods and keeps their votes. Day changes start with the next period."
: "One time slot, repeated on the days you pick."
}
>
<fieldset>
<Label asChild>
<legend>Raid days</legend>
Expand Down
Loading