From 36935248e705c132bf8789dc814ecd6929362858 Mon Sep 17 00:00:00 2001 From: joshchen-dev Date: Tue, 6 Oct 2026 13:53:13 +0000 Subject: [PATCH] feat(web): vote on availability from the web Members can now answer from the availability grid: "Edit my availability" turns their row (pinned first and marked "You") into toggles, and saving calls PUT /api/polls/{id}/availability. It reuses SetAvailability, so the rules match Discord: only roster members may vote, started dates are locked, unselected dates become Unavailable, and confirmed dates move to Needs attention when someone drops out. The Discord announcement updates through the background refresher. Unknown occurrence IDs are now a 400 instead of a 500. --- README.md | 4 +- backend/internal/discord/poll.go | 11 +++ backend/internal/postgres/store.go | 2 +- backend/internal/postgres/store_test.go | 4 + backend/internal/web/api.go | 31 +++++++ backend/internal/web/handler.go | 1 + backend/internal/web/handler_test.go | 22 +++++ web/e2e/fixtures.ts | 2 + web/e2e/web.spec.ts | 20 +++++ web/package-lock.json | 4 +- web/package.json | 2 +- web/src/App.tsx | 8 +- .../features/overview/availability-grid.tsx | 26 +++++- web/src/features/overview/overview.tsx | 84 +++++++++++++++++-- 14 files changed, 206 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7fc918f..ea9229b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Raidy is a Discord-first raid scheduling project. One Go process runs the Discord bot, web API, scheduler, and production web assets, backed by PostgreSQL. -**Status:** `v0.0.11` is an early development snapshot. +**Status:** `v0.0.12` is an early development snapshot. ```text backend/ Go service, Discord interactions, web API, PostgreSQL migrations @@ -24,7 +24,7 @@ deployment/ local PostgreSQL and Kubernetes deployment manifests - Leaders confirm, cancel, or reopen dates. A confirmed date becomes `Attention required` when availability drops. - Polls become read-only history when their final occurrence starts. - The standalone web console manages teams and recurring schedules through Discord OAuth2. -- Members can view the current timetable on the web; voting and date decisions remain in Discord. +- Members vote on the web availability grid and leaders confirm, cancel, or reopen dates there; Discord votes still work during the move to announcement-only Discord. - The responsive console supports persisted light and dark themes. ## Run locally diff --git a/backend/internal/discord/poll.go b/backend/internal/discord/poll.go index 0e04d6d..f5d7064 100644 --- a/backend/internal/discord/poll.go +++ b/backend/internal/discord/poll.go @@ -213,6 +213,17 @@ func (b *Bot) saveAvailability(ctx context.Context, i *discordgo.Interaction, po return nil } +// SubmitAvailability records a member's answers made outside Discord: listed +// dates become Available and every other upcoming date Unavailable. The +// Discord announcement is refreshed in the background. +func (b *Bot) SubmitAvailability(ctx context.Context, pollID int64, memberID string, available []int64) error { + if err := b.Store.SetAvailability(ctx, pollID, memberID, available, now()); err != nil { + return err + } + b.refresher.queue(pollID) + return nil +} + func (b *Bot) openPollManage(ctx context.Context, i *discordgo.Interaction, pollID int64) error { if err := invalidID(pollID); err != nil { return err diff --git a/backend/internal/postgres/store.go b/backend/internal/postgres/store.go index 9eaad56..0376745 100644 --- a/backend/internal/postgres/store.go +++ b/backend/internal/postgres/store.go @@ -655,7 +655,7 @@ func (s *Store) SetAvailability(ctx context.Context, pollID int64, memberID stri wanted := make(map[int64]bool, len(availableIDs)) for _, id := range availableIDs { if !valid[id] { - return errors.New("invalid or expired occurrence") + return ValidationError{"invalid or expired occurrence"} } wanted[id] = true } diff --git a/backend/internal/postgres/store_test.go b/backend/internal/postgres/store_test.go index 5901cbf..e75a188 100644 --- a/backend/internal/postgres/store_test.go +++ b/backend/internal/postgres/store_test.go @@ -344,6 +344,10 @@ func TestSchedulingLifecycle(t *testing.T) { if err := store.SetAvailability(ctx, poll.ID, "outsider", nil, now); !errors.Is(err, ErrForbidden) { t.Fatalf("outsider availability error = %v", err) } + var validation ValidationError + if err := store.SetAvailability(ctx, poll.ID, "a", []int64{999999}, now); !errors.As(err, &validation) { + t.Fatalf("foreign occurrence availability error = %v, want ValidationError", err) + } if err := store.SetOccurrenceStatus(ctx, occurrence.ID, "outsider", "confirm", now); !errors.Is(err, ErrForbidden) { t.Fatalf("outsider status error = %v", err) } diff --git a/backend/internal/web/api.go b/backend/internal/web/api.go index 358f51d..66aa459 100644 --- a/backend/internal/web/api.go +++ b/backend/internal/web/api.go @@ -597,6 +597,37 @@ func pollJSON(view team.PollView) pollResponse { return result } +func (h *Handler) availability(w http.ResponseWriter, r *http.Request) { + pollID, err := strconv.ParseInt(r.PathValue("pollID"), 10, 64) + if err != nil || pollID <= 0 { + h.fail(w, r, clientError{message: "invalid timetable"}) + return + } + var input struct { + Available *[]int64 `json:"available"` + } + if err := decode(r, &input); err != nil { + h.fail(w, r, clientError{err.Error()}) + return + } + if input.Available == nil { + h.fail(w, r, clientError{message: "available must list the dates you can attend"}) + return + } + for _, id := range *input.Available { + if id <= 0 { + h.fail(w, r, clientError{message: "invalid raid date"}) + return + } + } + // SetAvailability only accepts members of this poll's roster snapshot. + if err := h.bot.SubmitAvailability(r.Context(), pollID, sessionFrom(r.Context()).User.ID, *input.Available); err != nil { + h.fail(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) occurrenceStatus(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("occurrenceID"), 10, 64) if err != nil || id <= 0 { diff --git a/backend/internal/web/handler.go b/backend/internal/web/handler.go index 33b696b..3abe7c0 100644 --- a/backend/internal/web/handler.go +++ b/backend/internal/web/handler.go @@ -93,6 +93,7 @@ func (h *Handler) Routes(health http.Handler) http.Handler { api.HandleFunc("POST /api/teams/{teamID}/republish", h.republish) api.HandleFunc("GET /api/teams/{teamID}/polls", h.openPolls) api.HandleFunc("POST /api/occurrences/{occurrenceID}/status", h.occurrenceStatus) + api.HandleFunc("PUT /api/polls/{pollID}/availability", h.availability) root.Handle("/api/", h.requireSession(api)) root.HandleFunc("/", h.serveApp) return root diff --git a/backend/internal/web/handler_test.go b/backend/internal/web/handler_test.go index f1db65a..ec528d0 100644 --- a/backend/internal/web/handler_test.go +++ b/backend/internal/web/handler_test.go @@ -128,3 +128,25 @@ func TestAppConfigExposesBotInviteWithoutSession(t *testing.T) { t.Fatalf("config = %d %s", response.Code, response.Body.String()) } } + +func TestAvailabilityRejectsInvalidInput(t *testing.T) { + h := &Handler{log: slog.New(slog.NewTextHandler(io.Discard, nil))} + tests := []struct{ id, body string }{ + {"0", `{"available":[1]}`}, + {"abc", `{"available":[1]}`}, + {"7", `{"available":[0]}`}, + {"7", `{"available":[1],"extra":true}`}, + {"7", `{}`}, + } + for _, test := range tests { + request := httptest.NewRequest(http.MethodPut, "/api/polls/"+test.id+"/availability", strings.NewReader(test.body)) + request.Header.Set("Content-Type", "application/json") + request.SetPathValue("pollID", test.id) + request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, session{User: user{ID: "member"}})) + response := httptest.NewRecorder() + h.availability(response, request) + if response.Code != http.StatusBadRequest { + t.Errorf("id=%s body=%s status = %d, want 400", test.id, test.body, response.Code) + } + } +} diff --git a/web/e2e/fixtures.ts b/web/e2e/fixtures.ts index 8fbfbe9..9d530b7 100644 --- a/web/e2e/fixtures.ts +++ b/web/e2e/fixtures.ts @@ -104,6 +104,8 @@ export async function mockSignedInLeader(page: Page): Promise { { id: "c1", name: "raid-schedule" }, { id: "c2", name: "general" } ]); + case "PUT /api/polls/10/availability": + return route.fulfill({ status: 204 }); case "POST /api/occurrences/102/status": return route.fulfill({ status: 204 }); default: diff --git a/web/e2e/web.spec.ts b/web/e2e/web.spec.ts index 0b12b2c..ae55aaa 100644 --- a/web/e2e/web.spec.ts +++ b/web/e2e/web.spec.ts @@ -90,3 +90,23 @@ test("raid times use a 24-hour clock", async ({ page }) => { await expect(header).toContainText("21:00"); await expect(header).not.toContainText(/AM|PM/); }); + +test("a member edits and saves their availability on the web", async ({ page }) => { + const recorder = await mockSignedInLeader(page); + await page.goto("/app"); + const grid = page.getByRole("table", { name: /availability/i }); + // The signed-in member's row comes first and is marked as theirs. + await expect(grid.getByRole("rowheader").first()).toContainText("Alisaie"); + await expect(grid.getByRole("rowheader").first()).toContainText("You"); + + await page.getByRole("button", { name: "Edit my availability" }).click(); + const thursday = page.getByRole("button", { name: "Available on Thu 8" }); + await expect(thursday).toHaveAttribute("aria-pressed", "true"); + await thursday.click(); + await expect(thursday).toHaveAttribute("aria-pressed", "false"); + await page.getByRole("button", { name: "Save availability" }).click(); + + await expect(page.getByText("Availability saved")).toBeVisible(); + expect(recorder.bodies["PUT /api/polls/10/availability"]).toEqual({ available: [101, 103] }); + await expect(page.getByRole("button", { name: "Edit my availability" })).toBeVisible(); +}); diff --git a/web/package-lock.json b/web/package-lock.json index f8ebe1a..75163c3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "raidy-web", - "version": "0.0.11", + "version": "0.0.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "raidy-web", - "version": "0.0.11", + "version": "0.0.12", "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 b684ec7..1499586 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "raidy-web", "private": true, - "version": "0.0.11", + "version": "0.0.12", "type": "module", "scripts": { "dev": "vite", diff --git a/web/src/App.tsx b/web/src/App.tsx index b826f94..391f8b8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -192,7 +192,13 @@ export default function App() { ) : !selected ? ( ) : tab === "overview" ? ( - setTab("schedule")} onError={setError} /> + setTab("schedule")} + onError={setError} + /> ) : tab === "schedule" ? ( void; + currentUserID?: string; + /** Dates the current user marks available while editing; null when not editing. */ + draft?: Set | null; + onToggle?: (occurrenceID: number) => void; }) { const occurrences = poll.occurrences; const memberIDs = Array.from( @@ -99,7 +106,7 @@ export function AvailabilityGrid({ const name = (id: string) => members.find((member) => member.id === id)?.name ?? "Former member"; const rows = memberIDs .map((id) => members.find((member) => member.id === id) ?? { id, name: name(id) }) - .sort((a, b) => a.name.localeCompare(b.name)); + .sort((a, b) => (a.id === currentUserID ? -1 : b.id === currentUserID ? 1 : a.name.localeCompare(b.name))); const vote = (occurrence: Occurrence, id: string): Vote => occurrence.votes?.find((value) => value.memberId === id)?.status ?? "pending"; @@ -136,11 +143,24 @@ export function AvailabilityGrid({ {member.name} + {member.id === currentUserID && You} {occurrences.map((occurrence) => ( - + {draft && member.id === currentUserID && occurrence.id && new Date(occurrence.startsAt) > now ? ( + + ) : ( + + )} ))} diff --git a/web/src/features/overview/overview.tsx b/web/src/features/overview/overview.tsx index ccdbf7f..7fe314c 100644 --- a/web/src/features/overview/overview.tsx +++ b/web/src/features/overview/overview.tsx @@ -15,11 +15,13 @@ const actionMessages: Record = { export function Overview({ team, + currentUserID, revision, onEditSchedule, onError }: { team: Team; + currentUserID: string; revision: number; onEditSchedule: () => void; onError: (value: string) => void; @@ -30,6 +32,8 @@ export function Overview({ const [loading, setLoading] = useState(true); const [reload, setReload] = useState(0); const [busyID, setBusyID] = useState(null); + const [draft, setDraft] = useState | null>(null); + const [savingVotes, setSavingVotes] = useState(false); useEffect(() => { let active = true; @@ -63,8 +67,48 @@ export function Overview({ } } + function startEditing(current: Poll) { + const mine = current.occurrences.filter( + (occurrence) => + occurrence.id && + occurrence.votes?.some((vote) => vote.memberId === currentUserID && vote.status === "available") + ); + setDraft(new Set(mine.map((occurrence) => occurrence.id!))); + } + + function toggle(occurrenceID: number) { + setDraft((current) => { + const next = new Set(current); + if (next.has(occurrenceID)) next.delete(occurrenceID); + else next.add(occurrenceID); + return next; + }); + } + + async function saveVotes(current: Poll) { + if (!draft) return; + setSavingVotes(true); + try { + await api(`/api/polls/${current.id}/availability`, { + method: "PUT", + body: JSON.stringify({ available: [...draft] }) + }); + toast.success("Availability saved"); + setDraft(null); + setReload((value) => value + 1); + } catch (reason) { + toast.error(message(reason)); + } finally { + setSavingVotes(false); + } + } + if (loading) return ; const poll = polls.find((value) => value.id === pollID) ?? null; + const canVote = + poll !== null && + poll.occurrences.some((occurrence) => occurrence.votes?.some((vote) => vote.memberId === currentUserID)) && + poll.occurrences.some((occurrence) => new Date(occurrence.startsAt) > new Date()); if (!schedule) { return ( @@ -111,7 +155,13 @@ export function Overview({ } actions={ polls.length > 1 && ( - { + setPollID(Number(value)); + setDraft(null); + }} + > @@ -126,16 +176,40 @@ export function Overview({ ) } /> + {canVote && ( +
+

+ {draft ? "Tap each date you can make, then save." : "Your answers are in the first row."} +

+ {draft ? ( +
+ + +
+ ) : ( + + )} +
+ )} -

- Members answer in Discord. {team.isLeader ? "Use ⋯ under a date to confirm, cancel, or reopen it." : ""} -

+ {team.isLeader && !draft && ( +

Use ⋯ under a date to confirm, cancel, or reopen it.

+ )} ); }