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
71 changes: 71 additions & 0 deletions docs/superpowers/plans/2026-07-29-decision-oriented-reports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Decision-Oriented Reports TDD Plan

**Goal:** Make weekly and monthly reports explain what the available data supports doing next instead of only repeating dashboard metrics.

**Behavior:** The server adds a deterministic decision synthesis to every non-empty weekly and monthly report. The synthesis covers what changed, descriptive associations, what appears to have worked, one next experiment, and evidence limitations or missing data. Web, mobile, and new shared-report snapshots render the same server-owned synthesis without recomputing health meaning.

**Scope:** Use the period summaries already returned by the report repositories. Keep the existing metric snapshots and sharing flow, avoid causal claims or a new analytics query, and preserve compatibility with shared snapshots created before the synthesis field existed.

**Related issue:** [#2171](https://github.com/Asherlc/dofek/issues/2171)

---

## Current Evidence

- `WeeklyReportRepository` and `MonthlyReportRepository` return current and historical metric aggregates but no decision synthesis.
- `WeeklyReportCard`, `MonthlyReportContent`, and the mobile reports screen render metric grids independently.
- Shared reports persist the same server response as JSON; the web shared-report route validates old snapshots at runtime.

## Test Strategy

- Unit: prove the server synthesizer handles improvements, trade-offs, missing sleep/recovery data, and insufficient history without causal wording.
- Repository: prove empty reports have no synthesis and non-empty weekly/monthly reports attach server-generated synthesis.
- Web: prove both report components render all five synthesis sections and old shared snapshots still parse.
- Mobile: prove the reports screen renders the same server-provided synthesis and performs no client calculation.

## File Structure

- Create `packages/server/src/repositories/report-decision-synthesis.ts` and its colocated test for the shared server domain model.
- Modify weekly/monthly report repositories and tests to attach synthesis.
- Create `packages/web/src/components/ReportDecisionSynthesis.tsx` and `packages/mobile/components/ReportDecisionSynthesis.tsx` with colocated tests and representative stories.
- Modify the weekly/monthly web report components, `packages/web/src/routes/health-report.tsx`, and `packages/mobile/app/reports.tsx` to render the server response.

## Tasks

### Task 1: Add Failing Server Tests

- [ ] Add unit cases for changed metrics, descriptive co-movement, positive evidence, next-step copy, observed-period context, and missing-data limitations.
- [ ] Add repository assertions for synthesis presence and absence.
- [ ] Run `pnpm exec vitest run packages/server/src/repositories/report-decision-synthesis.test.ts packages/server/src/repositories/weekly-report-repository.test.ts packages/server/src/repositories/monthly-report-repository.test.ts`.
- [ ] Confirm the tests fail because the synthesis contract does not exist.

### Task 2: Implement the Server Synthesis

- [ ] Add the minimum shared synthesis type and deterministic weekly/monthly builders.
- [ ] Attach the result after repository metrics have been computed.
- [ ] Run the focused server tests and confirm they pass.

### Task 3: Add Failing Client Tests

- [ ] Require all five server-provided sections on web weekly/monthly reports and mobile.
- [ ] Require shared-report parsing to accept both new synthesis snapshots and legacy snapshots without the field.
- [ ] Run `pnpm exec vitest run packages/web/src/components/ReportDecisionSynthesis.test.tsx packages/web/src/components/WeeklyReportCard.test.tsx packages/web/src/components/MonthlyReportContent.test.tsx packages/web/src/routes/health-report.test.tsx --project unit`.
- [ ] Run `pnpm exec vitest run packages/mobile/components/ReportDecisionSynthesis.test.tsx packages/mobile/app/reports.test.tsx --project mobile`.
- [ ] Confirm the tests fail because the synthesis is not rendered.

### Task 4: Implement Web and Mobile Parity

- [ ] Add small render-only synthesis components and representative stories.
- [ ] Render the server payload on weekly, monthly, mobile, and newly shared reports.
- [ ] Keep legacy shared reports readable without generating decisions on the client.
- [ ] Run the focused web/mobile tests and confirm they pass.

### Task 5: Final Verification

- [ ] In Codex cloud, initialize with `SANDBOX=1 mise run cloud:init` and run the complete Docker-free verification entrypoint with `mise run test:sandbox`.
- [ ] Outside the Codex cloud sandbox, run `pnpm lint`.
- [ ] Run `pnpm typecheck`.
- [ ] Run `pnpm --dir packages/server typecheck`.
- [ ] Run `pnpm --dir packages/web typecheck`.
- [ ] Run `pnpm test`.
- [ ] Build both Storybook catalogs if the focused checks pass.
1 change: 1 addition & 0 deletions packages/format/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"./medication-dose-events": "./src/medication-dose-events.ts",
"./medication-reminders": "./src/medication-reminders.ts",
"./record-local-time": "./src/record-local-time.ts",
"./report-decision-synthesis": "./src/report-decision-synthesis.ts",
"./supplement-dose-events": "./src/supplement-dose-events.ts",
"./units": "./src/units.ts"
},
Expand Down
24 changes: 24 additions & 0 deletions packages/format/src/report-decision-synthesis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
keyedDecisionSynthesisItems,
reportDecisionSynthesisSections,
} from "./report-decision-synthesis.ts";

