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 91d367ae7..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", 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, 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 c546c8e69..f76b222f6 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..c5846e9a0 --- /dev/null +++ b/blotztask-mobile/src/feature/review/components/letter-stats.tsx @@ -0,0 +1,54 @@ +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[] = []; + + // 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, + 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..4417df306 100644 --- a/blotztask-mobile/src/feature/review/models/review-dto.ts +++ b/blotztask-mobile/src/feature/review/models/review-dto.ts @@ -9,7 +9,14 @@ 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. 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; + // 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": "阅读信件",