"use client"; /** * v1.4.25 W5e — Settings → Sources. * * Per-metric-class source priority. When more than one ingest source * records the same metric for the same day, the analytics aggregator * picks ONE canonical source per day (for cumulative metrics like * steps / sleep duration) or surfaces a "preferred" source (for point * measurements like weight / BP). Non-picked rows stay in the DB as an * audit trail. * * Today (v1.4.25) only WITHINGS + MANUAL coexist; the cumulative-metric * aggregator no-ops because no user has two ingest paths reporting the * same daily total. The UI lands now so v1.5's Apple Health passthrough * drops onto a known foundation — every user's preferences carry * straight into iOS-era analytics without an extra migration step. * * v1.4.25 W8c — two-axis extension. The same screen now hosts three * vertically-stacked sections (Marc no-split directive): * 1. Global default ladder per metric class (existing). * 2. Per-metric override expander — collapsed by default; an * explicit knob for power users (the global ladder already lives * above and most users never open this). * 3. Device-type override expander — collapsed by default; one * global ladder ("watch beats phone beats scale") with optional * per-metric overrides. */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { toast } from "sonner"; import { ArrowDown, ArrowUp, ChevronDown, Layers, Loader2, RotateCcw, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { DEFAULT_DEVICE_TYPE_PRIORITY, DEFAULT_SOURCE_PRIORITY, type DeviceType, type DeviceTypePriority, type MetricPriority, SOURCE_PRIORITY_METRIC_KEYS, type SourcePriorityMetricKey, } from "@/lib/validations/source-priority"; /** * Resolved shape returned by `GET /api/auth/me/source-priority`. Mirrors * `parseSourcePriority()`'s return: every metric ladder is defaulted, * the two-axis containers are present (possibly empty). */ interface ResolvedPriority extends Required { metricPriority: Required; deviceTypePriority: DeviceTypePriority; } const METRIC_LABEL_KEYS: Record = { steps: "settings.sections.sources.metrics.steps", activeEnergy: "settings.sections.sources.metrics.activeEnergy", walkingRunningDistance: "settings.sections.sources.metrics.walkingRunningDistance", flightsClimbed: "settings.sections.sources.metrics.flightsClimbed", sleep: "settings.sections.sources.metrics.sleep", weight: "settings.sections.sources.metrics.weight", bloodPressure: "settings.sections.sources.metrics.bloodPressure", pulse: "settings.sections.sources.metrics.pulse", bodyFat: "settings.sections.sources.metrics.bodyFat", bodyTemperature: "settings.sections.sources.metrics.bodyTemperature", spo2: "settings.sections.sources.metrics.spo2", hrv: "settings.sections.sources.metrics.hrv", restingHeartRate: "settings.sections.sources.metrics.restingHeartRate", vo2Max: "settings.sections.sources.metrics.vo2Max", }; const SOURCE_LABEL_KEYS: Record = { WITHINGS: "settings.sections.sources.sourceLabels.WITHINGS", APPLE_HEALTH: "settings.sections.sources.sourceLabels.APPLE_HEALTH", MANUAL: "settings.sections.sources.sourceLabels.MANUAL", IMPORT: "settings.sections.sources.sourceLabels.IMPORT", }; const DEVICE_TYPE_LABEL_KEYS: Record = { watch: "settings.sections.sources.deviceLabels.watch", band: "settings.sections.sources.deviceLabels.band", ring: "settings.sections.sources.deviceLabels.ring", phone: "settings.sections.sources.deviceLabels.phone", scale: "settings.sections.sources.deviceLabels.scale", other: "settings.sections.sources.deviceLabels.other", unknown: "settings.sections.sources.deviceLabels.unknown", }; export function SourcesSection() { const { t } = useTranslations(); const queryClient = useQueryClient(); const { data: remote, isLoading } = useQuery({ queryKey: queryKeys.sourcePriority(), queryFn: async () => { const res = await fetch("/api/auth/me/source-priority"); if (!res.ok) throw new Error("failed"); const json = await res.json(); return json.data as ResolvedPriority; }, }); // Local draft state — same pattern as ``. The // user reorders sources via the up/down arrows; nothing hits the // network until they click Save. const [draft, setDraft] = useState(null); const priority = draft ?? remote ?? null; const [showPerMetric, setShowPerMetric] = useState(false); const [showDeviceType, setShowDeviceType] = useState(false); const saveMutation = useMutation({ mutationFn: async (next: ResolvedPriority) => { // PUT the W8c-canonical shape: nested `metricPriority` + // `deviceTypePriority` together. The server's // `parseSourcePriority` echoes the resolved shape back so the // optimistic update is just a `setQueryData`. const body = { metricPriority: next.metricPriority, deviceTypePriority: next.deviceTypePriority, }; const res = await fetch("/api/auth/me/source-priority", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error("save failed"); return (await res.json()).data as ResolvedPriority; }, onSuccess: (saved) => { queryClient.setQueryData(queryKeys.sourcePriority(), saved); // The analytics aggregator folds source priority into per-day // canonical-row picks — flush its cache so the charts re-paint // with the new picker on the next mount. queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); setDraft(null); toast.success(t("settings.sections.sources.saveSuccess")); }, onError: () => toast.error(t("settings.sections.sources.saveError")), }); const resetMutation = useMutation({ mutationFn: async () => { // Sending the empty W8c shape clears every per-user override; the // server's `parseSourcePriority` then returns the constant // defaults verbatim. Keeps the wire payload tiny. const res = await fetch("/api/auth/me/source-priority", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); if (!res.ok) throw new Error("reset failed"); return (await res.json()).data as ResolvedPriority; }, onSuccess: (saved) => { queryClient.setQueryData(queryKeys.sourcePriority(), saved); queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); setDraft(null); toast.success(t("settings.sections.sources.resetSuccess")); }, }); /** * Shared adjacent-swap for both ladder buckets. Returns a copy of * `list` with `index` and `index+delta` swapped, or `null` when the * target index falls outside the array. The helper deliberately stays * generic (typed via `T`) so both the source ladder (string[]) and * the device-type ladder (DeviceType[]) share one implementation — * the previous moveSource + moveDeviceType pair drifted apart twice * during W8c before they were re-aligned. */ function reorderLadder( list: readonly T[], index: number, delta: -1 | 1, ): T[] | null { const targetIdx = index + delta; if (targetIdx < 0 || targetIdx >= list.length) return null; const next = [...list]; [next[index], next[targetIdx]] = [next[targetIdx], next[index]]; return next; } function moveSource( metric: SourcePriorityMetricKey, index: number, delta: -1 | 1, ) { if (!priority) return; const current = priority.metricPriority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric]; const list = reorderLadder(current, index, delta); if (!list) return; const nextMetric = { ...priority.metricPriority, [metric]: list }; setDraft({ ...priority, ...nextMetric, metricPriority: nextMetric, }); } function moveDeviceType( /** `null` for the global ladder, MeasurementType-enum string otherwise. */ bucket: string | null, index: number, delta: -1 | 1, ) { if (!priority) return; const key = bucket ?? "default"; const current = priority.deviceTypePriority[key] ?? DEFAULT_DEVICE_TYPE_PRIORITY; const list = reorderLadder(current, index, delta); if (!list) return; const nextDevicePriority: DeviceTypePriority = { ...priority.deviceTypePriority, [key]: list, }; setDraft({ ...priority, deviceTypePriority: nextDevicePriority }); } function resetMetricToDefault(metric: SourcePriorityMetricKey) { if (!priority) return; const nextMetric: Required = { ...priority.metricPriority, [metric]: [...DEFAULT_SOURCE_PRIORITY[metric]], }; setDraft({ ...priority, ...nextMetric, metricPriority: nextMetric, }); } function resetDeviceTypeAxis() { if (!priority) return; setDraft({ ...priority, deviceTypePriority: {} }); } const dirty = draft !== null && priority !== null; // The per-metric expander shows every metric whose ladder has been // overridden, plus a hint when none has. Counter lives next to the // expander label so the section "asks" for attention proportional // to its setting. const overriddenMetrics = useMemo(() => { if (!priority) return [] as SourcePriorityMetricKey[]; return SOURCE_PRIORITY_METRIC_KEYS.filter((metric) => { const list = priority.metricPriority[metric]; const defaultList = DEFAULT_SOURCE_PRIORITY[metric]; if (!list || list.length !== defaultList.length) return true; return list.some((s, i) => s !== defaultList[i]); }); }, [priority]); // Count distinct overrides the user has set on the device-type axis. // `default` counts as one if present; every per-metric override // (`WEIGHT`, `ACTIVITY_STEPS`, …) counts as one more. The number drives // the expander label so the section is self-explanatory at a glance. const deviceTypeOverrideCount = useMemo(() => { if (!priority) return 0; return Object.keys(priority.deviceTypePriority).length; }, [priority]); return (

{t("settings.sections.sources.title")}

{t("settings.sections.sources.description")}

{t("settings.sections.sources.cardTitle")}

{t("settings.sections.sources.help")}

{isLoading || !priority ? (
{t("common.loading")}
) : (
{SOURCE_PRIORITY_METRIC_KEYS.map((metric) => { const list = priority.metricPriority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric]; return (
{t(METRIC_LABEL_KEYS[metric])}
    {list.map((source, index) => (
  • {index + 1}. {t(SOURCE_LABEL_KEYS[source] ?? source)}
  • ))}
); })}
)} {/* ── v1.4.25 W8c — per-metric override expander ── Collapsed by default. The global ladder above already covers every metric; this section lets a power user reset a single metric back to the constant default in one click. */} {priority && (
{showPerMetric && (

{t("settings.sections.sources.perMetricHelp")}

{overriddenMetrics.length === 0 ? (

{t("settings.sections.sources.perMetricEmpty")}

) : (
    {overriddenMetrics.map((metric) => (
  • {t(METRIC_LABEL_KEYS[metric])}
  • ))}
)}
)}
)} {/* ── v1.4.25 W8c — device-type override expander ── One global ladder ("watch beats phone beats scale"). When multiple device-types stream the same source's metric (Apple Watch + iPhone → APPLE_HEALTH steps), the higher- ranked device-type wins. Collapsed by default. */} {priority && (
{showDeviceType && (

{t("settings.sections.sources.deviceTypeHelp")}

{t("settings.sections.sources.deviceTypeDefault")}
    {( priority.deviceTypePriority.default ?? DEFAULT_DEVICE_TYPE_PRIORITY ).map((deviceType, index, list) => (
  • {index + 1}. {t(DEVICE_TYPE_LABEL_KEYS[deviceType])}
  • ))}
)}
)} {dirty && priority && (
)}
); }