mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path. Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container. Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app. The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
164 lines
5.7 KiB
TypeScript
164 lines
5.7 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
import { memoizePerRequest } from "@/lib/request-cache";
|
|
|
|
/**
|
|
* v1.4.31 — Assistant-surface operator feature flags.
|
|
*
|
|
* Six boolean toggles on `AppSettings` carve the visibility cut for
|
|
* the seven LLM-driven surfaces. The master flag is a single kill-
|
|
* switch; the five sub-flags carve specific surfaces.
|
|
*
|
|
* `assistant.enabled = false` forces every sub-flag false in the
|
|
* resolved shape — the master always wins. This means a server-side
|
|
* caller never has to compose `master && sub`; reading
|
|
* `flags.coach` already accounts for both layers.
|
|
*
|
|
* Per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5: the
|
|
* matrix gates BOTH server-routed AND iOS on-device assistant
|
|
* surfaces. The `GET /api/feature-flags` endpoint projects this
|
|
* shape so iOS reads the same authoritative set the web reads.
|
|
*/
|
|
|
|
/** The five assistant sub-flags the operator can carve. */
|
|
export type AssistantSurface =
|
|
| "coach"
|
|
| "briefing"
|
|
| "insightStatus"
|
|
| "correlations"
|
|
| "healthScoreExplainer";
|
|
|
|
export interface AssistantFlagSet {
|
|
/** Master kill-switch — when false, every sub-flag is forced false. */
|
|
enabled: boolean;
|
|
/** Coach drawer, chat SSE, history rail, feedback. */
|
|
coach: boolean;
|
|
/** Daily Briefing card + advisor recommendations + regen icon. */
|
|
briefing: boolean;
|
|
/** Per-metric status cards on every `/insights/<metric>` sub-page. */
|
|
insightStatus: boolean;
|
|
/** Correlation narration tile on the mother page. */
|
|
correlations: boolean;
|
|
/** `?` glyph that opens the Health-Score delta explainer popover. */
|
|
healthScoreExplainer: boolean;
|
|
}
|
|
|
|
/** All-on default; mirrors v1.4.30 behaviour for fresh installs. */
|
|
export const ASSISTANT_FLAGS_DEFAULT: AssistantFlagSet = Object.freeze({
|
|
enabled: true,
|
|
coach: true,
|
|
briefing: true,
|
|
insightStatus: true,
|
|
correlations: true,
|
|
healthScoreExplainer: true,
|
|
});
|
|
|
|
/**
|
|
* Load the assistant flag set from `AppSettings`. Read-through pattern
|
|
* matches `getGlobalServiceAvailability()` — null/error/missing-row all
|
|
* fall back to defaults so the assistant stays visible on first boot.
|
|
*
|
|
* The master always wins: every sub-flag is forced false when the
|
|
* master is off, before the resolved set leaves this function.
|
|
*
|
|
* v1.4.33 — memoised per-request so a single Coach-drawer open that
|
|
* fires five gated fetches in parallel (`/api/insights/chat`,
|
|
* `/api/insights/comprehensive`, six `/api/insights/<metric>-status`
|
|
* routes, the rail list) reads `AppSettings.singleton` once instead of
|
|
* five times. The cache lives on the active `WideEventBuilder` so it
|
|
* goes away with the request; behaviour outside an event context (unit
|
|
* tests, background jobs) stays unchanged.
|
|
*/
|
|
export async function getAssistantFlags(): Promise<AssistantFlagSet> {
|
|
return memoizePerRequest("assistant-flags", async () => {
|
|
try {
|
|
const settings = await prisma.appSettings.findUnique({
|
|
where: { id: "singleton" },
|
|
select: {
|
|
assistantEnabled: true,
|
|
assistantCoachEnabled: true,
|
|
assistantBriefingEnabled: true,
|
|
assistantInsightStatusEnabled: true,
|
|
assistantCorrelationsEnabled: true,
|
|
assistantHealthScoreExplainerEnabled: true,
|
|
},
|
|
});
|
|
|
|
const master = settings?.assistantEnabled ?? true;
|
|
return resolveAssistantFlags({
|
|
enabled: master,
|
|
coach: settings?.assistantCoachEnabled ?? true,
|
|
briefing: settings?.assistantBriefingEnabled ?? true,
|
|
insightStatus: settings?.assistantInsightStatusEnabled ?? true,
|
|
correlations: settings?.assistantCorrelationsEnabled ?? true,
|
|
healthScoreExplainer:
|
|
settings?.assistantHealthScoreExplainerEnabled ?? true,
|
|
});
|
|
} catch {
|
|
getEvent()?.addWarning(
|
|
"Failed to load assistant feature flags, using defaults",
|
|
);
|
|
return ASSISTANT_FLAGS_DEFAULT;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Pure resolver — given a raw set of master + sub-flag values, return
|
|
* the operator-effective shape. Exposed for unit tests and for the
|
|
* admin PUT handler that needs to echo the resolved set after a write.
|
|
*/
|
|
export function resolveAssistantFlags(raw: AssistantFlagSet): AssistantFlagSet {
|
|
if (!raw.enabled) {
|
|
return {
|
|
enabled: false,
|
|
coach: false,
|
|
briefing: false,
|
|
insightStatus: false,
|
|
correlations: false,
|
|
healthScoreExplainer: false,
|
|
};
|
|
}
|
|
return { ...raw };
|
|
}
|
|
|
|
/**
|
|
* Error class thrown by `requireAssistantSurface()` when the operator
|
|
* has disabled the surface. The api-handler catches it and returns
|
|
* the 403 envelope per the iOS contract (R5):
|
|
*
|
|
* { data: null, error: "...", meta: { errorCode: "assistant.disabled.<surface>" } }
|
|
*
|
|
* Older iOS clients that predate the v1.4.31 contract surface this as
|
|
* a generic 403 (their existing 403-handler covers the case); v1.4.31+
|
|
* clients read the `errorCode` to render an inline operator-disabled
|
|
* notice.
|
|
*/
|
|
export class AssistantDisabledError extends Error {
|
|
readonly surface: AssistantSurface;
|
|
readonly errorCode: string;
|
|
|
|
constructor(surface: AssistantSurface) {
|
|
super(`Assistant surface "${surface}" is disabled on this server`);
|
|
this.name = "AssistantDisabledError";
|
|
this.surface = surface;
|
|
this.errorCode = `assistant.disabled.${surface}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Server-side gate helper. Throws `AssistantDisabledError` when the
|
|
* relevant surface (or the master) is off. Routes call this near the
|
|
* top, after auth and rate-limit, and let the apiHandler catch turn
|
|
* it into the 403 envelope.
|
|
*/
|
|
export async function requireAssistantSurface(
|
|
surface: AssistantSurface,
|
|
): Promise<AssistantFlagSet> {
|
|
const flags = await getAssistantFlags();
|
|
if (!flags[surface]) {
|
|
throw new AssistantDisabledError(surface);
|
|
}
|
|
return flags;
|
|
}
|