mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
1b1214bcb3
commit
4dcaa08b42
32 files changed
+639
-295
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user