mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(nutrients): tell the account holder when a sync arrived but nothing landed
GET /api/nutrients now looks up the most recent nutrient.batch.ingest audit row when the window is genuinely empty, and surfaces it as lastAttempt only when that call landed nothing (inserted === 0 && updated === 0). A real success elsewhere is left alone, so an empty window for an unrelated reason never gets reframed as a failure. The lookup runs on the zero-rows path only, never on the happy path, and stays counts-and-reason-codes only - no user-typed content, no per-entry health values. The reason code never reaches the page as text: each of the four closed skip reasons maps to its own human sentence, and the two that have no useful action for the account holder (unit_mismatch, upsert_failed) say plainly that the entries couldn't be stored rather than inventing advice. day_invalid is the one case worth naming a real cause for - a clock or time zone mismatch on the sending side - without claiming it was a phone, since any token holder can call this route. This depends on AuditLog retention: cleanupOldAuditLogs prunes rows past 365 days by default, so a dormant account's last failed attempt can age out and the explanation silently reverts to the plain empty state. Documented at the lookup for whoever next touches that job.
This commit is contained in:
1 parent
3ec3a8c6aa
commit
12d3239de0
12 files changed
+374
-17
No files matched your search
@@ -7,6 +7,12 @@
|
||||
* does the server hold". Catalog order, no pagination (≤ 26 rows).
|
||||
* Module-gated like the ingest path: 403 `module.disabled` when the
|
||||
* opt-in `nutrients` module is off. `userId` is narrowed from auth.
|
||||
*
|
||||
* `lastAttempt` (v1.34.3): non-null only when the window is empty AND the
|
||||
* most recent `nutrient.batch.ingest` audit row shows a call that landed
|
||||
* nothing, so `/insights/nutrients` can say a sync arrived and why instead
|
||||
* of a bare "no data yet". See the lookup below for the AuditLog-retention
|
||||
* coupling this introduces.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -21,6 +27,70 @@ import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Highest-count skip reason; ties break on this fixed declaration order. */
|
||||
const SKIP_REASON_PRIORITY = [
|
||||
"unit_mismatch",
|
||||
"value_out_of_range",
|
||||
"day_invalid",
|
||||
"upsert_failed",
|
||||
] as const;
|
||||
|
||||
interface LastIngestDetails {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
skippedByReason: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `AuditLog.details` is a free-form JSON string; parse it back into the
|
||||
* shape `nutrient.batch.ingest` (`src/app/api/nutrients/batch/route.ts`)
|
||||
* writes, rejecting anything that doesn't match rather than trusting the
|
||||
* blob.
|
||||
*/
|
||||
function parseLastIngestDetails(raw: string | null): LastIngestDetails | null {
|
||||
if (!raw) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const { inserted, updated, skipped } = obj;
|
||||
if (
|
||||
typeof inserted !== "number" ||
|
||||
typeof updated !== "number" ||
|
||||
typeof skipped !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const skippedByReason: Record<string, number> = {};
|
||||
const rawByReason = obj.skippedByReason;
|
||||
if (rawByReason && typeof rawByReason === "object") {
|
||||
for (const [reason, count] of Object.entries(
|
||||
rawByReason as Record<string, unknown>,
|
||||
)) {
|
||||
if (typeof count === "number") skippedByReason[reason] = count;
|
||||
}
|
||||
}
|
||||
return { inserted, updated, skipped, skippedByReason };
|
||||
}
|
||||
|
||||
function pickTopSkipReason(byReason: Record<string, number>): string | null {
|
||||
let best: string | null = null;
|
||||
let bestCount = 0;
|
||||
for (const reason of SKIP_REASON_PRIORITY) {
|
||||
const count = byReason[reason] ?? 0;
|
||||
if (count > bestCount) {
|
||||
best = reason;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -106,6 +176,37 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
};
|
||||
});
|
||||
|
||||
// The account holder's only signal that a sync ever arrived is this
|
||||
// lookup — the read path stays honest instead of a bare "no data yet"
|
||||
// when a batch actually landed and every entry was rejected. Gated on
|
||||
// the zero-rows path ONLY: an AuditLog read must never run on the happy
|
||||
// path just to serve a read endpoint. COUPLING: this depends on the row
|
||||
// still existing — `cleanupOldAuditLogs` (`src/lib/jobs/audit-log-cleanup.ts`)
|
||||
// prunes AuditLog nightly past `AUDIT_LOG_RETENTION_DAYS` (365 days by
|
||||
// default), so a dormant account whose last failed attempt ages past
|
||||
// that window silently loses the explanation and falls back to the
|
||||
// plain empty state — anyone changing that retention job should know a
|
||||
// user-facing message depends on it.
|
||||
let lastAttempt: { at: string; topReason: string } | null = null;
|
||||
if (nutrients.length === 0) {
|
||||
const lastIngest = await prisma.auditLog.findFirst({
|
||||
where: { userId: user.id, action: "nutrient.batch.ingest" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true, details: true },
|
||||
});
|
||||
const details = parseLastIngestDetails(lastIngest?.details ?? null);
|
||||
// Only surface a reason when the last attempt landed NOTHING: a
|
||||
// succeeded attempt whose day just falls outside this window is a
|
||||
// different, unrelated situation — showing a failure notice over it
|
||||
// would be the very false claim this feature exists to prevent.
|
||||
if (details && details.inserted === 0 && details.updated === 0) {
|
||||
const topReason = pickTopSkipReason(details.skippedByReason);
|
||||
if (topReason) {
|
||||
lastAttempt = { at: lastIngest!.createdAt.toISOString(), topReason };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "nutrient.intake.read" },
|
||||
meta: {
|
||||
@@ -115,5 +216,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ windowDays: days, nutrients });
|
||||
return apiSuccess({ windowDays: days, nutrients, lastAttempt });
|
||||
});
|
||||
@@ -12,13 +12,36 @@ import { HydrationCard } from "@/components/insights/nutrients/hydration-card";
|
||||
import { CaffeineCard } from "@/components/insights/nutrients/caffeine-card";
|
||||
import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { NutrientIntakeOverview } from "@/components/insights/nutrients/types";
|
||||
|
||||
const WINDOW_DAYS = 30;
|
||||
|
||||
/**
|
||||
* `nutrient.batch.ingest`'s closed skip-reason codes
|
||||
* (`src/app/api/nutrients/batch/route.ts`) mapped to a human sentence —
|
||||
* never render the raw code. A reason with no useful action for the
|
||||
* account holder says plainly that the entries couldn't be stored rather
|
||||
* than inventing advice; `dayInvalid` is the one case with something
|
||||
* genuinely worth checking (a clock/time-zone mismatch on the sending
|
||||
* side), so it says so.
|
||||
*/
|
||||
const LAST_ATTEMPT_REASON_KEYS: Record<string, string> = {
|
||||
unit_mismatch: "nutrients.page.lastAttemptReason.unitMismatch",
|
||||
value_out_of_range: "nutrients.page.lastAttemptReason.valueOutOfRange",
|
||||
day_invalid: "nutrients.page.lastAttemptReason.dayInvalid",
|
||||
upsert_failed: "nutrients.page.lastAttemptReason.upsertFailed",
|
||||
};
|
||||
|
||||
function lastAttemptReasonKey(reason: string): string {
|
||||
return (
|
||||
LAST_ATTEMPT_REASON_KEYS[reason] ??
|
||||
"nutrients.page.lastAttemptReason.unknown"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `/insights/nutrients` — hydration + micronutrients (v1.29).
|
||||
*
|
||||
@@ -44,13 +67,22 @@ const WINDOW_DAYS = 30;
|
||||
* `queryKeys.nutrientIntake(WINDOW_DAYS)` query and already renders
|
||||
* its own error card, but this page's old empty-state branch
|
||||
* short-circuited before that card ever mounted.
|
||||
* - module on, zero rows anywhere in the window → one EmptyState
|
||||
* explaining where data comes from (phone health-app sync or
|
||||
* manual water entry) instead of three near-empty cards.
|
||||
* - module on, zero rows anywhere in the window, AND the server found a
|
||||
* recent sync attempt that landed nothing (`overview.data.lastAttempt`,
|
||||
* from `GET /api/nutrients` — see that route for the AuditLog lookup
|
||||
* and its retention coupling) → an EmptyState naming the attempt and
|
||||
* the reason, not the generic copy. A batch CAN arrive and have every
|
||||
* entry rejected (wrong unit, an implausible amount, a bad date, a
|
||||
* write failure); without this branch that read the same as nothing
|
||||
* ever syncing.
|
||||
* - module on, zero rows, no recorded attempt → the generic EmptyState
|
||||
* explaining where data comes from (a health-app sync or a manual
|
||||
* water entry) instead of three near-empty cards.
|
||||
* - otherwise → the three-card spine.
|
||||
*/
|
||||
export default function InsightsNutrientsPage() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -110,16 +142,34 @@ export default function InsightsNutrientsPage() {
|
||||
|
||||
const hasAnyData = (overview.data?.nutrients.length ?? 0) > 0;
|
||||
if (!overview.isLoading && !hasAnyData) {
|
||||
const lastAttempt = overview.data?.lastAttempt ?? null;
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("nutrients.page.title")}
|
||||
description={t("nutrients.page.description")}
|
||||
>
|
||||
<EmptyState
|
||||
icon={<Leaf className="size-6" />}
|
||||
title={t("nutrients.page.emptyTitle")}
|
||||
description={t("nutrients.page.emptyDescription")}
|
||||
/>
|
||||
{lastAttempt ? (
|
||||
<EmptyState
|
||||
icon={<Leaf className="size-6" />}
|
||||
title={t("nutrients.page.lastAttemptTitle")}
|
||||
description={
|
||||
<>
|
||||
{t(lastAttemptReasonKey(lastAttempt.topReason))}
|
||||
<span className="mt-1 block text-xs">
|
||||
{t("nutrients.page.lastAttemptAt", {
|
||||
date: fmt.dateTime(lastAttempt.at),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Leaf className="size-6" />}
|
||||
title={t("nutrients.page.emptyTitle")}
|
||||
description={t("nutrients.page.emptyDescription")}
|
||||
/>
|
||||
)}
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,11 @@ function render(node: React.ReactNode, client: QueryClient): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** React's SSR serialiser escapes `'` as `'` in text nodes. */
|
||||
function htmlEscaped(text: string): string {
|
||||
return text.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
describe("mental wellbeing — screening history", () => {
|
||||
const mh = en.mentalHealth;
|
||||
|
||||
@@ -170,11 +175,56 @@ describe("/insights/nutrients page — overview read shares the micronutrients q
|
||||
|
||||
it("still renders the honest empty state when the window genuinely has no rows", () => {
|
||||
const client = makeClient();
|
||||
client.setQueryData(queryKeys.nutrientIntake(30), { nutrients: [] });
|
||||
client.setQueryData(queryKeys.nutrientIntake(30), {
|
||||
nutrients: [],
|
||||
lastAttempt: null,
|
||||
});
|
||||
const html = render(<InsightsNutrientsPage />, client);
|
||||
|
||||
expect(html).not.toContain('data-slot="query-error-card"');
|
||||
expect(html).toContain(np.emptyTitle);
|
||||
expect(html).not.toContain(np.lastAttemptTitle);
|
||||
});
|
||||
|
||||
// `GET /api/nutrients` returns `lastAttempt` non-null only when the window
|
||||
// is empty AND the most recent `nutrient.batch.ingest` audit row shows a
|
||||
// call that landed nothing — a batch CAN arrive and have every entry
|
||||
// rejected, which used to read on this page exactly like nothing ever
|
||||
// synced. The reason must render as the mapped human sentence, never the
|
||||
// raw wire code.
|
||||
it("names the sync attempt and its reason instead of the generic empty state", () => {
|
||||
const client = makeClient();
|
||||
client.setQueryData(queryKeys.nutrientIntake(30), {
|
||||
nutrients: [],
|
||||
lastAttempt: {
|
||||
at: "2026-07-15T09:30:00.000Z",
|
||||
topReason: "unit_mismatch",
|
||||
},
|
||||
});
|
||||
const html = render(<InsightsNutrientsPage />, client);
|
||||
|
||||
expect(html).toContain(np.lastAttemptTitle);
|
||||
expect(html).toContain(htmlEscaped(np.lastAttemptReason.unitMismatch));
|
||||
// The date line renders around a formatted timestamp; assert the fixed
|
||||
// prefix rather than the locale-formatted date itself.
|
||||
expect(html).toContain(np.lastAttemptAt.split("{date}")[0]);
|
||||
expect(html).not.toContain(np.emptyTitle);
|
||||
expect(html).not.toContain("unit_mismatch");
|
||||
});
|
||||
|
||||
it("falls back to the generic reason sentence for an unmapped code", () => {
|
||||
const client = makeClient();
|
||||
client.setQueryData(queryKeys.nutrientIntake(30), {
|
||||
nutrients: [],
|
||||
lastAttempt: {
|
||||
at: "2026-07-15T09:30:00.000Z",
|
||||
topReason: "some_future_reason",
|
||||
},
|
||||
});
|
||||
const html = render(<InsightsNutrientsPage />, client);
|
||||
|
||||
expect(html).toContain(np.lastAttemptTitle);
|
||||
expect(html).toContain(htmlEscaped(np.lastAttemptReason.unknown));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,20 @@ export interface NutrientOverviewRow {
|
||||
daysWithData: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-null only when `nutrients` is empty AND the most recent
|
||||
* `nutrient.batch.ingest` audit row shows a call that landed nothing —
|
||||
* `topReason` is the most common of the closed skip-reason codes
|
||||
* (`unit_mismatch` | `value_out_of_range` | `day_invalid` |
|
||||
* `upsert_failed`) from that call.
|
||||
*/
|
||||
export interface NutrientLastIngestAttempt {
|
||||
at: string;
|
||||
topReason: string;
|
||||
}
|
||||
|
||||
export interface NutrientIntakeOverview {
|
||||
windowDays: number;
|
||||
nutrients: NutrientOverviewRow[];
|
||||
lastAttempt: NutrientLastIngestAttempt | null;
|
||||
}
|
||||
@@ -100,6 +100,16 @@ export const nutrientOverviewSchema = z
|
||||
daysWithData: z.number().int().min(1),
|
||||
}),
|
||||
),
|
||||
lastAttempt: z
|
||||
.object({
|
||||
at: z.iso.datetime({ offset: true }),
|
||||
topReason: z.string(),
|
||||
})
|
||||
.nullable()
|
||||
.meta({
|
||||
description:
|
||||
"Non-null only when `nutrients` is empty AND the most recent nutrient-batch call landed nothing — the most common skip reason from that call, so the client can say a sync arrived and why instead of a bare empty state.",
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "NutrientIntakeOverview",
|
||||
|
||||
Reference in new issue
Block a user