diff --git a/docs/implementation-plans/goals-backend-questions.md b/docs/implementation-plans/goals-backend-questions.md new file mode 100644 index 00000000..6afdd14e --- /dev/null +++ b/docs/implementation-plans/goals-backend-questions.md @@ -0,0 +1,400 @@ +# Goals Feature — Backend Planning Questions + +**Purpose:** This document captures the four architectural questions that were brought to the backend team, along with the options considered and the final decisions. + +**Status:** All questions resolved. Decisions are incorporated into the [master plan](./goals-feature-master-plan.md). + +**Frontend master plan:** `docs/implementation-plans/goals-feature-master-plan.md` + +--- + +## Current State + +### What exists today + +**Database schema (`overarching_goals` table — being renamed to `goals`):** +``` +id UUID (PK) +coaching_session_id UUID (FK → coaching_sessions) ← renaming to created_in_session_id +user_id UUID (FK → users) +title TEXT (nullable) +body TEXT (nullable) +status ItemStatus enum (NotStarted | InProgress | Completed | WontDo) +status_changed_at TIMESTAMPTZ +completed_at TIMESTAMPTZ +created_at TIMESTAMPTZ +updated_at TIMESTAMPTZ +``` + +**Existing API endpoints for overarching goals (paths being renamed `/overarching_goals` → `/goals`):** + +| Method | Path | Query Params | Notes | +|--------|------|-------------|-------| +| GET | `/overarching_goals` | `coaching_session_id`, `sort_by`, `sort_order` | List goals for a session | +| POST | `/overarching_goals` | — | Body: `{ coaching_session_id, title?, body?, status }` | +| GET | `/overarching_goals/{id}` | — | Single goal | +| PUT | `/overarching_goals/{id}` | — | Full update | +| PUT | `/overarching_goals/{id}/status` | `value` | Status-only update | +| GET | `/users/{user_id}/overarching_goals` | `coaching_session_id`, `sort_by`, `sort_order` | User-scoped goal list | +| DELETE | `/overarching_goals/{id}` | — | **Confirmed** — included in backend PR2 | + +**Existing related tables:** + +``` +actions table: + id, coaching_session_id (FK), body, user_id, status, status_changed_at, + due_by, created_at, updated_at + +action_assignees table (many-to-many): + action_id (FK → actions), user_id (FK → users) + +agreements table: + id, coaching_session_id (FK), body, user_id, created_at, updated_at + +coaching_sessions table: + id, coaching_relationship_id (FK), date, created_at, updated_at + +coaching_relationships table: + id, coach_id (FK → users), coachee_id (FK → users), organization_id (FK), + created_at, updated_at +``` + +**Existing SSE events:** `overarching_goal_created`, `overarching_goal_updated`, `overarching_goal_deleted`, `action_created`, `action_updated`, `action_deleted`, `agreement_created`, `agreement_updated`, `agreement_deleted` + +### What the new UX requires + +The frontend prototypes model goals as **relationship-level entities tracked across multiple sessions**. A coach and coachee work on 1–3 active goals across 20+ sessions. Each goal: +- Has a lifecycle: Active → On Hold → Completed or Abandoned +- Accumulates actions from multiple sessions +- Tracks which sessions discussed it (displayed as a vertical timeline) +- Shows progress: `actionsCompleted / actionsTotal` +- Has a health signal: `SolidMomentum | NeedsAttention | LetsRefocus` +- Can be linked/unlinked from individual sessions (max 3 goals per session) + +--- + +## Q1: Goal Scoping — Relationship Column vs. Many-to-Many Join Table + +> **Decision: Option B (join table) — confirmed** + +### The problem + +`overarching_goals.coaching_session_id` makes goals 1:1 with sessions. The new UX needs goals to span multiple sessions within a coaching relationship. + +### Option A: Add `coaching_relationship_id` column + +```sql +ALTER TABLE overarching_goals + ADD COLUMN coaching_relationship_id UUID REFERENCES coaching_relationships(id); +-- coaching_session_id becomes "originating session" (where goal was first created) +``` + +**Pros:** +- Simple schema change +- One column addition, no new tables +- Goals are naturally scoped to a relationship + +**Cons:** +- No explicit record of which sessions discussed which goals +- Cannot enforce per-session goal limit (MAX=3) — note: even with Option B, this is frontend-only enforcement +- Goal detail timeline ("which sessions discussed this goal") requires inferring from actions/notes — no direct join +- Cannot "link/unlink" a goal from a session — it's either scoped to the relationship or not + +**Frontend impact:** +- (a) list all goals for a relationship: `GET /goals?coaching_relationship_id=X` — works +- (b) list goals linked to a specific session: **Cannot do directly** — would need to infer from actions or other data +- (c) link/unlink a goal from a session: **Not possible** — goals exist at the relationship level +- (d) know which sessions discussed a goal: **Indirect only** — infer from actions created in those sessions + +### Option B: Many-to-many join table (CHOSEN) + +```sql +-- Rename table and column +ALTER TABLE overarching_goals RENAME TO goals; +ALTER TABLE goals RENAME COLUMN coaching_session_id TO created_in_session_id; +-- created_in_session_id is NULLABLE — goals can be created outside a session context + +-- Add relationship scoping and target date +ALTER TABLE goals + ADD COLUMN coaching_relationship_id UUID NOT NULL REFERENCES coaching_relationships(id), + -- backfill: derive from coaching_sessions.coaching_relationship_id via created_in_session_id + ADD COLUMN target_date DATE; -- optional achieve-by date, drives dynamic health heuristics + +-- New join table for explicit session-goal links +CREATE TABLE coaching_sessions_goals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + coaching_session_id UUID NOT NULL REFERENCES coaching_sessions(id) ON DELETE CASCADE, + goal_id UUID NOT NULL REFERENCES goals(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(coaching_session_id, goal_id) +); +-- Backfill: populate join table rows from existing coaching_session_id (now created_in_session_id) data +``` + +**New endpoints (PR2 implemented — nested routes, join table hidden as implementation detail):** +``` +POST /coaching_sessions/{id}/goals — Link a goal to a session (body: {goal_id}) +DELETE /coaching_sessions/{id}/goals/{goal_id} — Unlink a goal from a session +GET /coaching_sessions/{id}/goals — List goals linked to a session (eager-loads full goal models) +GET /goals/{id}/sessions — List sessions that discussed a goal +``` + +**Pros:** +- Explicit tracking of which sessions discussed which goals +- Supports per-relationship active goal limit (MAX=3 InProgress, **backend-enforced** — returns HTTP 409 Conflict with active goal summaries) +- Enables the goal drawer UX: link/unlink goals per session +- Goal detail timeline is a direct query on the join table +- `created_in_session_id` on `goals` remains as "originating session" + +**Cons:** +- More complex schema (new table, new endpoints, new CRUD) +- More SSE events to emit (`coaching_session_goal_created`, `coaching_session_goal_deleted`) +- Migration needs to populate `coaching_relationship_id` for existing goals (derive from `coaching_sessions.coaching_relationship_id`) + +**Frontend impact:** +- (a) list all goals for a relationship: `GET /goals?coaching_relationship_id=X` — works (**required** for auth — Option C confirmed) +- (b) list goals linked to a specific session: `GET /coaching_sessions/{id}/goals` — direct query (used instead of filtering `GET /goals` by `created_in_session_id`) +- (c) link/unlink a goal from a session: `POST/DELETE /coaching_sessions/{id}/goals` — nested routes (join table hidden as implementation detail) +- (d) know which sessions discussed a goal: `GET /goals/{id}/sessions` — direct query +- (e) `created_in_session_id` is **display metadata only** (which session originated the goal) — not used as a query filter on `GET /goals` +- (f) auto-link on creation: when `created_in_session_id` is provided in `POST /goals`, backend auto-inserts a join table row + +### Additional decisions made alongside Q1 + +- **Table rename:** `overarching_goals` → `goals` (simpler, matches UX terminology) +- **Column rename:** `coaching_session_id` → `created_in_session_id` on goals (nullable — goals can be created outside a session) +- **`coaching_relationship_id`:** NOT NULL with backfill migration (derived from `coaching_sessions.coaching_relationship_id`) +- **New field:** `target_date` (DATE, nullable) — optional achieve-by date; drives dynamic health heuristics +- **Join table name:** `coaching_sessions_goals` (plural "sessions") +- **Join table FK:** `goal_id` (not `overarching_goal_id`) +- **Join table CASCADE:** Both FKs use ON DELETE CASCADE (matches `actions_users` pattern) +- **Join table backfill:** Existing data populated from current `coaching_session_id` relationships +- **`DELETE /goals/{id}`:** Implemented in PR2 — atomic delete in transaction with SSE event publishing +- **Active goal limit (MAX=3 InProgress per relationship):** **Backend-enforced** at `entity_api` layer on create and status transitions to InProgress. Returns HTTP 409 Conflict with `ValidationError` containing `message` + structured `details` payload (active goal summaries). Uses generic `EntityErrorKind::Conflict` mapped through existing error chain (not a goal-specific error variant). Frontend handles with `ActiveGoalLimitError` types and destructive toast; uses `max_active_goals` from 409 response (not hardcoded) +- **Authorization (Option C):** `GET /goals` requires `coaching_relationship_id` — backend protect middleware (`by_id`, `by_coaching_session_id`) authorizes through `coaching_relationship_id` directly. Frontend never queries goals by `created_in_session_id` alone; for session-linked goals, use `GET /coaching_sessions/{id}/goals` +- **Auto-link on creation:** When `created_in_session_id` is provided, backend auto-inserts a `coaching_sessions_goals` row (extracted into `link_to_originating_session()` with CHANGEME markers for PR3 carry-forward) + +### PR2 coordinated deploy (✅ completed) + +PR2 was a breaking change requiring simultaneous frontend deployment. Both PRs merged together: +- Backend: [PR #242](https://github.com/refactor-group/refactor-platform-rs/pull/242) +- Frontend: [PR #330](https://github.com/refactor-group/refactor-platform-fe/pull/330) + +Breaking changes deployed: +- POST/PUT request bodies: `coaching_session_id` → `created_in_session_id` (nullable) +- `GET /goals` requires `coaching_relationship_id` query param for auth +- Join table endpoints use nested routes: `POST/DELETE /coaching_sessions/{id}/goals` (not flat `/coaching_sessions_goals`) +- `GET /coaching_sessions/{id}/goals` returns eager-loaded full goal models (not join table records) +- Goal responses include `coaching_relationship_id`, `created_in_session_id`, `target_date` +- HTTP 409 on exceeding 3 InProgress goals per relationship + +### Goal-session carry-forward workflow (PR3 scope) + +When a coach creates a new coaching session, all **active goals** from the relationship are automatically linked via the join table (carry-forward model). Goals are pre-linked, not manually attached. + +During a session, the coach/coachee can: +- Unlink an existing active goal from the current session +- Create a new goal and link it to the current session +- Link an existing unlinked goal to the current session + +`coaching_sessions_goals` rows are created at **session-creation time** (not lazily). The frontend "create coaching session" flow will need a goal review/management step. + +**PR2 interim behavior:** Goals are auto-linked to their originating session only (via `link_to_originating_session()`). Full carry-forward is PR3 scope. The auto-link function has CHANGEME markers for removal. + +--- + +## Q2: Should `goal_id` Be Added to Actions? + +> **Decision: Option A (add FK) — confirmed** + +### The problem + +The frontend shows actions grouped by goal: +- Goal card: `5/8 actions completed` +- Goal detail sheet → Actions tab: all actions for this goal, grouped by status +- Dashboard goals overview: per-goal progress bar + +Currently `actions` only has `coaching_session_id`. There is no way to query "all actions for goal X" without fetching all actions and filtering client-side. + +### Option A: Add `goal_id` FK to actions (CHOSEN) + +```sql +ALTER TABLE actions + ADD COLUMN goal_id UUID REFERENCES goals(id) ON DELETE SET NULL; +``` + +**Update endpoints:** +- `GET /actions?goal_id=X` — filter actions by goal +- `GET /users/{user_id}/actions?goal_id=X` — same, user-scoped +- Backend can compute goal-level stats: `SELECT status, COUNT(*) FROM actions WHERE goal_id = X GROUP BY status` + +**Pros:** +- Simple, direct FK +- Trivial queries for actions-by-goal +- Enables backend health signal computation (action completion rate is an input) +- Existing actions without a goal simply have NULL +- ON DELETE SET NULL preserves actions when a goal is deleted + +**Cons:** +- Another nullable FK on actions +- When creating an action from the notes selection menu, the UI must know which goal to associate it with (or leave it NULL and let the user assign later) + +### Option B: Derive through session-goal joins + +If Q1 resolves to Option B (join table), actions are linked to sessions, sessions are linked to goals. So: `action → session → coaching_sessions_goals → goal`. + +**Problem:** A session can link to multiple goals. Which goal does the action belong to? The association is **ambiguous**. You'd need additional logic (or user input) to disambiguate. + +**Verdict:** This doesn't work cleanly. Even with a join table, actions need a direct goal FK to avoid ambiguity. + +### Option C: Separate `goal_actions` join table + +```sql +CREATE TABLE goal_actions ( + goal_id UUID REFERENCES goals(id), + action_id UUID REFERENCES actions(id), + PRIMARY KEY (goal_id, action_id) +); +``` + +**When this makes sense:** Only if an action can belong to multiple goals simultaneously. The current UX doesn't show this — each action appears under one goal. + +**Verdict:** Over-engineered for current needs. Option A is simpler and sufficient. + +--- + +## Q3: SSE Events for New Entities + +> **Decision: Renamed events + two new join table events; health computed synchronously** + +### Current SSE event types (being renamed) + +``` +overarching_goal_created → goal_created (invalidates /goals cache) +overarching_goal_updated → goal_updated (invalidates /goals cache) +overarching_goal_deleted → goal_deleted (invalidates /goals cache) +action_created → (unchanged) (invalidates /actions cache) +action_updated → (unchanged) (invalidates /actions cache) +action_deleted → (unchanged) (invalidates /actions cache) +agreement_created → (unchanged) (invalidates /agreements cache) +agreement_updated → (unchanged) (invalidates /agreements cache) +agreement_deleted → (unchanged) (invalidates /agreements cache) +``` + +### New SSE events (confirmed) + +``` +coaching_session_goal_created → invalidates /coaching_sessions_goals cache +coaching_session_goal_deleted → invalidates /coaching_sessions_goals cache +``` + +These are needed so that when coach A links a goal to a session, coachee B (who may have the session open) sees the goal appear in real-time. + +### Health signal updates + +Health signals are computed **synchronously on read** — no separate SSE event needed. When an action is created/updated/deleted, the `action_*` SSE events trigger cache invalidation. When the frontend re-fetches goal data, the health signal is recomputed as part of the response. + +--- + +## Q4: Note Annotation Persistence + +> **Decision: Option C (both SSE + load-time) with per-entity-type validation endpoints** + +### The problem + +PR 5b will allow users to select text in coaching notes and create a Goal, Action, or Agreement from it. The selected text gets visually annotated (colored inline mark) in the editor, linking it to the created entity. + +**Current notes architecture:** +- Coaching notes are **not stored in our database** +- They are Yjs CRDT documents persisted in **TipTap Cloud** (Hocuspocus) +- Each coaching session has one collaborative document, keyed by session ID +- The editor uses TipTap v3 with the `Collaboration` extension +- Content is ProseMirror JSON internally, synced via Yjs + +**What the annotation system needs:** +1. Custom TipTap marks (`actionMark`, `agreementMark`, `goalMark`) with an `entityId` attribute +2. When applied, the mark wraps selected text with a colored background and a label (e.g. "Action", "Goal") +3. Marks are persisted automatically by Yjs (they become part of the CRDT document) +4. When the editor reloads, marks render with their styling +5. **Critical:** When the linked entity is deleted from the DB, the mark must be cleaned up (converted back to plain text) + +### The stale mark problem + +If a user creates an action from selected text (annotating it), then later deletes that action from the Actions tab or the global actions page, the annotation in the notes becomes stale — it references a non-existent entity. + +### Chosen strategy: Option C (both SSE + load-time) + +**SSE-triggered cleanup (real-time):** +- When an entity is deleted, the SSE event (`action_deleted`, `agreement_deleted`, `goal_deleted`) fires +- The frontend listens for these events and scans the TipTap document for marks with matching `entityId` +- Matching marks are removed (text reverts to plain) +- Handles: "I delete an action while the notes are open" + +**Load-time validation (safety net):** +- When a coaching session's notes load, the frontend collects all `entityId` values from marks in the document +- Groups IDs by entity type and validates via per-entity-type endpoints +- Stale marks are removed +- Handles: "entity was deleted while I was offline" + +### Validation endpoints (per-entity-type) + +Instead of a single cross-entity endpoint, validation uses per-entity-type endpoints: + +``` +POST /actions/validate +Body: { ids: [UUID, UUID, ...] } +Response: { valid: [UUID, ...], invalid: [UUID, ...] } + +POST /agreements/validate +Body: { ids: [UUID, UUID, ...] } +Response: { valid: [UUID, ...], invalid: [UUID, ...] } + +POST /goals/validate +Body: { ids: [UUID, UUID, ...] } +Response: { valid: [UUID, ...], invalid: [UUID, ...] } +``` + +This is a lightweight existence check — no need to return full entities. + +--- + +## Summary of Decisions + +| # | Question | Decision | Key Details | +|---|----------|----------|-------------| +| Q1 | Goal scoping model | **Option B: join table** | `goals` table + `coaching_sessions_goals` join table (CASCADE both FKs); `coaching_relationship_id` (NOT NULL, backfill); `created_in_session_id` (nullable); `target_date` (nullable); `DELETE /goals/{id}` ✅ PR2; MAX=3 InProgress per relationship **backend-enforced** (409 Conflict); nested join table routes; auto-link on creation; **auth: Option C** — `GET /goals` requires `coaching_relationship_id`, `by_id` and `by_coaching_session_id` protect middleware | +| Q2 | Goal FK on actions | **Option A: add FK** | Nullable `goal_id` on actions, ON DELETE SET NULL | +| Q3 | SSE events | **Renamed + 2 new** | `goal_*` events, `coaching_session_goal_created/deleted`; health sync on read with dynamic heuristics when `target_date` set | +| Q4 | Annotation cleanup | **Option C: both** | SSE real-time + per-entity-type load-time validation endpoints | + +--- + +## Backend Implementation Plan (5 PRs) + +1. **PR1 — Rename:** `overarching_goals` → `goals` (table, API paths, SSE events) — **✅ MERGED** +2. **PR2 — Goal scoping (✅ coordinated deploy completed):** — **✅ MERGED** ([backend #242](https://github.com/refactor-group/refactor-platform-rs/pull/242), [frontend #330](https://github.com/refactor-group/refactor-platform-fe/pull/330)) + - `coaching_relationship_id` (NOT NULL, backfill), `created_in_session_id` (nullable), `target_date` (nullable) + - `coaching_sessions_goals` join table (CASCADE both FKs, backfill, nested endpoints) + - `DELETE /goals/{id}` (atomic, with SSE publishing) + - Active goal limit: max 3 InProgress per relationship (entity_api layer, 409 Conflict) + - Protect middleware: `by_id`, `by_coaching_session_id` + - Auto-link on creation when `created_in_session_id` provided + - Entity helpers: `in_progress()`, `includes_user()` + - Coding standards: error variant reuse guidance +3. **PR3 — Action FK + carry-forward:** Add nullable `goal_id` to `actions` (ON DELETE SET NULL), add `goal_id` query param. Refactor `batch_load_goals` to use join table. Implement carry-forward workflow +4. **PR4 — SSE events + health:** `coaching_session_goal_created`, `coaching_session_goal_deleted`; health signal computation (synchronous on read) via `GET /goals/{id}/health` returning `GoalHealthMetrics` +5. **PR5 — Validation endpoints:** `POST /actions/validate`, `POST /agreements/validate`, `POST /goals/validate` + +PR1 + PR2 are merged. PR3–PR5 can land in parallel with frontend Layer 3+ work. + +--- + +## What the Frontend Needs from the Backend (Summary) + +1. **PR1:** ✅ Rename `overarching_goals` → `goals` (table, API paths, SSE events) +2. **PR2:** ✅ Relationship scoping, join table with nested endpoints, `DELETE /goals/{id}`, active goal limit (409), auto-link on creation, protect middleware +3. **PR3:** Add nullable `goal_id` FK to `actions` (ON DELETE SET NULL), add `goal_id` query param on `GET /actions` and `GET /users/{id}/actions`. Carry-forward workflow (auto-link active goals on session creation). Refactor `batch_load_goals` to use join table +4. **PR4:** SSE events for join table (`coaching_session_goal_created`, `coaching_session_goal_deleted`) + health signal computation (synchronous on read) via `GET /goals/{id}/health` returning `GoalHealthMetrics` (dynamic heuristics when `target_date` set, momentum-only when null) +5. **PR5:** Per-entity-type validation endpoints: `POST /actions/validate`, `POST /agreements/validate`, `POST /goals/validate` +6. **Carry-forward workflow (PR3 scope):** Active goals auto-linked to new sessions at creation time; frontend needs goal review/management step in session creation flow diff --git a/docs/implementation-plans/goals-feature-master-plan.md b/docs/implementation-plans/goals-feature-master-plan.md new file mode 100644 index 00000000..858c9b6c --- /dev/null +++ b/docs/implementation-plans/goals-feature-master-plan.md @@ -0,0 +1,402 @@ +# Goals Feature — Master Implementation Plan + +## Context + +The current overarching goal model is a per-session text label (`OverarchingGoal` has a `coaching_session_id` FK, 1:1 with sessions). The coaching platform needs goals to be **relationship-level entities tracked across multiple sessions** — a coach and coachee work on a small set of active goals across many sessions, each goal accumulating actions, agreements, and session history over time. + +The prototypes under `src/app/prototype/` (dashboard-goals, goals-hub, session-goals, goal-detail, review-actions) demonstrate the target UX. This plan turns those prototypes into production code, broken into independently mergeable branches/PRs ordered bottom-up by dependency. + +### Visual Design Direction + +The prototypes establish a refined UI theme inspired by Mercury's dashboard aesthetic: clean card-based layouts with `shadow-none` borders, muted low-contrast palette (`text-muted-foreground/60`), small precise typography (`text-[11px]`, `text-[13px]`, `tabular-nums`), generous whitespace, and status indicators using subtle colored dots rather than heavy badges. **Every implementation PR must match the prototype's styling as closely as possible.** This is the new design direction for the platform — not just for goals, but as the visual standard going forward. + +### Prototype Validation Rule + +For all PRs in Layer 3 and beyond, the corresponding prototype page serves as the **visual and functional specification**. Before marking any PR complete, compare the implementation against the prototype for: +- Layout, spacing, and typography fidelity +- Interaction behavior (hover states, expand/collapse, transitions) +- Data display patterns (progress bars, status indicators, counts) +- Responsive breakpoints (`grid-cols-1 md:grid-cols-2 xl:grid-cols-3`) + +--- + +## Resolved Backend Decisions + +All four architectural questions have been resolved with the backend team. These decisions are now confirmed and inform all subsequent layers. + +**Q1: Goal Scoping — Option B (join table) confirmed — ✅ PR2 implemented** + +- `overarching_goals` table renamed to `goals` +- `coaching_session_id` on goals renamed to `created_in_session_id` — **nullable**, allowing goals to be created outside a session context (e.g. from the dashboard or goals page) +- New `coaching_relationship_id` column added to `goals` for relationship scoping (**NOT NULL** with backfill migration deriving from `coaching_sessions.coaching_relationship_id`) +- New optional `target_date` field (DATE, nullable) — the intended achieve-by date for the goal; drives dynamic health heuristics when set +- New `coaching_sessions_goals` many-to-many join table with `goal_id` FK (not `overarching_goal_id`); join table hidden as implementation detail with nested endpoints (`POST/DELETE /coaching_sessions/{id}/goals`) +- **Active goal limit:** Max 3 InProgress goals per coaching relationship, **backend-enforced** at `entity_api` layer. Returns HTTP 409 Conflict with `ValidationError` containing active goal summaries. Frontend handles with destructive toast and `ActiveGoalLimitError` types +- Both FKs in the join table use **CASCADE** delete (matches `actions_users` pattern) +- Existing data is **backfilled** into the join table from current `coaching_session_id` relationships (separate data migration) +- **Auto-link on creation:** When `created_in_session_id` is provided, backend auto-inserts a join table row (interim behavior until PR3 carry-forward replaces it) + +**Q2: Action FK — Option A (direct FK) confirmed** + +- Nullable `goal_id` column added to `actions` table (ON DELETE SET NULL) +- When a goal is deleted, associated actions are preserved with `goal_id` set to NULL +- Enables: `GET /actions?goal_id=X`, backend goal-level stats computation + +**Q3: SSE Events — Confirmed** + +- Goal events renamed: `goal_created`, `goal_updated`, `goal_deleted` (replacing `overarching_goal_*`) +- Two new events for join table: `coaching_session_goal_created`, `coaching_session_goal_deleted` +- Health signals computed **synchronously on read** — no separate health event needed; existing action events trigger cache invalidation which causes goal data to re-fetch with updated health +- Health heuristics are **dynamic when `target_date` is set**: compares elapsed time % vs action progress %. When `target_date` is null, health reflects **momentum only** (action completion regularity, no time pressure) + +**Q4: Annotation Persistence — Option C (both SSE + load-time) confirmed** + +- SSE cleanup for real-time mark removal when entities are deleted while notes are open +- Load-time validation as safety net for entities deleted while user was offline +- **Per-entity-type validation endpoints** (not a single cross-entity endpoint): + - `POST /actions/validate` — body: `{ ids: [UUID, ...] }`, response: `{ valid: [...], invalid: [...] }` + - `POST /agreements/validate` — same shape + - `POST /goals/validate` — same shape + +--- + +## API Gap Analysis + +| Endpoint | Method | Purpose | Status | +|---|---|---|---| +| `GET /goals?coaching_relationship_id=` | GET | List goals for a relationship | **PR2 done** — `coaching_relationship_id` required for auth (Option C) | +| `GET /users/{id}/goals?coaching_relationship_id=` | GET | List goals for a user, filtered by relationship | **PR2 done** — `coaching_relationship_id` query param | +| `GET /goals/{id}` | GET | Single goal | **PR2 done** — response includes `coaching_relationship_id`, `created_in_session_id`, `target_date` | +| `POST /goals` | POST | Create goal | **PR2 done** — body takes `coaching_relationship_id`; auto-links to originating session when `created_in_session_id` provided | +| `PUT /goals/{id}` | PUT | Update goal | **PR2 done** — active goal limit enforced on status transitions to InProgress | +| `PUT /goals/{id}/status` | PUT | Update goal status | **PR2 done** — active goal limit enforced on transitions to InProgress | +| `DELETE /goals/{id}` | DELETE | Delete goal | **PR2 done** — atomic delete with SSE event publishing | +| `POST /coaching_sessions/{id}/goals` | POST | Link goal to session | **PR2 done** — nested route (join table hidden as implementation detail) | +| `DELETE /coaching_sessions/{id}/goals/{goal_id}` | DELETE | Unlink goal from session | **PR2 done** — nested route | +| `GET /coaching_sessions/{id}/goals` | GET | Goals linked to a session (eager-loaded full models) | **PR2 done** — returns full goal models, not join table records | +| `GET /goals/{id}/sessions` | GET | Sessions that discussed a goal | **PR2 done** | +| `GET /users/{id}/goals?status=` | GET | Filter by status | Needs new query param | +| `GET /actions?goal_id=` | GET | Actions for a goal | Needs new query param (PR3) | +| `GET /goals/{id}/health` | GET | Aggregated stats (action counts, session count, health signal) | New — avoids N+1 (PR4) | +| `POST /actions/validate` | POST | Batch existence check for action IDs | New (PR5, annotation cleanup) | +| `POST /agreements/validate` | POST | Batch existence check for agreement IDs | New (PR5, annotation cleanup) | +| `POST /goals/validate` | POST | Batch existence check for goal IDs | New (PR5, annotation cleanup) | +| Health signal on goal responses | — | Backend-computed enum: `SolidMomentum / NeedsAttention / LetsRefocus` | New (PR4, computed sync on read) | + +--- + +## PR Dependency Graph + +``` +Layer 1 (backend — 5 PRs) + └─► Layer 2 (frontend types & API) + ├─► PR 3a (Dashboard: Goals Overview Card) + │ └─► PR 3b (Dashboard: Upcoming Session Card) + │ └─► PR 3c (Dashboard: Sessions dual-view — list) + │ └─► PR 3d (Dashboard: Sessions dual-view — timeline) + ├─► PR 4a (Goals Page: main view) + │ └─► PR 4b (Goals Page: Goal Detail Sheet) + └─► PR 5a (Session: Goal drawer replacement) + └─► PR 5b (Session: Notes selection menu + annotations) +``` + +--- + +## Layer 1 — Backend Schema & API Changes + +**Repo:** refactor-platform-rs (backend) +**Blocks:** Everything else + +### Confirmed scope (5-PR backend plan) + +The backend team has committed to a 5-PR implementation plan: + +1. **PR1 — Rename:** `overarching_goals` → `goals` across DB table, API paths (`/overarching_goals` → `/goals`), and SSE event names (`overarching_goal_*` → `goal_*`) — **✅ MERGED** +2. **PR2 — Goal scoping:** — **✅ MERGED** (backend [PR #242](https://github.com/refactor-group/refactor-platform-rs/pull/242), frontend [PR #330](https://github.com/refactor-group/refactor-platform-fe/pull/330)) + - Add `coaching_relationship_id` (NOT NULL, backfill) to `goals`, rename `coaching_session_id` → `created_in_session_id` (nullable), add `target_date` (DATE, nullable) + - Create `coaching_sessions_goals` join table (CASCADE on both FKs) with nested endpoints (hidden as implementation detail): `POST/DELETE /coaching_sessions/{id}/goals`, `GET /coaching_sessions/{id}/goals` (eager-loads full goal models), `GET /goals/{id}/sessions` + - Separate schema and data migrations; data migration backfills join table from existing `created_in_session_id` links + - Auto-link on creation: when `created_in_session_id` is provided, backend auto-inserts a `coaching_sessions_goals` row (extracted into `link_to_originating_session()` with CHANGEME markers for PR3 carry-forward) + - Add `DELETE /goals/{id}` (atomic delete in transaction with SSE event publishing) + - **Active goal limit:** Max 3 InProgress goals per coaching relationship, enforced at `entity_api` layer on create and status transitions. Returns HTTP 409 Conflict with `ValidationError` containing active goal summaries. Uses generic `EntityErrorKind::Conflict` (not a goal-specific error variant) + - Protect middleware: `by_id` (path-based goal auth) and `by_coaching_session_id` (session-based auth) middleware on goal routes; authorizes via `coaching_relationship_id` directly + - Entity helpers: `in_progress()` on goal model, `includes_user()` on coaching relationship model + - Email helper: uses join table for session→goals lookup, formats as HTML ordered list, uses `max_in_progress_goals()` accessor instead of hardcoded limit + - Coding standards: added "Error Variant Reuse" guidance — prefer generic, reusable error variants with context fields over one-off variants +3. **PR3 — Action FK + carry-forward:** Add nullable `goal_id` to `actions` (ON DELETE SET NULL), add `goal_id` query param to action list endpoints. Refactor `batch_load_goals` to use join table (CHANGEME in PR2). Implement carry-forward workflow (auto-link active goals on session creation) +4. **PR4 — SSE events + health:** Add `coaching_session_goal_created` and `coaching_session_goal_deleted` events for the join table; implement health signal computation (synchronous on read) via `GET /goals/{id}/health` returning `GoalHealthMetrics` +5. **PR5 — Validation endpoints:** `POST /actions/validate`, `POST /agreements/validate`, `POST /goals/validate` for annotation stale-mark cleanup + +### PR2 Coordinated Deploy (completed) + +PR2 was a breaking change requiring simultaneous frontend deployment. Both PRs were merged together. Key breaking changes: +- POST/PUT request bodies: `coaching_session_id` → `created_in_session_id` (nullable) +- `GET /goals` requires `coaching_relationship_id` as a query param — backend protect middleware authorizes through it directly +- Join table endpoints use nested routes: `POST /coaching_sessions/{id}/goals` (not flat `/coaching_sessions_goals`) +- `GET /coaching_sessions/{id}/goals` returns eager-loaded full goal models (not join table records) +- Goal responses include `coaching_relationship_id`, `created_in_session_id`, `target_date` + +### Goal-Session Carry-Forward Workflow (PR3 scope) + +When a coach creates a new coaching session, all **active goals** from the relationship are automatically linked to it via the `coaching_sessions_goals` join table (carry-forward model). The coach reviews them before/during the session. + +**Opt-out model:** Goals are pre-linked (not manually attached). During a session, the coach/coachee can: +- Unlink an existing active goal from the current session +- Create a new goal and link it to the current session +- Link an existing unlinked goal to the current session + +This means `coaching_sessions_goals` rows are **created at session-creation time** (not lazily). Frontend implication: the "create coaching session" flow will need a goal review/management step. + +**PR2 interim behavior:** Goals are auto-linked to their originating session only (via `link_to_originating_session()`). Full carry-forward (auto-link all active goals on session creation) is PR3 scope. The `link_to_originating_session()` function has CHANGEME markers for removal when carry-forward replaces auto-linking. + +### Deliverable +Backend PRs with migrations, endpoint changes, and tests. PR1 + PR2 are merged — frontend Layer 2 work can proceed. PR3–PR5 can land in parallel with early frontend work. + +--- + +## Layer 2 — Frontend Types & API Layer + +### PR2 companion (✅ MERGED — [PR #330](https://github.com/refactor-group/refactor-platform-fe/pull/330)) + +**What was delivered:** +- Updated `Goal` interface: removed `coaching_session_id`, added `coaching_relationship_id` (required), `created_in_session_id` (nullable), `target_date` (nullable) +- Hardened `isGoal` type guard with all field checks (title, body, target_date, status_changed_at, completed_at) +- `GoalApi.list()` sends `coaching_relationship_id` query param +- Added `GoalApi.listBySession()` → renamed to `listNested` using `EntityApi.listNestedFn` pattern for `GET /coaching_sessions/{id}/goals` +- Added `useGoalsBySession` hook with conditional URL construction (avoids embedding "null" string when session ID absent) +- Added `goalTitle()` helper and `DEFAULT_GOAL_TITLE` constant for consistent goal title display across 6 UI call sites +- Send `created_in_session_id` on goal creation to trigger backend auto-link +- SSE cache invalidation narrowed: `invalidateEndpoint('/coaching_sessions')` was too broad (caused title flash on goal updates) → replaced with targeted invalidator matching only `/coaching_sessions/{id}/goals` keys +- Active goal limit handling: `ActiveGoalLimitError` types, `extractActiveGoalLimitError` helper, destructive toast on HTTP 409 using `max_active_goals` from response (not hardcoded) +- Applied render guard pattern in `GoalContainer` +- 38 new tests: Goal type guard (27), GoalApi/hooks (11) + +**Key files modified:** +- `src/types/goal.ts` — Goal interface, `isGoal` guard, `ActiveGoalLimitError` types, `goalTitle()` helper +- `src/lib/api/goals.ts` — `GoalApi`, `useGoalList`, `useGoalsBySession`, `listNested` +- `src/lib/hooks/use-sse-cache-invalidation.ts` — narrowed session-goal cache invalidation +- `src/components/ui/coaching-sessions/goal-container.tsx` — render guards, `handleGoalChange` accepts goal as parameter +- `src/components/ui/coaching-session.tsx`, `coaching-session-selector.tsx`, `dashboard/today-session-card.tsx`, `join-session-popover.tsx` — updated to use `goalTitle()` helper +- `__tests__/types/goal.test.ts`, `__tests__/lib/api/goals.test.ts` — new test files + +### Remaining Layer 2 work (future PRs) + +- Add `GoalHealth` enum (`SolidMomentum | NeedsAttention | LetsRefocus`), `GoalHealthMetrics` interface — blocked on backend PR4 +- Add goal health API hook (`GET /goals/{id}/health`) — blocked on backend PR4 +- Add `CoachingSessionGoal` join table events to SSE types (`coaching_session_goal_created/deleted`) — blocked on backend PR4 +- Add entity validation API functions (`POST /actions/validate`, etc.) — blocked on backend PR5 +- Add `goal_id` param support in action API hooks — blocked on backend PR3 +- Add user-level goal listing with `status` filter param — blocked on backend adding `status` query param + +--- + +## Layer 3 — Dashboard Component Updates + +### PR 3a: Goals Overview Card +**Branch:** `feat/dashboard-goals-overview` + +New component: a collapsible card on the dashboard showing the user's active goals with a circular SVG progress ring, per-goal action progress rows, and health signal. + +**Prototype reference:** `src/app/prototype/dashboard-goals/page.tsx` — `GoalsOverviewCard` and `ProgressRing` functions. + +**Key files:** `src/components/ui/dashboard/goals-overview-card.tsx`, `progress-ring.tsx`, `goal-row.tsx`. Modify `dashboard-container.tsx`. + +### PR 3b: Upcoming Session Card +**Branch:** `feat/dashboard-upcoming-session-card` + +Replace the `TodaysSessions` carousel + multi-card surface with a single **Upcoming Session Card** on the dashboard. Match the prototype's Mercury styling, add a duration line not present in the prototype, render a multi-goal list of every goal linked to the session, and provide an actionable empty state when no session remains today. + +**Prototype reference:** `src/app/prototype/dashboard-goals/page.tsx` — `TodaySessionCard` function (lines 186–232). + +#### Session selection logic + +- Source: `useTodaysSessions()` — already returns today's sessions in the user's timezone with a 30s tick; reuse as-is. +- Select **the next non-past session**: first session in `asc`-by-date order whose urgency is not `Past` (i.e. `Underway`, `Imminent`, `Soon`, or `Later`). +- If none remains → empty state. Strictly today; no fallback to tomorrow. + +#### Card structure (prototype + additions) + +- `border shadow-none h-full flex flex-col`; inner `p-6 flex flex-col flex-1 gap-4`. +- **Header row:** + - Eyebrow: `UPCOMING SESSION` (`text-xs font-medium uppercase tracking-wider text-muted-foreground/60`). + - Title: `Session with {participantName}` (`text-base font-semibold`). + - Right column (stacked): time (`text-sm tabular-nums`) on line 1, duration (`text-[11px] text-muted-foreground/60 tabular-nums` — e.g. `60 min`) on line 2. Use `DEFAULT_SESSION_DURATION_MINUTES` until backend ships per-session duration (existing TODO preserved). +- **Participant row:** 32px muted avatar circle with initials + `N actions due` copy (reuse the filter logic currently in `TodaySessionCard` lines 184–193). +- **Goals list:** render `` — stacked dot+title rows, one per linked goal, truncated. +- **Footer** (separated by `border-t`): + - Left: pulsing dot + urgency copy from `getUrgencyMessage(session, urgency, timezone)` — reuse existing util. The current colored urgency *band* is dropped, but the *messages* (`Starting in 45 min`, `Under way`, etc.) remain. + - Right: `Reschedule` (outline, coach-only) + `Join` (primary, links to `/coaching-sessions/{id}`). + +#### Dropped from current card (not in prototype) + +Share-link icon, organization name, role line, colored urgency band. These remain accessible on the coaching-session page itself. + +#### Empty state (new, extends prototype) + +Same card chrome. Centered inside the card body: +- Small Lucide `CalendarPlus` icon above, `text-muted-foreground/40`. +- Heading: `No sessions scheduled for today` (`text-sm font-medium`). +- Subcopy: one-line explanation (`text-xs text-muted-foreground`). +- Primary button: `Schedule a coaching session`, proportionally sized to match the filled state's `Reschedule/Join` footer. Wires to `DashboardContainer`'s existing `CoachingSessionDialog` via an `onCreateSession` callback prop — no new dialog state. + +#### Factored shared component + +**`src/components/ui/session-goal-list.tsx`** — new, read-only presentational component, placed loose in `src/components/ui/` to match the existing convention for shared goal widgets (`goal-pill.tsx`, `goal-picker-popover.tsx`). + +```ts +interface SessionGoalListProps { + goals: Goal[]; + dotClassName?: string; // default: "bg-emerald-800/50" + textClassName?: string; // default: "text-sm text-muted-foreground" + gapClassName?: string; // default: "gap-0.5" + emptyFallback?: React.ReactNode; // default: null — caller decides +} +``` + +Renders the prototype's dot-per-goal pattern (prototype lines 714–720). Uses `goalTitle(goal)`. No interactivity — callers wrap with a link or popover if needed. + +**Why extract rather than reuse `CompactGoalCard`:** `goal-card-compact.tsx` is an interactive flip-card with edit/remove/swap modes and a per-goal `useGoalProgress` fetch — overkill for read-only "what goals is this session about" surfaces. This extraction gives one place to enforce the Mercury goal-chip look; `CompactGoalCard` keeps its interactive role on the session panel unchanged. + +Reuse sites: +- `UpcomingSessionCard` (this PR) +- `SessionRow` detail panel in PR 3c +- Timeline hover card in PR 3d +- Any future session-summary surface + +#### Files + +**Created:** +- `src/components/ui/dashboard/upcoming-session-card.tsx` +- `src/components/ui/dashboard/upcoming-session-empty.tsx` (empty-state subcomponent) +- `src/components/ui/session-goal-list.tsx` + +**Modified:** +- `src/components/ui/dashboard/dashboard-container.tsx` — swap `TodaysSessions` for `UpcomingSessionCard`, pass `onCreateSession={() => handleOpenDialog()}` and the existing reschedule callback. + +**Deleted (after parity is verified):** +- `src/components/ui/dashboard/today-session-card.tsx` +- `src/components/ui/dashboard/todays-sessions.tsx` +- `src/components/ui/dashboard/todays-sessions-states.tsx` +- `src/components/ui/dashboard/session-carousel-navigation.tsx` +- `src/lib/hooks/use-carousel-state.ts` and `src/lib/hooks/use-session-auto-scroll.ts` **if** Grep confirms no other callers; otherwise leave in place. +- Associated tests under `__tests__/`. + +#### Tests (Vitest + MSW, TDD) + +- `upcoming-session-card.test.tsx` + - Picks the first non-past session from a mixed set (not simply the first by date). + - Renders participant initials, time, duration line, urgency message; pulsing dot shown only for `Imminent` / `Underway`. + - Lists all linked goals via `SessionGoalList`; truncates long titles. + - `Reschedule` hidden when current user is coachee, shown when coach. + - Empty state renders when all sessions are `Past`. + - Empty-state button fires `onCreateSession`. +- `session-goal-list.test.tsx` — one row per goal, uses `goalTitle()` fallback for empty titles, honors class overrides. +- Update `dashboard-container.test.tsx` (if present) to assert `UpcomingSessionCard` replaces the carousel. + +#### Acceptance criteria + +1. Visual parity with the prototype's `TodaySessionCard` (spacing, typography, pulsing dot, footer layout, border/shadow). +2. Duration line under time (new). +3. Multi-goal vertical list (new, via `SessionGoalList`). +4. Selects next non-past today session; empty state otherwise. +5. Empty state's "Schedule a coaching session" button opens the existing `CoachingSessionDialog`. +6. Live countdown (30s tick) preserved. +7. Deleted files have no remaining references (Grep clean). + +### PR 3c: Sessions Dual-View — List +**Branch:** `feat/dashboard-sessions-list` + +Replace `CoachingSessionList` with new sessions card containing list view with master-detail hover pattern. + +**Prototype reference:** `src/app/prototype/dashboard-goals/page.tsx` — `SessionRow` function. + +**Key files:** New `sessions-card.tsx`, `sessions-list-view.tsx`, `session-row.tsx`. Modify `dashboard-container.tsx`. + +### PR 3d: Sessions Dual-View — Timeline +**Branch:** `feat/dashboard-sessions-timeline` + +Add timeline view: horizontal day timeline (6 AM–9 PM), draggable session blocks, 5-minute snap, "Now" indicator, reschedule confirmation. + +**Prototype reference:** `src/app/prototype/dashboard-goals/page.tsx` — `DayTimeline` function. + +**Key files:** New `sessions-timeline-view.tsx`, `day-timeline.tsx`. Modify `sessions-card.tsx`. + +--- + +## Layer 4 — Goals Page + +### PR 4a: Goals Page Main View +**Branch:** `feat/goals-page` +**Route:** `src/app/goals/` + +New top-level page following `src/app/actions/` naming pattern. Status filter toggle, summary banner, responsive goal card grid, inline goal creation. + +**Prototype reference:** `src/app/prototype/goals-hub/page.tsx` — `GoalsHubPrototype`, `SummaryBanner`, `GoalCard`, `NewGoalCard`. + +**Key files:** `src/app/goals/layout.tsx`, `page.tsx`, `src/components/ui/goals/goals-page-container.tsx`, `goals-page-header.tsx`, `goal-card.tsx`, `new-goal-card.tsx`, `summary-banner.tsx`. Modify `app-sidebar.tsx`. + +### PR 4b: Goal Detail Sheet +**Branch:** `feat/goals-detail-sheet` + +Bottom sheet (85vh) opened from goal card click. Editable title/description, status dropdown, optional `target_date` date picker, stats cards, tabs for session timeline and action groups. + +**Prototype reference:** `src/app/prototype/goals-hub/page.tsx` — `GoalDetailSheet`, `TimelineSession`, `ActionGroup`, `ActionRow`. Also `goal-detail/page.tsx` for inline-edit. + +**Key files:** `src/components/ui/goals/goal-detail-sheet.tsx`, `goal-session-timeline.tsx`, `goal-action-groups.tsx`, `goal-stat-card.tsx`. + +--- + +## Layer 5 — Coaching Session Page Changes + +### PR 5a: Goal Drawer Replacement +**Branch:** `feat/session-goal-drawer` + +Replace `OverarchingGoalContainer` (single-goal strip) with multi-goal drawer: collapsible bar with goal chips, progress cards, goal picker combobox for link/unlink. + +**Prototype reference:** `src/app/prototype/session-goals/page.tsx` — `GoalDrawer`, `GoalChip`, `GoalPicker`. + +**Key files:** New `goal-drawer.tsx`, `goal-chip.tsx`, `goal-picker.tsx`. Modify `coaching-sessions/[id]/page.tsx`. Replace `overarching-goal-container.tsx` and `overarching-goal.tsx`. + +### PR 5b: Notes Selection Menu + Annotations +**Branch:** `feat/session-notes-annotations` + +Extend the existing `SelectionBubbleMenu` with "Create Goal" and "Create Agreement" buttons. Implement custom TipTap marks for colored inline annotations that persist in Yjs and link to DB entities. + +**Annotation cleanup strategy (Q4 resolved):** +- **Real-time:** SSE events (`goal_deleted`, `action_deleted`, `agreement_deleted`) trigger mark removal in open editors +- **Load-time safety net:** On editor load, collect all `entityId` values from marks, batch-validate via `POST /goals/validate`, `POST /actions/validate`, `POST /agreements/validate`, remove stale marks + +**Prototype reference:** `src/app/prototype/session-goals/page.tsx` — `MockBubbleMenu`, `MarkPopover`. + +**Key files:** Modify `selection-bubble-menu.tsx`, `coaching-tabs-container.tsx`, `coaching-notes.tsx`. New `entity-mark-extension.tsx`, `mark-popover-extension.tsx`. + +--- + +## Testing Strategy + +### Unit tests (Vitest + jsdom + MSW) +Default for all PRs. TDD approach: write failing tests first, implement, verify pass. + +### Playwright E2E tests +Reserve for complex interactions: + +| PR | E2E Candidate | Why | +|---|---|---| +| PR 3d | Drag-to-reschedule | Pointer capture + coordinate math needs real browser | +| PR 4a | Status filter + URL sync | URL ↔ filter ↔ grid state round-trip | +| PR 5a | Goal picker + link/unlink | Complex popover + combobox chain | +| PR 5b | Mark creation + persistence | TipTap + selection + marks requires real DOM | + +--- + +## Rules Applied to Every PR + +1. Read `.claude/coding-standards.md` before writing code; conform to all patterns +2. Inventory existing components, hooks, types, utils for reuse; verify existing call sites on extension +3. Identify existing tests for modified code; enumerate and preserve regression coverage +4. TDD: write focused, failing tests first → implement → verify pass → refactor +5. Prefer discriminated unions and Result types over nullable types +6. Thread `locale` and config values via props, not `siteConfig` imports in leaf components +7. Use render guards (not `|| ""` fallbacks) for nullable hook values +8. Import React hooks directly, not via `React.` prefix +9. **Validate against prototype:** compare styling and functional behavior to the corresponding prototype page before marking complete