mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge origin/main (v1.44.2) into fix/v145-sheet-motion
next stays on main's 16.4.2 (the RCE and Server Actions advisories) with its two companions; release's newer dev tools and its own additions are kept. The lockfile is release's, re-resolved. pt-BR, which main does not have, takes the one key main added.
This commit is contained in:
commit
9c8972bc96
86 files changed
+5051
-487
No files matched your search
@@ -64,6 +64,7 @@ import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
|
||||
import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets";
|
||||
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
|
||||
import { providerFailureKind } from "@/lib/ai/failure-kind";
|
||||
import {
|
||||
recordBriefingFailure,
|
||||
readBriefingFailure,
|
||||
@@ -855,6 +856,10 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
: "provider-error",
|
||||
locale,
|
||||
httpStatus: failureHttpStatus,
|
||||
failureKind:
|
||||
e instanceof AllProvidersFailedError
|
||||
? (e.attempts[0]?.failureKind ?? null)
|
||||
: providerFailureKind(e).failure,
|
||||
});
|
||||
if (e instanceof AllProvidersFailedError) {
|
||||
annotate({
|
||||
|
||||
@@ -667,6 +667,37 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* A bottom sheet with a preview step (`BottomSheetContent preview`) is laid
|
||||
* out at its full height and, on the preview step, rests lowered by the
|
||||
* difference: moving between the steps, or dragging, is a transform and
|
||||
* never a relayout. Its footer is lifted by the same amount so it stays on
|
||||
* the window's bottom edge, and its scrolling body ends above that footer,
|
||||
* so nothing in it is out of reach on the lower step. The drag writes the
|
||||
* same three properties inline while a finger is down.
|
||||
*/
|
||||
[data-sheet-snap="preview"] {
|
||||
transform: translateY(
|
||||
calc(var(--sheet-height) - var(--sheet-preview-height))
|
||||
);
|
||||
}
|
||||
[data-sheet-snap] [data-sheet-footer] {
|
||||
transition: transform 260ms ease-out;
|
||||
}
|
||||
[data-sheet-snap="preview"] [data-sheet-footer] {
|
||||
transform: translateY(
|
||||
calc(var(--sheet-preview-height) - var(--sheet-height))
|
||||
);
|
||||
}
|
||||
[data-sheet-snap="preview"] [data-sheet-body] {
|
||||
margin-bottom: calc(var(--sheet-height) - var(--sheet-preview-height));
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-sheet-snap] [data-sheet-footer] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Side and top sheets run the full height of the window. In an installed
|
||||
* app on a tablet the status bar sits over their first 24 px, and a phone
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The SpO₂ chart shades the person's own target when they set one on
|
||||
* `/targets`, and the clinical bands otherwise. The page used to hand the
|
||||
* chart a fixed 80 / 90 / 95 / 100 band, so a target of 88 to 92 % (COPD,
|
||||
* altitude) left every reading of the person's normal range shaded as a
|
||||
* warning.
|
||||
*
|
||||
* The scaffold is replaced by a probe that prints the bands it receives; the
|
||||
* thresholds read is seeded straight into the shared query cell, which is the
|
||||
* cell the target editor invalidates on save.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { ValueBand } from "@/lib/analytics/value-bands";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/insights/healthkit-metric-page", () => ({
|
||||
HealthKitMetricPage: (props: { valueBands?: ValueBand[] }) => (
|
||||
<pre data-testid="bands">{JSON.stringify(props.valueBands ?? null)}</pre>
|
||||
),
|
||||
}));
|
||||
|
||||
const { default: InsightsOxygenSaturationPage } = await import("../page");
|
||||
|
||||
function bandsRendered(thresholds: unknown): ValueBand[] {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
if (thresholds !== undefined) {
|
||||
client.setQueryData(queryKeys.userThresholds(), thresholds);
|
||||
}
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<InsightsOxygenSaturationPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const match = /<pre[^>]*>([^<]*)<\/pre>/.exec(html);
|
||||
const json = (match?.[1] ?? "null").replaceAll(""", '"');
|
||||
return JSON.parse(json) as ValueBand[];
|
||||
}
|
||||
|
||||
const green = (bands: ValueBand[]) =>
|
||||
bands.find((band) => band.color === "var(--success)");
|
||||
|
||||
describe("/insights/oxygen value bands", () => {
|
||||
it("keeps the clinical bands without a personal target", () => {
|
||||
const bands = bandsRendered({
|
||||
effective: {
|
||||
OXYGEN_SATURATION: {
|
||||
range: { greenMin: 95, greenMax: 100, orangeMin: 92, orangeMax: 100 },
|
||||
isOverride: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(bands).toEqual([
|
||||
{ min: 80, max: 90, color: "var(--destructive)", opacity: 0.18 },
|
||||
{ min: 90, max: 95, color: "var(--warning)", opacity: 0.18 },
|
||||
{ min: 95, max: 100, color: "var(--success)", opacity: 0.18 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the clinical bands while the thresholds read is still loading", () => {
|
||||
expect(green(bandsRendered(undefined))).toEqual(
|
||||
expect.objectContaining({ min: 95, max: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shades the person's own band when they set one", () => {
|
||||
const bands = bandsRendered({
|
||||
effective: {
|
||||
OXYGEN_SATURATION: {
|
||||
range: {
|
||||
greenMin: 88,
|
||||
greenMax: 92,
|
||||
orangeMin: 87.4,
|
||||
orangeMax: 92.6,
|
||||
},
|
||||
isOverride: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(green(bands)).toEqual(expect.objectContaining({ min: 88, max: 92 }));
|
||||
// Nothing is shaded above 100 %.
|
||||
expect(Math.max(...bands.map((band) => band.max))).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
import { Wind } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { buildOxygenSaturationBands } from "@/lib/analytics/value-bands";
|
||||
import { useOwnTargetRange } from "@/lib/queries/use-own-target-range";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `/insights/oxygen`.
|
||||
@@ -13,8 +15,12 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
|
||||
* compressing the trend. Withings ScanWatch + Apple Watch both
|
||||
* report SpO₂; the v1.4.25 source-priority picker keeps the surface
|
||||
* deduped.
|
||||
*
|
||||
* The shaded zone follows the person's own SpO₂ target when they set one on
|
||||
* `/targets`, and the clinical bands otherwise.
|
||||
*/
|
||||
export default function InsightsOxygenSaturationPage() {
|
||||
const ownRange = useOwnTargetRange("OXYGEN_SATURATION");
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="OXYGEN_SATURATION"
|
||||
@@ -26,14 +32,9 @@ export default function InsightsOxygenSaturationPage() {
|
||||
color="var(--chart-4)"
|
||||
unit="%"
|
||||
yAxisUnit="%"
|
||||
valueBands={[
|
||||
// < 90 % — clinical hypoxia floor, paint the danger band.
|
||||
{ min: 80, max: 90, color: "var(--destructive)", opacity: 0.18 },
|
||||
// 90 .. 95 % — watch zone.
|
||||
{ min: 90, max: 95, color: "var(--warning)", opacity: 0.18 },
|
||||
// 95 .. 100 % — clinical normal.
|
||||
{ min: 95, max: 100, color: "var(--success)", opacity: 0.18 },
|
||||
]}
|
||||
// The clinical shading (hypoxia floor < 90 %, watch zone to 95 %,
|
||||
// normal above), or the person's own band when they set one.
|
||||
valueBands={buildOxygenSaturationBands(ownRange)}
|
||||
emptyStateIcon={<Wind className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
captureType="OXYGEN_SATURATION"
|
||||
|
||||
+8
-10
@@ -378,10 +378,10 @@ export default function DashboardPageClient({
|
||||
analyticsThickQuery.data,
|
||||
]);
|
||||
|
||||
// v1.34 — the user's own weight target, for the legacy (snapshot-disabled)
|
||||
// band fallback only. In snapshot mode the server resolves the same override
|
||||
// into `targetBands` and this query never fires; the fallback would otherwise
|
||||
// keep shading the height-derived band for someone who has set a target.
|
||||
// The user's own targets, for the legacy (snapshot-disabled) band fallback
|
||||
// only. In snapshot mode the server resolves the same overrides into
|
||||
// `targetBands` and this query never fires; the fallback would otherwise
|
||||
// keep shading the default band for someone who has set a target.
|
||||
const { data: thresholdsData } = useQuery({
|
||||
queryKey: queryKeys.userThresholds(),
|
||||
queryFn: async () => {
|
||||
@@ -754,9 +754,9 @@ export default function DashboardPageClient({
|
||||
// in the snapshot steady state) before this. Keyed on the `user` object
|
||||
// identity so the React Compiler can preserve the memo (a narrower
|
||||
// property list trips `preserve-manual-memoization`).
|
||||
// v1.34 — the weight target rides the same memo. In snapshot mode
|
||||
// v1.34 — the user's own targets ride the same memo. In snapshot mode
|
||||
// `thresholdsData` is never fetched, so the client copy falls back to the
|
||||
// height-derived band for the frames before `serverBands` lands — exactly
|
||||
// default bands for the frames before `serverBands` lands — exactly
|
||||
// as it already did for every other profile-derived number.
|
||||
// v1.36.x — the profile this fallback reads is the CALLER's, and under a
|
||||
// switch the caller is the delegate. `viewerBandProfile` is where that is
|
||||
@@ -771,9 +771,7 @@ export default function DashboardPageClient({
|
||||
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
|
||||
gender: toProfileSex(user?.gender),
|
||||
heightCm: user?.heightCm ?? null,
|
||||
weightTargetOverride: resolveWeightTargetOverride(
|
||||
thresholdsData?.overrides ?? null,
|
||||
),
|
||||
thresholdsJson: thresholdsData?.overrides ?? null,
|
||||
},
|
||||
inSharedRecord,
|
||||
),
|
||||
@@ -791,7 +789,7 @@ export default function DashboardPageClient({
|
||||
const weightTrend =
|
||||
(snapshotEnabled ? snapshotQuery.data?.tiles.weightTrend : undefined) ??
|
||||
resolveWeightTrend(
|
||||
clientBandProfile.weightTargetOverride,
|
||||
resolveWeightTargetOverride(clientBandProfile.thresholdsJson),
|
||||
weightTrendReferenceKg(w),
|
||||
);
|
||||
// v1.43 — every other tile's direction comes resolved from the server
|
||||
|
||||
Reference in new issue
Block a user