describe("report decision synthesis formatting", () => {
it("defines the shared section order and titles", () => {
expect(reportDecisionSynthesisSections).toEqual([
{ key: "whatChanged", title: "What changed" },
{ key: "likelyAssociations", title: "Likely associations" },
{ key: "whatWorked", title: "What worked" },
{ key: "whatToTryNext", title: "What to try next" },
{ key: "confidenceAndMissingData", title: "Confidence and missing data" },
]);
});

it("assigns stable occurrence-aware keys to repeated narrative items", () => {
expect(keyedDecisionSynthesisItems(["Repeated evidence.", "Repeated evidence."])).toEqual([
{ key: '["Repeated evidence.",0]', text: "Repeated evidence." },
{ key: '["Repeated evidence.",1]', text: "Repeated evidence." },
]);
});
});
18 changes: 18 additions & 0 deletions packages/format/src/report-decision-synthesis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const reportDecisionSynthesisSections = [
{ key: "whatChanged", title: "What changed" },
{ key: "likelyAssociations", title: "Likely associations" },
{ key: "whatWorked", title: "What worked" },
{ key: "whatToTryNext", title: "What to try next" },
{ key: "confidenceAndMissingData", title: "Confidence and missing data" },
] as const;

export function keyedDecisionSynthesisItems(
items: readonly string[],
): { key: string; text: string }[] {
const occurrences = new Map<string, number>();
return items.map((text) => {
const occurrence = occurrences.get(text) ?? 0;
occurrences.set(text, occurrence + 1);
return { key: JSON.stringify([text, occurrence]), text };
});
}
32 changes: 32 additions & 0 deletions packages/mobile/app/reports.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const monthlyQueryControl = vi.hoisted(() => ({
showError: false,
preserveData: false,
showDecisionSupport: true,
}));
const mockWeeklyReportQuery = vi.hoisted(() => vi.fn());
const mockMonthlyReportQuery = vi.hoisted(() => vi.fn());
Expand Down Expand Up @@ -35,6 +36,15 @@ vi.mock("../lib/trpc", () => ({
avgHrv: 48,
},
history: [],
decisionSupport: monthlyQueryControl.showDecisionSupport
? {
whatChanged: ["Weekly training increased."],
likelyAssociations: ["Training and sleep moved together."],
whatWorked: ["Sleep stayed consistent."],
whatToTryNext: ["Repeat the routine next week."],
confidenceAndMissingData: ["Confidence is limited."],
}
: null,
},
isLoading: false,
error: null,
Expand Down Expand Up @@ -63,6 +73,15 @@ vi.mock("../lib/trpc", () => ({
avgSleepTrend: null,
},
history: [],
decisionSupport: monthlyQueryControl.showDecisionSupport
? {
whatChanged: ["Monthly training increased."],
likelyAssociations: ["Training and sleep moved together."],
whatWorked: ["Sleep stayed consistent."],
whatToTryNext: ["Repeat the routine next month."],
confidenceAndMissingData: ["Confidence is limited."],
}
: null,
},
isLoading: false,
error: monthlyQueryControl.showError
Expand All @@ -89,6 +108,7 @@ describe("ReportsScreen", () => {
beforeEach(() => {
monthlyQueryControl.showError = false;
monthlyQueryControl.preserveData = false;
monthlyQueryControl.showDecisionSupport = true;
mockWeeklyReportQuery.mockClear();
mockMonthlyReportQuery.mockClear();
});
Expand All @@ -103,6 +123,8 @@ describe("ReportsScreen", () => {
expect(screen.getAllByText("Average Heart Rate Variability (HRV)")).toHaveLength(2);
expect(screen.getByRole("button", { name: "Share weekly report" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Share monthly report" })).toBeTruthy();
expect(screen.getByText("Weekly training increased.")).toBeTruthy();
expect(screen.getByText("Monthly training increased.")).toBeTruthy();
expect(mockWeeklyReportQuery).toHaveBeenCalledWith(
{ weeks: 12, endDate: "2026-07-24" },
{ retry: false },
Expand All @@ -120,6 +142,16 @@ describe("ReportsScreen", () => {
expect(screen.queryByText("Not enough monthly data to create a report.")).toBeNull();
});

it("renders report metrics without a decision summary when synthesis is unavailable", async () => {
monthlyQueryControl.showDecisionSupport = false;
const { default: ReportsScreen } = await import("./reports");

render(<ReportsScreen />);

expect(screen.queryByText("Decision summary")).toBeNull();
expect(screen.getAllByText("Average Heart Rate Variability (HRV)")).toHaveLength(2);
});

it("keeps cached monthly report data visible during a background failure", async () => {
monthlyQueryControl.showError = true;
monthlyQueryControl.preserveData = true;
Expand Down
113 changes: 62 additions & 51 deletions packages/mobile/app/reports.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ScrollView, StyleSheet, Text, View } from "react-native";
import { Card } from "../components/Card";
import { HealthReportShareButton } from "../components/HealthReportShareButton";
import { getQueryErrorMessage, QueryStatePanel } from "../components/QueryStatePanel";
import { ReportDecisionSynthesis } from "../components/ReportDecisionSynthesis";
import { trpc } from "../lib/trpc";
import { useTodayQueryDate } from "../lib/useTodayQueryDate";
import { colors, spacing } from "../theme";
Expand All @@ -34,7 +35,7 @@ export default function ReportsScreen() {
<View style={styles.intro}>
<Text style={styles.title}>Health Reports</Text>
<Text style={styles.subtitle}>
Create shareable snapshots from the health data calculated by Dofek.
See what changed, what the data suggests, and what to compare next.
</Text>
</View>

Expand All @@ -52,33 +53,38 @@ export default function ReportsScreen() {
) : weeklyReport.isLoading ? (
<QueryStatePanel variant="loading" />
) : weeklyReport.data?.current ? (
<Card>
<Text style={styles.periodLabel}>
Week of {formatDateShort(weeklyReport.data.current.weekStart)}
</Text>
<View style={styles.metricGrid}>
<ReportMetric
label="Training"
value={formatDurationMinutes(weeklyReport.data.current.trainingHours * 60)}
/>
<ReportMetric
label="Activities"
value={`${weeklyReport.data.current.activityCount}`}
/>
<ReportMetric
label="Avg nightly sleep"
value={
weeklyReport.data.current.avgSleepMinutes > 0
? formatDurationMinutes(weeklyReport.data.current.avgSleepMinutes)
: "Not tracked"
}
/>
<ReportMetric
label="Average Heart Rate Variability (HRV)"
value={formatHRV(weeklyReport.data.current.avgHrv)}
/>
</View>
</Card>
<>
{weeklyReport.data.decisionSupport ? (
<ReportDecisionSynthesis synthesis={weeklyReport.data.decisionSupport} />
) : null}
<Card>
<Text style={styles.periodLabel}>
Week of {formatDateShort(weeklyReport.data.current.weekStart)}
</Text>
<View style={styles.metricGrid}>
<ReportMetric
label="Training"
value={formatDurationMinutes(weeklyReport.data.current.trainingHours * 60)}
/>
<ReportMetric
label="Activities"
value={`${weeklyReport.data.current.activityCount}`}
/>
<ReportMetric
label="Avg nightly sleep"
value={
weeklyReport.data.current.avgSleepMinutes > 0
? formatDurationMinutes(weeklyReport.data.current.avgSleepMinutes)
: "Not tracked"
}
/>
<ReportMetric
label="Average Heart Rate Variability (HRV)"
value={formatHRV(weeklyReport.data.current.avgHrv)}
/>
</View>
</Card>
</>
) : (
<QueryStatePanel variant="empty" message="Not enough weekly data to create a report." />
)}
Expand All @@ -96,29 +102,34 @@ export default function ReportsScreen() {
) : monthlyReport.isLoading ? (
<QueryStatePanel variant="loading" />
) : monthlyReport.data?.current ? (
<Card>
<Text style={styles.periodLabel}>
{formatMonthYear(monthlyReport.data.current.monthStart)}
</Text>
<View style={styles.metricGrid}>
<ReportMetric
label="Training"
value={formatDurationMinutes(monthlyReport.data.current.trainingHours * 60)}
/>
<ReportMetric
label="Activities"
value={`${monthlyReport.data.current.activityCount}`}
/>
<ReportMetric
label="Avg sleep"
value={formatDurationMinutes(monthlyReport.data.current.avgSleepMinutes)}
/>
<ReportMetric
label="Average Heart Rate Variability (HRV)"
value={formatHRV(monthlyReport.data.current.avgHrv)}
/>
</View>
</Card>
<>
{monthlyReport.data.decisionSupport ? (
<ReportDecisionSynthesis synthesis={monthlyReport.data.decisionSupport} />
) : null}
<Card>
<Text style={styles.periodLabel}>
{formatMonthYear(monthlyReport.data.current.monthStart)}
</Text>
<View style={styles.metricGrid}>
<ReportMetric
label="Training"
value={formatDurationMinutes(monthlyReport.data.current.trainingHours * 60)}
/>
<ReportMetric
label="Activities"
value={`${monthlyReport.data.current.activityCount}`}
/>
<ReportMetric
label="Avg sleep"
value={formatDurationMinutes(monthlyReport.data.current.avgSleepMinutes)}
/>
<ReportMetric
label="Average Heart Rate Variability (HRV)"
value={formatHRV(monthlyReport.data.current.avgHrv)}
/>
</View>
</Card>
</>
) : (
<QueryStatePanel variant="empty" message="Not enough monthly data to create a report." />
)}
Expand Down
45 changes: 45 additions & 0 deletions packages/mobile/components/ReportDecisionSynthesis.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react-native";
import { ReportDecisionSynthesis } from "./ReportDecisionSynthesis";

const synthesis = {
whatChanged: [
"Training was 6 hours, 50% more than the previous week.",
"Average nightly sleep was 7 hours, 30 minutes less than the previous week.",
],
likelyAssociations: [
"Higher training coincided with less sleep and lower heart rate variability this week. This is a descriptive association, not evidence that one change caused another.",
],
whatWorked: [
"You completed 4 activities; no recovery improvement is clear in the available weekly metrics.",
],
whatToTryNext: [
"Keep training near 6 hours and protect the sleep schedule next week, then compare sleep and recovery again before increasing volume.",
],
confidenceAndMissingData: [
"Confidence is limited because only 2 weekly periods are available.",
"These period averages can show co-movement, but they cannot establish cause and effect.",
],
};

const meta = {
title: "Reports/ReportDecisionSynthesis",
component: ReportDecisionSynthesis,
args: { synthesis },
} satisfies Meta<typeof ReportDecisionSynthesis>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const MissingData: Story = {
args: {
synthesis: {
...synthesis,
confidenceAndMissingData: [
...synthesis.confidenceAndMissingData,
"Missing current-period data: sleep and heart rate variability.",
],
},
},
};
Loading
Loading