Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 10 additions & 5 deletions docs/product/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions e2e/authenticated.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
});

Expand Down
23 changes: 22 additions & 1 deletion e2e/daily.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');

Expand All @@ -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');
Expand Down
15 changes: 15 additions & 0 deletions playwright.local.config.ts
Original file line number Diff line number Diff line change
@@ -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'] } }],
});
7 changes: 6 additions & 1 deletion src/app/api/weekly-nudge/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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),
};
}

Expand Down
1 change: 1 addition & 0 deletions src/app/journal/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default async function JournalPage() {
})),
weeksRemaining,
initialQuestion: question,
nudgeRequest: { signals, excludeIds },
archiveSlot: <JournalArchive records={journalHistory} />,
}}
actions={{
Expand Down
161 changes: 141 additions & 20 deletions src/components/weekly-log/weekly-log-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
type WeekStartsOn,
} from '~/lib/weekly-log';
import {
detectThemes,
fallbackQuestion,
WEEKLY_QUESTIONS,
type NudgeSignals,
type WeeklyQuestion,
} from '~/lib/weekly-questions';
Expand Down Expand Up @@ -86,6 +86,14 @@ export function WeeklyLogSurface({
const [callingTitle, setCallingTitle] = useState<string | null>(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<Array<{ questionText: string; answer: string }>>([]);
const [askedFamilies, setAskedFamilies] = useState<string[]>([]);
const [askedIds, setAskedIds] = useState<string[]>([]);
const [themes, setThemes] = useState<string[]>([]);
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 ?? '');
Expand Down Expand Up @@ -132,34 +140,86 @@ export function WeeklyLogSurface({
};
}, [question, nudgeRequest, weekStartsOn, weekOf]);

const allExcludeIds = [...(data.nudgeRequest?.excludeIds ?? []), ...askedIds];

async function resolveNextQuestion(turn: number, signalPatch: Partial<NudgeSignals>) {
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 {
Expand Down Expand Up @@ -216,6 +276,9 @@ export function WeeklyLogSurface({
},
onSave: handleSave,
onCycle: cycleQuestion,
onNext: nextQuestion,
turns,
fetchingNext,
saving,
saved,
saveError,
Expand Down Expand Up @@ -302,16 +365,35 @@ 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;
hasEntry: boolean;
};

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 (
<section
aria-labelledby="weekly-entry-title"
Expand All @@ -323,35 +405,74 @@ function WriteCard({ card }: { card: WriteCardModel }) {
id="weekly-entry-title"
className="mt-1 font-serif text-3xl font-medium tracking-tight text-foreground"
>
{label}
{fetchingNext ? 'One more…' : label}
</h2>
</div>
<div className="px-5 py-6 sm:px-7 sm:py-8">
{turns.length ? (
<ol className="mb-5 space-y-4">
{turns.map((turn, index) => (
<li
key={`${turn.questionText}-${index}`}
className="border-l-2 border-[#c5abfa] pl-4"
>
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-[#625b50]">
{turn.questionText}
</p>
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
{turn.answer}
</p>
</li>
))}
</ol>
) : null}
<label htmlFor="weekly-entry" className="sr-only">
{label}
</label>
<textarea
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"
/>
<div className="mt-4 flex flex-wrap items-center gap-3">
{interview ? (
<button
type="button"
onClick={onNext}
disabled={!canAdvance}
className="inline-flex min-h-12 items-center gap-2 rounded-xl bg-[#176b4a] px-5 font-bold text-white disabled:opacity-45"
>
{fetchingNext ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ArrowRight className="size-4" />
)}
Next question
</button>
) : null}
<button
type="button"
onClick={onSave}
disabled={saving || !text.trim()}
className="inline-flex min-h-12 items-center gap-2 rounded-xl bg-[#176b4a] px-5 font-bold text-white disabled:opacity-45"
disabled={saving || (interview ? !canFinish : !text.trim())}
className={`inline-flex min-h-12 items-center gap-2 rounded-xl px-5 font-bold disabled:opacity-45 ${
interview ? 'border border-[#176b4a] text-[#176b4a]' : 'bg-[#176b4a] text-white'
}`}
>
{saving ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
{hasEntry ? 'Update this week' : 'Save this week'}
{hasEntry ? 'Update this week' : "I'm done — keep this week"}
</button>
<button
type="button"
onClick={onCycle}
className="inline-flex min-h-11 items-center gap-2 rounded-xl border border-[#cfc3b0] px-4 text-sm font-bold text-[#625b50]"
disabled={fetchingNext}
className="inline-flex min-h-11 items-center gap-2 rounded-xl border border-[#cfc3b0] px-4 text-sm font-bold text-[#625b50] disabled:opacity-45"
>
<RefreshCw className="size-3.5" /> A different question
</button>
Expand Down
Loading
Loading