mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
c816946140
commit
51374274aa
15 files changed
+956
-1475
No files matched your search
+503
-1391
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -5460,7 +5460,8 @@
|
||||
"moduleHintLink": "Module verwalten",
|
||||
"detail": "Nur lesend — die Quell-App besitzt die Werte."
|
||||
},
|
||||
"helpDetail": "Die übrigen bleiben für die Audit-Spur gespeichert."
|
||||
"helpDetail": "Die übrigen bleiben für die Audit-Spur gespeichert. Aufgeführt sind nur Quellen, die du verbunden hast oder von denen Daten vorliegen.",
|
||||
"noneInUse": "Noch keine Quelle zum Sortieren. Verbinde eine unter Integrationen oder trage einen Wert ein."
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Anbieter",
|
||||
|
||||
+2
-1
@@ -5460,7 +5460,8 @@
|
||||
"moduleHintLink": "Manage modules",
|
||||
"detail": "Read-only — the source app owns the numbers."
|
||||
},
|
||||
"helpDetail": "The others stay stored for the audit trail."
|
||||
"helpDetail": "The others stay stored for the audit trail. Only sources you have connected or hold data from are listed.",
|
||||
"noneInUse": "No source to rank yet. Connect one under Integrations or record a value."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI provider",
|
||||
|
||||
+2
-1
@@ -5460,7 +5460,8 @@
|
||||
"moduleHintLink": "Gestionar módulos",
|
||||
"detail": "Solo lectura: la app de origen es la dueña de los valores."
|
||||
},
|
||||
"helpDetail": "Las demás se conservan para la traza de auditoría."
|
||||
"helpDetail": "Las demás se conservan para la traza de auditoría. Solo aparecen las fuentes que has conectado o de las que hay datos.",
|
||||
"noneInUse": "Aún no hay fuentes que ordenar. Conecta una en Integraciones o registra un valor."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Proveedor de IA",
|
||||
|
||||
+2
-1
@@ -5460,7 +5460,8 @@
|
||||
"moduleHintLink": "Gérer les modules",
|
||||
"detail": "Lecture seule : l'app source reste propriétaire des valeurs."
|
||||
},
|
||||
"helpDetail": "Les autres restent enregistrées pour la piste d’audit."
|
||||
"helpDetail": "Les autres restent enregistrées pour la piste d’audit. Seules les sources connectées ou ayant fourni des données sont listées.",
|
||||
"noneInUse": "Aucune source à classer pour l'instant. Connectez-en une dans Intégrations ou saisissez une valeur."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Fournisseur d'IA",
|
||||
|
||||
+2
-1
@@ -5460,7 +5460,8 @@
|
||||
"moduleHintLink": "Gestisci moduli",
|
||||
"detail": "Sola lettura: i valori appartengono all'app di origine."
|
||||
},
|
||||
"helpDetail": "Le altre restano memorizzate per la traccia di controllo."
|
||||
"helpDetail": "Le altre restano memorizzate per la traccia di controllo. Sono elencate solo le fonti collegate o con dati.",
|
||||
"noneInUse": "Ancora nessuna fonte da ordinare. Collegane una in Integrazioni o registra un valore."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Provider IA",
|
||||
|
||||
+2
-1
@@ -5460,7 +5460,8 @@
|
||||
"moduleHintLink": "모듈 관리",
|
||||
"detail": "읽기 전용 — 숫자는 원본 앱이 관리해요."
|
||||
},
|
||||
"helpDetail": "나머지는 감사 기록용으로 그대로 보관해요."
|
||||
"helpDetail": "나머지는 감사 기록용으로 그대로 보관해요. 연결했거나 데이터가 있는 소스만 보여요.",
|
||||
"noneInUse": "아직 순위를 정할 소스가 없어요. 연동에서 연결하거나 값을 기록해 보세요."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI 제공자",
|
||||
|
||||
+2
-1
@@ -5460,7 +5460,8 @@
|
||||
"moduleHintLink": "Zarządzaj modułami",
|
||||
"detail": "Tylko do odczytu — wartości należą do aplikacji źródłowej."
|
||||
},
|
||||
"helpDetail": "Pozostałe pozostają zapisane na potrzeby audytu."
|
||||
"helpDetail": "Pozostałe pozostają zapisane na potrzeby audytu. Widać tylko źródła, które połączono lub z których są dane.",
|
||||
"noneInUse": "Nie ma jeszcze źródeł do uszeregowania. Połącz jedno w Integracjach albo wpisz wartość."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Dostawca AI",
|
||||
|
||||
@@ -11,6 +11,10 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/integrations/sources-in-use", () => ({
|
||||
getSourcesInUse: vi.fn().mockResolvedValue(["WITHINGS", "MANUAL"]),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -73,6 +77,17 @@ describe("GET /api/auth/me/source-priority", () => {
|
||||
expect(env.data).toHaveProperty("metricPriority");
|
||||
expect(env.data).toHaveProperty("deviceTypePriority");
|
||||
});
|
||||
|
||||
it("carries the account's sources in use beside the ladders", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
const env = (await res.json()) as { data: { inUse: string[] } };
|
||||
expect(env.data.inUse).toEqual(["WITHINGS", "MANUAL"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/source-priority", () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserProfile } from "@/lib/cache/invalidate";
|
||||
import { getSourcesInUse } from "@/lib/integrations/sources-in-use";
|
||||
import {
|
||||
parseSourcePriority,
|
||||
sourcePrioritySchema,
|
||||
@@ -41,11 +42,19 @@ export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.source-priority.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true },
|
||||
const [row, inUse] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true },
|
||||
}),
|
||||
getSourcesInUse(user.id),
|
||||
]);
|
||||
// `inUse` is read-only context for the editor: which ranked sources this
|
||||
// account has connected or holds data from. It never feeds the ladder.
|
||||
return apiSuccess({
|
||||
...parseSourcePriority(row?.sourcePriorityJson),
|
||||
inUse,
|
||||
});
|
||||
return apiSuccess(parseSourcePriority(row?.sourcePriorityJson));
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (req: Request) => {
|
||||
@@ -118,5 +127,8 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
action: { name: "auth.me.source-priority.put" },
|
||||
meta: { keys: Object.keys(parsed.data).length },
|
||||
});
|
||||
return apiSuccess(parseSourcePriority(parsed.data));
|
||||
return apiSuccess({
|
||||
...parseSourcePriority(parsed.data),
|
||||
inUse: await getSourcesInUse(user.id),
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findFirst: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
withingsConnection: { findUnique: vi.fn() },
|
||||
whoopConnection: { findUnique: vi.fn() },
|
||||
fitbitConnection: { findUnique: vi.fn() },
|
||||
googleHealthConnection: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getSourcesInUse,
|
||||
RANKABLE_SOURCES,
|
||||
} from "@/lib/integrations/sources-in-use";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.googleHealthConnection.findUnique).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("getSourcesInUse", () => {
|
||||
it("lists a source the account holds data from", async () => {
|
||||
vi.mocked(prisma.measurement.findFirst).mockImplementation(((args: {
|
||||
where: { source: string };
|
||||
}) =>
|
||||
Promise.resolve(
|
||||
args.where.source === "MANUAL" ? { id: "m1" } : null,
|
||||
)) as never);
|
||||
|
||||
expect(await getSourcesInUse("u1")).toEqual(["MANUAL"]);
|
||||
});
|
||||
|
||||
it("lists a connected integration that has not delivered yet", async () => {
|
||||
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
|
||||
userId: "u1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
polarAccessTokenEncrypted: "x",
|
||||
ouraAccessTokenEncrypted: null,
|
||||
stravaAccessTokenEncrypted: null,
|
||||
} as never);
|
||||
|
||||
const inUse = await getSourcesInUse("u1");
|
||||
expect(inUse).toContain("WITHINGS");
|
||||
expect(inUse).toContain("POLAR");
|
||||
expect(inUse).not.toContain("OURA");
|
||||
});
|
||||
|
||||
it("lists nothing for an account with no data and no connection", async () => {
|
||||
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
|
||||
expect(await getSourcesInUse("u1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("probes every rankable source exactly once", async () => {
|
||||
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
|
||||
await getSourcesInUse("u1");
|
||||
expect(prisma.measurement.findFirst).toHaveBeenCalledTimes(
|
||||
RANKABLE_SOURCES.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { DEFAULT_SOURCE_PRIORITY } from "@/lib/validations/source-priority";
|
||||
|
||||
/**
|
||||
* Every source a source-priority ladder can rank, in a stable order.
|
||||
*
|
||||
* Derived from the default ladders rather than listed by hand, so a source
|
||||
* added to a ladder is probed without a second edit here.
|
||||
*/
|
||||
export const RANKABLE_SOURCES: readonly MeasurementSource[] = Array.from(
|
||||
new Set(Object.values(DEFAULT_SOURCE_PRIORITY).flat()),
|
||||
) as MeasurementSource[];
|
||||
|
||||
/**
|
||||
* The rankable sources this account actually has: a source it holds at least
|
||||
* one measurement from, or an integration it has connected that has not
|
||||
* delivered yet.
|
||||
*
|
||||
* The Settings ladder lists only these. Ranking a source the person has never
|
||||
* used decides nothing, and ten of them per metric buried the two that do.
|
||||
* The ladders themselves stay whole: hidden sources keep their slot, so a
|
||||
* source that starts delivering later lands where the ladder already put it.
|
||||
*
|
||||
* Each data probe is a `LIMIT 1` on the `(userId, source, measuredAt)` index,
|
||||
* so the cost is one index lookup per rankable source, not a scan.
|
||||
*/
|
||||
export async function getSourcesInUse(
|
||||
userId: string,
|
||||
): Promise<MeasurementSource[]> {
|
||||
const [withData, user, withings, whoop, fitbit, googleHealth] =
|
||||
await Promise.all([
|
||||
Promise.all(
|
||||
RANKABLE_SOURCES.map((source) =>
|
||||
prisma.measurement
|
||||
.findFirst({ where: { userId, source }, select: { id: true } })
|
||||
.then((row) => (row ? source : null)),
|
||||
),
|
||||
),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
polarAccessTokenEncrypted: true,
|
||||
ouraAccessTokenEncrypted: true,
|
||||
stravaAccessTokenEncrypted: true,
|
||||
},
|
||||
}),
|
||||
prisma.withingsConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { userId: true },
|
||||
}),
|
||||
prisma.whoopConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { userId: true },
|
||||
}),
|
||||
prisma.fitbitConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { userId: true },
|
||||
}),
|
||||
prisma.googleHealthConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { userId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const inUse = new Set<MeasurementSource>(
|
||||
withData.filter((s): s is MeasurementSource => s !== null),
|
||||
);
|
||||
if (withings) inUse.add("WITHINGS");
|
||||
if (whoop) inUse.add("WHOOP");
|
||||
if (fitbit) inUse.add("FITBIT");
|
||||
if (googleHealth) inUse.add("GOOGLE_HEALTH");
|
||||
if (user?.polarAccessTokenEncrypted) inUse.add("POLAR");
|
||||
if (user?.ouraAccessTokenEncrypted) inUse.add("OURA");
|
||||
if (user?.stravaAccessTokenEncrypted) inUse.add("STRAVA");
|
||||
|
||||
return RANKABLE_SOURCES.filter((s) => inUse.has(s));
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { notificationPrefsSchema } from "@/lib/validations/notification-prefs";
|
||||
import { sourcePrioritySchema } from "@/lib/validations/source-priority";
|
||||
import { measurementSourceEnum } from "@/lib/validations/measurement";
|
||||
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
|
||||
import { MODULE_KEYS } from "@/lib/modules/registry";
|
||||
import {
|
||||
@@ -204,6 +205,21 @@ export const aiAccountBlock = z
|
||||
"Which AI capabilities the record this session is inside has, and why not when it has none. Resolved on the server for the ACTIVE RECORD and masked to the sections the active grant opens, exactly like `modules` and `moduleAccess`; with no switch (every native request) it describes the caller's own record.",
|
||||
});
|
||||
|
||||
/**
|
||||
* The source-priority read and save both echo the resolved ladders plus
|
||||
* `inUse`: the ranked sources the account has connected or holds data from.
|
||||
* Context for an editor, never part of the stored shape (the PUT body stays
|
||||
* `SourcePriority`).
|
||||
*/
|
||||
const sourcePriorityResolved = sourcePrioritySchema
|
||||
.extend({
|
||||
inUse: z.array(measurementSourceEnum).meta({
|
||||
description:
|
||||
"Ranked sources this account has connected or holds at least one measurement from, in ladder-catalogue order. Read-only; an editor may list only these while the ladders keep every source.",
|
||||
}),
|
||||
})
|
||||
.meta({ id: "ResolvedSourcePriority" });
|
||||
|
||||
const moduleMapEnvelopeInner = z
|
||||
.object({ modules: moduleMapResolved, updatedAt: updatedAtTokenField })
|
||||
.meta({ id: "ModulesResponse" });
|
||||
@@ -1425,7 +1441,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
sourcePrioritySchema,
|
||||
sourcePriorityResolved,
|
||||
"GetSourcePriorityResponse",
|
||||
),
|
||||
},
|
||||
@@ -1449,7 +1465,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
sourcePrioritySchema,
|
||||
sourcePriorityResolved,
|
||||
"PutSourcePriorityResponse",
|
||||
),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user