diff --git a/ui/src/components/retro/note-dialog.tsx b/ui/src/components/retro/note-dialog.tsx
index b222e20..fcd1006 100644
--- a/ui/src/components/retro/note-dialog.tsx
+++ b/ui/src/components/retro/note-dialog.tsx
@@ -16,9 +16,10 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
+import { AutoTextarea } from "@/components/ui/auto-textarea";
+import { Kbd } from "@/components/ui/kbd";
import { zodResolver } from "@hookform/resolvers/zod";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { FieldValues, SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
@@ -53,16 +54,20 @@ export default function NoteDialog({
onContentSave(data.content);
};
- useEffect(() => {
- if (content) {
- form.reset({ content });
+ function handleOpenChange(next: boolean) {
+ setOpen(next);
+
+ // Reopening should show what the note says now, not the last thing typed
+ // into this dialog or someone else's live edit.
+ if (next) {
+ form.reset({ content: content ?? "" });
}
- }, [content, form]);
+ }
return (
-
+
{children}
-
+
{title}
{description}
@@ -78,10 +83,20 @@ export default function NoteDialog({
Note
- {
+ if (event.key !== "Enter" || event.shiftKey) return;
+
+ // Shift+Enter still makes a newline; plain Enter
+ // would otherwise just add one via the textarea's
+ // own default.
+ event.preventDefault();
+ event.currentTarget.form?.requestSubmit();
+ }}
{...field}
/>
@@ -92,7 +107,12 @@ export default function NoteDialog({
/>
- Save
+
+ Save
+
+ ↵
+
+
diff --git a/ui/src/components/retro/note.tsx b/ui/src/components/retro/note.tsx
index d90eb40..caf7daa 100644
--- a/ui/src/components/retro/note.tsx
+++ b/ui/src/components/retro/note.tsx
@@ -1,4 +1,4 @@
-import { accentForName, initialsFor } from "@/lib/column-accent";
+import { accentForName } from "@/lib/column-accent";
import { cardVariants, spring } from "@/lib/motion";
import { Note as NoteType } from "@/types";
import {
@@ -160,7 +160,7 @@ function NoteBody({
)}
{hasActions && (
-
+
{onUngroup && (
@@ -270,11 +270,9 @@ function Author({ name }: { name: string }) {
- {initialsFor(name)}
-
+ />
{name}
);
diff --git a/ui/src/components/retro/notes.tsx b/ui/src/components/retro/notes.tsx
new file mode 100644
index 0000000..781ade0
--- /dev/null
+++ b/ui/src/components/retro/notes.tsx
@@ -0,0 +1,16 @@
+import { NotesContext, useNotesState } from "@/hooks/use-notes";
+import { Note } from "@/types";
+
+export default function NotesProvider({
+ notes,
+ children,
+}: {
+ notes: Note[];
+ children: React.ReactNode;
+}) {
+ const value = useNotesState(notes);
+
+ return (
+ {children}
+ );
+}
diff --git a/ui/src/components/retro/settings.tsx b/ui/src/components/retro/settings.tsx
index ea66b82..002bc83 100644
--- a/ui/src/components/retro/settings.tsx
+++ b/ui/src/components/retro/settings.tsx
@@ -18,10 +18,11 @@ import {
} from "@/components/ui/form";
import { createSocketEvent, PayloadRetroUpdate, SocketEvent } from "@/events";
import useRetro from "@/hooks/use-retro";
+import { useRetroSocket, useSocketEvent } from "@/hooks/use-retro-socket";
import { Retro } from "@/types";
import { zodResolver } from "@hookform/resolvers/zod";
import { Cog } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
+import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -37,10 +38,8 @@ const schema = z.object({
});
export default function Settings() {
- const {
- retro,
- socket: { sendJsonMessage, lastJsonMessage },
- } = useRetro();
+ const { retro } = useRetro();
+ const { send } = useRetroSocket();
// retro_updated also fires for column edits and for other people's changes,
// so only confirm a save this dialog actually started.
@@ -49,14 +48,10 @@ export default function Settings() {
function handleSubmit(data: PayloadRetroUpdate) {
pendingSave.current = true;
- sendJsonMessage(createSocketEvent("retro_update", data));
+ send(createSocketEvent("retro_update", data));
}
- useEffect(() => {
- if (!lastJsonMessage) return;
-
- const event = lastJsonMessage as SocketEvent;
-
+ useSocketEvent((event: SocketEvent) => {
if (event.name === "retro_updated" && pendingSave.current) {
pendingSave.current = false;
@@ -64,7 +59,7 @@ export default function Settings() {
description: "The settings for this retrospective have been updated.",
});
}
- }, [lastJsonMessage]);
+ });
return ;
}
diff --git a/ui/src/components/retro/socket.tsx b/ui/src/components/retro/socket.tsx
new file mode 100644
index 0000000..16935af
--- /dev/null
+++ b/ui/src/components/retro/socket.tsx
@@ -0,0 +1,63 @@
+import { SocketEvent } from "@/events";
+import {
+ ReadyStateContext,
+ RetroSocketContext,
+ SocketListener,
+} from "@/hooks/use-retro-socket";
+import { socketURL } from "@/lib/socket";
+import { useCallback, useMemo, useRef } from "react";
+import useWebSocket from "react-use-websocket";
+
+export default function SocketProvider({
+ retroId,
+ children,
+}: {
+ retroId: string;
+ children: React.ReactNode;
+}) {
+ const listeners = useRef(new Set());
+
+ const { sendJsonMessage, readyState } = useWebSocket(
+ socketURL(`/api/retros/${retroId}/ws`).href,
+ {
+ share: true,
+ shouldReconnect: () => true,
+ reconnectAttempts: 10,
+ reconnectInterval: (attemptNumber) =>
+ Math.min(Math.pow(2, attemptNumber) * 1000, 10000),
+ // react-use-websocket stores each frame with flushSync, so reading
+ // lastJsonMessage re-rendered the whole board synchronously on every
+ // message. onMessage runs before filter, and filter gates only that
+ // state write, so this pair delivers frames without any render here.
+ // A second useWebSocket call on this url must not copy the filter
+ // without its own onMessage, or it will receive nothing.
+ filter: () => false,
+ onMessage: (message) => {
+ const event = JSON.parse(message.data) as SocketEvent;
+
+ listeners.current.forEach((listener) => listener(event));
+ },
+ },
+ );
+
+ const subscribe = useCallback((listener: SocketListener) => {
+ listeners.current.add(listener);
+
+ return () => {
+ listeners.current.delete(listener);
+ };
+ }, []);
+
+ const value = useMemo(
+ () => ({ send: sendJsonMessage, subscribe }),
+ [sendJsonMessage, subscribe],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/ui/src/components/retro/stage-rail.tsx b/ui/src/components/retro/stage-rail.tsx
index c26fc9c..ba32a6c 100644
--- a/ui/src/components/retro/stage-rail.tsx
+++ b/ui/src/components/retro/stage-rail.tsx
@@ -40,19 +40,25 @@ export default function StageRail({
return (
<>
-
+
{stages.map((stage, index) => {
const done = index < current;
const active = index === current;
const Icon = done ? Check : stage.icon;
return (
-
+
{index > 0 && (
diff --git a/ui/src/components/retro/tag-input.tsx b/ui/src/components/retro/tag-input.tsx
index 1fcdd0e..2e517bb 100644
--- a/ui/src/components/retro/tag-input.tsx
+++ b/ui/src/components/retro/tag-input.tsx
@@ -1,4 +1,5 @@
import { Badge } from "@/components/ui/badge";
+import { Kbd, KbdGroup } from "@/components/ui/kbd";
import {
Popover,
PopoverContent,
@@ -214,6 +215,19 @@ export default function TagInput({
))}
+
+
+
+ ↵ add
+
+
+
+ ↑
+ ↓
+
+ choose
+
+
);
diff --git a/ui/src/components/retro/task-dialog.tsx b/ui/src/components/retro/task-dialog.tsx
index cbab0f3..b809430 100644
--- a/ui/src/components/retro/task-dialog.tsx
+++ b/ui/src/components/retro/task-dialog.tsx
@@ -16,7 +16,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { z } from "zod";
import { Button } from "../ui/button";
import { DatePickerFormItem } from "../date-picker";
@@ -60,23 +60,27 @@ export default function TaskDialog({
},
});
- function handleSubmit(data: TaskData) {
+ function handleSubmit(values: TaskData) {
setOpen(false);
- onSave(data);
+ onSave(values);
}
- useEffect(() => {
- if (data) {
+ function handleOpenChange(next: boolean) {
+ setOpen(next);
+
+ // Reopening should show what the task says now, not the last thing typed
+ // into this dialog or someone else's live edit.
+ if (next) {
form.reset({
- who: data.who,
- what: data.what,
- when: new Date(data.when),
+ who: data?.who ?? "",
+ what: data?.what ?? "",
+ when: data ? new Date(data.when) : new Date(),
});
}
- }, [data, form]);
+ }
return (
-
+
{children}
diff --git a/ui/src/components/retro/vote.tsx b/ui/src/components/retro/vote.tsx
index 91eaec3..460c46d 100644
--- a/ui/src/components/retro/vote.tsx
+++ b/ui/src/components/retro/vote.tsx
@@ -1,18 +1,14 @@
import { useColumnActions } from "@/hooks/use-columns";
import { useNotes } from "@/hooks/use-notes";
import useRetro from "@/hooks/use-retro";
-import { api } from "@/lib/api";
+import { useVotes } from "@/hooks/use-votes";
import { AnimatePresence } from "motion/react";
-import { useEffect, useState } from "react";
+import { useEffect } from "react";
import { EmptyColumn, NoteSkeletons } from "./column-states";
import { Column, Columns } from "./columns";
import { Note } from "./note";
import { VotableNoteGroup } from "./note-group";
-interface Vote {
- group_id: string;
-}
-
export default function Vote({
setVotesRemaining,
}: {
@@ -22,25 +18,12 @@ export default function Vote({
const { notes, groupedNotes, loaded } = useNotes();
const columnActions = useColumnActions(notes);
- const [votes, setVotes] = useState([]);
+ const { voted, count, toggle } = useVotes(retro.id);
+ const canVote = retro.max_votes > count;
useEffect(() => {
- api.get(`/api/retros/${retro.id}/votes`).then((res) => {
- setVotes(res.data);
- setVotesRemaining(retro.max_votes - res.data.length);
- });
- }, [retro.id, retro.max_votes, setVotesRemaining]);
-
- function handleVote(groupId: string, value: boolean) {
- api
- .post(`/api/retros/${retro.id}/votes`, { group_id: groupId, value })
- .then((res) => {
- if (res.status === 200) {
- setVotes(res.data);
- setVotesRemaining(retro.max_votes - res.data.length);
- }
- });
- }
+ setVotesRemaining(retro.max_votes - count);
+ }, [retro.max_votes, count, setVotesRemaining]);
return (
{groups.map(([groupId, groupNotes]) => (
handleVote(groupId, value)}
- voted={!!votes.find((vote) => vote.group_id === groupId)}
- canVote={retro.max_votes > votes.length}
+ onVote={(value) => toggle(groupId, value)}
+ voted={voted.has(groupId)}
+ canVote={canVote}
key={groupId}
>
{groupNotes.map((note) => (
diff --git a/ui/src/components/ui/kbd.tsx b/ui/src/components/ui/kbd.tsx
new file mode 100644
index 0000000..ff93b53
--- /dev/null
+++ b/ui/src/components/ui/kbd.tsx
@@ -0,0 +1,26 @@
+import { cn } from "@/lib/utils"
+
+function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
+ return (
+
+ )
+}
+
+function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Kbd, KbdGroup }
diff --git a/ui/src/components/ui/tooltip.tsx b/ui/src/components/ui/tooltip.tsx
index 329efa3..0a4cb89 100644
--- a/ui/src/components/ui/tooltip.tsx
+++ b/ui/src/components/ui/tooltip.tsx
@@ -15,12 +15,11 @@ function TooltipProvider({
)
}
+// No TooltipProvider here, unlike the shadcn original: one lives at the app
+// root instead. Every note in the group stage renders a Tooltip, and a provider
+// each meant a state machine each and no shared delay between them.
function Tooltip({ ...props }: React.ComponentProps) {
- return (
-
-
-
- )
+ return
}
function TooltipTrigger({ ...props }: React.ComponentProps) {
diff --git a/ui/src/events/index.ts b/ui/src/events/index.ts
index 20b1f97..6de7e3e 100644
--- a/ui/src/events/index.ts
+++ b/ui/src/events/index.ts
@@ -28,6 +28,9 @@ export type PayloadStatusUpdated = PayloadStatusUpdate;
export interface PayloadNoteCreate {
column_id: string;
content: string;
+ // Added to the local dispatch only, never sent: without it the author line
+ // pops in when the server echo lands.
+ created_by_name?: string;
}
export interface PayloadNoteUpdate {
diff --git a/ui/src/hooks/use-columns.ts b/ui/src/hooks/use-columns.ts
index 46e60b1..edd4a01 100644
--- a/ui/src/hooks/use-columns.ts
+++ b/ui/src/hooks/use-columns.ts
@@ -3,6 +3,7 @@ import { ColumnData } from "@/components/retro/column-dialog";
import { Note, RetroColumn } from "@/types";
import { useCallback, useMemo } from "react";
import useRetro from "./use-retro";
+import { useRetroSocket } from "./use-retro-socket";
// Kept in step with minColumns/maxColumns in cmd/thoughts/event/columns.go.
const minColumns = 2;
@@ -15,10 +16,8 @@ export interface ColumnActions {
}
export function useColumnActions(notes: Note[]) {
- const {
- retro,
- socket: { sendJsonMessage },
- } = useRetro();
+ const { retro } = useRetro();
+ const { send } = useRetroSocket();
const noteCounts = useMemo(
() =>
@@ -33,23 +32,21 @@ export function useColumnActions(notes: Note[]) {
const create = useCallback(
(data: ColumnData) => {
- sendJsonMessage(createSocketEvent("column_create", data));
+ send(createSocketEvent("column_create", data));
},
- [sendJsonMessage],
+ [send],
);
const forColumn = useCallback(
(column: RetroColumn): ColumnActions => ({
onEdit: (data) =>
- sendJsonMessage(
- createSocketEvent("column_update", { id: column.id, ...data }),
- ),
+ send(createSocketEvent("column_update", { id: column.id, ...data })),
onDelete: () =>
- sendJsonMessage(createSocketEvent("column_delete", { id: column.id })),
+ send(createSocketEvent("column_delete", { id: column.id })),
canDelete:
(noteCounts[column.id] ?? 0) === 0 && columnCount > minColumns,
}),
- [sendJsonMessage, noteCounts, columnCount],
+ [send, noteCounts, columnCount],
);
return { create, forColumn, canCreate: columnCount < maxColumns };
diff --git a/ui/src/hooks/use-notes.test.ts b/ui/src/hooks/use-notes.test.ts
index 48e2abc..ebe883f 100644
--- a/ui/src/hooks/use-notes.test.ts
+++ b/ui/src/hooks/use-notes.test.ts
@@ -59,6 +59,20 @@ describe("notesReducer", () => {
expect(state.rollbacks).toEqual({ "ref-1": null });
});
+ it("carries the author onto the placeholder", () => {
+ const state = replay({
+ name: "note_create",
+ payload: {
+ column_id: "column-1",
+ content: "optimistic",
+ ref: "ref-1",
+ created_by_name: "Alex",
+ },
+ });
+
+ expect(state.notes[0].created_by_name).toBe("Alex");
+ });
+
it("swaps the placeholder for the confirmed note", () => {
const state = replay(
{ name: "note_create", payload: { column_id: "column-1", content: "optimistic", ref: "ref-1" } },
diff --git a/ui/src/hooks/use-notes.ts b/ui/src/hooks/use-notes.ts
index 5b54e9d..d8c6d77 100644
--- a/ui/src/hooks/use-notes.ts
+++ b/ui/src/hooks/use-notes.ts
@@ -7,8 +7,23 @@ import {
} from "@/events";
import { api } from "@/lib/api";
import { Note } from "@/types";
-import { useCallback, useEffect, useMemo, useReducer } from "react";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useReducer,
+ useRef,
+} from "react";
+import { ReadyState } from "react-use-websocket";
+import { useAuth } from "./use-auth";
import useRetro from "./use-retro";
+import {
+ useReadyState,
+ useRetroSocket,
+ useSocketEvent,
+} from "./use-retro-socket";
const optimisticEvents = new Set(["note_create", "note_update", "note_delete"]);
@@ -61,6 +76,7 @@ function notesReducer(state: NotesState, event: SocketEvent): NotesState {
{
id: payload.ref,
created_by_me: true,
+ created_by_name: payload.created_by_name,
content: payload.content,
column_id: payload.column_id,
group_id: payload.ref,
@@ -186,17 +202,51 @@ function groupNotes(notes: Note[]) {
return groups;
}
-export { groupNotes, notesReducer, initialState };
+function notesByColumn(notes: Note[]) {
+ const columns: Record = {};
+
+ notes.forEach((note) => {
+ (columns[note.column_id] ??= []).push(note);
+ });
+
+ return columns;
+}
+
+export { groupNotes, notesByColumn, notesReducer, initialState };
export type { NotesState };
+export type NotesValue = {
+ notes: Note[];
+ groupedNotes: GroupedNotes;
+ notesByColumn: Record;
+ loaded: boolean;
+ dispatch: (event: SocketEvent) => void;
+};
+
+export const NotesContext = createContext(null);
+
export function useNotes() {
- const {
- retro,
- socket: { lastJsonMessage, sendJsonMessage },
- } = useRetro();
- const [state, dispatch] = useReducer(notesReducer, initialState);
+ const ctx = useContext(NotesContext);
+
+ if (!ctx) throw new Error("useNotes must be used within a NotesProvider");
+
+ return ctx;
+}
+
+export function useNotesState(loaded: Note[]): NotesValue {
+ const { retro } = useRetro();
+ const { user } = useAuth();
+ const { send } = useRetroSocket();
+ const readyState = useReadyState();
+
+ // Seeded through the reducer rather than by hand, so loaded and rollbacks
+ // cannot drift from what note_index already does.
+ const [state, dispatch] = useReducer(notesReducer, loaded, (notes) =>
+ notesReducer(initialState, { name: "note_index", payload: notes }),
+ );
const groupedNotes = useMemo(() => groupNotes(state.notes), [state.notes]);
+ const byColumn = useMemo(() => notesByColumn(state.notes), [state.notes]);
const dispatchAndSend = useCallback(
(event: SocketEvent) => {
@@ -207,28 +257,53 @@ export function useNotes() {
}
: event;
- dispatch(tracked);
- sendJsonMessage(tracked);
+ send(tracked);
+
+ dispatch(
+ tracked.name === "note_create"
+ ? {
+ ...tracked,
+ payload: { ...tracked.payload, created_by_name: user?.name },
+ }
+ : tracked,
+ );
},
- [sendJsonMessage],
+ [send, user?.name],
);
- useEffect(() => {
- if (!lastJsonMessage) return;
-
- dispatch(lastJsonMessage as SocketEvent);
- }, [lastJsonMessage]);
+ useSocketEvent(dispatch);
- useEffect(() => {
+ const load = useCallback(() => {
api.get(`/api/retros/${retro.id}/notes`).then((res) => {
dispatch({ name: "note_index", payload: res.data });
});
}, [retro.id]);
- return {
- notes: state.notes,
- groupedNotes,
- loaded: state.loaded,
- dispatch: dispatchAndSend,
- };
+ // Nothing replays what the socket missed while it was down, and this hook no
+ // longer remounts per stage to refetch by accident, so a reconnect has to ask
+ // the server for the list again. The route loader covers the first connection.
+ const dropped = useRef(false);
+
+ useEffect(() => {
+ if (readyState === ReadyState.CLOSED) {
+ dropped.current = true;
+ return;
+ }
+
+ if (readyState === ReadyState.OPEN && dropped.current) {
+ dropped.current = false;
+ load();
+ }
+ }, [readyState, load]);
+
+ return useMemo(
+ () => ({
+ notes: state.notes,
+ groupedNotes,
+ notesByColumn: byColumn,
+ loaded: state.loaded,
+ dispatch: dispatchAndSend,
+ }),
+ [state.notes, state.loaded, groupedNotes, byColumn, dispatchAndSend],
+ );
}
diff --git a/ui/src/hooks/use-retro-socket.ts b/ui/src/hooks/use-retro-socket.ts
new file mode 100644
index 0000000..10a92e0
--- /dev/null
+++ b/ui/src/hooks/use-retro-socket.ts
@@ -0,0 +1,44 @@
+import { SocketEvent } from "@/events";
+import { createContext, useContext, useEffect, useRef } from "react";
+import { ReadyState } from "react-use-websocket";
+
+export type SocketListener = (event: SocketEvent) => void;
+
+type RetroSocketContextType = {
+ send: (event: SocketEvent) => void;
+ subscribe: (listener: SocketListener) => () => void;
+};
+
+export const RetroSocketContext = createContext(
+ null,
+);
+
+export const ReadyStateContext = createContext(
+ ReadyState.UNINSTANTIATED,
+);
+
+export function useRetroSocket() {
+ const ctx = useContext(RetroSocketContext);
+
+ if (!ctx)
+ throw new Error("useRetroSocket must be used within a SocketProvider");
+
+ return ctx;
+}
+
+export function useReadyState() {
+ return useContext(ReadyStateContext);
+}
+
+export function useSocketEvent(listener: SocketListener) {
+ const { subscribe } = useRetroSocket();
+ const ref = useRef(listener);
+
+ // Through a ref so callers can pass an inline handler without resubscribing
+ // on every render.
+ useEffect(() => {
+ ref.current = listener;
+ });
+
+ useEffect(() => subscribe((event) => ref.current(event)), [subscribe]);
+}
diff --git a/ui/src/hooks/use-retro.ts b/ui/src/hooks/use-retro.ts
index 26d545f..1104152 100644
--- a/ui/src/hooks/use-retro.ts
+++ b/ui/src/hooks/use-retro.ts
@@ -1,11 +1,9 @@
import { Retro } from "@/types";
import { createContext, useContext } from "react";
-import { WebSocketHook } from "react-use-websocket/dist/lib/types";
type RetroContextType = {
retro: Retro;
setRetro: (retro: Retro) => void;
- socket: WebSocketHook;
};
export const RetroContext = createContext(
diff --git a/ui/src/hooks/use-votes.test.ts b/ui/src/hooks/use-votes.test.ts
new file mode 100644
index 0000000..ed0e60a
--- /dev/null
+++ b/ui/src/hooks/use-votes.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+import {
+ initialVotesState,
+ votesReducer,
+ VotesEvent,
+ VotesState,
+} from "./use-votes";
+
+function replay(...events: VotesEvent[]): VotesState {
+ return events.reduce(votesReducer, initialVotesState);
+}
+
+describe("votesReducer", () => {
+ it("shows a vote before the server has seen it", () => {
+ const state = replay({
+ name: "vote",
+ payload: { group_id: "group-1", value: true },
+ });
+
+ expect(state.votes).toEqual([{ group_id: "group-1" }]);
+ });
+
+ it("removes a vote before the server has seen it", () => {
+ const state = replay(
+ { name: "vote_index", payload: [{ group_id: "group-1" }] },
+ { name: "vote", payload: { group_id: "group-1", value: false } },
+ );
+
+ expect(state.votes).toEqual([]);
+ });
+
+ it("takes the server's list as the truth", () => {
+ const state = replay(
+ { name: "vote", payload: { group_id: "group-1", value: true } },
+ {
+ name: "voted",
+ payload: {
+ group_id: "group-1",
+ votes: [{ group_id: "group-1" }, { group_id: "group-2" }],
+ },
+ },
+ );
+
+ expect(state.votes.map((v) => v.group_id)).toEqual(["group-1", "group-2"]);
+ expect(state.rollbacks).toEqual({});
+ });
+
+ it("puts the vote back when the request fails", () => {
+ const state = replay(
+ { name: "vote_index", payload: [{ group_id: "group-1" }] },
+ { name: "vote", payload: { group_id: "group-1", value: false } },
+ { name: "vote_failed", payload: { group_id: "group-1" } },
+ );
+
+ expect(state.votes).toEqual([{ group_id: "group-1" }]);
+ expect(state.rollbacks).toEqual({});
+ });
+
+ it("rolls back only the group that failed", () => {
+ const state = replay(
+ { name: "vote", payload: { group_id: "group-1", value: true } },
+ { name: "vote", payload: { group_id: "group-2", value: true } },
+ { name: "vote_failed", payload: { group_id: "group-2" } },
+ );
+
+ expect(state.votes.map((v) => v.group_id)).toEqual(["group-1"]);
+ expect(state.rollbacks).toEqual({ "group-1": [] });
+ });
+
+ it("rolls back to the last confirmed state, not to a pending click", () => {
+ const state = replay(
+ { name: "vote_index", payload: [] },
+ { name: "vote", payload: { group_id: "group-1", value: true } },
+ { name: "vote", payload: { group_id: "group-1", value: false } },
+ { name: "vote_failed", payload: { group_id: "group-1" } },
+ );
+
+ expect(state.votes).toEqual([]);
+ });
+
+ it("ignores a failure it has no rollback for", () => {
+ const state = replay(
+ { name: "vote_index", payload: [{ group_id: "group-1" }] },
+ { name: "vote_failed", payload: { group_id: "group-9" } },
+ );
+
+ expect(state.votes).toEqual([{ group_id: "group-1" }]);
+ });
+});
diff --git a/ui/src/hooks/use-votes.ts b/ui/src/hooks/use-votes.ts
new file mode 100644
index 0000000..77bec30
--- /dev/null
+++ b/ui/src/hooks/use-votes.ts
@@ -0,0 +1,124 @@
+import { api } from "@/lib/api";
+import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
+import { toast } from "sonner";
+
+// Only the vote stage. In discuss the same endpoint returns votes with counts
+// for everyone, not this user's own list.
+export interface Vote {
+ group_id: string;
+}
+
+export interface VotesState {
+ votes: Vote[];
+ rollbacks: Record;
+}
+
+export const initialVotesState: VotesState = { votes: [], rollbacks: {} };
+
+export type VotesEvent =
+ | { name: "vote_index"; payload: Vote[] }
+ | { name: "vote"; payload: { group_id: string; value: boolean } }
+ | { name: "voted"; payload: { group_id: string; votes: Vote[] } }
+ | { name: "vote_failed"; payload: { group_id: string } };
+
+function forget(rollbacks: VotesState["rollbacks"], groupId: string) {
+ if (!(groupId in rollbacks)) return rollbacks;
+
+ const next = { ...rollbacks };
+ delete next[groupId];
+
+ return next;
+}
+
+export function votesReducer(state: VotesState, event: VotesEvent): VotesState {
+ switch (event.name) {
+ case "vote_index":
+ return { votes: event.payload, rollbacks: {} };
+
+ case "vote": {
+ const { group_id, value } = event.payload;
+
+ return {
+ votes: value
+ ? [...state.votes, { group_id }]
+ : state.votes.filter((vote) => vote.group_id !== group_id),
+ // Only the first stash: clicking twice before either lands should roll
+ // back to what the server last confirmed, not to the other click.
+ rollbacks:
+ group_id in state.rollbacks
+ ? state.rollbacks
+ : { ...state.rollbacks, [group_id]: state.votes },
+ };
+ }
+
+ case "voted":
+ return {
+ votes: event.payload.votes,
+ rollbacks: forget(state.rollbacks, event.payload.group_id),
+ };
+
+ case "vote_failed": {
+ const { group_id } = event.payload;
+ const before = state.rollbacks[group_id];
+
+ if (!before) return state;
+
+ return { votes: before, rollbacks: forget(state.rollbacks, group_id) };
+ }
+
+ default:
+ return state;
+ }
+}
+
+export function useVotes(retroId: string) {
+ const [state, dispatch] = useReducer(votesReducer, initialVotesState);
+ const inFlight = useRef>({});
+
+ useEffect(() => {
+ api.get(`/api/retros/${retroId}/votes`).then((res) => {
+ dispatch({ name: "vote_index", payload: res.data });
+ });
+ }, [retroId]);
+
+ const toggle = useCallback(
+ (groupId: string, value: boolean) => {
+ dispatch({ name: "vote", payload: { group_id: groupId, value } });
+
+ const ticket = (inFlight.current[groupId] =
+ (inFlight.current[groupId] ?? 0) + 1);
+
+ api
+ .post(`/api/retros/${retroId}/votes`, {
+ group_id: groupId,
+ value,
+ })
+ .then((res) => {
+ // A slow response to an older click would otherwise undo a newer one.
+ if (inFlight.current[groupId] !== ticket) return;
+
+ dispatch({
+ name: "voted",
+ payload: { group_id: groupId, votes: res.data },
+ });
+ })
+ .catch(() => {
+ if (inFlight.current[groupId] !== ticket) return;
+
+ dispatch({ name: "vote_failed", payload: { group_id: groupId } });
+
+ toast("Your vote didn't stick", {
+ description: "We couldn't reach the server, so it's been undone.",
+ });
+ });
+ },
+ [retroId],
+ );
+
+ const voted = useMemo(
+ () => new Set(state.votes.map((vote) => vote.group_id)),
+ [state.votes],
+ );
+
+ return { voted, count: state.votes.length, toggle };
+}
diff --git a/ui/src/routes/_auth.retros.$retroId.tsx b/ui/src/routes/_auth.retros.$retroId.tsx
index 930eb42..30c04c5 100644
--- a/ui/src/routes/_auth.retros.$retroId.tsx
+++ b/ui/src/routes/_auth.retros.$retroId.tsx
@@ -1,52 +1,99 @@
import Container from "@/components/container";
import Board from "@/components/retro/board";
+import { NoteSkeletons } from "@/components/retro/column-states";
+import { Column, Columns } from "@/components/retro/columns";
+import NotesProvider from "@/components/retro/notes";
+import SocketProvider from "@/components/retro/socket";
import { RetroContext } from "@/hooks/use-retro";
+import { useSocketEvent } from "@/hooks/use-retro-socket";
import { api } from "@/lib/api";
-import { socketURL } from "@/lib/socket";
import { SocketEvent } from "@/events";
-import { Retro } from "@/types";
-import { createFileRoute } from "@tanstack/react-router";
-import { useEffect, useState } from "react";
-import useWebSocket from "react-use-websocket";
+import { Note, Retro } from "@/types";
+import { createFileRoute, useRouter } from "@tanstack/react-router";
+import { useMemo, useState } from "react";
export const Route = createFileRoute("/_auth/retros/$retroId")({
+ // Both only need the id from the path, so neither has to wait for the other.
loader: async ({ params }) => {
- return (await api.get(`/api/retros/${params.retroId}`)).data;
+ const [retro, notes] = await Promise.all([
+ api.get(`/api/retros/${params.retroId}`),
+ api.get(`/api/retros/${params.retroId}/notes`),
+ ]);
+
+ return { retro: retro.data, notes: notes.data };
},
+ pendingComponent: BoardPending,
component: RouteComponent,
});
export default function RouteComponent() {
- const [retro, setRetro] = useState(Route.useLoaderData());
-
- const socket = useWebSocket(socketURL(`/api/retros/${retro.id}/ws`).href, {
- share: true,
- shouldReconnect: () => true,
- reconnectAttempts: 10,
- reconnectInterval: (attemptNumber) =>
- Math.min(Math.pow(2, attemptNumber) * 1000, 10000),
- });
+ const { retro, notes } = Route.useLoaderData();
- // Owned here rather than in Settings: that component lives inside the
- // board's collapsible header, which Radix unmounts when collapsed, so
- // column and settings changes from other people would be missed.
- const { lastJsonMessage } = socket;
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
- useEffect(() => {
- if (!lastJsonMessage) return;
+function BoardPending() {
+ return (
+
+
+
- const event = lastJsonMessage as SocketEvent;
+
+ {[0, 1].map((i) => (
+
+
+
+ ))}
+
+
+
+ );
+}
+
+function RetroProvider({
+ loaded,
+ children,
+}: {
+ loaded: Retro;
+ children: React.ReactNode;
+}) {
+ const router = useRouter();
+ const [retro, setRetro] = useState(loaded);
+ // Owned here rather than in Settings: that component lives inside the
+ // board's collapsible header, which Radix unmounts when collapsed, so
+ // column and settings changes from other people would be missed.
+ useSocketEvent((event: SocketEvent) => {
if (event.name === "retro_updated") {
setRetro(event.payload as Retro);
}
- }, [lastJsonMessage]);
+
+ // Anything the socket reports leaves the loader's cached copy wrong, and
+ // the router keeps that copy after you navigate away. Coming back seeds
+ // the board from it, so without this you get the stage you left rather
+ // than the one the retro is on.
+ if (event.name === "retro_updated" || event.name === "status_updated") {
+ router.invalidate();
+ }
+ });
+
+ const value = useMemo(() => ({ retro, setRetro }), [retro]);
return (
-
-
-
-
-
+ {children}
);
}