chore(release): v1.4.15

Bumps version to 1.4.15 and adds the corresponding CHANGELOG entry
covering backup completeness, achievements UI, onboarding tour,
doctor-report v2, auto-deploy on GHCR push, empty-states sweep,
notification + integration status UI, AI hallucination guardrails,
mobile UX fixes, and the BD-Zielbereich percentage fix.

Includes the formatting drift left over by the v1.4.15 development
phases (Prettier sweep on touched files) and a typecheck fix in
`dashboard-layout.test.ts` so `pnpm typecheck`, `pnpm lint`,
`pnpm format:check`, `pnpm test`, and `pnpm test:integration` all
pass green on the release tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 22:27:13 +02:00
1 parent 1b1214bcb3
commit 4dcaa08b42
32 files changed
+639 -295

No files matched your search

+5 -6
View File
@@ -3,6 +3,7 @@ import {
DEFAULT_DASHBOARD_LAYOUT,
resolveDashboardLayout,
serializeDashboardLayout,
type DashboardLayout,
} from "@/lib/dashboard-layout";
/**
@@ -73,15 +74,13 @@ describe("resolveDashboardLayout() — tileVisible upgrade", () => {
it("defaults the layout when raw input is null / not an object", () => {
expect(resolveDashboardLayout(null)).toEqual(DEFAULT_DASHBOARD_LAYOUT);
expect(resolveDashboardLayout(undefined)).toEqual(
DEFAULT_DASHBOARD_LAYOUT,
);
expect(resolveDashboardLayout(undefined)).toEqual(DEFAULT_DASHBOARD_LAYOUT);
});
});
describe("serializeDashboardLayout() — tileVisible persistence", () => {
it("persists tileVisible explicitly so a re-read keeps the user's choice", () => {
const layout = {
const layout: DashboardLayout = {
version: 1,
widgets: [
// User toggled tileVisible to false but kept the chart visible.
@@ -94,7 +93,7 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => {
});
it("derives tileVisible from visible when the field is missing on input", () => {
const layout = {
const layout: DashboardLayout = {
version: 1,
widgets: [
// Field omitted — serialize must not produce undefined; mirror.
@@ -106,7 +105,7 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => {
});
it("normalizes order to 0-based dense", () => {
const layout = {
const layout: DashboardLayout = {
version: 1,
widgets: [
{ id: "weight", visible: true, tileVisible: true, order: 5 },
+22 -21
View File
@@ -214,7 +214,11 @@ describe("i18n locale file integrity", () => {
});
// Flat helper used by the value-quality checks below.
function flattenValues(obj: unknown, prefix: string, out: [string, string][]) {
function flattenValues(
obj: unknown,
prefix: string,
out: [string, string][],
) {
if (obj == null || typeof obj !== "object") return;
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const key = prefix ? `${prefix}.${k}` : k;
@@ -295,24 +299,21 @@ describe("i18n locale file integrity", () => {
it.each([
["en", EN_PATH],
["de", DE_PATH],
])(
"%s locale has no TODO/FIXME placeholders in values",
(_locale, path) => {
const data = JSON.parse(readFileSync(path, "utf8")) as Record<
string,
unknown
>;
const flat: [string, string][] = [];
flattenValues(data, "", flat);
const todoRe = /\b(TODO|FIXME|XXX|TBD)\b/;
const todos = flat
.filter(([, v]) => todoRe.test(v))
.map(([k, v]) => `${k} = ${JSON.stringify(v)}`);
expect(
todos,
`Found TODO/FIXME/XXX/TBD placeholders in translation values:\n` +
todos.map((s) => ` ${s}`).join("\n"),
).toEqual([]);
},
);
])("%s locale has no TODO/FIXME placeholders in values", (_locale, path) => {
const data = JSON.parse(readFileSync(path, "utf8")) as Record<
string,
unknown
>;
const flat: [string, string][] = [];
flattenValues(data, "", flat);
const todoRe = /\b(TODO|FIXME|XXX|TBD)\b/;
const todos = flat
.filter(([, v]) => todoRe.test(v))
.map(([k, v]) => `${k} = ${JSON.stringify(v)}`);
expect(
todos,
`Found TODO/FIXME/XXX/TBD placeholders in translation values:\n` +
todos.map((s) => ` ${s}`).join("\n"),
).toEqual([]);
});
});
@@ -83,7 +83,11 @@ describe("citation-from-data — schema-level enforcement", () => {
id: "rec-1",
text: "Walk more.",
severity: "suggestion",
metricSource: { type: "activity", timeRange: "last7days", summary: "" },
metricSource: {
type: "activity",
timeRange: "last7days",
summary: "",
},
},
],
};
@@ -158,7 +162,10 @@ describe("citation-from-data — cross-check enforcement", () => {
const missing = findUncitedRecommendations(orphan);
expect(missing).toHaveLength(1);
expect(missing[0].recommendationId).toBe("rec-orphan");
expect(missing[0].missing).toEqual({ type: "diet", timeRange: "last7days" });
expect(missing[0].missing).toEqual({
type: "diet",
timeRange: "last7days",
});
});
it("partial-key match on type alone is NOT enough — timeRange must also match", () => {
@@ -145,10 +145,7 @@ describe("generateInsight wrapper", () => {
it("retries once with correction message and succeeds on second attempt", async () => {
const provider = new MockAIProvider({
responses: [
"definitely not json",
JSON.stringify(validResponse),
],
responses: ["definitely not json", JSON.stringify(validResponse)],
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
@@ -203,7 +200,10 @@ describe("generateInsight wrapper", () => {
citations: [], // empty — fails cross-check
};
const provider = new MockAIProvider({
responses: [JSON.stringify(orphanResponse), JSON.stringify(validResponse)],
responses: [
JSON.stringify(orphanResponse),
JSON.stringify(validResponse),
],
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
@@ -5,10 +5,7 @@ import {
OUT_OF_SCOPE_REFUSAL_EN,
OUT_OF_SCOPE_REFUSAL_DE,
} from "../prompts/insight-generator";
import {
aiInsightResponseSchema,
findUncitedRecommendations,
} from "../schema";
import { aiInsightResponseSchema, findUncitedRecommendations } from "../schema";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
+2 -1
View File
@@ -252,7 +252,8 @@ export class CodexClient implements AIProvider {
upstream: "codex",
model: attempted[attempted.length - 1] ?? null,
bodyExcerpt: lastSlugRejectionError
? (lastSlugRejectionError as Error & { bodyExcerpt?: string }).bodyExcerpt
? (lastSlugRejectionError as Error & { bodyExcerpt?: string })
.bodyExcerpt
: null,
attempted,
});
+7 -5
View File
@@ -42,10 +42,7 @@ export interface GenerateInsightOutcome {
* what went wrong on the first attempt. Includes the violated zod
* issues verbatim (truncated to 1KB) so the model can self-correct.
*/
function buildRetryCorrectionMessage(
reason: string,
details: string,
): string {
function buildRetryCorrectionMessage(reason: string, details: string): string {
return `
Your previous response did not satisfy the required JSON schema.
Reason: ${reason}
@@ -144,7 +141,12 @@ export async function generateInsight(
): Promise<GenerateInsightOutcome> {
const first = await tryOnce(provider, params);
if (first.ok && first.parsed) {
return { parsed: first.parsed, raw: first.raw, attempts: 1, retried: false };
return {
parsed: first.parsed,
raw: first.raw,
attempts: 1,
retried: false,
};
}
// Retry-once with corrective context appended to the user prompt.
+7 -4
View File
@@ -59,7 +59,9 @@ export const metricSourceSchema = z.object({
* 12 readings" or "compliance 0.71 / 30d". Empty value rejected so
* the model cannot fake a citation.
*/
summary: z.string().min(1, "metricSource.summary required (zero-hallucination)"),
summary: z
.string()
.min(1, "metricSource.summary required (zero-hallucination)"),
/** Sample count behind the value, when known. */
n: z.number().int().nonnegative().optional(),
});
@@ -156,9 +158,10 @@ export class InsightSchemaError extends Error {
* Returns the list of missing-citation issues; an empty list means
* the response is internally consistent.
*/
export function findUncitedRecommendations(
parsed: AIInsightResponse,
): Array<{ recommendationId: string; missing: { type: string; timeRange: string } }> {
export function findUncitedRecommendations(parsed: AIInsightResponse): Array<{
recommendationId: string;
missing: { type: string; timeRange: string };
}> {
const cited = new Set(
parsed.citations.map((c) => `${c.type}::${c.timeRange}`),
);
Binary file not shown.
@@ -58,9 +58,7 @@ describe("formatAdminAlertPayload — moodLog transient", () => {
subjectLabel: "marc@example.com",
});
expect(out.title).toBe("moodLog sync failing for marc@example.com");
expect(out.message).toContain(
"Action: investigate the upstream service.",
);
expect(out.message).toContain("Action: investigate the upstream service.");
expect(out.message).toContain("(http_502)");
});
});