From abcafdd9b40eae26462e80518995147ba8c014c5 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Sun, 24 May 2026 19:48:32 +0800 Subject: [PATCH 1/8] feat(history): mirror day-detail cards + routed detail page at /mirror/$id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DayDetailCard renders mirror reflections as rich rows: context badge, time, story-reframe headline, highlight-phrase pull quote, transcript snippet, plus a "Show more →" link for backend-backed entries - New MirrorDetailSheet at /mirror/$id shows the full mirror: Story reframe, Validation, Inferred meaning, Transcript, with Confirm / Forget actions for pending reviews; sidebar carries date, time, context, status, and mood tags - Route lives at /mirror/$id (not /history/mirror/$id) because TanStack's matcher picks /history/$tab over the more specific /history/mirror/$id when both sit under _app/history - Distinguish "Loading…" (captures slice not yet hydrated) from "couldn't find that mirror" (genuinely missing) - backend-snapshot: validation flows from MirrorEntryRow into the snapshot (optional on the type so incremental captures.patch writes are unaffected) --- .../student-space/sheets/DayDetailCard.tsx | 112 ++++- .../sheets/MirrorDetailSheet.tsx | 417 ++++++++++++++++++ src/lib/student-space/backend-snapshot.ts | 5 + src/routes/_app.mirror.$id.tsx | 17 + 4 files changed, 531 insertions(+), 20 deletions(-) create mode 100644 src/components/student-space/sheets/MirrorDetailSheet.tsx create mode 100644 src/routes/_app.mirror.$id.tsx diff --git a/src/components/student-space/sheets/DayDetailCard.tsx b/src/components/student-space/sheets/DayDetailCard.tsx index 86e16e2..f39674b 100644 --- a/src/components/student-space/sheets/DayDetailCard.tsx +++ b/src/components/student-space/sheets/DayDetailCard.tsx @@ -1,5 +1,5 @@ -import { useNavigate } from '@tanstack/react-router' -import { Sparkles } from 'lucide-react' +import { Link, useNavigate } from '@tanstack/react-router' +import { ArrowRight, Sparkles } from 'lucide-react' import { useState } from 'react' /** @@ -39,6 +39,7 @@ interface DayDetailCapture { entryDate: string kind: string text?: string + validation?: string createdAt?: string prompt?: string | null backendMirrorEntryId?: number | string @@ -47,6 +48,28 @@ interface DayDetailCapture { syncError?: string contextType?: string caption?: string + reframe?: { + headline?: string + highlightPhrase?: string + themes?: string[] + needs?: string[] + moods?: string[] + } +} + +const CONTEXT_LABEL: Record = { + school: 'School', + peer: 'Peer', + civic: 'Civic', + family: 'Family', + hobby: 'Hobby', +} + +function formatTime(iso: string | undefined): string { + if (!iso) return '' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '' + return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) } interface DayDetailEngineState { @@ -271,22 +294,58 @@ export function DayDetailCard({ ) : null} {captures.length > 0 ? (
-

Captures

+

