Skip to content
Open
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
3 changes: 2 additions & 1 deletion blotztask-api/Modules/Reviews/Domain/ReviewLetter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion blotztask-api/Modules/Reviews/Prompts/ReviewPrompts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -49,9 +53,33 @@ export function LetterCardContent({
} else if (isCurrentMonth) {
content = <MonthlyLetterInProgressState />;
} 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;
Comment thread
AlexisEvan marked this conversation as resolved.
const nextStep = periodType === ReviewPeriodType.Monthly ? report.oneThingToTryNext : null;

content = (
<>
{theme != null && (
<>
<LetterTheme theme={theme} />
<LetterStats tasksCompleted={report.tasksCompleted} />
<View className="mb-6">
<FormDivider marginVertical={0} />
</View>
</>
)}

<LetterBody recipientName={recipientName} body={report.letter ?? ""} />

{nextStep != null && <LetterNextStep suggestion={nextStep} />}

{(theme != null || nextStep != null) && (
<View className="mb-6">
<FormDivider marginVertical={0} />
</View>
)}

<LetterSignature />
<Text className="text-xs font-baloo text-secondary/50 mt-6 text-center">
{t(`${ns}.aiDisclosure`)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<View className="mb-8">
<Text className="text-[15px] font-balooBold text-secondary mb-1">
{t("monthlyReview.nextMonthTitle")}
</Text>

<Text className="text-[15px] font-baloo text-secondary" style={{ lineHeight: 26 }}>
{suggestion}
</Text>
</View>
);
}
54 changes: 54 additions & 0 deletions blotztask-mobile/src/feature/review/components/letter-stats.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View className="flex-row mb-5 gap-x-9">
{stats.map((stat) => (
<View key={stat.key}>
<View className="flex-row items-center">
<MaterialCommunityIcons name={stat.icon} size={18} color={stat.color} />

<Text className="ml-1.5 text-xl font-balooBold" style={{ color: stat.color }}>
{stat.value}
</Text>
</View>

<Text className="mt-0.5 text-xs font-baloo text-secondary/50">{stat.label}</Text>
</View>
))}
</View>
);
}
20 changes: 20 additions & 0 deletions blotztask-mobile/src/feature/review/components/letter-theme.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View className="mb-5">
<Text className="text-[10px] font-baloo text-secondary/50 uppercase tracking-[2px] mb-1.5">
{t("monthlyReview.themeLabel")}
</Text>

<Text className="text-base font-balooBold text-secondary">{theme}</Text>
</View>
);
}
7 changes: 7 additions & 0 deletions blotztask-mobile/src/feature/review/models/review-dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
3 changes: 3 additions & 0 deletions blotztask-mobile/src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions blotztask-mobile/src/i18n/locales/zh/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"comingSoonTitle": "你的第一封信正在路上 ✨",
"comingSoonBody": "月度回顾会在你使用 Blotz 满一个整月后送达。继续记录你的任务,很快就会有一封信等着你!🌱",
"aiDisclosure": "由AI根据您本月创建的任务生成",
"themeLabel": "本月主题",
"tasksCompleted": "完成任务",
"nextMonthTitle": "🎯 下个月可以试试",
"recordToday": "记录今天",
"readyTitle": "你的{{periodLabel}}月度信件已准备好。",
"readLetter": "阅读信件",
Expand Down
Loading