From a669b879ebcb1944d1ee96e6577de1b7c5685792 Mon Sep 17 00:00:00 2001 From: Ellis Green Date: Mon, 10 Aug 2026 23:18:04 +0100 Subject: [PATCH 01/17] Fix note authorisation, socket robustness and identity; add tests and CI Correctness and security: - Note updates and deletes went unauthorised: any authenticated user could edit or delete anyone's note, including notes belonging to a different retro. Content changes and deletes are now author-only while moving notes between columns and groups stays open, since that is the point of the group stage. - The websocket accepted any Origin. Sessions are cookie based, so this let any page a logged-in user visited drive their retros. - Client send channels were unbuffered and the hub wrote to them synchronously, so a single stalled client froze broadcasts for the whole retro. They are now buffered, with stalled clients dropped. - The 512 byte frame limit silently closed connections: a retro_update with a full title and ten tags already exceeded it. - note_created always obfuscated its content regardless of stage, so notes written after brainstorm read as noise until a refresh. - Optimistic updates all shared one "placeholder" id, so any failure wiped every in-flight create and failed edits were never rolled back. Mutations now carry a ref that the server echoes on both confirmation and failure. Identity: logging in inserted a fresh user row every time, so people lost ownership of their own notes on re-login. Names are the identity in a name-only auth model, so logins now resolve to a single row. The migration collapses existing duplicates onto the earliest row, repoints notes and votes, and adds a unique index. Consistency: column count limits now agree between API and UI, max_votes: 0 reports as a range error rather than "required", names may contain spaces and accents, retro tags load in one query instead of one per retro, Obfuscate can emit the last character of each class, and GIFs survive the markdown export. Tests: first coverage in the repo - the status machine, note authorisation, the websocket payload binder, the obfuscator, the markdown exporter, the user migration and the notes reducer. CI now runs build, vet, race tests, lint, typecheck and the UI build on every push and pull request. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 68 +++ cmd/thoughts/controllers/auth.go | 24 +- cmd/thoughts/controllers/retros.go | 2 +- cmd/thoughts/controllers/tags.go | 12 +- cmd/thoughts/dal/retro.go | 8 +- cmd/thoughts/dal/tags.go | 41 ++ cmd/thoughts/dal/user.go | 20 + cmd/thoughts/dal/user_test.go | 180 ++++++++ cmd/thoughts/event/broker.go | 15 +- cmd/thoughts/event/event.go | 18 + cmd/thoughts/event/event_test.go | 406 ++++++++++++++++++ cmd/thoughts/event/notes.go | 90 +++- cmd/thoughts/event/retro.go | 2 +- cmd/thoughts/exporters/markdown.go | 10 +- cmd/thoughts/exporters/markdown_test.go | 121 ++++++ cmd/thoughts/requests/map_test.go | 135 ++++++ cmd/thoughts/routes.go | 9 +- cmd/thoughts/socket/client.go | 24 +- cmd/thoughts/socket/hub.go | 38 +- cmd/thoughts/socket/socket.go | 44 +- cmd/thoughts/testutil/db.go | 72 ++++ cmd/thoughts/util/obfuscate.go | 9 +- cmd/thoughts/util/obfuscate_test.go | 75 ++++ .../20260810120000_dedupe_users_by_name.sql | 70 +++ ui/README.md | 50 --- ui/package.json | 4 +- ui/pnpm-lock.yaml | 238 ++++++++++ ui/src/components/container.tsx | 21 + ui/src/components/nav.tsx | 5 +- ui/src/events/index.ts | 10 + ui/src/hooks/use-notes.test.ts | 193 +++++++++ ui/src/hooks/use-notes.ts | 220 +++++++--- ui/src/routes/_auth.index.tsx | 5 +- ui/src/routes/_auth.retros.$retroId.tsx | 5 +- ui/src/routes/_auth.tags.$tag.tsx | 5 +- ui/src/routes/login.tsx | 2 +- ui/vitest.config.ts | 16 + 37 files changed, 2092 insertions(+), 175 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 cmd/thoughts/dal/user_test.go create mode 100644 cmd/thoughts/event/event_test.go create mode 100644 cmd/thoughts/exporters/markdown_test.go create mode 100644 cmd/thoughts/requests/map_test.go create mode 100644 cmd/thoughts/testutil/db.go create mode 100644 cmd/thoughts/util/obfuscate_test.go create mode 100644 migrations/20260810120000_dedupe_users_by_name.sql delete mode 100644 ui/README.md create mode 100644 ui/src/components/container.tsx create mode 100644 ui/src/hooks/use-notes.test.ts create mode 100644 ui/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..12bdcfc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + go: + name: Go + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race ./... + + ui: + name: UI + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: ui/package.json + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: ui/pnpm-lock.yaml + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm exec tsc -b + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build diff --git a/cmd/thoughts/controllers/auth.go b/cmd/thoughts/controllers/auth.go index 11baede..1b86763 100644 --- a/cmd/thoughts/controllers/auth.go +++ b/cmd/thoughts/controllers/auth.go @@ -3,6 +3,9 @@ package controllers import ( "log/slog" "net/http" + "strings" + "unicode" + "unicode/utf8" "github.com/ellgreen/thoughts/cmd/thoughts/auth" "github.com/ellgreen/thoughts/cmd/thoughts/dal" @@ -13,7 +16,7 @@ import ( ) type AuthLoginRequest struct { - Name string `json:"name" validate:"required,alpha,min=2,max=20"` + Name string `json:"name" validate:"required,max=32"` } func AuthLogin(db *sqlx.DB, sessionProvider *session.Provider) http.Handler { @@ -23,20 +26,27 @@ func AuthLogin(db *sqlx.DB, sessionProvider *session.Provider) http.Handler { return } - if len(req.Name) < 1 { - http.Error(w, "name should be more than one character", http.StatusBadRequest) + // Names are free text - people have spaces, hyphens and accents in + // them. Only control characters are worth rejecting. + name := strings.TrimSpace(req.Name) + + if utf8.RuneCountInString(name) < 2 { + http.Error(w, "Name should contain at least 2 characters", http.StatusBadRequest) return } - user, err := dal.UserInsert(r.Context(), db, req.Name) + if strings.ContainsFunc(name, func(r rune) bool { return !unicode.IsPrint(r) }) { + http.Error(w, "Name should not contain control characters", http.StatusBadRequest) + return + } + + user, err := dal.UserGetOrCreate(r.Context(), db, name) if err != nil { - slog.Error("failed to insert user", "error", err) + slog.Error("failed to resolve user", "error", err) w.WriteHeader(http.StatusInternalServerError) return } - slog.Info("user created", "user_id", user.ID, "name", user.Name) - if err := sessionProvider.AddUserID(w, r, user.ID); err != nil { slog.Error("failed to add user id to session", "error", err) w.WriteHeader(http.StatusInternalServerError) diff --git a/cmd/thoughts/controllers/retros.go b/cmd/thoughts/controllers/retros.go index ab13d28..5a9f2f7 100644 --- a/cmd/thoughts/controllers/retros.go +++ b/cmd/thoughts/controllers/retros.go @@ -62,7 +62,7 @@ type RetroCreateRequest struct { Columns []struct { Title string `json:"title" validate:"required,min=2,max=255"` Description string `json:"description" validate:"max=255"` - } `json:"columns" validate:"required,min=2,dive,required"` + } `json:"columns" validate:"required,min=2,max=5,dive,required"` Unlisted bool `json:"unlisted"` Tags []string `json:"tags" validate:"omitempty,max=10,dive,min=1,max=50"` } diff --git a/cmd/thoughts/controllers/tags.go b/cmd/thoughts/controllers/tags.go index d129d12..cd8f9db 100644 --- a/cmd/thoughts/controllers/tags.go +++ b/cmd/thoughts/controllers/tags.go @@ -54,14 +54,10 @@ func TagRetros(db *sqlx.DB) http.Handler { return } - for _, retro := range retros { - tags, err := dal.RetroTagsList(r.Context(), db, retro.ID) - if err != nil { - slog.Error("problem fetching tags for retro", "error", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - retro.Tags = tags + if err := dal.RetroTagsAttach(r.Context(), db, retros); err != nil { + slog.Error("problem fetching tags for retros", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return } openTasks, err := dal.TagOpenTasks(r.Context(), db, tag) diff --git a/cmd/thoughts/dal/retro.go b/cmd/thoughts/dal/retro.go index 8df0da4..cf4aff4 100644 --- a/cmd/thoughts/dal/retro.go +++ b/cmd/thoughts/dal/retro.go @@ -32,12 +32,8 @@ func RetroList(ctx context.Context, db *sqlx.DB, includeUnlisted bool) ([]*model return nil, fmt.Errorf("%w: failed to select retros: %w", ErrExecution, err) } - for _, retro := range retros { - tags, err := RetroTagsList(ctx, db, retro.ID) - if err != nil { - return nil, err - } - retro.Tags = tags + if err := RetroTagsAttach(ctx, db, retros); err != nil { + return nil, err } return retros, nil diff --git a/cmd/thoughts/dal/tags.go b/cmd/thoughts/dal/tags.go index 71bb028..e2d33b1 100644 --- a/cmd/thoughts/dal/tags.go +++ b/cmd/thoughts/dal/tags.go @@ -17,6 +17,47 @@ func RetroTagsList(ctx context.Context, db *sqlx.DB, retroID uuid.UUID) ([]strin return tags, nil } +// RetroTagsAttach loads the tags for a batch of retros in one query and hangs +// them off the models, replacing a RetroTagsList call per retro. +func RetroTagsAttach(ctx context.Context, db *sqlx.DB, retros []*model.Retro) error { + if len(retros) == 0 { + return nil + } + + ids := make([]uuid.UUID, len(retros)) + for i, retro := range retros { + ids[i] = retro.ID + retro.Tags = []string{} + } + + query, args, err := sqlx.In("select retro_id, tag from retro_tags where retro_id in (?) order by tag", ids) + if err != nil { + return fmt.Errorf("%w: failed to build tags query: %w", ErrExecution, err) + } + + var rows []struct { + RetroID uuid.UUID `db:"retro_id"` + Tag string `db:"tag"` + } + + if err := db.SelectContext(ctx, &rows, db.Rebind(query), args...); err != nil { + return fmt.Errorf("%w: failed to list tags for retros: %w", ErrExecution, err) + } + + byRetro := make(map[uuid.UUID][]string, len(retros)) + for _, row := range rows { + byRetro[row.RetroID] = append(byRetro[row.RetroID], row.Tag) + } + + for _, retro := range retros { + if tags, ok := byRetro[retro.ID]; ok { + retro.Tags = tags + } + } + + return nil +} + // RetroTagsSet replaces all tags for a retro atomically. func RetroTagsSet(ctx context.Context, db *sqlx.DB, retroID uuid.UUID, tags []string) error { tx, err := db.BeginTxx(ctx, nil) diff --git a/cmd/thoughts/dal/user.go b/cmd/thoughts/dal/user.go index cb452c4..18c74e7 100644 --- a/cmd/thoughts/dal/user.go +++ b/cmd/thoughts/dal/user.go @@ -2,6 +2,8 @@ package dal import ( "context" + "database/sql" + "errors" "fmt" "time" @@ -19,6 +21,24 @@ func UserGet(ctx context.Context, db *sqlx.DB, id uuid.UUID) (*model.User, error return user, nil } +// UserGetOrCreate resolves the identity behind a name, creating it on first +// sight. Auth is name-only, so the name is the identity: logging in again has +// to land on the same row or the person loses ownership of their own notes. +func UserGetOrCreate(ctx context.Context, db *sqlx.DB, name string) (*model.User, error) { + user := &model.User{} + + err := db.GetContext(ctx, user, "select * from users where name = ?", name) + if err == nil { + return user, nil + } + + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: failed to get user by name: %w", ErrExecution, err) + } + + return UserInsert(ctx, db, name) +} + func UserInsert(ctx context.Context, db *sqlx.DB, name string) (*model.User, error) { user := &model.User{ ID: uuid.New(), diff --git a/cmd/thoughts/dal/user_test.go b/cmd/thoughts/dal/user_test.go new file mode 100644 index 0000000..73362f6 --- /dev/null +++ b/cmd/thoughts/dal/user_test.go @@ -0,0 +1,180 @@ +package dal_test + +import ( + "context" + "testing" + "time" + + "github.com/ellgreen/thoughts/cmd/thoughts/dal" + "github.com/ellgreen/thoughts/cmd/thoughts/testutil" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +// The migration that collapses duplicate identities. Everything before it is +// applied first so the test can seed the mess it is meant to clean up. +const beforeDedupeVersion = 20250510163227 + +func TestUserGetOrCreateReusesTheSameIdentity(t *testing.T) { + db := testutil.NewDB(t) + ctx := context.Background() + + first, err := dal.UserGetOrCreate(ctx, db, "Ada Lovelace") + if err != nil { + t.Fatalf("first login failed: %v", err) + } + + second, err := dal.UserGetOrCreate(ctx, db, "Ada Lovelace") + if err != nil { + t.Fatalf("second login failed: %v", err) + } + + if first.ID != second.ID { + t.Errorf("logging in again produced a new identity: %s then %s", first.ID, second.ID) + } + + other, err := dal.UserGetOrCreate(ctx, db, "Grace Hopper") + if err != nil { + t.Fatalf("third login failed: %v", err) + } + + if other.ID == first.ID { + t.Error("different names collapsed onto the same identity") + } +} + +func TestDedupeMigrationCollapsesDuplicateUsers(t *testing.T) { + db := testutil.NewDBAt(t, beforeDedupeVersion) + ctx := context.Background() + + retroID := seedRetro(t, db) + + // The same person across three sessions, oldest first. + oldest := seedUser(t, db, "Ada", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + middle := seedUser(t, db, "Ada", time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)) + newest := seedUser(t, db, "Ada", time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)) + other := seedUser(t, db, "Grace", time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)) + + groupID := uuid.New() + + noteA := seedNote(t, db, retroID, middle, groupID) + noteB := seedNote(t, db, retroID, newest, groupID) + noteC := seedNote(t, db, retroID, other, uuid.New()) + + // Two identities of the same person voting for the same group: the unique + // index on (retro_id, user_id, group_id) only bites once they are merged. + seedVote(t, db, retroID, middle, groupID) + seedVote(t, db, retroID, newest, groupID) + + testutil.MigrateUp(t, db) + + var remaining int + if err := db.GetContext(ctx, &remaining, "select count(*) from users where name = 'Ada'"); err != nil { + t.Fatalf("failed to count users: %v", err) + } + + if remaining != 1 { + t.Errorf("expected 1 Ada after dedupe, got %d", remaining) + } + + for _, noteID := range []uuid.UUID{noteA, noteB} { + var userID uuid.UUID + if err := db.GetContext(ctx, &userID, "select user_id from notes where id = ?", noteID); err != nil { + t.Fatalf("failed to read note %s: %v", noteID, err) + } + + if userID != oldest { + t.Errorf("note %s points at %s, expected the earliest identity %s", noteID, userID, oldest) + } + } + + var untouched uuid.UUID + if err := db.GetContext(ctx, &untouched, "select user_id from notes where id = ?", noteC); err != nil { + t.Fatalf("failed to read note %s: %v", noteC, err) + } + + if untouched != other { + t.Errorf("an unrelated note moved from %s to %s", other, untouched) + } + + var votes int + if err := db.GetContext(ctx, &votes, "select count(*) from votes where retro_id = ?", retroID); err != nil { + t.Fatalf("failed to count votes: %v", err) + } + + if votes != 1 { + t.Errorf("expected the colliding votes to collapse to 1, got %d", votes) + } + + // The unique index should now be in force. + _, err := db.ExecContext(ctx, ` + insert into users (id, name, created_at, updated_at) values (?, 'Ada', ?, ?) + `, uuid.New(), time.Now(), time.Now()) + + if err == nil { + t.Error("expected a duplicate name to be rejected after the migration") + } +} + +func seedRetro(t *testing.T, db *sqlx.DB) uuid.UUID { + t.Helper() + + id := uuid.New() + + _, err := db.Exec(` + insert into retros (id, status, title, columns, created_at, updated_at) + values (?, 'brainstorm', 'Seeded retro', '[]', ?, ?) + `, id, time.Now(), time.Now()) + + if err != nil { + t.Fatalf("failed to seed retro: %v", err) + } + + return id +} + +func seedUser(t *testing.T, db *sqlx.DB, name string, createdAt time.Time) uuid.UUID { + t.Helper() + + id := uuid.New() + + _, err := db.Exec(` + insert into users (id, name, created_at, updated_at) values (?, ?, ?, ?) + `, id, name, createdAt, createdAt) + + if err != nil { + t.Fatalf("failed to seed user %s: %v", name, err) + } + + return id +} + +func seedNote(t *testing.T, db *sqlx.DB, retroID, userID, groupID uuid.UUID) uuid.UUID { + t.Helper() + + id := uuid.New() + + _, err := db.Exec(` + insert into notes (id, retro_id, user_id, column_id, group_id, content, created_at, updated_at) + values (?, ?, ?, ?, ?, 'seeded note', ?, ?) + `, id, retroID, userID, uuid.New(), groupID, time.Now(), time.Now()) + + if err != nil { + t.Fatalf("failed to seed note: %v", err) + } + + return id +} + +func seedVote(t *testing.T, db *sqlx.DB, retroID, userID, groupID uuid.UUID) { + t.Helper() + + _, err := db.Exec(` + insert into votes (id, retro_id, user_id, group_id, created_at, updated_at) + values (?, ?, ?, ?, ?, ?) + `, uuid.New(), retroID, userID, groupID, time.Now(), time.Now()) + + if err != nil { + t.Fatalf("failed to seed vote: %v", err) + } +} diff --git a/cmd/thoughts/event/broker.go b/cmd/thoughts/event/broker.go index 8ce472c..0e35604 100644 --- a/cmd/thoughts/event/broker.go +++ b/cmd/thoughts/event/broker.go @@ -34,7 +34,7 @@ func NewBroker(db *sqlx.DB, retroID uuid.UUID) *Broker { b.register("status_update", b.handleStatusUpdate(db, retroID)) b.register("note_create", b.handleNoteCreate(db, retroID)) b.register("note_update", b.handleNoteUpdate(db, retroID)) - b.register("note_delete", b.handleNoteDelete(db)) + b.register("note_delete", b.handleNoteDelete(db, retroID)) b.register("task_create", b.handleTaskCreate(db, retroID)) b.register("task_update", b.handleTaskUpdate(db)) b.register("task_complete", b.handleTaskComplete(db)) @@ -67,7 +67,18 @@ func (b *Broker) Handle(ctx context.Context, user *model.User, message io.Reader return err } - return handler(ctx, user, event.Payload) + err = handler(ctx, user, event.Payload) + + // Echo the client's correlation ref back on failure so it can roll back the + // one optimistic update that failed rather than every in-flight one. + errorEvent := &ErrorEvent{} + if errors.As(err, &errorEvent) { + if ref, ok := event.Payload["ref"].(string); ok && ref != "" { + errorEvent.Payload["ref"] = ref + } + } + + return err } func (b *Broker) dispatch(event *Event) { diff --git a/cmd/thoughts/event/event.go b/cmd/thoughts/event/event.go index 32b9ea0..4c4c854 100644 --- a/cmd/thoughts/event/event.go +++ b/cmd/thoughts/event/event.go @@ -40,6 +40,24 @@ func newErrorEvent(message string) *ErrorEvent { type UserDependentEvent func(user *model.User) *Event +// refFrom pulls the client's correlation id out of an inbound payload. Clients +// use it to match server confirmations and failures back to the optimistic +// update they already applied locally. +func refFrom(payload Payload) string { + ref, _ := payload["ref"].(string) + + return ref +} + +// withRef echoes a correlation id back on an outbound payload. +func withRef(payload Payload, ref string) Payload { + if ref != "" { + payload["ref"] = ref + } + + return payload +} + func (e *Event) ToJSON() []byte { val, err := json.Marshal(e) if err != nil { diff --git a/cmd/thoughts/event/event_test.go b/cmd/thoughts/event/event_test.go new file mode 100644 index 0000000..b7b62a8 --- /dev/null +++ b/cmd/thoughts/event/event_test.go @@ -0,0 +1,406 @@ +package event_test + +import ( + "bytes" + "context" + "encoding/json" + "testing" + "time" + + "github.com/ellgreen/thoughts/cmd/thoughts/dal" + "github.com/ellgreen/thoughts/cmd/thoughts/event" + "github.com/ellgreen/thoughts/cmd/thoughts/model" + "github.com/ellgreen/thoughts/cmd/thoughts/testutil" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +// harness drives a broker the way the socket does - inbound JSON in, broadcast +// events out - while draining both outbound channels so handlers never block. +type harness struct { + db *sqlx.DB + broker *event.Broker + retro *model.Retro + columns model.RetroColumns + observed chan *event.Event +} + +func newHarness(t *testing.T) *harness { + t.Helper() + + db := testutil.NewDB(t) + + columns := model.RetroColumns{ + {Title: "Went well", Description: "The good bits"}, + {Title: "Went badly", Description: "The bad bits"}, + } + + retro, err := dal.RetroInsert(context.Background(), db, "A test retro", columns, false) + if err != nil { + t.Fatalf("failed to seed retro: %v", err) + } + + h := &harness{ + db: db, + broker: event.NewBroker(db, retro.ID), + retro: retro, + columns: retro.GetColumns(), + observed: make(chan *event.Event, 64), + } + + observer := &model.User{ID: uuid.New(), Name: "Observer"} + stop := make(chan struct{}) + t.Cleanup(func() { close(stop) }) + + go func() { + for { + select { + case <-stop: + return + case evt := <-h.broker.Listen(): + h.observed <- evt + case userDependent := <-h.broker.ListenUserDependent(): + h.observed <- userDependent(observer) + } + } + }() + + return h +} + +func (h *harness) user(t *testing.T, name string) *model.User { + t.Helper() + + user, err := dal.UserGetOrCreate(context.Background(), h.db, name) + if err != nil { + t.Fatalf("failed to create user %s: %v", name, err) + } + + return user +} + +func (h *harness) handle(t *testing.T, user *model.User, name string, payload map[string]any) error { + t.Helper() + + body, err := json.Marshal(map[string]any{"name": name, "payload": payload}) + if err != nil { + t.Fatalf("failed to encode event: %v", err) + } + + return h.broker.Handle(context.Background(), user, bytes.NewReader(body)) +} + +// next waits for the next broadcast event, failing if none arrives. +func (h *harness) next(t *testing.T) *event.Event { + t.Helper() + + select { + case evt := <-h.observed: + return evt + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for a broadcast event") + return nil + } +} + +// createNote runs a note_create as user and returns the persisted note id. +func (h *harness) createNote(t *testing.T, user *model.User, content string) uuid.UUID { + t.Helper() + + if err := h.handle(t, user, "note_create", map[string]any{ + "column_id": h.columns[0].ID.String(), + "content": content, + }); err != nil { + t.Fatalf("note_create failed: %v", err) + } + + evt := h.next(t) + if evt.Name != "note_created" { + t.Fatalf("expected note_created, got %s", evt.Name) + } + + // Payloads hold native values until they are marshalled onto the wire. + id, ok := evt.Payload["id"].(uuid.UUID) + if !ok { + t.Fatalf("note_created carried an id of type %T", evt.Payload["id"]) + } + + return id +} + +func assertErrorEvent(t *testing.T, err error, want string) { + t.Helper() + + if err == nil { + t.Fatalf("expected an error event saying %q, got nil", want) + } + + if err.Error() != want { + t.Errorf("expected error %q, got %q", want, err.Error()) + } +} + +func TestStatusTransitions(t *testing.T) { + cases := []struct { + from model.RetroStatus + to string + wantErr string + }{ + {model.RetroStatusBrainstorm, "group", ""}, + {model.RetroStatusBrainstorm, "vote", "invalid status transition"}, + {model.RetroStatusBrainstorm, "discuss", "invalid status transition"}, + {model.RetroStatusBrainstorm, "brainstorm", "status is already set to brainstorm"}, + {model.RetroStatusGroup, "brainstorm", ""}, + {model.RetroStatusGroup, "vote", ""}, + {model.RetroStatusGroup, "discuss", "invalid status transition"}, + {model.RetroStatusVote, "group", ""}, + {model.RetroStatusVote, "discuss", ""}, + {model.RetroStatusVote, "brainstorm", "invalid status transition"}, + {model.RetroStatusDiscuss, "vote", ""}, + {model.RetroStatusDiscuss, "group", "invalid status transition"}, + {model.RetroStatusDiscuss, "brainstorm", "invalid status transition"}, + } + + for _, tc := range cases { + t.Run(string(tc.from)+"_to_"+tc.to, func(t *testing.T) { + h := newHarness(t) + user := h.user(t, "Facilitator") + + if err := dal.RetroUpdateStatus(context.Background(), h.db, h.retro.ID, tc.from); err != nil { + t.Fatalf("failed to set starting status: %v", err) + } + + err := h.handle(t, user, "status_update", map[string]any{"status": tc.to}) + + if tc.wantErr != "" { + assertErrorEvent(t, err, tc.wantErr) + return + } + + if err != nil { + t.Fatalf("expected the transition to be allowed, got %v", err) + } + + if evt := h.next(t); evt.Name != "status_updated" { + t.Errorf("expected status_updated, got %s", evt.Name) + } + }) + } +} + +func TestStatusUpdateRejectsUnknownStatus(t *testing.T) { + h := newHarness(t) + + err := h.handle(t, h.user(t, "Facilitator"), "status_update", map[string]any{"status": "napping"}) + if err == nil { + t.Fatal("expected an unknown status to be rejected") + } +} + +func TestOnlyTheAuthorMayEditNoteContent(t *testing.T) { + h := newHarness(t) + + author := h.user(t, "Author") + other := h.user(t, "Someone Else") + + noteID := h.createNote(t, author, "my own thought") + + err := h.handle(t, other, "note_update", map[string]any{ + "id": noteID.String(), + "content": "not my thought", + }) + + assertErrorEvent(t, err, "you can only change your own notes") + + note, getErr := dal.NoteGet(context.Background(), h.db, noteID) + if getErr != nil { + t.Fatalf("failed to re-read note: %v", getErr) + } + + if note.Content != "my own thought" { + t.Errorf("content was changed to %q despite the rejection", note.Content) + } +} + +func TestOnlyTheAuthorMayAttachAGif(t *testing.T) { + h := newHarness(t) + + author := h.user(t, "Author") + other := h.user(t, "Someone Else") + + noteID := h.createNote(t, author, "my own thought") + + err := h.handle(t, other, "note_update", map[string]any{ + "id": noteID.String(), + "img_url": "https://example.com/cat.gif", + }) + + assertErrorEvent(t, err, "you can only change your own notes") +} + +func TestAnyoneMayMoveANote(t *testing.T) { + h := newHarness(t) + + author := h.user(t, "Author") + other := h.user(t, "Someone Else") + + noteID := h.createNote(t, author, "my own thought") + + // Grouping is a shared activity - moving someone else's note is the point. + err := h.handle(t, other, "note_update", map[string]any{ + "id": noteID.String(), + "column_id": h.columns[1].ID.String(), + "group_id": uuid.New().String(), + }) + + if err != nil { + t.Fatalf("expected a move by another user to be allowed, got %v", err) + } + + if evt := h.next(t); evt.Name != "note_updated" { + t.Errorf("expected note_updated, got %s", evt.Name) + } + + note, getErr := dal.NoteGet(context.Background(), h.db, noteID) + if getErr != nil { + t.Fatalf("failed to re-read note: %v", getErr) + } + + if note.ColumnID != h.columns[1].ID { + t.Errorf("note did not move: still in column %s", note.ColumnID) + } +} + +func TestOnlyTheAuthorMayDeleteANote(t *testing.T) { + h := newHarness(t) + + author := h.user(t, "Author") + other := h.user(t, "Someone Else") + + noteID := h.createNote(t, author, "my own thought") + + assertErrorEvent( + t, + h.handle(t, other, "note_delete", map[string]any{"id": noteID.String()}), + "you can only change your own notes", + ) + + if _, err := dal.NoteGet(context.Background(), h.db, noteID); err != nil { + t.Errorf("note was deleted despite the rejection: %v", err) + } + + if err := h.handle(t, author, "note_delete", map[string]any{"id": noteID.String()}); err != nil { + t.Fatalf("the author should be able to delete their own note, got %v", err) + } +} + +func TestNotesFromAnotherRetroAreInvisible(t *testing.T) { + h := newHarness(t) + ctx := context.Background() + + author := h.user(t, "Author") + + // A note that lives in a different retro entirely. + otherRetro, err := dal.RetroInsert(ctx, h.db, "Another retro", model.RetroColumns{ + {Title: "One", Description: ""}, + {Title: "Two", Description: ""}, + }, false) + if err != nil { + t.Fatalf("failed to seed the second retro: %v", err) + } + + foreign, err := dal.NoteInsert(ctx, h.db, otherRetro.ID, author.ID, otherRetro.GetColumns()[0].ID, "elsewhere") + if err != nil { + t.Fatalf("failed to seed the foreign note: %v", err) + } + + assertErrorEvent( + t, + h.handle(t, author, "note_delete", map[string]any{"id": foreign.ID.String()}), + "note not found", + ) + + assertErrorEvent( + t, + h.handle(t, author, "note_update", map[string]any{"id": foreign.ID.String(), "content": "reached in"}), + "note not found", + ) + + if _, err := dal.NoteGet(ctx, h.db, foreign.ID); err != nil { + t.Errorf("the foreign note was deleted: %v", err) + } +} + +func TestErrorEventsEchoTheClientRef(t *testing.T) { + h := newHarness(t) + + author := h.user(t, "Author") + other := h.user(t, "Someone Else") + + noteID := h.createNote(t, author, "my own thought") + + err := h.handle(t, other, "note_update", map[string]any{ + "id": noteID.String(), + "content": "not my thought", + "ref": "abc-123", + }) + + errorEvent, ok := err.(*event.ErrorEvent) + if !ok { + t.Fatalf("expected an ErrorEvent, got %T", err) + } + + if errorEvent.Payload["ref"] != "abc-123" { + t.Errorf("expected the ref to be echoed back, got %v", errorEvent.Payload["ref"]) + } +} + +func TestConfirmationsEchoTheClientRef(t *testing.T) { + h := newHarness(t) + + if err := h.handle(t, h.user(t, "Author"), "note_create", map[string]any{ + "column_id": h.columns[0].ID.String(), + "content": "a thought", + "ref": "xyz-789", + }); err != nil { + t.Fatalf("note_create failed: %v", err) + } + + if got := h.next(t).Payload["ref"]; got != "xyz-789" { + t.Errorf("expected the ref to be echoed back, got %v", got) + } +} + +func TestNotesAreOnlyObfuscatedDuringBrainstorm(t *testing.T) { + h := newHarness(t) + ctx := context.Background() + + author := h.user(t, "Author") + + // Brainstorm: other people see noise. + h.createNote(t, author, "secret thought") + + if err := dal.RetroUpdateStatus(ctx, h.db, h.retro.ID, model.RetroStatusGroup); err != nil { + t.Fatalf("failed to advance the retro: %v", err) + } + + if err := h.handle(t, author, "note_create", map[string]any{ + "column_id": h.columns[0].ID.String(), + "content": "open thought", + }); err != nil { + t.Fatalf("note_create failed: %v", err) + } + + // Past brainstorm, note_created must not scramble the content any more. + if got := h.next(t).Payload["content"]; got != "open thought" { + t.Errorf("expected the content in the clear after brainstorm, got %q", got) + } +} + +func TestUnknownEventsAreRejected(t *testing.T) { + h := newHarness(t) + + if err := h.handle(t, h.user(t, "Author"), "drop_database", nil); err == nil { + t.Error("expected an unknown event name to be rejected") + } +} diff --git a/cmd/thoughts/event/notes.go b/cmd/thoughts/event/notes.go index 7b48e40..02e8168 100644 --- a/cmd/thoughts/event/notes.go +++ b/cmd/thoughts/event/notes.go @@ -2,6 +2,8 @@ package event import ( "context" + "database/sql" + "errors" "log/slog" "github.com/ellgreen/thoughts/cmd/thoughts/dal" @@ -12,6 +14,11 @@ import ( "github.com/jmoiron/sqlx" ) +// Payload fields that change what a note says, as opposed to where it sits. +// Only the author may change these. Moving a note between columns and groups +// stays open to everyone, which is the whole point of the group stage. +var noteContentFields = []string{"content", "img_url", "remove_img_url"} + type noteCreateRequest struct { ColumnID uuid.UUID `json:"column_id" validate:"required,uuid"` Content string `json:"content" validate:"required,min=2,max=255"` @@ -24,13 +31,19 @@ func (b *Broker) handleNoteCreate(db *sqlx.DB, retroID uuid.UUID) Handler { return newErrorEvent(err.Error()) } + retro, err := dal.RetroGet(ctx, db, retroID) + if err != nil { + slog.Error("problem getting retro", "error", err) + return newErrorEvent("problem getting retro") + } + note, err := dal.NoteInsert(ctx, db, retroID, user.ID, req.ColumnID, req.Content) if err != nil { slog.Error("problem inserting note", "error", err) return newErrorEvent("problem inserting note") } - b.dispatchUserDependent(newNoteCreatedEvent(note)) + b.dispatchUserDependent(newNoteCreatedEvent(note, retro, refFrom(payload))) return nil } @@ -46,7 +59,7 @@ type noteUpdateRequest struct { } func (b *Broker) handleNoteUpdate(db *sqlx.DB, retroID uuid.UUID) Handler { - return func(ctx context.Context, _ *model.User, payload Payload) error { + return func(ctx context.Context, user *model.User, payload Payload) error { retro, err := dal.RetroGet(ctx, db, retroID) if err != nil { slog.Error("problem getting retro", "error", err) @@ -58,13 +71,17 @@ func (b *Broker) handleNoteUpdate(db *sqlx.DB, retroID uuid.UUID) Handler { return newErrorEvent(err.Error()) } + if err := authoriseNote(ctx, db, user, retroID, req.NoteID, payloadHasAny(payload, noteContentFields...)); err != nil { + return err + } + note, err := dal.NoteUpdate(ctx, db, req.NoteID, req.ColumnID, req.GroupID, req.Content, req.ImgURL, req.RemoveImgURL) if err != nil { slog.Error("problem updating note", "error", err) return newErrorEvent("problem updating note") } - b.dispatchUserDependent(newNoteUpdatedEvent(note, retro)) + b.dispatchUserDependent(newNoteUpdatedEvent(note, retro, refFrom(payload))) return nil } @@ -74,51 +91,98 @@ type noteDeleteRequest struct { NoteID uuid.UUID `json:"id" validate:"required,uuid"` } -func (b *Broker) handleNoteDelete(db *sqlx.DB) Handler { - return func(ctx context.Context, _ *model.User, payload Payload) error { +func (b *Broker) handleNoteDelete(db *sqlx.DB, retroID uuid.UUID) Handler { + return func(ctx context.Context, user *model.User, payload Payload) error { req, err := requests.FromMap[noteDeleteRequest](payload) if err != nil { return newErrorEvent(err.Error()) } + if err := authoriseNote(ctx, db, user, retroID, req.NoteID, true); err != nil { + return err + } + if err := dal.NoteDelete(ctx, db, req.NoteID); err != nil { slog.Error("problem deleting note", "error", err) return newErrorEvent("problem deleting note") } - b.dispatch(newNoteDeletedEvent(req.NoteID)) + b.dispatch(newNoteDeletedEvent(req.NoteID, refFrom(payload))) return nil } } -func newNoteCreatedEvent(note *model.Note) UserDependentEvent { +// authoriseNote checks that the note exists and belongs to this retro, and when +// requireOwner is set, that the caller wrote it. Without the retro check a +// crafted event could reach into a retro the caller is not even connected to. +func authoriseNote( + ctx context.Context, + db *sqlx.DB, + user *model.User, + retroID uuid.UUID, + noteID uuid.UUID, + requireOwner bool, +) error { + note, err := dal.NoteGet(ctx, db, noteID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return newErrorEvent("note not found") + } + + slog.Error("problem getting note", "error", err) + + return newErrorEvent("problem getting note") + } + + if note.RetroID != retroID { + return newErrorEvent("note not found") + } + + if requireOwner && note.UserID != user.ID { + return newErrorEvent("you can only change your own notes") + } + + return nil +} + +func payloadHasAny(payload Payload, keys ...string) bool { + for _, key := range keys { + if _, ok := payload[key]; ok { + return true + } + } + + return false +} + +func newNoteCreatedEvent(note *model.Note, retro *model.Retro, ref string) UserDependentEvent { return func(user *model.User) *Event { - resource := resources.NoteFromModel(note, nil, user.ID, true) + resource := resources.NoteFromModel(note, nil, user.ID, retro.IsBrainstorming()) payload := resources.StructToMap(resource) return &Event{ Name: "note_created", - Payload: payload, + Payload: withRef(payload, ref), } } } -func newNoteUpdatedEvent(note *model.Note, retro *model.Retro) UserDependentEvent { +func newNoteUpdatedEvent(note *model.Note, retro *model.Retro, ref string) UserDependentEvent { return func(user *model.User) *Event { resource := resources.NoteFromModel(note, nil, user.ID, retro.IsBrainstorming()) payload := resources.StructToMap(resource) return &Event{ Name: "note_updated", - Payload: payload, + Payload: withRef(payload, ref), } } } -func newNoteDeletedEvent(noteID uuid.UUID) *Event { +func newNoteDeletedEvent(noteID uuid.UUID, ref string) *Event { return &Event{ Name: "note_deleted", - Payload: Payload{"id": noteID}, + Payload: withRef(Payload{"id": noteID}, ref), } } diff --git a/cmd/thoughts/event/retro.go b/cmd/thoughts/event/retro.go index 8724216..d1e58dc 100644 --- a/cmd/thoughts/event/retro.go +++ b/cmd/thoughts/event/retro.go @@ -15,7 +15,7 @@ import ( type retroUpdateRequest struct { Title string `json:"title" validate:"required,min=5,max=255"` Unlisted bool `json:"unlisted"` - MaxVotes int `json:"max_votes" validate:"required,number,min=1,max=15"` + MaxVotes int `json:"max_votes" validate:"min=1,max=15"` Tags []string `json:"tags" validate:"omitempty,max=10,dive,min=1,max=50"` } diff --git a/cmd/thoughts/exporters/markdown.go b/cmd/thoughts/exporters/markdown.go index 054a50c..9c2aa68 100644 --- a/cmd/thoughts/exporters/markdown.go +++ b/cmd/thoughts/exporters/markdown.go @@ -67,7 +67,15 @@ func (e *Exporter) ToMarkdown(ctx context.Context, retro *model.Retro) ([]byte, content = strings.ReplaceAll(content, "\n", "\n> ") buf.WriteString("\n> " + content) - buf.WriteString("\n>\n") + buf.WriteString("\n") + + if note.ImgURL.Valid && note.ImgURL.V != "" { + buf.WriteString(">\n") + buf.WriteString("> ![](" + note.ImgURL.V + ")") + buf.WriteString("\n") + } + + buf.WriteString(">\n") buf.WriteString("> — " + author + "") buf.WriteString("\n") } diff --git a/cmd/thoughts/exporters/markdown_test.go b/cmd/thoughts/exporters/markdown_test.go new file mode 100644 index 0000000..4d3dda9 --- /dev/null +++ b/cmd/thoughts/exporters/markdown_test.go @@ -0,0 +1,121 @@ +package exporters_test + +import ( + "context" + "strings" + "testing" + + "github.com/ellgreen/thoughts/cmd/thoughts/dal" + "github.com/ellgreen/thoughts/cmd/thoughts/exporters" + "github.com/ellgreen/thoughts/cmd/thoughts/model" + "github.com/ellgreen/thoughts/cmd/thoughts/testutil" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +func TestToMarkdown(t *testing.T) { + db := testutil.NewDB(t) + ctx := context.Background() + + retro, err := dal.RetroInsert(ctx, db, "Sprint 42 retro", model.RetroColumns{ + {Title: "Went well", Description: "The good bits"}, + {Title: "Went badly", Description: "The bad bits"}, + }, false) + if err != nil { + t.Fatalf("failed to seed retro: %v", err) + } + + columns := retro.GetColumns() + + author, err := dal.UserGetOrCreate(ctx, db, "Ada") + if err != nil { + t.Fatalf("failed to seed user: %v", err) + } + + if _, err := dal.NoteInsert(ctx, db, retro.ID, author.ID, columns[0].ID, "deploys were quick"); err != nil { + t.Fatalf("failed to seed note: %v", err) + } + + withGif, err := dal.NoteInsert(ctx, db, retro.ID, author.ID, columns[1].ID, "too many meetings") + if err != nil { + t.Fatalf("failed to seed note: %v", err) + } + + if _, err := dal.NoteUpdate( + ctx, db, withGif.ID, withGif.ColumnID, withGif.GroupID, "", "https://example.com/tired.gif", false, + ); err != nil { + t.Fatalf("failed to attach gif: %v", err) + } + + seedTask(t, db, retro.ID, "Ada", "book a smaller room", true) + seedTask(t, db, retro.ID, "Grace", "trim the standup", false) + + out, err := exporters.NewExporter(db).ToMarkdown(ctx, retro) + if err != nil { + t.Fatalf("export failed: %v", err) + } + + got := string(out) + + for _, want := range []string{ + "# Sprint 42 retro", + "## Went well", + "The good bits", + "> deploys were quick", + "> — Ada", + "## Went badly", + "> too many meetings", + "> ![](https://example.com/tired.gif)", + "- [x] Ada: book a smaller room", + "- [ ] Grace: trim the standup", + } { + if !strings.Contains(got, want) { + t.Errorf("export is missing %q\n---\n%s", want, got) + } + } +} + +func TestToMarkdownWithNoContent(t *testing.T) { + db := testutil.NewDB(t) + ctx := context.Background() + + retro, err := dal.RetroInsert(ctx, db, "Empty retro", model.RetroColumns{ + {Title: "Only column", Description: ""}, + {Title: "Second column", Description: ""}, + }, false) + if err != nil { + t.Fatalf("failed to seed retro: %v", err) + } + + out, err := exporters.NewExporter(db).ToMarkdown(ctx, retro) + if err != nil { + t.Fatalf("export failed: %v", err) + } + + got := string(out) + + for _, want := range []string{"# Empty retro", "## Tasks", "## Notes", "## Only column"} { + if !strings.Contains(got, want) { + t.Errorf("export is missing %q\n---\n%s", want, got) + } + } +} + +func seedTask(t *testing.T, db *sqlx.DB, retroID uuid.UUID, who, what string, completed bool) { + t.Helper() + + ctx := context.Background() + + task, err := dal.TaskInsert(ctx, db, retroID, who, what, "2026-09-01") + if err != nil { + t.Fatalf("failed to seed task: %v", err) + } + + if !completed { + return + } + + if _, err := dal.TaskUpdateComplete(ctx, db, task.ID, true); err != nil { + t.Fatalf("failed to complete task: %v", err) + } +} diff --git a/cmd/thoughts/requests/map_test.go b/cmd/thoughts/requests/map_test.go new file mode 100644 index 0000000..1f3d49d --- /dev/null +++ b/cmd/thoughts/requests/map_test.go @@ -0,0 +1,135 @@ +package requests + +import ( + "testing" + + "github.com/google/uuid" +) + +type target struct { + Name string `json:"name" validate:"required,min=2"` + Count int `json:"count"` + ID uuid.UUID `json:"id"` + Tags []string `json:"tags"` + Flag bool `json:"flag"` + Untagged string +} + +func TestFromMapBindsSupportedTypes(t *testing.T) { + id := uuid.New() + + got, err := FromMap[target](map[string]any{ + "name": "a name", + "count": float64(7), // JSON numbers decode as float64 + "id": id.String(), + "tags": []any{"one", "two"}, + "flag": true, + }) + + if err != nil { + t.Fatalf("expected the payload to bind, got %v", err) + } + + if got.Name != "a name" { + t.Errorf("Name = %q, want %q", got.Name, "a name") + } + + if got.Count != 7 { + t.Errorf("Count = %d, want 7", got.Count) + } + + if got.ID != id { + t.Errorf("ID = %s, want %s", got.ID, id) + } + + if len(got.Tags) != 2 || got.Tags[0] != "one" || got.Tags[1] != "two" { + t.Errorf("Tags = %v, want [one two]", got.Tags) + } + + if !got.Flag { + t.Error("Flag = false, want true") + } +} + +func TestFromMapAcceptsIntsAsStrings(t *testing.T) { + got, err := FromMap[target](map[string]any{"name": "ok", "count": "42"}) + if err != nil { + t.Fatalf("expected a numeric string to bind, got %v", err) + } + + if got.Count != 42 { + t.Errorf("Count = %d, want 42", got.Count) + } +} + +func TestFromMapLeavesAbsentKeysAtTheirZeroValue(t *testing.T) { + got, err := FromMap[target](map[string]any{"name": "ok"}) + if err != nil { + t.Fatalf("expected a partial payload to bind, got %v", err) + } + + if got.Count != 0 || got.ID != uuid.Nil || got.Tags != nil || got.Flag { + t.Errorf("absent keys were not left at their zero value: %+v", got) + } +} + +func TestFromMapIgnoresUnknownAndUntaggedFields(t *testing.T) { + got, err := FromMap[target](map[string]any{ + "name": "ok", + "Untagged": "should be ignored", + "nonsense": 1, + }) + + if err != nil { + t.Fatalf("expected unknown keys to be ignored, got %v", err) + } + + if got.Untagged != "" { + t.Errorf("a field with no json tag was bound: %q", got.Untagged) + } +} + +func TestFromMapRejectsBadValues(t *testing.T) { + cases := map[string]map[string]any{ + "unparseable int": {"name": "ok", "count": "not a number"}, + "int from bool": {"name": "ok", "count": true}, + "unparseable uuid": {"name": "ok", "id": "tasks"}, + "uuid from number": {"name": "ok", "id": float64(3)}, + "slice of non-string": {"name": "ok", "tags": []any{1, 2}}, + "slice from string": {"name": "ok", "tags": "one"}, + } + + for name, payload := range cases { + t.Run(name, func(t *testing.T) { + if _, err := FromMap[target](payload); err == nil { + t.Errorf("expected %v to be rejected", payload) + } + }) + } +} + +func TestFromMapRunsValidation(t *testing.T) { + if _, err := FromMap[target](map[string]any{}); err == nil { + t.Error("expected a missing required field to be rejected") + } + + _, err := FromMap[target](map[string]any{"name": "a"}) + if err == nil { + t.Fatal("expected a too-short value to be rejected") + } + + if err.Error() != "Name should contain more than 2 characters" { + t.Errorf("unexpected validation message: %q", err.Error()) + } +} + +func TestFromMapTreatsAnEmptyUUIDStringAsNil(t *testing.T) { + got, err := FromMap[target](map[string]any{"name": "ok", "id": ""}) + if err != nil { + t.Fatalf("expected an empty uuid to bind as nil, got %v", err) + } + + if got.ID != uuid.Nil { + t.Errorf("ID = %s, want the nil UUID", got.ID) + } +} diff --git a/cmd/thoughts/routes.go b/cmd/thoughts/routes.go index 4ec3ad9..5f06301 100644 --- a/cmd/thoughts/routes.go +++ b/cmd/thoughts/routes.go @@ -52,7 +52,14 @@ func applyRoutes( retroRouter := retrosRouter.PathPrefix(fmt.Sprintf("/{id:%s}", uuidRegex)).Subrouter() - retroRouter.Handle("/ws", socket.NewRetroSocketHandler(db)) + // The Vite dev server runs on a different origin, so it needs allowing + // explicitly. A bundled build is always same-origin. + devOrigin := "" + if !ui.IsBundled() { + devOrigin = cfg.UIAddress + } + + retroRouter.Handle("/ws", socket.NewRetroSocketHandler(db, devOrigin)) retroRouter.Handle("/notes", controllers.RetroNotesIndex(db)).Methods(http.MethodGet) retroRouter.Handle("/votes", controllers.VotesIndex(db)).Methods(http.MethodGet) retroRouter.Handle("/votes", controllers.Vote(db)).Methods(http.MethodPost) diff --git a/cmd/thoughts/socket/client.go b/cmd/thoughts/socket/client.go index a0ec07e..1b8c7d9 100644 --- a/cmd/thoughts/socket/client.go +++ b/cmd/thoughts/socket/client.go @@ -21,8 +21,13 @@ const ( // Send pings to peer with this period. Must be less than pongWait. pingPeriod = (pongWait * 9) / 10 - // Maximum message size allowed from peer. - maxMessageSize = 512 + // Maximum message size allowed from peer. A retro_update carrying a + // 255 character title plus ten tags is already well over 512 bytes. + maxMessageSize = 4096 + + // Number of outbound messages buffered per client before it is considered + // stalled and dropped. + sendBufferSize = 64 ) type Client struct { @@ -37,7 +42,7 @@ func NewClient(hub *Hub, conn *websocket.Conn, user *model.User) *Client { hub: hub, conn: conn, user: user, - send: make(chan []byte), + send: make(chan []byte, sendBufferSize), } } @@ -69,8 +74,17 @@ func (c *Client) ReadPump(ctx context.Context) { } } -func (c *Client) Send(message []byte) { - c.send <- message +// Send queues a message for the client. It reports false when the client's +// buffer is full, meaning its write pump has stalled. Blocking here would +// freeze the hub, and with it every broadcast in the retro, so the caller is +// expected to drop the client instead. +func (c *Client) Send(message []byte) bool { + select { + case c.send <- message: + return true + default: + return false + } } func (c *Client) WritePump() { diff --git a/cmd/thoughts/socket/hub.go b/cmd/thoughts/socket/hub.go index ee9d617..f1ad67c 100644 --- a/cmd/thoughts/socket/hub.go +++ b/cmd/thoughts/socket/hub.go @@ -1,6 +1,8 @@ package socket import ( + "log/slog" + "github.com/ellgreen/thoughts/cmd/thoughts/event" ) @@ -42,20 +44,40 @@ func (h *Hub) Run() { h.connectionInfoSync() case event := <-h.broker.Listen(): - for client := range h.clients { - client.Send(event.ToJSON()) - } + h.broadcast(event.ToJSON()) case userDependentEvent := <-h.broker.ListenUserDependent(): for client := range h.clients { evt := userDependentEvent(client.user) - if evt != nil { - client.Send(evt.ToJSON()) + if evt == nil { + continue + } + + if !client.Send(evt.ToJSON()) { + h.drop(client) } } } } } +func (h *Hub) broadcast(data []byte) { + for client := range h.clients { + if !client.Send(data) { + h.drop(client) + } + } +} + +// drop removes a client whose send buffer has filled up. Its read pump will +// also unregister once the connection tears down; Run's unregister case +// tolerates a client that is already gone. +func (h *Hub) drop(client *Client) { + slog.Warn("dropping stalled websocket client", "user", client.user.Name) + + delete(h.clients, client) + close(client.send) +} + func (h *Hub) userNames() []string { users := make([]string, 0, len(h.clients)) for client := range h.clients { @@ -66,9 +88,5 @@ func (h *Hub) userNames() []string { } func (h *Hub) connectionInfoSync() { - data := event.NewConnectionInfoEvent(h.userNames()).ToJSON() - - for client := range h.clients { - client.Send(data) - } + h.broadcast(event.NewConnectionInfoEvent(h.userNames()).ToJSON()) } diff --git a/cmd/thoughts/socket/socket.go b/cmd/thoughts/socket/socket.go index 94392fb..a3e1e31 100644 --- a/cmd/thoughts/socket/socket.go +++ b/cmd/thoughts/socket/socket.go @@ -4,6 +4,8 @@ import ( "context" "log/slog" "net/http" + "net/url" + "strings" "sync" "github.com/ellgreen/thoughts/cmd/thoughts/auth" @@ -14,13 +16,14 @@ import ( "github.com/jmoiron/sqlx" ) -func NewRetroSocketHandler(db *sqlx.DB) http.HandlerFunc { +// NewRetroSocketHandler serves the per-retro websocket. devOrigin is an extra +// origin to accept alongside the request's own host, used to let the Vite dev +// server connect; it should be empty in production. +func NewRetroSocketHandler(db *sqlx.DB, devOrigin string) http.HandlerFunc { upgrader := websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { - return true //TODO: Only do this in development - }, + CheckOrigin: checkOrigin(devOrigin), } var hubs sync.Map @@ -71,3 +74,36 @@ func NewRetroSocketHandler(db *sqlx.DB) http.HandlerFunc { go client.WritePump() } } + +// checkOrigin rejects cross-site websocket connections. Sessions are cookie +// based, so accepting any origin would let any page a logged-in user happens to +// visit drive their retros. Requests with no Origin header are not browsers and +// are allowed through. +func checkOrigin(devOrigin string) func(*http.Request) bool { + return func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + + originURL, err := url.Parse(origin) + if err != nil { + slog.Warn("rejecting websocket with unparseable origin", "origin", origin) + return false + } + + if strings.EqualFold(originURL.Host, r.Host) { + return true + } + + if devOrigin != "" { + if devURL, err := url.Parse(devOrigin); err == nil && strings.EqualFold(originURL.Host, devURL.Host) { + return true + } + } + + slog.Warn("rejecting websocket from disallowed origin", "origin", origin, "host", r.Host) + + return false + } +} diff --git a/cmd/thoughts/testutil/db.go b/cmd/thoughts/testutil/db.go new file mode 100644 index 0000000..ff421cb --- /dev/null +++ b/cmd/thoughts/testutil/db.go @@ -0,0 +1,72 @@ +// Package testutil provides throwaway databases for tests. +package testutil + +import ( + "fmt" + "path/filepath" + "testing" + + "github.com/ellgreen/thoughts/migrations" + "github.com/jmoiron/sqlx" + "github.com/pressly/goose/v3" + _ "modernc.org/sqlite" +) + +// NewDB opens a throwaway SQLite database with every migration applied. It is +// removed with the test's temp directory. +func NewDB(t *testing.T) *sqlx.DB { + t.Helper() + + db := open(t) + + if err := goose.Up(db.DB, "."); err != nil { + t.Fatalf("failed to migrate test database: %v", err) + } + + return db +} + +// NewDBAt opens a throwaway database migrated only as far as the given goose +// version, so a migration can be exercised against data that predates it. +func NewDBAt(t *testing.T, version int64) *sqlx.DB { + t.Helper() + + db := open(t) + + if err := goose.UpTo(db.DB, ".", version); err != nil { + t.Fatalf("failed to migrate test database to %d: %v", version, err) + } + + return db +} + +// MigrateUp applies any migrations still outstanding on db. +func MigrateUp(t *testing.T, db *sqlx.DB) { + t.Helper() + + if err := goose.Up(db.DB, "."); err != nil { + t.Fatalf("failed to migrate test database: %v", err) + } +} + +func open(t *testing.T) *sqlx.DB { + t.Helper() + + goose.SetBaseFS(migrations.Embedded) + goose.SetLogger(goose.NopLogger()) + + if err := goose.SetDialect("sqlite3"); err != nil { + t.Fatalf("failed to set goose dialect: %v", err) + } + + dsn := fmt.Sprintf("file:%s?_foreign_keys=on", filepath.Join(t.TempDir(), "test.sqlite")) + + db, err := sqlx.Open("sqlite", dsn) + if err != nil { + t.Fatalf("failed to open test database: %v", err) + } + + t.Cleanup(func() { db.Close() }) + + return db +} diff --git a/cmd/thoughts/util/obfuscate.go b/cmd/thoughts/util/obfuscate.go index 9db85d8..6f5fe62 100644 --- a/cmd/thoughts/util/obfuscate.go +++ b/cmd/thoughts/util/obfuscate.go @@ -12,6 +12,9 @@ var ( alphaUpperCharSet = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ") ) +// Obfuscate scrambles a string while preserving its length and the character +// class of each rune, so other people's notes read as noise of the right shape +// during the brainstorm stage. func Obfuscate(v string) string { var obfuscated strings.Builder @@ -24,15 +27,15 @@ func Obfuscate(v string) string { func obfuscateChar(c rune) rune { if slices.Contains(alphaLowerCharSet, c) { - return alphaLowerCharSet[rand.Intn(len(alphaLowerCharSet)-1)] + return alphaLowerCharSet[rand.Intn(len(alphaLowerCharSet))] } if slices.Contains(alphaUpperCharSet, c) { - return alphaUpperCharSet[rand.Intn(len(alphaUpperCharSet)-1)] + return alphaUpperCharSet[rand.Intn(len(alphaUpperCharSet))] } if slices.Contains(numCharSet, c) { - return numCharSet[rand.Intn(len(numCharSet)-1)] + return numCharSet[rand.Intn(len(numCharSet))] } return c diff --git a/cmd/thoughts/util/obfuscate_test.go b/cmd/thoughts/util/obfuscate_test.go new file mode 100644 index 0000000..29d282a --- /dev/null +++ b/cmd/thoughts/util/obfuscate_test.go @@ -0,0 +1,75 @@ +package util + +import ( + "strings" + "testing" + "unicode" +) + +func TestObfuscatePreservesShape(t *testing.T) { + input := "Sprint 9 went well - mostly! (a few blockers)" + got := Obfuscate(input) + + if len([]rune(got)) != len([]rune(input)) { + t.Fatalf("length changed: %d -> %d", len([]rune(input)), len([]rune(got))) + } + + for i, in := range []rune(input) { + out := []rune(got)[i] + + switch { + case unicode.IsLower(in) && in <= unicode.MaxASCII: + if !unicode.IsLower(out) { + t.Errorf("position %d: %q became %q, expected a lowercase letter", i, in, out) + } + case unicode.IsUpper(in) && in <= unicode.MaxASCII: + if !unicode.IsUpper(out) { + t.Errorf("position %d: %q became %q, expected an uppercase letter", i, in, out) + } + case unicode.IsDigit(in): + if !unicode.IsDigit(out) { + t.Errorf("position %d: %q became %q, expected a digit", i, in, out) + } + default: + // Punctuation and spacing are left alone so the text keeps its rhythm. + if out != in { + t.Errorf("position %d: %q became %q, expected it to be left alone", i, in, out) + } + } + } +} + +func TestObfuscateCanEmitEveryCharacterInAClass(t *testing.T) { + // A rand.Intn(len-1) off-by-one used to make the last rune of each class + // unreachable, which is a subtle tell that text has been scrambled. + cases := map[string]struct { + input string + want rune + }{ + "lowercase": {strings.Repeat("a", 400), 'z'}, + "uppercase": {strings.Repeat("A", 400), 'Z'}, + "digits": {strings.Repeat("1", 400), '9'}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if !strings.ContainsRune(Obfuscate(tc.input), tc.want) { + t.Errorf("%q never appeared in 400 obfuscated runes", tc.want) + } + }) + } +} + +func TestObfuscateHidesTheOriginal(t *testing.T) { + input := "the quick brown fox jumps over the lazy dog and keeps on running" + + if Obfuscate(input) == input { + t.Error("obfuscated text came back unchanged") + } +} + +func TestObfuscateHandlesEmptyInput(t *testing.T) { + if got := Obfuscate(""); got != "" { + t.Errorf("expected an empty string, got %q", got) + } +} diff --git a/migrations/20260810120000_dedupe_users_by_name.sql b/migrations/20260810120000_dedupe_users_by_name.sql new file mode 100644 index 0000000..5dbb315 --- /dev/null +++ b/migrations/20260810120000_dedupe_users_by_name.sql @@ -0,0 +1,70 @@ +-- Logging in used to insert a fresh user row every time, so the same person +-- accumulated one identity per session and stopped owning their own notes. +-- Collapse the duplicates onto the earliest row for each name, then make the +-- name unique so it cannot happen again. + +-- +goose Up +-- +goose StatementBegin +delete from votes +where rowid in ( + select rowid from ( + select + v.rowid as rowid, + row_number() over ( + partition by + v.retro_id, + v.group_id, + ( + select m.id from users m + where m.name = (select o.name from users o where o.id = v.user_id) + order by m.created_at asc, m.id asc + limit 1 + ) + order by v.rowid asc + ) as rn + from votes v + ) + where rn > 1 +); +-- +goose StatementEnd + +-- +goose StatementBegin +update votes set user_id = ( + select m.id from users m + where m.name = (select o.name from users o where o.id = votes.user_id) + order by m.created_at asc, m.id asc + limit 1 +) +where exists (select 1 from users o where o.id = votes.user_id); +-- +goose StatementEnd + +-- +goose StatementBegin +update notes set user_id = ( + select m.id from users m + where m.name = (select o.name from users o where o.id = notes.user_id) + order by m.created_at asc, m.id asc + limit 1 +) +where exists (select 1 from users o where o.id = notes.user_id); +-- +goose StatementEnd + +-- +goose StatementBegin +delete from users +where id <> ( + select m.id from users m + where m.name = users.name + order by m.created_at asc, m.id asc + limit 1 +); +-- +goose StatementEnd + +-- +goose StatementBegin +create unique index unique_user_name on users(name); +-- +goose StatementEnd + +-- +goose Down +-- The collapsed duplicate identities cannot be restored; only the constraint +-- that prevents them coming back is reversible. +-- +goose StatementBegin +drop index if exists unique_user_name; +-- +goose StatementEnd diff --git a/ui/README.md b/ui/README.md deleted file mode 100644 index 74872fd..0000000 --- a/ui/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default tseslint.config({ - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) -``` - -- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` -- Optionally add `...tseslint.configs.stylisticTypeChecked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: - -```js -// eslint.config.js -import react from 'eslint-plugin-react' - -export default tseslint.config({ - // Set the react version - settings: { react: { version: '18.3' } }, - plugins: { - // Add the react plugin - react, - }, - rules: { - // other rules... - // Enable its recommended rules - ...react.configs.recommended.rules, - ...react.configs['jsx-runtime'].rules, - }, -}) -``` diff --git a/ui/package.json b/ui/package.json index 72f8417..f44ecee 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,6 +8,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "vitest run", "preview": "vite preview" }, "dependencies": { @@ -62,6 +63,7 @@ "tw-animate-css": "^1.4.0", "typescript": "~5.6.3", "typescript-eslint": "^8.57.1", - "vite": "^6.4.1" + "vite": "^6.4.1", + "vitest": "^4.1.10" } } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 77e70ae..3d5eecd 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -159,6 +159,9 @@ importers: vite: specifier: ^6.4.1 version: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@22.19.15)(msw@2.12.14(@types/node@22.19.15)(typescript@5.6.3))(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) packages: @@ -1693,6 +1696,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/core-darwin-arm64@1.15.18': resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} engines: {node: '>=10'} @@ -1923,6 +1929,12 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -2010,6 +2022,35 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2069,6 +2110,10 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -2141,6 +2186,10 @@ packages: caniuse-lite@1.0.30001780: resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2373,6 +2422,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2460,6 +2512,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2484,6 +2539,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.3.1: resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} engines: {node: '>= 16'} @@ -3102,6 +3161,10 @@ packages: resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} engines: {node: '>= 10'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -3431,6 +3494,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -3459,10 +3525,16 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -3530,10 +3602,21 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + tldts-core@7.0.27: resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} @@ -3708,6 +3791,47 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -3725,6 +3849,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -5216,6 +5345,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@swc/core-darwin-arm64@1.15.18': optional: true @@ -5426,6 +5557,13 @@ snapshots: minimatch: 10.2.4 path-browserify: 1.0.1 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -5545,6 +5683,48 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(msw@2.12.14(@types/node@22.19.15)(typescript@5.6.3))(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.14(@types/node@22.19.15)(typescript@5.6.3) + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -5597,6 +5777,8 @@ snapshots: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -5683,6 +5865,8 @@ snapshots: caniuse-lite@1.0.30001780: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5867,6 +6051,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -6020,6 +6206,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} @@ -6057,6 +6247,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expect-type@1.4.0: {} + express-rate-limit@8.3.1(express@5.2.1): dependencies: express: 5.2.1 @@ -6584,6 +6776,8 @@ snapshots: object-treeify@1.1.33: {} + obug@2.1.4: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -7032,6 +7226,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -7049,8 +7245,12 @@ snapshots: source-map@0.7.6: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.2.0: {} + stdin-discarder@0.2.2: {} strict-event-emitter@0.5.1: {} @@ -7105,11 +7305,17 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.1: {} + tldts-core@7.0.27: {} tldts@7.0.27: @@ -7246,6 +7452,33 @@ snapshots: lightningcss: 1.32.0 tsx: 4.21.0 + vitest@4.1.10(@types/node@22.19.15)(msw@2.12.14(@types/node@22.19.15)(typescript@5.6.3))(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(msw@2.12.14(@types/node@22.19.15)(typescript@5.6.3))(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.1 + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + transitivePeerDependencies: + - msw + web-streams-polyfill@3.3.3: {} webpack-virtual-modules@0.6.2: {} @@ -7258,6 +7491,11 @@ snapshots: dependencies: isexe: 3.1.5 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@6.2.0: diff --git a/ui/src/components/container.tsx b/ui/src/components/container.tsx new file mode 100644 index 0000000..882c403 --- /dev/null +++ b/ui/src/components/container.tsx @@ -0,0 +1,21 @@ +import { twMerge } from "tailwind-merge"; + +/** + * Shared page gutter. The nav is sticky and sits above every page, so it and + * the content beneath it have to agree on width or they visibly misalign on + * wide screens. + */ +export default function Container({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} diff --git a/ui/src/components/nav.tsx b/ui/src/components/nav.tsx index ee1c4ff..1896716 100644 --- a/ui/src/components/nav.tsx +++ b/ui/src/components/nav.tsx @@ -1,3 +1,4 @@ +import Container from "@/components/container"; import { useAuth } from "@/hooks/use-auth"; import useTheme, { Theme } from "@/hooks/use-theme"; import { Link, useNavigate } from "@tanstack/react-router"; @@ -29,7 +30,7 @@ export default function Nav() { return (
-
+ -
+
); } diff --git a/ui/src/events/index.ts b/ui/src/events/index.ts index f22f57e..9888042 100644 --- a/ui/src/events/index.ts +++ b/ui/src/events/index.ts @@ -20,6 +20,9 @@ export interface PayloadStatusUpdate { export interface PayloadError { message: string; + /** Echoed back from the failed request so the client can roll back the one + * optimistic update that failed rather than every in-flight one. */ + ref?: string; } export type PayloadStatusUpdated = PayloadStatusUpdate; @@ -35,6 +38,13 @@ export interface PayloadNoteUpdate { column_id?: string; group_id?: string; img_url?: string; + remove_img_url?: boolean; +} + +/** Correlation id attached to optimistic mutations and echoed back by the + * server on both confirmation and failure. */ +export interface Ref { + ref: string; } export interface PayloadConnectionInfo { diff --git a/ui/src/hooks/use-notes.test.ts b/ui/src/hooks/use-notes.test.ts new file mode 100644 index 0000000..0248889 --- /dev/null +++ b/ui/src/hooks/use-notes.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import { SocketEvent } from "@/events"; +import { Note } from "@/types"; +import { groupNotes, initialState, notesReducer, NotesState } from "./use-notes"; + +function note(overrides: Partial = {}): Note { + return { + id: "note-1", + created_by_me: true, + column_id: "column-1", + group_id: "group-1", + content: "a thought", + img_url: "", + ...overrides, + }; +} + +/** Replays a sequence of events, the way the hook does. */ +function replay(...events: SocketEvent[]): NotesState { + return events.reduce(notesReducer, initialState); +} + +describe("notesReducer", () => { + it("replaces everything on note_index", () => { + const state = replay( + { name: "note_create", payload: { column_id: "c", content: "x", ref: "r" } }, + { name: "note_index", payload: [note({ id: "server-1" })] }, + ); + + expect(state.notes.map((n) => n.id)).toEqual(["server-1"]); + expect(state.rollbacks).toEqual({}); + }); + + it("shows a created note immediately, keyed by its ref", () => { + const state = replay({ + name: "note_create", + payload: { column_id: "column-1", content: "optimistic", ref: "ref-1" }, + }); + + expect(state.notes).toHaveLength(1); + expect(state.notes[0].id).toBe("ref-1"); + expect(state.notes[0].content).toBe("optimistic"); + expect(state.rollbacks).toEqual({ "ref-1": null }); + }); + + it("swaps the placeholder for the confirmed note", () => { + const state = replay( + { name: "note_create", payload: { column_id: "column-1", content: "optimistic", ref: "ref-1" } }, + { name: "note_created", payload: { ...note({ id: "server-1" }), ref: "ref-1" } }, + ); + + expect(state.notes.map((n) => n.id)).toEqual(["server-1"]); + expect(state.rollbacks).toEqual({}); + }); + + it("does not leak the ref onto the stored note", () => { + const state = replay({ + name: "note_created", + payload: { ...note({ id: "server-1" }), ref: "ref-1" }, + }); + + expect(state.notes[0]).not.toHaveProperty("ref"); + }); + + it("rolls back only the create that failed", () => { + const state = replay( + { name: "note_create", payload: { column_id: "column-1", content: "first", ref: "ref-1" } }, + { name: "note_create", payload: { column_id: "column-1", content: "second", ref: "ref-2" } }, + { name: "error", payload: { message: "nope", ref: "ref-1" } }, + ); + + // The unrelated in-flight create survives - the old single-sentinel + // placeholder wiped out every pending note at once. + expect(state.notes.map((n) => n.content)).toEqual(["second"]); + expect(state.rollbacks).toEqual({ "ref-2": null }); + }); + + it("restores the previous content when an edit fails", () => { + const state = replay( + { name: "note_index", payload: [note({ id: "n1", content: "original" })] }, + { name: "note_update", payload: { id: "n1", content: "edited", ref: "ref-1" } }, + { name: "error", payload: { message: "nope", ref: "ref-1" } }, + ); + + expect(state.notes[0].content).toBe("original"); + expect(state.rollbacks).toEqual({}); + }); + + it("puts a note back when a delete fails", () => { + const state = replay( + { name: "note_index", payload: [note({ id: "n1" })] }, + { name: "note_delete", payload: { id: "n1", ref: "ref-1" } }, + { name: "error", payload: { message: "nope", ref: "ref-1" } }, + ); + + expect(state.notes.map((n) => n.id)).toEqual(["n1"]); + }); + + it("ignores errors that carry no ref", () => { + const state = replay( + { name: "note_create", payload: { column_id: "column-1", content: "pending", ref: "ref-1" } }, + { name: "error", payload: { message: "something unrelated" } }, + ); + + expect(state.notes).toHaveLength(1); + }); + + it("applies a move optimistically", () => { + const state = replay( + { name: "note_index", payload: [note({ id: "n1" })] }, + { + name: "note_update", + payload: { id: "n1", column_id: "column-2", group_id: "group-2", ref: "ref-1" }, + }, + ); + + expect(state.notes[0].column_id).toBe("column-2"); + expect(state.notes[0].group_id).toBe("group-2"); + }); + + it("ungroups a note when an update omits the group, matching the server", () => { + const state = replay( + { name: "note_index", payload: [note({ id: "n1", group_id: "shared" })] }, + { name: "note_update", payload: { id: "n1", content: "edited", ref: "ref-1" } }, + ); + + expect(state.notes[0].group_id).toBe("ungrouped-n1"); + }); + + it("clears the image when remove_img_url is sent", () => { + const state = replay( + { name: "note_index", payload: [note({ id: "n1", img_url: "https://example.com/a.gif" })] }, + { name: "note_update", payload: { id: "n1", remove_img_url: true, ref: "ref-1" } }, + ); + + expect(state.notes[0].img_url).toBe(""); + }); + + it("ignores an update for a note it has never seen", () => { + const state = replay({ + name: "note_update", + payload: { id: "ghost", content: "x", ref: "ref-1" }, + }); + + expect(state).toBe(initialState); + }); + + it("accepts confirmations for notes created by other people", () => { + const state = replay( + { name: "note_index", payload: [] }, + { name: "note_created", payload: note({ id: "theirs", created_by_me: false }) }, + { name: "note_updated", payload: note({ id: "theirs", created_by_me: false, content: "changed" }) }, + ); + + expect(state.notes).toHaveLength(1); + expect(state.notes[0].content).toBe("changed"); + }); + + it("removes a note on note_deleted", () => { + const state = replay( + { name: "note_index", payload: [note({ id: "n1" }), note({ id: "n2" })] }, + { name: "note_deleted", payload: { id: "n1" } }, + ); + + expect(state.notes.map((n) => n.id)).toEqual(["n2"]); + }); + + it("leaves state untouched for events it does not handle", () => { + const state = replay({ name: "connection_info", payload: { users: [] } }); + + expect(state).toBe(initialState); + }); +}); + +describe("groupNotes", () => { + it("nests notes by column and then group", () => { + const grouped = groupNotes([ + note({ id: "a", column_id: "c1", group_id: "g1" }), + note({ id: "b", column_id: "c1", group_id: "g1" }), + note({ id: "c", column_id: "c1", group_id: "g2" }), + note({ id: "d", column_id: "c2", group_id: "g3" }), + ]); + + expect(Object.keys(grouped)).toEqual(["c1", "c2"]); + expect(grouped.c1.g1.map((n) => n.id)).toEqual(["a", "b"]); + expect(grouped.c1.g2.map((n) => n.id)).toEqual(["c"]); + expect(grouped.c2.g3.map((n) => n.id)).toEqual(["d"]); + }); + + it("returns nothing for no notes", () => { + expect(groupNotes([])).toEqual({}); + }); +}); diff --git a/ui/src/hooks/use-notes.ts b/ui/src/hooks/use-notes.ts index 6acf5c4..0fb12cb 100644 --- a/ui/src/hooks/use-notes.ts +++ b/ui/src/hooks/use-notes.ts @@ -1,67 +1,168 @@ -import { PayloadNoteCreate, PayloadNoteUpdate, SocketEvent } from "@/events"; +import { + PayloadError, + PayloadNoteCreate, + PayloadNoteUpdate, + Ref, + SocketEvent, +} from "@/events"; import { api } from "@/lib/api"; import { Note } from "@/types"; import { useCallback, useEffect, useMemo, useReducer } from "react"; import useRetro from "./use-retro"; -function notesReducer(notes: Note[], event: SocketEvent): Note[] { +/** Events we apply locally before the server has confirmed them. */ +const optimisticEvents = new Set(["note_create", "note_update", "note_delete"]); + +interface NotesState { + notes: Note[]; + /** + * Keyed by the ref of an unconfirmed mutation, holding what to restore if the + * server rejects it. `null` means "this was a create, so drop the note whose + * id is the ref". + */ + rollbacks: Record; +} + +const initialState: NotesState = { notes: [], rollbacks: {} }; + +function upsert(notes: Note[], note: Note): Note[] { + return notes.some((n) => n.id === note.id) + ? notes.map((n) => (n.id === note.id ? note : n)) + : [...notes, note]; +} + +function forget( + rollbacks: NotesState["rollbacks"], + ref?: string, +): NotesState["rollbacks"] { + if (!ref || !(ref in rollbacks)) return rollbacks; + + const next = { ...rollbacks }; + delete next[ref]; + + return next; +} + +/** Strips the correlation id the server echoes back alongside the note. */ +function toNote(payload: Note & Partial): Note { + const note = { ...payload }; + delete note.ref; + + return note; +} + +function notesReducer(state: NotesState, event: SocketEvent): NotesState { switch (event.name) { - case "error": { - return notes.filter((note) => note.id !== "placeholder"); - } case "note_index": { - return event.payload as Note[]; + return { notes: event.payload as Note[], rollbacks: {} }; } + + // Optimistic — applied locally the moment the user acts. case "note_create": { - const payload = event.payload as PayloadNoteCreate; - return [ - ...notes, - { - id: "placeholder", - created_by_me: true, - content: payload.content, - column_id: payload.column_id, - group_id: "placeholder", - img_url: "", - }, - ]; + const payload = event.payload as PayloadNoteCreate & Ref; + + return { + notes: [ + ...state.notes, + { + id: payload.ref, + created_by_me: true, + content: payload.content, + column_id: payload.column_id, + group_id: payload.ref, + img_url: "", + }, + ], + rollbacks: { ...state.rollbacks, [payload.ref]: null }, + }; } - case "note_created": { - const payload = event.payload as Note; - return [ - ...notes.filter( - (note) => note.id !== "placeholder" && note.id !== payload.id, + + case "note_update": { + const payload = event.payload as PayloadNoteUpdate & Ref; + const before = state.notes.find((note) => note.id === payload.id); + if (!before) return state; + + return { + notes: state.notes.map((note) => + note.id === payload.id + ? { + ...note, + content: payload.content ?? note.content, + column_id: payload.column_id ?? note.column_id, + // Mirrors the server: an update without a group_id drops the + // note back into a group of its own. + group_id: payload.group_id ?? `ungrouped-${note.id}`, + img_url: payload.remove_img_url + ? "" + : (payload.img_url ?? note.img_url), + } + : note, ), - payload, - ]; + rollbacks: { ...state.rollbacks, [payload.ref]: before }, + }; } - case "note_update": { - const payload = event.payload as PayloadNoteUpdate; - return notes.map((note) => - note.id === payload.id - ? { - ...note, - content: payload.content ?? note.content, - column_id: payload.column_id ?? note.column_id, - group_id: payload.group_id ?? `placeholder-${note.id}`, - } - : note, - ); + + case "note_delete": { + const payload = event.payload as { id: string } & Ref; + const before = state.notes.find((note) => note.id === payload.id); + if (!before) return state; + + return { + notes: state.notes.filter((note) => note.id !== payload.id), + rollbacks: { ...state.rollbacks, [payload.ref]: before }, + }; } - case "note_updated": { - const payload = event.payload as Note; - return notes.map((note) => (note.id === payload.id ? payload : note)); + + // Confirmations broadcast by the server. + case "note_created": { + const payload = event.payload as Note & Partial; + + // Our own placeholder carries the ref as its id; swap it for the real note. + const notes = payload.ref + ? state.notes.filter((note) => note.id !== payload.ref) + : state.notes; + + return { + notes: upsert(notes, toNote(payload)), + rollbacks: forget(state.rollbacks, payload.ref), + }; } - case "note_delete": { - const payload = event.payload as { id: string }; - return notes.filter((note) => note.id !== payload.id); + + case "note_updated": { + const payload = event.payload as Note & Partial; + + return { + notes: upsert(state.notes, toNote(payload)), + rollbacks: forget(state.rollbacks, payload.ref), + }; } + case "note_deleted": { - const payload = event.payload as { id: string }; - return notes.filter((note) => note.id !== payload.id); + const payload = event.payload as { id: string } & Partial; + + return { + notes: state.notes.filter((note) => note.id !== payload.id), + rollbacks: forget(state.rollbacks, payload.ref), + }; } + + case "error": { + const { ref } = event.payload as PayloadError; + if (!ref || !(ref in state.rollbacks)) return state; + + const before = state.rollbacks[ref]; + + return { + notes: + before === null + ? state.notes.filter((note) => note.id !== ref) + : upsert(state.notes, before), + rollbacks: forget(state.rollbacks, ref), + }; + } + default: - return notes; + return state; } } @@ -89,19 +190,32 @@ function groupNotes(notes: Note[]) { return groups; } +export { groupNotes, notesReducer, initialState }; +export type { NotesState }; + export function useNotes() { const { retro, socket: { lastJsonMessage, sendJsonMessage }, } = useRetro(); - const [notes, dispatch] = useReducer(notesReducer, []); + const [state, dispatch] = useReducer(notesReducer, initialState); + + const groupedNotes = useMemo(() => groupNotes(state.notes), [state.notes]); - const groupedNotes = useMemo(() => groupNotes(notes), [notes]); + const dispatchAndSend = useCallback( + (event: SocketEvent) => { + const tracked: SocketEvent = optimisticEvents.has(event.name) + ? { + ...event, + payload: { ...event.payload, ref: crypto.randomUUID() }, + } + : event; - const dispatchAndSend = useCallback((event: SocketEvent) => { - dispatch(event); - sendJsonMessage(event); - }, []); + dispatch(tracked); + sendJsonMessage(tracked); + }, + [sendJsonMessage], + ); useEffect(() => { if (!lastJsonMessage) return; @@ -115,5 +229,5 @@ export function useNotes() { }); }, [retro.id]); - return { notes, groupedNotes, dispatch: dispatchAndSend }; + return { notes: state.notes, groupedNotes, dispatch: dispatchAndSend }; } diff --git a/ui/src/routes/_auth.index.tsx b/ui/src/routes/_auth.index.tsx index 8990152..f2a7943 100644 --- a/ui/src/routes/_auth.index.tsx +++ b/ui/src/routes/_auth.index.tsx @@ -1,3 +1,4 @@ +import Container from "@/components/container"; import Creator from "@/components/retro/creator"; import Hero from "@/components/retro/hero"; import List from "@/components/retro/list"; @@ -29,13 +30,13 @@ function RouteComponent() { const { retros, stats } = Route.useLoaderData(); return ( -
+
-
+ ); } diff --git a/ui/src/routes/_auth.retros.$retroId.tsx b/ui/src/routes/_auth.retros.$retroId.tsx index 7170296..e72bce8 100644 --- a/ui/src/routes/_auth.retros.$retroId.tsx +++ b/ui/src/routes/_auth.retros.$retroId.tsx @@ -1,3 +1,4 @@ +import Container from "@/components/container"; import Board from "@/components/retro/board"; import { RetroContext } from "@/hooks/use-retro"; import { api } from "@/lib/api"; @@ -27,9 +28,9 @@ export default function RouteComponent() { return ( -
+ -
+
); } diff --git a/ui/src/routes/_auth.tags.$tag.tsx b/ui/src/routes/_auth.tags.$tag.tsx index 5d54596..4e3b98e 100644 --- a/ui/src/routes/_auth.tags.$tag.tsx +++ b/ui/src/routes/_auth.tags.$tag.tsx @@ -1,3 +1,4 @@ +import Container from "@/components/container"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -43,7 +44,7 @@ function RouteComponent() { } return ( -
+ {/* Header */}
@@ -122,7 +123,7 @@ function RouteComponent() { )} -
+
); } diff --git a/ui/src/routes/login.tsx b/ui/src/routes/login.tsx index d9f8f41..a51c636 100644 --- a/ui/src/routes/login.tsx +++ b/ui/src/routes/login.tsx @@ -38,7 +38,7 @@ function RouteComponent() { } const schema = z.object({ - name: z.string().min(2).max(20), + name: z.string().trim().min(2).max(32), }); function LoginForm() { diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts new file mode 100644 index 0000000..c0eb35e --- /dev/null +++ b/ui/vitest.config.ts @@ -0,0 +1,16 @@ +import path from "path"; +import { defineConfig } from "vitest/config"; + +// Kept separate from vite.config.ts: the router plugin regenerates the route +// tree on start, which tests neither need nor should trigger. +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); From 52de052302db899ef3e1f2eba4435f50f8aa3b20 Mon Sep 17 00:00:00 2001 From: Ellis Green Date: Mon, 10 Aug 2026 23:29:16 +0100 Subject: [PATCH 02/17] Replace the retired Tenor API with pluggable GIF providers Google shut the Tenor API down on 2026-06-30, so GIF search had been failing on every request. Giphy no longer has a free tier either, so the gif package is now a small registry rather than one hard-wired vendor. - Klipy is the new default: free for life, and the closest thing to a drop-in Tenor replacement. Giphy ships alongside it for anyone holding a key. - THOUGHTS_GIF_PROVIDER / THOUGHTS_GIF_API_KEY replace THOUGHTS_TENOR_API_KEY, which now only logs a warning explaining the shutdown. - Providers gained trending and pagination, so the picker has something to show before anyone types and can keep loading as you scroll. - Outbound calls now have an 8s timeout; http.DefaultClient had none, so a hung provider could pin a request goroutine indefinitely. - GET /api/gifs replaces POST, since it is a read. Search is no longer required for the feature to work. The picker has a second tab where you paste any https image link and see a live preview before attaching it, so images work with zero configuration. Because those URLs are now genuinely user-supplied, img_url must be https and under 2048 characters. The picker itself was a bare form with nine results, no loading state and no error handling. It now debounces as you type, loads trending on open, reserves each tile's space so the grid does not reflow as images stream in, scrolls infinitely, and has real empty, error and loading states. Also fixes THOUGHTS_DATA in the Dockerfile: the config key is data_path, so that variable was never read and the database landed in the container's working directory rather than the mounted volume. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 11 + .github/copilot-instructions.md | 7 +- Dockerfile | 9 +- README.md | 27 +- cmd/thoughts/config.go | 4 + cmd/thoughts/controllers/gifs.go | 43 +++- cmd/thoughts/event/notes.go | 14 +- cmd/thoughts/gif/enabled.go | 23 -- cmd/thoughts/gif/gif_test.go | 275 +++++++++++++++++++++ cmd/thoughts/gif/giphy.go | 148 +++++++++++ cmd/thoughts/gif/klipy.go | 154 ++++++++++++ cmd/thoughts/gif/provider.go | 39 ++- cmd/thoughts/gif/registry.go | 81 ++++++ cmd/thoughts/gif/tenor.go | 86 ------- cmd/thoughts/main.go | 13 +- cmd/thoughts/requests/requests.go | 6 + cmd/thoughts/resources/retro.go | 4 +- cmd/thoughts/routes.go | 2 +- ui/src/components/retro/brainstorm.tsx | 19 +- ui/src/components/retro/gif-dialog.tsx | 330 +++++++++++++++++++------ ui/src/components/ui/tabs.tsx | 54 ++++ ui/src/components/ui/tooltip.tsx | 56 +++++ ui/src/hooks/use-gif-search.ts | 78 ++++++ ui/src/types.ts | 3 +- 24 files changed, 1250 insertions(+), 236 deletions(-) create mode 100644 .claude/launch.json delete mode 100644 cmd/thoughts/gif/enabled.go create mode 100644 cmd/thoughts/gif/gif_test.go create mode 100644 cmd/thoughts/gif/giphy.go create mode 100644 cmd/thoughts/gif/klipy.go create mode 100644 cmd/thoughts/gif/registry.go delete mode 100644 cmd/thoughts/gif/tenor.go create mode 100644 ui/src/components/ui/tabs.tsx create mode 100644 ui/src/components/ui/tooltip.tsx create mode 100644 ui/src/hooks/use-gif-search.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..6234f2e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "thoughts", + "runtimeExecutable": "./build/thoughts", + "runtimeArgs": [], + "port": 3000 + } + ] +} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b6570be..78659b6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -58,7 +58,7 @@ cmd/thoughts/ ├── session/ # Gorilla session management ├── auth/ # Auth middleware (checks session cookie) ├── ai/ # OpenAI integration via langchaingo -└── gif/ # Tenor API integration +└── gif/ # GIF search behind a swappable provider (Klipy, Giphy) ``` **Request flow:** HTTP → `routes.go` → auth middleware → controller → DAL → SQLite. Real-time updates flow via `event.Broker` → WebSocket → frontend. @@ -74,9 +74,10 @@ All config is via environment variables with the `THOUGHTS_` prefix, managed by | Variable | Default | Notes | |---|---|---| | `THOUGHTS_ADDRESS` | `localhost:3000` | HTTP listen address | -| `THOUGHTS_DATA` | `./data` | SQLite + session key location | +| `THOUGHTS_DATA_PATH` | `./data` | SQLite + session key location | | `THOUGHTS_OPENAI_API_KEY` | _(unset)_ | Enables AI template generation | -| `THOUGHTS_TENOR_API_KEY` | _(unset)_ | Enables GIF search | +| `THOUGHTS_GIF_API_KEY` | _(unset)_ | Enables GIF search; pasting a link always works | +| `THOUGHTS_GIF_PROVIDER` | _(auto)_ | `klipy`, `giphy` or `none` | | `THOUGHTS_TLS_CERT_PATH` / `THOUGHTS_TLS_KEY_PATH` | _(unset)_ | Optional TLS | ### Layered request/response pattern diff --git a/Dockerfile b/Dockerfile index e730c85..81b4033 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,11 +18,18 @@ RUN wget -qO /app/thoughts.tar.gz "https://github.com/ellgreen/thoughts/releases FROM alpine:latest ENV THOUGHTS_ADDRESS=":3000" -ENV THOUGHTS_DATA="/data" +# The config key is data_path, so THOUGHTS_DATA was never read and the database +# landed in the container's working directory instead of the mounted volume. +ENV THOUGHTS_DATA_PATH="/data" ENV THOUGHTS_TLS_CERT_PATH="" ENV THOUGHTS_TLS_KEY_PATH="" +# Optional integrations, pass with -e to enable. +ENV THOUGHTS_GIF_PROVIDER="" +ENV THOUGHTS_GIF_API_KEY="" +ENV THOUGHTS_OPENAI_API_KEY="" + COPY --from=setup /thoughts /usr/local/bin/ RUN mkdir -p /data diff --git a/README.md b/README.md index 2511f86..71276f0 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,28 @@ You can follow the instructions to get an API key here: -#### GIF Support +#### Images and GIFs -To enable support for gifs via Tenor, you will need to set the -`THOUGHTS_TENOR_API_KEY` environment variable. +Adding an image to a thought always works — paste any `https` image or GIF +link and you get a preview before it is attached. No configuration needed. -You can follow the instructions to get an API key here: +To also get in-app GIF **search**, set `THOUGHTS_GIF_API_KEY`. The default +provider is [Klipy](https://klipy.com/developers), which is free for life: +a test key works immediately, and a production key (a form in their partner +panel) lifts the rate limit. - +```shell +docker run -p 3000:3000 \ + -v $PWD/data:/data \ + -e THOUGHTS_GIF_API_KEY="your-key" \ + ghcr.io/ellgreen/thoughts:latest +``` + +| Variable | Values | Default | +| --- | --- | --- | +| `THOUGHTS_GIF_API_KEY` | Your provider's API key | unset — search disabled | +| `THOUGHTS_GIF_PROVIDER` | `klipy`, `giphy`, `none` | auto: `klipy` when a key is set | + +> [!NOTE] +> Google shut the Tenor API down on 30 June 2026, so `THOUGHTS_TENOR_API_KEY` +> no longer does anything. Thoughts logs a warning if it is still set. diff --git a/cmd/thoughts/config.go b/cmd/thoughts/config.go index 1d20719..5e06696 100644 --- a/cmd/thoughts/config.go +++ b/cmd/thoughts/config.go @@ -13,6 +13,8 @@ type config struct { SessionKeyFile string `mapstructure:"session_key_file"` TLSKeyPath string `mapstructure:"tls_key_path"` TLSCertPath string `mapstructure:"tls_cert_path"` + GIFProvider string `mapstructure:"gif_provider"` + GIFAPIKey string `mapstructure:"gif_api_key"` TenorAPIKey string `mapstructure:"tenor_api_key"` OpenAIAPIKey string `mapstructure:"openai_api_key"` } @@ -28,6 +30,8 @@ func loadConfig() (*config, error) { v.SetDefault("session_key_file", "session.key") v.SetDefault("tls_key_path", "") v.SetDefault("tls_cert_path", "") + v.SetDefault("gif_provider", "") + v.SetDefault("gif_api_key", "") v.SetDefault("tenor_api_key", "") v.SetDefault("openai_api_key", "") diff --git a/cmd/thoughts/controllers/gifs.go b/cmd/thoughts/controllers/gifs.go index c4c1551..dee09eb 100644 --- a/cmd/thoughts/controllers/gifs.go +++ b/cmd/thoughts/controllers/gifs.go @@ -1,32 +1,51 @@ package controllers import ( + "log/slog" "net/http" + "strconv" + "strings" "github.com/ellgreen/thoughts/cmd/thoughts/gif" - "github.com/ellgreen/thoughts/cmd/thoughts/requests" ) -type GifSearchRequest struct { - Query string `json:"q" validate:"required,min=2,max=32"` -} +// maxGifQuery is generous enough for a phrase and short enough that nobody can +// use the proxy to smuggle a payload upstream. +const maxGifQuery = 64 -func GifSearch(gifProvider gif.Provider) http.Handler { - if gifProvider == nil { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "gifs not available", http.StatusServiceUnavailable) +// GifSearch proxies GIF search so the provider's API key never reaches the +// browser. An empty query returns trending, which gives the picker something +// to show before anyone types. +func GifSearch(provider gif.Provider) http.Handler { + if provider == nil { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "gif search is not configured", http.StatusServiceUnavailable) }) } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - req, ok := requests.From[GifSearchRequest](w, r) - if !ok { + query := strings.TrimSpace(r.URL.Query().Get("q")) + if len(query) > maxGifQuery { + http.Error(w, "search is too long", http.StatusBadRequest) return } - results, err := gifProvider.Search(r.Context(), req.Query) + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + + var ( + results *gif.SearchPage + err error + ) + + if query == "" { + results, err = provider.Trending(r.Context(), page) + } else { + results, err = provider.Search(r.Context(), query, page) + } + if err != nil { - http.Error(w, "failed to search for gifs", http.StatusInternalServerError) + slog.Error("gif search failed", "provider", provider.Name(), "error", err) + http.Error(w, "could not reach the gif service", http.StatusBadGateway) return } diff --git a/cmd/thoughts/event/notes.go b/cmd/thoughts/event/notes.go index 02e8168..43ca0bf 100644 --- a/cmd/thoughts/event/notes.go +++ b/cmd/thoughts/event/notes.go @@ -50,12 +50,14 @@ func (b *Broker) handleNoteCreate(db *sqlx.DB, retroID uuid.UUID) Handler { } type noteUpdateRequest struct { - NoteID uuid.UUID `json:"id" validate:"required,uuid"` - ColumnID uuid.UUID `json:"column_id" validate:"omitempty,required_with=group_id,uuid"` - GroupID uuid.UUID `json:"group_id" validate:"omitempty,uuid"` - Content string `json:"content" validate:"omitempty,min=2,max=255"` - ImgURL string `json:"img_url" validate:"omitempty,url"` - RemoveImgURL bool `json:"remove_img_url"` + NoteID uuid.UUID `json:"id" validate:"required,uuid"` + ColumnID uuid.UUID `json:"column_id" validate:"omitempty,required_with=group_id,uuid"` + GroupID uuid.UUID `json:"group_id" validate:"omitempty,uuid"` + Content string `json:"content" validate:"omitempty,min=2,max=255"` + // Now that people can paste their own link, not just pick from a proxied + // provider, insist on https - a browser would block mixed content anyway. + ImgURL string `json:"img_url" validate:"omitempty,url,startswith=https://,max=2048"` + RemoveImgURL bool `json:"remove_img_url"` } func (b *Broker) handleNoteUpdate(db *sqlx.DB, retroID uuid.UUID) Handler { diff --git a/cmd/thoughts/gif/enabled.go b/cmd/thoughts/gif/enabled.go deleted file mode 100644 index b7845ed..0000000 --- a/cmd/thoughts/gif/enabled.go +++ /dev/null @@ -1,23 +0,0 @@ -package gif - -import "log/slog" - -var provider Provider - -func ResolveProvider(tenorAPIKey string) Provider { - if provider != nil { - return provider - } - - if tenorAPIKey != "" { - slog.Info("using tenor gif provider") - - provider = NewTenorProvider(tenorAPIKey) - } - - return provider -} - -func IsAvailable() bool { - return provider != nil -} diff --git a/cmd/thoughts/gif/gif_test.go b/cmd/thoughts/gif/gif_test.go new file mode 100644 index 0000000..739a7b2 --- /dev/null +++ b/cmd/thoughts/gif/gif_test.go @@ -0,0 +1,275 @@ +package gif + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +const klipyBody = `{ + "result": true, + "data": { + "data": [ + { + "slug": "dancing-cat", + "files": { + "gif": {"hd": {"url": "https://cdn.example/hd.gif", "width": 480, "height": 360}, + "md": {"url": "https://cdn.example/md.gif", "width": 360, "height": 270}, + "sm": {"url": "https://cdn.example/sm.gif", "width": 240, "height": 180}}, + "webp": {"sm": {"url": "https://cdn.example/sm.webp", "width": 240, "height": 180}} + } + }, + { + "slug": "an-advert", + "files": {} + }, + { + "slug": "no-webp", + "files": { + "gif": {"sm": {"url": "https://cdn.example/only-sm.gif", "width": 100, "height": 100}} + } + } + ], + "current_page": 2, + "per_page": 24, + "has_next": true + } +}` + +func TestKlipySearchMapsResults(t *testing.T) { + var gotPath string + var gotQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(klipyBody)) + })) + defer server.Close() + + provider := &KlipyProvider{BaseURL: server.URL, APIKey: "test-key"} + + page, err := provider.Search(context.Background(), "dancing cat", 2) + if err != nil { + t.Fatalf("search failed: %v", err) + } + + // The key travels in the path, not a header or query parameter. + if gotPath != "/test-key/gifs/search" { + t.Errorf("requested %q, want /test-key/gifs/search", gotPath) + } + + if gotQuery.Get("q") != "dancing cat" { + t.Errorf("q = %q, want %q", gotQuery.Get("q"), "dancing cat") + } + + if gotQuery.Get("page") != "2" { + t.Errorf("page = %q, want 2", gotQuery.Get("page")) + } + + if gotQuery.Get("per_page") != "24" { + t.Errorf("per_page = %q, want 24", gotQuery.Get("per_page")) + } + + // The advert with no files is skipped. + if len(page.Results) != 2 { + t.Fatalf("got %d results, want 2", len(page.Results)) + } + + first := page.Results[0] + + if first.URL != "https://cdn.example/md.gif" { + t.Errorf("URL = %q, want the medium gif", first.URL) + } + + if first.PreviewURL != "https://cdn.example/sm.webp" { + t.Errorf("PreviewURL = %q, want the small webp", first.PreviewURL) + } + + if first.Width != 240 || first.Height != 180 { + t.Errorf("preview dimensions = %dx%d, want 240x180", first.Width, first.Height) + } + + // Falls back through the sizes when a variant is missing. + second := page.Results[1] + + if second.URL != "https://cdn.example/only-sm.gif" || second.PreviewURL != "https://cdn.example/only-sm.gif" { + t.Errorf("fallback mapping produced %+v", second) + } + + if !page.HasNext || page.Page != 2 { + t.Errorf("pagination = page %d hasNext %v, want page 2 hasNext true", page.Page, page.HasNext) + } +} + +func TestKlipyTrendingUsesTheTrendingEndpoint(t *testing.T) { + var gotPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(klipyBody)) + })) + defer server.Close() + + provider := &KlipyProvider{BaseURL: server.URL, APIKey: "k"} + + if _, err := provider.Trending(context.Background(), 0); err != nil { + t.Fatalf("trending failed: %v", err) + } + + if gotPath != "/k/gifs/trending" { + t.Errorf("requested %q, want /k/gifs/trending", gotPath) + } +} + +func TestKlipySurfacesUpstreamFailures(t *testing.T) { + cases := map[string]http.HandlerFunc{ + "http error": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + }, + "unparseable body": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("not json")) + }, + "result false": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"result": false, "data": {}}`)) + }, + } + + for name, handler := range cases { + t.Run(name, func(t *testing.T) { + server := httptest.NewServer(handler) + defer server.Close() + + provider := &KlipyProvider{BaseURL: server.URL, APIKey: "k"} + + if _, err := provider.Search(context.Background(), "cat", 1); err == nil { + t.Error("expected an error to be surfaced") + } + }) + } +} + +func TestGiphySearchMapsResults(t *testing.T) { + var gotQuery url.Values + + body, _ := json.Marshal(map[string]any{ + "data": []map[string]any{ + { + "id": "abc", + "images": map[string]any{ + "fixed_width": map[string]any{"url": "https://cdn.giphy/fw.gif", "width": "200", "height": "150"}, + "downsized_medium": map[string]any{"url": "https://cdn.giphy/dm.gif"}, + "original": map[string]any{"url": "https://cdn.giphy/orig.gif"}, + }, + }, + {"id": "no-images", "images": map[string]any{}}, + }, + "pagination": map[string]any{"total_count": 100, "count": 2, "offset": 24}, + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + _, _ = w.Write(body) + })) + defer server.Close() + + provider := &GiphyProvider{BaseURL: server.URL, APIKey: "giphy-key"} + + page, err := provider.Search(context.Background(), "cat", 2) + if err != nil { + t.Fatalf("search failed: %v", err) + } + + if gotQuery.Get("api_key") != "giphy-key" { + t.Errorf("api_key = %q", gotQuery.Get("api_key")) + } + + // Giphy paginates by offset rather than page number. + if gotQuery.Get("offset") != "24" { + t.Errorf("offset = %q, want 24", gotQuery.Get("offset")) + } + + if len(page.Results) != 1 { + t.Fatalf("got %d results, want 1", len(page.Results)) + } + + got := page.Results[0] + + if got.URL != "https://cdn.giphy/dm.gif" || got.PreviewURL != "https://cdn.giphy/fw.gif" { + t.Errorf("mapping produced %+v", got) + } + + if got.Width != 200 || got.Height != 150 { + t.Errorf("dimensions = %dx%d, want 200x150", got.Width, got.Height) + } + + if !page.HasNext { + t.Error("expected more pages given 100 total results") + } +} + +func TestResolve(t *testing.T) { + cases := []struct { + name string + provider string + apiKey string + wantName string + wantNil bool + wantErrPart string + }{ + {name: "auto with a key picks klipy", apiKey: "k", wantName: ProviderKlipy}, + {name: "auto without a key disables search", wantNil: true}, + {name: "explicit none", provider: ProviderNone, apiKey: "k", wantNil: true}, + {name: "explicit klipy", provider: ProviderKlipy, apiKey: "k", wantName: ProviderKlipy}, + {name: "explicit giphy", provider: ProviderGiphy, apiKey: "k", wantName: ProviderGiphy}, + {name: "klipy without a key", provider: ProviderKlipy, wantErrPart: "THOUGHTS_GIF_API_KEY"}, + {name: "unknown provider", provider: "tenor", apiKey: "k", wantErrPart: "unknown gif provider"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + provider, err := Resolve(tc.provider, tc.apiKey) + + if tc.wantErrPart != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErrPart) { + t.Fatalf("expected an error containing %q, got %v", tc.wantErrPart, err) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tc.wantNil { + if provider != nil { + t.Errorf("expected search to be disabled, got %s", provider.Name()) + } + + if SearchAvailable() { + t.Error("SearchAvailable should be false with no provider") + } + + return + } + + if provider == nil { + t.Fatal("expected a provider") + } + + if provider.Name() != tc.wantName { + t.Errorf("provider = %s, want %s", provider.Name(), tc.wantName) + } + + if !SearchAvailable() { + t.Error("SearchAvailable should be true with a provider") + } + }) + } +} diff --git a/cmd/thoughts/gif/giphy.go b/cmd/thoughts/gif/giphy.go new file mode 100644 index 0000000..45a73eb --- /dev/null +++ b/cmd/thoughts/gif/giphy.go @@ -0,0 +1,148 @@ +package gif + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// GiphyProvider talks to https://developers.giphy.com. Giphy no longer has a +// free tier, so it is here for anyone who already holds a key rather than as +// the default. +type GiphyProvider struct { + BaseURL string + APIKey string +} + +var _ Provider = (*GiphyProvider)(nil) + +const giphyBaseURL = "https://api.giphy.com/v1" + +func NewGiphyProvider(apiKey string) *GiphyProvider { + return &GiphyProvider{ + BaseURL: giphyBaseURL, + APIKey: apiKey, + } +} + +func (g *GiphyProvider) Name() string { + return ProviderGiphy +} + +func (g *GiphyProvider) Search(ctx context.Context, query string, page int) (*SearchPage, error) { + return g.fetch(ctx, "gifs/search", url.Values{"q": []string{query}}, page) +} + +func (g *GiphyProvider) Trending(ctx context.Context, page int) (*SearchPage, error) { + return g.fetch(ctx, "gifs/trending", url.Values{}, page) +} + +type ( + giphyImage struct { + URL string `json:"url"` + Width string `json:"width"` + Height string `json:"height"` + } + + giphyItem struct { + ID string `json:"id"` + Images struct { + FixedWidth giphyImage `json:"fixed_width"` + DownsizedMedium giphyImage `json:"downsized_medium"` + Original giphyImage `json:"original"` + } `json:"images"` + } + + giphyResponse struct { + Data []giphyItem `json:"data"` + Pagination struct { + TotalCount int `json:"total_count"` + Count int `json:"count"` + Offset int `json:"offset"` + } `json:"pagination"` + } +) + +func (g *GiphyProvider) fetch(ctx context.Context, path string, params url.Values, page int) (*SearchPage, error) { + page = normalisePage(page) + + endpoint, err := url.JoinPath(g.BaseURL, path) + if err != nil { + return nil, fmt.Errorf("failed to build giphy url: %w", err) + } + + offset := (page - 1) * perPage + + params.Set("api_key", g.APIKey) + params.Set("limit", strconv.Itoa(perPage)) + params.Set("offset", strconv.Itoa(offset)) + params.Set("rating", "pg-13") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+params.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create giphy request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to reach giphy: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("giphy responded with status %d", resp.StatusCode) + } + + var body giphyResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("failed to decode giphy response: %w", err) + } + + results := make([]SearchResult, 0, len(body.Data)) + + for _, item := range body.Data { + if result, ok := item.toResult(); ok { + results = append(results, result) + } + } + + return &SearchPage{ + Results: results, + Page: page, + HasNext: offset+len(body.Data) < body.Pagination.TotalCount, + }, nil +} + +func (i giphyItem) toResult() (SearchResult, bool) { + full := firstImage(i.Images.DownsizedMedium, i.Images.Original, i.Images.FixedWidth) + if full.URL == "" { + return SearchResult{}, false + } + + preview := firstImage(i.Images.FixedWidth, full) + + // Giphy reports dimensions as strings. + width, _ := strconv.Atoi(preview.Width) + height, _ := strconv.Atoi(preview.Height) + + return SearchResult{ + PreviewURL: preview.URL, + URL: full.URL, + Width: width, + Height: height, + }, true +} + +func firstImage(images ...giphyImage) giphyImage { + for _, image := range images { + if image.URL != "" { + return image + } + } + + return giphyImage{} +} diff --git a/cmd/thoughts/gif/klipy.go b/cmd/thoughts/gif/klipy.go new file mode 100644 index 0000000..4fa18ad --- /dev/null +++ b/cmd/thoughts/gif/klipy.go @@ -0,0 +1,154 @@ +package gif + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// KlipyProvider talks to https://klipy.com, which offers a free-for-life GIF +// API and is the closest replacement for the retired Tenor API. +type KlipyProvider struct { + BaseURL string + APIKey string +} + +var _ Provider = (*KlipyProvider)(nil) + +const klipyBaseURL = "https://api.klipy.com/api/v1" + +func NewKlipyProvider(apiKey string) *KlipyProvider { + return &KlipyProvider{ + BaseURL: klipyBaseURL, + APIKey: apiKey, + } +} + +func (k *KlipyProvider) Name() string { + return ProviderKlipy +} + +func (k *KlipyProvider) Search(ctx context.Context, query string, page int) (*SearchPage, error) { + return k.fetch(ctx, "gifs/search", url.Values{"q": []string{query}}, page) +} + +func (k *KlipyProvider) Trending(ctx context.Context, page int) (*SearchPage, error) { + return k.fetch(ctx, "gifs/trending", url.Values{}, page) +} + +type ( + klipyFile struct { + URL string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` + } + + // Klipy returns each format in three sizes. + klipyVariants struct { + HD klipyFile `json:"hd"` + MD klipyFile `json:"md"` + SM klipyFile `json:"sm"` + } + + klipyItem struct { + Slug string `json:"slug"` + Files struct { + Gif klipyVariants `json:"gif"` + Webp klipyVariants `json:"webp"` + } `json:"files"` + } + + klipyResponse struct { + Result bool `json:"result"` + Data struct { + Data []klipyItem `json:"data"` + CurrentPage int `json:"current_page"` + HasNext bool `json:"has_next"` + } `json:"data"` + } +) + +func (k *KlipyProvider) fetch(ctx context.Context, path string, params url.Values, page int) (*SearchPage, error) { + page = normalisePage(page) + + // Klipy carries the key in the path rather than a header or query param. + endpoint, err := url.JoinPath(k.BaseURL, k.APIKey, path) + if err != nil { + return nil, fmt.Errorf("failed to build klipy url: %w", err) + } + + params.Set("per_page", strconv.Itoa(perPage)) + params.Set("page", strconv.Itoa(page)) + params.Set("rating", "pg-13") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+params.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create klipy request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to reach klipy: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("klipy responded with status %d", resp.StatusCode) + } + + var body klipyResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("failed to decode klipy response: %w", err) + } + + if !body.Result { + return nil, fmt.Errorf("klipy reported a failed request") + } + + results := make([]SearchResult, 0, len(body.Data.Data)) + + for _, item := range body.Data.Data { + if result, ok := item.toResult(); ok { + results = append(results, result) + } + } + + return &SearchPage{ + Results: results, + Page: page, + HasNext: body.Data.HasNext, + }, nil +} + +func (i klipyItem) toResult() (SearchResult, bool) { + // Medium is the sweet spot on a note card; fall back through the other + // sizes so an item missing one variant is still usable. + full := firstFile(i.Files.Gif.MD, i.Files.Gif.HD, i.Files.Gif.SM) + if full.URL == "" { + // Ads and other non-GIF items come through the same list. + return SearchResult{}, false + } + + preview := firstFile(i.Files.Webp.SM, i.Files.Gif.SM, full) + + return SearchResult{ + PreviewURL: preview.URL, + URL: full.URL, + Width: preview.Width, + Height: preview.Height, + }, true +} + +func firstFile(files ...klipyFile) klipyFile { + for _, file := range files { + if file.URL != "" { + return file + } + } + + return klipyFile{} +} diff --git a/cmd/thoughts/gif/provider.go b/cmd/thoughts/gif/provider.go index a0f040c..09f230b 100644 --- a/cmd/thoughts/gif/provider.go +++ b/cmd/thoughts/gif/provider.go @@ -1,12 +1,49 @@ +// Package gif provides GIF search behind a swappable provider. Google shut the +// Tenor API down on 2026-06-30, so the backend is now configurable rather than +// hard-wired to one vendor. package gif import "context" +// SearchResult is a single GIF: a small preview for the picker grid and the +// full URL that gets stored on the note. type SearchResult struct { PreviewURL string `json:"preview_url"` URL string `json:"url"` + // Dimensions of the preview, so the picker can reserve the right space + // before the image loads instead of reflowing around it. + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` } +// SearchPage is one page of results. +type SearchPage struct { + Results []SearchResult `json:"results"` + Page int `json:"page"` + HasNext bool `json:"has_next"` +} + +// Provider is a GIF search backend. type Provider interface { - Search(ctx context.Context, query string) ([]SearchResult, error) + // Name identifies the provider, for logging and attribution. + Name() string + + // Search returns GIFs matching query. Pages are 1 based. + Search(ctx context.Context, query string, page int) (*SearchPage, error) + + // Trending returns what is popular right now, so the picker has something + // to show before anyone types. + Trending(ctx context.Context, page int) (*SearchPage, error) +} + +// perPage is a full grid without being so large that a slow connection stalls +// on one request. +const perPage = 24 + +func normalisePage(page int) int { + if page < 1 { + return 1 + } + + return page } diff --git a/cmd/thoughts/gif/registry.go b/cmd/thoughts/gif/registry.go new file mode 100644 index 0000000..5e7013e --- /dev/null +++ b/cmd/thoughts/gif/registry.go @@ -0,0 +1,81 @@ +package gif + +import ( + "fmt" + "log/slog" + "net/http" + "time" +) + +// Provider names accepted by THOUGHTS_GIF_PROVIDER. +const ( + ProviderKlipy = "klipy" + ProviderGiphy = "giphy" + ProviderNone = "none" +) + +// httpClient bounds every outbound call. http.DefaultClient has no timeout, so +// a hung provider would pin a request goroutine indefinitely. +var httpClient = &http.Client{Timeout: 8 * time.Second} + +// searchAvailable records whether a provider was configured, so the resource +// layer can tell clients whether the search tab is worth showing. Pasting a +// link never depends on a provider. +var searchAvailable bool + +// SearchAvailable reports whether GIF search is configured. +func SearchAvailable() bool { + return searchAvailable +} + +// Resolve builds the configured search provider. A nil provider is a supported +// outcome, not an error: the picker still lets people paste a link. +func Resolve(name, apiKey string) (Provider, error) { + provider, err := build(name, apiKey) + if err != nil { + return nil, err + } + + searchAvailable = provider != nil + + if provider == nil { + slog.Info("gif search disabled, paste a link instead") + } else { + slog.Info("gif search enabled", "provider", provider.Name()) + } + + return provider, nil +} + +func build(name, apiKey string) (Provider, error) { + switch name { + case ProviderNone: + return nil, nil + + case "": + // Unset means "use search if a key was supplied". + if apiKey == "" { + return nil, nil + } + + return NewKlipyProvider(apiKey), nil + + case ProviderKlipy: + if apiKey == "" { + return nil, fmt.Errorf("gif provider %q needs THOUGHTS_GIF_API_KEY", name) + } + + return NewKlipyProvider(apiKey), nil + + case ProviderGiphy: + if apiKey == "" { + return nil, fmt.Errorf("gif provider %q needs THOUGHTS_GIF_API_KEY", name) + } + + return NewGiphyProvider(apiKey), nil + + default: + return nil, fmt.Errorf("unknown gif provider %q, expected one of %q, %q or %q", + name, ProviderKlipy, ProviderGiphy, ProviderNone) + } +} diff --git a/cmd/thoughts/gif/tenor.go b/cmd/thoughts/gif/tenor.go deleted file mode 100644 index a3e13af..0000000 --- a/cmd/thoughts/gif/tenor.go +++ /dev/null @@ -1,86 +0,0 @@ -package gif - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - - "github.com/samber/lo" -) - -type TenorProvider struct { - URL string - APIKey string -} - -var _ Provider = (*TenorProvider)(nil) - -func NewTenorProvider(apiKey string) *TenorProvider { - return &TenorProvider{ - URL: "https://tenor.googleapis.com", - APIKey: apiKey, - } -} - -type ( - tenorMediaFormat struct { - URL string `json:"url"` - } - - tenorResult struct { - ID string `json:"id"` - MediaFormats struct { - MediumGif tenorMediaFormat `json:"mediumgif"` - Gif tenorMediaFormat `json:"gif"` - } `json:"media_formats"` - } - - tenorSearchResponse struct { - Results []tenorResult `json:"results"` - } -) - -func (t *TenorProvider) Search(ctx context.Context, query string) ([]SearchResult, error) { - searchURL, err := url.JoinPath(t.URL, "/v2/search") - if err != nil { - return nil, fmt.Errorf("failed to join url path for tenor search: %w", err) - } - - searchURL += "?" + url.Values{ - "q": []string{query}, - "key": []string{t.APIKey}, - "limit": []string{"9"}, - "contentfilter": []string{"medium"}, - "media_filter": []string{"gif,mediumgif"}, - }.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request for tenor search: %w", err) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to perform request for tenor search: %w", err) - } - - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("tenor search failed with status code %d", resp.StatusCode) - } - - var searchResp tenorSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { - return nil, fmt.Errorf("failed to decode tenor search response: %w", err) - } - - return lo.Map(searchResp.Results, func(res tenorResult, _ int) SearchResult { - return SearchResult{ - PreviewURL: res.MediaFormats.MediumGif.URL, - URL: res.MediaFormats.Gif.URL, - } - }), nil -} diff --git a/cmd/thoughts/main.go b/cmd/thoughts/main.go index ed905bd..ba7667c 100644 --- a/cmd/thoughts/main.go +++ b/cmd/thoughts/main.go @@ -53,13 +53,24 @@ func main() { os.Exit(1) } + if cfg.TenorAPIKey != "" { + slog.Warn("THOUGHTS_TENOR_API_KEY is set but the Tenor API was shut down on 2026-06-30; " + + "use THOUGHTS_GIF_PROVIDER and THOUGHTS_GIF_API_KEY instead") + } + + gifProvider, err := gif.Resolve(cfg.GIFProvider, cfg.GIFAPIKey) + if err != nil { + slog.Error("failed to configure gif provider", "err", err) + os.Exit(1) + } + router := mux.NewRouter() applyRoutes( router, sessionProvider, ai.ResolveModel(cfg.OpenAIAPIKey), - gif.ResolveProvider(cfg.TenorAPIKey), + gifProvider, ) corsCfg := cors.Default() diff --git a/cmd/thoughts/requests/requests.go b/cmd/thoughts/requests/requests.go index 88a358c..a8e39a1 100644 --- a/cmd/thoughts/requests/requests.go +++ b/cmd/thoughts/requests/requests.go @@ -39,6 +39,12 @@ func validationMessage(validationErrors validator.ValidationErrors) string { fieldMessages = append(fieldMessages, fmt.Sprintf("%s should be less than %s characters", err.Field(), err.Param())) case "uuid": fieldMessages = append(fieldMessages, fmt.Sprintf("%s is not a valid UUID", err.Field())) + case "url": + fieldMessages = append(fieldMessages, fmt.Sprintf("%s is not a valid URL", err.Field())) + case "startswith": + fieldMessages = append(fieldMessages, fmt.Sprintf("%s should start with %s", err.Field(), err.Param())) + case "oneof": + fieldMessages = append(fieldMessages, fmt.Sprintf("%s should be one of: %s", err.Field(), err.Param())) default: fieldMessages = append(fieldMessages, fmt.Sprintf("%s is invalid", err.Field())) } diff --git a/cmd/thoughts/resources/retro.go b/cmd/thoughts/resources/retro.go index 040cac3..11cc998 100644 --- a/cmd/thoughts/resources/retro.go +++ b/cmd/thoughts/resources/retro.go @@ -22,7 +22,7 @@ type Retro struct { Columns []*RetroColumn `json:"columns"` Unlisted bool `json:"unlisted"` MaxVotes int `json:"max_votes"` - GIFsEnabled bool `json:"gifs_enabled"` + GIFSearchEnabled bool `json:"gif_search_enabled"` Tags []string `json:"tags"` CreatedAt time.Time `json:"created_at"` NoteCount int `json:"note_count"` @@ -49,7 +49,7 @@ func RetroFromModel(m *model.Retro) *Retro { }), Unlisted: m.Unlisted, MaxVotes: m.MaxVotes, - GIFsEnabled: gif.IsAvailable(), + GIFSearchEnabled: gif.SearchAvailable(), Tags: tags, CreatedAt: m.CreatedAt, NoteCount: m.NoteCount, diff --git a/cmd/thoughts/routes.go b/cmd/thoughts/routes.go index 5f06301..3d6a220 100644 --- a/cmd/thoughts/routes.go +++ b/cmd/thoughts/routes.go @@ -40,7 +40,7 @@ func applyRoutes( authRouter.Handle("/tags", controllers.TagSuggestions(db)).Methods(http.MethodGet) authRouter.Handle("/tags/{tag}", controllers.TagRetros(db)).Methods(http.MethodGet) - authRouter.Handle("/gifs", controllers.GifSearch(gifProvider)).Methods(http.MethodPost) + authRouter.Handle("/gifs", controllers.GifSearch(gifProvider)).Methods(http.MethodGet) aiRouter := authRouter.PathPrefix("/ai").Subrouter() aiRouter.Handle("/retro-template", controllers.AIRetroTemplate(aiModel)).Methods(http.MethodPost) diff --git a/ui/src/components/retro/brainstorm.tsx b/ui/src/components/retro/brainstorm.tsx index cc0a5d5..f36f938 100644 --- a/ui/src/components/retro/brainstorm.tsx +++ b/ui/src/components/retro/brainstorm.tsx @@ -10,7 +10,7 @@ import NoteDialog from "./note-dialog"; export default function Brainstorm() { const { - retro: { columns, gifs_enabled }, + retro: { columns }, } = useRetro(); const { notes, dispatch } = useNotes(); @@ -27,7 +27,10 @@ export default function Brainstorm() { const overColumnId = event.over?.id; const note = notes.find((n) => n.id === event.active?.id); - if (note?.column_id === overColumnId) return; + // Dropped outside any column, or the column vanished mid-drag. + if (!note || !overColumnId) return; + + if (note.column_id === overColumnId) return; dispatch( createSocketEvent("note_update", { @@ -96,16 +99,8 @@ export default function Brainstorm() { note={note} onEdit={(content) => handleNoteEdit(note.id, content)} onDelete={() => handleNoteDelete(note.id)} - onGifSelected={ - gifs_enabled - ? (url) => handleNoteGifSelected(note.id, url) - : undefined - } - onGifRemoved={ - gifs_enabled - ? () => handleNoteGifRemoved(note.id) - : undefined - } + onGifSelected={(url) => handleNoteGifSelected(note.id, url)} + onGifRemoved={() => handleNoteGifRemoved(note.id)} /> ) : ( diff --git a/ui/src/components/retro/gif-dialog.tsx b/ui/src/components/retro/gif-dialog.tsx index ae54972..2f64c07 100644 --- a/ui/src/components/retro/gif-dialog.tsx +++ b/ui/src/components/retro/gif-dialog.tsx @@ -6,29 +6,15 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormMessage, -} from "../ui/form"; -import { api } from "@/lib/api"; -import { useState } from "react"; - -const schema = z.object({ - query: z.string().min(2).max(32), -}); - -interface SearchResult { - preview_url: string; - url: string; -} +import { Spinner } from "@/components/ui/spinner"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { GifResult, useGifSearch } from "@/hooks/use-gif-search"; +import useRetro from "@/hooks/use-retro"; +import { cn } from "@/lib/utils"; +import { ImageOff, Link2, SearchIcon, SearchX } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; export default function GIFDialog({ children, @@ -37,79 +23,259 @@ export default function GIFDialog({ children: React.ReactNode; onSelect: (url: string) => void; }) { + const { + retro: { gif_search_enabled }, + } = useRetro(); + const [open, setOpen] = useState(false); - const [results, setResults] = useState([]); - - const form = useForm({ - resolver: zodResolver(schema), - defaultValues: { - query: "", - }, - }); - - function handleSubmit(data: z.infer) { - api - .post("/api/gifs", { - q: data.query, - }) - .then((res) => { - setResults(res.data); - }); + function handleSelect(url: string) { + setOpen(false); + onSelect(url); } return ( {children} - + + - Choose a GIF + Add an image - Search for a GIF to add to your note. + {gif_search_enabled + ? "Search for a GIF, or paste a link to any image." + : "Paste a link to any image or GIF."} -
- - ( - - - - - - Press enter to search - - - - )} - /> - - - -
    - {results.map((result, i) => ( -
  • - -
  • - ))} -
+ + + + + Search + + + + Paste link + + + + + {/* Mounted only while visible so trending is not fetched for + someone who only ever pastes links. */} + {gif_search_enabled && } + + + + + +
); } + +function SearchTab({ onSelect }: { onSelect: (url: string) => void }) { + const [query, setQuery] = useState(""); + const { results, status, hasNext, loadMore } = useGifSearch(query, true); + + const sentinel = useRef(null); + + useEffect(() => { + const target = sentinel.current; + if (!target || !hasNext) return; + + const observer = new IntersectionObserver( + ([entry]) => entry.isIntersecting && loadMore(), + { rootMargin: "200px" }, + ); + + observer.observe(target); + + return () => observer.disconnect(); + }, [hasNext, loadMore]); + + const isFirstLoad = status === "loading"; + + return ( +
+ setQuery(e.target.value)} + placeholder="dancing cat" + aria-label="Search for a GIF" + /> + +
+ {status === "error" && ( + } + title="Couldn't reach the GIF service" + body="Try again in a moment, or paste a link instead." + /> + )} + + {isFirstLoad && } + + {status !== "error" && !isFirstLoad && results.length === 0 && ( + } + title={query.trim() ? `No GIFs for "${query.trim()}"` : "Nothing to show"} + body="Try a different search." + /> + )} + + {!isFirstLoad && results.length > 0 && ( + <> +
+ {results.map((result, i) => ( + onSelect(result.url)} + /> + ))} +
+ +
+ {status === "loading-more" && } +
+ + )} +
+
+ ); +} + +function Tile({ + result, + onSelect, +}: { + result: GifResult; + onSelect: () => void; +}) { + const [loaded, setLoaded] = useState(false); + + // Reserve the tile's space up front so the grid does not reflow as images + // stream in. Providers do not always report dimensions, so fall back square. + const aspectRatio = + result.width && result.height ? result.width / result.height : 1; + + return ( + + ); +} + +function TileSkeletons() { + // Uneven heights so the placeholder reads as a masonry grid, not a table. + const heights = [140, 96, 120, 108, 152, 88, 116, 132, 100]; + + return ( +
+ {heights.map((height, i) => ( +
+ ))} +
+ ); +} + +function LinkTab({ onSelect }: { onSelect: (url: string) => void }) { + const [value, setValue] = useState(""); + const [broken, setBroken] = useState(false); + + const url = value.trim(); + const looksValid = /^https:\/\/\S+$/i.test(url); + const canUse = looksValid && !broken; + + return ( +
{ + e.preventDefault(); + if (canUse) onSelect(url); + }} + > + { + setValue(e.target.value); + setBroken(false); + }} + placeholder="https://media.giphy.com/…" + aria-label="Image URL" + /> + +