Mirrors

    - {captures.map((cap) => ( -
  • -

    - {cap.kind === 'ask' ? 'Reflection' : cap.kind} -

    - {cap.text ? ( -

    {cap.text.slice(0, 180)}

    - ) : cap.caption ? ( -

    {cap.caption}

    - ) : null} - {cap.kind === 'ask' ? ( + {captures.map((cap) => { + if (cap.kind !== 'ask') { + return ( +
  • +

    {cap.kind}

    + {cap.text ? ( +

    {cap.text.slice(0, 180)}

    + ) : cap.caption ? ( +

    {cap.caption}

    + ) : null} +
  • + ) + } + const headline = cap.reframe?.headline?.trim() ?? '' + const highlight = cap.reframe?.highlightPhrase?.trim() ?? '' + const time = formatTime(cap.createdAt) + const contextLabel = cap.contextType + ? (CONTEXT_LABEL[cap.contextType] ?? cap.contextType) + : 'Mirror' + const entryId = Number(cap.backendMirrorEntryId) + const hasBackendId = Number.isInteger(entryId) && entryId > 0 + return ( +
  • +
    + + {contextLabel} + + {time ? {time} : null} +
    + {headline ? ( +

    + {headline} +

    + ) : null} + {highlight ? ( +

    + “{highlight}” +

    + ) : null} + {cap.text ? ( +

    + {cap.text.slice(0, 180)} +

    + ) : null} void reviewCapture(cap, status)} onRetry={() => void retryCaptureSync(cap)} /> - ) : null} -
  • - ))} + {hasBackendId ? ( +
    + + Show more + + +
    + ) : null} + + ) + })}
) : null} diff --git a/src/components/student-space/sheets/MirrorDetailSheet.tsx b/src/components/student-space/sheets/MirrorDetailSheet.tsx new file mode 100644 index 0000000..f90249c --- /dev/null +++ b/src/components/student-space/sheets/MirrorDetailSheet.tsx @@ -0,0 +1,417 @@ +import { Link, useNavigate, useParams } from '@tanstack/react-router' +import { ArrowLeft } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + PageSurface, + SheetBody, + SheetContent, + SheetDescription, + SheetEyebrow, + SheetIdentityHeader, + SheetPageHeader, + SheetSidebar, + SheetTitle, + usePageEscape, +} from '~/components/ui/sheet' +import { useEngine } from '~/lib/student-space/use-engine' +import { useEngineSliceVersion } from '~/lib/student-space/use-engine-slice-version' + +/** + * MirrorDetailSheet — full details view for a single mirror reflection. + * + * Opens at `/mirror/$id` from the History day-detail card's "Show more" + * link. Reads the capture out of the engine's `captures` slice by + * `backendMirrorEntryId`. Sidebar carries context (date, time, status); + * the right pane shows the story reframe, validation, inferred meaning, + * and the full transcript, plus Confirm / Forget actions for pending + * reflections. + */ + +interface MirrorCapture { + id: string + entryDate?: string + kind: string + text?: string + validation?: string + createdAt?: string + backendMirrorEntryId?: number | string + reviewStatus?: 'pending' | 'confirmed' | 'forgotten' | string + contextType?: string + reframe?: { + headline?: string + highlightPhrase?: string + themes?: string[] + needs?: string[] + moods?: string[] + } +} + +interface MirrorEngineState { + applyBackendSnapshot?: (snapshot: unknown) => void + backend?: { + updateReflectionReview?: (input: { + entryId: number + status: 'confirmed' | 'forgotten' + }) => Promise<{ + reviewStatus?: string + transcript?: string + contextType?: string + storyReframe?: string + inferredMeaning?: string + validation?: string + }> + refreshSnapshot?: () => Promise + } + captures?: { + entries?: MirrorCapture[] + patch?: (id: string, updates: Record) => unknown + } +} + +const CONTEXT_LABEL: Record = { + school: 'School', + peer: 'Peer', + civic: 'Civic', + family: 'Family', + hobby: 'Hobby', +} + +type Subscribable = { subscribe: (cb: () => void) => () => void } + +export function MirrorDetailSheet() { + const engine = useEngine() + const navigate = useNavigate() + const params = useParams({ strict: false }) as { id?: string } + const entryId = Number(params.id) + const entryIdValid = Number.isInteger(entryId) && entryId > 0 + + const state = ( + engine as unknown as { state?: MirrorEngineState & { captures?: Subscribable } } | null + )?.state + useEngineSliceVersion(state?.captures ?? null) + + const entries = state?.captures?.entries + const hydrated = Array.isArray(entries) + + const capture = useMemo(() => { + if (!entryIdValid) return null + return ( + entries?.find( + (entry) => Number(entry.backendMirrorEntryId) === entryId && entry.kind === 'ask', + ) ?? null + ) + }, [entries, entryId, entryIdValid]) + + useEffect(() => { + document.body.classList.add('has-overlay') + return () => document.body.classList.remove('has-overlay') + }, []) + + const dismissToHistory = useCallback(() => navigate({ to: '/history' }), [navigate]) + usePageEscape(dismissToHistory) + + const notFoundCopy = + !hydrated && entryIdValid + ? 'Loading…' + : 'We couldn’t find that mirror. It may have been let go.' + + return ( + + + + + + Back to history + + Mirror + + {capture + ? formatLongDate(capture.entryDate ?? toEntryDate(capture.createdAt)) + : 'Reflection details'} + + + {capture ? ( +
+ +
+ ) : null} +
+ + {capture ? ( + <> + + Story reframe + + {capture.reframe?.headline?.trim() || + capture.reframe?.highlightPhrase?.trim() || + 'Untitled mirror'} + + {capture.reframe?.highlightPhrase?.trim() ? ( +

+ “{capture.reframe.highlightPhrase.trim()}” +

+ ) : null} +
+ + + + + ) : ( + +

+ {notFoundCopy} +

+ + + Back to history + +
+ )} +
+
+ ) +} + +function SidebarMeta({ capture }: { capture: MirrorCapture }) { + const time = capture.createdAt + ? new Date(capture.createdAt).toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + }) + : '' + const contextLabel = capture.contextType + ? (CONTEXT_LABEL[capture.contextType] ?? capture.contextType) + : null + const moods = capture.reframe?.moods ?? [] + const status = capture.reviewStatus + return ( +
+ {time ? ( + + {time} + + ) : null} + {contextLabel ? ( + + + {contextLabel} + + + ) : null} + {status ? ( + + + {status === 'confirmed' + ? 'Confirmed' + : status === 'forgotten' + ? 'Let go' + : 'Pending review'} + + + ) : null} + {moods.length > 0 ? ( + +
+ {moods.map((mood) => ( + + {mood} + + ))} +
+ + ) : null} +
+ ) +} + +function Meta({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ) +} + +function MirrorBody({ + capture, + engineState, +}: { + capture: MirrorCapture + engineState: MirrorEngineState | undefined +}) { + const reframe = capture.reframe + const validation = capture.validation?.trim() ?? '' + const transcript = capture.text?.trim() ?? '' + return ( +
+ {reframe?.headline?.trim() ? ( +
+

{reframe.headline}

+
+ ) : null} + {validation ? ( +
+

{validation}

+
+ ) : null} + {reframe?.highlightPhrase?.trim() ? ( +
+

{reframe.highlightPhrase}

+
+ ) : null} + {transcript ? ( +
+

+ {transcript} +

+
+ ) : null} + +
+ ) +} + +function Section({ + eyebrow, + title, + children, +}: { + eyebrow: string + title: string + children: React.ReactNode +}) { + return ( +
+

+ {eyebrow} +

+

{title}

+
{children}
+
+ ) +} + +function ReviewActions({ + capture, + engineState, +}: { + capture: MirrorCapture + engineState: MirrorEngineState | undefined +}) { + const entryId = Number(capture.backendMirrorEntryId) + const canReview = Number.isInteger(entryId) && entryId > 0 && capture.reviewStatus === 'pending' + const [pendingStatus, setPendingStatus] = useState<'confirmed' | 'forgotten' | null>(null) + const [error, setError] = useState(null) + + const run = useCallback( + async (status: 'confirmed' | 'forgotten') => { + const update = engineState?.backend?.updateReflectionReview + if (!update || pendingStatus !== null) return + setPendingStatus(status) + setError(null) + try { + const updated = await update({ entryId, status }) + const patch: Record = { reviewStatus: updated?.reviewStatus || status } + if (updated?.transcript) patch.text = updated.transcript + if (updated?.validation) patch.validation = updated.validation + if (updated?.contextType) patch.contextType = updated.contextType + if (updated) { + patch.reframe = { + headline: updated.storyReframe || capture.reframe?.headline || '', + highlightPhrase: updated.inferredMeaning || capture.reframe?.highlightPhrase || '', + themes: updated.contextType ? [updated.contextType] : (capture.reframe?.themes ?? []), + needs: capture.reframe?.needs ?? [], + moods: capture.reframe?.moods ?? [], + } + } + let patched = engineState.captures?.patch?.(`mirror:${entryId}`, patch) + if (!patched && capture.id) { + patched = engineState.captures?.patch?.(capture.id, patch) + } + try { + const snapshot = await engineState.backend?.refreshSnapshot?.() + if (snapshot) engineState.applyBackendSnapshot?.(snapshot) + } catch (refreshErr) { + console.warn('[MirrorDetailSheet] snapshot refresh failed', refreshErr) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn('[MirrorDetailSheet] review failed', err) + setError(`Review update failed: ${message}`) + } finally { + setPendingStatus(null) + } + }, + [capture, engineState, entryId, pendingStatus], + ) + + if (!canReview) return null + return ( +
+
+ + +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} + +function formatLongDate(ymd: string | undefined): string { + if (!ymd) return '' + try { + return new Date(`${ymd}T00:00:00`).toLocaleDateString(undefined, { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + }) + } catch { + return ymd + } +} + +function toEntryDate(iso: string | undefined): string { + if (!iso) return '' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '' + return date.toISOString().slice(0, 10) +} diff --git a/src/lib/student-space/backend-snapshot.ts b/src/lib/student-space/backend-snapshot.ts index 069c80a..508222e 100644 --- a/src/lib/student-space/backend-snapshot.ts +++ b/src/lib/student-space/backend-snapshot.ts @@ -40,6 +40,10 @@ export interface StudentSpaceReflectionCaptureSnapshot { entryDate: string kind: 'ask' text: string + /** Mirror's empathic acknowledgement of the moment. Optional because + * incremental `captures.patch` writes (AskSheet, retry sync) don't carry + * this field; the detail view reads it via optional chaining. */ + validation?: string reframe: { headline: string highlightPhrase: string @@ -278,6 +282,7 @@ export function mapMirrorEntryToReflectionCapture( entryDate: toEntryDate(entry.created_at), kind: 'ask', text: entry.transcript, + validation: entry.validation, reframe: { headline: entry.story_reframe, highlightPhrase: entry.inferred_meaning, diff --git a/src/routes/_app.mirror.$id.tsx b/src/routes/_app.mirror.$id.tsx new file mode 100644 index 0000000..9d59fd0 --- /dev/null +++ b/src/routes/_app.mirror.$id.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from '@tanstack/react-router' +import { MirrorDetailSheet } from '~/components/student-space/sheets/MirrorDetailSheet' + +// `/mirror/$id` — full details page for a single mirror reflection, opened +// from the History day-detail card's "Show more" link. +// +// Lives at `/mirror/$id` (not `/history/mirror/$id`) because TanStack's +// matcher picks `/history/$tab` (with $tab='mirror') over the more specific +// `/history/mirror/$id` when both are siblings under `_app/history` — +// hoisting one level avoids the conflict. +export const Route = createFileRoute('/_app/mirror/$id')({ + component: MirrorDetailPage, +}) + +function MirrorDetailPage() { + return +} From 1edb42ccda5d01db89a6ec018d02235a0c525e7e Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Sun, 24 May 2026 20:05:52 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(history):=20rename=20day-card=20section?= =?UTF-8?q?=20"Mirrors"=20=E2=86=92=20"Reflections"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Mirror" is the agent name; "reflection" is the student-facing capture. The list heading is user-facing copy, so match the noun the user owns. --- src/components/student-space/sheets/DayDetailCard.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/student-space/sheets/DayDetailCard.tsx b/src/components/student-space/sheets/DayDetailCard.tsx index f39674b..922c3e4 100644 --- a/src/components/student-space/sheets/DayDetailCard.tsx +++ b/src/components/student-space/sheets/DayDetailCard.tsx @@ -294,7 +294,9 @@ export function DayDetailCard({ ) : null} {captures.length > 0 ? (
-

Mirrors

+

+ Reflections +

    {captures.map((cap) => { if (cap.kind !== 'ask') { From 6d127230ca5a5be5d8722186d5e5509cf093227b Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Sun, 24 May 2026 20:34:58 +0800 Subject: [PATCH 3/8] chore(demo): land demo reflections in the current week, hide redundant status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shift all seed timestamps forward 182 days so demo data lands in the 2026-04 to 2026-05 window (was 2025-07 to 2025-11). Demo-a's 8 reflections are then re-spread across May 18–30 so the calendar's default 'this week' view shows reflection markers on multiple days - Day detail card: drop the literal `status: confirmed` text — review state is already implicit (Confirm/Forget appear only when pending), and the detail page surfaces the status badge explicitly --- .../student-space/sheets/DayDetailCard.tsx | 1 - test/ablation/fixtures/seed-multistudent.json | 229 ++++++++++++------ 2 files changed, 152 insertions(+), 78 deletions(-) diff --git a/src/components/student-space/sheets/DayDetailCard.tsx b/src/components/student-space/sheets/DayDetailCard.tsx index 922c3e4..8e8ebf0 100644 --- a/src/components/student-space/sheets/DayDetailCard.tsx +++ b/src/components/student-space/sheets/DayDetailCard.tsx @@ -415,7 +415,6 @@ function CaptureActions({ const failed = capture.syncStatus === 'failed' return (
    - {capture.reviewStatus ?

    status: {capture.reviewStatus}

    : null} {syncLine(capture) ?

    {syncLine(capture)}

    : null} {capture.prompt ?

    prompt: {capture.prompt}

    : null} {canReview ? ( diff --git a/test/ablation/fixtures/seed-multistudent.json b/test/ablation/fixtures/seed-multistudent.json index c454d0b..8d232dc 100644 --- a/test/ablation/fixtures/seed-multistudent.json +++ b/test/ablation/fixtures/seed-multistudent.json @@ -7,9 +7,18 @@ "name_handle": "Mei (Sec 4, NA)", "year_level": "Sec 4", "school_type": "sec", - "values_dominance": ["values.contribution", "values.relationships"], - "riasec_tilt": ["interests.social", "interests.enterprising"], - "skills_evident": ["skills.interpersonal", "skills.communication"], + "values_dominance": [ + "values.contribution", + "values.relationships" + ], + "riasec_tilt": [ + "interests.social", + "interests.enterprising" + ], + "skills_evident": [ + "skills.interpersonal", + "skills.communication" + ], "notes_for_review": "The helper. Drawn to peer-support and group-facing CCA work; reads people quickly; gets a kick out of being the bridge between adults and friends. Lives in a HDB block in Pasir Ris; mum is a nurse at Changi General. The negative reflection (peer fallout) is deliberately ungeneralized — she's hurt, not righteous, and doesn't yet have language for the failure mode of over-helping." }, "coverage_matrix": "context_type coverage for demo-a: school (r1, r4, r7), peer (r2, r5), civic (r3), family (r6), hobby (r8). 5 of 5 enum values; ≥3 satisfied. Affect spread: positive (r1, r3, r6), ordinary (r4, r7, r8), negative (r2, r5).", @@ -21,7 +30,7 @@ "inferred_meaning": "Helping feels most alive for you when another person feels respected first, not fixed.", "story_reframe": "A classmate opened up because Mei treated him as a thinker, not a problem to solve. The moment points toward patient, relational mentoring work where dignity comes before instruction.", "review_status": "confirmed", - "created_at": "2025-10-14T08:30:00Z" + "created_at": "2026-05-18T08:30:00Z" }, { "context_type": "peer", @@ -30,7 +39,7 @@ "inferred_meaning": "You are learning the difference between being useful and taking ownership of a problem that belongs to someone else.", "story_reframe": "Mei's helper instinct ran ahead of consent. The conflict does not erase her care; it gives the care a sharper edge: ask first, then support.", "review_status": "confirmed", - "created_at": "2025-10-19T19:10:00Z" + "created_at": "2026-05-19T19:10:00Z" }, { "context_type": "civic", @@ -39,7 +48,7 @@ "inferred_meaning": "You may have a social-service tilt: being with someone, listening closely, and making ordinary conversation meaningful.", "story_reframe": "A visit to Uncle Tan felt less like duty and more like fit. Mei came back full because the work was direct, human, and built on presence rather than performance.", "review_status": "confirmed", - "created_at": "2025-10-26T15:00:00Z" + "created_at": "2026-05-21T15:00:00Z" }, { "context_type": "school", @@ -48,7 +57,7 @@ "inferred_meaning": "Teaching and explanation may be a learning style as much as a possible career direction.", "story_reframe": "Trigonometry became meaningful when Mei had someone to help. Content sticks for her when it is relational, translated, and useful to a real person.", "review_status": "confirmed", - "created_at": "2025-11-02T16:45:00Z" + "created_at": "2026-05-23T16:45:00Z" }, { "context_type": "peer", @@ -57,7 +66,7 @@ "inferred_meaning": "You care about relationships enough to tolerate awkward feedback instead of defending your old role.", "story_reframe": "Mei started converting a painful conflict into a boundary skill. Asking first feels slower, but it may be how her care becomes trustworthy.", "review_status": "confirmed", - "created_at": "2025-11-04T20:20:00Z" + "created_at": "2026-05-24T17:20:00Z" }, { "context_type": "family", @@ -66,7 +75,7 @@ "inferred_meaning": "You are drawn to forms of care that are quiet, sustained, and patient with resistance.", "story_reframe": "The kitchen conversation gave Mei a model of durable care: keep showing up without forcing the other person to receive help before they are ready.", "review_status": "confirmed", - "created_at": "2025-11-09T22:30:00Z" + "created_at": "2026-05-26T20:30:00Z" }, { "context_type": "school", @@ -75,7 +84,7 @@ "inferred_meaning": "You may be willing to trade speed and prestige for a path that keeps you close to people-facing contribution.", "story_reframe": "A longer route did not scare Mei as much as a wrong-fit shortcut. The decision lens here is not prestige; it is whether the destination lets her do work that feels honest.", "review_status": "confirmed", - "created_at": "2025-11-16T09:00:00Z" + "created_at": "2026-05-28T09:00:00Z" }, { "context_type": "hobby", @@ -84,7 +93,7 @@ "inferred_meaning": "Everyday tech help is not the point by itself; the point is that you naturally turn small practical fixes into trust and conversation.", "story_reframe": "Mei noticed a pattern hiding in ordinary neighbourly help. What looked like a five-minute phone fix became evidence that human-facing support rarely feels like a chore to her.", "review_status": "confirmed", - "created_at": "2025-11-23T17:15:00Z" + "created_at": "2026-05-30T11:15:00Z" } ], "vips_pages": [ @@ -117,8 +126,10 @@ "verbatim_quote": "I don't think I should be the teacher but I do think I want to do this kind of thing more.", "reflection_index": 1, "strength": "high", - "parallax_tag": ["school"], - "committed_at": "2025-10-14T08:45:00Z" + "parallax_tag": [ + "school" + ], + "committed_at": "2026-05-18T08:45:00Z" }, { "key": "values-relationships-ask-first", @@ -127,8 +138,10 @@ "verbatim_quote": "She said she wasn't asking me to stop helping, she was asking me to ask first.", "reflection_index": 5, "strength": "high", - "parallax_tag": ["peer"], - "committed_at": "2025-11-04T20:35:00Z" + "parallax_tag": [ + "peer" + ], + "committed_at": "2026-05-24T17:35:00Z" }, { "key": "values-contribution-showing-up", @@ -137,8 +150,11 @@ "verbatim_quote": "you just keep showing up and one day they ask you for water and you know.", "reflection_index": 6, "strength": "medium", - "parallax_tag": ["family", "civic"], - "committed_at": "2025-11-09T22:45:00Z" + "parallax_tag": [ + "family", + "civic" + ], + "committed_at": "2026-05-26T20:45:00Z" }, { "key": "values-independence-fit", @@ -147,8 +163,10 @@ "verbatim_quote": "I don't think I want the shortcut if it lands me somewhere I don't fit.", "reflection_index": 7, "strength": "medium", - "parallax_tag": ["school"], - "committed_at": "2025-11-16T09:15:00Z" + "parallax_tag": [ + "school" + ], + "committed_at": "2026-05-28T09:15:00Z" }, { "key": "interests-social-lions", @@ -157,8 +175,10 @@ "verbatim_quote": "I came back feeling full, not tired. The other VIA sessions I came back tired. The visit one is different.", "reflection_index": 3, "strength": "high", - "parallax_tag": ["civic"], - "committed_at": "2025-10-26T15:15:00Z" + "parallax_tag": [ + "civic" + ], + "committed_at": "2026-05-21T15:15:00Z" }, { "key": "interests-social-neighbour", @@ -167,8 +187,10 @@ "verbatim_quote": "It's never felt like a chore. Maybe that's data.", "reflection_index": 8, "strength": "high", - "parallax_tag": ["hobby"], - "committed_at": "2025-11-23T17:30:00Z" + "parallax_tag": [ + "hobby" + ], + "committed_at": "2026-05-30T11:30:00Z" }, { "key": "interests-social-maths", @@ -177,8 +199,10 @@ "verbatim_quote": "the maths only sticks when there's a person on the other end of it.", "reflection_index": 4, "strength": "medium", - "parallax_tag": ["school"], - "committed_at": "2025-11-02T17:00:00Z" + "parallax_tag": [ + "school" + ], + "committed_at": "2026-05-23T17:00:00Z" }, { "key": "interests-enterprising-mediate", @@ -187,8 +211,10 @@ "verbatim_quote": "I jumped in to mediate between her and Shafiqah without anyone asking me.", "reflection_index": 2, "strength": "low", - "parallax_tag": ["peer"], - "committed_at": "2025-10-19T19:25:00Z" + "parallax_tag": [ + "peer" + ], + "committed_at": "2026-05-19T19:25:00Z" }, { "key": "personality-extraversion-energised", @@ -197,8 +223,10 @@ "verbatim_quote": "I was just talking to him. But I noticed I came back feeling full, not tired.", "reflection_index": 3, "strength": "medium", - "parallax_tag": ["civic"], - "committed_at": "2025-10-26T15:20:00Z" + "parallax_tag": [ + "civic" + ], + "committed_at": "2026-05-21T15:15:00Z" }, { "key": "personality-neuroticism-conflict", @@ -207,8 +235,10 @@ "verbatim_quote": "I went home and cried in the bus.", "reflection_index": 2, "strength": "medium", - "parallax_tag": ["peer"], - "committed_at": "2025-10-19T19:30:00Z" + "parallax_tag": [ + "peer" + ], + "committed_at": "2026-05-19T19:25:00Z" }, { "key": "personality-neuroticism-repair", @@ -217,8 +247,10 @@ "verbatim_quote": "I left feeling lighter than yesterday but not light.", "reflection_index": 5, "strength": "low", - "parallax_tag": ["peer"], - "committed_at": "2025-11-04T20:40:00Z" + "parallax_tag": [ + "peer" + ], + "committed_at": "2026-05-24T17:35:00Z" }, { "key": "skills-interpersonal-daryl", @@ -227,8 +259,10 @@ "verbatim_quote": "He talked for 20 minutes straight, sia. I didn't say much.", "reflection_index": 1, "strength": "high", - "parallax_tag": ["school"], - "committed_at": "2025-10-14T08:50:00Z" + "parallax_tag": [ + "school" + ], + "committed_at": "2026-05-18T08:45:00Z" }, { "key": "skills-communication-aisyah", @@ -237,8 +271,10 @@ "verbatim_quote": "Explaining it to her, I suddenly understood it better than when I was doing it myself.", "reflection_index": 4, "strength": "medium", - "parallax_tag": ["school"], - "committed_at": "2025-11-02T17:05:00Z" + "parallax_tag": [ + "school" + ], + "committed_at": "2026-05-23T17:00:00Z" }, { "key": "skills-interpersonal-mum", @@ -247,8 +283,10 @@ "verbatim_quote": "I made her teh-o and asked her to sit.", "reflection_index": 6, "strength": "medium", - "parallax_tag": ["family"], - "committed_at": "2025-11-09T22:50:00Z" + "parallax_tag": [ + "family" + ], + "committed_at": "2026-05-26T20:45:00Z" }, { "key": "skills-communication-neighbour", @@ -257,8 +295,10 @@ "verbatim_quote": "Took me 5 minutes. She gave me kuih lapis and we talked about her son in Australia for half an hour.", "reflection_index": 8, "strength": "medium", - "parallax_tag": ["hobby"], - "committed_at": "2025-11-23T17:35:00Z" + "parallax_tag": [ + "hobby" + ], + "committed_at": "2026-05-30T11:30:00Z" } ], "trajectory": { @@ -288,7 +328,10 @@ "timeline_key": "personality-neuroticism-conflict" } ], - "ecg_region_tags": ["cluster.public-service", "cluster.healthcare"], + "ecg_region_tags": [ + "cluster.public-service", + "cluster.healthcare" + ], "risks_tradeoffs": "This route fits Mei's relational strengths, but it can also reward over-responsibility. She would need adult supervision, clear role boundaries, and practice noticing when empathy turns into taking over.", "exploration_prompt": "Try one structured helping role with supervision, such as peer support training, a counselling-related VIA placement, or a shadowing conversation with a school counsellor or social worker. Track whether she feels grounded after the interaction, not only useful during it." }, @@ -311,7 +354,9 @@ "timeline_key": "values-relationships-ask-first" } ], - "ecg_region_tags": ["cluster.education"], + "ecg_region_tags": [ + "cluster.education" + ], "risks_tradeoffs": "Education routes may give Mei the person-on-the-other-end motivation she needs, but classroom systems can be slow, assessment-heavy, and emotionally tiring. The fit depends on whether she enjoys sustained instruction, not just rescue moments.", "exploration_prompt": "Run a small recurring tutoring experiment for four weeks. After each session, note what energised her, what drained her, and whether asking before helping changed the quality of the support." }, @@ -339,7 +384,10 @@ "timeline_key": "skills-communication-neighbour" } ], - "ecg_region_tags": ["cluster.public-service", "cluster.business-finance"], + "ecg_region_tags": [ + "cluster.public-service", + "cluster.business-finance" + ], "risks_tradeoffs": "Operations and outreach roles could use Mei's bridge-building without putting every emotional outcome on her shoulders. The risk is that admin-heavy work may feel too far from direct human contact unless the mission is visible.", "exploration_prompt": "Interview someone who coordinates volunteers, community programmes, or social-service operations. Ask what proportion of the work is direct people contact, coordination, paperwork, and crisis response." } @@ -358,9 +406,18 @@ "name_handle": "Ravi (JC1, RI)", "year_level": "JC1", "school_type": "JC", - "values_dominance": ["values.achievement", "values.learning"], - "riasec_tilt": ["interests.investigative", "interests.realistic"], - "skills_evident": ["skills.analytical", "skills.practical"], + "values_dominance": [ + "values.achievement", + "values.learning" + ], + "riasec_tilt": [ + "interests.investigative", + "interests.realistic" + ], + "skills_evident": [ + "skills.analytical", + "skills.practical" + ], "notes_for_review": "The seeker. Sharp, internally driven, takes the hard problem on purpose. PCME at H2. Olympiad-track, but slightly disillusioned with the prestige-as-end framing his school culture pushes. The negative reflection (failing an Astro olympiad round) is real grief, not narrative. The 'helping his sister' beat shows he's not purely solitary." }, "coverage_matrix": "context_type coverage for demo-b: school (r1, r3, r6), hobby (r2, r5, r8), family (r4), peer (r7). 4 of 5 enum values; ≥3 satisfied. Affect spread: positive (r1, r2, r4), ordinary (r3, r6, r8), negative (r5, r7).", @@ -368,42 +425,42 @@ { "context_type": "school", "transcript": "H2 Physics today — Mr Goh did the proof for SHM from first principles. The trig substitution at the end was the cleanest thing I've seen all term. Half the class was zoning out but I copied the whole derivation again at home just because I wanted to see if I could do it without the textbook. I could, after three tries. It was past midnight. I didn't notice it was midnight.", - "created_at": "2025-09-08T23:45:00Z" + "created_at": "2026-03-09T23:45:00Z" }, { "context_type": "hobby", "transcript": "Built the eq-platform mount over the weekend, finally. The polar alignment kept drifting because I had the latitude scale 1.3 degrees off — took me until 2am to spot it. Once I corrected it Jupiter sat dead centre for the whole hour I tracked. Took photos of the Galilean moons. Showed Pa the next morning; he asked if I'd been sleeping. I have been sleeping. Just not on the nights when the sky is clear.", - "created_at": "2025-09-21T02:30:00Z" + "created_at": "2026-03-22T02:30:00Z" }, { "context_type": "school", "transcript": "Econs essay back. 18 out of 25. Mr Iqbal said my reasoning was 'over-engineered for the marks available.' I get what he means but I also disagree — the simpler answer he wanted misses the actual mechanism. I'll write to the marks scheme next time. But I'm not going to pretend I think the marks scheme is the more honest answer.", - "created_at": "2025-09-29T15:00:00Z" + "created_at": "2026-03-30T15:00:00Z" }, { "context_type": "family", "transcript": "Helped Anjali with her Sec 2 Maths. She was stuck on simultaneous equations because they were using the substitution method only and she'd been taught elimination first. I drew both on the same page and she got it in 10 minutes. She said 'why don't teachers do that.' I said because they have 40 of you and one of me. But the honest answer is I think most teachers don't enjoy explaining things twice and I do.", - "created_at": "2025-10-05T20:00:00Z" + "created_at": "2026-04-05T20:00:00Z" }, { "context_type": "hobby", "transcript": "Astro olympiad selection round was today. I got the planetary motion question wrong because I read the problem wrong — confused aphelion with perihelion in the eccentricity formula. The maths was right but on the wrong input. Didn't make the team. I knew within 5 minutes of leaving the hall. I went home and didn't eat dinner. Mum knocked twice. The honest version is: I'm angry not because I lost but because the mistake was the kind of mistake I keep making, and I haven't found the discipline to stop making it.", - "created_at": "2025-10-12T22:15:00Z" + "created_at": "2026-04-12T22:15:00Z" }, { "context_type": "school", "transcript": "Maths class today was vectors. The teacher was solving by inspection — guessing the answer and then verifying. I do the same but I don't tell anyone because it feels like cheating. After class I asked Mr Goh whether that's actually how mathematicians work or whether we should learn the formal method first. He said both — the inspection is the hypothesis, the formal method is the proof. That landed. I've been treating the inspection as a shortcut. It's actually the hypothesis step.", - "created_at": "2025-10-19T16:30:00Z" + "created_at": "2026-04-19T16:30:00Z" }, { "context_type": "peer", "transcript": "Group study at Brandon's place. We were supposed to work through the chem mock. I solved questions 1 to 4 in 20 minutes and the others wanted me to explain how. I tried but I think I went too fast. Daniel said 'bro you're not actually explaining, you're showing off.' That stung because I wasn't trying to show off. I left at 9. The walk back I kept replaying it. Maybe the speed itself is the show-off, even when I don't mean it that way.", - "created_at": "2025-10-26T21:00:00Z" + "created_at": "2026-04-26T21:00:00Z" }, { "context_type": "hobby", "transcript": "Two-hour stretch on the orbital mechanics simulator I'm building in Python. The two-body case works. The three-body case is chaotic and my naive RK4 step is too coarse — it diverges within 10 orbits. I knew the textbook said this. I didn't know what 'chaotic' felt like in my own code until I watched the trajectory smear. Different from reading about it. Adaptive step-size is next.", - "created_at": "2025-11-02T18:00:00Z" + "created_at": "2026-05-03T18:00:00Z" } ] }, @@ -413,9 +470,18 @@ "name_handle": "Iman (Sec 3, IP)", "year_level": "Sec 3", "school_type": "IP", - "values_dominance": ["values.independence", "values.learning"], - "riasec_tilt": ["interests.artistic", "interests.investigative"], - "skills_evident": ["skills.creative", "skills.communication"], + "values_dominance": [ + "values.independence", + "values.learning" + ], + "riasec_tilt": [ + "interests.artistic", + "interests.investigative" + ], + "skills_evident": [ + "skills.creative", + "skills.communication" + ], "notes_for_review": "The maker. Visual-first, writes and draws compulsively, mistrusts pre-set paths. IP track so no O-levels to anchor against. Tension with family expectation (parents are both doctors) is real but not melodramatic — she still loves them, she just doesn't want what they want for her. The art-block reflection is the negative beat; the 'reading aloud in lit' is the positive." }, "coverage_matrix": "context_type coverage for demo-c: hobby (r1, r4, r6), school (r2, r5, r7), family (r3, r8), peer (r9). 4 of 5 enum values; ≥3 satisfied. Affect spread: positive (r2, r4, r7), ordinary (r1, r5, r9), negative (r3, r6, r8).", @@ -423,47 +489,47 @@ { "context_type": "hobby", "transcript": "Started a new sketchbook today. Drew the same window from my desk seven times — different times of day. The 6am one is the only one I like. The afternoon ones all feel forced because I was trying to make them good. The 6am one I drew before I was fully awake so I wasn't trying. I want to remember that — the trying is what kills it for me. But I can't just decide to not try.", - "created_at": "2025-08-15T07:00:00Z" + "created_at": "2026-02-13T07:00:00Z" }, { "context_type": "school", "transcript": "Mrs Devi asked me to read the Plath poem out loud in Lit today. 'Daddy.' I didn't want to but I did. Halfway through I forgot the rest of the class was there. When I stopped Mrs Devi said 'that's how it's supposed to feel' and didn't say anything else. The class was quiet. I felt exposed but also like I'd done something real. The two feelings sit together.", - "created_at": "2025-08-22T11:00:00Z" + "created_at": "2026-02-20T11:00:00Z" }, { "context_type": "family", "transcript": "Dinner argument with Ma again. She asked which CCA I want to commit to for portfolio purposes and I said Art Club. She said 'just Art Club?' as if there was a more serious option I was missing. Pa stayed quiet which is somehow worse than if he'd agreed with her. I went to my room and didn't draw, I just sat. The part that's hard is I don't think she's wrong to worry — the math of it favours her. The part that's hard is I'm still going to do what I'm going to do.", - "created_at": "2025-08-29T21:30:00Z" + "created_at": "2026-02-27T21:30:00Z" }, { "context_type": "hobby", "transcript": "Spent three hours in Bras Basah today. Bought a different kind of ink — sumi, the bottled one from the Kinokuniya stationery floor. Tried it with a stick instead of a brush. The lines were ugly and I loved them. Came back and wrote a paragraph about how the wrong-tool-on-purpose feels like the move I keep making. I don't know if that paragraph is for anyone or just for me. Probably just for me.", - "created_at": "2025-09-05T19:00:00Z" + "created_at": "2026-03-06T19:00:00Z" }, { "context_type": "school", "transcript": "Chemistry today. Titration. I followed every step and got the right concentration to two decimal places. Mr Pereira said well done. I wasn't happy. There was nothing in it that was mine. I knew the answer before I started because we'd been told what the answer should be. I don't want to do this for a living. I'm sure that's not new information to me but today it landed differently.", - "created_at": "2025-09-13T15:30:00Z" + "created_at": "2026-03-14T15:30:00Z" }, { "context_type": "hobby", "transcript": "Tried to start the next piece for the school art show. Sat at my desk for 90 minutes. Could not start. Every line felt like a commitment I wasn't ready to make. I ended up doodling the same vine pattern in the corner over and over. Eventually I put the paper away. I'm scared the show is going to be the first time I make work that I have to make rather than work that I want to make. Maybe that's just being a grown-up artist. Maybe it's a warning.", - "created_at": "2025-09-20T22:00:00Z" + "created_at": "2026-03-21T22:00:00Z" }, { "context_type": "school", "transcript": "Lit lesson on close-reading today. Mrs Devi gave us four lines from a Mary Oliver and asked us to write 300 words. I wrote 800. I couldn't stop. The way the line break sits between 'wild' and 'precious' — there's a whole essay in just that space. I handed in 300 and saved the rest. The 500 I cut were the ones I cared about most. That's the second time this week I've noticed: the work I do for myself is bigger than the work I hand in.", - "created_at": "2025-09-27T16:00:00Z" + "created_at": "2026-03-28T16:00:00Z" }, { "context_type": "family", "transcript": "Pa took me to his clinic on Saturday morning. He said he wasn't trying to recruit me, he just wanted me to see what a day looks like. I watched him with a young couple whose baby had a rash. He was good with them. He was patient in a way he sometimes isn't with me. I came home and didn't know what to do with that. He was right that it changed how I see him. He was also right that it didn't change what I want.", - "created_at": "2025-10-04T13:30:00Z" + "created_at": "2026-04-04T13:30:00Z" }, { "context_type": "peer", "transcript": "Showed Hannah the sketchbook today. She flipped through it for ten minutes without saying anything. I almost said 'never mind' three times. At the end she said 'the 6am window one is the best.' That's the same one I'd marked. We didn't talk much after that but I felt seen in a way I don't usually feel seen at school. I want to remember her name in this notebook so future-me knows who saw me first.", - "created_at": "2025-10-11T18:00:00Z" + "created_at": "2026-04-11T18:00:00Z" } ] }, @@ -473,9 +539,18 @@ "name_handle": "Aaron (Sec 4, IP transferring to poly route)", "year_level": "Sec 4", "school_type": "IP", - "values_dominance": ["values.relationships", "values.wellbeing"], - "riasec_tilt": ["interests.realistic", "interests.social"], - "skills_evident": ["skills.practical", "skills.leadership"], + "values_dominance": [ + "values.relationships", + "values.wellbeing" + ], + "riasec_tilt": [ + "interests.realistic", + "interests.social" + ], + "skills_evident": [ + "skills.practical", + "skills.leadership" + ], "notes_for_review": "The steady. Hands-on, finishes things, is the one peers ask to make things work. Considering polytechnic (likely SP Mechanical Engineering) over JC despite the IP school's strong-default-to-JC culture. The reflections show the slow shape of him noticing his own pattern: he is happiest when something concrete needs doing for someone he likes. The negative beat is the relay race injury — physical setback that exposes the tightness of his self-image." }, "coverage_matrix": "context_type coverage for demo-d: school (r1, r5), hobby (r2, r4, r7), peer (r3), family (r6). 4 of 5 enum values; ≥3 satisfied. Affect spread: positive (r1, r3, r6), ordinary (r5, r7), negative (r2, r4).", @@ -483,37 +558,37 @@ { "context_type": "school", "transcript": "Robotics CCA today. The Sec 1s were jamming on the chassis assembly — they kept stripping the M3 screws because they were over-torquing with the wrong driver. I sat with Faiz for 20 minutes and just showed him the half-turn-and-stop trick my brother taught me. By the end he was teaching the other two. Mr Selvam said 'you're the only senior they actually listen to.' That hit me funny because I'm not the smartest one in the room. I just don't make them feel stupid.", - "created_at": "2025-07-12T17:30:00Z" + "created_at": "2026-01-10T17:30:00Z" }, { "context_type": "hobby", "transcript": "Pulled my hamstring at relay training today. The MO said two weeks off. I sat through the rest of the session watching from the bench and I hated it more than I expected. Not because of the race — the race I can win or lose, doesn't matter. It's because the team can't pass batons properly without the four of us actually running together. They were trying and they were failing and I couldn't help. I went home and iced it and pretended to be okay at dinner.", - "created_at": "2025-07-26T20:00:00Z" + "created_at": "2026-01-24T20:00:00Z" }, { "context_type": "peer", "transcript": "Helped Jia Hao move into his new place at Hougang on Sunday. His family sold the old flat. He didn't ask anyone — I just heard from his cousin and showed up at 8am with a borrowed trolley. We were done by 2pm. He didn't say much. On the MRT back he said 'eh thanks ah' and I said 'no need' and we left it. That was enough for both of us. I think that's what 'enough' looks like for me — small actions, no speech.", - "created_at": "2025-08-03T18:00:00Z" + "created_at": "2026-02-01T18:00:00Z" }, { "context_type": "hobby", "transcript": "Tried to fix Ma's broken kitchen tap myself before calling a plumber. Watched two YouTube videos. The cartridge was different from the videos. I broke the sink stopper trying to lever the old cartridge out. Plumber came and fixed it and charged 80. Ma laughed about it later but in the moment I felt small. I usually finish what I start. I don't usually fail in front of my own house.", - "created_at": "2025-08-10T15:00:00Z" + "created_at": "2026-02-08T15:00:00Z" }, { "context_type": "school", "transcript": "Subject combo briefing today. Mrs Lim laid out the JC routes and a 10-minute slide at the end on poly. The poly slide was thinner. I asked her after class about SP Mechanical Engineering specifically. She was honest — said it's a strong programme but I'd be walking away from the IP advantage. I asked what advantage exactly and she said 'the keep-options-open advantage.' I left thinking — I don't actually want all the options open. I want one that fits.", - "created_at": "2025-08-17T10:00:00Z" + "created_at": "2026-02-15T10:00:00Z" }, { "context_type": "family", "transcript": "Talked to Kor Kor about the poly decision. He did the JC route and he's now in NTU doing biz. He didn't try to talk me out of it — he just asked what my day-by-day would look like if I picked poly vs JC. I told him. He said 'the poly day sounds like you and the JC day sounds like me.' That's the first time anyone's said it that clearly. Ma still doesn't know I'm seriously thinking about poly. Telling her is the part I keep putting off.", - "created_at": "2025-08-24T22:00:00Z" + "created_at": "2026-02-22T22:00:00Z" }, { "context_type": "hobby", "transcript": "Back at training after the two weeks. Hamstring held. I ran the 200 split slower than my PB but the baton pass with Idris was clean — we'd been doing pickups together for the whole break, off the track, just walking through the motion. Coach Yusof said the pass looked like we'd been running it for a year. We have, in a way. I don't think the speed is what makes the relay work. I think it's the handover.", - "created_at": "2025-09-07T19:00:00Z" + "created_at": "2026-03-08T19:00:00Z" } ] } From c85581be6a33b1e3282778f4071655251e83aa9a Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Sun, 24 May 2026 20:38:02 +0800 Subject: [PATCH 4/8] feat(history): make day-card reflections clickable, drop per-card chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each reflection card is now itself a link to /mirror/$id (when backend-backed); no separate "Show more" button - Drop the per-card "Reflection" label — the section heading already says "Reflections", repeating it on every row is noise - Drop Confirm/Forget buttons + status text from the card; reviews live on the detail page where there's room for the full context - Generic non-ask captures keep their text-only render with the kind label removed for the same reason --- .../student-space/sheets/DayDetailCard.tsx | 65 +++++---- test/ablation/fixtures/seed-multistudent.json | 135 ++++-------------- 2 files changed, 62 insertions(+), 138 deletions(-) diff --git a/src/components/student-space/sheets/DayDetailCard.tsx b/src/components/student-space/sheets/DayDetailCard.tsx index 8e8ebf0..29dbff9 100644 --- a/src/components/student-space/sheets/DayDetailCard.tsx +++ b/src/components/student-space/sheets/DayDetailCard.tsx @@ -1,6 +1,7 @@ import { Link, useNavigate } from '@tanstack/react-router' -import { ArrowRight, Sparkles } from 'lucide-react' +import { Sparkles } from 'lucide-react' import { useState } from 'react' +import { cn } from '~/lib/utils' /** * DayDetailCard — inline content panel rendered alongside the Calendar grid @@ -305,11 +306,10 @@ export function DayDetailCard({ key={cap.id} className="rounded-lg bg-white/40 px-3 py-2 text-sm text-(--color-sheet-ink)" > -

    {cap.kind}

    {cap.text ? ( -

    {cap.text.slice(0, 180)}

    +

    {cap.text.slice(0, 180)}

    ) : cap.caption ? ( -

    {cap.caption}

    +

    {cap.caption}

    ) : null} ) @@ -319,18 +319,19 @@ export function DayDetailCard({ const time = formatTime(cap.createdAt) const contextLabel = cap.contextType ? (CONTEXT_LABEL[cap.contextType] ?? cap.contextType) - : 'Mirror' + : null const entryId = Number(cap.backendMirrorEntryId) const hasBackendId = Number.isInteger(entryId) && entryId > 0 - return ( -
  • + const cardClasses = + 'block rounded-lg bg-white/40 px-3 py-2 text-sm text-(--color-sheet-ink) transition-colors' + const body = ( + <>
    - - {contextLabel} - + {contextLabel ? ( + + {contextLabel} + + ) : null} {time ? {time} : null}
    {headline ? ( @@ -348,27 +349,25 @@ export function DayDetailCard({ {cap.text.slice(0, 180)}

    ) : null} - void reviewCapture(cap, status)} - onRetry={() => void retryCaptureSync(cap)} - /> + + ) + return ( +
  • {hasBackendId ? ( -
    - - Show more - - -
    - ) : null} + + {body} + + ) : ( +
    {body}
    + )}
  • ) })} diff --git a/test/ablation/fixtures/seed-multistudent.json b/test/ablation/fixtures/seed-multistudent.json index 8d232dc..36e4137 100644 --- a/test/ablation/fixtures/seed-multistudent.json +++ b/test/ablation/fixtures/seed-multistudent.json @@ -7,18 +7,9 @@ "name_handle": "Mei (Sec 4, NA)", "year_level": "Sec 4", "school_type": "sec", - "values_dominance": [ - "values.contribution", - "values.relationships" - ], - "riasec_tilt": [ - "interests.social", - "interests.enterprising" - ], - "skills_evident": [ - "skills.interpersonal", - "skills.communication" - ], + "values_dominance": ["values.contribution", "values.relationships"], + "riasec_tilt": ["interests.social", "interests.enterprising"], + "skills_evident": ["skills.interpersonal", "skills.communication"], "notes_for_review": "The helper. Drawn to peer-support and group-facing CCA work; reads people quickly; gets a kick out of being the bridge between adults and friends. Lives in a HDB block in Pasir Ris; mum is a nurse at Changi General. The negative reflection (peer fallout) is deliberately ungeneralized — she's hurt, not righteous, and doesn't yet have language for the failure mode of over-helping." }, "coverage_matrix": "context_type coverage for demo-a: school (r1, r4, r7), peer (r2, r5), civic (r3), family (r6), hobby (r8). 5 of 5 enum values; ≥3 satisfied. Affect spread: positive (r1, r3, r6), ordinary (r4, r7, r8), negative (r2, r5).", @@ -126,9 +117,7 @@ "verbatim_quote": "I don't think I should be the teacher but I do think I want to do this kind of thing more.", "reflection_index": 1, "strength": "high", - "parallax_tag": [ - "school" - ], + "parallax_tag": ["school"], "committed_at": "2026-05-18T08:45:00Z" }, { @@ -138,9 +127,7 @@ "verbatim_quote": "She said she wasn't asking me to stop helping, she was asking me to ask first.", "reflection_index": 5, "strength": "high", - "parallax_tag": [ - "peer" - ], + "parallax_tag": ["peer"], "committed_at": "2026-05-24T17:35:00Z" }, { @@ -150,10 +137,7 @@ "verbatim_quote": "you just keep showing up and one day they ask you for water and you know.", "reflection_index": 6, "strength": "medium", - "parallax_tag": [ - "family", - "civic" - ], + "parallax_tag": ["family", "civic"], "committed_at": "2026-05-26T20:45:00Z" }, { @@ -163,9 +147,7 @@ "verbatim_quote": "I don't think I want the shortcut if it lands me somewhere I don't fit.", "reflection_index": 7, "strength": "medium", - "parallax_tag": [ - "school" - ], + "parallax_tag": ["school"], "committed_at": "2026-05-28T09:15:00Z" }, { @@ -175,9 +157,7 @@ "verbatim_quote": "I came back feeling full, not tired. The other VIA sessions I came back tired. The visit one is different.", "reflection_index": 3, "strength": "high", - "parallax_tag": [ - "civic" - ], + "parallax_tag": ["civic"], "committed_at": "2026-05-21T15:15:00Z" }, { @@ -187,9 +167,7 @@ "verbatim_quote": "It's never felt like a chore. Maybe that's data.", "reflection_index": 8, "strength": "high", - "parallax_tag": [ - "hobby" - ], + "parallax_tag": ["hobby"], "committed_at": "2026-05-30T11:30:00Z" }, { @@ -199,9 +177,7 @@ "verbatim_quote": "the maths only sticks when there's a person on the other end of it.", "reflection_index": 4, "strength": "medium", - "parallax_tag": [ - "school" - ], + "parallax_tag": ["school"], "committed_at": "2026-05-23T17:00:00Z" }, { @@ -211,9 +187,7 @@ "verbatim_quote": "I jumped in to mediate between her and Shafiqah without anyone asking me.", "reflection_index": 2, "strength": "low", - "parallax_tag": [ - "peer" - ], + "parallax_tag": ["peer"], "committed_at": "2026-05-19T19:25:00Z" }, { @@ -223,9 +197,7 @@ "verbatim_quote": "I was just talking to him. But I noticed I came back feeling full, not tired.", "reflection_index": 3, "strength": "medium", - "parallax_tag": [ - "civic" - ], + "parallax_tag": ["civic"], "committed_at": "2026-05-21T15:15:00Z" }, { @@ -235,9 +207,7 @@ "verbatim_quote": "I went home and cried in the bus.", "reflection_index": 2, "strength": "medium", - "parallax_tag": [ - "peer" - ], + "parallax_tag": ["peer"], "committed_at": "2026-05-19T19:25:00Z" }, { @@ -247,9 +217,7 @@ "verbatim_quote": "I left feeling lighter than yesterday but not light.", "reflection_index": 5, "strength": "low", - "parallax_tag": [ - "peer" - ], + "parallax_tag": ["peer"], "committed_at": "2026-05-24T17:35:00Z" }, { @@ -259,9 +227,7 @@ "verbatim_quote": "He talked for 20 minutes straight, sia. I didn't say much.", "reflection_index": 1, "strength": "high", - "parallax_tag": [ - "school" - ], + "parallax_tag": ["school"], "committed_at": "2026-05-18T08:45:00Z" }, { @@ -271,9 +237,7 @@ "verbatim_quote": "Explaining it to her, I suddenly understood it better than when I was doing it myself.", "reflection_index": 4, "strength": "medium", - "parallax_tag": [ - "school" - ], + "parallax_tag": ["school"], "committed_at": "2026-05-23T17:00:00Z" }, { @@ -283,9 +247,7 @@ "verbatim_quote": "I made her teh-o and asked her to sit.", "reflection_index": 6, "strength": "medium", - "parallax_tag": [ - "family" - ], + "parallax_tag": ["family"], "committed_at": "2026-05-26T20:45:00Z" }, { @@ -295,9 +257,7 @@ "verbatim_quote": "Took me 5 minutes. She gave me kuih lapis and we talked about her son in Australia for half an hour.", "reflection_index": 8, "strength": "medium", - "parallax_tag": [ - "hobby" - ], + "parallax_tag": ["hobby"], "committed_at": "2026-05-30T11:30:00Z" } ], @@ -328,10 +288,7 @@ "timeline_key": "personality-neuroticism-conflict" } ], - "ecg_region_tags": [ - "cluster.public-service", - "cluster.healthcare" - ], + "ecg_region_tags": ["cluster.public-service", "cluster.healthcare"], "risks_tradeoffs": "This route fits Mei's relational strengths, but it can also reward over-responsibility. She would need adult supervision, clear role boundaries, and practice noticing when empathy turns into taking over.", "exploration_prompt": "Try one structured helping role with supervision, such as peer support training, a counselling-related VIA placement, or a shadowing conversation with a school counsellor or social worker. Track whether she feels grounded after the interaction, not only useful during it." }, @@ -354,9 +311,7 @@ "timeline_key": "values-relationships-ask-first" } ], - "ecg_region_tags": [ - "cluster.education" - ], + "ecg_region_tags": ["cluster.education"], "risks_tradeoffs": "Education routes may give Mei the person-on-the-other-end motivation she needs, but classroom systems can be slow, assessment-heavy, and emotionally tiring. The fit depends on whether she enjoys sustained instruction, not just rescue moments.", "exploration_prompt": "Run a small recurring tutoring experiment for four weeks. After each session, note what energised her, what drained her, and whether asking before helping changed the quality of the support." }, @@ -384,10 +339,7 @@ "timeline_key": "skills-communication-neighbour" } ], - "ecg_region_tags": [ - "cluster.public-service", - "cluster.business-finance" - ], + "ecg_region_tags": ["cluster.public-service", "cluster.business-finance"], "risks_tradeoffs": "Operations and outreach roles could use Mei's bridge-building without putting every emotional outcome on her shoulders. The risk is that admin-heavy work may feel too far from direct human contact unless the mission is visible.", "exploration_prompt": "Interview someone who coordinates volunteers, community programmes, or social-service operations. Ask what proportion of the work is direct people contact, coordination, paperwork, and crisis response." } @@ -406,18 +358,9 @@ "name_handle": "Ravi (JC1, RI)", "year_level": "JC1", "school_type": "JC", - "values_dominance": [ - "values.achievement", - "values.learning" - ], - "riasec_tilt": [ - "interests.investigative", - "interests.realistic" - ], - "skills_evident": [ - "skills.analytical", - "skills.practical" - ], + "values_dominance": ["values.achievement", "values.learning"], + "riasec_tilt": ["interests.investigative", "interests.realistic"], + "skills_evident": ["skills.analytical", "skills.practical"], "notes_for_review": "The seeker. Sharp, internally driven, takes the hard problem on purpose. PCME at H2. Olympiad-track, but slightly disillusioned with the prestige-as-end framing his school culture pushes. The negative reflection (failing an Astro olympiad round) is real grief, not narrative. The 'helping his sister' beat shows he's not purely solitary." }, "coverage_matrix": "context_type coverage for demo-b: school (r1, r3, r6), hobby (r2, r5, r8), family (r4), peer (r7). 4 of 5 enum values; ≥3 satisfied. Affect spread: positive (r1, r2, r4), ordinary (r3, r6, r8), negative (r5, r7).", @@ -470,18 +413,9 @@ "name_handle": "Iman (Sec 3, IP)", "year_level": "Sec 3", "school_type": "IP", - "values_dominance": [ - "values.independence", - "values.learning" - ], - "riasec_tilt": [ - "interests.artistic", - "interests.investigative" - ], - "skills_evident": [ - "skills.creative", - "skills.communication" - ], + "values_dominance": ["values.independence", "values.learning"], + "riasec_tilt": ["interests.artistic", "interests.investigative"], + "skills_evident": ["skills.creative", "skills.communication"], "notes_for_review": "The maker. Visual-first, writes and draws compulsively, mistrusts pre-set paths. IP track so no O-levels to anchor against. Tension with family expectation (parents are both doctors) is real but not melodramatic — she still loves them, she just doesn't want what they want for her. The art-block reflection is the negative beat; the 'reading aloud in lit' is the positive." }, "coverage_matrix": "context_type coverage for demo-c: hobby (r1, r4, r6), school (r2, r5, r7), family (r3, r8), peer (r9). 4 of 5 enum values; ≥3 satisfied. Affect spread: positive (r2, r4, r7), ordinary (r1, r5, r9), negative (r3, r6, r8).", @@ -539,18 +473,9 @@ "name_handle": "Aaron (Sec 4, IP transferring to poly route)", "year_level": "Sec 4", "school_type": "IP", - "values_dominance": [ - "values.relationships", - "values.wellbeing" - ], - "riasec_tilt": [ - "interests.realistic", - "interests.social" - ], - "skills_evident": [ - "skills.practical", - "skills.leadership" - ], + "values_dominance": ["values.relationships", "values.wellbeing"], + "riasec_tilt": ["interests.realistic", "interests.social"], + "skills_evident": ["skills.practical", "skills.leadership"], "notes_for_review": "The steady. Hands-on, finishes things, is the one peers ask to make things work. Considering polytechnic (likely SP Mechanical Engineering) over JC despite the IP school's strong-default-to-JC culture. The reflections show the slow shape of him noticing his own pattern: he is happiest when something concrete needs doing for someone he likes. The negative beat is the relay race injury — physical setback that exposes the tightness of his self-image." }, "coverage_matrix": "context_type coverage for demo-d: school (r1, r5), hobby (r2, r4, r7), peer (r3), family (r6). 4 of 5 enum values; ≥3 satisfied. Affect spread: positive (r1, r3, r6), ordinary (r5, r7), negative (r2, r4).", From e777a56ab2686e05c8692fb65dab91ba15542642 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Sun, 24 May 2026 20:50:31 +0800 Subject: [PATCH 5/8] chore(history): drop dead review/sync code; calendar mood icon goes white when selected DayDetailCard: - Remove CaptureActions component, reviewCapture / retryCaptureSync handlers, patchReviewCapture, syncLine helper, and their state. All unused now that the day card is a passive list of clickable cards. CalendarPane: - Mood Smile icon flips to text-white when its day cell is selected, matching the reflection/event icons that already do. --- .../student-space/sheets/CalendarPane.tsx | 6 +- .../student-space/sheets/DayDetailCard.tsx | 172 ------------------ 2 files changed, 4 insertions(+), 174 deletions(-) diff --git a/src/components/student-space/sheets/CalendarPane.tsx b/src/components/student-space/sheets/CalendarPane.tsx index c51605d..99021be 100644 --- a/src/components/student-space/sheets/CalendarPane.tsx +++ b/src/components/student-space/sheets/CalendarPane.tsx @@ -276,8 +276,10 @@ export function CalendarPane({ // biome-ignore lint/suspicious/noArrayIndexKey: mood badges are positional within a day key={i} aria-hidden - className="size-3" - style={{ color: MOOD_HEX[mood.emotion ?? ''] ?? '#bbb' }} + className={cn('size-3', isSelected && 'text-white')} + style={ + isSelected ? undefined : { color: MOOD_HEX[mood.emotion ?? ''] ?? '#bbb' } + } /> ))} {cellCaps.length > 0 diff --git a/src/components/student-space/sheets/DayDetailCard.tsx b/src/components/student-space/sheets/DayDetailCard.tsx index 29dbff9..0e7f225 100644 --- a/src/components/student-space/sheets/DayDetailCard.tsx +++ b/src/components/student-space/sheets/DayDetailCard.tsx @@ -1,6 +1,5 @@ import { Link, useNavigate } from '@tanstack/react-router' import { Sparkles } from 'lucide-react' -import { useState } from 'react' import { cn } from '~/lib/utils' /** @@ -124,13 +123,6 @@ export function DayDetailCard({ date: string | null engineState: DayDetailEngineState | undefined }) { - const [reviewInFlight, setReviewInFlight] = useState<{ - entryId: number - status: 'confirmed' | 'forgotten' - } | null>(null) - const [reviewError, setReviewError] = useState<{ entryId: number; message: string } | null>(null) - const [retryInFlightId, setRetryInFlightId] = useState(null) - const moods = date ? (engineState?.moodPins?.pins ?? []).filter((p) => p.entryDate === date) : [] const captures = date ? (engineState?.captures?.entries ?? []).filter((c) => c.entryDate === date) @@ -139,101 +131,6 @@ export function DayDetailCard({ ? (engineState?.calendar?.events ?? []).filter((e) => eventDate(e) === date) : [] - async function reviewCapture(capture: DayDetailCapture, status: 'confirmed' | 'forgotten') { - const entryId = Number(capture.backendMirrorEntryId) - if (!Number.isInteger(entryId) || !engineState?.backend?.updateReflectionReview) return - setReviewInFlight({ entryId, status }) - setReviewError(null) - try { - const updated = await engineState.backend.updateReflectionReview({ entryId, status }) - patchReviewCapture(capture, entryId, status, updated) - try { - const snapshot = await engineState.backend.refreshSnapshot?.() - if (snapshot) engineState.applyBackendSnapshot?.(snapshot) - } catch (refreshErr) { - console.warn('[DayDetailCard] reflection review snapshot refresh failed', refreshErr) - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.warn('[DayDetailCard] reflection review failed', err) - setReviewError({ entryId, message: `Review update failed: ${message}` }) - } finally { - setReviewInFlight(null) - } - } - - async function retryCaptureSync(capture: DayDetailCapture) { - if (!capture.id || capture.kind !== 'ask' || !engineState?.backend?.submitReflection) return - setRetryInFlightId(capture.id) - engineState.captures?.patch?.(capture.id, { syncStatus: 'syncing', syncError: '' }) - try { - const result = await engineState.backend.submitReflection({ - localCaptureId: capture.id, - transcript: capture.text || '', - contextType: capture.contextType || 'school', - }) - const mirror = result?.mirrorEntry - if (mirror) { - engineState.captures?.patch?.(capture.id, { - backendMirrorEntryId: mirror.id, - text: mirror.transcript || capture.text || '', - reviewStatus: mirror.reviewStatus || 'pending', - syncStatus: 'synced', - syncError: '', - contextType: mirror.contextType || 'school', - reframe: { - headline: mirror.storyReframe || '', - highlightPhrase: mirror.inferredMeaning || '', - themes: mirror.contextType ? [mirror.contextType] : [], - needs: [], - moods: [], - }, - }) - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.warn('[DayDetailCard] reflection sync retry failed', err) - engineState.captures?.patch?.(capture.id, { syncStatus: 'failed', syncError: message }) - } finally { - setRetryInFlightId(null) - } - } - - function patchReviewCapture( - capture: DayDetailCapture, - entryId: number, - status: 'confirmed' | 'forgotten', - updated: - | { - reviewStatus?: string - transcript?: string - contextType?: string - storyReframe?: string - inferredMeaning?: string - } - | undefined, - ) { - const patch = { - reviewStatus: updated?.reviewStatus || status, - ...(updated?.transcript ? { text: updated.transcript } : {}), - ...(updated?.contextType ? { contextType: updated.contextType } : {}), - ...(updated - ? { - reframe: { - headline: updated.storyReframe || '', - highlightPhrase: updated.inferredMeaning || '', - themes: updated.contextType ? [updated.contextType] : [], - needs: [], - moods: [], - }, - } - : {}), - } - let patched = engineState?.captures?.patch?.(`mirror:${entryId}`, patch) - if (patched) return - if (capture.id) patched = engineState?.captures?.patch?.(capture.id, patch) - } - if (!date) { return (
    void - onRetry: () => void -}) { - const entryId = Number(capture.backendMirrorEntryId) - const reviewing = Number.isInteger(entryId) && reviewInFlight?.entryId === entryId - const canReview = Number.isInteger(entryId) && capture.reviewStatus === 'pending' - const failed = capture.syncStatus === 'failed' - return ( -
    - {syncLine(capture) ?

    {syncLine(capture)}

    : null} - {capture.prompt ?

    prompt: {capture.prompt}

    : null} - {canReview ? ( -
    - - -
    - ) : null} - {failed ? ( - - ) : null} - {reviewError?.entryId === entryId ? ( -

    - {reviewError.message} -

    - ) : null} -
    - ) -} - function EmptyDay({ date }: { date: string }) { const navigate = useNavigate() const today = ymd(new Date()) @@ -498,10 +333,3 @@ function eventDate(event: { entryDate?: string; date?: string }) { function eventLabel(event: { title?: string; label?: string; kind?: string }) { return event.title ?? event.label ?? event.kind ?? 'Event' } - -function syncLine(capture: DayDetailCapture) { - if (capture.syncStatus === 'failed') - return `sync failed${capture.syncError ? `: ${capture.syncError}` : ''}` - if (capture.syncStatus === 'syncing') return 'syncing...' - return '' -} From 07841c83df35a70976fbd676c23f6023077fd472 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Sun, 24 May 2026 20:59:46 +0800 Subject: [PATCH 6/8] fix(seed): scope per-student wipes by student_id explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resetSeedStudent ran unqualified DELETEs and relied on the RLS policy to scope them to the current student. The seed CLI typically runs as neondb_owner which has BYPASSRLS, so those DELETEs wiped every student's rows on each iteration — only the last student's seed survived. Same problem made the 'existing count' check see other students' rows and skip them on subsequent runs. Fix: filter every reset DELETE by studentId, and filter the existing count query the same way. Correct under both RLS-enforced and BYPASSRLS roles. --- src/db/seed.ts | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/db/seed.ts b/src/db/seed.ts index 9f63e18..b4cc9b1 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { sql } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import type { VipsClaimStrength, VipsContextType } from '~/agents/tools/schemas' import { VIPS_DIMENSIONS, type VipsDimension } from '~/data/vips-taxonomy' import { type TenantContext, withStudent } from './client' @@ -157,15 +157,19 @@ export async function seed(): Promise { if (selectedStudentIds && !selectedStudentIds.has(student.student_id)) continue const result = await withStudent(student.student_id, async (ctx) => { - // RLS scopes this to `student.student_id`; counting all rows is fine. + // Explicit student_id scoping: the seed runs as a DB role with + // BYPASSRLS (Neon's `neondb_owner`), so the RLS policy that would + // normally scope `from mirror_entries` to `app.student_id` is + // skipped. Filtering inline keeps the seed correct under both + // RLS-enforced and RLS-bypass roles. const existing = await ctx.db.execute<{ c: number }>( - sql`select count(*)::int as c from ${mirrorEntries}`, + sql`select count(*)::int as c from ${mirrorEntries} where ${mirrorEntries.studentId} = ${student.student_id}`, ) if ((existing.rows[0]?.c ?? 0) > 0) { if (!replaceExisting) { return { skipped: true, replaced: false, inserted: 0, timelineInserted: 0, trajectory: 0 } } - await resetSeedStudent(ctx.db) + await resetSeedStudent(ctx.db, student.student_id) } let count = 0 @@ -313,19 +317,24 @@ function isTruthy(value: string | undefined): boolean { type SeedTransaction = TenantContext['db'] -async function resetSeedStudent(db: SeedTransaction): Promise { - await db.delete(agentTraces) - await db.delete(cartographerOutputs) - await db.delete(pathfinderOutputs) - await db.delete(connectorOutputs) - await db.delete(vipsProposedDiffs) - await db.delete(vipsTimelineEntries) - await db.delete(vipsPages) - await db.delete(vipsForgetCount) - await db.delete(memorySnapshots) - await db.delete(studentMemoryFiles) - await db.delete(mirrorEntries) - await db.delete(tags) +async function resetSeedStudent(db: SeedTransaction, studentId: string): Promise { + // Each delete is explicitly scoped by student_id. The seed CLI typically + // runs as `neondb_owner` (BYPASSRLS), so relying on the RLS policy to + // scope these deletes would silently wipe every student's rows on each + // iteration; tenancy-bound DELETEs are correct under both RLS-enforced + // and RLS-bypass roles. + await db.delete(agentTraces).where(eq(agentTraces.studentId, studentId)) + await db.delete(cartographerOutputs).where(eq(cartographerOutputs.studentId, studentId)) + await db.delete(pathfinderOutputs).where(eq(pathfinderOutputs.studentId, studentId)) + await db.delete(connectorOutputs).where(eq(connectorOutputs.studentId, studentId)) + await db.delete(vipsProposedDiffs).where(eq(vipsProposedDiffs.studentId, studentId)) + await db.delete(vipsTimelineEntries).where(eq(vipsTimelineEntries.studentId, studentId)) + await db.delete(vipsPages).where(eq(vipsPages.studentId, studentId)) + await db.delete(vipsForgetCount).where(eq(vipsForgetCount.studentId, studentId)) + await db.delete(memorySnapshots).where(eq(memorySnapshots.studentId, studentId)) + await db.delete(studentMemoryFiles).where(eq(studentMemoryFiles.studentId, studentId)) + await db.delete(mirrorEntries).where(eq(mirrorEntries.studentId, studentId)) + await db.delete(tags).where(eq(tags.studentId, studentId)) } async function applyMirrorReviewStatus( From 966114cdbc0b5fb384feb965beeef5271e8f8cac Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Sun, 24 May 2026 21:16:41 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(history):=20darken=20disgust=20mood=20c?= =?UTF-8?q?olor=20#9CC36E=20=E2=86=92=20#5E9135=20for=20stronger=20contras?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/student-space/sheets/CalendarPane.tsx | 2 +- src/components/student-space/sheets/DayDetailCard.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/student-space/sheets/CalendarPane.tsx b/src/components/student-space/sheets/CalendarPane.tsx index 99021be..e1b6f15 100644 --- a/src/components/student-space/sheets/CalendarPane.tsx +++ b/src/components/student-space/sheets/CalendarPane.tsx @@ -20,7 +20,7 @@ const MOOD_HEX: Record = { sadness: '#7FB3D9', anger: '#E36A55', fear: '#B49AD6', - disgust: '#9CC36E', + disgust: '#5E9135', anxiety: '#F1A04E', envy: '#6FC2B3', embarrassment: '#F0A6B5', diff --git a/src/components/student-space/sheets/DayDetailCard.tsx b/src/components/student-space/sheets/DayDetailCard.tsx index 0e7f225..55b2a1e 100644 --- a/src/components/student-space/sheets/DayDetailCard.tsx +++ b/src/components/student-space/sheets/DayDetailCard.tsx @@ -13,7 +13,7 @@ const MOOD_HEX: Record = { sadness: '#7FB3D9', anger: '#E36A55', fear: '#B49AD6', - disgust: '#9CC36E', + disgust: '#5E9135', anxiety: '#F1A04E', envy: '#6FC2B3', embarrassment: '#F0A6B5', From 4939bfc4c5978e540222f18ee199992afb527aa1 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Sun, 24 May 2026 21:24:48 +0800 Subject: [PATCH 8/8] feat(history): use mood shape images instead of smile icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single lucide Smile icon (color-tinted per emotion) with the existing 3D-style shape art from `mood-shapes.ts`: - Calendar cell mood markers render the shape image (sphere / teardrop / octahedron / cube / torus / capsule / egg / halfcube / disk) - Day-detail card "Moods" list shows the shape next to the emotion name in place of the small colored dot Drop the local MOOD_HEX color maps in both files — the shape SVG owns its own palette via EMOTIONS, so duplicated hex constants were stale and prone to drift. --- .../student-space/sheets/CalendarPane.tsx | 39 +++++----- .../student-space/sheets/DayDetailCard.tsx | 71 ++++++++++--------- 2 files changed, 52 insertions(+), 58 deletions(-) diff --git a/src/components/student-space/sheets/CalendarPane.tsx b/src/components/student-space/sheets/CalendarPane.tsx index e1b6f15..2b10827 100644 --- a/src/components/student-space/sheets/CalendarPane.tsx +++ b/src/components/student-space/sheets/CalendarPane.tsx @@ -3,6 +3,7 @@ import { Toggle } from '@base-ui-components/react/toggle' import { ToggleGroup } from '@base-ui-components/react/toggle-group' import { CalendarDays, Camera, ChevronLeft, ChevronRight, NotebookPen, Smile } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' +import { EMOTION_BY_ID, shapeDataUri } from '~/lib/student-space/mood-shapes' import { cn } from '~/lib/utils' /** @@ -15,18 +16,6 @@ import { cn } from '~/lib/utils' * Tailwind variant only, not a full re-render of the calendar (PR #33 * invariant). */ -const MOOD_HEX: Record = { - joy: '#FFD66B', - sadness: '#7FB3D9', - anger: '#E36A55', - fear: '#B49AD6', - disgust: '#5E9135', - anxiety: '#F1A04E', - envy: '#6FC2B3', - embarrassment: '#F0A6B5', - ennui: '#A8A5BD', -} - const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] const MONTH_NAMES = [ 'January', @@ -271,17 +260,21 @@ export function CalendarPane({ > {cell.getDate()}
    - {cellMoods.slice(0, 3).map((mood, i) => ( - - ))} + {cellMoods.slice(0, 3).map((mood, i) => { + const emotion = EMOTION_BY_ID[mood.emotion ?? ''] + if (!emotion) return null + return ( + + ) + })} {cellCaps.length > 0 ? (() => { const hasPhoto = cellCaps.some((c) => c.kind === 'photo') diff --git a/src/components/student-space/sheets/DayDetailCard.tsx b/src/components/student-space/sheets/DayDetailCard.tsx index 55b2a1e..e5f48a2 100644 --- a/src/components/student-space/sheets/DayDetailCard.tsx +++ b/src/components/student-space/sheets/DayDetailCard.tsx @@ -1,5 +1,6 @@ import { Link, useNavigate } from '@tanstack/react-router' import { Sparkles } from 'lucide-react' +import { EMOTION_BY_ID, shapeDataUri } from '~/lib/student-space/mood-shapes' import { cn } from '~/lib/utils' /** @@ -8,18 +9,6 @@ import { cn } from '~/lib/utils' * events for the selected day. Renders an empty placeholder when no day is * selected. */ -const MOOD_HEX: Record = { - joy: '#FFD66B', - sadness: '#7FB3D9', - anger: '#E36A55', - fear: '#B49AD6', - disgust: '#5E9135', - anxiety: '#F1A04E', - envy: '#6FC2B3', - embarrassment: '#F0A6B5', - ennui: '#A8A5BD', -} - function formatLongDate(ymd: string | null): string { if (!ymd) return '' try { @@ -163,30 +152,42 @@ export function DayDetailCard({

    Moods

      - {moods.map((mood, i) => ( -
    • - - - {mood.emotion} - - {typeof mood.intensity === 'number' ? ( - - · intensity {mood.intensity} + {moods.map((mood, i) => { + const emotion = EMOTION_BY_ID[mood.emotion ?? ''] + return ( +
    • + {emotion ? ( + + ) : ( + + )} + + {mood.emotion} - ) : null} - {mood.note ? ( - — {mood.note} - ) : null} -
    • - ))} + {typeof mood.intensity === 'number' ? ( + + · intensity {mood.intensity} + + ) : null} + {mood.note ? ( + — {mood.note} + ) : null} + + ) + })}
    ) : null}