mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user