+ Any https image or GIF link works — right-click an image anywhere and + copy its address. +

+ +
+ {!looksValid && ( +

Preview appears here

+ )} + + {looksValid && broken && ( +

+ That link didn't load as an image +

+ )} + + {looksValid && ( + setBroken(true)} + className={cn("max-h-full max-w-full object-contain", broken && "hidden")} + /> + )} +
+ + +
+ ); +} + +function Empty({ + icon, + title, + body, +}: { + icon: React.ReactNode; + title: string; + body: string; +}) { + return ( +
+ {icon} +

{title}

+

{body}

+
+ ); +} diff --git a/ui/src/components/ui/tabs.tsx b/ui/src/components/ui/tabs.tsx new file mode 100644 index 0000000..8f92ce8 --- /dev/null +++ b/ui/src/components/ui/tabs.tsx @@ -0,0 +1,54 @@ +import { Tabs as TabsPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Tabs({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function TabsList({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function TabsTrigger({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/ui/src/components/ui/tooltip.tsx b/ui/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..ebbce6c --- /dev/null +++ b/ui/src/components/ui/tooltip.tsx @@ -0,0 +1,56 @@ +import { Tooltip as TooltipPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 200, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ ...props }: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ ...props }: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 4, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/ui/src/hooks/use-gif-search.ts b/ui/src/hooks/use-gif-search.ts new file mode 100644 index 0000000..43ffb8c --- /dev/null +++ b/ui/src/hooks/use-gif-search.ts @@ -0,0 +1,78 @@ +import { api } from "@/lib/api"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface GifResult { + preview_url: string; + url: string; + width?: number; + height?: number; +} + +interface GifSearchPage { + results: GifResult[]; + page: number; + has_next: boolean; +} + +export type GifSearchStatus = "idle" | "loading" | "loading-more" | "error"; + +const debounceMs = 300; + +/** + * Searches the GIF proxy, debounced and paginated. An empty query returns + * trending, so the picker has something to show the moment it opens. + */ +export function useGifSearch(query: string, enabled: boolean) { + const [results, setResults] = useState([]); + const [status, setStatus] = useState("idle"); + const [page, setPage] = useState(1); + const [hasNext, setHasNext] = useState(false); + + // Requests can land out of order; only the newest one may touch state. + const latest = useRef(0); + + const fetchPage = useCallback(async (q: string, nextPage: number) => { + const id = ++latest.current; + + setStatus(nextPage === 1 ? "loading" : "loading-more"); + + try { + const res = await api.get("/api/gifs", { + params: { q, page: nextPage }, + }); + + if (id !== latest.current) return; + + setResults((prev) => + nextPage === 1 ? res.data.results : [...prev, ...res.data.results], + ); + setPage(res.data.page); + setHasNext(res.data.has_next); + setStatus("idle"); + } catch { + if (id !== latest.current) return; + + setStatus("error"); + setHasNext(false); + } + }, []); + + useEffect(() => { + if (!enabled) return; + + const trimmed = query.trim(); + + // Trending needs no debounce; typing does. + const timer = setTimeout(() => fetchPage(trimmed, 1), trimmed ? debounceMs : 0); + + return () => clearTimeout(timer); + }, [query, enabled, fetchPage]); + + const loadMore = useCallback(() => { + if (status !== "idle" || !hasNext) return; + + fetchPage(query.trim(), page + 1); + }, [status, hasNext, query, page, fetchPage]); + + return { results, status, hasNext, loadMore }; +} diff --git a/ui/src/types.ts b/ui/src/types.ts index 981b283..ddbcffc 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -12,7 +12,8 @@ export interface Retro { columns: RetroColumn[]; unlisted: boolean; max_votes: number; - gifs_enabled: boolean; + /** Whether a GIF search provider is configured. Pasting a link always works. */ + gif_search_enabled: boolean; tags: string[]; created_at: string; note_count: number; From 4632a1f019d63dc653c2acfa0e511f61bbf3a15c Mon Sep 17 00:00:00 2001 From: Ellis Green Date: Mon, 10 Aug 2026 23:42:14 +0100 Subject: [PATCH 03/17] Let columns be renamed, added and deleted from the board A typo in a column title used to mean recreating the whole retro: columns had no update path at all, since dal.RetroUpdate never touched the field. Columns are a JSON blob on the retro, not a table, and the websocket payload binder cannot decode nested structs, so each change is its own flat event: column_create, column_update and column_delete. They broadcast the existing retro_updated, so every connected client re-renders for free. Guards, all covered by tests: a column must exist, only empty columns can be deleted, a retro keeps at least two columns and gains at most five, and a note can no longer be written into a column that has just been deleted - without that check it would land somewhere nothing renders or exports while still counting towards the retro's total. Simultaneous edits are a read-modify-write race on one blob, so the broker serialises them with a mutex, released before broadcasting. A transaction would not help: sqlx issues a deferred BEGIN, so under WAL a concurrent writer fails rather than serialising. The controls sit on the column header, revealed on hover like the note actions, and are available in every stage - deleting an empty column strands nothing, because no notes means no groups and no votes. When a column cannot be deleted the button is disabled with a tooltip saying why. Two fixes fell out of this: - The retro_updated listener lived in Settings, which renders inside the board's collapsible header. Radix unmounts collapsed content, so with the header collapsed no settings or column change from anyone else arrived. It now lives on the route. - The column grid picked its width from an array indexed by child count, which was undefined outside 2..6 and collapsed the board into a single column. Discuss appends a Tasks column, so a six-column retro already hit this. The index is now clamped and Discuss declares its real count. Co-Authored-By: Claude Opus 5 --- cmd/thoughts/dal/note.go | 15 + cmd/thoughts/dal/retro.go | 21 + cmd/thoughts/event/broker.go | 8 + cmd/thoughts/event/columns.go | 183 +++++++++ cmd/thoughts/event/columns_test.go | 368 ++++++++++++++++++ cmd/thoughts/event/notes.go | 44 ++- cmd/thoughts/model/retro.go | 25 ++ ui/src/components/retro/brainstorm.tsx | 13 +- .../components/retro/column-delete-dialog.tsx | 40 ++ ui/src/components/retro/column-dialog.tsx | 128 ++++++ ui/src/components/retro/columns.tsx | 174 ++++++++- ui/src/components/retro/discuss.tsx | 17 +- ui/src/components/retro/group.tsx | 13 +- ui/src/components/retro/settings.tsx | 15 +- ui/src/components/retro/vote.tsx | 15 +- ui/src/components/ui/tooltip.tsx | 4 +- ui/src/events/index.ts | 13 + ui/src/hooks/use-columns.ts | 66 ++++ ui/src/routes/_auth.retros.$retroId.tsx | 18 +- 19 files changed, 1137 insertions(+), 43 deletions(-) create mode 100644 cmd/thoughts/event/columns.go create mode 100644 cmd/thoughts/event/columns_test.go create mode 100644 ui/src/components/retro/column-delete-dialog.tsx create mode 100644 ui/src/components/retro/column-dialog.tsx create mode 100644 ui/src/hooks/use-columns.ts diff --git a/cmd/thoughts/dal/note.go b/cmd/thoughts/dal/note.go index 97519e7..6845187 100644 --- a/cmd/thoughts/dal/note.go +++ b/cmd/thoughts/dal/note.go @@ -121,6 +121,21 @@ func NoteDelete(ctx context.Context, db *sqlx.DB, id uuid.UUID) error { return nil } +// NoteCountForColumn reports how many notes a column holds, which is what +// decides whether it may be deleted. +func NoteCountForColumn(ctx context.Context, db *sqlx.DB, retroID, columnID uuid.UUID) (int, error) { + var count int + + err := db.GetContext(ctx, &count, + "select count(*) from notes where retro_id = ? and column_id = ?", retroID, columnID) + + if err != nil { + return 0, fmt.Errorf("%w: failed to count notes for column: %w", ErrExecution, err) + } + + return count, nil +} + func NoteList( ctx context.Context, db *sqlx.DB, diff --git a/cmd/thoughts/dal/retro.go b/cmd/thoughts/dal/retro.go index cf4aff4..4b9981a 100644 --- a/cmd/thoughts/dal/retro.go +++ b/cmd/thoughts/dal/retro.go @@ -113,6 +113,27 @@ func RetroUpdate( return retro, nil } +// RetroSetColumns persists a mutated columns blob. It takes the model rather +// than an id because the caller has already read the retro to validate the +// change, and needs the same object to broadcast afterwards. +func RetroSetColumns(ctx context.Context, db *sqlx.DB, retro *model.Retro, columns model.RetroColumns) error { + retro.Columns = columns.ToJSON() + retro.UpdatedAt = time.Now() + + _, err := db.NamedExecContext(ctx, ` + update retros set + columns = :columns, + updated_at = :updated_at + where id = :id + `, retro) + + if err != nil { + return fmt.Errorf("%w: failed to update retro columns: %w", ErrExecution, err) + } + + return nil +} + func RetroUpdateStatus(ctx context.Context, db *sqlx.DB, id uuid.UUID, status model.RetroStatus) error { _, err := db.ExecContext(ctx, ` update retros set status = ?, updated_at = ? where id = ? diff --git a/cmd/thoughts/event/broker.go b/cmd/thoughts/event/broker.go index 0e35604..56c986d 100644 --- a/cmd/thoughts/event/broker.go +++ b/cmd/thoughts/event/broker.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "sync" "github.com/ellgreen/thoughts/cmd/thoughts/model" "github.com/google/uuid" @@ -21,6 +22,10 @@ type Broker struct { handlers map[string]Handler events chan *Event userDependentEvents chan UserDependentEvent + + // Guards the read-modify-write of the retro's columns JSON blob, and the + // check-then-insert when a note is written into a column. + columnsMu sync.Mutex } func NewBroker(db *sqlx.DB, retroID uuid.UUID) *Broker { @@ -32,6 +37,9 @@ func NewBroker(db *sqlx.DB, retroID uuid.UUID) *Broker { b.register("retro_update", b.handleRetroUpdate(db, retroID)) b.register("status_update", b.handleStatusUpdate(db, retroID)) + b.register("column_create", b.handleColumnCreate(db, retroID)) + b.register("column_update", b.handleColumnUpdate(db, retroID)) + b.register("column_delete", b.handleColumnDelete(db, retroID)) b.register("note_create", b.handleNoteCreate(db, retroID)) b.register("note_update", b.handleNoteUpdate(db, retroID)) b.register("note_delete", b.handleNoteDelete(db, retroID)) diff --git a/cmd/thoughts/event/columns.go b/cmd/thoughts/event/columns.go new file mode 100644 index 0000000..a2313d6 --- /dev/null +++ b/cmd/thoughts/event/columns.go @@ -0,0 +1,183 @@ +package event + +import ( + "context" + "fmt" + "log/slog" + + "github.com/ellgreen/thoughts/cmd/thoughts/dal" + "github.com/ellgreen/thoughts/cmd/thoughts/model" + "github.com/ellgreen/thoughts/cmd/thoughts/requests" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +const ( + // Matches the create validator in controllers/retros.go. The upper bound + // also keeps the board grid within the widths it has classes for. + minColumns = 2 + maxColumns = 5 +) + +// Columns live as a JSON blob on the retro rather than in their own table, and +// requests.FromMap cannot decode nested structs, so each event carries one +// column at a time. +type ( + columnCreateRequest struct { + Title string `json:"title" validate:"required,min=2,max=255"` + Description string `json:"description" validate:"max=255"` + } + + columnUpdateRequest struct { + ColumnID uuid.UUID `json:"id" validate:"required,uuid"` + Title string `json:"title" validate:"required,min=2,max=255"` + Description string `json:"description" validate:"max=255"` + } + + columnDeleteRequest struct { + ColumnID uuid.UUID `json:"id" validate:"required,uuid"` + } +) + +func (b *Broker) handleColumnCreate(db *sqlx.DB, retroID uuid.UUID) Handler { + return func(ctx context.Context, _ *model.User, payload Payload) error { + req, err := requests.FromMap[columnCreateRequest](payload) + if err != nil { + return newErrorEvent(err.Error()) + } + + retro, err := b.mutateColumns(ctx, db, retroID, + func(columns model.RetroColumns) (model.RetroColumns, error) { + if len(columns) >= maxColumns { + return nil, newErrorEvent(fmt.Sprintf("a retro can have at most %d columns", maxColumns)) + } + + column := &model.RetroColumn{ + ID: uuid.New(), + Title: req.Title, + Description: req.Description, + } + + return append(columns, column), nil + }) + + if err != nil { + return err + } + + b.dispatch(newRetroUpdatedEvent(retro)) + + return nil + } +} + +func (b *Broker) handleColumnUpdate(db *sqlx.DB, retroID uuid.UUID) Handler { + return func(ctx context.Context, _ *model.User, payload Payload) error { + req, err := requests.FromMap[columnUpdateRequest](payload) + if err != nil { + return newErrorEvent(err.Error()) + } + + retro, err := b.mutateColumns(ctx, db, retroID, + func(columns model.RetroColumns) (model.RetroColumns, error) { + column := columns.Find(req.ColumnID) + if column == nil { + return nil, newErrorEvent("column not found") + } + + column.Title = req.Title + column.Description = req.Description + + return columns, nil + }) + + if err != nil { + return err + } + + b.dispatch(newRetroUpdatedEvent(retro)) + + return nil + } +} + +func (b *Broker) handleColumnDelete(db *sqlx.DB, retroID uuid.UUID) Handler { + return func(ctx context.Context, _ *model.User, payload Payload) error { + req, err := requests.FromMap[columnDeleteRequest](payload) + if err != nil { + return newErrorEvent(err.Error()) + } + + retro, err := b.mutateColumns(ctx, db, retroID, + func(columns model.RetroColumns) (model.RetroColumns, error) { + if columns.Find(req.ColumnID) == nil { + return nil, newErrorEvent("column not found") + } + + if len(columns) <= minColumns { + return nil, newErrorEvent(fmt.Sprintf("a retro must have at least %d columns", minColumns)) + } + + // Deleting an empty column strands nothing: no notes means no + // groups, and no groups means no votes. + count, err := dal.NoteCountForColumn(ctx, db, retroID, req.ColumnID) + if err != nil { + slog.Error("problem counting notes for column", "error", err) + return nil, newErrorEvent("problem checking the column") + } + + if count > 0 { + return nil, newErrorEvent("cannot delete a column that contains notes") + } + + return columns.Without(req.ColumnID), nil + }) + + if err != nil { + return err + } + + b.dispatch(newRetroUpdatedEvent(retro)) + + return nil + } +} + +// mutateColumns serialises the read-modify-write of the columns blob against +// other column changes and against note creation. +// +// A transaction is the wrong tool here: sqlx issues a deferred BEGIN, so under +// WAL a concurrent writer fails with SQLITE_BUSY_SNAPSHOT rather than +// serialising. There is exactly one broker per retro and the app is single +// process, so a mutex is both sufficient and simpler. +// +// The caller broadcasts after this returns - dispatch reaches a client's write +// pump, and holding the lock across it would let one wedged client block every +// column change in the retro. +func (b *Broker) mutateColumns( + ctx context.Context, + db *sqlx.DB, + retroID uuid.UUID, + mutate func(columns model.RetroColumns) (model.RetroColumns, error), +) (*model.Retro, error) { + b.columnsMu.Lock() + defer b.columnsMu.Unlock() + + retro, err := dal.RetroGet(ctx, db, retroID) + if err != nil { + slog.Error("problem getting retro", "error", err) + return nil, newErrorEvent("problem getting retro") + } + + columns, err := mutate(retro.GetColumns()) + if err != nil { + return nil, err + } + + if err := dal.RetroSetColumns(ctx, db, retro, columns); err != nil { + slog.Error("problem updating columns", "error", err) + return nil, newErrorEvent("problem updating columns") + } + + return retro, nil +} diff --git a/cmd/thoughts/event/columns_test.go b/cmd/thoughts/event/columns_test.go new file mode 100644 index 0000000..6a47c40 --- /dev/null +++ b/cmd/thoughts/event/columns_test.go @@ -0,0 +1,368 @@ +package event_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "sync" + "testing" + + "github.com/ellgreen/thoughts/cmd/thoughts/dal" + "github.com/ellgreen/thoughts/cmd/thoughts/model" + "github.com/google/uuid" +) + +// currentColumns re-reads the retro so assertions see what was actually persisted. +func (h *harness) currentColumns(t *testing.T) model.RetroColumns { + t.Helper() + + retro, err := dal.RetroGet(context.Background(), h.db, h.retro.ID) + if err != nil { + t.Fatalf("failed to re-read retro: %v", err) + } + + return retro.GetColumns() +} + +func TestColumnUpdateRenamesInPlace(t *testing.T) { + h := newHarness(t) + user := h.user(t, "Facilitator") + + target := h.columns[0].ID + + if err := h.handle(t, user, "column_update", map[string]any{ + "id": target.String(), + "title": "What went well", + "description": "Wins worth repeating", + }); err != nil { + t.Fatalf("column_update failed: %v", err) + } + + if evt := h.next(t); evt.Name != "retro_updated" { + t.Fatalf("expected retro_updated, got %s", evt.Name) + } + + columns := h.currentColumns(t) + + if len(columns) != 2 { + t.Fatalf("expected 2 columns, got %d", len(columns)) + } + + updated := columns.Find(target) + if updated == nil { + t.Fatal("the column lost its id") + } + + if updated.Title != "What went well" || updated.Description != "Wins worth repeating" { + t.Errorf("column was not updated: %+v", updated) + } + + // The other column is untouched, and order is preserved. + if columns[0].ID != target { + t.Error("columns were reordered") + } + + if columns[1].Title != "Went badly" { + t.Errorf("the neighbouring column changed to %q", columns[1].Title) + } +} + +func TestColumnUpdateClearsADescription(t *testing.T) { + h := newHarness(t) + + if err := h.handle(t, h.user(t, "Facilitator"), "column_update", map[string]any{ + "id": h.columns[0].ID.String(), + "title": "Went well", + "description": "", + }); err != nil { + t.Fatalf("column_update failed: %v", err) + } + + h.next(t) + + if got := h.currentColumns(t)[0].Description; got != "" { + t.Errorf("description = %q, want it cleared", got) + } +} + +func TestColumnUpdateRejectsBadInput(t *testing.T) { + cases := map[string]map[string]any{ + "missing id": {"title": "A title", "description": ""}, + "unknown id": {"id": uuid.New().String(), "title": "A title", "description": ""}, + "not a uuid": {"id": "tasks", "title": "A title", "description": ""}, + "empty title": {"title": "", "description": ""}, + "short title": {"title": "a", "description": ""}, + "missing title": {"description": ""}, + } + + for name, payload := range cases { + t.Run(name, func(t *testing.T) { + h := newHarness(t) + + if _, ok := payload["id"]; !ok { + // Cases that are about the title still need a valid id. + if name != "missing id" { + payload["id"] = h.columns[0].ID.String() + } + } + + if err := h.handle(t, h.user(t, "Facilitator"), "column_update", payload); err == nil { + t.Errorf("expected %v to be rejected", payload) + } + }) + } +} + +func TestColumnDeleteRemovesAnEmptyColumn(t *testing.T) { + h := newHarness(t) + user := h.user(t, "Facilitator") + + // Two columns is the floor, so add one first. + if err := h.handle(t, user, "column_create", map[string]any{ + "title": "Ideas", + "description": "Anything else", + }); err != nil { + t.Fatalf("column_create failed: %v", err) + } + + h.next(t) + + target := h.currentColumns(t)[2].ID + + if err := h.handle(t, user, "column_delete", map[string]any{"id": target.String()}); err != nil { + t.Fatalf("column_delete failed: %v", err) + } + + if evt := h.next(t); evt.Name != "retro_updated" { + t.Fatalf("expected retro_updated, got %s", evt.Name) + } + + columns := h.currentColumns(t) + + if len(columns) != 2 { + t.Fatalf("expected 2 columns after delete, got %d", len(columns)) + } + + if columns.Find(target) != nil { + t.Error("the deleted column is still there") + } +} + +func TestColumnDeleteRefusesWhenTheColumnHasNotes(t *testing.T) { + h := newHarness(t) + user := h.user(t, "Author") + + if err := h.handle(t, user, "column_create", map[string]any{ + "title": "Ideas", + "description": "", + }); err != nil { + t.Fatalf("column_create failed: %v", err) + } + + h.next(t) + + h.createNote(t, user, "a thought worth keeping") + + assertErrorEvent( + t, + h.handle(t, user, "column_delete", map[string]any{"id": h.columns[0].ID.String()}), + "cannot delete a column that contains notes", + ) + + if len(h.currentColumns(t)) != 3 { + t.Error("the column was deleted despite holding a note") + } +} + +func TestColumnDeleteKeepsAMinimumOfTwo(t *testing.T) { + h := newHarness(t) + + assertErrorEvent( + t, + h.handle(t, h.user(t, "Facilitator"), "column_delete", map[string]any{ + "id": h.columns[0].ID.String(), + }), + "a retro must have at least 2 columns", + ) + + if len(h.currentColumns(t)) != 2 { + t.Error("a column was removed below the minimum") + } +} + +func TestColumnDeleteRejectsAnUnknownColumn(t *testing.T) { + h := newHarness(t) + + assertErrorEvent( + t, + h.handle(t, h.user(t, "Facilitator"), "column_delete", map[string]any{ + "id": uuid.New().String(), + }), + "column not found", + ) +} + +func TestColumnDeleteRejectsTheSyntheticTasksColumn(t *testing.T) { + h := newHarness(t) + + // The discuss stage renders a hardcoded "tasks" column that is not a real + // column; it must not be able to reach a handler. + if err := h.handle(t, h.user(t, "Facilitator"), "column_delete", map[string]any{ + "id": "tasks", + }); err == nil { + t.Error("expected a non-uuid column id to be rejected") + } +} + +func TestColumnCreateAppendsUpToTheLimit(t *testing.T) { + h := newHarness(t) + user := h.user(t, "Facilitator") + + for i := range 3 { + if err := h.handle(t, user, "column_create", map[string]any{ + "title": "Extra column", + "description": "", + }); err != nil { + t.Fatalf("column_create %d failed: %v", i, err) + } + + h.next(t) + } + + if len(h.currentColumns(t)) != 5 { + t.Fatalf("expected 5 columns, got %d", len(h.currentColumns(t))) + } + + assertErrorEvent( + t, + h.handle(t, user, "column_create", map[string]any{"title": "One too many", "description": ""}), + "a retro can have at most 5 columns", + ) +} + +func TestColumnCreateAssignsAnID(t *testing.T) { + h := newHarness(t) + + if err := h.handle(t, h.user(t, "Facilitator"), "column_create", map[string]any{ + "title": "Ideas", + "description": "Anything else", + }); err != nil { + t.Fatalf("column_create failed: %v", err) + } + + h.next(t) + + added := h.currentColumns(t)[2] + + if added.ID == uuid.Nil { + t.Error("the new column has no id") + } + + if added.Title != "Ideas" || added.Description != "Anything else" { + t.Errorf("the new column is wrong: %+v", added) + } +} + +func TestColumnsMayBeChangedInAnyStage(t *testing.T) { + for _, status := range []model.RetroStatus{ + model.RetroStatusBrainstorm, + model.RetroStatusGroup, + model.RetroStatusVote, + model.RetroStatusDiscuss, + } { + t.Run(string(status), func(t *testing.T) { + h := newHarness(t) + + if err := dal.RetroUpdateStatus(context.Background(), h.db, h.retro.ID, status); err != nil { + t.Fatalf("failed to set status: %v", err) + } + + if err := h.handle(t, h.user(t, "Facilitator"), "column_update", map[string]any{ + "id": h.columns[0].ID.String(), + "title": "Renamed mid retro", + "description": "", + }); err != nil { + t.Fatalf("column_update failed in %s: %v", status, err) + } + + h.next(t) + + if h.currentColumns(t)[0].Title != "Renamed mid retro" { + t.Errorf("the rename did not stick in %s", status) + } + }) + } +} + +func TestConcurrentColumnCreatesDoNotClobberEachOther(t *testing.T) { + h := newHarness(t) + user := h.user(t, "Facilitator") + + // Columns are one JSON blob, so simultaneous edits are a read-modify-write + // race. Three facilitators adding a column at once must produce three + // columns, not one. + var wg sync.WaitGroup + + for i := range 3 { + wg.Add(1) + + go func() { + defer wg.Done() + + body, err := json.Marshal(map[string]any{ + "name": "column_create", + "payload": map[string]any{ + "title": fmt.Sprintf("Column %d", i), + "description": "", + }, + }) + if err != nil { + return + } + + _ = h.broker.Handle(context.Background(), user, bytes.NewReader(body)) + }() + } + + wg.Wait() + + for range 3 { + h.next(t) + } + + if got := len(h.currentColumns(t)); got != 5 { + t.Errorf("expected 5 columns after 3 concurrent creates, got %d", got) + } +} + +func TestNotesCannotBeCreatedInADeletedColumn(t *testing.T) { + h := newHarness(t) + user := h.user(t, "Author") + + if err := h.handle(t, user, "column_create", map[string]any{ + "title": "Doomed", + "description": "", + }); err != nil { + t.Fatalf("column_create failed: %v", err) + } + + h.next(t) + + doomed := h.currentColumns(t)[2].ID + + if err := h.handle(t, user, "column_delete", map[string]any{"id": doomed.String()}); err != nil { + t.Fatalf("column_delete failed: %v", err) + } + + h.next(t) + + assertErrorEvent( + t, + h.handle(t, user, "note_create", map[string]any{ + "column_id": doomed.String(), + "content": "a homeless thought", + }), + "that column no longer exists", + ) +} diff --git a/cmd/thoughts/event/notes.go b/cmd/thoughts/event/notes.go index 43ca0bf..45b451b 100644 --- a/cmd/thoughts/event/notes.go +++ b/cmd/thoughts/event/notes.go @@ -31,16 +31,9 @@ func (b *Broker) handleNoteCreate(db *sqlx.DB, retroID uuid.UUID) Handler { return newErrorEvent(err.Error()) } - retro, err := dal.RetroGet(ctx, db, retroID) - if err != nil { - slog.Error("problem getting retro", "error", err) - return newErrorEvent("problem getting retro") - } - - note, err := dal.NoteInsert(ctx, db, retroID, user.ID, req.ColumnID, req.Content) + retro, note, err := b.createNote(ctx, db, retroID, user, req) if err != nil { - slog.Error("problem inserting note", "error", err) - return newErrorEvent("problem inserting note") + return err } b.dispatchUserDependent(newNoteCreatedEvent(note, retro, refFrom(payload))) @@ -49,6 +42,39 @@ func (b *Broker) handleNoteCreate(db *sqlx.DB, retroID uuid.UUID) Handler { } } +// createNote holds the columns lock so that a column cannot be deleted between +// checking it exists and writing a note into it. Without the check a note can +// land in a deleted column, where nothing renders it and nothing exports it, +// while it still counts towards the retro's note total. +func (b *Broker) createNote( + ctx context.Context, + db *sqlx.DB, + retroID uuid.UUID, + user *model.User, + req *noteCreateRequest, +) (*model.Retro, *model.Note, error) { + b.columnsMu.Lock() + defer b.columnsMu.Unlock() + + retro, err := dal.RetroGet(ctx, db, retroID) + if err != nil { + slog.Error("problem getting retro", "error", err) + return nil, nil, newErrorEvent("problem getting retro") + } + + if retro.GetColumns().Find(req.ColumnID) == nil { + return nil, nil, newErrorEvent("that column no longer exists") + } + + note, err := dal.NoteInsert(ctx, db, retroID, user.ID, req.ColumnID, req.Content) + if err != nil { + slog.Error("problem inserting note", "error", err) + return nil, nil, newErrorEvent("problem inserting note") + } + + return retro, note, nil +} + type noteUpdateRequest struct { NoteID uuid.UUID `json:"id" validate:"required,uuid"` ColumnID uuid.UUID `json:"column_id" validate:"omitempty,required_with=group_id,uuid"` diff --git a/cmd/thoughts/model/retro.go b/cmd/thoughts/model/retro.go index 24251a5..7882bb8 100644 --- a/cmd/thoughts/model/retro.go +++ b/cmd/thoughts/model/retro.go @@ -54,6 +54,31 @@ func (r *Retro) IsBrainstorming() bool { return r.Status == RetroStatusBrainstorm } +// Find returns the column with the given id, or nil. Value receiver because +// GetColumns hands back a value, not a pointer. +func (rc RetroColumns) Find(id uuid.UUID) *RetroColumn { + for _, column := range rc { + if column.ID == id { + return column + } + } + + return nil +} + +// Without returns a copy with the given column removed. +func (rc RetroColumns) Without(id uuid.UUID) RetroColumns { + remaining := make(RetroColumns, 0, len(rc)) + + for _, column := range rc { + if column.ID != id { + remaining = append(remaining, column) + } + } + + return remaining +} + func (rc *RetroColumns) AssignIDs() *RetroColumns { for _, c := range *rc { c.ID = uuid.New() diff --git a/ui/src/components/retro/brainstorm.tsx b/ui/src/components/retro/brainstorm.tsx index f36f938..e34523c 100644 --- a/ui/src/components/retro/brainstorm.tsx +++ b/ui/src/components/retro/brainstorm.tsx @@ -1,4 +1,5 @@ import { createSocketEvent } from "@/events"; +import { useColumnActions } from "@/hooks/use-columns"; import { useNotes } from "@/hooks/use-notes"; import useRetro from "@/hooks/use-retro"; import { DndContext, DragEndEvent } from "@dnd-kit/core"; @@ -13,6 +14,7 @@ export default function Brainstorm() { retro: { columns }, } = useRetro(); const { notes, dispatch } = useNotes(); + const columnActions = useColumnActions(notes); function handleNewNote(columnId: string, content: string) { dispatch( @@ -77,9 +79,16 @@ export default function Brainstorm() { return ( - + {columns.map((column) => ( - + void; +}) { + return ( + + {children} + + + Delete “{columnTitle}”? + + The column is empty, so nothing is lost — but everyone in this retro + will see it disappear. + + + + Cancel + Delete + + + + ); +} diff --git a/ui/src/components/retro/column-dialog.tsx b/ui/src/components/retro/column-dialog.tsx new file mode 100644 index 0000000..83247c7 --- /dev/null +++ b/ui/src/components/retro/column-dialog.tsx @@ -0,0 +1,128 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { RetroColumn } from "@/types"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +// Mirrors the server's column validation. +const schema = z.object({ + title: z.string().trim().min(2).max(255), + description: z.string().trim().max(255), +}); + +export type ColumnData = z.infer; + +export default function ColumnDialog({ + children, + title, + description, + column, + onSave, +}: { + children: React.ReactNode; + title: string; + description: string; + column?: RetroColumn; + onSave: (data: ColumnData) => void; +}) { + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + title: column?.title ?? "", + description: column?.description ?? "", + }, + }); + + function handleOpenChange(next: boolean) { + setOpen(next); + + // Reopening should show what the column says now, not a half-finished edit + // or someone else's live rename. + if (next) { + form.reset({ + title: column?.title ?? "", + description: column?.description ?? "", + }); + } + } + + return ( + + {children} + + + + {title} + {description} + + +
+ { + setOpen(false); + onSave(data); + })} + > + ( + + Title + + + + + + )} + /> + + ( + + Description + + + + + A prompt to help people fill this column in. Optional. + + + + )} + /> + + + + + + +
+
+ ); +} diff --git a/ui/src/components/retro/columns.tsx b/ui/src/components/retro/columns.tsx index 91a815a..68b2978 100644 --- a/ui/src/components/retro/columns.tsx +++ b/ui/src/components/retro/columns.tsx @@ -1,21 +1,71 @@ +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { ColumnActions } from "@/hooks/use-columns"; import * as types from "@/types"; import { useDroppable } from "@dnd-kit/core"; +import { Pencil, Plus, Trash2 } from "lucide-react"; import { Children } from "react"; import { twMerge } from "tailwind-merge"; import { Heading } from "../typography"; +import ColumnDeleteDialog from "./column-delete-dialog"; +import ColumnDialog, { ColumnData } from "./column-dialog"; -function Columns({ children }: { children: React.ReactNode }) { - const gridCols = [ - "grid-cols-2", - "grid-cols-3", - "grid-cols-4", - "grid-cols-5", - "grid-cols-6", - ][Children.count(children) - 2]; +const gridColumns = [ + "grid-cols-2", + "grid-cols-3", + "grid-cols-4", + "grid-cols-5", + "grid-cols-6", +]; + +function Columns({ + children, + count, + onAddColumn, + canAddColumn, +}: { + children: React.ReactNode; + /** Overrides the child count. Discuss appends a synthetic Tasks column. */ + count?: number; + onAddColumn?: (data: ColumnData) => void; + canAddColumn?: boolean; +}) { + // Clamped: outside 2..6 the lookup is undefined, which used to collapse the + // whole board into a single column. + const columnCount = Math.min( + Math.max(count ?? Children.count(children), 2), + 6, + ); return ( -
- {children} +
+ {onAddColumn && ( +
+ + + +
+ )} + +
+ {children} +
); } @@ -24,30 +74,115 @@ const Column = function Column({ column, children, className, + onEdit, + onDelete, + canDelete, ...props }: { column: types.RetroColumn; children: React.ReactNode; className?: string; -} & React.ComponentProps<"div">) { +} & Partial & + React.ComponentProps<"div">) { + const hasActions = !!(onEdit || onDelete); + return ( -
-
- {column.title} -

{column.description}

+ // A named group: notes use a bare `group` for their own hover actions, and + // a bare group here would reveal these whenever a note is hovered. +
+
+
+ {column.title} +

+ {column.description} +

+
+ + {hasActions && ( +
+ {onEdit && ( + + + + )} + + {onDelete && ( + + )} +
+ )}
+
{children}
); }; +function ColumnDeleteButton({ + column, + onDelete, + canDelete, +}: { + column: types.RetroColumn; +} & Pick) { + const button = ( + + ); + + if (canDelete) { + return ( + + {button} + + ); + } + + return ( + + {/* A disabled button swallows pointer events, so the tooltip needs a + wrapper to hang off. */} + + {button} + + + Only empty columns can be deleted, and a retro needs at least two. + + + ); +} + function DroppableColumn({ column, children, + ...actions }: { column: types.RetroColumn; children: React.ReactNode; -}) { +} & Partial) { const { setNodeRef, isOver } = useDroppable({ id: column.id, }); @@ -56,7 +191,12 @@ function DroppableColumn({ {children} diff --git a/ui/src/components/retro/discuss.tsx b/ui/src/components/retro/discuss.tsx index 7d02a7b..648c8f1 100644 --- a/ui/src/components/retro/discuss.tsx +++ b/ui/src/components/retro/discuss.tsx @@ -1,4 +1,5 @@ import { createSocketEvent, SocketEvent } from "@/events"; +import { useColumnActions } from "@/hooks/use-columns"; import { useNotes } from "@/hooks/use-notes"; import useRetro from "@/hooks/use-retro"; import { api } from "@/lib/api"; @@ -23,7 +24,8 @@ export default function Discuss() { retro, socket: { lastJsonMessage }, } = useRetro(); - const { groupedNotes, dispatch } = useNotes(); + const { notes, groupedNotes, dispatch } = useNotes(); + const columnActions = useColumnActions(notes); const [votes, setVotes] = useState([]); const [tasks, setTasks] = useState([]); @@ -101,9 +103,18 @@ export default function Discuss() { } return ( - + {retro.columns.map((column) => ( - + {groupedNotesForColumn(column.id).map(([groupId, groupNotes]) => ( - + {columns.map((column) => ( - + {Object.entries(groupedNotes[column.id] ?? []).map( ([groupId, groupNotes]) => ( void; }) { const { retro } = useRetro(); - const { groupedNotes } = useNotes(); + const { notes, groupedNotes } = useNotes(); + const columnActions = useColumnActions(notes); const [votes, setVotes] = useState([]); @@ -39,9 +41,16 @@ export default function Vote({ } return ( - + {retro.columns.map((column) => ( - + {Object.entries(groupedNotes[column.id] ?? []).map( ([groupId, groupNotes]) => ( void; + onDelete: () => void; + canDelete: boolean; +} + +/** + * Wires the column mutation events. Columns stay server-authoritative — they + * only ever arrive via retro_updated — so there is no optimistic state here to + * drift out of sync. + * + * Takes the notes rather than calling useNotes itself: a second copy would + * mean a second fetch and a second reducer that diverges from the board's. + */ +export function useColumnActions(notes: Note[]) { + const { + retro, + socket: { sendJsonMessage }, + } = useRetro(); + + const noteCounts = useMemo( + () => + notes.reduce>((acc, note) => { + acc[note.column_id] = (acc[note.column_id] ?? 0) + 1; + return acc; + }, {}), + [notes], + ); + + const columnCount = retro.columns.length; + + const create = useCallback( + (data: ColumnData) => { + sendJsonMessage(createSocketEvent("column_create", data)); + }, + [sendJsonMessage], + ); + + const forColumn = useCallback( + (column: RetroColumn): ColumnActions => ({ + onEdit: (data) => + sendJsonMessage( + createSocketEvent("column_update", { id: column.id, ...data }), + ), + onDelete: () => + sendJsonMessage(createSocketEvent("column_delete", { id: column.id })), + // Unconfirmed notes are in the list too, so a note being written right + // now already blocks the delete. + canDelete: + (noteCounts[column.id] ?? 0) === 0 && columnCount > minColumns, + }), + [sendJsonMessage, noteCounts, columnCount], + ); + + return { create, forColumn, canCreate: columnCount < maxColumns }; +} diff --git a/ui/src/routes/_auth.retros.$retroId.tsx b/ui/src/routes/_auth.retros.$retroId.tsx index e72bce8..930eb42 100644 --- a/ui/src/routes/_auth.retros.$retroId.tsx +++ b/ui/src/routes/_auth.retros.$retroId.tsx @@ -3,9 +3,10 @@ import Board from "@/components/retro/board"; import { RetroContext } from "@/hooks/use-retro"; 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 { useState } from "react"; +import { useEffect, useState } from "react"; import useWebSocket from "react-use-websocket"; export const Route = createFileRoute("/_auth/retros/$retroId")({ @@ -26,6 +27,21 @@ export default function RouteComponent() { Math.min(Math.pow(2, attemptNumber) * 1000, 10000), }); + // 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; + + useEffect(() => { + if (!lastJsonMessage) return; + + const event = lastJsonMessage as SocketEvent; + + if (event.name === "retro_updated") { + setRetro(event.payload as Retro); + } + }, [lastJsonMessage]); + return ( From 323cad4cf06960730c88f3ef9c27cc5794145e4a Mon Sep 17 00:00:00 2001 From: Ellis Green Date: Mon, 10 Aug 2026 23:55:52 +0100 Subject: [PATCH 04/17] Redesign the board and give it a motion system The UI worked but had no shared visual or motion language: instant DOM swaps, five near-identical grey "chart" tokens doing nothing, and a board that laid out five fixed columns at every screen size. Motion, via motion/react behind LazyMotion and a global MotionConfig reducedMotion="user", so the OS setting is honoured everywhere without per-component guards. All timings come from one small vocabulary in lib/motion.ts. The showpiece is a shared layoutId on each note: advancing a stage no longer blinks the board away and back, the same cards glide from their brainstorm positions into their groups and then into vote-ranked order. Around that, notes spring in and out, groups reflow, the dragged card lifts and tilts, drop targets outline in their column's colour, and the vote count pops when it changes. Colour: the dead --chart-1..5 tokens become a five-hue column accent palette, tuned separately for light and dark. Accents are derived from column position, so no migration and no change to the creation form. Once notes are grouped and reordered by vote, colour is the only remaining signal of where a thought came from. Surface tokens let cards layer instead of sitting on flat white. Component work: - A stage rail replaces two anonymous chevrons, showing where the retro is, with the active pill sliding between steps and direction-aware confirm copy. - Note cards move to elevation and a ring, with author chips after brainstorm, and GIFs in a fixed aspect box with a placeholder - they used to pop in at their natural height and shove the column down the page. - The home page leads with the retros. Creating one is behind a "New retro" dialog rather than a permanently open form taking half the page, the list is a card grid with a stage-coloured spine, and the hero stats count up. - Presence shows avatars that animate in and out, with a pulse on the live dot. - Real empty states and loading skeletons: columns used to render blank both while fetching and when genuinely empty, which look identical. Two fixes: - The board is a horizontal snap-scroller below `lg`, so a five-column retro is usable on a phone instead of being crushed to unreadable slivers. The header stacks too - the title and the rail cannot share 375px. - The note dialog said "press enter to submit" and had no button, relying on implicit form submission. It has a Save button now. Co-Authored-By: Claude Opus 5 --- ui/package.json | 1 + ui/pnpm-lock.yaml | 54 ++++++ ui/src/components/retro/board.tsx | 143 ++++++++++----- ui/src/components/retro/brainstorm.tsx | 96 +++++----- .../components/retro/change-status-button.tsx | 93 ---------- ui/src/components/retro/column-states.tsx | 43 +++++ ui/src/components/retro/columns.tsx | 90 +++++++-- .../components/retro/connection-indicator.tsx | 107 ++++++++--- ui/src/components/retro/creator.tsx | 45 +++-- ui/src/components/retro/discuss.tsx | 106 ++++++----- ui/src/components/retro/group.tsx | 59 +++--- ui/src/components/retro/hero.tsx | 130 ++++++++----- ui/src/components/retro/list.tsx | 123 ++++++++----- ui/src/components/retro/note-dialog.tsx | 18 +- ui/src/components/retro/note-group.tsx | 112 ++++++++---- ui/src/components/retro/note.tsx | 172 +++++++++++++----- ui/src/components/retro/stage-rail.tsx | 141 ++++++++++++++ ui/src/components/retro/status-indicator.tsx | 50 ----- ui/src/components/retro/task.tsx | 16 +- ui/src/components/retro/vote.tsx | 59 +++--- ui/src/hooks/use-notes.test.ts | 17 ++ ui/src/hooks/use-notes.ts | 21 ++- ui/src/index.css | 37 +++- ui/src/lib/column-accent.ts | 47 +++++ ui/src/lib/motion.ts | 55 ++++++ ui/src/main.tsx | 20 +- ui/src/routes/_auth.index.tsx | 14 +- ui/src/routes/_auth.tags.$tag.tsx | 6 +- 28 files changed, 1299 insertions(+), 576 deletions(-) delete mode 100644 ui/src/components/retro/change-status-button.tsx create mode 100644 ui/src/components/retro/column-states.tsx create mode 100644 ui/src/components/retro/stage-rail.tsx delete mode 100644 ui/src/components/retro/status-indicator.tsx create mode 100644 ui/src/lib/column-accent.ts create mode 100644 ui/src/lib/motion.ts diff --git a/ui/package.json b/ui/package.json index f44ecee..65d50c4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -34,6 +34,7 @@ "cmdk": "^1.1.1", "date-fns": "^3.6.0", "lucide-react": "^0.510.0", + "motion": "^13.1.0", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19.2.4", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 3d5eecd..607d691 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: lucide-react: specifier: ^0.510.0 version: 0.510.0(react@19.2.4) + motion: + specifier: ^13.1.0 + version: 13.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2633,6 +2636,17 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@13.1.0: + resolution: {integrity: sha512-QSZrF0Id3QGuHJ+OL+9PSY9pk86C8ERFalwAGSchzTm65+ZoGH/RM26lmEARLljcHj2lqhv0jZOOks+EI3COOw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3090,6 +3104,23 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + motion-dom@13.0.0: + resolution: {integrity: sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==} + + motion-utils@13.0.0: + resolution: {integrity: sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==} + + motion@13.1.0: + resolution: {integrity: sha512-qtvscq59uCPdWnNW4SdSkrxR+BS/QYsa923bx7ocA+4p+ZGNbbVQwkSnG4aukB81QWjtl3AxX36plxNyZLmHCA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -6367,6 +6398,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@13.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 13.0.0 + motion-utils: 13.0.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + fresh@2.0.0: {} fs-extra@11.3.4: @@ -6709,6 +6749,20 @@ snapshots: minimist@1.2.8: {} + motion-dom@13.0.0: + dependencies: + motion-utils: 13.0.0 + + motion-utils@13.0.0: {} + + motion@13.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + framer-motion: 13.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + ms@2.1.3: {} msw@2.12.14(@types/node@22.19.15)(typescript@5.6.3): diff --git a/ui/src/components/retro/board.tsx b/ui/src/components/retro/board.tsx index 44f3857..f7c402a 100644 --- a/ui/src/components/retro/board.tsx +++ b/ui/src/components/retro/board.tsx @@ -1,3 +1,9 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, +} from "@/components/ui/collapsible"; import { createSocketEvent, PayloadConnectionInfo, @@ -6,23 +12,21 @@ import { SocketEvent, } from "@/events"; import useRetro from "@/hooks/use-retro"; +import { panelVariants } from "@/lib/motion"; import { RetroStatus } from "@/types"; -import { useEffect, useState } from "react"; import { Link } from "@tanstack/react-router"; -import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { AnimatePresence, m } from "motion/react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; import Brainstorm from "./brainstorm"; -import ChangeStatusButton from "./change-status-button"; import ConnectionIndicator from "./connection-indicator"; import Discuss from "./discuss"; import Group from "./group"; import Settings from "./settings"; -import StatusIndicator from "./status-indicator"; -import Vote from "./vote"; import ShowMarkdown from "./show-markdown"; +import StageRail from "./stage-rail"; +import Vote from "./vote"; const stageLabel: Record = { brainstorm: "Brainstorm", @@ -37,7 +41,9 @@ export default function Board() { socket: { sendJsonMessage, lastJsonMessage, readyState }, } = useRetro(); const [status, setStatus] = useState(retro.status); - const [connectionInfo, setConnectionInfo] = useState({ users: [] }); + const [connectionInfo, setConnectionInfo] = useState({ + users: [], + }); const [votesRemaining, setVotesRemaining] = useState(0); const [expanded, setExpanded] = useState(true); @@ -46,7 +52,9 @@ export default function Board() { const event = lastJsonMessage as SocketEvent; switch (event.name) { case "error": - toast("Something went wrong", { description: (event.payload as PayloadError).message }); + toast("Something went wrong", { + description: (event.payload as PayloadError).message, + }); return; case "status_updated": setStatus((event.payload as PayloadStatusUpdated).status); @@ -63,58 +71,92 @@ export default function Board() { return (
- {/* Sticky board header */}
-
- {/* Compact bar — always visible */} -
- +
+ {/* Compact bar — always visible. Stacks on narrow screens: the + title and the rail cannot share 375px without one of them + becoming unreadable. */} +
+ {retro.title} - {!expanded && ( - - {stageLabel[status]} - - )} - - - + +
+ {!expanded && ( + + {stageLabel[status]} + + )} + + + + +
{/* Expanded detail row */} -
-
+
+
{retro.tags && retro.tags.length > 0 ? ( retro.tags.map((tag) => ( - + #{tag} )) ) : ( - no tags + + no tags + )}
-
- -
-
- {status === "vote" && ( - {votesRemaining} votes left - )} + +
+ + {status === "vote" && ( + + {votesRemaining} votes left + + )} + + {status === "discuss" && } - +
@@ -122,7 +164,22 @@ export default function Board() {
- + {/* Stage content crossfades while the notes themselves morph across via + their shared layoutIds. */} + + + + +
); } diff --git a/ui/src/components/retro/brainstorm.tsx b/ui/src/components/retro/brainstorm.tsx index e34523c..2449958 100644 --- a/ui/src/components/retro/brainstorm.tsx +++ b/ui/src/components/retro/brainstorm.tsx @@ -4,7 +4,9 @@ import { useNotes } from "@/hooks/use-notes"; import useRetro from "@/hooks/use-retro"; import { DndContext, DragEndEvent } from "@dnd-kit/core"; import { Plus } from "lucide-react"; +import { AnimatePresence } from "motion/react"; import { Button } from "../ui/button"; +import { EmptyColumn, NoteSkeletons } from "./column-states"; import { Columns, DroppableColumn } from "./columns"; import { DraggableNote, Note } from "./note"; import NoteDialog from "./note-dialog"; @@ -13,7 +15,7 @@ export default function Brainstorm() { const { retro: { columns }, } = useRetro(); - const { notes, dispatch } = useNotes(); + const { notes, loaded, dispatch } = useNotes(); const columnActions = useColumnActions(notes); function handleNewNote(columnId: string, content: string) { @@ -36,44 +38,27 @@ export default function Brainstorm() { dispatch( createSocketEvent("note_update", { - id: note?.id, + id: note.id, column_id: overColumnId, }), ); } function handleNoteEdit(noteId: string, content: string) { - dispatch( - createSocketEvent("note_update", { - id: noteId, - content, - }), - ); + dispatch(createSocketEvent("note_update", { id: noteId, content })); } function handleNoteDelete(noteId: string) { - dispatch( - createSocketEvent("note_delete", { - id: noteId, - }), - ); + dispatch(createSocketEvent("note_delete", { id: noteId })); } function handleNoteGifSelected(noteId: string, url: string) { - dispatch( - createSocketEvent("note_update", { - id: noteId, - img_url: url, - }), - ); + dispatch(createSocketEvent("note_update", { id: noteId, img_url: url })); } function handleNoteGifRemoved(noteId: string) { dispatch( - createSocketEvent("note_update", { - id: noteId, - remove_img_url: true, - }), + createSocketEvent("note_update", { id: noteId, remove_img_url: true }), ); } @@ -83,41 +68,54 @@ export default function Brainstorm() { onAddColumn={columnActions.create} canAddColumn={columnActions.canCreate} > - {columns.map((column) => ( - - handleNewNote(column.id, content)} + {columns.map((column, index) => { + const columnNotes = notes.filter((n) => n.column_id === column.id); + + return ( + - - + handleNewNote(column.id, content)} + > + + + + {!loaded && } + + {loaded && columnNotes.length === 0 && ( + Nothing here yet. + )} - {notes - .filter((n) => n.column_id == column.id) - .map((note) => ( -
- {note.created_by_me ? ( + + {columnNotes.map((note) => + note.created_by_me ? ( handleNoteEdit(note.id, content)} onDelete={() => handleNoteDelete(note.id)} - onGifSelected={(url) => handleNoteGifSelected(note.id, url)} + onGifSelected={(url) => + handleNoteGifSelected(note.id, url) + } onGifRemoved={() => handleNoteGifRemoved(note.id)} /> ) : ( - - )} -
- ))} -
- ))} + + ), + )} + + + ); + })} ); diff --git a/ui/src/components/retro/change-status-button.tsx b/ui/src/components/retro/change-status-button.tsx deleted file mode 100644 index 780c2a5..0000000 --- a/ui/src/components/retro/change-status-button.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { RetroStatus } from "@/types"; -import { ChevronsLeft, ChevronsRight } from "lucide-react"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; - -const variants = { - next: { - brainstorm: ["group", "Group", "Proceed to grouping stage?"], - group: ["vote", "Vote", "Done grouping? Proceed to vote."], - vote: ["discuss", "Discuss", "Done voting? Proceed to discussion."], - discuss: [], - }, - prev: { - brainstorm: [], - discuss: ["vote", "Vote", "Back to voting stage?"], - vote: ["group", "Group", "Back to grouping stage?"], - group: ["brainstorm", "Brainstorm", "Back to brainstorming stage?"], - }, -}; - -export default function ChangeStatusButton({ - variant, - status, - onStatusUpdate, -}: { - variant: keyof typeof variants; - status: RetroStatus; - onStatusUpdate: (status: RetroStatus) => void; -}) { - const [open, setOpen] = useState(false); - - if (variant === "next" && status === "discuss") { - return null; - } - - if (variant === "prev" && status === "brainstorm") { - return null; - } - - const [newStatus, buttonText, title] = variants[variant][status]; - - return ( - - - - - - - - {title} - - Make sure everyone is ready before moving to the{" "} - {variant === "next" ? "next " : "previous "} - stage! - - - - - - - - - - - - ); -} diff --git a/ui/src/components/retro/column-states.tsx b/ui/src/components/retro/column-states.tsx new file mode 100644 index 0000000..9fa8d47 --- /dev/null +++ b/ui/src/components/retro/column-states.tsx @@ -0,0 +1,43 @@ +import { cn } from "@/lib/utils"; + +/** + * Placeholder shown while the first fetch is in flight. Columns used to render + * as empty for the round trip, which reads as "there is nothing here". + */ +export function NoteSkeletons({ count = 2 }: { count?: number }) { + // Uneven heights so it reads as notes rather than a table. + const heights = [64, 88, 72, 96]; + + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ ))} +
+ ); +} + +/** Quiet nudge for a column with nothing in it yet. */ +export function EmptyColumn({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/ui/src/components/retro/columns.tsx b/ui/src/components/retro/columns.tsx index 68b2978..c747dd0 100644 --- a/ui/src/components/retro/columns.tsx +++ b/ui/src/components/retro/columns.tsx @@ -5,6 +5,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { ColumnActions } from "@/hooks/use-columns"; +import { accentStyle } from "@/lib/column-accent"; import * as types from "@/types"; import { useDroppable } from "@dnd-kit/core"; import { Pencil, Plus, Trash2 } from "lucide-react"; @@ -14,12 +15,15 @@ import { Heading } from "../typography"; import ColumnDeleteDialog from "./column-delete-dialog"; import ColumnDialog, { ColumnData } from "./column-dialog"; +// Literal strings so Tailwind's scanner finds them. Only applied from `lg`: +// below that the board is a horizontal snap-scroller, which beats crushing +// five columns into a phone. const gridColumns = [ - "grid-cols-2", - "grid-cols-3", - "grid-cols-4", - "grid-cols-5", - "grid-cols-6", + "lg:grid-cols-2", + "lg:grid-cols-3", + "lg:grid-cols-4", + "lg:grid-cols-5", + "lg:grid-cols-6", ]; function Columns({ @@ -63,7 +67,13 @@ function Columns({
)} -
+
{children}
@@ -72,14 +82,18 @@ function Columns({ const Column = function Column({ column, + index = 0, children, className, + style, onEdit, onDelete, canDelete, ...props }: { column: types.RetroColumn; + /** Position in the board, which picks the accent colour. */ + index?: number; children: React.ReactNode; className?: string; } & Partial & @@ -89,13 +103,33 @@ const Column = function Column({ return ( // A named group: notes use a bare `group` for their own hover actions, and // a bare group here would reveal these whenever a note is hovered. -
-
+
+
- {column.title} -

- {column.description} -

+
+ + + {column.title} + +
+ + {column.description && ( +

+ {column.description} +

+ )}
{hasActions && ( @@ -129,7 +163,17 @@ const Column = function Column({ )}
-
{children}
+ {/* Accent rule, fading out so it frames rather than boxes in. */} +
+ +
{children}
); }; @@ -177,10 +221,12 @@ function ColumnDeleteButton({ function DroppableColumn({ column, + index, children, ...actions }: { column: types.RetroColumn; + index?: number; children: React.ReactNode; } & Partial) { const { setNodeRef, isOver } = useDroppable({ @@ -191,11 +237,19 @@ function DroppableColumn({ {children} diff --git a/ui/src/components/retro/connection-indicator.tsx b/ui/src/components/retro/connection-indicator.tsx index 504f251..7901ddb 100644 --- a/ui/src/components/retro/connection-indicator.tsx +++ b/ui/src/components/retro/connection-indicator.tsx @@ -1,11 +1,23 @@ -import { PayloadConnectionInfo } from "@/events"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { PayloadConnectionInfo } from "@/events"; +import { accentForName, initialsFor } from "@/lib/column-accent"; +import { spring, springy } from "@/lib/motion"; +import { AnimatePresence, m } from "motion/react"; import { Button } from "../ui/button"; -import { User, Zap } from "lucide-react"; + +const states: Record = { + 0: { label: "Connecting", dot: "bg-yellow-500", live: false }, + 1: { label: "Connected", dot: "bg-green-500", live: true }, + 2: { label: "Disconnecting", dot: "bg-yellow-500", live: false }, + 3: { label: "Disconnected", dot: "bg-red-500", live: false }, +}; + +// How many avatars fit before the rest collapse into a +N. +const maxAvatars = 3; export default function ConnectionIndicator({ connectionInfo, @@ -14,32 +26,85 @@ export default function ConnectionIndicator({ connectionInfo: PayloadConnectionInfo; readyState: number; }) { - const [state, stateClassName] = { - 0: ["Connecting", "text-yellow-500"], - 1: ["Connected", "text-green-500"], - 2: ["Disconnecting", "text-yellow-500"], - 3: ["Disconnected", "text-red-500"], - }[readyState] || ["Unknown", "text-red-500"]; + const state = states[readyState] ?? { + label: "Unknown", + dot: "bg-red-500", + live: false, + }; + + const users = connectionInfo.users; + const visible = users.slice(0, maxAvatars); + const overflow = users.length - visible.length; return ( - - - {connectionInfo.users.length === 0 ? ( -
- No users connected -
+ + +

{state.label}

+ + {users.length === 0 ? ( +

Nobody else is here.

) : ( -
    - {connectionInfo.users.map((user) => ( -
  • - - {user} +
      + {users.map((user) => ( +
    • + + {initialsFor(user)} + + {user}
    • ))}
    diff --git a/ui/src/components/retro/creator.tsx b/ui/src/components/retro/creator.tsx index 5b1aa84..4c9c7b4 100644 --- a/ui/src/components/retro/creator.tsx +++ b/ui/src/components/retro/creator.tsx @@ -1,6 +1,14 @@ import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -23,7 +31,8 @@ import { Route as RetrosRoute } from "@/routes/_auth.retros.$retroId"; import { Retro } from "@/types"; import { zodResolver } from "@hookform/resolvers/zod"; import { useNavigate } from "@tanstack/react-router"; -import { BookDashed, Trash2 } from "lucide-react"; +import { BookDashed, Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; import { useFieldArray, useForm, useFormContext } from "react-hook-form"; import { z } from "zod"; import AIRetroTemplate from "./ai-retro-template"; @@ -45,8 +54,9 @@ const schema = z.object({ tags: z.array(z.string().min(1).max(50)).max(10).optional(), }); -export default function Creator({ className }: { className?: string }) { +export default function Creator() { const navigate = useNavigate(); + const [open, setOpen] = useState(false); const form = useForm({ resolver: zodResolver(schema), @@ -60,17 +70,28 @@ export default function Creator({ className }: { className?: string }) { function handleSubmit(data: z.infer) { api.post("/api/retros", data).then((response) => { + setOpen(false); navigate({ to: RetrosRoute.path, params: { retroId: response.data.id } }); }); } return ( - - - Create a retrospective - + + + + + + + + Create a retrospective + + Pick a template or write your own columns. You can rename them later. + + -
    - + + +
    -
    -
    + + ); } diff --git a/ui/src/components/retro/discuss.tsx b/ui/src/components/retro/discuss.tsx index 648c8f1..778a762 100644 --- a/ui/src/components/retro/discuss.tsx +++ b/ui/src/components/retro/discuss.tsx @@ -5,8 +5,10 @@ import useRetro from "@/hooks/use-retro"; import { api } from "@/lib/api"; import { Task as TaskType } from "@/types"; import { Plus } from "lucide-react"; +import { AnimatePresence } from "motion/react"; import { useCallback, useEffect, useState } from "react"; import { Button } from "../ui/button"; +import { EmptyColumn, NoteSkeletons } from "./column-states"; import { Column, Columns } from "./columns"; import { Note } from "./note"; import { NoteGroup } from "./note-group"; @@ -24,7 +26,7 @@ export default function Discuss() { retro, socket: { lastJsonMessage }, } = useRetro(); - const { notes, groupedNotes, dispatch } = useNotes(); + const { notes, groupedNotes, loaded, dispatch } = useNotes(); const columnActions = useColumnActions(notes); const [votes, setVotes] = useState([]); @@ -109,64 +111,86 @@ export default function Discuss() { onAddColumn={columnActions.create} canAddColumn={columnActions.canCreate} > - {retro.columns.map((column) => ( - - {groupedNotesForColumn(column.id).map(([groupId, groupNotes]) => ( - v.group_id === groupId)?.count ?? 0, - total: votes.reduce((acc, v) => acc + v.count, 0), - }} - authors={Array.from( - new Set( - groupNotes - .map((note) => note.created_by_name) - .filter((name): name is string => Boolean(name)), - ), - )} - > - {groupNotes.map((note) => ( - + {retro.columns.map((column, index) => { + const groups = groupedNotesForColumn(column.id); + + return ( + + {!loaded && } + + {loaded && groups.length === 0 && ( + Nothing came up here. + )} + + + {groups.map(([groupId, groupNotes]) => ( + v.group_id === groupId)?.count ?? 0, + total: votes.reduce((acc, v) => acc + v.count, 0), + }} + authors={Array.from( + new Set( + groupNotes + .map((note) => note.created_by_name) + .filter((name): name is string => Boolean(name)), + ), + )} + > + {groupNotes.map((note) => ( + + ))} + ))} - - ))} - - ))} + + + ); + })} - {tasks - .sort((a, b) => Number(a.completed) - Number(b.completed)) - .map((task) => ( - handleEditTask(task.id, d)} - onComplete={(c) => handleTaskComplete(task.id, c)} - /> - ))} + {tasks.length === 0 && ( + No action items yet. + )} + + + {[...tasks] + .sort((a, b) => Number(a.completed) - Number(b.completed)) + .map((task) => ( + handleEditTask(task.id, d)} + onComplete={(c) => handleTaskComplete(task.id, c)} + /> + ))} + ); diff --git a/ui/src/components/retro/group.tsx b/ui/src/components/retro/group.tsx index a881853..6ac89b5 100644 --- a/ui/src/components/retro/group.tsx +++ b/ui/src/components/retro/group.tsx @@ -3,6 +3,8 @@ import { useColumnActions } from "@/hooks/use-columns"; import { useNotes } from "@/hooks/use-notes"; import useRetro from "@/hooks/use-retro"; import { DndContext, DragEndEvent } from "@dnd-kit/core"; +import { AnimatePresence } from "motion/react"; +import { EmptyColumn, NoteSkeletons } from "./column-states"; import { Columns, DroppableColumn } from "./columns"; import { DraggableNote } from "./note"; import { DroppableNoteGroup } from "./note-group"; @@ -11,7 +13,7 @@ export default function Group() { const { retro: { columns }, } = useRetro(); - const { notes, groupedNotes, dispatch } = useNotes(); + const { notes, groupedNotes, loaded, dispatch } = useNotes(); const columnActions = useColumnActions(notes); function handleDragEnd(event: DragEndEvent) { @@ -24,7 +26,7 @@ export default function Group() { let groupId = ""; if (overId.includes(".")) { - // Dragged to a group + // Dragged onto an existing group rather than empty column space. [columnId, groupId] = overId.split("."); } @@ -45,27 +47,38 @@ export default function Group() { onAddColumn={columnActions.create} canAddColumn={columnActions.canCreate} > - {columns.map((column) => ( - - {Object.entries(groupedNotes[column.id] ?? []).map( - ([groupId, groupNotes]) => ( - - {groupNotes.map((note) => ( - - ))} - - ), - )} - - ))} + {columns.map((column, index) => { + const groups = Object.entries(groupedNotes[column.id] ?? {}); + + return ( + + {!loaded && } + + {loaded && groups.length === 0 && ( + No thoughts in this column. + )} + + + {groups.map(([groupId, groupNotes]) => ( + + {groupNotes.map((note) => ( + + ))} + + ))} + + + ); + })} ); diff --git a/ui/src/components/retro/hero.tsx b/ui/src/components/retro/hero.tsx index b597a0e..0e258e7 100644 --- a/ui/src/components/retro/hero.tsx +++ b/ui/src/components/retro/hero.tsx @@ -1,9 +1,18 @@ +import { spring } from "@/lib/motion"; import { - ZapIcon, - UsersIcon, + animate, + m, + useInView, + useMotionValue, + useTransform, +} from "motion/react"; +import { useEffect, useRef } from "react"; +import { + CheckSquareIcon, FileDownIcon, SparklesIcon, - CheckSquareIcon, + UsersIcon, + ZapIcon, } from "lucide-react"; interface HeroStats { @@ -20,48 +29,70 @@ const features = [ { icon: FileDownIcon, label: "Markdown export" }, ]; -export default function Hero({ stats }: { stats: HeroStats }) { +const stats = [ + { key: "retro_count", label: "retrospectives", accent: "var(--chart-1)" }, + { key: "note_count", label: "thoughts", accent: "var(--chart-2)" }, + { key: "task_count", label: "actions", accent: "var(--chart-4)" }, +] as const; + +export default function Hero({ stats: values }: { stats: HeroStats }) { return ( -
    - {/* Wispy background orbs */} -
    -
    -
    -
    +
    + {/* Wispy background orbs, drifting slowly enough to read as ambient. */} + + + -
    - {/* Left: tagline + features */} +
    -

    - A simple tool for running better retrospectives with your team. -

    +

    + Better retrospectives, + together. +

    +
    - {features.map(({ icon: Icon, label }) => ( - + {features.map(({ icon: Icon, label }, i) => ( + {label} - + ))}
    - {/* Right: stats */} -
    - - - +
    + {stats.map((stat) => ( + + ))}
    @@ -71,19 +102,34 @@ export default function Hero({ stats }: { stats: HeroStats }) { function Stat({ value, label, - gradient, + accent, }: { value: number; label: string; - gradient: string; + accent: string; }) { + const ref = useRef(null); + const inView = useInView(ref, { once: true }); + + const count = useMotionValue(0); + const rounded = useTransform(count, (v) => Math.round(v).toLocaleString()); + + useEffect(() => { + if (!inView) return; + + const controls = animate(count, value, { duration: 0.9, ease: "easeOut" }); + + return () => controls.stop(); + }, [inView, value, count]); + return ( -
    -
    + - {value} -
    + {rounded} +
    {label}
    ); diff --git a/ui/src/components/retro/list.tsx b/ui/src/components/retro/list.tsx index 4967a42..a49d1e5 100644 --- a/ui/src/components/retro/list.tsx +++ b/ui/src/components/retro/list.tsx @@ -1,14 +1,10 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Link, useNavigate } from "@tanstack/react-router"; +import { accentForIndex } from "@/lib/column-accent"; +import { cardVariants, spring, stagger } from "@/lib/motion"; import { Retro, RetroStatus } from "@/types"; -import { CircleCheck, StickyNote } from "lucide-react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { CircleCheck, StickyNote, Telescope } from "lucide-react"; +import { AnimatePresence, m } from "motion/react"; const statusLabel: Record = { brainstorm: "Brainstorm", @@ -17,55 +13,91 @@ const statusLabel: Record = { discuss: "Discuss", }; +// One accent per stage, so a glance down the list tells you where things are. +const statusAccent: Record = { + brainstorm: accentForIndex(0), + group: accentForIndex(1), + vote: accentForIndex(3), + discuss: accentForIndex(2), +}; + export default function List({ retros }: { retros?: Retro[] }) { - return ( - - - Recent Retros - Your last {retros?.length ?? 0} retrospectives - + if (!retros || retros.length === 0) { + return ( +
    + +

    No retros yet

    +

    + Start one above and share the link — everyone joins by typing their + name. +

    +
    + ); + } - - {!retros || retros.length === 0 ? ( -

    No retros yet 😢

    - ) : ( -
    - {retros.map((retro) => ( - - ))} -
    - )} -
    -
    + return ( +
    + + {retros.map((retro, i) => ( + + ))} + +
    ); } -export function RetroItem({ retro }: { retro: Retro }) { +export function RetroItem({ + retro, + index = 0, +}: { + retro: Retro; + index?: number; +}) { const navigate = useNavigate(); const allTasksDone = retro.task_count > 0 && retro.task_count === retro.task_completed_count; + function open() { + navigate({ to: "/retros/$retroId", params: { retroId: retro.id } }); + } + return ( -
    navigate({ to: "/retros/$retroId", params: { retroId: retro.id } })} + onClick={open} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { - navigate({ to: "/retros/$retroId", params: { retroId: retro.id } }); + e.preventDefault(); + open(); } }} - className="flex flex-col gap-3 rounded-lg border bg-card p-4 hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className="group relative flex cursor-pointer flex-col gap-3 overflow-hidden rounded-xl bg-surface-raised p-4 pl-5 ring-1 ring-border/70 transition-shadow hover:shadow-[0_8px_28px_-10px_rgb(0_0_0/0.25)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" > - {/* Title + status */} + {/* Stage-coloured spine down the left edge. */} + +
    - {retro.title} - + {retro.title} + {statusLabel[retro.status]}
    - {/* Tags */} {retro.tags && retro.tags.length > 0 && (
    {retro.tags.map((tag) => ( @@ -74,7 +106,7 @@ export function RetroItem({ retro }: { retro: Retro }) { to="/tags/$tag" params={{ tag }} onClick={(e) => e.stopPropagation()} - className="text-xs text-muted-foreground hover:text-foreground transition-colors" + className="text-xs text-muted-foreground transition-colors hover:text-foreground" > #{tag} @@ -82,22 +114,25 @@ export function RetroItem({ retro }: { retro: Retro }) {
    )} - {/* Footer */} -
    +
    - {retro.note_count} notes + {retro.note_count} {retro.task_count > 0 && ( - + - {retro.task_completed_count}/{retro.task_count} tasks - + {retro.task_completed_count}/{retro.task_count} + )}
    {new Date(retro.created_at).toLocaleDateString()}
    -
    + ); } diff --git a/ui/src/components/retro/note-dialog.tsx b/ui/src/components/retro/note-dialog.tsx index f369cbd..c26069b 100644 --- a/ui/src/components/retro/note-dialog.tsx +++ b/ui/src/components/retro/note-dialog.tsx @@ -1,7 +1,9 @@ +import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, DialogTrigger, @@ -9,7 +11,6 @@ import { import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, @@ -77,15 +78,24 @@ export default function NoteDialog({ Note - + - Press enter to submit - )} /> + + {/* An explicit button: the form previously relied on implicit + submission, which is easy to miss and easy to break. */} + + + diff --git a/ui/src/components/retro/note-group.tsx b/ui/src/components/retro/note-group.tsx index 2b394ca..e30b31d 100644 --- a/ui/src/components/retro/note-group.tsx +++ b/ui/src/components/retro/note-group.tsx @@ -1,48 +1,60 @@ +import { cardVariants, spring, springy } from "@/lib/motion"; import { useDroppable } from "@dnd-kit/core"; import { Check, Flame, TrendingUp, X } from "lucide-react"; +import { AnimatePresence, m } from "motion/react"; import React, { Children } from "react"; import { twMerge } from "tailwind-merge"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; -interface NoteGroupProps extends React.HTMLAttributes { +// Omit children: motion widens it to accept MotionValues, which Children.count +// cannot deal with. +interface NoteGroupProps + extends Omit, "children"> { voteCount?: { forGroup: number; total: number; }; authors?: string[]; + children?: React.ReactNode; } - export const NoteGroup = ({ voteCount, authors, className, children, ...props -}: NoteGroupProps & React.ComponentProps<"div">) => { +}: NoteGroupProps) => { const hasVoteCount = voteCount !== undefined; const childCount = Children.count(children); const isGrouped = childCount > 1; const hasNonZeroVotes = hasVoteCount && voteCount.forGroup > 0; const showFooter = authors || hasNonZeroVotes; - const classes = twMerge( - "rounded-lg transition-all duration-100", - isGrouped || hasVoteCount - ? "bg-muted/60 border border-border/60 p-2 space-y-2" - : "space-y-1", - className, - ); - return ( -
    + {children} {showFooter && (
    {authors && ( - + {authors.join(", ")} )} @@ -52,7 +64,7 @@ export const NoteGroup = ({ )}
    )} -
    + ); }; @@ -61,21 +73,22 @@ const hotThreshold = 0.098; function VoteCount({ forGroup, total }: { forGroup: number; total: number }) { if (forGroup === 0 || total === 0) return null; - let Icon: React.ElementType; - let variant: "destructive" | "secondary"; - - if (forGroup / total >= hotThreshold) { - Icon = Flame; - variant = "destructive"; - } else { - Icon = TrendingUp; - variant = "secondary"; - } + const isHot = forGroup / total >= hotThreshold; + const Icon = isHot ? Flame : TrendingUp; return ( - - {forGroup} - + + + {forGroup} + + ); } @@ -94,8 +107,12 @@ export function DroppableNoteGroup({ return ( {children} @@ -114,18 +131,28 @@ export function VotableNoteGroup({ canVote: boolean; }) { return ( -
    -
    {children}
    +
    {children}
    -
    + ); } diff --git a/ui/src/components/retro/note.tsx b/ui/src/components/retro/note.tsx index 1df2777..ee4d798 100644 --- a/ui/src/components/retro/note.tsx +++ b/ui/src/components/retro/note.tsx @@ -1,3 +1,5 @@ +import { accentForName, initialsFor } from "@/lib/column-accent"; +import { cardVariants, spring } from "@/lib/motion"; import { Note as NoteType } from "@/types"; import { DraggableAttributes, @@ -5,17 +7,20 @@ import { useDraggable, } from "@dnd-kit/core"; import { GripVertical, Image, ImageOff, Pencil, Trash2 } from "lucide-react"; -import React from "react"; +import { m } from "motion/react"; +import React, { useState } from "react"; import { twMerge } from "tailwind-merge"; import { Button } from "../ui/button"; -import NoteDialog from "./note-dialog"; import GIFDialog from "./gif-dialog"; import NoteDeleteDialog from "./note-delete-dialog"; +import NoteDialog from "./note-dialog"; -interface NoteProps extends React.HTMLAttributes { +interface NoteProps { note: NoteType; showGrip?: boolean; blur?: boolean; + /** Shows who wrote it. Off during brainstorm, when notes are private. */ + showAuthor?: boolean; listeners?: DraggableSyntheticListeners; attributes?: DraggableAttributes; onEdit?: (content: string) => void; @@ -28,6 +33,7 @@ export const Note = ({ note, showGrip, blur, + showAuthor, listeners, attributes, onEdit, @@ -36,48 +42,59 @@ export const Note = ({ onGifRemoved, className, ...props -}: NoteProps & React.ComponentProps<"div">) => { +}: NoteProps & React.ComponentProps) => { const hasActions = !!(onGifSelected || onGifRemoved || onDelete || onEdit); return ( -
    - {note.img_url && ( -
    - -
    - )} + {note.img_url && }
    {showGrip && ( )} -

    +

    {note.content}

    + {showAuthor && note.created_by_name && ( + + )} + {hasActions && ( -
    +
    {onGifSelected && note.img_url === "" && ( - @@ -88,6 +105,7 @@ export const Note = ({ variant="ghost" size="icon" className="size-6" + aria-label="Remove the image" onClick={onGifRemoved} > @@ -96,7 +114,12 @@ export const Note = ({ {onDelete && ( - @@ -104,57 +127,108 @@ export const Note = ({ {onEdit && ( - )}
    )} -
    + ); }; +/** + * Fixed aspect box with a placeholder: the image used to pop in at its natural + * height and shove everything below it down the page. + */ +function NoteImage({ src, blur }: { src: string; blur?: boolean }) { + const [loaded, setLoaded] = useState(false); + + return ( +
    + {!loaded &&
    } + + setLoaded(true)} + className={twMerge( + "h-full w-full object-contain transition-opacity duration-300", + loaded ? "opacity-100" : "opacity-0", + )} + /> +
    + ); +} + +function Author({ name }: { name: string }) { + return ( +
    + + {initialsFor(name)} + + {name} +
    + ); +} + export function DraggableNote({ note, - onEdit, - onDelete, - onGifSelected, - onGifRemoved, + ...actions }: { note: NoteType; - onEdit?: (content: string) => void; - onDelete?: () => void; - onGifSelected?: (url: string) => void; - onGifRemoved?: () => void; -}) { - const { setNodeRef, transform, listeners, attributes } = useDraggable({ - id: note.id, - }); - - const style = transform - ? { - transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, - } - : undefined; +} & Pick) { + const { setNodeRef, transform, listeners, attributes, isDragging } = + useDraggable({ + id: note.id, + }); return ( ); } diff --git a/ui/src/components/retro/stage-rail.tsx b/ui/src/components/retro/stage-rail.tsx new file mode 100644 index 0000000..a92dec5 --- /dev/null +++ b/ui/src/components/retro/stage-rail.tsx @@ -0,0 +1,141 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { spring } from "@/lib/motion"; +import { cn } from "@/lib/utils"; +import { RetroStatus } from "@/types"; +import { Brain, Check, Group, Speech, Vote } from "lucide-react"; +import { m } from "motion/react"; +import { useState } from "react"; + +const stages = [ + { status: "brainstorm", icon: Brain, label: "Brainstorm" }, + { status: "group", icon: Group, label: "Group" }, + { status: "vote", icon: Vote, label: "Vote" }, + { status: "discuss", icon: Speech, label: "Discuss" }, +] as const satisfies readonly { status: RetroStatus; icon: unknown; label: string }[]; + +/** + * Replaces a pair of anonymous chevrons with something that shows where the + * retro actually is. Adjacent stages are clickable; the rest are context. + */ +export default function StageRail({ + status, + onStatusUpdate, +}: { + status: RetroStatus; + onStatusUpdate: (status: RetroStatus) => void; +}) { + const [pending, setPending] = useState(null); + + const currentIndex = stages.findIndex((stage) => stage.status === status); + const pendingStage = stages.find((stage) => stage.status === pending); + const movingForward = pendingStage + ? stages.indexOf(pendingStage) > currentIndex + : true; + + return ( + <> +
      + {stages.map((stage, index) => { + const done = index < currentIndex; + const active = index === currentIndex; + const reachable = Math.abs(index - currentIndex) === 1; + const Icon = done ? Check : stage.icon; + + return ( +
    1. + {index > 0 && ( + + )} + + +
    2. + ); + })} +
    + + !open && setPending(null)} + > + + + + {movingForward ? "Move on to" : "Go back to"}{" "} + {pendingStage?.label.toLowerCase()}? + + + Everyone sees this change straight away, so make sure the room is + ready. + + + + + + + + + + + + + ); +} diff --git a/ui/src/components/retro/status-indicator.tsx b/ui/src/components/retro/status-indicator.tsx deleted file mode 100644 index b10d153..0000000 --- a/ui/src/components/retro/status-indicator.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { RetroStatus } from "@/types"; -import { Brain, CheckCircle2, ChevronRight, Group, Speech, Vote } from "lucide-react"; - -const stages = [ - { status: "brainstorm" as RetroStatus, icon: Brain, label: "Brainstorm" }, - { status: "group" as RetroStatus, icon: Group, label: "Group" }, - { status: "vote" as RetroStatus, icon: Vote, label: "Vote" }, - { status: "discuss" as RetroStatus, icon: Speech, label: "Discuss" }, -]; - -export default function StatusIndicator({ status }: { status: RetroStatus }) { - const currentIndex = stages.findIndex((s) => s.status === status); - - return ( -
      - {stages.map((stage, index) => { - const isDone = index < currentIndex; - const isActive = index === currentIndex; - const Icon = isDone ? CheckCircle2 : stage.icon; - - return ( -
    1. - {index > 0 && ( - - )} - - - {stage.label} - -
    2. - ); - })} -
    - ); -} diff --git a/ui/src/components/retro/task.tsx b/ui/src/components/retro/task.tsx index f56c1d0..0841daf 100644 --- a/ui/src/components/retro/task.tsx +++ b/ui/src/components/retro/task.tsx @@ -16,6 +16,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { DialogTrigger } from "@/components/ui/dialog"; +import { cardVariants, spring } from "@/lib/motion"; +import { m } from "motion/react"; import { twMerge } from "tailwind-merge"; export function Task({ @@ -32,10 +34,16 @@ export function Task({ const isOverdue = !task.completed && new Date(task.when) < today; return ( -
    @@ -65,7 +73,7 @@ export function Task({ > {task.what}

    -
    + ); } diff --git a/ui/src/components/retro/vote.tsx b/ui/src/components/retro/vote.tsx index f98ed14..ac571a9 100644 --- a/ui/src/components/retro/vote.tsx +++ b/ui/src/components/retro/vote.tsx @@ -2,7 +2,9 @@ import { useColumnActions } from "@/hooks/use-columns"; import { useNotes } from "@/hooks/use-notes"; import useRetro from "@/hooks/use-retro"; import { api } from "@/lib/api"; +import { AnimatePresence } from "motion/react"; import { useEffect, useState } from "react"; +import { EmptyColumn, NoteSkeletons } from "./column-states"; import { Column, Columns } from "./columns"; import { Note } from "./note"; import { VotableNoteGroup } from "./note-group"; @@ -17,7 +19,7 @@ export default function Vote({ setVotesRemaining: (votesRemaining: number) => void; }) { const { retro } = useRetro(); - const { notes, groupedNotes } = useNotes(); + const { notes, groupedNotes, loaded } = useNotes(); const columnActions = useColumnActions(notes); const [votes, setVotes] = useState([]); @@ -45,28 +47,39 @@ export default function Vote({ onAddColumn={columnActions.create} canAddColumn={columnActions.canCreate} > - {retro.columns.map((column) => ( - - {Object.entries(groupedNotes[column.id] ?? []).map( - ([groupId, groupNotes]) => ( - handleVote(groupId, value)} - voted={!!votes.find((vote) => vote.group_id === groupId)} - canVote={retro.max_votes > votes.length} - key={groupId} - > - {groupNotes.map((note) => ( - - ))} - - ), - )} - - ))} + {retro.columns.map((column, index) => { + const groups = Object.entries(groupedNotes[column.id] ?? {}); + + return ( + + {!loaded && } + + {loaded && groups.length === 0 && ( + Nothing to vote on here. + )} + + + {groups.map(([groupId, groupNotes]) => ( + handleVote(groupId, value)} + voted={!!votes.find((vote) => vote.group_id === groupId)} + canVote={retro.max_votes > votes.length} + key={groupId} + > + {groupNotes.map((note) => ( + + ))} + + ))} + + + ); + })} ); } diff --git a/ui/src/hooks/use-notes.test.ts b/ui/src/hooks/use-notes.test.ts index 0248889..af460d8 100644 --- a/ui/src/hooks/use-notes.test.ts +++ b/ui/src/hooks/use-notes.test.ts @@ -31,6 +31,23 @@ describe("notesReducer", () => { expect(state.rollbacks).toEqual({}); }); + it("is not loaded until the first fetch lands", () => { + expect(initialState.loaded).toBe(false); + + const state = replay({ name: "note_index", payload: [] }); + + expect(state.loaded).toBe(true); + }); + + it("stays loaded once notes start arriving over the socket", () => { + const state = replay( + { name: "note_index", payload: [] }, + { name: "note_created", payload: note({ id: "n1" }) }, + ); + + expect(state.loaded).toBe(true); + }); + it("shows a created note immediately, keyed by its ref", () => { const state = replay({ name: "note_create", diff --git a/ui/src/hooks/use-notes.ts b/ui/src/hooks/use-notes.ts index 0fb12cb..dfda79b 100644 --- a/ui/src/hooks/use-notes.ts +++ b/ui/src/hooks/use-notes.ts @@ -15,6 +15,9 @@ const optimisticEvents = new Set(["note_create", "note_update", "note_delete"]); interface NotesState { notes: Note[]; + /** False until the first fetch lands, so the board can show skeletons + * instead of an empty column that is about to fill up. */ + loaded: boolean; /** * Keyed by the ref of an unconfirmed mutation, holding what to restore if the * server rejects it. `null` means "this was a create, so drop the note whose @@ -23,7 +26,7 @@ interface NotesState { rollbacks: Record; } -const initialState: NotesState = { notes: [], rollbacks: {} }; +const initialState: NotesState = { notes: [], loaded: false, rollbacks: {} }; function upsert(notes: Note[], note: Note): Note[] { return notes.some((n) => n.id === note.id) @@ -54,7 +57,7 @@ function toNote(payload: Note & Partial): Note { function notesReducer(state: NotesState, event: SocketEvent): NotesState { switch (event.name) { case "note_index": { - return { notes: event.payload as Note[], rollbacks: {} }; + return { notes: event.payload as Note[], loaded: true, rollbacks: {} }; } // Optimistic — applied locally the moment the user acts. @@ -62,6 +65,7 @@ function notesReducer(state: NotesState, event: SocketEvent): NotesState { const payload = event.payload as PayloadNoteCreate & Ref; return { + ...state, notes: [ ...state.notes, { @@ -83,6 +87,7 @@ function notesReducer(state: NotesState, event: SocketEvent): NotesState { if (!before) return state; return { + ...state, notes: state.notes.map((note) => note.id === payload.id ? { @@ -108,6 +113,7 @@ function notesReducer(state: NotesState, event: SocketEvent): NotesState { if (!before) return state; return { + ...state, notes: state.notes.filter((note) => note.id !== payload.id), rollbacks: { ...state.rollbacks, [payload.ref]: before }, }; @@ -123,6 +129,7 @@ function notesReducer(state: NotesState, event: SocketEvent): NotesState { : state.notes; return { + ...state, notes: upsert(notes, toNote(payload)), rollbacks: forget(state.rollbacks, payload.ref), }; @@ -132,6 +139,7 @@ function notesReducer(state: NotesState, event: SocketEvent): NotesState { const payload = event.payload as Note & Partial; return { + ...state, notes: upsert(state.notes, toNote(payload)), rollbacks: forget(state.rollbacks, payload.ref), }; @@ -141,6 +149,7 @@ function notesReducer(state: NotesState, event: SocketEvent): NotesState { const payload = event.payload as { id: string } & Partial; return { + ...state, notes: state.notes.filter((note) => note.id !== payload.id), rollbacks: forget(state.rollbacks, payload.ref), }; @@ -153,6 +162,7 @@ function notesReducer(state: NotesState, event: SocketEvent): NotesState { const before = state.rollbacks[ref]; return { + ...state, notes: before === null ? state.notes.filter((note) => note.id !== ref) @@ -229,5 +239,10 @@ export function useNotes() { }); }, [retro.id]); - return { notes: state.notes, groupedNotes, dispatch: dispatchAndSend }; + return { + notes: state.notes, + groupedNotes, + loaded: state.loaded, + dispatch: dispatchAndSend, + }; } diff --git a/ui/src/index.css b/ui/src/index.css index 9fdd4e1..6fad9c8 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -32,6 +32,8 @@ --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + --color-surface: var(--surface); + --color-surface-raised: var(--surface-raised); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); @@ -68,11 +70,19 @@ --border: oklch(0.92 0.004 286.32); --input: oklch(0.92 0.004 286.32); --ring: oklch(0.705 0.015 286.067); - --chart-1: oklch(0.871 0.006 286.286); - --chart-2: oklch(0.552 0.016 285.938); - --chart-3: oklch(0.442 0.017 285.786); - --chart-4: oklch(0.37 0.013 285.805); - --chart-5: oklch(0.274 0.006 286.033); + + /* Column accents. Five hues at matched lightness and chroma so no column + shouts louder than its neighbours. Assigned by position, not stored. */ + --chart-1: oklch(0.58 0.19 292); + --chart-2: oklch(0.6 0.15 220); + --chart-3: oklch(0.62 0.15 165); + --chart-4: oklch(0.68 0.15 65); + --chart-5: oklch(0.6 0.18 15); + + /* Layered backgrounds, so cards do not have to sit on flat white. */ + --surface: oklch(0.985 0.002 286); + --surface-raised: oklch(1 0 0); + --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-primary: oklch(0.541 0.281 293.009); @@ -102,11 +112,18 @@ --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.552 0.016 285.938); - --chart-1: oklch(0.871 0.006 286.286); - --chart-2: oklch(0.552 0.016 285.938); - --chart-3: oklch(0.442 0.017 285.786); - --chart-4: oklch(0.37 0.013 285.805); - --chart-5: oklch(0.274 0.006 286.033); + + /* Lifted and slightly desaturated: the same hues read as neon on a dark + background at the light-mode chroma. */ + --chart-1: oklch(0.7 0.17 292); + --chart-2: oklch(0.72 0.13 220); + --chart-3: oklch(0.74 0.13 165); + --chart-4: oklch(0.79 0.13 65); + --chart-5: oklch(0.71 0.16 15); + + --surface: oklch(0.176 0.005 285.8); + --surface-raised: oklch(0.235 0.006 285.9); + --sidebar: oklch(0.21 0.006 285.885); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.606 0.25 292.717); diff --git a/ui/src/lib/column-accent.ts b/ui/src/lib/column-accent.ts new file mode 100644 index 0000000..a47fd9c --- /dev/null +++ b/ui/src/lib/column-accent.ts @@ -0,0 +1,47 @@ +/** + * Column accents are derived from position, not stored, so no migration and no + * change to the creation form. Five hues cover the maximum column count. + * + * Once notes are grouped and reordered by vote, colour is the only thing left + * that says which column a thought came from. + */ +const accents = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +] as const; + +export function accentForIndex(index: number): string { + return accents[((index % accents.length) + accents.length) % accents.length]; +} + +/** + * CSS custom property carrying a column's accent. Set it on the column and + * anything inside can reach it with `[color:var(--accent)]` and friends, + * without threading a prop through every child. + */ +export function accentStyle(index: number): React.CSSProperties { + return { "--accent": accentForIndex(index) } as React.CSSProperties; +} + +/** Deterministic accent for a person, used for author initials. */ +export function accentForName(name: string): string { + let hash = 0; + + for (let i = 0; i < name.length; i++) { + hash = (hash * 31 + name.charCodeAt(i)) | 0; + } + + return accentForIndex(Math.abs(hash)); +} + +export function initialsFor(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} diff --git a/ui/src/lib/motion.ts b/ui/src/lib/motion.ts new file mode 100644 index 0000000..dd99670 --- /dev/null +++ b/ui/src/lib/motion.ts @@ -0,0 +1,55 @@ +import type { Transition, Variants } from "motion/react"; + +/** + * A small shared motion vocabulary. Everything on the board pulls from here so + * the whole app moves with one personality rather than a dozen ad-hoc easings. + * + * Reduced motion is handled globally by + * in main.tsx, so nothing here needs to guard for it. + */ + +/** The workhorse: quick, barely overshoots. Layout changes and reflows. */ +export const spring: Transition = { + type: "spring", + stiffness: 400, + damping: 32, + mass: 0.6, +}; + +/** Livelier, with a visible bounce. Reserved for moments worth noticing. */ +export const springy: Transition = { + type: "spring", + stiffness: 550, + damping: 22, + mass: 0.5, +}; + +/** For opacity and colour, where a spring reads as a wobble. */ +export const ease: Transition = { + duration: 0.18, + ease: [0.32, 0.72, 0, 1], +}; + +/** Notes and cards arriving and leaving. */ +export const cardVariants: Variants = { + initial: { opacity: 0, y: 8, scale: 0.96 }, + animate: { opacity: 1, y: 0, scale: 1, transition: spring }, + exit: { opacity: 0, scale: 0.94, transition: ease }, +}; + +/** Panels and stage content. */ +export const panelVariants: Variants = { + initial: { opacity: 0, y: 6 }, + animate: { opacity: 1, y: 0, transition: ease }, + exit: { opacity: 0, y: -6, transition: ease }, +}; + +const maxStagger = 0.24; + +/** + * Staggers a list without letting a long column cascade for seconds. Capped so + * the sixtieth note is not still arriving after everything else has settled. + */ +export function stagger(index: number, step = 0.03): Transition { + return { ...spring, delay: Math.min(index * step, maxStagger) }; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 5b9f00d..f9fbf2f 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,4 +1,5 @@ import { RouterProvider } from "@tanstack/react-router"; +import { domMax, LazyMotion, MotionConfig } from "motion/react"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import AuthProvider from "./components/auth.tsx"; @@ -16,11 +17,20 @@ function InnerApp() { function App() { return ( - - - - - + // reducedMotion="user" honours the OS setting everywhere at once, so no + // component has to remember to check it. + + {/* domMax rather than domAnimation: the board leans on layout and + shared-element transitions. strict keeps us on `m.*`, so the full + motion bundle can never sneak back in. */} + + + + + + + + ); } diff --git a/ui/src/routes/_auth.index.tsx b/ui/src/routes/_auth.index.tsx index f2a7943..957542c 100644 --- a/ui/src/routes/_auth.index.tsx +++ b/ui/src/routes/_auth.index.tsx @@ -3,8 +3,10 @@ import Creator from "@/components/retro/creator"; import Hero from "@/components/retro/hero"; import List from "@/components/retro/list"; import { api } from "@/lib/api"; +import { panelVariants } from "@/lib/motion"; import { Retro } from "@/types"; import { createFileRoute } from "@tanstack/react-router"; +import { m } from "motion/react"; export const Route = createFileRoute("/_auth/")({ component: RouteComponent, @@ -31,12 +33,18 @@ function RouteComponent() { return ( - + + + -
    + {/* The list leads now; creating a retro is one click behind a dialog + rather than a permanently open form taking half the page. */} +
    +

    Recent retros

    -
    + + ); } diff --git a/ui/src/routes/_auth.tags.$tag.tsx b/ui/src/routes/_auth.tags.$tag.tsx index 4e3b98e..838c790 100644 --- a/ui/src/routes/_auth.tags.$tag.tsx +++ b/ui/src/routes/_auth.tags.$tag.tsx @@ -115,9 +115,9 @@ function RouteComponent() { {retros.length === 0 ? (

    No retrospectives found for this tag.

    ) : ( -
    - {retros.map((retro: Retro) => ( - +
    + {retros.map((retro: Retro, i: number) => ( + ))}
    )} From d543c16326752b3cbfc73783e7b6a0673e72281b Mon Sep 17 00:00:00 2001 From: Ellis Green Date: Mon, 10 Aug 2026 23:57:40 +0100 Subject: [PATCH 05/17] Drop the launch config added while testing It pointed at ./build/thoughts, which only exists after a bundled build, so it would fail for anyone who ran it without building first. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 6234f2e..0000000 --- a/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "thoughts", - "runtimeExecutable": "./build/thoughts", - "runtimeArgs": [], - "port": 3000 - } - ] -} From 4791bb729d0a7fe19b09eaaedee2ed4f3f882a98 Mon Sep 17 00:00:00 2001 From: Ellis Green Date: Tue, 11 Aug 2026 08:44:04 +0100 Subject: [PATCH 06/17] Make the session the source of truth for auth Logging in returned 200 and then /api/retros and /api/stats came back 401. The cause: the route guard treated localStorage as proof of authentication. localStorage only caches the name so the nav can render immediately - the session cookie is the credential, and it can expire or stop resolving to a user without the browser saying so. Land on the app with a stale cache and _auth/beforeLoad waved you through, the loaders fired, and every one of them 401'd. The auth provider now asks the server before anything decides what to render, and nothing mounts the router until that answer arrives. A stale cache with no cookie is now a single /api/auth/self 401 and the login screen, rather than four failing requests and a bounce. Two things made it stick rather than recover: - The axios interceptor threw a router redirect on 401. That is only caught inside a loader; from the provider's own effect it was a swallowed rejection that had already cleared the stored user on the way past, so a slow /api/auth/self could wipe a login that had just succeeded. The interceptor now reports expiry to the auth layer and the router decides where to send people, so a session dying mid-use is one 401 and a clean trip to the login screen. - Nav redirected to /login whenever it had no user, racing the route guard it duplicated. Removed; the guard owns this. Server: the auth middleware wrote 500 on a lookup failure and then carried on into the handler with a nil user, where UserFromRequest panicked. Covered by a test, along with the sessions that legitimately fail to resolve - no cookie, a cookie signed with a rotated key, and one pointing at a user that no longer exists. Login also tells you when it fails now, instead of the rejection vanishing. Co-Authored-By: Claude Opus 5 --- cmd/thoughts/auth/auth.go | 4 + cmd/thoughts/auth/auth_test.go | 146 +++++++++++++++++++++++++++++++++ ui/src/components/auth.tsx | 72 +++++++++++----- ui/src/components/nav.tsx | 9 +- ui/src/hooks/use-auth.ts | 12 +++ ui/src/lib/api.ts | 32 ++++++-- ui/src/main.tsx | 19 ++++- ui/src/routes/login.tsx | 34 +++++--- 8 files changed, 279 insertions(+), 49 deletions(-) create mode 100644 cmd/thoughts/auth/auth_test.go diff --git a/cmd/thoughts/auth/auth.go b/cmd/thoughts/auth/auth.go index 50f4d76..c1f23a7 100644 --- a/cmd/thoughts/auth/auth.go +++ b/cmd/thoughts/auth/auth.go @@ -40,6 +40,10 @@ func Middleware(db *sqlx.DB, sp *session.Provider) mux.MiddlewareFunc { slog.Error("failed to get user", "error", err) w.WriteHeader(http.StatusInternalServerError) + + // Without this the handler ran on with a nil user, and + // UserFromRequest panicked on the way through. + return } r = RequestWithUser(r, user) diff --git a/cmd/thoughts/auth/auth_test.go b/cmd/thoughts/auth/auth_test.go new file mode 100644 index 0000000..5a479fe --- /dev/null +++ b/cmd/thoughts/auth/auth_test.go @@ -0,0 +1,146 @@ +package auth_test + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/ellgreen/thoughts/cmd/thoughts/auth" + "github.com/ellgreen/thoughts/cmd/thoughts/dal" + "github.com/ellgreen/thoughts/cmd/thoughts/session" + "github.com/ellgreen/thoughts/cmd/thoughts/testutil" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +// sessionFor issues a request carrying a session cookie for the given user id. +func sessionFor(t *testing.T, sp *session.Provider, userID uuid.UUID) *http.Request { + t.Helper() + + recorder := httptest.NewRecorder() + seed := httptest.NewRequest(http.MethodGet, "/api/retros", nil) + + if err := sp.AddUserID(recorder, seed, userID); err != nil { + t.Fatalf("failed to seed session: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/retros", nil) + for _, cookie := range recorder.Result().Cookies() { + req.AddCookie(cookie) + } + + return req +} + +func serve(t *testing.T, db *sqlx.DB, sp *session.Provider, req *http.Request) *httptest.ResponseRecorder { + t.Helper() + + reached := false + + handler := auth.Middleware(db, sp)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + + // Panics here are the failure mode we care about: the middleware used + // to fall through with a nil user after writing an error status. + if user := auth.UserFromRequest(r); user == nil { + t.Error("handler reached with no user") + } + + w.WriteHeader(http.StatusOK) + })) + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK && reached { + t.Errorf("handler ran despite a %d response", recorder.Code) + } + + return recorder +} + +func newProvider(t *testing.T) *session.Provider { + t.Helper() + + sp, err := session.LoadSessionProvider(filepath.Join(t.TempDir(), "session.key")) + if err != nil { + t.Fatalf("failed to load session provider: %v", err) + } + + return sp +} + +func TestMiddlewareAllowsAKnownUser(t *testing.T) { + db := testutil.NewDB(t) + sp := newProvider(t) + + user, err := dal.UserGetOrCreate(context.Background(), db, "Ada") + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + if code := serve(t, db, sp, sessionFor(t, sp, user.ID)).Code; code != http.StatusOK { + t.Errorf("got %d, want 200", code) + } +} + +func TestMiddlewareRejectsASessionForAUserThatNoLongerExists(t *testing.T) { + db := testutil.NewDB(t) + sp := newProvider(t) + + // The state people end up in when their identity row has gone: the cookie + // still decodes, it just points at nobody. This has to be a clean 401 so + // the client knows to send them back to the login screen. + code := serve(t, db, sp, sessionFor(t, sp, uuid.New())).Code + + if code != http.StatusUnauthorized { + t.Errorf("got %d, want 401", code) + } +} + +func TestMiddlewareRejectsARequestWithNoSession(t *testing.T) { + db := testutil.NewDB(t) + sp := newProvider(t) + + req := httptest.NewRequest(http.MethodGet, "/api/retros", nil) + + if code := serve(t, db, sp, req).Code; code != http.StatusUnauthorized { + t.Errorf("got %d, want 401", code) + } +} + +func TestMiddlewareRejectsAnUndecodableCookie(t *testing.T) { + db := testutil.NewDB(t) + sp := newProvider(t) + + // A cookie signed with a different key, which is what a rotated session + // key looks like from the browser's side. + other := newProvider(t) + req := sessionFor(t, other, uuid.New()) + + if code := serve(t, db, sp, req).Code; code != http.StatusUnauthorized { + t.Errorf("got %d, want 401", code) + } +} + +func TestMiddlewareStopsOnALookupFailure(t *testing.T) { + db := testutil.NewDB(t) + sp := newProvider(t) + + user, err := dal.UserGetOrCreate(context.Background(), db, "Ada") + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + req := sessionFor(t, sp, user.ID) + + // Any lookup failure that is not "no such row". The middleware wrote a 500 + // and then carried on into the handler with a nil user, which panicked. + db.Close() + + if code := serve(t, db, sp, req).Code; code != http.StatusInternalServerError { + t.Errorf("got %d, want 500", code) + } +} diff --git a/ui/src/components/auth.tsx b/ui/src/components/auth.tsx index f66a9a2..d80d283 100644 --- a/ui/src/components/auth.tsx +++ b/ui/src/components/auth.tsx @@ -1,5 +1,10 @@ -import { AuthContext, getStoredUser, setStoredUser } from "@/hooks/use-auth"; -import { api } from "@/lib/api"; +import { + AuthContext, + AuthStatus, + getStoredUser, + setStoredUser, +} from "@/hooks/use-auth"; +import { api, setSessionExpiredHandler } from "@/lib/api"; import { User } from "@/types"; import { useCallback, useEffect, useState } from "react"; @@ -8,51 +13,76 @@ export default function AuthProvider({ }: { children: React.ReactNode; }) { + // Seeded from the cache purely so the name is there on first paint. Status + // stays "pending" until the server has spoken. const [user, setUser] = useState(getStoredUser()); + const [status, setStatus] = useState("pending"); - const isAuthenticated = !!user; - - const logout = useCallback(async () => { - await api.post("/api/auth/logout"); - + const clearSession = useCallback(() => { setStoredUser(null); setUser(null); + setStatus("anonymous"); }, []); const login = useCallback(async (name: string) => { const res = await api.post("/api/auth/login", { name }); - if (res && res.status === 200) { - setStoredUser(res.data); - setUser(res.data); - } + setStoredUser(res.data); + setUser(res.data); + setStatus("authenticated"); }, []); - useEffect(() => { - const stored = getStoredUser(); - setUser(stored); + const logout = useCallback(async () => { + // Drop the local session even if the request fails; staying "logged in" + // against a server that has forgotten us is the worse outcome. + await api.post("/api/auth/logout").catch(() => {}); + + clearSession(); + }, [clearSession]); + // Ask the server whether the cookie is still good. Without this the router + // guards on a cached name, walks into the app with a dead session, and every + // loader comes back 401. + useEffect(() => { let cancelled = false; api .get("/api/auth/self") .then((res) => { - if (!cancelled && res.status === 200) { - setStoredUser(res.data); - setUser(res.data); - } + if (cancelled) return; + + setStoredUser(res.data); + setUser(res.data); + setStatus("authenticated"); }) .catch(() => { - // ignore; the axios interceptor already handles 401s by redirecting + if (cancelled) return; + + clearSession(); }); return () => { cancelled = true; }; - }, []); + }, [clearSession]); + + // A session can also die mid-use. Any 401 from a real endpoint lands here. + useEffect(() => { + setSessionExpiredHandler(clearSession); + + return () => setSessionExpiredHandler(() => {}); + }, [clearSession]); return ( - + {children} ); diff --git a/ui/src/components/nav.tsx b/ui/src/components/nav.tsx index 1896716..5b352dd 100644 --- a/ui/src/components/nav.tsx +++ b/ui/src/components/nav.tsx @@ -1,9 +1,8 @@ import Container from "@/components/container"; import { useAuth } from "@/hooks/use-auth"; import useTheme, { Theme } from "@/hooks/use-theme"; -import { Link, useNavigate } from "@tanstack/react-router"; +import { Link } from "@tanstack/react-router"; import { LogOut, Monitor, Moon, Sun } from "lucide-react"; -import { useEffect } from "react"; import { Button } from "./ui/button"; import { DropdownMenu, @@ -19,12 +18,6 @@ import { export default function Nav() { const { theme, setTheme } = useTheme(); const { user, logout } = useAuth(); - const navigate = useNavigate(); - - useEffect(() => { - if (user) return; - navigate({ to: "/login" }); - }, [user]); const themeIcon = theme === "dark" ? : theme === "light" ? : ; diff --git a/ui/src/hooks/use-auth.ts b/ui/src/hooks/use-auth.ts index edf8043..599e22c 100644 --- a/ui/src/hooks/use-auth.ts +++ b/ui/src/hooks/use-auth.ts @@ -1,7 +1,14 @@ import { User } from "@/types"; import { createContext, useContext } from "react"; +/** + * "pending" until the server has confirmed whether the session cookie is still + * good. Nothing may decide what to render until it resolves. + */ +export type AuthStatus = "pending" | "authenticated" | "anonymous"; + export interface AuthContext { + status: AuthStatus; isAuthenticated: boolean; login: (name: string) => Promise; logout: () => Promise; @@ -13,6 +20,11 @@ export const AuthContext = createContext(null); const nameKeyName = "thoughts.auth.user.name"; const aiKeyName = "thoughts.auth.user.ai_enabled"; +/** + * Cached so the nav can show a name on first paint. This is a convenience + * cache, never proof of anything: the session cookie is the only credential, + * and it can expire or be invalidated without the browser telling us. + */ export function getStoredUser(): User | null { const name = localStorage.getItem(nameKeyName); const ai_enabled = JSON.parse(localStorage.getItem(aiKeyName) ?? "false"); diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 9d7f80a..9cde960 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -1,5 +1,3 @@ -import { setStoredUser } from "@/hooks/use-auth"; -import { redirect } from "@tanstack/react-router"; import axios from "axios"; import { toast } from "sonner"; @@ -8,10 +6,30 @@ export const api = axios.create({ baseURL: import.meta.env.DEV ? "http://localhost:3000" : undefined, }); +/** Endpoints where a 401 is a normal answer, not an expired session. */ +const authEndpoints = ["/api/auth/self", "/api/auth/login"]; + +type SessionExpiredHandler = () => void; + +let onSessionExpired: SessionExpiredHandler = () => {}; + +/** + * Registered by the auth provider. The interceptor cannot redirect on its own: + * a thrown router redirect is only caught inside a loader, so from a plain + * component effect it becomes a swallowed rejection while still having wiped + * the stored user on the way past. + */ +export function setSessionExpiredHandler(handler: SessionExpiredHandler) { + onSessionExpired = handler; +} + api.interceptors.response.use( (response) => response, (error) => { - if (error.response?.status === 400) { + const status = error.response?.status; + const url: string = error.config?.url ?? ""; + + if (status === 400) { toast("There was a problem with the request", { description: error.response.data, }); @@ -19,10 +37,10 @@ api.interceptors.response.use( return Promise.reject(error); } - if (error.response?.status === 401) { - setStoredUser(null); - - throw redirect({ to: "/login" }); + if (status === 401 && !authEndpoints.some((path) => url.startsWith(path))) { + // The cookie is gone or no longer valid. Tell the auth layer and let the + // router decide where to send people. + onSessionExpired(); } return Promise.reject(error); diff --git a/ui/src/main.tsx b/ui/src/main.tsx index f9fbf2f..87ebe2b 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,9 +1,10 @@ import { RouterProvider } from "@tanstack/react-router"; import { domMax, LazyMotion, MotionConfig } from "motion/react"; -import { StrictMode } from "react"; +import { StrictMode, useEffect } from "react"; import { createRoot } from "react-dom/client"; import AuthProvider from "./components/auth.tsx"; import ThemeProvider from "./components/theme.tsx"; +import { Spinner } from "./components/ui/spinner.tsx"; import { useAuth } from "./hooks/use-auth.ts"; import "@fontsource-variable/inter"; import "@fontsource-variable/space-grotesk"; @@ -12,6 +13,22 @@ import { router } from "./router.tsx"; function InnerApp() { const auth = useAuth(); + + // A session going stale has to re-run the route guards, otherwise whatever + // is on screen keeps firing requests that will only ever 401. + useEffect(() => { + router.invalidate(); + }, [auth.status]); + + // Guarded routes must not load until we know whether the session is real. + if (auth.status === "pending") { + return ( +
    + +
    + ); + } + return ; } diff --git a/ui/src/routes/login.tsx b/ui/src/routes/login.tsx index a51c636..5d6b753 100644 --- a/ui/src/routes/login.tsx +++ b/ui/src/routes/login.tsx @@ -17,7 +17,7 @@ import { import { Input } from "@/components/ui/input"; import { useAuth } from "@/hooks/use-auth"; import { zodResolver } from "@hookform/resolvers/zod"; -import { createFileRoute, useRouter } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; import { useEffect } from "react"; import { FieldValues, useForm } from "react-hook-form"; import { z } from "zod"; @@ -42,8 +42,7 @@ const schema = z.object({ }); function LoginForm() { - const { user, login } = useAuth(); - const router = useRouter(); + const { isAuthenticated, login } = useAuth(); const navigate = Route.useNavigate(); const search = Route.useSearch(); @@ -55,18 +54,24 @@ function LoginForm() { }); async function handleSubmit(data: FieldValues) { - await login(data.name); + try { + await login(data.name); + } catch { + // Anything the server rejected outright, so it lands on the field the + // person can actually do something about rather than vanishing. + form.setError("name", { + message: "We couldn't sign you in. Please try again.", + }); + } } + // Driven by the verified session, not by a cached name: landing here with a + // stale cache used to bounce straight back into the app and 401. useEffect(() => { - if (!user) return; + if (!isAuthenticated) return; - router.invalidate().then(() => { - navigate({ - to: search.redirect || "/", - }); - }); - }, [user]); + navigate({ to: search.redirect || "/" }); + }, [isAuthenticated, navigate, search.redirect]); return ( @@ -95,7 +100,12 @@ function LoginForm() { )} /> - + From cc7e758eecad0b3aea798f33c6364113f7f5470f Mon Sep 17 00:00:00 2001 From: Ellis Green Date: Tue, 11 Aug 2026 09:00:32 +0100 Subject: [PATCH 07/17] Stop marking the session cookie Secure on a plain http server The actual reason logins would not stick. gorilla/sessions v1.4.0 builds NewCookieStore with Secure: true and SameSite=None, so the session cookie was only ever storable over https. Chrome treats http://localhost as a trustworthy origin and keeps it anyway, which is why this went unnoticed; Safari does not, and neither does anything else served over plain http. The login returned 200 with a Set-Cookie the browser then threw away, so every request after it came back 401. The options are now set explicitly rather than inherited: - Secure only when this server terminates TLS, or when the request arrived over https, so a deployment behind a TLS-terminating proxy still gets it. - SameSite=Lax rather than None. Everything here is same-site, and None cannot be used without Secure. - HttpOnly, which gorilla did not set. Nothing reads this cookie from JavaScript. CORS is now credential-aware whether or not the UI is bundled. A wildcard origin cannot carry credentials at all, so pointing a browser at the Vite dev server while a bundled binary served the API silently dropped the cookie in the same way. Rejections now say why, under THOUGHTS_VERBOSE=true. The useful part is whether a cookie arrived at all: none means the browser never stored it, one that names nobody means it failed to decode. Co-Authored-By: Claude Opus 5 --- cmd/thoughts/auth/auth.go | 29 ++++++ cmd/thoughts/auth/auth_test.go | 2 +- cmd/thoughts/main.go | 31 +++--- cmd/thoughts/session/session.go | 61 +++++++++++- cmd/thoughts/session/session_test.go | 137 +++++++++++++++++++++++++++ 5 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 cmd/thoughts/session/session_test.go diff --git a/cmd/thoughts/auth/auth.go b/cmd/thoughts/auth/auth.go index c1f23a7..cb46ab1 100644 --- a/cmd/thoughts/auth/auth.go +++ b/cmd/thoughts/auth/auth.go @@ -27,14 +27,22 @@ func Middleware(db *sqlx.DB, sp *session.Provider) mux.MiddlewareFunc { return } + // The single most useful thing when someone cannot stay logged + // in: whether the browser sent a cookie at all. None means it + // was never stored (wrong origin, blocked cookies); one that + // carries no user means it failed to decode. + rejected(r, "session carries no user", "cookie_sent", hasSessionCookie(r)) w.WriteHeader(http.StatusUnauthorized) + return } user, err := dal.UserGet(r.Context(), db, userID) if err != nil { if errors.Is(err, sql.ErrNoRows) { + rejected(r, "session names a user that no longer exists", "user_id", userID) w.WriteHeader(http.StatusUnauthorized) + return } @@ -53,6 +61,27 @@ func Middleware(db *sqlx.DB, sp *session.Provider) mux.MiddlewareFunc { }) } +// rejected records why a request was turned away. Debug level, so it is there +// under THOUGHTS_VERBOSE=true when someone is stuck without being noise the +// rest of the time: every logged-out page load produces one of these. +func rejected(r *http.Request, reason string, args ...any) { + slog.Debug( + "rejecting unauthenticated request", + append([]any{ + "reason", reason, + "path", r.URL.Path, + "origin", r.Header.Get("Origin"), + "host", r.Host, + }, args...)..., + ) +} + +func hasSessionCookie(r *http.Request) bool { + _, err := r.Cookie("session") + + return err == nil +} + func RequestWithUser(r *http.Request, user *model.User) *http.Request { ctx := r.Context() diff --git a/cmd/thoughts/auth/auth_test.go b/cmd/thoughts/auth/auth_test.go index 5a479fe..85d0b1b 100644 --- a/cmd/thoughts/auth/auth_test.go +++ b/cmd/thoughts/auth/auth_test.go @@ -64,7 +64,7 @@ func serve(t *testing.T, db *sqlx.DB, sp *session.Provider, req *http.Request) * func newProvider(t *testing.T) *session.Provider { t.Helper() - sp, err := session.LoadSessionProvider(filepath.Join(t.TempDir(), "session.key")) + sp, err := session.LoadSessionProvider(filepath.Join(t.TempDir(), "session.key"), false) if err != nil { t.Fatalf("failed to load session provider: %v", err) } diff --git a/cmd/thoughts/main.go b/cmd/thoughts/main.go index ba7667c..9cf25ae 100644 --- a/cmd/thoughts/main.go +++ b/cmd/thoughts/main.go @@ -11,7 +11,6 @@ import ( "github.com/ellgreen/thoughts/cmd/thoughts/gif" "github.com/ellgreen/thoughts/cmd/thoughts/session" "github.com/ellgreen/thoughts/migrations" - "github.com/ellgreen/thoughts/ui" "github.com/gorilla/mux" "github.com/jmoiron/sqlx" "github.com/pressly/goose/v3" @@ -46,8 +45,10 @@ func main() { os.Exit(1) } + servesTLS := cfg.TLSCertPath != "" && cfg.TLSKeyPath != "" + sessionKeyPath := filepath.Join(cfg.DataPath, cfg.SessionKeyFile) - sessionProvider, err := session.LoadSessionProvider(sessionKeyPath) + sessionProvider, err := session.LoadSessionProvider(sessionKeyPath, servesTLS) if err != nil { slog.Error("failed to load session provider", "err", err) os.Exit(1) @@ -73,19 +74,19 @@ func main() { gifProvider, ) - corsCfg := cors.Default() - if !ui.IsBundled() { - corsCfg = cors.New(cors.Options{ - AllowedOrigins: []string{cfg.UIAddress}, - AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Authorization", "Content-Type"}, - AllowCredentials: true, - }) - } - - handler := corsCfg.Handler(router) - - if cfg.TLSCertPath != "" && cfg.TLSKeyPath != "" { + // Always credential-aware, bundled or not. A wildcard origin cannot carry + // credentials at all, so a browser pointed at the Vite dev server while a + // bundled binary served the API would silently drop the session cookie: + // the login looked like a 200 in the network tab and every request after + // it came back 401. + handler := cors.New(cors.Options{ + AllowedOrigins: []string{cfg.UIAddress}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Authorization", "Content-Type"}, + AllowCredentials: true, + }).Handler(router) + + if servesTLS { slog.Info("starting server (with tls)", "addr", cfg.Address) if err := http.ListenAndServeTLS(cfg.Address, cfg.TLSCertPath, cfg.TLSKeyPath, handler); err != nil { diff --git a/cmd/thoughts/session/session.go b/cmd/thoughts/session/session.go index 3b4d656..c1e2531 100644 --- a/cmd/thoughts/session/session.go +++ b/cmd/thoughts/session/session.go @@ -6,27 +6,75 @@ import ( "log/slog" "net/http" "os" + "strings" "github.com/google/uuid" "github.com/gorilla/securecookie" "github.com/gorilla/sessions" ) -const keyLength = 64 +const ( + keyLength = 64 + + // Thirty days, matching what gorilla/sessions would have used. + sessionMaxAge = 86400 * 30 +) var ErrValueNotFound = errors.New("session: value not found") type Provider struct { store sessions.Store + + // tls is set when the server terminates TLS itself. A request arriving + // over https through a proxy is detected per request instead. + tls bool } -func LoadSessionProvider(keyPath string) (*Provider, error) { +// LoadSessionProvider builds the cookie session store. tls says whether this +// server terminates TLS, which decides whether the cookie may be marked +// Secure. +func LoadSessionProvider(keyPath string, tls bool) (*Provider, error) { key, err := loadKey(keyPath) if err != nil { return nil, err } - return &Provider{store: sessions.NewCookieStore(key)}, nil + store := sessions.NewCookieStore(key) + + // gorilla/sessions defaults to Secure with SameSite=None, so the cookie is + // only ever stored over https. Chrome makes an exception for + // http://localhost; Safari does not, which left people logged out with a + // login that looked like it had worked. Set the options ourselves. + store.Options = defaultOptions(tls) + + return &Provider{store: store, tls: tls}, nil +} + +func defaultOptions(secure bool) *sessions.Options { + return &sessions.Options{ + Path: "/", + MaxAge: sessionMaxAge, + // The session cookie is never read from JavaScript. + HttpOnly: true, + // Lax, not None: everything here is same-site, and None would drag the + // Secure requirement back in with it. + SameSite: http.SameSiteLaxMode, + Secure: secure, + } +} + +// optionsFor allows Secure when the request itself arrived over https, so a +// deployment behind a TLS-terminating proxy still gets a Secure cookie. +func (sp *Provider) optionsFor(r *http.Request) *sessions.Options { + return defaultOptions(sp.tls || isHTTPS(r)) +} + +func isHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") } func loadKey(path string) ([]byte, error) { @@ -53,12 +101,17 @@ func loadKey(path string) ([]byte, error) { func (sp *Provider) Get(w http.ResponseWriter, r *http.Request) (*sessions.Session, error) { sess, err := sp.store.Get(r, "session") + if sess != nil { + // Applies to this response's cookie, whatever the store was built with. + sess.Options = sp.optionsFor(r) + } + if err != nil { if sess == nil { return nil, fmt.Errorf("failed to get session: %w", err) } - slog.Warn("failed to get session, creating a new one", "err", err) + slog.Debug("could not decode the session cookie, starting a new one", "err", err) if err := sess.Save(r, w); err != nil { return nil, fmt.Errorf("failed to save session after retry: %w", err) diff --git a/cmd/thoughts/session/session_test.go b/cmd/thoughts/session/session_test.go new file mode 100644 index 0000000..fd8eada --- /dev/null +++ b/cmd/thoughts/session/session_test.go @@ -0,0 +1,137 @@ +package session_test + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/ellgreen/thoughts/cmd/thoughts/session" + "github.com/google/uuid" +) + +func newProvider(t *testing.T, tls bool) *session.Provider { + t.Helper() + + sp, err := session.LoadSessionProvider(filepath.Join(t.TempDir(), "session.key"), tls) + if err != nil { + t.Fatalf("failed to load session provider: %v", err) + } + + return sp +} + +// issue performs a login-shaped save and returns the cookie that came back. +func issue(t *testing.T, sp *session.Provider, req *http.Request) *http.Cookie { + t.Helper() + + recorder := httptest.NewRecorder() + + if err := sp.AddUserID(recorder, req, uuid.New()); err != nil { + t.Fatalf("failed to save session: %v", err) + } + + cookies := recorder.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("no cookie was set") + } + + return cookies[len(cookies)-1] +} + +func TestCookieIsNotSecureOverPlainHTTP(t *testing.T) { + // gorilla/sessions defaults to Secure with SameSite=None, so the cookie was + // only ever stored over https. Chrome lets http://localhost away with it, + // Safari does not, and the login silently failed to stick. + cookie := issue(t, newProvider(t, false), httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)) + + if cookie.Secure { + t.Error("cookie is marked Secure on a plain http server, so browsers will discard it") + } + + if cookie.SameSite == http.SameSiteNoneMode { + t.Error("SameSite=None requires Secure, which drags the same problem back in") + } +} + +func TestCookieIsSecureWhenServingTLS(t *testing.T) { + cookie := issue(t, newProvider(t, true), httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)) + + if !cookie.Secure { + t.Error("cookie should be Secure when the server terminates TLS") + } +} + +func TestCookieIsSecureBehindAnHTTPSProxy(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil) + req.Header.Set("X-Forwarded-Proto", "https") + + cookie := issue(t, newProvider(t, false), req) + + if !cookie.Secure { + t.Error("cookie should be Secure when the request arrived over https") + } +} + +func TestCookieIsHttpOnly(t *testing.T) { + cookie := issue(t, newProvider(t, false), httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)) + + if !cookie.HttpOnly { + t.Error("the session cookie is never read from JavaScript, so it should be HttpOnly") + } +} + +func TestRoundTripsTheUserID(t *testing.T) { + sp := newProvider(t, false) + + userID := uuid.New() + + recorder := httptest.NewRecorder() + if err := sp.AddUserID(recorder, httptest.NewRequest(http.MethodPost, "/api/auth/login", nil), userID); err != nil { + t.Fatalf("failed to save session: %v", err) + } + + next := httptest.NewRequest(http.MethodGet, "/api/retros", nil) + for _, c := range recorder.Result().Cookies() { + next.AddCookie(c) + } + + got, err := sp.GetUserID(httptest.NewRecorder(), next) + if err != nil { + t.Fatalf("failed to read the session back: %v", err) + } + + if got != userID { + t.Errorf("got %s, want %s", got, userID) + } +} + +func TestForgetClearsTheUserID(t *testing.T) { + sp := newProvider(t, false) + + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil) + + if err := sp.AddUserID(recorder, req, uuid.New()); err != nil { + t.Fatalf("failed to save session: %v", err) + } + + loggedIn := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) + for _, c := range recorder.Result().Cookies() { + loggedIn.AddCookie(c) + } + + out := httptest.NewRecorder() + if err := sp.ForgetUserID(out, loggedIn); err != nil { + t.Fatalf("failed to forget: %v", err) + } + + after := httptest.NewRequest(http.MethodGet, "/api/retros", nil) + for _, c := range out.Result().Cookies() { + after.AddCookie(c) + } + + if _, err := sp.GetUserID(httptest.NewRecorder(), after); err == nil { + t.Error("expected the session to no longer name a user") + } +} From f3b51f98ebc7e4564f0a7faf22e9bc8fe3dc6ebd Mon Sep 17 00:00:00 2001 From: Ellis Green Date: Tue, 11 Aug 2026 09:34:18 +0100 Subject: [PATCH 08/17] Rework the new retro screen Descriptions were single-line inputs, so anything longer than the box scrolled sideways out of sight - the one thing you most want to read back before creating a board. They are textareas that grow with their content now, with a character count that only speaks up near the limit. Sized in JS rather than with CSS field-sizing, which only Chrome supports. It also needs its own element and a merged ref: callers spread a react-hook-form field in, and that carries a ref of its own which was landing after ours and winning, leaving nothing to measure. The AI feature was an outline button in a row of three, hiding a bare popover. It is now a panel of its own with a gradient edge that spins while it thinks, a wand that waves, and one-click theme suggestions so nobody has to invent a prompt to find out what it does. Generating shows shimmering placeholder columns when the board is empty, and leaves columns you already have in place, dimmed - they are only replaced if the generation succeeds. Templates were a dropdown of bare names, so picking one was a guess. Each now shows its columns as chips in the colours they will be on the board. The rest of the screen: a wider dialog, columns as cards carrying their board accent, a count against the limit, a real empty state, hover-revealed remove, and the visibility toggle demoted from a bordered block to a line. Three fixes found while verifying this: - The AI prompt sat inside the create-retro form. HTML has no nested forms, so the browser dropped the inner one and its submit button belonged to the outer form: pressing Enter on a theme, or clicking Generate, submitted the retro instead of generating anything. - The generate call had no timeout, so an unreachable provider left someone watching a spinner. A bad key took two minutes to come back. - A generated template could exceed what the create endpoint accepts. The response is clamped to five columns and the field lengths, and max tokens raised from 250, which could not fit five columns and truncated the JSON into a parse failure. Also stops router.invalidate() firing on first mount, where beforeLoad ran before the router had its context and threw on every load. Co-Authored-By: Claude Opus 5 --- cmd/thoughts/ai/prompts/retro_template.go | 40 +- .../ai/prompts/retro_template_test.go | 93 ++++ cmd/thoughts/controllers/ai.go | 13 +- ui/src/components/retro/ai-retro-template.tsx | 269 ++++++---- ui/src/components/retro/creator.tsx | 503 ++++++++++++------ ui/src/components/retro/template-picker.tsx | 70 +++ ui/src/components/ui/auto-textarea.tsx | 75 +++ ui/src/components/ui/textarea.tsx | 11 +- ui/src/main.tsx | 9 +- 9 files changed, 811 insertions(+), 272 deletions(-) create mode 100644 cmd/thoughts/ai/prompts/retro_template_test.go create mode 100644 ui/src/components/retro/template-picker.tsx create mode 100644 ui/src/components/ui/auto-textarea.tsx diff --git a/cmd/thoughts/ai/prompts/retro_template.go b/cmd/thoughts/ai/prompts/retro_template.go index 26ed0e7..a73487c 100644 --- a/cmd/thoughts/ai/prompts/retro_template.go +++ b/cmd/thoughts/ai/prompts/retro_template.go @@ -8,6 +8,12 @@ import ( "github.com/tmc/langchaingo/llms" ) +const ( + // Mirrors the create endpoint's limits. + maxColumns = 5 + maxFieldLength = 255 +) + const retroTemplateSystemPrompt = `You are a backend service that generates JSON-only retrospective board templates. You MUST respond with a single JSON object matching exactly this schema: @@ -79,7 +85,9 @@ func GenerateRetroTemplate(ctx context.Context, model ai.Model, userPrompt strin }, llms.WithJSONMode(), llms.WithTemperature(0.9), - llms.WithMaxTokens(250), + // Five columns with descriptions do not fit in 250, and a truncated + // response is invalid JSON, so the whole generation failed. + llms.WithMaxTokens(700), ) if err != nil { return RetroTemplateResponse{}, fmt.Errorf("failed to generate content: %w", err) @@ -90,5 +98,33 @@ func GenerateRetroTemplate(ctx context.Context, model ai.Model, userPrompt strin return RetroTemplateResponse{}, fmt.Errorf("failed to parse response: %w", err) } - return resp, nil + return clamp(resp), nil +} + +// clamp brings a generated template inside what the create endpoint accepts. +// The prompt asks for 2 to 5 columns within the length limits, but a model is +// free to ignore that, and the failure would otherwise surface as a validation +// error on a form the person never filled in themselves. +func clamp(resp RetroTemplateResponse) RetroTemplateResponse { + if len(resp.Columns) > maxColumns { + resp.Columns = resp.Columns[:maxColumns] + } + + resp.Theme = truncate(resp.Theme, maxFieldLength) + + for i := range resp.Columns { + resp.Columns[i].Title = truncate(resp.Columns[i].Title, maxFieldLength) + resp.Columns[i].Description = truncate(resp.Columns[i].Description, maxFieldLength) + } + + return resp +} + +func truncate(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + + return string(runes[:limit]) } diff --git a/cmd/thoughts/ai/prompts/retro_template_test.go b/cmd/thoughts/ai/prompts/retro_template_test.go new file mode 100644 index 0000000..a71c700 --- /dev/null +++ b/cmd/thoughts/ai/prompts/retro_template_test.go @@ -0,0 +1,93 @@ +package prompts + +import ( + "strings" + "testing" +) + +func column(title, description string) struct { + Title string `json:"title"` + Description string `json:"description"` +} { + return struct { + Title string `json:"title"` + Description string `json:"description"` + }{Title: title, Description: description} +} + +func TestClampTrimsExtraColumns(t *testing.T) { + resp := RetroTemplateResponse{Theme: "Star Wars"} + + for range 8 { + resp.Columns = append(resp.Columns, column("A column ✨", "Something")) + } + + got := clamp(resp) + + // The create endpoint accepts at most five, and the person never chose + // these columns, so a validation error would be baffling. + if len(got.Columns) != maxColumns { + t.Errorf("got %d columns, want %d", len(got.Columns), maxColumns) + } +} + +func TestClampLeavesAGoodResponseAlone(t *testing.T) { + resp := RetroTemplateResponse{ + Theme: "Star Wars", + Columns: []struct { + Title string `json:"title"` + Description string `json:"description"` + }{ + column("Light Side Wins ✨", "What went well this sprint?"), + column("Dark Side Risks 🌑", "What is threatening us?"), + }, + } + + got := clamp(resp) + + if len(got.Columns) != 2 || got.Columns[0].Title != "Light Side Wins ✨" { + t.Errorf("a valid response was altered: %+v", got) + } + + if got.Theme != "Star Wars" { + t.Errorf("theme changed to %q", got.Theme) + } +} + +func TestClampTruncatesOverlongFields(t *testing.T) { + long := strings.Repeat("a", 400) + + got := clamp(RetroTemplateResponse{ + Theme: long, + Columns: []struct { + Title string `json:"title"` + Description string `json:"description"` + }{column(long, long)}, + }) + + if len([]rune(got.Theme)) != maxFieldLength { + t.Errorf("theme is %d runes, want %d", len([]rune(got.Theme)), maxFieldLength) + } + + if len([]rune(got.Columns[0].Title)) != maxFieldLength { + t.Errorf("title is %d runes, want %d", len([]rune(got.Columns[0].Title)), maxFieldLength) + } + + if len([]rune(got.Columns[0].Description)) != maxFieldLength { + t.Errorf("description is %d runes, want %d", len([]rune(got.Columns[0].Description)), maxFieldLength) + } +} + +func TestTruncateCountsRunesNotBytes(t *testing.T) { + // Titles end in an emoji by design, so cutting on bytes would leave a + // mangled rune at the end. + got := truncate(strings.Repeat("🚀", 10), 4) + + if len([]rune(got)) != 4 { + t.Errorf("got %d runes, want 4", len([]rune(got))) + } + + if !strings.HasSuffix(got, "🚀") { + t.Errorf("truncation split a rune: %q", got) + } +} diff --git a/cmd/thoughts/controllers/ai.go b/cmd/thoughts/controllers/ai.go index 82f49d7..96477d2 100644 --- a/cmd/thoughts/controllers/ai.go +++ b/cmd/thoughts/controllers/ai.go @@ -1,14 +1,20 @@ package controllers import ( + "context" "log/slog" "net/http" + "time" "github.com/ellgreen/thoughts/cmd/thoughts/ai" "github.com/ellgreen/thoughts/cmd/thoughts/ai/prompts" "github.com/ellgreen/thoughts/cmd/thoughts/requests" ) +// Generous enough for a slow model, short enough that a hung provider surfaces +// as an error rather than an endless wait. +const generateTimeout = 30 * time.Second + type PromptRequest struct { Prompt string `json:"prompt" validate:"required,min=2,max=128"` } @@ -26,7 +32,12 @@ func AIRetroTemplate(aiModel ai.Model) http.Handler { return } - resp, err := prompts.GenerateRetroTemplate(r.Context(), aiModel, req.Prompt) + // Bounded, so a slow or unreachable provider cannot leave someone + // watching a spinner indefinitely. + ctx, cancel := context.WithTimeout(r.Context(), generateTimeout) + defer cancel() + + resp, err := prompts.GenerateRetroTemplate(ctx, aiModel, req.Prompt) if err != nil { slog.Error("failed to generate retro template", "err", err) http.Error(w, "failed to generate retro template", http.StatusInternalServerError) diff --git a/ui/src/components/retro/ai-retro-template.tsx b/ui/src/components/retro/ai-retro-template.tsx index 6e017be..689415b 100644 --- a/ui/src/components/retro/ai-retro-template.tsx +++ b/ui/src/components/retro/ai-retro-template.tsx @@ -1,130 +1,197 @@ -import { Sparkles } from "lucide-react"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import { Button } from "../ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormMessage, -} from "../ui/form"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/api"; +import { spring, springy } from "@/lib/motion"; import { AIRetroTemplateResponse } from "@/types"; -import z from "zod"; -import { useForm, useFormContext } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { api } from "@/lib/api"; -import { toast } from "sonner"; -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, -} from "../ui/input-group"; +import { Sparkles, WandSparkles } from "lucide-react"; +import { m } from "motion/react"; import { useState } from "react"; -import { Spinner } from "../ui/spinner"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import z from "zod"; -const aiTemplateSchema = z.object({ +const schema = z.object({ prompt: z .string() - .min(4, "Please enter a prompt") - .max(128, "Prompt is too long (max 128 characters)") - .transform((value) => value.trim()), + .trim() + .min(2, "Give it something to work with") + .max(128, "That's a bit long — keep it under 128 characters"), }); -export default function AIRetroTemplate() { - const { setValue } = useFormContext(); - const [popoverOpen, setPopoverOpen] = useState(false); - const [isGenerating, setIsGenerating] = useState(false); +/** Enough to show what this is for without anyone having to think. */ +const suggestions = [ + "Star Wars", + "The Great British Bake Off", + "Pirates", + "Deep sea", + "Heist movie", + "Formula 1", +]; + +export interface GeneratedColumn { + title: string; + description: string; +} + +export default function AIRetroTemplate({ + onApply, + onGeneratingChange, +}: { + onApply: (columns: GeneratedColumn[]) => void; + onGeneratingChange?: (generating: boolean) => void; +}) { + const [generating, setGenerating] = useState(false); const form = useForm({ - resolver: zodResolver(aiTemplateSchema), - defaultValues: { - prompt: "", - }, + resolver: zodResolver(schema), + defaultValues: { prompt: "" }, }); - function handleSubmit(data: z.infer) { - if (isGenerating) return; - setIsGenerating(true); + function generate(prompt: string) { + if (generating) return; + + setGenerating(true); + onGeneratingChange?.(true); api - .post("/api/ai/retro-template", { - prompt: data.prompt, - }) + .post("/api/ai/retro-template", { prompt }) .then((res) => { - setValue("columns", res.data.columns); - form.reset(); - toast.success(`🎉 ${res.data.theme} retro generated!`, { - description: `${res.data.columns.length} fresh columns are ready to go 🚀`, - duration: 5000, + onApply(res.data.columns); + + toast.success(`${res.data.theme} it is ✨`, { + description: `${res.data.columns.length} columns ready — tweak anything you like.`, }); }) .catch(() => { - toast.error("Failed to generate columns from AI"); + toast.error("Couldn't dream that one up", { + description: "Try a different theme, or pick a template instead.", + }); }) .finally(() => { - setIsGenerating(false); - setPopoverOpen(false); + setGenerating(false); + onGeneratingChange?.(false); }); } + // Validates through react-hook-form without an enclosing
    element. + const submit = form.handleSubmit((data) => generate(data.prompt)); + + function applySuggestion(suggestion: string) { + form.setValue("prompt", suggestion); + generate(suggestion); + } + + const error = form.formState.errors.prompt?.message; + return ( - - - - - -

    Generate a retro template using AI.

    -

    - Enter a prompt to generate a retro template. The AI will generate - columns based on the prompt. +

    + {/* Gradient edge that drifts while it thinks, and sits still otherwise. */} + + +
    +
    + + + + +

    Conjure a themed board

    + + + Powered by AI + +
    + +

    + Name a theme and we'll write the columns around it.

    - - ( - - - - { - if (e.key === "Enter") { - e.preventDefault(); - form.handleSubmit(handleSubmit)(); - } - }} - /> - - - {isGenerating ? ( - - ) : ( - - Generate - - )} - - - - - - - )} + {/* Deliberately not a : this sits inside the create-retro form, + and HTML has no nested forms - the browser drops the inner one, so + a submit button here would submit the outer form and create the + retro instead of generating anything. */} +
    + { + if (event.key !== "Enter") return; + + event.preventDefault(); + submit(); + }} + {...form.register("prompt")} /> - - - + + {/* The label swaps in place rather than crossfading: an empty button + mid-transition jumps the layout of the whole row. */} + +
    + + {error &&

    {error}

    } + +
    + {suggestions.map((suggestion, i) => ( + applySuggestion(suggestion)} + initial={{ opacity: 0, y: 3 }} + animate={{ opacity: 1, y: 0 }} + transition={{ ...spring, delay: 0.03 * i }} + whileHover={{ y: -1 }} + className="rounded-full border border-border/70 px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:border-[var(--chart-1)] hover:text-foreground disabled:opacity-50" + > + {suggestion} + + ))} +
    +
    +
    ); } diff --git a/ui/src/components/retro/creator.tsx b/ui/src/components/retro/creator.tsx index 4c9c7b4..ee8aee1 100644 --- a/ui/src/components/retro/creator.tsx +++ b/ui/src/components/retro/creator.tsx @@ -1,3 +1,4 @@ +import { AutoTextarea } from "@/components/ui/auto-textarea"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -9,12 +10,6 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Form, FormControl, @@ -25,40 +20,56 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import useTemplates from "@/hooks/use-templates"; +import { useAuth } from "@/hooks/use-auth"; import { api } from "@/lib/api"; +import { accentForIndex } from "@/lib/column-accent"; +import { cardVariants, spring, stagger } from "@/lib/motion"; +import { cn } from "@/lib/utils"; import { Route as RetrosRoute } from "@/routes/_auth.retros.$retroId"; import { Retro } from "@/types"; import { zodResolver } from "@hookform/resolvers/zod"; import { useNavigate } from "@tanstack/react-router"; -import { BookDashed, Plus, Trash2 } from "lucide-react"; +import { Columns3, Plus, Trash2 } from "lucide-react"; +import { AnimatePresence, m } from "motion/react"; import { useState } from "react"; -import { useFieldArray, useForm, useFormContext } from "react-hook-form"; +import { Control, useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; import { z } from "zod"; import AIRetroTemplate from "./ai-retro-template"; -import { useAuth } from "@/hooks/use-auth"; import TagInput from "./tag-input"; +import TemplatePicker from "./template-picker"; + +const minColumns = 2; +const maxColumns = 5; +const maxDescription = 255; const schema = z.object({ - title: z.string().min(5).max(255), + title: z.string().trim().min(5).max(255), columns: z .array( z.object({ - title: z.string().min(2).max(255), - description: z.string().max(255), + title: z.string().trim().min(2).max(255), + description: z.string().trim().max(maxDescription), }), ) - .min(2, "At least 2 columns are required") - .max(5, "At most 5 columns are allowed"), + .min(minColumns, `Add at least ${minColumns} columns`) + .max(maxColumns, `${maxColumns} columns is the most a board can hold`), unlisted: z.boolean().optional(), tags: z.array(z.string().min(1).max(50)).max(10).optional(), }); +type FormValues = z.infer; +type ColumnDraft = FormValues["columns"][number]; + +const emptyColumn: ColumnDraft = { title: "", description: "" }; + export default function Creator() { const navigate = useNavigate(); const [open, setOpen] = useState(false); + const [generating, setGenerating] = useState(false); - const form = useForm({ + // Typed explicitly: inferring from empty defaults gives columns: never[]. + const form = useForm({ resolver: zodResolver(schema), defaultValues: { title: "", @@ -68,11 +79,31 @@ export default function Creator() { }, }); - function handleSubmit(data: z.infer) { - api.post("/api/retros", data).then((response) => { - setOpen(false); - navigate({ to: RetrosRoute.path, params: { retroId: response.data.id } }); - }); + const columns = useFieldArray({ control: form.control, name: "columns" }); + + function handleSubmit(data: FormValues) { + api + .post("/api/retros", data) + .then((response) => { + setOpen(false); + form.reset(); + navigate({ + to: RetrosRoute.path, + params: { retroId: response.data.id }, + }); + }) + .catch(() => { + toast.error("Couldn't create the retro", { + description: "Give it another go in a moment.", + }); + }); + } + + // Templates and AI replace the set outright: replace() regenerates the field + // keys, so the cards animate in as new rather than the old ones mutating. + function applyColumns(next: ColumnDraft[]) { + columns.replace(next.slice(0, maxColumns)); + form.clearErrors("columns"); } return ( @@ -84,18 +115,19 @@ export default function Creator() { - + Create a retrospective - Pick a template or write your own columns. You can rename them later. + Start from a template, dream one up, or write your own. Everything + here can be changed later.
    Title - - Give your retrospective a title - )} /> - - - ( - - - - -
    - Unlisted - - If checked, the retrospective will not be listed on the - home page. - -
    -
    - )} + fields={columns.fields} + generating={generating} + onApply={applyColumns} + onAdd={() => columns.append(emptyColumn)} + onRemove={columns.remove} + onGeneratingChange={setGenerating} /> - ( - - Tags - - - - - Tag retros to group them by team or project. Press Enter or comma to add. - - - - )} - /> +
    - - + + + @@ -172,131 +178,304 @@ export default function Creator() { ); } -function Columns() { - const { fields, append, remove } = useFieldArray({ name: "columns" }); - const { control } = useFormContext(); - +function ColumnsSection({ + control, + fields, + generating, + onApply, + onAdd, + onRemove, + onGeneratingChange, +}: { + control: Control; + fields: { id: string }[]; + generating: boolean; + onApply: (columns: ColumnDraft[]) => void; + onAdd: () => void; + onRemove: (index: number) => void; + onGeneratingChange: (generating: boolean) => void; +}) { const { user } = useAuth(); - return ( -
    -

    Columns

    - - {fields.map((field, index) => ( - remove(index)} - /> - ))} + const full = fields.length >= maxColumns; - } /> - -
    - + return ( +
    +
    +
    +

    Columns

    + + {fields.length} of {maxColumns} + +
    -
    - +
    + +
    +
    - {user?.ai_enabled && ( -
    - -
    + {user?.ai_enabled && ( + + )} + + {/* Columns you already have stay put while a generation runs: they are + only replaced if it succeeds, and flashing them away and back on a + failure would look like losing your work. */} +
    0 && "opacity-50", )} + > + + {fields.length === 0 && generating && ( + + )} + + {fields.length === 0 && !generating && } + + {fields.map((field, index) => ( + onRemove(index)} + canRemove={fields.length > minColumns} + /> + ))} +
    -
    + + } + /> +
    ); } -function ColumnInput({ +function ColumnCard({ + control, index, onRemove, + canRemove, }: { + control: Control; index: number; onRemove: () => void; + canRemove: boolean; }) { - const { control } = useFormContext(); + const accent = accentForIndex(index); return ( -
    -
    - - Column {index + 1} - + + {/* The colour this column will actually be on the board. */} + + +
    +
    + ( + + + + + + + )} + /> + + ( + + + + + +
    + + +
    +
    + )} + /> +
    +
    +
    + ); +} -
    - ( - - Title - - - - - - )} - /> +/** Only speaks up near the limit, rather than nagging from the first keystroke. */ +function CharacterCount({ value }: { value: string }) { + const remaining = maxDescription - value.length; - ( - - Description - - - - - - )} - /> -
    -
    + if (remaining > 40) return null; + + return ( + + {remaining} left + ); } -function FromTemplateDropDown() { - const templates = useTemplates(); - const { setValue } = useFormContext(); +function EmptyColumns() { + return ( + + +

    No columns yet

    +

    + Pick a template, name a theme above, or add your own. +

    +
    + ); +} +/** Shimmering stand-ins so the wait shows its working. */ +function GeneratingPlaceholder() { return ( - - - - - - {templates.map((template) => ( - setValue("columns", template.columns)} - > - {template.title} - - ))} - - + + {[0, 1, 2].map((i) => ( +
    + +
    +
    +
    +
    +
    + ))} + + ); +} + +function Details({ control }: { control: Control }) { + return ( +
    + ( + + Tags + + + + + Groups retros together by team or project. Enter or comma to add. + + + + )} + /> + + ( + + + + +
    + Unlisted + + Keep it off the home page — anyone with the link can still join. + +
    +
    + )} + /> +
    ); } diff --git a/ui/src/components/retro/template-picker.tsx b/ui/src/components/retro/template-picker.tsx new file mode 100644 index 0000000..5516dd4 --- /dev/null +++ b/ui/src/components/retro/template-picker.tsx @@ -0,0 +1,70 @@ +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import useTemplates from "@/hooks/use-templates"; +import { accentForIndex } from "@/lib/column-accent"; +import { BookDashed, ChevronDown } from "lucide-react"; + +interface TemplateColumn { + title: string; + description: string; +} + +export default function TemplatePicker({ + onApply, +}: { + onApply: (columns: TemplateColumn[]) => void; +}) { + const templates = useTemplates(); + + return ( + + + + + + + + Tried and tested formats + + + {templates.map((template) => ( + onApply(template.columns)} + // Items are two lines tall, so the default centring looks off. + className="flex-col items-start gap-1.5 py-2" + > + {template.title} + + {/* The column names, in the colours they will actually be on the + board - picking a template stops being a guess. */} + + {template.columns.map((column, i) => ( + + {column.title} + + ))} + + + ))} + + + ); +} diff --git a/ui/src/components/ui/auto-textarea.tsx b/ui/src/components/ui/auto-textarea.tsx new file mode 100644 index 0000000..53c6b26 --- /dev/null +++ b/ui/src/components/ui/auto-textarea.tsx @@ -0,0 +1,75 @@ +import { cn } from "@/lib/utils"; +import { useCallback, useLayoutEffect, useRef } from "react"; +import { textareaClassName } from "./textarea"; + +/** + * A textarea that grows with its content instead of hiding it behind a + * scrollbar. + * + * Sized in JS rather than with CSS `field-sizing: content`, which only Chrome + * supports - in Safari and Firefox that leaves a fixed box, which is exactly + * the problem this is here to solve. + * + * Renders its own element rather than wrapping Textarea: the ref has to reach + * the real node to measure it. + */ +export function AutoTextarea({ + className, + value, + onChange, + maxHeight = 180, + // Pulled out of props deliberately. Callers spread a react-hook-form field + // here, which carries its own ref; left in the spread it would land after + // ours and win, leaving nothing to measure. + ref: forwardedRef, + ...props +}: React.ComponentProps<"textarea"> & { maxHeight?: number }) { + const ref = useRef(null); + + const attachRef = useCallback( + (node: HTMLTextAreaElement | null) => { + ref.current = node; + + if (typeof forwardedRef === "function") { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }, + [forwardedRef], + ); + + const resize = useCallback(() => { + const el = ref.current; + if (!el) return; + + // Collapse first, or scrollHeight only ever reports the current height. + el.style.height = "auto"; + + const next = Math.min(el.scrollHeight, maxHeight); + + el.style.height = `${next}px`; + el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden"; + }, [maxHeight]); + + // Layout effect so it is sized before paint. Also covers value changing from + // outside, such as a template or an AI generation filling the form in. + useLayoutEffect(resize, [value, resize]); + + return ( +