feat(charts): medication chart matches other charts (7d trend + indicator + target-range)

Marc: "Bei Medikamenten zum Beispiel wird jetzt nicht 7-Tage-Schnitt,
Trend und Zielbereiche angezeigt. Das möchte ich natürlich für alle
Charts."

The medication-compliance chart shipped in v1.4.15 A4 (replacing the
static placeholder) was missing the trend chip + target-range
visualization the BP/weight/mood charts already paint. Bringing it to
parity:

- 7-day trend chip in the header: signed delta in percentage points
  with a metric-aware sentiment colour (rising compliance → green,
  falling → orange) and an arrow icon. Mirrors the bg-muted/40 chip
  pattern HealthChart uses for its weekly/monthly bucket label.
- New `computeMedicationTrend7d()` helper computes the delta as the
  mean of the most-recent 7 daily rates minus the mean of the prior 7
  (capped at a 14-day window). Mean-difference rather than slope so a
  single outlier day doesn't wag the indicator.
- Target-range visualization: added a 100 % goal ReferenceLine (green,
  solid-ish) ALONGSIDE the existing 80 % minimum-acceptable threshold
  (yellow, paler dash). Two lines together visualise the target range
  the way HealthChart's targetZone band does for BP/weight tiles.

Tests:
- 6 new unit tests pin computeMedicationTrend7d() including direction,
  delta, the 14-day cap, short windows, stable noise rejection.
- The existing chart-render snapshot is extended with an explicit
  assertion that the trend chip is suppressed in the empty state
  (so the empty-state header doesn't paint a misleading "0 pp"
  indicator).

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 23:32:01 +02:00
1 parent af77e5ecf8
commit 9b01c86e30
5 files changed
+441 -11

No files matched your search

