"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 (the maintainer 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 Link from "next/link"; 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 { Skeleton } from "@/components/ui/skeleton"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; 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"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; /** * 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", // v1.11.0 — WHOOP-overlapping metric classes + native-vs-derived recovery. skinTemperature: "settings.sections.sources.metrics.skinTemperature", respiratoryRate: "settings.sections.sources.metrics.respiratoryRate", recovery: "settings.sections.sources.metrics.recovery", // v1.18.10 I-5 — native-vs-derived stress ladder. stress: "settings.sections.sources.metrics.stress", // v1.25.0 — native-vs-computed sleep-debt ladder. sleepDebt: "settings.sections.sources.metrics.sleepDebt", }; 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", // v1.11.0 — WHOOP native source + the COMPUTED proxy (surfaces in the // `recovery` ladder for native-vs-derived ordering). WHOOP: "settings.sections.sources.sourceLabels.WHOOP", COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED", // v1.12.0 — Fitbit/Pixel native source. FITBIT: "settings.sections.sources.sourceLabels.FITBIT", }; 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", }; /** * `` — the per-metric source-priority + device-type ladders. * * v1.18.1 (D4) — Sources is a standalone `/settings/sources` left-side entry * again (split back out of the Integrations sub-tabs). It renders its own * section header + the cross-link back to Integrationen. */ export function SourcesSection() { const { t } = useTranslations(); const queryClient = useQueryClient(); const { data: remote, isLoading } = useQuery({ queryKey: queryKeys.sourcePriority(), queryFn: async () => { return apiGet("/api/auth/me/source-priority"); }, }); // 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, }; return apiPut("/api/auth/me/source-priority", body); }, 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. return apiPut("/api/auth/me/source-priority", {}); }, 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]); // v1.18.6 (W9) — the visible heading + subtitle now come from the shared // `` (the off-style muted intro folded into the // standard subtitle). The quiet cross-link back to Integrationen — where // the sources this ladder ranks are added / removed — stays inside the // card, below the ladder. return (
resetMutation.mutate()} disabled={resetMutation.isPending || saveMutation.isPending} > {t("settings.sections.sources.resetDefaults")} } /> {isLoading || !priority ? ( ) : (
{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.27 R3d MB2 — stack up/down vertically on narrow viewports so each button keeps the 44 px floor without crowding the row. From `sm:` up the two buttons sit side-by-side again to preserve the desktop layout. */}
  • ))}
); })}
)} {/* ── 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])} {/* v1.4.27 R3d MB2 — same stacked-on-mobile shape as the metric source rows above. */}
  • ))}
)}
)} {/* `flex-col-reverse` mirrors the DialogFooter idiom: the primary action stacks on top on mobile, row order stays Cancel → Save on `sm+`. */} {dirty && priority && (
)} {/* v1.18.6 (W9) — quiet pointer back to Integrationen, where the sources this ladder ranks are connected / removed. Folded into the card so the page top stays the standard heading + subtitle. */}

{t("settings.sections.sources.integrationsHint")}{" "} {t("settings.sections.sources.integrationsHintLink")}

); } /** * Skeleton placeholder rendered while `/api/auth/me/source-priority` * is in flight. Reserves one row per `SOURCE_PRIORITY_METRIC_KEYS` * entry at roughly the loaded height so the page does not jump when * the fetched ladder list swaps in. The pulsing animation honours * `prefers-reduced-motion` via Tailwind's `motion-reduce:animate-none`. */ function SourcesSkeletonList() { return ( ); }