From 8fafc4f9523efed44402d24f2b62ee45d3f0c8a4 Mon Sep 17 00:00:00 2001 From: AlexisEvans Date: Wed, 16 Sep 2026 23:47:57 +1000 Subject: [PATCH 1/2] feat(review): lay out the monthly letter as theme, stats and suggestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monthly letter rendered as one block of prose even after the backend split it into parts, so the month had no shape at a glance. The card now renders the theme, the month's numbers, the body and one suggestion for next month as separate blocks. - Theme decides whether the structured blocks show at all: a weekly letter, a month too quiet to name a theme and a letter written before the split all leave it null and render body-only, which is the one rule that covers every case. tasksCompleted is counted live and always has a value, so it cannot carry that signal - The stats row renders whichever stats it was given rather than a fixed three: days active has no data source yet and focused minutes is deferred - The weekly review is untouched — the new blocks are gated to monthly - A quiet period now keeps its next-period suggestion instead of dropping it, so a low-activity month still closes on something to try Co-Authored-By: Claude Opus 5 (1M context) --- .../Modules/Reviews/Prompts/ReviewPrompts.cs | 2 +- .../review/components/letter-card-content.tsx | 28 ++++++++++ .../review/components/letter-next-step.tsx | 22 ++++++++ .../review/components/letter-stats.tsx | 53 +++++++++++++++++++ .../review/components/letter-theme.tsx | 20 +++++++ .../src/feature/review/models/review-dto.ts | 6 +++ .../src/i18n/locales/en/settings.json | 3 ++ .../src/i18n/locales/zh/settings.json | 3 ++ 8 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 blotztask-mobile/src/feature/review/components/letter-next-step.tsx create mode 100644 blotztask-mobile/src/feature/review/components/letter-stats.tsx create mode 100644 blotztask-mobile/src/feature/review/components/letter-theme.tsx diff --git a/blotztask-api/Modules/Reviews/Prompts/ReviewPrompts.cs b/blotztask-api/Modules/Reviews/Prompts/ReviewPrompts.cs index 91d367ae7..8e437955b 100644 --- a/blotztask-api/Modules/Reviews/Prompts/ReviewPrompts.cs +++ b/blotztask-api/Modules/Reviews/Prompts/ReviewPrompts.cs @@ -55,7 +55,7 @@ public static string GetReviewPrompt( First, decide which tasks carry real signal about how the user actually spent or intended their time. Disregard entries that are not genuine activity — placeholder, sample or example content, tests, or text that reads as random rather than a real task. Base everything below only on the tasks that remain. - If little or no genuine activity remains after that, do NOT invent themes, patterns, or meaning. Put two or three warm, honest sentences acknowledging it was a quiet {periodNoun} in "body", leave "theme" and "oneThingToTryNext" null, and stop there. + If little or no genuine activity remains after that, do NOT invent themes, patterns, or meaning. Put two or three warm, honest sentences acknowledging it was a quiet {periodNoun} in "body" and leave "theme" null. Still write "oneThingToTryNext": for a quiet {periodNoun} it is not advice drawn from the data but one small, kind invitation for next {periodNoun} — something easy to say yes to, like noting one moment a day. Then stop there. Otherwise, write ONE short {reviewKind} review letter in three parts: diff --git a/blotztask-mobile/src/feature/review/components/letter-card-content.tsx b/blotztask-mobile/src/feature/review/components/letter-card-content.tsx index c546c8e69..b1b845a68 100644 --- a/blotztask-mobile/src/feature/review/components/letter-card-content.tsx +++ b/blotztask-mobile/src/feature/review/components/letter-card-content.tsx @@ -1,11 +1,15 @@ import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { CustomSpinner } from "@/shared/components/custom-spinner"; +import { FormDivider } from "@/shared/components/form-divider"; import { ReviewPeriodType, ReviewReportDTO } from "../models/review-dto"; import { LetterBody } from "./letter-body"; import { LetterGeneratingState } from "./letter-generating-state"; +import { LetterNextStep } from "./letter-next-step"; import { LetterSignature } from "./letter-signature"; +import { LetterStats } from "./letter-stats"; +import { LetterTheme } from "./letter-theme"; import { MonthlyLetterInProgressState } from "./monthly-letter-in-progress-state"; import { LetterReadyState } from "./letter-ready-state"; import { LetterStamp } from "./letter-stamp"; @@ -49,9 +53,33 @@ export function LetterCardContent({ } else if (isCurrentMonth) { content = ; } else if (report) { + // Theme is what marks a letter as written in parts — a quiet month and a pre-split letter + // both leave it null, and both render body-only. A quiet month still gets a suggestion. + const theme = periodType === ReviewPeriodType.Monthly ? report.theme : null; + const nextStep = periodType === ReviewPeriodType.Monthly ? report.oneThingToTryNext : null; + content = ( <> + {theme !== null && ( + <> + + + + + + + )} + + + {nextStep !== null && } + + {(theme !== null || nextStep !== null) && ( + + + + )} + {t(`${ns}.aiDisclosure`)} diff --git a/blotztask-mobile/src/feature/review/components/letter-next-step.tsx b/blotztask-mobile/src/feature/review/components/letter-next-step.tsx new file mode 100644 index 000000000..7bc3d96ab --- /dev/null +++ b/blotztask-mobile/src/feature/review/components/letter-next-step.tsx @@ -0,0 +1,22 @@ +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; + +type Props = { + suggestion: string; +}; + +export function LetterNextStep({ suggestion }: Props) { + const { t } = useTranslation("settings"); + + return ( + + + {t("monthlyReview.nextMonthTitle")} + + + + {suggestion} + + + ); +} diff --git a/blotztask-mobile/src/feature/review/components/letter-stats.tsx b/blotztask-mobile/src/feature/review/components/letter-stats.tsx new file mode 100644 index 000000000..ff3a48723 --- /dev/null +++ b/blotztask-mobile/src/feature/review/components/letter-stats.tsx @@ -0,0 +1,53 @@ +import { Text, View } from "react-native"; +import MaterialCommunityIcons from "@react-native-vector-icons/material-design-icons/static"; +import { useTranslation } from "react-i18next"; + +type Props = { + tasksCompleted: number | null; +}; + +type Stat = { + key: string; + value: number; + label: string; + icon: "check-circle"; + color: string; +}; + +// The design shows three stats, but only tasksCompleted has a data source today — so the row +// renders whichever stats it was given rather than a fixed three. +export function LetterStats({ tasksCompleted }: Props) { + const { t } = useTranslation("settings"); + + const stats: Stat[] = []; + + if (tasksCompleted !== null) { + stats.push({ + key: "tasksCompleted", + value: tasksCompleted, + label: t("monthlyReview.tasksCompleted"), + icon: "check-circle", + color: "#84CC16", + }); + } + + if (stats.length === 0) return null; + + return ( + + {stats.map((stat) => ( + + + + + + {stat.value} + + + + {stat.label} + + ))} + + ); +} diff --git a/blotztask-mobile/src/feature/review/components/letter-theme.tsx b/blotztask-mobile/src/feature/review/components/letter-theme.tsx new file mode 100644 index 000000000..201c6ff7c --- /dev/null +++ b/blotztask-mobile/src/feature/review/components/letter-theme.tsx @@ -0,0 +1,20 @@ +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; + +type Props = { + theme: string; +}; + +export function LetterTheme({ theme }: Props) { + const { t } = useTranslation("settings"); + + return ( + + + {t("monthlyReview.themeLabel")} + + + {theme} + + ); +} diff --git a/blotztask-mobile/src/feature/review/models/review-dto.ts b/blotztask-mobile/src/feature/review/models/review-dto.ts index 7e4893b48..441f88185 100644 --- a/blotztask-mobile/src/feature/review/models/review-dto.ts +++ b/blotztask-mobile/src/feature/review/models/review-dto.ts @@ -9,7 +9,13 @@ export type ReviewReportDTO = { periodType: ReviewPeriodType; periodStartLocal: string; periodEndLocalExclusive: string; + // letter holds the body; theme and oneThingToTryNext are null on letters written before the + // backend split them out, and on a period too quiet to name a theme. letter: string | null; + theme: string | null; + oneThingToTryNext: string | null; + // Counted live from the tasks, so it has a value even for a period with no letter. + tasksCompleted: number; isLowActivity: boolean; generatedAtUtc: string | null; }; diff --git a/blotztask-mobile/src/i18n/locales/en/settings.json b/blotztask-mobile/src/i18n/locales/en/settings.json index 28c394ca3..b07b2eb18 100644 --- a/blotztask-mobile/src/i18n/locales/en/settings.json +++ b/blotztask-mobile/src/i18n/locales/en/settings.json @@ -83,6 +83,9 @@ "comingSoonTitle": "Your first letter is being written...", "comingSoonBody": "Your first Monthly Letter will be ready after you've recorded for 1 month.", "aiDisclosure": "Generated by AI from the tasks you created this month", + "themeLabel": "Theme of the month", + "tasksCompleted": "tasks completed", + "nextMonthTitle": "🎯 One thing to try next month", "readyTitle": "Your {{periodLabel}} Letter is ready.", "readLetter": "Read letter", "recordToday": "Record Today", diff --git a/blotztask-mobile/src/i18n/locales/zh/settings.json b/blotztask-mobile/src/i18n/locales/zh/settings.json index fbce44203..5dc9c86d1 100644 --- a/blotztask-mobile/src/i18n/locales/zh/settings.json +++ b/blotztask-mobile/src/i18n/locales/zh/settings.json @@ -83,6 +83,9 @@ "comingSoonTitle": "你的第一封信正在路上 ✨", "comingSoonBody": "月度回顾会在你使用 Blotz 满一个整月后送达。继续记录你的任务,很快就会有一封信等着你!🌱", "aiDisclosure": "由AI根据您本月创建的任务生成", + "themeLabel": "本月主题", + "tasksCompleted": "完成任务", + "nextMonthTitle": "🎯 下个月可以试试", "recordToday": "记录今天", "readyTitle": "你的{{periodLabel}}月度信件已准备好。", "readLetter": "阅读信件", From 0c044e3a58cbf7c490dc33bfa53458044764f9b0 Mon Sep 17 00:00:00 2001 From: AlexisEvans Date: Sat, 19 Sep 2026 02:28:49 +1000 Subject: [PATCH 2/2] fix(review): address letter layout review feedback - Hide the tasks-completed stat when the count is 0 - Treat missing theme/suggestion fields as absent (!= null) for older APIs - Give the quiet-period suggestion several examples and ask the model to vary it - Correct comments on when theme and oneThingToTryNext are null Co-Authored-By: Claude Opus 5 (1M context) --- blotztask-api/Modules/Reviews/Domain/ReviewLetter.cs | 3 ++- blotztask-api/Modules/Reviews/Prompts/ReviewPrompts.cs | 2 +- .../src/feature/review/components/letter-card-content.tsx | 6 +++--- .../src/feature/review/components/letter-stats.tsx | 3 ++- blotztask-mobile/src/feature/review/models/review-dto.ts | 3 ++- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/blotztask-api/Modules/Reviews/Domain/ReviewLetter.cs b/blotztask-api/Modules/Reviews/Domain/ReviewLetter.cs index dbdac3cd0..d1df5164f 100644 --- a/blotztask-api/Modules/Reviews/Domain/ReviewLetter.cs +++ b/blotztask-api/Modules/Reviews/Domain/ReviewLetter.cs @@ -3,7 +3,8 @@ namespace BlotzTask.Modules.Reviews.Domain; -// Theme and OneThingToTryNext are optional: the model omits them for a quiet period. +// Theme and OneThingToTryNext are optional: the model omits Theme for a quiet period, and +// OneThingToTryNext when the data doesn't support a specific suggestion. public record ReviewLetter(string Body, string? Theme, string? OneThingToTryNext); public static class ReviewLetterParser diff --git a/blotztask-api/Modules/Reviews/Prompts/ReviewPrompts.cs b/blotztask-api/Modules/Reviews/Prompts/ReviewPrompts.cs index 8e437955b..274eadd53 100644 --- a/blotztask-api/Modules/Reviews/Prompts/ReviewPrompts.cs +++ b/blotztask-api/Modules/Reviews/Prompts/ReviewPrompts.cs @@ -55,7 +55,7 @@ public static string GetReviewPrompt( First, decide which tasks carry real signal about how the user actually spent or intended their time. Disregard entries that are not genuine activity — placeholder, sample or example content, tests, or text that reads as random rather than a real task. Base everything below only on the tasks that remain. - If little or no genuine activity remains after that, do NOT invent themes, patterns, or meaning. Put two or three warm, honest sentences acknowledging it was a quiet {periodNoun} in "body" and leave "theme" null. Still write "oneThingToTryNext": for a quiet {periodNoun} it is not advice drawn from the data but one small, kind invitation for next {periodNoun} — something easy to say yes to, like noting one moment a day. Then stop there. + If little or no genuine activity remains after that, do NOT invent themes, patterns, or meaning. Put two or three warm, honest sentences acknowledging it was a quiet {periodNoun} in "body" and leave "theme" null. Still write "oneThingToTryNext": for a quiet {periodNoun} it is not advice drawn from the data but one small, kind invitation for next {periodNoun} — something easy to say yes to, like noting one moment a day, picking one small thing to finish each week, or setting aside fifteen minutes for something they enjoy. These are only examples — vary the invitation and do not reuse them word for word. Then stop there. Otherwise, write ONE short {reviewKind} review letter in three parts: diff --git a/blotztask-mobile/src/feature/review/components/letter-card-content.tsx b/blotztask-mobile/src/feature/review/components/letter-card-content.tsx index b1b845a68..f76b222f6 100644 --- a/blotztask-mobile/src/feature/review/components/letter-card-content.tsx +++ b/blotztask-mobile/src/feature/review/components/letter-card-content.tsx @@ -60,7 +60,7 @@ export function LetterCardContent({ content = ( <> - {theme !== null && ( + {theme != null && ( <> @@ -72,9 +72,9 @@ export function LetterCardContent({ - {nextStep !== null && } + {nextStep != null && } - {(theme !== null || nextStep !== null) && ( + {(theme != null || nextStep != null) && ( diff --git a/blotztask-mobile/src/feature/review/components/letter-stats.tsx b/blotztask-mobile/src/feature/review/components/letter-stats.tsx index ff3a48723..c5846e9a0 100644 --- a/blotztask-mobile/src/feature/review/components/letter-stats.tsx +++ b/blotztask-mobile/src/feature/review/components/letter-stats.tsx @@ -21,7 +21,8 @@ export function LetterStats({ tasksCompleted }: Props) { const stats: Stat[] = []; - if (tasksCompleted !== null) { + // Hide a zero count — the letter is gentle about unfinished tasks, and a big green "0" reads harsh. + if (tasksCompleted !== null && tasksCompleted > 0) { stats.push({ key: "tasksCompleted", value: tasksCompleted, diff --git a/blotztask-mobile/src/feature/review/models/review-dto.ts b/blotztask-mobile/src/feature/review/models/review-dto.ts index 441f88185..4417df306 100644 --- a/blotztask-mobile/src/feature/review/models/review-dto.ts +++ b/blotztask-mobile/src/feature/review/models/review-dto.ts @@ -10,7 +10,8 @@ export type ReviewReportDTO = { periodStartLocal: string; periodEndLocalExclusive: string; // letter holds the body; theme and oneThingToTryNext are null on letters written before the - // backend split them out, and on a period too quiet to name a theme. + // backend split them out. theme is also null on a period too quiet to name one, and + // oneThingToTryNext when the data doesn't support a specific suggestion. letter: string | null; theme: string | null; oneThingToTryNext: string | null;