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
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "ui",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--dir", "ui", "run", "dev"],
"port": 5173
}
]
}
23 changes: 17 additions & 6 deletions cmd/thoughts/event/notes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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{
Expand All @@ -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{
Expand Down
5 changes: 4 additions & 1 deletion ui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -42,7 +43,9 @@ export default function App() {
<LazyMotion features={domMax} strict>
<AuthProvider>
<ThemeProvider defaultTheme="system">
<InnerApp />
<TooltipProvider>
<InnerApp />
</TooltipProvider>
</ThemeProvider>
</AuthProvider>
</LazyMotion>
Expand Down
70 changes: 28 additions & 42 deletions ui/src/components/retro/board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,20 +30,17 @@ 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<RetroStatus>(retro.status);
const [connectionInfo, setConnectionInfo] = useState<PayloadConnectionInfo>({
users: [],
});
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", {
Expand All @@ -57,38 +54,37 @@ 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 (
<div className="flex flex-col pt-3">
<div className="sticky top-12 z-40 mb-4">
<Collapsible open={expanded} onOpenChange={setExpanded}>
<div className="rounded-xl bg-background/80 shadow-sm ring-1 ring-border/40 backdrop-blur-xl">
{/* 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. */}
<div className="rounded-xl bg-background shadow-sm ring-1 ring-border/40">
<div className="flex flex-col gap-1.5 px-4 py-2 sm:h-12 sm:flex-row sm:items-center sm:gap-3 sm:py-0">
<span
className={`min-w-0 flex-1 truncate font-bold tracking-tight transition-all ${
className={`min-w-0 flex-1 truncate font-bold tracking-tight ${
expanded ? "text-lg sm:text-2xl" : "text-base"
}`}
>
{retro.title}
</span>

<div className="flex shrink-0 items-center gap-2">
{!expanded && (
<Badge
variant="outline"
className="hidden shrink-0 text-xs sm:flex"
>
{stageLabel(status)}
</Badge>
)}

<StageRail status={status} onStatusUpdate={handleStatusUpdate} />

<ConnectionIndicator
connectionInfo={connectionInfo}
readyState={readyState}
/>

<Button
variant="ghost"
size="icon"
Expand Down Expand Up @@ -144,36 +140,26 @@ export default function Board() {

{status === "discuss" && <ShowMarkdown />}
<Settings />
<ConnectionIndicator
connectionInfo={connectionInfo}
readyState={readyState}
/>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
</div>

<AnimatePresence mode="wait" initial={false}>
<m.div
key={status}
variants={panelVariants}
initial="initial"
animate="animate"
exit="exit"
>
<BoardForStatus
status={status}
setVotesRemaining={setVotesRemaining}
/>
</m.div>
</AnimatePresence>
{/* 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. */}
<m.div key={status} variants={panelVariants} initial="initial" animate="animate">
<BoardForStatus status={status} setVotesRemaining={setVotesRemaining} />
</m.div>
</div>
);
}

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,
}: {
Expand All @@ -192,4 +178,4 @@ function BoardForStatus({
default:
return <div>Unknown status: {status}</div>;
}
}
});
4 changes: 2 additions & 2 deletions ui/src/components/retro/brainstorm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
<DroppableColumn
Expand Down
83 changes: 45 additions & 38 deletions ui/src/components/retro/discuss.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { createSocketEvent, SocketEvent } from "@/events";
import { useColumnActions } from "@/hooks/use-columns";
import { useNotes } from "@/hooks/use-notes";
import useRetro from "@/hooks/use-retro";
import { useSocketEvent } from "@/hooks/use-retro-socket";
import { api } from "@/lib/api";
import { Task as TaskType } from "@/types";
import { Note as NoteType, Task as TaskType } from "@/types";
import { Plus } from "lucide-react";
import { AnimatePresence } from "motion/react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "../ui/button";
import { EmptyColumn, NoteSkeletons } from "./column-states";
import { Column, Columns } from "./columns";
Expand All @@ -21,11 +22,18 @@ interface Vote {
count: number;
}

function authorsOf(notes: NoteType[]) {
return Array.from(
new Set(
notes
.map((note) => 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);

Expand All @@ -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]);
Expand All @@ -76,7 +92,7 @@ export default function Discuss() {
break;
}
}
}, [lastJsonMessage]);
});

function handleNewTask(data: {
who: string;
Expand Down Expand Up @@ -132,17 +148,10 @@ export default function Discuss() {
<NoteGroup
key={groupId}
voteCount={{
forGroup:
votes.find((v) => 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) => (
<Note key={note.id} note={note} />
Expand Down Expand Up @@ -180,16 +189,14 @@ export default function Discuss() {
)}

<AnimatePresence mode="popLayout" initial={false}>
{[...tasks]
.sort((a, b) => Number(a.completed) - Number(b.completed))
.map((task) => (
<Task
key={task.id}
task={task}
onEdit={(d) => handleEditTask(task.id, d)}
onComplete={(c) => handleTaskComplete(task.id, c)}
/>
))}
{sortedTasks.map((task) => (
<Task
key={task.id}
task={task}
onEdit={(d) => handleEditTask(task.id, d)}
onComplete={(c) => handleTaskComplete(task.id, c)}
/>
))}
</AnimatePresence>
</Column>
</Columns>
Expand Down
Loading
Loading