chore(release): v1.28.30 — the daily briefing stops silently skipping days

The recurring no-briefing day had a chain of causes, closed end to end:

Every failure path in the nightly pregenerate + the comprehensive generator
now annotates a queryable cause (several classes — features-error,
payload-too-large, aborted, generator-failed — previously bumped a counter
with zero trace). A failed nightly warm enqueues one bounded retry ~45
minutes later (singleton per user, existing budgets apply), so a transient
provider hiccup heals before morning. A cached payload that carries NO
briefing no longer satisfies the 24h POST short-circuit nor the
unchanged-content hash gate — a once-stripped briefing could previously
block its own regeneration indefinitely. The card surfaces the omission
reason persistently (grounding strip, generation failure, not attempted)
with a forcing retry.

Documentation corrected against code truth: Withings credentials are
per-user (the documented env path never existed; the dead example entries
and manifest rows are gone, and WITHINGS_WEBHOOK_SECRET is now actually
whitelisted in compose — stock deploys could never deliver it); the classic
Fitbit portal no longer accepts new registrations; the backup-restore
example no longer mints a fresh encryption key and uses pnpm dlx tsx; the
TLS guide describes the real client model (two CA-level SPKI pins,
any-match, leaf renewals need no build); scaling/admin references fixed;
the operator-shared subscription provider is documented.
This commit is contained in:
Marc-André Bombeck committed 2026-07-12 11:08:59 +02:00
1 parent 14a517387e
commit a4feabec81
26 files changed
+1190 -148

No files matched your search

@@ -0,0 +1,76 @@
/**
* v1.28.30 — wire-contract for the explicit regenerate.
*
* House rule: generation = nightly cron + explicit button. The button's
* mutation therefore MUST issue `POST { force: true }` — without `force`
* the route's 24 h short-circuit re-serves the cached payload and the
* button reads as doing nothing (one half of the recurring
* "no briefing today" chain). The read path stays a plain GET.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const apiFetchRaw = vi.fn();
vi.mock("@/lib/api/api-fetch", () => ({
apiFetchRaw: (...a: unknown[]) => apiFetchRaw(...a),
}));
import { fetchAdvisor } from "../use-insights-advisor";
function jsonResponse(data: unknown): Response {
return {
ok: true,
status: 200,
json: async () => ({ data }),
} as unknown as Response;
}
beforeEach(() => {
vi.clearAllMocks();
apiFetchRaw.mockResolvedValue(
jsonResponse({ insights: { dailyBriefing: null }, cached: false }),
);
});
describe("fetchAdvisor — explicit regenerate forces a generation", () => {
it("POSTs force: true on the regenerate path", async () => {
const result = await fetchAdvisor({ force: true });
expect(apiFetchRaw).toHaveBeenCalledTimes(1);
const [url, init] = apiFetchRaw.mock.calls[0] as [
string,
{ method: string; body?: string },
];
expect(url).toBe("/api/insights/generate");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body ?? "{}")).toEqual({ force: true });
expect(result.outcome).toBe("fresh");
});
it("uses a read-only GET (no body, no force) on the read path", async () => {
await fetchAdvisor();
expect(apiFetchRaw).toHaveBeenCalledTimes(1);
const [url, init] = apiFetchRaw.mock.calls[0] as [
string,
{ method: string; body?: string },
];
expect(url).toBe("/api/insights/generate");
expect(init.method).toBe("GET");
expect(init.body).toBeUndefined();
});
it("lifts briefingOmittedReason through the payload for the card", async () => {
apiFetchRaw.mockResolvedValue(
jsonResponse({
insights: { dailyBriefing: null },
cached: true,
briefingOmittedReason: "ungrounded",
}),
);
const result = await fetchAdvisor();
expect(result.payload?.briefingOmittedReason).toBe("ungrounded");
expect(result.payload?.dailyBriefing).toBeNull();
});
});
@@ -92,8 +92,12 @@ export interface InsightAdvisorPayload {
* v1.28.28 (#470) — set by the POST when the number-grounding gate stripped
* the freshly generated briefing (after its one corrective retry). Without
* it the 200 carried a silently-null briefing and the card's "no briefing
* yet" made the regenerate button read as doing nothing. Only ever present
* on a force-regenerate response; absent on the read path.
* yet" made the regenerate button read as doing nothing.
*
* v1.28.30 — also carried by the read-only GET, backed by a dated
* server-side marker: a briefing the nightly warm stripped now renders
* the same honest "withheld" state on every read until the next
* successful generation, not only on the regenerate response.
*/
briefingOmittedReason?: "ungrounded" | null;
}
@@ -169,7 +173,14 @@ interface AdvisorFetchResult {
outcome: AdvisorFetchOutcome;
}
async function fetchAdvisor(
/**
* Exported as a test seam (like `nextAdvisorPollInterval`): the explicit
* regenerate MUST post `force: true` — generation happens on the nightly
* cron and on explicit intent only, and a force-less button silently
* re-reads the 24 h cache (the "button does nothing" half of the
* no-briefing-today chain). The wire-contract test pins it.
*/
export async function fetchAdvisor(
options: { force?: boolean } = {},
): Promise<AdvisorFetchResult> {
const controller = new AbortController();
@@ -409,9 +420,10 @@ export function useInsightsAdvisorQuery(
generationFailed: query.data?.payload?.generationFailed ?? false,
// Absent → no specific lever hint; the generic failed-description holds.
generationFailureClass: query.data?.payload?.generationFailureClass ?? null,
// v1.28.28 (#470) — only a force-regenerate response carries the field
// (setQueryData writes that response into this cache); the read path's
// GET never does, so the hint clears on the next successful read.
// v1.28.28 (#470) — a force-regenerate response carries the field via
// setQueryData; since v1.28.30 the GET carries it too (marker-backed),
// so the "withheld" state persists across reads and clears on the next
// successful generation.
briefingOmittedReason: query.data?.payload?.briefingOmittedReason ?? null,
};
}