List only the sources an account uses on the source-priority page

The read and save of /api/auth/me/source-priority now carry inUse: the
ranked sources the account has connected or holds at least one
measurement from, one index probe per source. The settings page lists
only those, hides a metric nothing delivers, and opens one metric group
at a time, the first by default, with the winning source named on each
collapsed line. The ladders stay whole: moving a source swaps it with
its nearest listed neighbour, so a hidden source keeps its slot.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 20:40:39 +02:00
1 parent c816946140
commit 51374274aa
15 files changed
+956 -1475

No files matched your search

@@ -0,0 +1,72 @@
/**
* Settings → Source priority lists only the sources the account has connected
* or holds data from, and opens one metric group at a time. The ladders it
* edits stay whole: a hidden source keeps its slot when a visible one moves.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { parseSourcePriority } from "@/lib/validations/source-priority";
import { moveAmongVisible, SourcesSection } from "../sources-section";
function render(inUse: string[] | undefined): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0, staleTime: Infinity } },
});
client.setQueryData(queryKeys.sourcePriority(), {
...parseSourcePriority(null),
...(inUse ? { inUse } : {}),
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<SourcesSection />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("Settings → Source priority, sources in use", () => {
it("lists only metrics and sources the account uses", () => {
const html = render(["WITHINGS"]);
expect(html).toContain('data-testid="sources-metric-weight"');
// WHOOP-only metric classes have nothing in use here.
expect(html).not.toContain('data-testid="sources-metric-stress"');
expect(html).not.toContain("Apple Health");
});
it("opens the first group and collapses the rest", () => {
const html = render(["WITHINGS", "MANUAL"]);
const expanded = html.match(/aria-expanded="true"/g) ?? [];
expect(expanded).toHaveLength(1);
expect(html).toContain('aria-expanded="false"');
});
it("says so when no source is in use at all", () => {
expect(render([])).toContain('data-testid="sources-none-in-use"');
});
it("lists every source when the server sends no in-use list", () => {
expect(render(undefined)).toContain("Apple Health");
});
});
describe("moveAmongVisible", () => {
const visible = (s: string) => s !== "B";
it("swaps past a hidden neighbour, keeping its slot", () => {
expect(moveAmongVisible(["A", "B", "C"], "C", -1, visible)).toEqual([
"C",
"B",
"A",
]);
});
it("refuses a move with no visible neighbour", () => {
expect(moveAmongVisible(["A", "B", "C"], "A", -1, visible)).toBeNull();
expect(moveAmongVisible(["A", "B"], "A", 1, visible)).toBeNull();
});
});
+169 -70
View File
@@ -69,6 +69,38 @@ import { apiGet, apiPut } from "@/lib/api/api-fetch";
interface ResolvedPriority extends Required<MetricPriority> {
metricPriority: Required<MetricPriority>;
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<MetricPriority>[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<T>(
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<SourcePriorityMetricKey, string> = {
@@ -207,13 +239,13 @@ export function SourcesSection() {
function moveSource(
metric: SourcePriorityMetricKey,
index: number,
source: LadderSource,
delta: -1 | 1,
) {
if (!priority) return;
const current =
priority.metricPriority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric];
const list = reorderLadder(current, index, delta);
const list = moveAmongVisible(current, source, delta, isSourceVisible);
if (!list) return;
const nextMetric = { ...priority.metricPriority, [metric]: list };
setDraft({
@@ -262,6 +294,46 @@ export function SourcesSection() {
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<SourcePriorityMetricKey>
>(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
@@ -303,69 +375,100 @@ export function SourcesSection() {
{isLoading || !priority ? (
<SourcesSkeletonList />
) : visibleMetrics.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
data-testid="sources-none-in-use"
title={t("settings.sections.sources.noneInUse")}
/>
) : (
<div className="space-y-3">
{SOURCE_PRIORITY_METRIC_KEYS.map((metric) => {
const list =
{visibleMetrics.map((metric) => {
const list = (
priority.metricPriority[metric] ??
DEFAULT_SOURCE_PRIORITY[metric];
DEFAULT_SOURCE_PRIORITY[metric]
).filter(isSourceVisible);
const open = isMetricOpen(metric);
const panelId = `sources-metric-${metric}`;
return (
<div
key={metric}
className="border-border bg-background/30 space-y-2 rounded-md border p-3"
data-testid={`sources-metric-${metric}`}
className="border-border bg-background/30 rounded-md border"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
<button
type="button"
onClick={() => toggleMetric(metric)}
aria-expanded={open}
aria-controls={panelId}
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 rounded-md px-3 py-2 text-left transition-colors"
>
<ChevronDown
aria-hidden="true"
className={`text-muted-foreground size-4 shrink-0 transition-transform ${
open ? "rotate-180" : ""
}`}
/>
<span className="min-w-0 flex-1 text-sm font-medium">
{t(METRIC_LABEL_KEYS[metric])}
</span>
</div>
<ul className="space-y-1">
{list.map((source, index) => (
<li
key={`${metric}-${source}-${index}`}
className="border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5"
>
<span className="text-muted-foreground w-5 text-xs font-medium tabular-nums">
{index + 1}.
</span>
<span className="flex-1 text-sm">
{t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])}
</span>
{/* Up/down sit side by side at every width: two
44 px buttons leave a 390 px row ~170 px for the
source name. Stacking them on phones (v1.4.27
MB2) doubled every row and pushed this page past
twelve thousand pixels. */}
<div className="flex shrink-0 gap-1 sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, index, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, index, 1)}
disabled={
index === list.length - 1 ||
saveMutation.isPending
}
aria-label={t("settings.sections.sources.moveDown")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
{/* Collapsed, the group still says who wins. */}
{!open && list[0] ? (
<span className="text-muted-foreground shrink-0 truncate text-xs">
{t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[list[0]])}
</span>
) : null}
</button>
{open ? (
<ul id={panelId} className="space-y-1 px-3 pb-3">
{list.map((source, index) => (
<li
key={`${metric}-${source}`}
className="border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5"
>
<span className="text-muted-foreground w-5 text-xs font-medium tabular-nums">
{index + 1}.
</span>
<span className="flex-1 text-sm">
{t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])}
</span>
{/* Up/down sit side by side at every width: two
44 px buttons leave a 390 px row ~170 px for the
source name. */}
<div className="flex shrink-0 gap-1 sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, source, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, source, 1)}
disabled={
index === list.length - 1 ||
saveMutation.isPending
}
aria-label={t(
"settings.sections.sources.moveDown",
)}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
) : null}
</div>
);
})}
@@ -607,9 +710,9 @@ export function SourcesSection() {
/**
* 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
* 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() {
@@ -619,17 +722,13 @@ function SourcesSkeletonList() {
data-testid="sources-skeleton"
aria-hidden="true"
>
{SOURCE_PRIORITY_METRIC_KEYS.map((metric) => (
<div
key={metric}
className="border-border bg-background/30 space-y-2 rounded-md border p-3"
>
<Skeleton className="h-4 w-40" />
<div className="space-y-1">
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
</div>
<div className="border-border bg-background/30 space-y-2 rounded-md border p-3">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-14 w-full rounded-md" />
<Skeleton className="h-14 w-full rounded-md" />
</div>
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-11 w-full rounded-md" />
))}
</div>
);