diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..513c6f6 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "ui", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--dir", "ui", "run", "dev"], + "port": 5173 + } + ] +} diff --git a/cmd/thoughts/event/notes.go b/cmd/thoughts/event/notes.go index 3c44cbd..193185a 100644 --- a/cmd/thoughts/event/notes.go +++ b/cmd/thoughts/event/notes.go @@ -33,7 +33,7 @@ func (b *Broker) handleNoteCreate(db *sqlx.DB, retroID uuid.UUID) Handler { return err } - b.dispatchUserDependent(newNoteCreatedEvent(note, retro, refFrom(payload))) + b.dispatchUserDependent(newNoteCreatedEvent(note, user, retro, refFrom(payload))) return nil } @@ -101,7 +101,15 @@ func (b *Broker) handleNoteUpdate(db *sqlx.DB, retroID uuid.UUID) Handler { return newErrorEvent("problem updating note") } - b.dispatchUserDependent(newNoteUpdatedEvent(note, retro, refFrom(payload))) + // Not the acting user: grouping and moving are allowed on other + // people's notes, so the author has to be looked up. + author, err := dal.UserGet(ctx, db, note.UserID) + if err != nil { + slog.Error("problem getting note author", "error", err) + return newErrorEvent("problem updating note") + } + + b.dispatchUserDependent(newNoteUpdatedEvent(note, author, retro, refFrom(payload))) return nil } @@ -173,9 +181,12 @@ func payloadHasAny(payload Payload, keys ...string) bool { return false } -func newNoteCreatedEvent(note *model.Note, retro *model.Retro, ref string) UserDependentEvent { +// author, not the person receiving the event: NoteFromModel resolves +// created_by_name from it, and passing nil made every broadcast note read +// "unknown" for everyone once it was moved or edited. +func newNoteCreatedEvent(note *model.Note, author *model.User, retro *model.Retro, ref string) UserDependentEvent { return func(user *model.User) *Event { - resource := resources.NoteFromModel(note, nil, user.ID, retro.IsBrainstorming()) + resource := resources.NoteFromModel(note, author, user.ID, retro.IsBrainstorming()) payload := resources.StructToMap(resource) return &Event{ @@ -185,9 +196,9 @@ func newNoteCreatedEvent(note *model.Note, retro *model.Retro, ref string) UserD } } -func newNoteUpdatedEvent(note *model.Note, retro *model.Retro, ref string) UserDependentEvent { +func newNoteUpdatedEvent(note *model.Note, author *model.User, retro *model.Retro, ref string) UserDependentEvent { return func(user *model.User) *Event { - resource := resources.NoteFromModel(note, nil, user.ID, retro.IsBrainstorming()) + resource := resources.NoteFromModel(note, author, user.ID, retro.IsBrainstorming()) payload := resources.StructToMap(resource) return &Event{ diff --git a/ui/src/app.tsx b/ui/src/app.tsx index c402a6a..47bdc0e 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from "react"; import AuthProvider from "./components/auth.tsx"; import ThemeProvider from "./components/theme.tsx"; import { Spinner } from "./components/ui/spinner.tsx"; +import { TooltipProvider } from "./components/ui/tooltip.tsx"; import { useAuth } from "./hooks/use-auth.ts"; import { router } from "./router.tsx"; @@ -42,7 +43,9 @@ export default function App() { - + + + diff --git a/ui/src/components/retro/board.tsx b/ui/src/components/retro/board.tsx index 9b07614..e8781cc 100644 --- a/ui/src/components/retro/board.tsx +++ b/ui/src/components/retro/board.tsx @@ -12,13 +12,13 @@ import { SocketEvent, } from "@/events"; import useRetro from "@/hooks/use-retro"; +import { useReadyState, useRetroSocket, useSocketEvent } from "@/hooks/use-retro-socket"; import { panelVariants } from "@/lib/motion"; -import { stageLabel } from "@/lib/stages"; import { RetroStatus } from "@/types"; import { Link } from "@tanstack/react-router"; import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { AnimatePresence, m } from "motion/react"; -import { useEffect, useState } from "react"; +import { memo, useState } from "react"; import { toast } from "sonner"; import Brainstorm from "./brainstorm"; import ConnectionIndicator from "./connection-indicator"; @@ -30,10 +30,9 @@ import StageRail from "./stage-rail"; import Vote from "./vote"; export default function Board() { - const { - retro, - socket: { sendJsonMessage, lastJsonMessage, readyState }, - } = useRetro(); + const { retro } = useRetro(); + const { send } = useRetroSocket(); + const readyState = useReadyState(); const [status, setStatus] = useState(retro.status); const [connectionInfo, setConnectionInfo] = useState({ users: [], @@ -41,9 +40,7 @@ export default function Board() { const [votesRemaining, setVotesRemaining] = useState(0); const [expanded, setExpanded] = useState(true); - useEffect(() => { - if (!lastJsonMessage) return; - const event = lastJsonMessage as SocketEvent; + useSocketEvent((event: SocketEvent) => { switch (event.name) { case "error": toast("Something went wrong", { @@ -57,20 +54,23 @@ export default function Board() { setConnectionInfo(event.payload as PayloadConnectionInfo); return; } - }, [lastJsonMessage]); + }); function handleStatusUpdate(s: RetroStatus) { - sendJsonMessage(createSocketEvent("status_update", { status: s })); + send(createSocketEvent("status_update", { status: s })); } return (
-
+ {/* Opaque, not backdrop-blurred: Nav is already a sticky + backdrop-blur-xl directly above, and stacking a second one made + both re-rasterise on every scroll frame. */} +
@@ -78,17 +78,13 @@ export default function Board() {
- {!expanded && ( - - {stageLabel(status)} - - )} - + +
@@ -155,25 +147,19 @@ export default function Board() {
- - - - - + {/* No AnimatePresence: mode="wait" meant the incoming stage waited out + the outgoing one's exit, and the alternatives keep both mounted, so + every note's layoutId would exist twice at once. */} + + +
); } -function BoardForStatus({ +// Memoised because Board holds connectionInfo and votesRemaining: without it +// every join, leave and vote re-renders the whole stage and all its notes. +const BoardForStatus = memo(function BoardForStatus({ status, setVotesRemaining, }: { @@ -192,4 +178,4 @@ function BoardForStatus({ default: return
Unknown status: {status}
; } -} +}); diff --git a/ui/src/components/retro/brainstorm.tsx b/ui/src/components/retro/brainstorm.tsx index 193264e..9df08c5 100644 --- a/ui/src/components/retro/brainstorm.tsx +++ b/ui/src/components/retro/brainstorm.tsx @@ -16,7 +16,7 @@ export default function Brainstorm() { const { retro: { columns }, } = useRetro(); - const { notes, loaded, dispatch } = useNotes(); + const { notes, notesByColumn, loaded, dispatch } = useNotes(); const columnActions = useColumnActions(notes); function handleNewNote(columnId: string, content: string) { @@ -69,7 +69,7 @@ export default function Brainstorm() { canAddColumn={columnActions.canCreate} > {columns.map((column, index) => { - const columnNotes = notes.filter((n) => n.column_id === column.id); + const columnNotes = notesByColumn[column.id] ?? []; return ( note.created_by_name) + .filter((name): name is string => Boolean(name)), + ), + ); +} + export default function Discuss() { - const { - retro, - socket: { lastJsonMessage }, - } = useRetro(); + const { retro } = useRetro(); const { notes, groupedNotes, loaded, dispatch } = useNotes(); const columnActions = useColumnActions(notes); @@ -42,27 +50,35 @@ export default function Discuss() { }); }, [retro.id]); + const voteCounts = useMemo( + () => new Map(votes.map((v) => [v.group_id, v.count])), + [votes], + ); + + const totalVotes = useMemo( + () => votes.reduce((acc, v) => acc + v.count, 0), + [votes], + ); + const groupedNotesForColumn = useCallback( (columnId: string) => { const cols = [...Object.entries(groupedNotes[columnId] ?? [])]; - cols.sort(([aGroupId], [bGroupId]) => { - const countFor = (groupId: string) => - votes.find((v) => v.group_id === groupId)?.count ?? 0; - - return countFor(bGroupId) - countFor(aGroupId); - }); + cols.sort( + ([a], [b]) => (voteCounts.get(b) ?? 0) - (voteCounts.get(a) ?? 0), + ); return cols; }, - [groupedNotes, votes], + [groupedNotes, voteCounts], ); - useEffect(() => { - if (!lastJsonMessage) return; - - const event = lastJsonMessage as SocketEvent; + const sortedTasks = useMemo( + () => [...tasks].sort((a, b) => Number(a.completed) - Number(b.completed)), + [tasks], + ); + useSocketEvent((event: SocketEvent) => { switch (event.name) { case "task_created": setTasks((tasks) => [...tasks, event.payload as TaskType]); @@ -76,7 +92,7 @@ export default function Discuss() { break; } } - }, [lastJsonMessage]); + }); function handleNewTask(data: { who: string; @@ -132,17 +148,10 @@ export default function Discuss() { v.group_id === groupId)?.count ?? 0, - total: votes.reduce((acc, v) => acc + v.count, 0), + forGroup: voteCounts.get(groupId) ?? 0, + total: totalVotes, }} - authors={Array.from( - new Set( - groupNotes - .map((note) => note.created_by_name) - .filter((name): name is string => Boolean(name)), - ), - )} + authors={authorsOf(groupNotes)} > {groupNotes.map((note) => ( @@ -180,16 +189,14 @@ export default function Discuss() { )} - {[...tasks] - .sort((a, b) => Number(a.completed) - Number(b.completed)) - .map((task) => ( - handleEditTask(task.id, d)} - onComplete={(c) => handleTaskComplete(task.id, c)} - /> - ))} + {sortedTasks.map((task) => ( + handleEditTask(task.id, d)} + onComplete={(c) => handleTaskComplete(task.id, c)} + /> + ))} diff --git a/ui/src/components/retro/hero.tsx b/ui/src/components/retro/hero.tsx index cb20b2a..040ec8d 100644 --- a/ui/src/components/retro/hero.tsx +++ b/ui/src/components/retro/hero.tsx @@ -38,23 +38,17 @@ const stats = [ export default function Hero({ stats: values }: { stats: HeroStats }) { return (
- - -
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({ /> - + 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 ( -
    1. +
    2. {index > 0 && (
    3. ))} + +
      + + 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} ); }