@@ -34,6 +34,13 @@ vi.mock("@/lib/db", () => ({
})),
update: vi.fn(async () => ({})),
},
auditLog: {
// v1.4.16 A7: route now evicts stale per-status cache rows
// (`insights.<scope>-status.<locale>`) on every successful
// generation. The test prisma mock has to surface the call so
// the cache-invalidation test can assert against it.
deleteMany: vi.fn(async () => ({ count: 0 })),
},
},
}));
@@ -73,13 +80,39 @@ vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
import { POST } from "../route";
import { POST, resolveInsightsRateLimit } from "../route";
import { resolveProvider } from "@/lib/ai/provider";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
});
function makeWorkingProvider() {
vi.mocked(resolveProvider).mockResolvedValue({
type: "openai",
generateCompletion: vi.fn(async () => ({
content: JSON.stringify({
changed: "ok",
stable: "ok",
drivers: "ok",
nextSteps: "ok",
confidence: "mittel",
limitations: "ok",
}),
tokensUsed: 100,
providerType: "openai",
model: "gpt-4",
})),
} as unknown as Awaited<ReturnType<typeof resolveProvider>>);
}
interface ApiErrorEnvelope {
data: null;
error: string;
@@ -177,3 +210,121 @@ describe("POST /api/insights/generate — provider error mapping", () => {
expect(body.error).toMatch(/provider/i);
});
});
// v1.4.16 A7.1: rate limit raised from 2 → 10/h, env-configurable.
// Marc reported the previous 2/h was too aggressive when iterating on
// settings. The 10/h ceiling is the new default; the env override lets
// operators on a tight LLM budget dial it back without a rebuild.
describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
const ORIGINAL_ENV = { ...process.env };
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.INSIGHTS_RATE_LIMIT_PER_HOUR;
});
it("defaults to 10 requests per hour and rejects the 11th with a clear message", async () => {
makeWorkingProvider();
// Simulate 10 successful checkRateLimit responses, then one denial.
let callCount = 0;
vi.mocked(checkRateLimit).mockImplementation(
async (_key, limit, _windowMs) => {
callCount += 1;
// The route must pass `10` as the limit when the env var is unset.
expect(limit).toBe(10);
return {
allowed: callCount <= 10,
remaining: Math.max(0, 10 - callCount),
resetAt: Date.now() + 3600_000,
};
},
);
for (let i = 0; i < 10; i += 1) {
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status, `request ${i + 1} should succeed`).toBe(200);
}
const denied = await POST(jsonRequest({ force: true }) as never);
expect(denied.status).toBe(429);
const body = (await denied.json()) as ApiErrorEnvelope;
expect(body.error).toMatch(/Maximum 10 insight generations per hour/);
});
it("honours INSIGHTS_RATE_LIMIT_PER_HOUR env override", async () => {
process.env.INSIGHTS_RATE_LIMIT_PER_HOUR = "3";
expect(resolveInsightsRateLimit()).toBe(3);
makeWorkingProvider();
let callCount = 0;
vi.mocked(checkRateLimit).mockImplementation(async (_key, limit) => {
callCount += 1;
expect(limit).toBe(3);
return {
allowed: callCount <= 3,
remaining: Math.max(0, 3 - callCount),
resetAt: Date.now() + 3600_000,
};
});
for (let i = 0; i < 3; i += 1) {
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(200);
}
const denied = await POST(jsonRequest({ force: true }) as never);
expect(denied.status).toBe(429);
const body = (await denied.json()) as ApiErrorEnvelope;
expect(body.error).toMatch(/Maximum 3 insight generations per hour/);
});
it("falls back to 10 when env var is non-numeric or sub-1", () => {
process.env.INSIGHTS_RATE_LIMIT_PER_HOUR = "garbage";
expect(resolveInsightsRateLimit()).toBe(10);
process.env.INSIGHTS_RATE_LIMIT_PER_HOUR = "0";
expect(resolveInsightsRateLimit()).toBe(10);
process.env.INSIGHTS_RATE_LIMIT_PER_HOUR = "-5";
expect(resolveInsightsRateLimit()).toBe(10);
delete process.env.INSIGHTS_RATE_LIMIT_PER_HOUR;
expect(resolveInsightsRateLimit()).toBe(10);
});
});
// v1.4.16 A7.2: every fresh comprehensive insight evicts the per-
// scope status cache so the dashboard and the insights-page status
// cards never disagree. Without this, force-regeneration repaints
// `/api/insights/generate` while `/api/insights/<scope>-status` keeps
// returning yesterday's text until midnight Berlin time.
describe("POST /api/insights/generate — per-status cache eviction (A7.2)", () => {
it("deletes per-status audit-log cache rows after a successful generation", async () => {
makeWorkingProvider();
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(200);
expect(prisma.auditLog.deleteMany).toHaveBeenCalledTimes(1);
const args = vi.mocked(prisma.auditLog.deleteMany).mock.calls[0][0];
expect(args).toMatchObject({
where: {
userId: "u-1",
action: { startsWith: "insights." },
AND: [{ action: { contains: "-status." } }],
},
});
});
it("does NOT delete per-status cache when serving from the 24h DB cache", async () => {
// Cached path: route returns early without touching the LLM or the
// cache-eviction helper.
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify({ changed: "still fresh" }),
locale: "en",
} as never);
const res = await POST(jsonRequest({}) as never);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cached: boolean } };
expect(body.data.cached).toBe(true);
expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled();
});
});
+64 -5
View File
@@ -14,6 +14,52 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
const DEFAULT_INSIGHTS_RATE_LIMIT_PER_HOUR = 10;
/**
* Reads `INSIGHTS_RATE_LIMIT_PER_HOUR` from the environment with a
* sensible 10/hour default. The previous hard-coded 2/hour limit (from
* v1.4 P13) was too aggressive for users iterating on settings or
* regenerating after adding measurements. Operators who run on a
* stricter LLM budget can dial it down via env without redeploying
* code; values <1 fall back to the default.
*/
export function resolveInsightsRateLimit(): number {
const raw = process.env.INSIGHTS_RATE_LIMIT_PER_HOUR;
if (!raw) return DEFAULT_INSIGHTS_RATE_LIMIT_PER_HOUR;
const parsed = parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 1) {
return DEFAULT_INSIGHTS_RATE_LIMIT_PER_HOUR;
}
return parsed;
}
/**
* Per-status insight cache rows live in `audit_logs` keyed by
* `action = "insights.{scope}-status.{locale}"` (see
* `src/lib/insights/memory.ts` for the canonical scope list and
* `src/lib/insights/{general,blood-pressure,weight,pulse,bmi,
* mood,medication-compliance}-status.ts` for the writers). Each scope
* has its own per-day eviction, so before v1.4.16 a force-regeneration
* of the comprehensive insight would leave yesterday's per-status
* cards visible on the insights page until the next calendar day
* flipped. Drop them here so the next status fetch has to call the
* LLM again with the same fresh feature set the comprehensive blob
* just used.
*/
async function evictPerStatusInsightCache(userId: string): Promise<void> {
await prisma.auditLog.deleteMany({
where: {
userId,
action: { startsWith: "insights." },
// Keep the `insights.generate` row this request just wrote and
// the `insights.settings.*` rows — only the per-status cache
// entries (`insights.<scope>-status.<locale>`) carry stale text.
AND: [{ action: { contains: "-status." } }],
},
});
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const userId = user.id;
@@ -59,12 +105,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
// P13: rate-limit check moved BELOW the cache return so a hit on the
// 24h cache never burns one of the 2/hour LLM-generation tokens.
// Otherwise a noisy reload loop on the dashboard would lock out real
// refreshes for an hour for no benefit.
const rl = await checkRateLimit(`insights:${userId}`, 2, 60 * 60 * 1000);
// 24h cache never burns one of the LLM-generation tokens. Otherwise a
// noisy reload loop on the dashboard would lock out real refreshes
// for an hour for no benefit.
//
// v1.4.16 A7: limit raised from 2 → 10 per hour (env-configurable via
// `INSIGHTS_RATE_LIMIT_PER_HOUR`). The 2/hour limit triggered too
// aggressively when a user iterated on settings or regenerated after
// adding a few measurements; 10/hour still cleanly bounds cost while
// staying out of the way for legitimate use.
const limit = resolveInsightsRateLimit();
const rl = await checkRateLimit(`insights:${userId}`, limit, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 2 insight generations per hour.", 429);
return apiError(`Maximum ${limit} insight generations per hour.`, 429);
}
const provider = await resolveProvider(userId);
@@ -154,6 +207,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// v1.4.16 A7: a fresh comprehensive insight always supersedes the
// per-status cache. Otherwise the dashboard re-paints itself with the
// newly generated comprehensive while the insights-page status cards
// still show yesterday's text until midnight Berlin time.
await evictPerStatusInsightCache(userId);
await auditLog("insights.generate", {
userId,
ipAddress: getClientIp(request),