"use client"; /** * v1.4.25 — 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 — 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 { showErrorToast } from "@/lib/errors/error-toast"; import { ArrowDown, ArrowUp, ChevronDown, Layers, Loader2, RotateCcw, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { Skeleton } from "@/components/ui/skeleton"; import { EmptyState } from "@/components/ui/empty-state"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS } from "@/lib/i18n/source-labels"; import { queryKeys, refetchInactiveDailyReads } 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; /** * The ranked sources this account has connected or holds data from. The * editor lists only these; the ladders it edits keep every source. */ inUse?: string[]; } type LadderSource = Required[SourcePriorityMetricKey][number]; /** * Move `source` one step past its nearest VISIBLE neighbour in `list`. * * The editor hides sources the account does not use, so an adjacent swap on * the full ladder would trade places with an invisible entry and look like a * button that did nothing. Swapping with the next visible entry keeps the * hidden ones in their slots. Returns `null` when there is no visible * neighbour in that direction. */ export function moveAmongVisible( list: readonly T[], source: T, delta: -1 | 1, isVisible: (entry: T) => boolean, ): T[] | null { const from = list.indexOf(source); if (from < 0) return null; let to = from + delta; while (to >= 0 && to < list.length && !isVisible(list[to])) to += delta; if (to < 0 || to >= list.length) return null; const next = [...list]; [next[from], next[to]] = [next[to], next[from]]; return next; } 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", hrvRmssd: "settings.sections.sources.metrics.hrvRmssd", 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 — 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 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 — 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, the per-chart daily aggregates, the // measurement list and the dashboard snapshot all fold source // priority into their per-day canonical-row picks — flush them so // every chart and tile re-paints with the new picker, not only the // analytics report. queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: queryKeys.chartDataRoot() }); queryClient.invalidateQueries({ queryKey: queryKeys.measurements() }); void refetchInactiveDailyReads(queryClient); setDraft(null); toast.success(t("settings.sections.sources.saveSuccess")); }, onError: (failure) => showErrorToast(t("settings.sections.sources.saveError"), { cause: failure, }), }); 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); // Same fan-out as the save arm: the defaults change the canonical-row // picks everywhere the overrides did. queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: queryKeys.chartDataRoot() }); queryClient.invalidateQueries({ queryKey: queryKeys.measurements() }); void refetchInactiveDailyReads(queryClient); 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, source: LadderSource, delta: -1 | 1, ) { if (!priority) return; const current = priority.metricPriority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric]; const list = moveAmongVisible(current, source, delta, isSourceVisible); 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; // An older server sends no `inUse`; then every source stays listed. const remoteInUse = remote?.inUse; const inUse = useMemo( () => (remoteInUse ? new Set(remoteInUse) : null), [remoteInUse], ); function isSourceVisible(source: string): boolean { return inUse === null || inUse.has(source); } // The metrics worth showing: those with at least one source in use. A // metric nobody delivers has nothing to rank. const visibleMetrics = useMemo(() => { if (!priority) return [] as SourcePriorityMetricKey[]; return SOURCE_PRIORITY_METRIC_KEYS.filter((metric) => (priority.metricPriority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric]).some( (source) => inUse === null || inUse.has(source), ), ); }, [priority, inUse]); // Every group starts collapsed except the first, so the page opens on one // worked example instead of twenty open ladders. Toggled groups are // remembered by key; the first group's default is the inverse. const [toggledMetrics, setToggledMetrics] = useState< ReadonlySet >(new Set()); function isMetricOpen(metric: SourcePriorityMetricKey): boolean { const openByDefault = metric === visibleMetrics[0]; return toggledMetrics.has(metric) ? !openByDefault : openByDefault; } function toggleMetric(metric: SourcePriorityMetricKey) { setToggledMetrics((prev) => { const next = new Set(prev); if (next.has(metric)) next.delete(metric); else next.add(metric); return next; }); } // 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 — 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 (

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

{isLoading || !priority ? ( ) : visibleMetrics.length === 0 ? ( ) : (
{visibleMetrics.map((metric) => { const list = ( priority.metricPriority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric] ).filter(isSourceVisible); const open = isMetricOpen(metric); const panelId = `sources-metric-${metric}`; return (
{/* The list stays in the tree, empty and hidden, while the group is closed, so the toggle's aria-controls always names an element. */}
); })}
)} {/* ── 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 && (
)} {/* ── 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 && (
)} {/* v1.18.6 — quiet pointer back to Integrationen, where the sources this ladder ranks are connected / removed. Above the action row: nothing follows a card's actions. */}

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

{/* v1.44 (#1204) — the read-only view of what this ladder decides: which source delivered what on a day, and two sources side by side. Above the action row, like the hint before it. */}

{t("sourceProvenance.settingsHint")}{" "} {t("sourceProvenance.settingsHintLink")}

{/* One action row, last in the card (design standards §12). The reset used to ride the header's status slot, which carries status, not actions; Cancel + Save join it while a draft is open. */} } label={t("settings.sections.sources.resetDefaults")} title={t("settings.sections.sources.resetDefaultsTitle")} body={t("settings.sections.sources.resetDefaultsBody")} confirmLabel={t("settings.sections.sources.resetDefaultsConfirm")} disabled={saveMutation.isPending} pending={resetMutation.isPending} onConfirm={() => resetMutation.mutate()} destructive /> {dirty && priority ? ( <> ) : null}
{/* v1.28 shipped `` here; the 2026-07-17 UX/IA audit (M7) moved it to Settings → Integrations — it is a read-only Apple-Health sync inventory, not a which-connection-wins ranking, so it never belonged on the "source priority" ladder. See `` for the card's new home. */}
); } /** * Skeleton placeholder rendered while `/api/auth/me/source-priority` * is in flight. Mirrors the loaded shape: the first group open with a * couple of rows, the rest collapsed to their header line, so the page does * not jump when the ladders swap in. The pulsing animation honours * `prefers-reduced-motion` via Tailwind's `motion-reduce:animate-none`. */ function SourcesSkeletonList() { return ( ); }