Files
HealthLog/src/components/dashboard/range-display.tsx
T
Marc-André Bombeck 410c62c292 refactor(jobs,dashboard): a thin worker registry and a composed dashboard
The reminder worker becomes a registry over twelve queue modules cut
along the existing test structure, and the dashboard page sheds its
quick-entry sheets, header and range logic into components — same
behaviour, pinned by the existing suites.
2026-06-11 00:10:01 +02:00

118 lines
3.8 KiB
TypeScript

/**
* Range-display helpers for the dashboard tile strip and chart grid:
* traffic-light colour classes, the colour-legend hint block, the
* minutes→hours sleep projection, and the timezone-aware greeting hour.
*
* Extracted from the dashboard page; the composition (which tile uses
* which range) stays there.
*/
import type * as React from "react";
import type { TrafficRange } from "@/lib/analytics/value-bands";
import type { DataSummary } from "@/lib/analytics/trends";
export interface RangeDisplayConfig {
range: TrafficRange | null;
}
/**
* v1.11.4 — minutes→hours projection of a `DataSummary` for the sleep
* tile. The server emits `summaries.SLEEP_DURATION` as per-night minutes;
* the tile renders hours. Every value-bearing field (latest / min / max /
* mean / median / avg7 / avg30 / avg30LastMonth / avg30LastYear) divides
* by 60; the slope tuples scale their `slope` (per-day rate) by the same
* factor so the trend arrow stays consistent; count / direction /
* confidence / anomalyCount are unit-free and pass through.
*/
export function toHoursSummary(s: DataSummary): DataSummary {
const h = (v: number | null | undefined): number | null =>
v == null ? null : Math.round((v / 60) * 100) / 100;
const scaleSlope = (slope: DataSummary["slope7"]): DataSummary["slope7"] =>
slope == null
? null
: { ...slope, slope: Math.round((slope.slope / 60) * 1000) / 1000 };
return {
...s,
latest: h(s.latest),
min: h(s.min),
max: h(s.max),
mean: h(s.mean),
median: h(s.median),
avg7: h(s.avg7),
avg30: h(s.avg30),
avg30LastMonth: h(s.avg30LastMonth),
avg30LastYear: h(s.avg30LastYear),
slope7: scaleSlope(s.slope7),
slope30: scaleSlope(s.slope30),
slope90: scaleSlope(s.slope90),
};
}
export function getHourForTimeZone(timeZone?: string): number {
const now = new Date();
if (!timeZone) return now.getHours();
try {
const parts = new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
hour12: false,
timeZone,
}).formatToParts(now);
const hourPart = parts.find((part) => part.type === "hour")?.value;
const parsed = hourPart ? Number(hourPart) : Number.NaN;
return Number.isNaN(parsed) ? now.getHours() : parsed;
} catch {
return now.getHours();
}
}
export function getRangeColorClass(
value: number | null | undefined,
config: RangeDisplayConfig,
): string | undefined {
const range = config.range;
if (value == null || !range) return undefined;
const inGreen = value >= range.greenMin && value <= range.greenMax;
const inOrange =
!inGreen && value >= range.orangeMin && value <= range.orangeMax;
if (inGreen) return "text-success";
if (inOrange) return "text-warning";
return "text-destructive";
}
export function getRangeHint(
unit: string,
config: RangeDisplayConfig,
t: (key: string) => string,
formatNumber: (value: number, fractionDigits?: number) => string,
): React.ReactNode | undefined {
const range = config.range;
if (!range) return undefined;
const format = (value: number) => formatNumber(value, 1);
return (
<>
<p>
<span className="text-success font-bold">{t("charts.colorGreen")}</span>{" "}
{format(range.greenMin)}-{format(range.greenMax)} {unit}
</p>
<p>
<span className="text-warning font-bold">
{t("charts.colorOrange")}
</span>{" "}
{format(range.orangeMin)}-{format(range.greenMin)} {t("common.or")}{" "}
{format(range.greenMax)}-{format(range.orangeMax)} {unit}
</p>
<p>
<span className="text-destructive font-bold">
{t("charts.colorRed")}
</span>{" "}
{"< "}
{format(range.orangeMin)} {t("common.or")} {"> "}
{format(range.orangeMax)} {unit}
</p>
</>
);
}