diff --git a/.fleet/design-evidence/weekly-log/interview-1440.png b/.fleet/design-evidence/weekly-log/interview-1440.png new file mode 100644 index 00000000..158e2ba2 Binary files /dev/null and b/.fleet/design-evidence/weekly-log/interview-1440.png differ diff --git a/.fleet/design-evidence/weekly-log/interview-390.png b/.fleet/design-evidence/weekly-log/interview-390.png new file mode 100644 index 00000000..be4d0b16 Binary files /dev/null and b/.fleet/design-evidence/weekly-log/interview-390.png differ diff --git a/PRODUCT.md b/PRODUCT.md index cd76ed09..73a0323d 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -23,8 +23,9 @@ toward it — a family of focused personal products: - **Live** owns bucket lists, hobbies, commitments, timelines, side quests, discovery, and the optional small new thing for today. - **Weekly log** owns the private weekly record of what the user actually - lived — the journal's successor — and keeps earlier AM/PM entries readable - as archive. + lived — the journal's successor — as a short guided interview: an + opening question, then the next best question each turn until the user + says they're done. Earlier AM/PM entries remain readable as archive. - **Habits** owns simple, non-scoring practice check-ins. - **History** remains part of Live and helps the user understand the life accumulating behind their plans. diff --git a/docs/product/overview.md b/docs/product/overview.md index ab09220f..3d6d2e16 100644 --- a/docs/product/overview.md +++ b/docs/product/overview.md @@ -60,11 +60,16 @@ Emotional goal: users should feel seen, inspired, and gently nudged — not trac The 2026-08 split preserves existing data while giving each recurring job a clearer home: -- **Weekly log (private):** `/journal` hosts the weekly log — one honest - entry a week answering "what did you live this week?", keyed to user-local - weeks (Monday start by default, adjustable). Earlier AM/PM journal entries - remain readable as archive. Structurally private: no visibility field, - public API, or sharing. +- **Weekly log (private):** `/journal` hosts the weekly log — a short + guided interview per week. It opens with one question ("what did you + live this week?"), then asks the next most relevant question from a + ~30-question bank until the person says they're done; the answers + compose into the week's entry. Question selection uses classifier.dev + over categorical signals only — answer themes are detected on-device + and user prose never leaves the app. Entries are keyed to user-local + weeks (Monday start by default, adjustable). Earlier AM/PM journal + entries remain readable as archive. Structurally private: no + visibility field, public API, or sharing. - **Habits (private):** `/habits` owns simple check-ins and lightweight management without scoring. - **Live (private by default, selectively public):** hobbies, bucket lists, diff --git a/e2e/authenticated.spec.ts b/e2e/authenticated.spec.ts index f51af847..5e483991 100644 --- a/e2e/authenticated.spec.ts +++ b/e2e/authenticated.spec.ts @@ -102,7 +102,9 @@ test.describe('authenticated surfaces', () => { const journal = 'I noticed something new today.'; await authedPage.goto('/journal'); await authedPage.locator('#weekly-entry').fill(journal); - await authedPage.getByRole('button', { name: /Save this week|Update this week/ }).click(); + await authedPage + .getByRole('button', { name: /done — keep this week|Update this week/ }) + .click(); await authedPage.goto('/live-more'); await authedPage.getByLabel('What do you still want to live?').fill(chosenIdea); await expect(authedPage.getByRole('button', { name: 'Calling now' })).toBeVisible(); @@ -124,7 +126,7 @@ test.describe('authenticated surfaces', () => { await expect(authedPage.locator('#weekly-entry')).toBeVisible(); await expect(authedPage.getByText(/Week of /)).toBeVisible(); await expect( - authedPage.getByRole('button', { name: /Save this week|Update this week/ }) + authedPage.getByRole('button', { name: /done — keep this week|Update this week/ }) ).toBeVisible(); }); diff --git a/e2e/daily.spec.ts b/e2e/daily.spec.ts index af5e05d6..eeff2451 100644 --- a/e2e/daily.spec.ts +++ b/e2e/daily.spec.ts @@ -43,7 +43,7 @@ test.describe('Journal, Habits & manifesto', () => { await page.goto('/journal'); await page.locator('#weekly-entry').fill('I made room for a slower afternoon.'); - await page.getByRole('button', { name: /Save this week|Update this week/ }).click(); + await page.getByRole('button', { name: /done — keep this week|Update this week/ }).click(); await page.reload(); await expect(page.locator('#weekly-entry')).toHaveValue('I made room for a slower afternoon.'); @@ -54,6 +54,27 @@ test.describe('Journal, Habits & manifesto', () => { await expect(page.getByText(/\d+ of \d+ complete today/)).toHaveCount(0); }); + test('the weekly log asks follow-up questions until the week is done', async ({ page }) => { + await completeLocalOnboarding(page); + await page.goto('/journal'); + + await page.locator('#weekly-entry').fill('Dinner with my mom, then a long slow walk.'); + await page.getByRole('button', { name: 'Next question' }).click(); + + // The kept answer stays on the page while a fresh question takes over. + await expect(page.getByText('Dinner with my mom, then a long slow walk.')).toBeVisible(); + await expect(page.locator('#weekly-entry')).toHaveValue(''); + + await page.locator('#weekly-entry').fill('A quiet Sunday with coffee and a book.'); + await page.getByRole('button', { name: /done — keep this week/ }).click(); + await expect(page.getByText('Kept. This week is on record.')).toBeVisible(); + + await page.reload(); + const saved = await page.locator('#weekly-entry').inputValue(); + expect(saved).toContain('Dinner with my mom, then a long slow walk.'); + expect(saved).toContain('A quiet Sunday with coffee and a book.'); + }); + test('/live-more keeps and restores an exact dream', async ({ page }) => { await completeLocalOnboarding(page); await page.goto('/live-more'); diff --git a/playwright.local.config.ts b/playwright.local.config.ts new file mode 100644 index 00000000..4bc6dede --- /dev/null +++ b/playwright.local.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, devices } from '@playwright/test'; + +// Local verification config: points at an already-running dev server instead +// of spawning `pnpm dev:test-auth` on the hardcoded :3000 (squatted locally). +export default defineConfig({ + testDir: './e2e', + timeout: 60_000, + expect: { timeout: 10_000 }, + fullyParallel: true, + retries: 0, + workers: 4, + reporter: 'list', + use: { baseURL: 'http://localhost:3001', trace: 'on-first-retry', screenshot: 'only-on-failure' }, + projects: [{ name: 'desktop', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/src/app/api/weekly-nudge/route.ts b/src/app/api/weekly-nudge/route.ts index 143cab4a..c4804037 100644 --- a/src/app/api/weekly-nudge/route.ts +++ b/src/app/api/weekly-nudge/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'; import { resolveWeeklyNudge } from '~/lib/weekly-nudge'; import { weekStartFor, normalizeWeekStartsOn } from '~/lib/weekly-log'; import { dayKeyIn } from '~/lib/day'; -import type { NudgeSignals } from '~/lib/weekly-questions'; +import { isQuestionFamily, type NudgeSignals } from '~/lib/weekly-questions'; export const dynamic = 'force-dynamic'; @@ -19,12 +19,17 @@ function cleanSignals(value: unknown): NudgeSignals | null { : []; const count = (v: unknown, max: number) => typeof v === 'number' && Number.isInteger(v) && v >= 0 ? Math.min(v, max) : 0; + const families = (v: unknown, max: number) => + Array.isArray(v) ? v.filter((f): f is string => isQuestionFamily(f)).slice(0, max) : []; return { staleDreamCategories: categories, entriesLastMonth: count(raw.entriesLastMonth, 52), activeCommitments: count(raw.activeCommitments, 100), activeDreams: count(raw.activeDreams, 1000), isSuggestedDay: raw.isSuggestedDay === true, + detectedThemes: families(raw.detectedThemes, 10), + askedFamilies: families(raw.askedFamilies, 10), + turn: count(raw.turn, 20), }; } diff --git a/src/app/journal/page.tsx b/src/app/journal/page.tsx index faa9b14b..32ad3ba3 100644 --- a/src/app/journal/page.tsx +++ b/src/app/journal/page.tsx @@ -94,6 +94,7 @@ export default async function JournalPage() { })), weeksRemaining, initialQuestion: question, + nudgeRequest: { signals, excludeIds }, archiveSlot: , }} actions={{ diff --git a/src/components/weekly-log/weekly-log-surface.tsx b/src/components/weekly-log/weekly-log-surface.tsx index 2b90648f..cb54e1ae 100644 --- a/src/components/weekly-log/weekly-log-surface.tsx +++ b/src/components/weekly-log/weekly-log-surface.tsx @@ -12,8 +12,8 @@ import { type WeekStartsOn, } from '~/lib/weekly-log'; import { + detectThemes, fallbackQuestion, - WEEKLY_QUESTIONS, type NudgeSignals, type WeeklyQuestion, } from '~/lib/weekly-questions'; @@ -86,6 +86,14 @@ export function WeeklyLogSurface({ const [callingTitle, setCallingTitle] = useState(null); const [isPending, startTransition] = useTransition(); + // Interview turns: answers kept so far this week, plus the running + // classifier signals that pick the next question. + const [turns, setTurns] = useState>([]); + const [askedFamilies, setAskedFamilies] = useState([]); + const [askedIds, setAskedIds] = useState([]); + const [themes, setThemes] = useState([]); + const [fetchingNext, setFetchingNext] = useState(false); + const weekOf = useMemo(() => weekStartFor(data.today, weekStartsOn), [data.today, weekStartsOn]); const currentEntry = entries.find((entry) => entry.weekOf === weekOf) ?? null; const textareaValue = editedWeek === weekOf ? text : (currentEntry?.text ?? ''); @@ -132,34 +140,86 @@ export function WeeklyLogSurface({ }; }, [question, nudgeRequest, weekStartsOn, weekOf]); + const allExcludeIds = [...(data.nudgeRequest?.excludeIds ?? []), ...askedIds]; + + async function resolveNextQuestion(turn: number, signalPatch: Partial) { + setFetchingNext(true); + try { + const response = await fetch('/api/weekly-nudge', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + signals: { ...data.nudgeRequest?.signals, ...signalPatch, turn }, + weekStartsOn, + weekOf, + excludeIds: allExcludeIds, + }), + }); + if (response.ok) { + const body = (await response.json()) as { question?: WeeklyQuestion }; + if (body.question?.text) { + setQuestion(body.question); + servedRef.current?.(body.question.id); + return; + } + } + } catch { + // fall through to rotation + } finally { + setFetchingNext(false); + } + setQuestion(fallbackQuestion(`${weekOf}#${turn}`, allExcludeIds)); + } + function cycleQuestion() { - const exclude = [question?.id ?? '', ...(data.nudgeRequest?.excludeIds ?? [])]; - const pool = WEEKLY_QUESTIONS.filter((q) => !exclude.includes(q.id)); - const candidates = pool.length ? pool : WEEKLY_QUESTIONS.filter((q) => q.id !== question?.id); - setQuestion(candidates[0] ?? question); - if (candidates[0]) actions.onQuestionServed?.(candidates[0].id); + const skipped = question ? [question.family] : []; + const nextAsked = [...new Set([...askedFamilies, ...skipped])]; + setAskedFamilies(nextAsked); + if (question) setAskedIds((ids) => [...ids, question.id]); + void resolveNextQuestion(turns.length, { askedFamilies: nextAsked }); + } + + async function nextQuestion() { + const answer = textareaValue.trim(); + if (!answer || !question) return; + const newThemes = [...new Set([...themes, ...detectThemes(answer)])]; + const nextAsked = [...new Set([...askedFamilies, question.family])]; + setTurns((current) => [...current, { questionText: question.text, answer }]); + setThemes(newThemes); + setAskedFamilies(nextAsked); + setAskedIds((ids) => [...ids, question.id]); + setText(''); + await resolveNextQuestion(turns.length + 1, { + detectedThemes: newThemes, + askedFamilies: nextAsked, + }); } async function handleSave() { - const trimmed = textareaValue.trim(); - if (!trimmed) return; + const answers = [...turns.map((turn) => turn.answer), textareaValue.trim()].filter(Boolean); + const composed = answers.join('\n\n'); + if (!composed) return; + const promptText = turns[0]?.questionText ?? question?.text ?? null; setSaving(true); setSaved(false); setSaveError(null); try { - const ok = await actions.onSave(weekOf, trimmed, question?.text ?? null); + const ok = await actions.onSave(weekOf, composed, promptText); if (!ok) throw new Error('not saved'); setEntries((current) => { const next = { id: currentEntry?.id ?? `week-${weekOf}`, weekOf, - text: trimmed, - promptText: question?.text ?? null, + text: composed, + promptText, }; return current.some((entry) => entry.weekOf === weekOf) ? current.map((entry) => (entry.weekOf === weekOf ? next : entry)) : [next, ...current]; }); + setTurns([]); + setText(''); + setEditedWeek(null); setSaved(true); window.setTimeout(() => setSaved(false), 2500); } catch { @@ -216,6 +276,9 @@ export function WeeklyLogSurface({ }, onSave: handleSave, onCycle: cycleQuestion, + onNext: nextQuestion, + turns, + fetchingNext, saving, saved, saveError, @@ -302,6 +365,9 @@ type WriteCardModel = { onText: (value: string) => void; onSave: () => void; onCycle: () => void; + onNext: () => void; + turns: Array<{ questionText: string; answer: string }>; + fetchingNext: boolean; saving: boolean; saved: boolean; saveError: string | null; @@ -309,9 +375,25 @@ type WriteCardModel = { }; function WriteCard({ card }: { card: WriteCardModel }) { - const { weekOf, question, text, onText, onSave, onCycle, saving, saved, saveError, hasEntry } = - card; + const { + weekOf, + question, + text, + onText, + onSave, + onCycle, + onNext, + turns, + fetchingNext, + saving, + saved, + saveError, + hasEntry, + } = card; const label = question ? question.text : 'What did you live this week?'; + const interview = !hasEntry || turns.length > 0; + const canAdvance = !!text.trim() && !fetchingNext && !saving; + const canFinish = canAdvance || turns.length > 0; return (
- {label} + {fetchingNext ? 'One more…' : label}
+ {turns.length ? ( +
    + {turns.map((turn, index) => ( +
  1. +

    + {turn.questionText} +

    +

    + {turn.answer} +

    +
  2. + ))} +
+ ) : null} @@ -334,24 +433,46 @@ function WriteCard({ card }: { card: WriteCardModel }) { id="weekly-entry" value={text} onChange={(event) => onText(event.target.value)} - placeholder="One honest paragraph is enough. What did you actually do with your week?" - rows={7} + placeholder={ + turns.length + ? 'Answer this one too — or finish whenever the week feels written.' + : 'One honest paragraph is enough. What did you actually do with your week?' + } + rows={turns.length ? 4 : 7} className="w-full resize-y rounded-xl border border-[#cfc3b0] bg-[#fffdf8] px-4 py-3 text-base leading-relaxed outline-none focus:border-[#176b4a] focus:ring-2 focus:ring-[#176b4a]/20" />
+ {interview ? ( + + ) : null} diff --git a/src/lib/weekly-nudge.ts b/src/lib/weekly-nudge.ts index 23a1c6fe..03e81cd6 100644 --- a/src/lib/weekly-nudge.ts +++ b/src/lib/weekly-nudge.ts @@ -27,7 +27,15 @@ export async function resolveWeeklyNudge( weekOf: string, excludeIds: string[] = [] ): Promise { - if (isContextEmpty(signals)) return fallbackQuestion(weekOf, excludeIds); + // Each turn gets its own seed so consecutive questions in one session + // don't collapse onto the same deterministic pick. + const seed = `${weekOf}#${signals.turn ?? 0}`; + const fallback = () => fallbackQuestion(seed, excludeIds); + if (isContextEmpty(signals)) return fallback(); + + const asked = new Set(signals.askedFamilies ?? []); + const labels = QUESTION_FAMILIES.filter((family) => !asked.has(family)); + if (!labels.length) return fallback(); try { const response = await fetch(CLASSIFIER_ENDPOINT, { @@ -35,20 +43,20 @@ export async function resolveWeeklyNudge( headers: { 'content-type': 'application/json' }, body: JSON.stringify({ inputs: [buildNudgeContext(signals)], - labels: QUESTION_FAMILIES, + labels, }), signal: AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS), cache: 'no-store', }); - if (!response.ok) return fallbackQuestion(weekOf, excludeIds); + if (!response.ok) return fallback(); const body = (await response.json()) as { results?: ClassifierResult[] }; const label = body.results?.[0]?.label; if (typeof label !== 'string' || !isQuestionFamily(label)) { - return fallbackQuestion(weekOf, excludeIds); + return fallback(); } - return questionForFamily(label, weekOf, excludeIds); + return questionForFamily(label, seed, excludeIds); } catch { - return fallbackQuestion(weekOf, excludeIds); + return fallback(); } } diff --git a/src/lib/weekly-questions.test.ts b/src/lib/weekly-questions.test.ts index 845ea3b9..6a239a3e 100644 --- a/src/lib/weekly-questions.test.ts +++ b/src/lib/weekly-questions.test.ts @@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest'; import { buildNudgeContext, + detectThemes, fallbackQuestion, isContextEmpty, isQuestionFamily, QUESTION_FAMILIES, questionForFamily, + remainingFamilies, WEEKLY_QUESTIONS, type NudgeSignals, } from '~/lib/weekly-questions'; @@ -110,3 +112,63 @@ describe('isQuestionFamily', () => { expect(isQuestionFamily('')).toBe(false); }); }); + +describe('interview-turn signals', () => { + it('includes detected themes, asked families, and the turn in the context', () => { + const context = buildNudgeContext({ + ...signals, + detectedThemes: ['people', 'rest'], + askedFamilies: ['lived'], + turn: 2, + }); + expect(context).toContain('themes in earlier answers: people, rest'); + expect(context).toContain('families already asked: lived'); + expect(context).toContain('interview turn: 2'); + }); + + it('omits turn fields on the opener', () => { + const context = buildNudgeContext(signals); + expect(context).not.toContain('themes in earlier answers'); + expect(context).not.toContain('interview turn'); + }); + + it('detected themes keep a context non-empty', () => { + expect( + isContextEmpty({ + staleDreamCategories: [], + entriesLastMonth: 0, + activeCommitments: 0, + activeDreams: 0, + isSuggestedDay: false, + detectedThemes: ['people'], + }) + ).toBe(false); + }); +}); + +describe('detectThemes', () => { + it('detects people and rest themes without ever needing the prose', () => { + const themes = detectThemes('Had dinner with my mom, then a long slow walk and a movie.'); + expect(themes).toContain('people'); + expect(themes).toContain('rest'); + }); + + it('detects momentum and stuck signals', () => { + expect(detectThemes('Finally finished the prototype')).toContain('momentum'); + expect(detectThemes("I kept putting off the taxes, didn't get to it")).toContain('stuck'); + }); + + it('returns nothing for empty or themeless answers', () => { + expect(detectThemes('')).toEqual([]); + expect(detectThemes('zzzz')).toEqual([]); + }); +}); + +describe('remainingFamilies', () => { + it('excludes families already asked this session', () => { + const left = remainingFamilies(['lived', 'people']); + expect(left).not.toContain('lived'); + expect(left).not.toContain('people'); + expect(left.length).toBe(QUESTION_FAMILIES.length - 2); + }); +}); diff --git a/src/lib/weekly-questions.ts b/src/lib/weekly-questions.ts index 5e794eed..91758360 100644 --- a/src/lib/weekly-questions.ts +++ b/src/lib/weekly-questions.ts @@ -127,6 +127,12 @@ export type NudgeSignals = { activeDreams: number; /** Whether today is the suggested Sunday writing moment. */ isSuggestedDay: boolean; + /** Theme families detected locally from earlier answers this session. */ + detectedThemes?: string[]; + /** Question families already asked this session — excluded from the next pick. */ + askedFamilies?: string[]; + /** Which turn of the weekly interview this is (0 = opener). */ + turn?: number; }; /** @@ -141,16 +147,90 @@ export function buildNudgeContext(signals: NudgeSignals): string { `active dreams: ${signals.activeDreams}`, `suggested day: ${signals.isSuggestedDay ? 'yes' : 'no'}`, ]; + if (signals.detectedThemes?.length) { + parts.push(`themes in earlier answers: ${signals.detectedThemes.join(', ')}`); + } + if (signals.askedFamilies?.length) { + parts.push(`families already asked: ${signals.askedFamilies.join(', ')}`); + } + if (typeof signals.turn === 'number' && signals.turn > 0) { + parts.push(`interview turn: ${signals.turn}`); + } return parts.join('; '); } +// Theme detection runs locally — the answer itself never leaves the device. +// Only the matched family names become classifier signals. +const THEME_KEYWORDS: Array<{ family: QuestionFamily; pattern: RegExp }> = [ + { + family: 'people', + pattern: + /\b(friends?|wife|husband|partner|mom|mother|dad|father|family|kids?|son|daughter|colleague|coworker|team|dinner with|met up|called|visited|date|birthday|parents?)\b/i, + }, + { + family: 'rest', + pattern: + /\b(slept?|sleep|nap|rested?|relax\w*|walks?|read|reading|movie|film|show|watched|slow|quiet|coffee|recover\w*|weekend|garden|bath|massage)\b/i, + }, + { + family: 'stuck', + pattern: + /\b(didn.?t get|didn.?t|postponed|put off|procrastinat\w*|missed|skipped|couldn.?t|failed|behind|too busy|forgot)\b/i, + }, + { + family: 'new', + pattern: + /\b(first time|tried|trying|never before|signed up|lessons?|classes?|began|started learning|new (job|hobby|place|restaurant|trail))\b/i, + }, + { + family: 'want', + pattern: + /\b(dream|goal|bucket list|always wanted|someday|plan to|booked|reservation|applied)\b/i, + }, + { + family: 'honest', + pattern: + /\b(wasted|mindless|scroll\w*|stressed|anxious|overwhelm\w*|exhausted|regret|shouldn.?t have|hated|doom)\b/i, + }, + { + family: 'surprise', + pattern: + /\b(surpris\w*|unexpected|suddenly|randomly|turned out|out of nowhere|didn.?t expect|ran into)\b/i, + }, + { + family: 'momentum', + pattern: + /\b(finished|completed|started|launched|shipped|progress|finally|got done|submitted|workout|ran|practiced|built|wrote|cooked|painted|recorded)\b/i, + }, + { + family: 'next', + pattern: /\b(next week|plan\w*|tomorrow|upcoming|looking forward|will be)\b/i, + }, +]; + +/** + * Detect which question families an answer touched — locally, so the + * prose never leaves the device. 'lived' is the opener family and is + * never detected: it is the default, not a theme. + */ +export function detectThemes(text: string): QuestionFamily[] { + if (!text.trim()) return []; + return THEME_KEYWORDS.filter(({ pattern }) => pattern.test(text)).map(({ family }) => family); +} + +/** Families still available to ask — everything not already asked this session. */ +export function remainingFamilies(askedFamilies: string[] = []): QuestionFamily[] { + return QUESTION_FAMILIES.filter((family) => !askedFamilies.includes(family)); +} + /** True when the context carries no meaningful signal — go straight to fallback. */ export function isContextEmpty(signals: NudgeSignals): boolean { return ( signals.staleDreamCategories.length === 0 && signals.entriesLastMonth === 0 && signals.activeCommitments === 0 && - signals.activeDreams === 0 + signals.activeDreams === 0 && + (signals.detectedThemes?.length ?? 0) === 0 ); }