mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
perf(api): wire 3 hot read routes through the server cache
/api/analytics — wraps both the slim (?slice=summaries) and default
slice paths through caches.analytics, keyed on ${userId}|${slice}.
TTL 60s. The default builder lifts into buildAnalyticsResponse() so
the cache miss / hit boundary stays at the route entry.
/api/gamification/achievements — wraps the locale-agnostic web result
through caches.achievements keyed on userId. TTL 60s. The iOS-format
locale transform runs after the cache read so the cache stays format-
neutral and the dashboard's duplicate mount (HAR-captured) coalesces
through single-flight.
/api/medications/intake?scope=compliance — wraps the per-day
compliance bucket build through caches.medicationsIntake keyed on
${userId}|compliance|${days}|${userTz}. TTL 15min because daily
buckets are slow-moving. The POST handler invalidates the per-user
medications/compliance/achievement caches after committing the dose
status change.
Each cached() call hands annotate() to the wrapper so cache.<name>.
outcome (hit|miss|stampede) + cache.<name>.key_hash land on every
request's wide event.
This commit is contained in:
1 parent
13b451a5ca
commit
68c4bb73b2
3 files changed
+156
-51
No files matched your search
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
@@ -64,7 +65,16 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// tile's `<InView>` boundary. Consumers that need only the headline
|
||||
// tile values land on this branch and skip the heavy chain.
|
||||
if (readSliceParam(request) === "summaries") {
|
||||
const slim = await computeSummariesSlice(user.id);
|
||||
// v1.4.34 IW-G — read-through the analytics cache keyed on
|
||||
// (userId, slice). The slim slice is the dashboard tile strip's hot
|
||||
// path; multiple dashboard mounts inside a 60-second TTL all hit a
|
||||
// warm cache.
|
||||
const slim = await cached(
|
||||
caches.analytics as ServerCache<Awaited<ReturnType<typeof computeSummariesSlice>>>,
|
||||
`${user.id}|summaries`,
|
||||
() => computeSummariesSlice(user.id),
|
||||
annotate,
|
||||
);
|
||||
// v1.4.34 IW-B — bfcache-friendly directive on the slim slice too
|
||||
// so a back-forward navigation that landed on the dashboard tile
|
||||
// strip can restore from memory instead of paying a full reload.
|
||||
@@ -73,6 +83,43 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
return slimRes;
|
||||
}
|
||||
|
||||
// v1.4.34 IW-G — wrap the heavy default-slice body in the analytics
|
||||
// cache keyed on (userId, "default"). The dashboard, the checklist
|
||||
// mount, and the Coach drawer all hit this endpoint within seconds
|
||||
// of each other; the 60s TTL converts the 7.99s combined dashboard
|
||||
// wait to a Map lookup on every subsequent caller.
|
||||
const body = await cached(
|
||||
caches.analytics as ServerCache<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
|
||||
`${user.id}|default`,
|
||||
() => buildAnalyticsResponse(user, request),
|
||||
annotate,
|
||||
);
|
||||
|
||||
const response = apiSuccess(body);
|
||||
// v1.4.34 IW-B — bfcache-friendly directive so back-forward navigation
|
||||
// restores the dashboard from memory. Per `src/lib/http/cache-headers.ts`:
|
||||
// `private` keeps shared caches out of personal data, `max-age=0` forces
|
||||
// revalidation on every navigation so session swaps detect on the wire,
|
||||
// and `must-revalidate` holds the staleness contract. Replaces the
|
||||
// framework's stock `no-store` which Chromium treats as a hard
|
||||
// bfcache breaker.
|
||||
response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
|
||||
return response;
|
||||
});
|
||||
|
||||
type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
|
||||
|
||||
/**
|
||||
* v1.4.34 IW-G — the heavy default-slice body, lifted out of the route
|
||||
* handler so `cached()` can wrap it. Returns the raw JSON payload; the
|
||||
* route handler attaches `Cache-Control` headers afterward. The split
|
||||
* matches the v1.4.33 snapshot LRU's `buildCoachSnapshot` →
|
||||
* `buildCoachSnapshotImpl` shape.
|
||||
*/
|
||||
async function buildAnalyticsResponse(
|
||||
user: AuthedUser,
|
||||
_request: Request | undefined,
|
||||
) {
|
||||
// v1.4.25 W7b — every day-bucket call inside this route now honours
|
||||
// the user's display timezone. The legacy `berlinDayKey()` import
|
||||
// remains for sleep-stage and correlation paths that share their
|
||||
@@ -346,7 +393,7 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
});
|
||||
}
|
||||
|
||||
const response = apiSuccess({
|
||||
return {
|
||||
summaries: results,
|
||||
bmi,
|
||||
bpInTargetPct,
|
||||
@@ -363,17 +410,8 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// staleness caption on each `<TrendCard>`. Additive: clients that
|
||||
// don't read the field stay unchanged.
|
||||
lastSeenByType,
|
||||
});
|
||||
// v1.4.34 IW-B — bfcache-friendly directive so back-forward navigation
|
||||
// restores the dashboard from memory. Per `src/lib/http/cache-headers.ts`:
|
||||
// `private` keeps shared caches out of personal data, `max-age=0` forces
|
||||
// revalidation on every navigation so session swaps detect on the wire,
|
||||
// and `must-revalidate` holds the staleness contract. Replaces the
|
||||
// framework's stock `no-store` which Chromium treats as a hard
|
||||
// bfcache breaker.
|
||||
response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
|
||||
return response;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-stage sleep-minutes breakdown over the trailing 30 days.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import {
|
||||
ACHIEVEMENT_DEFINITIONS,
|
||||
GAMIFICATION_ROLLOUT_AT,
|
||||
@@ -463,6 +464,58 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: { format: isIosFormat ? "ios" : "default" },
|
||||
});
|
||||
|
||||
// v1.4.34 IW-G — cache the web-shape result keyed on userId. The
|
||||
// iOS-format branch runs the locale-aware transform after the cache
|
||||
// read so the cache stays format-agnostic and the achievement-progress
|
||||
// dashboard duplicate (seen twice per dashboard mount in the v1.4.33
|
||||
// HAR) coalesces into one builder call. Newly-unlocked achievements
|
||||
// get persisted inside the builder so the second concurrent mount
|
||||
// observes the up-to-date `persisted` state.
|
||||
const result = await cached(
|
||||
caches.achievements as ServerCache<AchievementsResult>,
|
||||
user.id,
|
||||
() => buildAchievementsResult(user),
|
||||
annotate,
|
||||
);
|
||||
|
||||
if (isIosFormat) {
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale,
|
||||
});
|
||||
const t = getServerTranslator(locale);
|
||||
const ios: IosAchievement[] = result.achievements.map((a) => ({
|
||||
id: a.id,
|
||||
key: a.id,
|
||||
title: t.t(a.titleKey),
|
||||
description: t.t(a.descriptionKey),
|
||||
iconName: a.icon,
|
||||
unlocked: a.unlocked,
|
||||
unlockedAt: a.completedAt,
|
||||
progress: Math.max(0, Math.min(1, a.progressPercent / 100)),
|
||||
}));
|
||||
return apiSuccess(ios);
|
||||
}
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
|
||||
type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
|
||||
|
||||
type AchievementsResult = Awaited<ReturnType<typeof buildAchievementsResult>>;
|
||||
|
||||
/**
|
||||
* v1.4.34 IW-G — pulled out of the GET handler so `cached()` can wrap
|
||||
* the heavy aggregation. Returns the locale-agnostic web payload; the
|
||||
* iOS-format transform runs in the handler after the cache read.
|
||||
*
|
||||
* `prisma.userAchievement.createMany` inside the builder is kept here
|
||||
* (rather than moved to the cache hit path) because new unlocks are
|
||||
* a side effect of the same aggregate computation. A duplicate concurrent
|
||||
* read coalesces through the cache's single-flight `pending` map so the
|
||||
* createMany only fires once.
|
||||
*/
|
||||
async function buildAchievementsResult(user: AuthedUser) {
|
||||
const now = new Date();
|
||||
const userId = user.id;
|
||||
const startDate = maxDate(user.createdAt, GAMIFICATION_ROLLOUT_AT);
|
||||
@@ -868,31 +921,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
metrics: redactedMetrics,
|
||||
};
|
||||
|
||||
if (isIosFormat) {
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale,
|
||||
});
|
||||
const t = getServerTranslator(locale);
|
||||
// Same redaction principle: iOS clients receive opaque
|
||||
// placeholders for hidden+locked entries. The native client
|
||||
// mirrors the web-card opaque-placeholder UI when it sees the
|
||||
// sentinel id `achievements.hiddenCard.title` come back.
|
||||
const ios: IosAchievement[] = redactedAchievements.map((a) => ({
|
||||
id: a.id,
|
||||
key: a.id,
|
||||
title: t.t(a.titleKey),
|
||||
description: t.t(a.descriptionKey),
|
||||
iconName: a.icon,
|
||||
unlocked: a.unlocked,
|
||||
unlockedAt: a.completedAt,
|
||||
progress: Math.max(0, Math.min(1, a.progressPercent / 100)),
|
||||
}));
|
||||
return apiSuccess(ios);
|
||||
}
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden Easter-eggs ship to the client *only* as opaque cards while
|
||||
|
||||
@@ -24,6 +24,8 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
|
||||
const querySchema = z.object({
|
||||
scope: z.enum(["today", "compliance"]),
|
||||
@@ -109,16 +111,54 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// compliance: per-day scheduled vs taken for the last N days
|
||||
const start = new Date(Date.now() - days * 86_400_000);
|
||||
// v1.4.34 IW-G — compliance: per-day scheduled vs taken for the last
|
||||
// N days. Cached at a 15-minute TTL because daily compliance buckets
|
||||
// are slow-moving (intake events trickle in, yesterday's row doesn't
|
||||
// move). Cache key carries the userTz so a user who changes timezone
|
||||
// doesn't read another tz's bucketing.
|
||||
const result = await cached(
|
||||
caches.medicationsIntake as ServerCache<ComplianceBucket[]>,
|
||||
`${user.id}|compliance|${days}|${userTz}`,
|
||||
() => buildComplianceBuckets(user.id, days, userTz),
|
||||
annotate,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.compliance" },
|
||||
meta: { days, count: result.length },
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
|
||||
interface ComplianceBucket {
|
||||
date: string;
|
||||
scheduled: number;
|
||||
taken: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.34 IW-G — pulled out of the GET handler so `cached()` can wrap
|
||||
* the per-user compliance aggregation. Pure function over the user's
|
||||
* intake events + the requested window; deterministic given a fixed
|
||||
* "now" wall-clock (we anchor on `Date.now()` once at call time so a
|
||||
* 15-minute cached row stays internally consistent).
|
||||
*/
|
||||
async function buildComplianceBuckets(
|
||||
userId: string,
|
||||
days: number,
|
||||
userTz: string,
|
||||
): Promise<ComplianceBucket[]> {
|
||||
const nowMs = Date.now();
|
||||
const start = new Date(nowMs - days * 86_400_000);
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: user.id, scheduledFor: { gte: start } },
|
||||
where: { userId, scheduledFor: { gte: start } },
|
||||
select: { scheduledFor: true, takenAt: true, skipped: true },
|
||||
});
|
||||
|
||||
const buckets = new Map<string, { scheduled: number; taken: number }>();
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(Date.now() - i * 86_400_000);
|
||||
const d = new Date(nowMs - i * 86_400_000);
|
||||
buckets.set(userDayKey(d, userTz), { scheduled: 0, taken: 0 });
|
||||
}
|
||||
for (const e of events) {
|
||||
@@ -129,17 +169,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (e.takenAt && !e.skipped) bucket.taken += 1;
|
||||
}
|
||||
|
||||
const result = [...buckets.entries()]
|
||||
return [...buckets.entries()]
|
||||
.map(([date, v]) => ({ date, scheduled: v.scheduled, taken: v.taken }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.compliance" },
|
||||
meta: { days, count: result.length },
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -201,5 +234,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
meta: { intakeId, status },
|
||||
});
|
||||
|
||||
// v1.4.34 IW-G — bust the medications + compliance + achievement
|
||||
// caches for this user so the next read reflects the dose change.
|
||||
invalidateUserMedications(user.id);
|
||||
|
||||
return apiSuccess(updated);
|
||||
});
|
||||
Reference in new issue
Block a user