+ ))}
+
+ ) : 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
);
}