feat: "Ran while you were away" card on /studies (feat_overnight_studies_summary_card)#444
Conversation
Add the pure-read discovery helper backing the upcoming GET /api/v1/studies/chains/recent endpoint (FR-1): - list_recent_completed_chains(db, *, since, limit) returns deduplicated completed overnight chains (length >= 2), newest tail-completion first, capped at `limit` distinct chains - Reuses Phase 1's get_chain_for_study traversal and derive_chain_stop_reason rather than re-deriving chain math - Filters: parent_study_id IS NOT NULL (length >= 2 implied), completed_at IS NOT NULL, terminal status, optional since-cutoff - Scan-caps candidates at limit * _CHAIN_MAX_DESCENDANTS so dedup-to- anchor can fill `limit` distinct chains in the maxed-out case - Defensive in-flight skip on resolved traversals (interior link still running -> exclude) - Concurrent-delete safety: skip candidates whose traversal returns None, never raise 8 integration tests (AC-1/2/3/4/11/12 + concurrent-delete safety net via monkeypatching). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
Adds the read-only discovery endpoint backing the upcoming "Ran while
you were away" card (FR-1, AC-1/5/6/11):
- RecentChainSummary + RecentChainsResponse Pydantic schemas (10 + 3
fields). Reuses ObjectiveDirection + ChainStopReason Literals so the
source-of-truth comment chain stays intact.
- get_recent_chains handler declared BEFORE /studies/{study_id} so the
static "chains" path segment is not captured as a dynamic study_id —
route-order regression asserted by an integration test.
- _recent_chain_row helper mirrors get_study_chain's derivation block
(select_best_link + compute_cumulative_lift + derive_chain_stop_reason)
without extracting a shared helper (plan §5 — bounded refactor only).
- Typed query params (since: datetime | None, limit: int ge=1 le=50
default 20) auto-route malformed input through the global
validation_exception_handler -> 422 VALIDATION_ERROR envelope.
- Emits X-Total-Count = len(data); inert pagination
(next_cursor=null, has_more=false) per OQ-2 resolution.
Tests: 4 integration (AC-1, AC-5, AC-11, route-order regression) + 9
contract (response-model shape, enum literals, OpenAPI presence,
X-Total-Count header, 422 envelope for malformed since/limit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
New glossary entry backing the InfoTooltip on the upcoming "Ran while you were away" card on /studies (FR-6). short=120 chars (≤140 cap), long=~720 chars; both pass the existing length-cap test. Lands first because <InfoTooltip glossaryKey="..."> is type-locked against ShortGlossaryKey — the card in Story 2.2 won't compile without it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
…com) Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
Adds the two TanStack/localStorage hooks the upcoming RecentChainsCard
will consume (FR-1, FR-5):
- useStudiesVisited(): returns { since, dismiss }. since is the
ISO-8601 cutoff read from localStorage or now - 7d on first visit
(AC-9). dismiss(T) writes T + 1ms so the inclusive since filter
doesn't re-show the just-dismissed chain (FR-5). Defensive: malformed
input is ignored without throwing. SSR-safe via typeof window guard.
- useRecentChains(since, opts?): TanStack hook fetching the new
GET /api/v1/studies/chains/recent endpoint. Refetches on window
focus + reconnect only — best-effort discoverability, no aggressive
polling. Mirrors useStudyChain shape.
Tests: 4 vitest cases on useStudiesVisited covering AC-8 (dismiss+1ms),
AC-9 (7d default), persistence across mounts, and defensive parse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
…2.2)
Adds the "Ran while you were away" card above the studies table on
/studies (FR-1, FR-3, FR-4, FR-6, ACs 7/8/10/11):
- <RecentChainsCard>: self-contained card consuming useStudiesVisited
+ useRecentChains from Story 2.1. Early-returns null on pending /
error / empty so the studies table beneath always renders predictably
(best-effort discoverability per spec §10 failure modes).
- Per-row layout: anchor name as a <Link href=/studies/{anchor_study_id}>,
chain length, "Best <metric>: <value>" line, "Lift: <±value>" via
the shared formatSignedLift helper, stop-reason phrase via the shared
CHAIN_STOP_REASON_PHRASE map (both already extracted by
feat_overnight_final_solution_phase2 Story 1 / FR-8).
- Null-metric branch (AC-11): when best_metric is null the card drops
the numeric line entirely and leads with the stop-reason phrase
instead of rendering "Best ndcg: —".
- "Got it" computes max(tail_completed_at) across rows and calls
dismiss(maxIso); the +1ms exclusive nudge in useStudiesVisited
prevents the inclusive since filter from re-showing the just-
dismissed chain (FR-5).
- TWO <InfoTooltip> affordances per FR-6: recent_chains_card on the
CardTitle + overnight_autopilot reused on the "Overnight" label.
Mount: /studies/page.tsx renders <RecentChainsCard /> immediately
after the page header, before the target-filter chip row. No
shared-state changes — the card owns its own query + visited-state.
Tests: 8 vitest cases covering pending/error/empty -> null; AC-7
full-row render; AC-10 all 6 stop_reason wire values phrased; AC-11
null-metric drops numeric line; AC-8 dismiss with max tail; FR-6 both
InfoTooltip affordances present. Full ui suite 1115 vitest green, tsc
clean, Next.js build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
GPT-5.5 phase-gate review flagged the `?? row.stop_reason` fallback in the stop-reason phrasing as a potential raw-enum leak under backend forward-compat drift. The Record<ChainStopReason, string> typing makes the lookup exhaustive at compile time, but if the backend ships a new wire value before the frontend redeploys, the user would see e.g. `Stopped: no_lift_v2` instead of a phrase. Replace the fallback with the generic 'Chain stopped' phrase — same UX outcome, no leaked enum. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
Adds the smoke-job E2E that exercises the "Ran while you were away" card
end-to-end against the running stack (FR-1 + AC-7 + AC-8):
- Test 1 ("renders the card with a working Review chain link"):
seeds a 3-link chain via seedAutoFollowupChain with inFlightLeaf=false
so the chain is terminal, sets localStorage["relyloop.last_visited_
studies_at"] to 2000-01-01 via page.addInitScript(), navigates to
/studies, asserts the card and its anchor link, then clicks through
and asserts navigation to /studies/{root_id} and the study page summary.
- Test 2 ("'Got it' dismisses ... stays hidden after reload"): same
setup, clicks the dismiss button, asserts the card unmounts on next
refetch, reloads, asserts the card stays gone (FR-5 — the +1ms
exclusive nudge in useStudiesVisited prevents re-show across the
reload).
Per CLAUDE.md E2E rules: no page.route() mocking of backend; setup via
seed helpers (request only); assertions via the page object. Card
visibility / null-metric / phrase mapping / stop-reason exhaustiveness
are covered in detail by the vitest component suite — this spec proves
the real-stack mount + navigation flow.
Note: the smoke job is OFF by default per state.md (SMOKE_TEST=false);
operators opt in via `gh variable set SMOKE_TEST --body true`. Spec
typechecks via `pnpm typecheck`; runtime green is gated on operator opt-in.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
…(Story 3.2)
- api-conventions.md: new paragraph on read-only discovery endpoints
with inert pagination, citing GET /api/v1/studies/chains/recent as
the first entry. Documents X-Total-Count = len(data), the always-
null next_cursor + always-false has_more wire contract (forward-
compat with a possible MVP3 keyset story), and the route-order
ordering requirement (static "chains" declared before the dynamic
{study_id} route).
- ui-architecture.md: extends the /studies row in the routes table
with a description of the dismissible RecentChainsCard above the
table — owns its own data via useRecentChains + useStudiesVisited,
early-returns null on pending/error/empty (best-effort discoverability),
reuses CHAIN_STOP_REASON_PHRASE from the shared
ui/src/lib/chain-stop-reason.ts so the card + the chain panel stay
aligned on a single source of truth.
state.md / state_history.md updates land at finalization (post-merge,
per Story 3.2 DoD + post-impl Step 2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
…ains card Per feat_overnight_studies_summary_card implementation_plan.md §1 "Deferred phases": the spec's open questions OQ-2 (keyset pagination on GET /api/v1/studies/chains/recent) and OQ-3 (Postgres indexes on studies.parent_study_id + studies.completed_at) are both defer-until- incident — not blocking the v1 ship, but worth capturing so future operators (or the next agent) don't have to re-derive the analysis. Both land in 99_backlog/ (defer-until-incident classification — not the active MVP2 release bucket — because: - the discovery endpoint emits inert pagination by design under the hard limit ≤ 50 cap (OQ-2 resolved limit-cap-only for v1); - the discovery query reads ≤ 100 candidates at default sizing, trivial at single-tenant on-laptop scale (OQ-3). Each idea file cites the spec OQ, names the trigger event that should pull it forward, scopes the work (capabilities + backend / frontend / migration / config impact), and cross-links the two ideas (they pair cleanly — keyset pagination needs the indexes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
There was a problem hiding this comment.
Code Review
This pull request implements the 'Ran while you were away' feature, introducing a new backend endpoint GET /api/v1/studies/chains/recent and a frontend <RecentChainsCard> component to display recently completed overnight chains. Feedback on the backend highlights an N+1 query pattern in the chain discovery repository helper, suggesting a set-based tracking mechanism to skip redundant traversals. On the frontend, the reviewer identified a potential hydration mismatch in Next.js caused by reading from localStorage during the initial render of the useStudiesVisited hook, recommending a useEffect approach for SSR safety along with defensive validation of the retrieved date string.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| by_anchor: dict[str, ChainTraversalResult] = {} | ||
| for candidate_id in candidate_ids: | ||
| if len(by_anchor) >= limit: | ||
| break | ||
| traversal = await get_chain_for_study(db, candidate_id) | ||
| if traversal is None: | ||
| # Concurrent hard-delete between candidate query and traversal | ||
| # (e.g. test teardown). Skip silently per Story 1.1 task 5. | ||
| continue |
There was a problem hiding this comment.
The current implementation of list_recent_completed_chains suffers from an N+1 query pattern. It iterates over all candidate IDs and calls get_chain_for_study for each one. Since multiple candidates can belong to the same chain, this results in redundant database queries and traversal calculations for the same chain. Keeping track of already traversed study IDs in a set allows us to skip redundant calls entirely, significantly improving performance.
by_anchor: dict[str, ChainTraversalResult] = {}
seen_study_ids: set[str] = set()
for candidate_id in candidate_ids:
if len(by_anchor) >= limit:
break
if candidate_id in seen_study_ids:
continue
traversal = await get_chain_for_study(db, candidate_id)
if traversal is None:
# Concurrent hard-delete between candidate query and traversal
# (e.g. test teardown). Skip silently per Story 1.1 task 5.
continue
seen_study_ids.update(lk.id for lk in traversal.links)| * (`ui/src/hooks/use-local-storage-set.ts`). | ||
| */ | ||
|
|
||
| import { useCallback, useState } from 'react'; |
| export function useStudiesVisited(): UseStudiesVisitedResult { | ||
| // Hydrate synchronously via the useState initializer — matches the | ||
| // pattern in useLocalStorageSet (no extra render cycle, safe under | ||
| // SSR because readVisitedAt() guards on `typeof window`). | ||
| const [since, setSince] = useState<string>(() => readVisitedAt()); |
There was a problem hiding this comment.
Initializing state directly from localStorage during the initial render causes a hydration mismatch in Next.js because the server renders with defaultSince() while the client hydrates with the value from localStorage. To ensure SSR safety, initialize the state to defaultSince() and update it in a useEffect hook on mount.
| export function useStudiesVisited(): UseStudiesVisitedResult { | |
| // Hydrate synchronously via the useState initializer — matches the | |
| // pattern in useLocalStorageSet (no extra render cycle, safe under | |
| // SSR because readVisitedAt() guards on `typeof window`). | |
| const [since, setSince] = useState<string>(() => readVisitedAt()); | |
| export function useStudiesVisited(): UseStudiesVisitedResult { | |
| const [since, setSince] = useState<string>(() => defaultSince()); | |
| useEffect(() => { | |
| setSince(readVisitedAt()); | |
| }, []); |
| function readVisitedAt(): string { | ||
| if (typeof window === 'undefined') return defaultSince(); | ||
| try { | ||
| const raw = window.localStorage.getItem(STORAGE_KEY); | ||
| if (raw) return raw; | ||
| } catch { | ||
| // Private browsing / quota / corrupt — fall back to default. | ||
| } | ||
| return defaultSince(); | ||
| } |
There was a problem hiding this comment.
Defensively validate the value retrieved from localStorage to ensure it is a valid date string before returning it. This prevents sending malformed parameters to the backend and triggering 422 errors.
| function readVisitedAt(): string { | |
| if (typeof window === 'undefined') return defaultSince(); | |
| try { | |
| const raw = window.localStorage.getItem(STORAGE_KEY); | |
| if (raw) return raw; | |
| } catch { | |
| // Private browsing / quota / corrupt — fall back to default. | |
| } | |
| return defaultSince(); | |
| } | |
| function readVisitedAt(): string { | |
| if (typeof window === 'undefined') return defaultSince(); | |
| try { | |
| const raw = window.localStorage.getItem(STORAGE_KEY); | |
| if (raw && !Number.isNaN(Date.parse(raw))) return raw; | |
| } catch { | |
| // Private browsing / quota / corrupt — fall back to default. | |
| } | |
| return defaultSince(); | |
| } |
CI's test_openapi_has_no_orphan_endpoints test (the hand-maintained
EXPECTED_ENDPOINTS allowlist guarding the OpenAPI surface from
drift) tripped on the new GET /api/v1/studies/chains/recent endpoint
shipped by feat_overnight_studies_summary_card Story 1.2.
Plan §3.3 explicitly flagged this: "Coordinate with
bug_contract_allowlists_outdated_after_mvp2_features — if a hand-
maintained endpoint/route allowlist trips on the new route, update
it in the same PR." Adding the entry alongside the existing
/studies/{study_id}/chain row (the Phase 1 chain endpoint).
Local: 131 contract tests pass in the openapi-surface suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
- Backend (finding #1, High): eliminate N+1 in list_recent_completed_ chains. Track seen_study_ids across the candidate loop so candidates belonging to a chain already resolved skip the redundant get_chain_for_study call. Worst case for a 6-link chain drops from 6 traversal calls to 1. The dedup outcome is unchanged. - Frontend (finding #4, Medium): defensively validate the localStorage value in useStudiesVisited's readVisitedAt(). A corrupt stored value (operator manual edit, older-release shape) would otherwise propagate to GET /api/v1/studies/chains/recent?since=<garbage> and 422 every request until the operator clears localStorage. Now silently falls back to the 7-day default. Findings #2 + #3 (High, claimed SSR hydration mismatch from useState localStorage read) rejected — see PR-444 adjudication comment for counter-evidence: the card returns null while query.data is undefined so the DOM output is identical on server (no fetch) and client (initial render), and the useState-initializer + typeof window pattern is the project's established convention (use-local-storage-set.ts :46-48 uses the exact same shape). Tests: 21 backend + 12 vitest pass against the updated code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai>
Review adjudication (Gemini Code Assist)Commits landing fixes: Gemini Code Assist (4 findings)
Outcomes
Ready for human review + merge. |
Final cross-model review (GPT-5.5)Convergence note: 0 findings — clean pass. Reviewed: full PR diff ( Rejection log carried forward from prior phases (Epic 1 + Epic 2 GPT-5.5, Gemini #2/#3) was supplied as a no-re-raise hint; final review found no new High/Medium issues to surface and did not re-raise any rejected findings. CI is green on |
…445) - Move feature folder 02_mvp2/ -> implemented_features/2026_06_04_* (spec + plan + idea + pipeline_status travel together; no phase idea files to preserve). - pipeline_status.md: Implementation -> Complete (PR #444, ba1e6d6, 7/7 stories, 33 tests, cross-model review summary); add Release: mvp2 marker so the dashboard classifier keeps it in MVP2 after the bucket-less move. - implementation_plan.md: Status -> Complete (PR #444, merged 2026-06-04). - state.md: new merge prepended to Last 5; PR #433 dropped to the older-entries reference line; branch/active-feature/in-flight/queued refreshed. state.md = 26 KB (under 60 KB gate). - state_history.md: full merge narrative prepended. - Dashboards (MVP2 + backlog) regenerated by pre-commit hook. Signed-off-by: SoundMindsAI <eric.starr@soundminds.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Ships the "Ran while you were away" card on
/studies— the index-page surface for the overnight autopilot (the chain panel +<OvernightResultCard>already cover the detail page).GET /api/v1/studies/chains/recentreturning the deduplicated set of recently-completed overnight chains (length ≥ 2, terminal, optional?since=cutoff). Repo helperlist_recent_completed_chainsreuses the Phase 1get_chain_for_studytraversal andderive_chain_stop_reason— no chain math is re-derived. Inert pagination per OQ-2 (next_cursor: null,has_more: false,X-Total-Count = len(data)).RecentChainsCardcomponent above the studies table, owning its own data via two new hooks:useRecentChains(since)(TanStack) anduseStudiesVisited()(localStorage, +1ms exclusive dismissal nudge per FR-5). Stop-reason phrasing reusesCHAIN_STOP_REASON_PHRASEfromui/src/lib/chain-stop-reason.ts— the same map shipped withfeat_overnight_final_solution_phase2, so the card and the chain panel never drift. Newrecent_chains_cardglossary key.api-conventions.md+ui-architecture.mdupdated per Story 3.2.No migration. All chain math is derived from existing schema; Alembic head stays at
0022_solr_engine_auth_check.Verdict tables (cross-model adjudication)
GPT-5.5 Epic 1 review (1 finding)
backend/app/api/v1/studies.py:631select_best_linkreturns a link object. Counter-evidence:backend/app/domain/study/chain_summary.py:212typed `-> strGPT-5.5 Epic 2 review (2 findings)
ui/src/components/studies/recent-chains-card.tsx:57057c6168— replaced?? row.stop_reasonraw-enum fallback with?? 'Chain stopped'so a forward-compat backend drift renders a generic phrase instead of leaking the raw wire value.ui/src/components/studies/recent-chains-card.tsx:58cumulative_lift. Counter-evidence: plan §3 Story 2.2 inventory + §11.4 consistency review gate the null branch onbest_metric === nullonly; the existing chain panel atauto-followup-chain-panel.tsx:259rendersformatSignedLiftreturning "—" for null lift, which is the project's universal null-cell convention. My code matches the planned gate verbatim.Test plan
make lint && make typecheck(backend, clean)./.venv/bin/ruff format --check backend/(573 files formatted)make test-worktree): 8 + 4 new tests pass (Story 1.1 repo + Story 1.2 endpoint)pnpm test— 1115 vitest passed (151 files)pnpm typecheck— cleanpnpm lint— 0 errors (176 pre-existing warnings)pnpm build— clean (/studiesstill ○ static)ui/tests/e2e/recent-chains-card.spec.tstypechecks; runtime green is gated on operator opt-in (gh variable set SMOKE_TEST --body true)./studiesafter merge — does the card render correctly with seeded chains?Deferred work captured (per plan §1)
Two
99_backlog/idea files filed for the spec's open questions (defer-until-incident):chore_studies_chain_recent_indexes— OQ-3 deferral, Postgres indexes onstudies.parent_study_id+studies.completed_atonce the discovery query becomes hot.chore_studies_chain_recent_keyset_pagination— OQ-2 deferral, real keyset pagination once an operator clips the fixed 50-row cap.Guide impact
Guide 06 (
Create and monitor a study) captures01-studies-list.pngof/studies. The newRecentChainsCardrenders above the table when there are recent terminated chains; the Acme seed in guide 06's setup may or may not trigger the card depending on chain tail vs the 7-day default cutoff. Flagged for operator-decided regen — the card is deterministic enough that the operator can choose to capture it (andlocalStorage.setItema known cutoff) or hide it (set the cutoff tonow).Notes
Commit
04732efd docs(planned): idea — walkthrough guides on public website (relyloop.com)landed on this branch from a parallel session — it's an unrelated planned-feature idea file. Disclosing rather than reverting (the commit is coherent on its own).🤖 Generated with Claude Code