Merge pull request #299 from MBombeck/release/v1.15.13

v1.15.13 — search, filter, and bulk delete
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-07 19:56:21 +02:00
commit 4895038bcb
34 files changed
+2684 -259

No files matched your search

+7
View File
@@ -2,6 +2,13 @@
## [Unreleased]
## [1.15.13] — 2026-06-07 — search, filter, and bulk delete
### Added
- **Filter your measurements and mood entries.** Both lists now filter by source (manual, Apple Health, Withings, …) and by a date range, alongside the existing type / mood filter and sorting.
- **Select and delete in bulk.** Tick several entries on a page and remove them in one step, with a confirmation step. Deletions stay reversible on your devices the same way a single delete does.
## [1.15.12] — 2026-06-07 — a fairer health score and a richer overview
### Fixed
+78 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.15.12
version: 1.15.13
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -1508,6 +1508,18 @@ paths:
type: string
enum:
- rollup
- in: query
name: sourceEq
schema:
type: string
enum:
- MANUAL
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
- in: query
name: groupBy
schema:
@@ -1698,6 +1710,31 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/measurements/bulk-delete:
post:
tags:
- Measurements
summary: Bulk soft-delete measurements
description: Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the
single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited
`measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is
the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementsBulkDeleteRequest"
responses:
"200":
description: Bulk delete processed.
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementsBulkDeleteResponse"
"401": *a1
"422": *a3
"429": *a4
/api/sleep/night:
get:
tags:
@@ -3998,6 +4035,19 @@ components:
- externalIds
description: iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped
(cross-user 404 guard).
MeasurementsBulkDeleteRequest:
type: object
properties:
ids:
minItems: 1
maxItems: 200
type: array
items:
type: string
minLength: 1
required:
- ids
description: 1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).
DeviceRegisterRequest:
type: object
properties:
@@ -7012,6 +7062,33 @@ components:
required:
- deletedCount
additionalProperties: false
MeasurementsBulkDeleteResponse:
type: object
properties:
data:
$ref: "#/components/schemas/MeasurementsBulkDeleteResult"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MeasurementsBulkDeleteResult:
type: object
properties:
deleted:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- deleted
additionalProperties: false
SleepNightResponse:
type: object
properties:
+32 -2
View File
@@ -494,7 +494,15 @@
"typeEnergyExpenditureKj": "Energieverbrauch",
"typeAverageHeartRate": "Durchschnittlicher Puls",
"typeMaxHeartRate": "Maximaler Puls",
"typeSleepDisturbanceCount": "Schlafstörungen"
"typeSleepDisturbanceCount": "Schlafstörungen",
"filterBySource": "Nach Quelle filtern",
"sourceComputed": "Berechnet",
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
"bulkDeleteSuccess": "{count} Messungen gelöscht",
"bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen",
"bulkDeleteConfirmTitle": "{count} Messungen löschen?",
"bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht."
},
"mood": {
"title": "Stimmung",
@@ -538,6 +546,8 @@
"deleteConfirmTitle": "Eintrag löschen?",
"deleteConfirmDescription": "Dieser Stimmungseintrag wird unwiderruflich gelöscht.",
"pageInfo": "Seite {page} von {total}",
"previousPage": "Vorherige Seite",
"nextPage": "Nächste Seite",
"loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen.",
"note": "Notiz",
"notePlaceholder": "Notiz dazu, wie du dich fühlst…",
@@ -598,7 +608,16 @@
"factorConflict": "Konflikt",
"factorFamily": "Familie",
"factorSadness": "Traurigkeit"
}
},
"filterBySource": "Nach Quelle filtern",
"sourceManual": "Manuell",
"sourceWeb": "Web",
"sourceTelegram": "Telegram",
"sourceDaylio": "Daylio",
"bulkDeleteSuccess": "{count} Einträge gelöscht",
"bulkDeleteError": "Fehler beim Löschen der ausgewählten Einträge",
"bulkDeleteConfirmTitle": "{count} Einträge löschen?",
"bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden endgültig gelöscht."
},
"medications": {
"title": "Medikamente",
@@ -5303,5 +5322,16 @@
"fertileSoonBody": "Dein geschätztes fruchtbares Fenster beginnt voraussichtlich in etwa zwei Tagen. Dies ist eine Schätzung und keine medizinische oder verhütende Garantie.",
"discreetTitle": "HealthLog-Erinnerung",
"discreetBody": "Du hast eine Erinnerung in HealthLog."
},
"dataList": {
"allSources": "Alle Quellen",
"dateFrom": "Von",
"dateTo": "Bis",
"selectAll": "Alle auf dieser Seite auswählen",
"selectRow": "Zeile auswählen",
"selected": "{count} ausgewählt",
"deleteN": "Löschen ({count})",
"clearSelection": "Auswahl aufheben",
"selectionBarLabel": "Auswahlaktionen"
}
}
+32 -2
View File
@@ -494,7 +494,15 @@
"typeEnergyExpenditureKj": "Energy expenditure",
"typeAverageHeartRate": "Average heart rate",
"typeMaxHeartRate": "Maximum heart rate",
"typeSleepDisturbanceCount": "Sleep disturbances"
"typeSleepDisturbanceCount": "Sleep disturbances",
"filterBySource": "Filter by source",
"sourceComputed": "Computed",
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
"bulkDeleteSuccess": "{count} measurements deleted",
"bulkDeleteError": "Error deleting the selected measurements",
"bulkDeleteConfirmTitle": "Delete {count} measurements?",
"bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted."
},
"mood": {
"title": "Mood",
@@ -538,6 +546,8 @@
"deleteConfirmTitle": "Delete entry?",
"deleteConfirmDescription": "This mood entry will be permanently deleted.",
"pageInfo": "Page {page} of {total}",
"previousPage": "Previous page",
"nextPage": "Next page",
"loginRequired": "Please sign in to view mood entries.",
"note": "Note",
"notePlaceholder": "Add a note about how you're feeling…",
@@ -598,7 +608,16 @@
"factorConflict": "conflict",
"factorFamily": "family",
"factorSadness": "sadness"
}
},
"filterBySource": "Filter by source",
"sourceManual": "Manual",
"sourceWeb": "Web",
"sourceTelegram": "Telegram",
"sourceDaylio": "Daylio",
"bulkDeleteSuccess": "{count} entries deleted",
"bulkDeleteError": "Error deleting the selected entries",
"bulkDeleteConfirmTitle": "Delete {count} entries?",
"bulkDeleteConfirmBody": "The {count} selected mood entries will be permanently deleted."
},
"medications": {
"title": "Medications",
@@ -5303,5 +5322,16 @@
"fertileSoonBody": "Your estimated fertile window is predicted to start in about two days. This is an estimate, not a medical or contraceptive guarantee.",
"discreetTitle": "HealthLog reminder",
"discreetBody": "You have a reminder in HealthLog."
},
"dataList": {
"allSources": "All sources",
"dateFrom": "From",
"dateTo": "To",
"selectAll": "Select all on this page",
"selectRow": "Select row",
"selected": "{count} selected",
"deleteN": "Delete ({count})",
"clearSelection": "Clear selection",
"selectionBarLabel": "Selection actions"
}
}
+32 -2
View File
@@ -494,7 +494,15 @@
"typeEnergyExpenditureKj": "Gasto energético",
"typeAverageHeartRate": "Frecuencia cardíaca media",
"typeMaxHeartRate": "Frecuencia cardíaca máxima",
"typeSleepDisturbanceCount": "Interrupciones del sueño"
"typeSleepDisturbanceCount": "Interrupciones del sueño",
"filterBySource": "Filtrar por fuente",
"sourceComputed": "Calculado",
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
"bulkDeleteSuccess": "{count} mediciones eliminadas",
"bulkDeleteError": "Error al eliminar las mediciones seleccionadas",
"bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?",
"bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente."
},
"mood": {
"title": "Estado de ánimo",
@@ -538,6 +546,8 @@
"deleteConfirmTitle": "¿Eliminar entrada?",
"deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará de forma permanente.",
"pageInfo": "Página {page} de {total}",
"previousPage": "Página anterior",
"nextPage": "Página siguiente",
"loginRequired": "Inicia sesión para ver tus estados de ánimo.",
"note": "Nota",
"notePlaceholder": "Añade una nota sobre cómo te sientes…",
@@ -598,7 +608,16 @@
"factorConflict": "conflicto",
"factorFamily": "familia",
"factorSadness": "tristeza"
}
},
"filterBySource": "Filtrar por fuente",
"sourceManual": "Manual",
"sourceWeb": "Web",
"sourceTelegram": "Telegram",
"sourceDaylio": "Daylio",
"bulkDeleteSuccess": "{count} entradas eliminadas",
"bulkDeleteError": "Error al eliminar las entradas seleccionadas",
"bulkDeleteConfirmTitle": "¿Eliminar {count} entradas?",
"bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán de forma permanente."
},
"medications": {
"title": "Medicamentos",
@@ -5303,5 +5322,16 @@
"fertileSoonBody": "Se prevé que tu ventana fértil estimada comience en unos dos días. Es una estimación, no una garantía médica ni anticonceptiva.",
"discreetTitle": "Recordatorio de HealthLog",
"discreetBody": "Tienes un recordatorio en HealthLog."
},
"dataList": {
"allSources": "Todas las fuentes",
"dateFrom": "Desde",
"dateTo": "Hasta",
"selectAll": "Seleccionar todo en esta página",
"selectRow": "Seleccionar fila",
"selected": "{count} seleccionados",
"deleteN": "Eliminar ({count})",
"clearSelection": "Borrar selección",
"selectionBarLabel": "Acciones de selección"
}
}
+32 -2
View File
@@ -494,7 +494,15 @@
"typeEnergyExpenditureKj": "Dépense énergétique",
"typeAverageHeartRate": "Fréquence cardiaque moyenne",
"typeMaxHeartRate": "Fréquence cardiaque maximale",
"typeSleepDisturbanceCount": "Perturbations du sommeil"
"typeSleepDisturbanceCount": "Perturbations du sommeil",
"filterBySource": "Filtrer par source",
"sourceComputed": "Calculé",
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
"bulkDeleteSuccess": "{count} mesures supprimées",
"bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées",
"bulkDeleteConfirmTitle": "Supprimer {count} mesures ?",
"bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées."
},
"mood": {
"title": "Humeur",
@@ -538,6 +546,8 @@
"deleteConfirmTitle": "Supprimer l’entrée ?",
"deleteConfirmDescription": "Cette entrée d’humeur sera supprimée définitivement.",
"pageInfo": "Page {page} sur {total}",
"previousPage": "Page précédente",
"nextPage": "Page suivante",
"loginRequired": "Connecte-toi pour voir tes entrées d’humeur.",
"note": "Note",
"notePlaceholder": "Ajoutez une note sur votre ressenti…",
@@ -598,7 +608,16 @@
"factorConflict": "conflit",
"factorFamily": "famille",
"factorSadness": "tristesse"
}
},
"filterBySource": "Filtrer par source",
"sourceManual": "Manuel",
"sourceWeb": "Web",
"sourceTelegram": "Telegram",
"sourceDaylio": "Daylio",
"bulkDeleteSuccess": "{count} entrées supprimées",
"bulkDeleteError": "Erreur lors de la suppression des entrées sélectionnées",
"bulkDeleteConfirmTitle": "Supprimer {count} entrées ?",
"bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront définitivement supprimées."
},
"medications": {
"title": "Médicaments",
@@ -5303,5 +5322,16 @@
"fertileSoonBody": "Votre fenêtre fertile estimée devrait commencer dans environ deux jours. Il s'agit d'une estimation, et non d'une garantie médicale ou contraceptive.",
"discreetTitle": "Rappel HealthLog",
"discreetBody": "Vous avez un rappel dans HealthLog."
},
"dataList": {
"allSources": "Toutes les sources",
"dateFrom": "Du",
"dateTo": "Au",
"selectAll": "Tout sélectionner sur cette page",
"selectRow": "Sélectionner la ligne",
"selected": "{count} sélectionné(s)",
"deleteN": "Supprimer ({count})",
"clearSelection": "Effacer la sélection",
"selectionBarLabel": "Actions de sélection"
}
}
+32 -2
View File
@@ -494,7 +494,15 @@
"typeEnergyExpenditureKj": "Dispendio energetico",
"typeAverageHeartRate": "Frequenza cardiaca media",
"typeMaxHeartRate": "Frequenza cardiaca massima",
"typeSleepDisturbanceCount": "Disturbi del sonno"
"typeSleepDisturbanceCount": "Disturbi del sonno",
"filterBySource": "Filtra per fonte",
"sourceComputed": "Calcolato",
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
"bulkDeleteSuccess": "{count} misurazioni eliminate",
"bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate",
"bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?",
"bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente."
},
"mood": {
"title": "Umore",
@@ -538,6 +546,8 @@
"deleteConfirmTitle": "Eliminare la voce?",
"deleteConfirmDescription": "Questa voce dell’umore sarà eliminata in modo definitivo.",
"pageInfo": "Pagina {page} di {total}",
"previousPage": "Pagina precedente",
"nextPage": "Pagina successiva",
"loginRequired": "Accedi per vedere le voci dell’umore.",
"note": "Nota",
"notePlaceholder": "Aggiungi una nota su come ti senti…",
@@ -598,7 +608,16 @@
"factorConflict": "conflitto",
"factorFamily": "famiglia",
"factorSadness": "tristezza"
}
},
"filterBySource": "Filtra per fonte",
"sourceManual": "Manuale",
"sourceWeb": "Web",
"sourceTelegram": "Telegram",
"sourceDaylio": "Daylio",
"bulkDeleteSuccess": "{count} voci eliminate",
"bulkDeleteError": "Errore durante l'eliminazione delle voci selezionate",
"bulkDeleteConfirmTitle": "Eliminare {count} voci?",
"bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate definitivamente."
},
"medications": {
"title": "Farmaci",
@@ -5303,5 +5322,16 @@
"fertileSoonBody": "La tua finestra fertile stimata dovrebbe iniziare tra circa due giorni. È una stima, non una garanzia medica o contraccettiva.",
"discreetTitle": "Promemoria HealthLog",
"discreetBody": "Hai un promemoria in HealthLog."
},
"dataList": {
"allSources": "Tutte le fonti",
"dateFrom": "Dal",
"dateTo": "Al",
"selectAll": "Seleziona tutto in questa pagina",
"selectRow": "Seleziona riga",
"selected": "{count} selezionati",
"deleteN": "Elimina ({count})",
"clearSelection": "Cancella selezione",
"selectionBarLabel": "Azioni di selezione"
}
}
+32 -2
View File
@@ -494,7 +494,15 @@
"typeEnergyExpenditureKj": "Wydatek energetyczny",
"typeAverageHeartRate": "Średnie tętno",
"typeMaxHeartRate": "Maksymalne tętno",
"typeSleepDisturbanceCount": "Zakłócenia snu"
"typeSleepDisturbanceCount": "Zakłócenia snu",
"filterBySource": "Filtruj według źródła",
"sourceComputed": "Obliczone",
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
"bulkDeleteSuccess": "Usunięto pomiary: {count}",
"bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów",
"bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?",
"bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte."
},
"mood": {
"title": "Nastrój",
@@ -538,6 +546,8 @@
"deleteConfirmTitle": "Usunąć wpis?",
"deleteConfirmDescription": "Ten wpis nastroju zostanie nieodwracalnie usunięty.",
"pageInfo": "Strona {page} z {total}",
"previousPage": "Poprzednia strona",
"nextPage": "Następna strona",
"loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju.",
"note": "Notatka",
"notePlaceholder": "Dodaj notatkę o swoim samopoczuciu…",
@@ -598,7 +608,16 @@
"factorConflict": "konflikt",
"factorFamily": "rodzina",
"factorSadness": "smutek"
}
},
"filterBySource": "Filtruj według źródła",
"sourceManual": "Ręcznie",
"sourceWeb": "Web",
"sourceTelegram": "Telegram",
"sourceDaylio": "Daylio",
"bulkDeleteSuccess": "Usunięto wpisy: {count}",
"bulkDeleteError": "Błąd podczas usuwania wybranych wpisów",
"bulkDeleteConfirmTitle": "Usunąć wpisy ({count})?",
"bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną trwale usunięte."
},
"medications": {
"title": "Leki",
@@ -5303,5 +5322,16 @@
"fertileSoonBody": "Szacowane okno płodności rozpocznie się prawdopodobnie za około dwa dni. To szacunek, a nie gwarancja medyczna ani antykoncepcyjna.",
"discreetTitle": "Przypomnienie HealthLog",
"discreetBody": "Masz przypomnienie w HealthLog."
},
"dataList": {
"allSources": "Wszystkie źródła",
"dateFrom": "Od",
"dateTo": "Do",
"selectAll": "Zaznacz wszystko na tej stronie",
"selectRow": "Zaznacz wiersz",
"selected": "Zaznaczono: {count}",
"deleteN": "Usuń ({count})",
"clearSelection": "Wyczyść zaznaczenie",
"selectionBarLabel": "Akcje zaznaczenia"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.15.12",
"version": "1.15.13",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,15 @@
-- v1.15.13 — source-filter support indexes for the measurements + mood
-- management lists. The new list surfaces add a source filter (and the
-- existing source/value sort). No index leads with `(user_id, source)`
-- today, so a source filter forces a `user_id` scan + in-memory sort —
-- the landmine at 100–200k rows per user. These two additive indexes turn
-- a source filter + recency sort into an index range scan.
--
-- Pure additive `CREATE INDEX IF NOT EXISTS`, no backfill — same low-risk
-- shape as migration 0123.
CREATE INDEX IF NOT EXISTS "measurements_user_id_source_measured_at_idx"
ON "measurements" ("user_id", "source", "measured_at" DESC);
CREATE INDEX IF NOT EXISTS "mood_entries_user_id_source_mood_logged_at_idx"
ON "mood_entries" ("user_id", "source", "mood_logged_at" DESC);
+8
View File
@@ -850,6 +850,10 @@ model Measurement {
// (Postgres semantics) so manual entries don't collide here.
@@unique([userId, type, source, externalId])
@@index([userId, type, measuredAt])
// v1.15.13 — leads with `(userId, source)` so the management list's
// source filter + recency sort is an index range scan instead of a
// `userId` scan + in-memory sort. See migration 0136.
@@index([userId, source, measuredAt(sort: Desc)])
@@index([externalId])
// v1.7.0 — supports the `/api/sync/changes` keyset feed, which walks
// `where userId` ordered by `(updatedAt asc, id asc)` with `take
@@ -2082,6 +2086,10 @@ model MoodEntry {
// null, moodLoggedAt: { gte } }` ordered by `moodLoggedAt desc`; this
// index turns that into a range scan. See migration 0123.
@@index([userId, moodLoggedAt])
// v1.15.13 — leads with `(userId, source)` so the mood management
// list's source filter + recency sort is an index range scan. See
// migration 0136.
@@index([userId, source, moodLoggedAt(sort: Desc)])
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@index([userId, updatedAt, id])
@@ -0,0 +1,182 @@
/**
* v1.15.13 — POST /api/measurements/bulk-delete.
*
* Asserts the ownership-scoped soft-delete contract: only owned, not-
* already-tombstoned rows are touched; a forged / foreign id is a silent
* no-op (no 404 existence leak); the rollup recompute collapses to the
* unique `(type, day)` set instead of firing one recompute per row; the
* >200-id cap returns 422.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
// keep the real collapse so the test exercises the actual fan-out logic
collapseToTypeDayKeys: actual.collapseToTypeDayKeys,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements/bulk-delete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
// `vi.resetAllMocks()` wipes the factory defaults; the best-effort
// post-delete hooks must be re-stubbed to resolved promises (the route
// awaits the rollup recompute and `.catch()`es the insight invalidate).
vi.mocked(recomputeBucketsForMeasurement).mockResolvedValue(undefined);
vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(undefined);
});
describe("POST /api/measurements/bulk-delete", () => {
it("soft-deletes only owned, non-tombstoned rows and returns that count", async () => {
// Caller asked for 3 ids but only 2 are owned + live.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 2,
} as never);
const res = await POST(postReq({ ids: ["m1", "m2", "foreign"] }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { deleted: number } };
expect(body.data.deleted).toBe(2);
// updateMany is ownership-scoped + filters tombstones — a forged id
// never 404s, it's just excluded from the count.
const call = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
expect(call.where).toMatchObject({
id: { in: ["m1", "m2", "foreign"] },
userId: "user-1",
deletedAt: null,
});
expect(call.data).toMatchObject({
syncVersion: { increment: 1 },
});
expect(call.data.deletedAt).toBeInstanceOf(Date);
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1");
});
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
// 4 deleted rows: 2× WEIGHT on the same UTC day, 1× WEIGHT next day,
// 1× PULSE same day as the first → 3 distinct (type, day) keys.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-01-02T09:00:00Z") },
{ type: "PULSE", measuredAt: new Date("2026-01-01T10:00:00Z") },
] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 4,
} as never);
await POST(postReq({ ids: ["a", "b", "c", "d"] }));
// 4 rows in, 3 distinct (type, day) recomputes out.
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
});
it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await POST(postReq({ ids: ["foreign-1", "foreign-2"] }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { deleted: number } };
expect(body.data.deleted).toBe(0);
expect(invalidateUserMeasurements).not.toHaveBeenCalled();
expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled();
});
it("rejects a batch over the 200-id cap with 422", async () => {
const ids = Array.from({ length: 201 }, (_, i) => `m${i}`);
const res = await POST(postReq({ ids }));
expect(res.status).toBe(422);
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
});
it("rejects an empty id array with 422", async () => {
const res = await POST(postReq({ ids: [] }));
expect(res.status).toBe(422);
});
it("429s when the rate limit is exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const res = await POST(postReq({ ids: ["m1"] }));
expect(res.status).toBe(429);
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,142 @@
/**
* `POST /api/measurements/bulk-delete` — multi-select soft-delete for the
* measurements management list (v1.15.13).
*
* Body:
* { ids: string[] } — 1..200 measurement ids, scoped to the caller.
*
* Soft-deletes (tombstones) every owned, not-already-deleted row in one
* `updateMany` — matching the single-DELETE iOS tombstone contract. A
* forged / foreign id is a silent no-op (never a 404 existence leak): the
* mutation `where` pins `userId` so it only ever touches the caller's rows.
*
* Response (200):
* { deleted: <number of rows tombstoned> }
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
} from "@/lib/rollups/measurement-rollups";
import type { MeasurementType } from "@/generated/prisma/client";
const MAX_IDS_PER_BATCH = 200;
const RATE_LIMIT_MAX = 60;
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
const bulkDeleteSchema = z.object({
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
});
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete));
async function postBulkDelete(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`measurements:bulk-delete:${user.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many bulk deletions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = bulkDeleteSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "measurement.bulk-delete.invalid",
});
}
const { ids } = parsed.data;
const now = new Date();
// Read the soon-to-be-tombstoned rows first so we know which
// `(type, day)` buckets need a rollup recompute and which types need
// a status-insight cache drop. Ownership-scoped + `deletedAt: null` so
// a forged / foreign / already-tombstoned id is excluded here exactly
// as it is from the mutation below — no existence leak.
const affected = await prisma.measurement.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: null },
select: { type: true, measuredAt: true },
});
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
// so a forged id for another user is silently a no-op; `deletedAt: null`
// keeps the count honest (an already-tombstoned id doesn't re-count).
const { count } = await prisma.measurement.updateMany({
where: { id: { in: ids }, userId: user.id, deletedAt: null },
data: { deletedAt: now, syncVersion: { increment: 1 } },
});
await auditLog("measurement.delete.bulk", {
userId: user.id,
ipAddress: getClientIp(request),
details: { count },
});
annotate({
action: { name: "measurement.delete.bulk" },
meta: { count },
});
if (count > 0) {
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches so subsequent reads reflect the deletions.
invalidateUserMeasurements(user.id);
// Collapse the deleted rows to the unique `(type, day)` set BEFORE
// recomputing so a 200-row delete spanning one day fires ~1 recompute
// per type, not 200 (mirrors the batch-insert path). Best-effort — a
// populator hiccup never fails the user's delete.
const keys = collapseToTypeDayKeys(
affected.map((row) => ({
type: row.type as MeasurementType,
measuredAt: row.measuredAt,
})),
);
try {
for (const k of keys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
} catch (err) {
console.warn("[measurements] bulk-delete rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows the deletion
// dirties so the next mount / nightly warm pass regenerates against
// the reduced history. Fire-and-forget: never blocks the delete.
const affectedTypes = Array.from(new Set(keys.map((k) => k.type)));
if (affectedTypes.length > 0) {
invalidateStatusInsightsForTypes(user.id, affectedTypes).catch((err) => {
console.warn(
"[measurements] bulk-delete status-insight invalidate failed",
err,
);
});
}
}
return apiSuccess({ deleted: count });
}
+5
View File
@@ -97,6 +97,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
sortDir,
aggregate,
source,
sourceEq,
groupBy,
dayKey,
} = parsed.data;
@@ -110,6 +111,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
// dashboard read paths) must never include them.
deletedAt: null,
...(type && { type: type as MeasurementType }),
// v1.15.13 — management-list source filter. Distinct from the
// `source=rollup` opt-in above (which selects the rollup read tier);
// `sourceEq` narrows the plain list to one ingest source.
...(sourceEq && { source: sourceEq }),
...(from || to
? {
measuredAt: {
@@ -178,6 +178,38 @@ describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
});
});
describe("GET /api/mood-entries — source filter (v1.15.13)", () => {
it("threads a valid source into the list where", async () => {
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0 as never);
const res = await GET(getReq("source=TELEGRAM"));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.moodEntry.findMany).mock.calls[0][0];
expect(call?.where).toMatchObject({
userId: "user-1",
deletedAt: null,
source: "TELEGRAM",
});
});
it("omits source from the where when not supplied", async () => {
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0 as never);
const res = await GET(getReq(""));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.moodEntry.findMany).mock.calls[0][0];
expect(call?.where).not.toHaveProperty("source");
});
it("422s on an unknown source value", async () => {
const res = await GET(getReq("source=BOGUS"));
expect(res.status).toBe(422);
});
});
describe("POST /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `mood` enum + missing `moodLoggedAt`.
@@ -0,0 +1,154 @@
/**
* v1.15.13 — POST /api/mood-entries/bulk-delete.
*
* Asserts the ownership-scoped soft-delete contract: only owned, not-
* already-tombstoned rows are touched; a forged / foreign id is a silent
* no-op (no 404 existence leak); the mood rollup recompute collapses to
* the unique day set; the >200-id cap returns 422.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMood: vi.fn(),
}));
vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/mood-entries/bulk-delete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
// `vi.resetAllMocks()` wipes the factory default; the route awaits the
// best-effort mood-rollup recompute, so re-stub it to a resolved promise.
vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined);
});
describe("POST /api/mood-entries/bulk-delete", () => {
it("soft-deletes only owned, non-tombstoned rows and bumps syncVersion", async () => {
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
{ moodLoggedAt: new Date("2026-01-02T20:00:00Z") },
] as never);
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
count: 2,
} as never);
const res = await POST(postReq({ ids: ["e1", "e2", "foreign"] }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { deleted: number } };
expect(body.data.deleted).toBe(2);
const call = vi.mocked(prisma.moodEntry.updateMany).mock.calls[0][0];
expect(call.where).toMatchObject({
id: { in: ["e1", "e2", "foreign"] },
userId: "user-1",
deletedAt: null,
});
expect(call.data).toMatchObject({ syncVersion: { increment: 1 } });
expect(call.data.deletedAt).toBeInstanceOf(Date);
expect(invalidateUserMood).toHaveBeenCalledWith("user-1");
});
it("collapses the rollup recompute to the unique day set, not per-row", async () => {
// 3 deleted rows across 2 distinct UTC days → 2 recomputes.
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
{ moodLoggedAt: new Date("2026-01-01T22:00:00Z") },
{ moodLoggedAt: new Date("2026-01-02T09:00:00Z") },
] as never);
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
count: 3,
} as never);
await POST(postReq({ ids: ["a", "b", "c"] }));
expect(recomputeMoodBucketsForEntry).toHaveBeenCalledTimes(2);
});
it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => {
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await POST(postReq({ ids: ["foreign-1"] }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { deleted: number } };
expect(body.data.deleted).toBe(0);
expect(invalidateUserMood).not.toHaveBeenCalled();
expect(recomputeMoodBucketsForEntry).not.toHaveBeenCalled();
});
it("rejects a batch over the 200-id cap with 422", async () => {
const ids = Array.from({ length: 201 }, (_, i) => `e${i}`);
const res = await POST(postReq({ ids }));
expect(res.status).toBe(422);
expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled();
});
it("429s when the rate limit is exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const res = await POST(postReq({ ids: ["e1"] }));
expect(res.status).toBe(429);
expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,131 @@
/**
* `POST /api/mood-entries/bulk-delete` — multi-select soft-delete for the
* mood management list (v1.15.13).
*
* Body:
* { ids: string[] } — 1..200 mood-entry ids, scoped to the caller.
*
* Soft-deletes (tombstones) every owned, not-already-deleted row in one
* `updateMany` — matching the single-DELETE iOS tombstone contract. A
* forged / foreign id is a silent no-op (never a 404 existence leak): the
* mutation `where` pins `userId` so it only ever touches the caller's rows.
*
* Response (200):
* { deleted: <number of rows tombstoned> }
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
const MAX_IDS_PER_BATCH = 200;
const RATE_LIMIT_MAX = 60;
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
const bulkDeleteSchema = z.object({
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
});
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete));
async function postBulkDelete(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`mood-entries:bulk-delete:${user.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many bulk deletions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = bulkDeleteSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "mood.bulk-delete.invalid",
});
}
const { ids } = parsed.data;
const now = new Date();
// Read the soon-to-be-tombstoned rows first so we know which days need
// a mood-rollup recompute. Ownership-scoped + `deletedAt: null` so a
// forged / foreign / already-tombstoned id is excluded here exactly as
// it is from the mutation below — no existence leak.
const affected = await prisma.moodEntry.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: null },
select: { moodLoggedAt: true },
});
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
// so a forged id for another user is silently a no-op; `deletedAt: null`
// keeps the count honest (an already-tombstoned id doesn't re-count).
const { count } = await prisma.moodEntry.updateMany({
where: { id: { in: ids }, userId: user.id, deletedAt: null },
data: { deletedAt: now, syncVersion: { increment: 1 } },
});
await auditLog("mood.delete.bulk", {
userId: user.id,
ipAddress: getClientIp(request),
details: { count },
});
annotate({
action: { name: "mood.delete.bulk" },
meta: { count },
});
if (count > 0) {
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
invalidateUserMood(user.id);
// v1.4.39 W-MOOD — collapse the deleted rows to the unique
// `(user, dayStart)` set BEFORE recomputing so a bulk delete spanning
// one day fires one recompute, not one per row. Best-effort — a
// populator hiccup never fails the user's delete.
const touchedDayStarts = new Set<number>();
for (const row of affected) {
const d = row.moodLoggedAt;
touchedDayStarts.add(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
);
}
try {
await Promise.all(
Array.from(touchedDayStarts).map((t) =>
recomputeMoodBucketsForEntry(user.id, new Date(t)),
),
);
} catch (rollupErr) {
annotate({
meta: {
mood_rollup_write_failed: true,
mood_rollup_write_error:
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
},
});
}
}
return apiSuccess({ deleted: count });
}
+4 -1
View File
@@ -71,13 +71,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const { mood, from, to, limit, offset, sortBy, sortDir } = parsed.data;
const { mood, source, from, to, limit, offset, sortBy, sortDir } =
parsed.data;
const where = {
userId: user.id,
// v1.7.0 sync — hide soft-deleted (tombstoned) rows from the list.
deletedAt: null,
...(mood && { mood }),
// v1.15.13 — management-list source filter.
...(source && { source }),
...(from || to
? {
date: {
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import {
toggleId,
toggleSelectAll,
selectAllState,
selectedIdsOnPage,
selectedCountOnPage,
} from "../selection";
/**
* v1.15.13 — pure page-scoped selection math for the measurements + mood
* management lists. These helpers back the multi-select chrome; the tests
* pin the set algebra so the select-all tri-state + the bulk-delete
* payload stay correct without needing a DOM render.
*/
describe("data-list selection math", () => {
describe("toggleId", () => {
it("adds an id that is not present", () => {
const next = toggleId(new Set(["a"]), "b");
expect([...next].sort()).toEqual(["a", "b"]);
});
it("removes an id that is present", () => {
const next = toggleId(new Set(["a", "b"]), "a");
expect([...next]).toEqual(["b"]);
});
it("returns a new set (does not mutate the input)", () => {
const input = new Set(["a"]);
const next = toggleId(input, "b");
expect(next).not.toBe(input);
expect([...input]).toEqual(["a"]);
});
});
describe("selectAllState", () => {
it("is none on an empty page", () => {
expect(selectAllState(new Set(["a"]), [])).toBe("none");
});
it("is none when nothing on the page is selected", () => {
expect(selectAllState(new Set(["x"]), ["a", "b"])).toBe("none");
});
it("is some when at least one but not all are selected", () => {
expect(selectAllState(new Set(["a"]), ["a", "b"])).toBe("some");
});
it("is all when every page id is selected", () => {
expect(selectAllState(new Set(["a", "b"]), ["a", "b"])).toBe("all");
});
it("ignores selected ids that are not on the page", () => {
// `x` is selected but off-page; the page is fully selected.
expect(selectAllState(new Set(["a", "b", "x"]), ["a", "b"])).toBe("all");
});
});
describe("toggleSelectAll", () => {
it("adds every page id when not all are selected", () => {
const next = toggleSelectAll(new Set(["a"]), ["a", "b", "c"]);
expect([...next].sort()).toEqual(["a", "b", "c"]);
});
it("clears the page ids when all are already selected", () => {
const next = toggleSelectAll(new Set(["a", "b"]), ["a", "b"]);
expect([...next]).toEqual([]);
});
it("preserves off-page selection when clearing the page", () => {
const next = toggleSelectAll(new Set(["a", "b", "x"]), ["a", "b"]);
expect([...next]).toEqual(["x"]);
});
it("is a no-op on an empty page", () => {
const next = toggleSelectAll(new Set(["a"]), []);
expect([...next]).toEqual(["a"]);
});
});
describe("selectedIdsOnPage / selectedCountOnPage", () => {
it("returns only selected ids that are present on the page, in page order", () => {
const selected = new Set(["c", "a", "x"]);
const pageIds = ["a", "b", "c"];
expect(selectedIdsOnPage(selected, pageIds)).toEqual(["a", "c"]);
expect(selectedCountOnPage(selected, pageIds)).toBe(2);
});
it("never includes off-page ids in the bulk-delete payload", () => {
const selected = new Set(["off-page-1", "off-page-2"]);
const pageIds = ["a", "b"];
expect(selectedIdsOnPage(selected, pageIds)).toEqual([]);
expect(selectedCountOnPage(selected, pageIds)).toBe(0);
});
});
});
@@ -0,0 +1,76 @@
"use client";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
/**
* Shared single-row delete button + confirm dialog for the data-
* management lists (measurements + mood). Lifted from the two
* copy-pasted `DeleteButton` helpers (v1.15.13).
*
* The confirm copy differs per surface (a measurement vs a mood entry),
* so the title/description are passed in rather than reaching for a fixed
* translation key. `cancelLabel` / `confirmLabel` default to the shared
* `common.*` strings.
*/
export function DeleteButton({
onConfirm,
title,
description,
cancelLabel,
confirmLabel,
className = "size-11",
iconClassName = "h-3.5 w-3.5",
}: {
onConfirm: () => void;
title: string;
description: string;
cancelLabel?: string;
confirmLabel?: string;
className?: string;
iconClassName?: string;
}) {
const { t } = useTranslations();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`text-destructive ${className}`}
aria-label={confirmLabel ?? t("common.delete")}
>
<Trash2 className={iconClassName} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel ?? t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onConfirm}
>
{confirmLabel ?? t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Shared chrome for the data-management lists (measurements + mood).
*
* v1.15.13 — the `SortableHead` + single-row `DeleteButton` were copy-
* pasted between `measurement-list.tsx` and `mood-list.tsx`; they now
* live here once, alongside the new page-scoped multi-select selection
* bar and its pure selection math. Each list keeps its own columns +
* formatting; only the shared chrome lives in this module.
*/
export { SortableHead } from "./sortable-head";
export { DeleteButton } from "./delete-button";
export { SelectionActionBar } from "./selection-action-bar";
export {
toggleId,
toggleSelectAll,
selectAllState,
selectedIdsOnPage,
selectedCountOnPage,
type SelectAllState,
} from "./selection";
@@ -0,0 +1,122 @@
"use client";
import { Loader2, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
/**
* Selection action bar for the data-management lists (measurements +
* mood). Appears when ≥1 row on the current page is selected; shows
* "N selected", a destructive "Delete (N)" button gated behind an
* AlertDialog confirm, and a clear-selection affordance.
*
* Pure / presentational: every behaviour is a callback. The component
* renders nothing when `count === 0`. The bar is sticky to the bottom of
* the viewport so it stays reachable while scrolling a long page, and the
* confirm dialog is focus-trapped by the shared `AlertDialog` primitive.
*
* The confirm copy is passed in because the title/body differ per surface
* (measurements vs mood) and the count is interpolated by the caller.
*/
export function SelectionActionBar({
count,
onClear,
onConfirmDelete,
isDeleting,
confirmTitle,
confirmBody,
}: {
count: number;
onClear: () => void;
onConfirmDelete: () => void;
isDeleting: boolean;
confirmTitle: string;
confirmBody: string;
}) {
const { t } = useTranslations();
if (count <= 0) return null;
return (
<div
role="region"
aria-label={t("dataList.selectionBarLabel")}
// v1.15.13 HIGH-1 — on mobile the fixed bottom-nav (64px, `md:hidden`,
// z-50) plus the iOS home-indicator inset sit over the page's lower
// edge. A `sticky bottom-2` bar lands BEHIND the nav, hiding the
// destructive "Delete (N)" button. Lift the bar to clear the nav band
// + safe area using the same offset the Coach FAB uses
// (`layout-coach-fab.tsx`); at `sm:`/desktop the nav is gone, so drop
// back to `bottom-4`. z-30 keeps the bar above page content without
// needing to beat the (now-cleared) nav's z-50.
className="bg-card border-border sticky bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] z-30 mt-2 flex items-center justify-between gap-2 rounded-lg border p-2 shadow-lg sm:bottom-4"
>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-11"
onClick={onClear}
disabled={isDeleting}
aria-label={t("dataList.clearSelection")}
>
<X className="h-4 w-4" />
</Button>
<span className="text-sm font-medium" aria-live="polite">
{t("dataList.selected", { count: String(count) })}
</span>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="min-h-11"
disabled={isDeleting}
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
{t("dataList.deleteN", { count: String(count) })}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirmTitle}</AlertDialogTitle>
<AlertDialogDescription>{confirmBody}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Pure, framework-free selection math for the page-scoped multi-select on
* the measurements + mood management lists (v1.15.13).
*
* Selection is scoped to the CURRENT PAGE per the v1.15.x list audit
* (§E) — no "select across 200k rows". Every helper here operates on a
* `Set<string>` of row ids that all belong to the rows currently painted,
* so the math stays a few cheap set operations regardless of total count.
*
* Extracted so the selection logic is unit-testable without a DOM /
* React render (`selection.test.ts`).
*/
/** Toggle a single id in/out of the selection set, returning a new Set. */
export function toggleId(selected: ReadonlySet<string>, id: string): Set<string> {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
}
/**
* Header "select all on this page" state.
*
* - `"all"` — every selectable id on the page is selected.
* - `"some"` — at least one but not all are selected (indeterminate).
* - `"none"` — nothing on the page is selected.
*
* A page with no selectable rows is `"none"` (the header checkbox should
* be a no-op then).
*/
export type SelectAllState = "all" | "some" | "none";
export function selectAllState(
selected: ReadonlySet<string>,
pageIds: readonly string[],
): SelectAllState {
if (pageIds.length === 0) return "none";
let selectedOnPage = 0;
for (const id of pageIds) {
if (selected.has(id)) selectedOnPage += 1;
}
if (selectedOnPage === 0) return "none";
if (selectedOnPage === pageIds.length) return "all";
return "some";
}
/**
* Apply a "select all on page" toggle. When every selectable id on the
* page is already selected, the toggle clears the page's ids; otherwise it
* adds the page's ids to the existing selection. Selection from other
* (now-unmounted) pages is dropped on a page change by the caller, so in
* practice `selected` only ever holds current-page ids — but this stays
* correct either way.
*/
export function toggleSelectAll(
selected: ReadonlySet<string>,
pageIds: readonly string[],
): Set<string> {
if (pageIds.length === 0) return new Set(selected);
const allSelected = pageIds.every((id) => selected.has(id));
const next = new Set(selected);
if (allSelected) {
for (const id of pageIds) next.delete(id);
} else {
for (const id of pageIds) next.add(id);
}
return next;
}
/**
* The selected ids intersected with the ids actually present on the page.
* The bulk-delete request only ever sends rows the user can currently see,
* which keeps the payload page-bounded (≤ PAGE_SIZE) and matches the
* server's 200-id cap with room to spare.
*/
export function selectedIdsOnPage(
selected: ReadonlySet<string>,
pageIds: readonly string[],
): string[] {
return pageIds.filter((id) => selected.has(id));
}
/** Count of selected ids that are present on the current page. */
export function selectedCountOnPage(
selected: ReadonlySet<string>,
pageIds: readonly string[],
): number {
let n = 0;
for (const id of pageIds) {
if (selected.has(id)) n += 1;
}
return n;
}
@@ -0,0 +1,50 @@
"use client";
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
import { TableHead } from "@/components/ui/table";
/**
* Shared clickable sort header for the data-management tables
* (measurements + mood). Lifted verbatim from the two copy-pasted
* `SortableHead` definitions (v1.15.13). Domain specifics — which
* columns are sortable, their labels — stay in each list; only this
* chrome moves.
*/
export function SortableHead({
column,
label,
currentSort,
currentDir,
onSort,
className,
}: {
column: string;
label: string;
currentSort: string;
currentDir: "asc" | "desc";
onSort: (col: string) => void;
className?: string;
}) {
const isActive = currentSort === column;
return (
<TableHead className={className}>
<button
type="button"
onClick={() => onSort(column)}
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
>
{label}
{isActive ? (
currentDir === "asc" ? (
<ArrowUp className="h-3.5 w-3.5" />
) : (
<ArrowDown className="h-3.5 w-3.5" />
)
) : (
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
)}
</button>
</TableHead>
);
}
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.15.13 — the new filter bar (source select + date range) and the
* page-scoped multi-select chrome (per-row + select-all checkboxes) on
* the measurements management list. Server-render assertions only — the
* selection action bar's count-driven render is covered by the pure
* `selection.test.ts` math + the integration suite; here we pin that the
* new controls actually mount with their labelled, keyboard-operable
* checkboxes.
*/
const baseMeasurements = [
{
id: "m-1",
type: "WEIGHT",
value: 81.5,
unit: "kg",
source: "MANUAL",
measuredAt: "2026-05-09T08:30:00.000Z",
notes: null,
},
{
id: "m-2",
type: "WEIGHT",
value: 80.9,
unit: "kg",
source: "WITHINGS",
measuredAt: "2026-05-08T08:30:00.000Z",
notes: null,
},
];
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/measurements",
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { measurements: baseMeasurements, meta: { total: 2 } },
isLoading: false,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "marc", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementList } from "../measurement-list";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<MeasurementList />
</I18nProvider>,
);
}
describe("MeasurementList — filter bar + multi-select chrome", () => {
it("renders the source filter control", () => {
const html = render("en");
expect(html).toContain("Filter by source");
});
it("renders the date-range labels wired to from/to", () => {
const html = render("en");
expect(html).toContain('for="measurements-from"');
expect(html).toContain('for="measurements-to"');
});
it("renders labelled selection checkboxes (per-row + select-all)", () => {
const html = render("en");
// The shared Checkbox primitive carries data-slot="checkbox".
const boxes = html.match(/data-slot="checkbox"/g);
// 1 header select-all + 2 selectable rows (desktop) + 2 rows (mobile).
expect((boxes?.length ?? 0)).toBeGreaterThanOrEqual(3);
expect(html).toContain('aria-label="Select all on this page"');
expect(html).toContain('aria-label="Select row"');
});
it("localises the new chrome in German", () => {
const html = render("de");
expect(html).toContain("Nach Quelle filtern");
expect(html).toContain('aria-label="Alle auf dieser Seite auswählen"');
});
});
+357 -125
View File
@@ -21,7 +21,6 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
@@ -49,19 +48,35 @@ import {
ChevronLeft,
ChevronRight,
ChevronDown,
ArrowUp,
ArrowDown,
ArrowUpDown,
MoreHorizontal,
} from "lucide-react";
import { Fragment, useId, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { formatDateOrRelative, formatDateTime } from "@/lib/format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement";
import { DateTimeInput } from "@/components/ui/date-input";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
MEASUREMENT_NOTES_MAX_LENGTH,
measurementSourceEnum,
} from "@/lib/validations/measurement";
import { DateInput, DateTimeInput } from "@/components/ui/date-input";
import { Checkbox } from "@/components/ui/checkbox";
import {
SortableHead,
DeleteButton,
SelectionActionBar,
toggleId,
toggleSelectAll,
selectAllState,
selectedIdsOnPage,
selectedCountOnPage,
} from "@/components/data-list";
import {
MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS,
MEASUREMENT_TYPE_ICONS as TYPE_ICONS,
@@ -191,9 +206,40 @@ function formatMeasurementSource(
if (source === "IMPORT") return t("measurements.sourceImport");
if (source === "MANUAL") return t("measurements.sourceManual");
if (source === "APPLE_HEALTH") return t("measurements.sourceAppleHealth");
if (source === "COMPUTED") return t("measurements.sourceComputed");
if (source === "WHOOP") return t("measurements.sourceWhoop");
if (source === "FITBIT") return t("measurements.sourceFitbit");
return source;
}
/**
* v1.15.13 — the `MeasurementSource` enum values offered in the
* management-list source filter. Derived from the shared Zod enum so a
* future source addition surfaces here automatically.
*/
const MEASUREMENT_SOURCE_OPTIONS = measurementSourceEnum.options;
/**
* v1.15.13 — convert a `<input type="date">` value (YYYY-MM-DD) into the
* offset-bearing ISO datetime the measurements list `from`/`to` params
* require. `from` snaps to the local start-of-day, `to` to the local
* end-of-day so an inclusive single-day range returns that whole day.
*/
function dayBoundaryIso(
day: string,
boundary: "start" | "end",
): string | undefined {
if (!day) return undefined;
const [y, m, d] = day.split("-").map(Number);
if (!y || !m || !d) return undefined;
const date =
boundary === "start"
? new Date(y, m - 1, d, 0, 0, 0, 0)
: new Date(y, m - 1, d, 23, 59, 59, 999);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
}
/**
* Per-source badge colour. `APPLE_HEALTH` gets the Dracula pink that
* matches the iOS app's accent; everything else falls back to the
@@ -220,10 +266,24 @@ export function MeasurementList({
const [typeFilter, setTypeFilterRaw] = useState<string>(
lockedType ?? "ALL",
);
// v1.15.13 — management-list source filter + optional date range.
// `ALL` clears the source filter; empty date strings clear the bound.
const [sourceFilter, setSourceFilterRaw] = useState<string>("ALL");
const [fromDay, setFromDayRaw] = useState<string>("");
const [toDay, setToDayRaw] = useState<string>("");
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<string>("measuredAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
// v1.15.13 — page-scoped multi-select. Holds the ids selected on the
// CURRENT page; cleared on any page / filter / sort change (per the
// v1.15.x audit — no "select across 200k rows"). The bulk-delete
// payload is intersected with the painted page ids, so it is always
// page-bounded (≤ PAGE_SIZE) and well under the server's 200 cap.
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(),
);
// v1.4.37 W7c — set of dayKeys whose drill-down is currently
// expanded. Each key maps to a one-shot query that lazy-fetches
// the per-sample rows for that calendar day on first open.
@@ -256,9 +316,38 @@ export function MeasurementList({
null,
);
// v1.15.13 — every filter / page / sort change resets pagination to
// page 1 AND clears the page-scoped selection (the rows it referred to
// are about to unmount).
const clearSelection = () => setSelectedIds(new Set());
const setTypeFilter = (value: string) => {
setTypeFilterRaw(value);
setPage(1);
clearSelection();
};
const setSourceFilter = (value: string) => {
setSourceFilterRaw(value);
setPage(1);
clearSelection();
};
const setFromDay = (value: string) => {
setFromDayRaw(value);
setPage(1);
clearSelection();
};
const setToDay = (value: string) => {
setToDayRaw(value);
setPage(1);
clearSelection();
};
const goToPage = (updater: (p: number) => number) => {
setPage(updater);
clearSelection();
};
function toggleSort(column: string) {
@@ -269,6 +358,7 @@ export function MeasurementList({
setSortDir(column === "measuredAt" ? "desc" : "asc");
}
setPage(1);
clearSelection();
}
// v1.4.37 W7c — when the type filter is a cumulative HK type
@@ -287,18 +377,34 @@ export function MeasurementList({
const isSleepFilter = typeFilter === "SLEEP_DURATION";
const isDayGroupedFilter = isCumulativeFilter || isSleepFilter;
// v1.15.13 — derive the ISO datetime window the list `from`/`to`
// params require from the two date inputs (local start/end of day).
const fromIso = dayBoundaryIso(fromDay, "start");
const toIso = dayBoundaryIso(toDay, "end");
const sourceEq = sourceFilter === "ALL" ? undefined : sourceFilter;
const listMode: "raw" | "groupBy=day" | "sleep-night" = isSleepFilter
? "sleep-night"
: isCumulativeFilter
? "groupBy=day"
: "raw";
const { data, isLoading } = useQuery({
queryKey: [
"measurements",
typeFilter === "ALL" ? undefined : typeFilter,
queryKey: queryKeys.measurementsList({
type: typeFilter === "ALL" ? undefined : typeFilter,
sourceEq,
from: fromIso,
to: toIso,
page,
sortBy,
sortDir,
isSleepFilter ? "sleep-night" : isCumulativeFilter ? "groupBy=day" : "raw",
],
mode: listMode,
}),
queryFn: async () => {
const params = new URLSearchParams();
if (typeFilter !== "ALL") params.set("type", typeFilter);
if (sourceEq) params.set("sourceEq", sourceEq);
if (fromIso) params.set("from", fromIso);
if (toIso) params.set("to", toIso);
params.set("limit", String(PAGE_SIZE));
params.set("offset", String((page - 1) * PAGE_SIZE));
params.set("sortBy", sortBy);
@@ -350,6 +456,61 @@ export function MeasurementList({
},
});
// v1.15.13 — page-scoped bulk soft-delete. Posts the selected ids
// (intersected with the painted page) to the bulk-delete route, then
// invalidates the same dependent-key bundle the single delete busts so
// the dashboard / insights / chart caches stay in lockstep.
const bulkDeleteMutation = useMutation({
mutationFn: async (ids: string[]) => {
const res = await fetch("/api/measurements/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
if (!res.ok) throw new Error("Bulk delete failed");
const json = (await res.json()) as { data: { deleted: number } };
return json.data.deleted;
},
onSuccess: async (deleted) => {
await invalidateKeys(queryClient, measurementDependentKeys);
clearSelection();
toast.success(t("measurements.bulkDeleteSuccess", { count: String(deleted) }));
},
onError: () => {
toast.error(t("measurements.bulkDeleteError"));
},
});
const pageIds = (data?.measurements ?? [])
// Synthetic day-grouped / sleep-night rows aren't individually
// deletable (they collapse many raw rows behind a `dayKey`), so they
// are excluded from the selectable set.
.filter((m) => !(m.dayKey !== undefined && m.sampleCount !== undefined))
.map((m) => m.id);
// Selection is held as a raw id set but every read intersects it with
// the painted page (`selectAllState` / `selectedCountOnPage` /
// `selectedIdsOnPage`), so an id that falls off the page after a
// refetch (e.g. a single-row delete) simply stops counting — no stale
// selection survives a page / filter / sort change either, since those
// call `clearSelection()`. No prune effect is needed.
const selectAll = selectAllState(selectedIds, pageIds);
const selectedOnPage = selectedCountOnPage(selectedIds, pageIds);
function onToggleRow(id: string) {
setSelectedIds((prev) => toggleId(prev, id));
}
function onToggleSelectAll() {
setSelectedIds((prev) => toggleSelectAll(prev, pageIds));
}
function onConfirmBulkDelete() {
const ids = selectedIdsOnPage(selectedIds, pageIds);
if (ids.length === 0) return;
bulkDeleteMutation.mutate(ids);
}
const updateMutation = useMutation({
mutationFn: async ({
id,
@@ -494,32 +655,103 @@ export function MeasurementList({
Pre-fix the trigger had a fixed `w-48` (192 px) which on
Pixel 5 (375 px content width) left only ~120 px for the
count, which wrapped to 2 lines. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
{lockedType ? (
// v1.8.5 — the insights "all readings" subpage knows the
// metric from the route, so the global type selector is
// suppressed; the count caption still anchors the column.
<span className="sr-only">{t("measurements.filterByType")}</span>
) : (
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger
className="w-full sm:w-48"
aria-label={t("measurements.filterByType")}
>
<SelectValue placeholder={t("measurements.allTypes")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
{t("measurements.allTypes")}
</SelectItem>
{Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => (
<SelectItem key={val} value={val}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
{lockedType ? (
// v1.8.5 — the insights "all readings" subpage knows the
// metric from the route, so the global type selector is
// suppressed; the count caption still anchors the column.
<span className="sr-only">{t("measurements.filterByType")}</span>
) : (
<div className="flex flex-col gap-1">
<Label className="text-muted-foreground text-xs">
{t("measurements.filterByType")}
</Label>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger
className="w-full sm:w-44"
aria-label={t("measurements.filterByType")}
>
<SelectValue placeholder={t("measurements.allTypes")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
{t("measurements.allTypes")}
</SelectItem>
{Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => (
<SelectItem key={val} value={val}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* v1.15.13 — source filter + date range. Suppressed on the
locked-type insights subpage, which wants a focused list
scoped to the route's single metric (matches the type-
selector suppression above). */}
{!lockedType && (
<>
<div className="flex flex-col gap-1">
<Label className="text-muted-foreground text-xs">
{t("measurements.filterBySource")}
</Label>
<Select value={sourceFilter} onValueChange={setSourceFilter}>
<SelectTrigger
className="w-full sm:w-40"
aria-label={t("measurements.filterBySource")}
>
<SelectValue placeholder={t("dataList.allSources")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
{t("dataList.allSources")}
</SelectItem>
{MEASUREMENT_SOURCE_OPTIONS.map((src) => (
<SelectItem key={src} value={src}>
{formatMeasurementSource(src, t)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor="measurements-from"
className="text-muted-foreground text-xs"
>
{t("dataList.dateFrom")}
</Label>
<DateInput
id="measurements-from"
className="w-full sm:w-40"
value={fromDay}
max={toDay || undefined}
onChange={(e) => setFromDay(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor="measurements-to"
className="text-muted-foreground text-xs"
>
{t("dataList.dateTo")}
</Label>
<DateInput
id="measurements-to"
className="w-full sm:w-40"
value={toDay}
min={fromDay || undefined}
onChange={(e) => setToDay(e.target.value)}
/>
</div>
</>
)}
</div>
{data?.meta?.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("measurements.measurementCount", {
@@ -576,7 +808,22 @@ export function MeasurementList({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-28 pl-4">
<TableHead className="w-10 pl-4">
{/* v1.15.13 — select-all-on-page header checkbox. */}
<Checkbox
checked={
selectAll === "all"
? true
: selectAll === "some"
? "indeterminate"
: false
}
disabled={pageIds.length === 0}
onCheckedChange={onToggleSelectAll}
aria-label={t("dataList.selectAll")}
/>
</TableHead>
<TableHead className="w-28">
{t("measurements.type")}
</TableHead>
<SortableHead
@@ -626,10 +873,22 @@ export function MeasurementList({
// expanded panel. dayKey is unique per row when
// grouped; fall back to m.id otherwise.
const drilldownId = `drilldown-desktop-${m.dayKey ?? m.id}`;
const isSelected = selectedIds.has(m.id);
return (
<Fragment key={m.id}>
<TableRow>
<TableRow data-state={isSelected ? "selected" : undefined}>
<TableCell className="pl-4">
{/* Grouped/synthetic rows aren't individually
deletable, so they have no checkbox. */}
{!isGrouped && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(m.id)}
aria-label={t("dataList.selectRow")}
/>
)}
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={TYPE_COLORS[m.type] ?? ""}
@@ -744,6 +1003,10 @@ export function MeasurementList({
onConfirm={() =>
deleteMutation.mutate(m.id)
}
title={t("measurements.deleteConfirmTitle")}
description={t(
"measurements.deleteConfirmDescription",
)}
/>
</>
)}
@@ -752,7 +1015,7 @@ export function MeasurementList({
</TableRow>
{isGrouped && isExpanded && (
<TableRow id={drilldownId}>
<TableCell colSpan={6} className="p-0">
<TableCell colSpan={7} className="p-0">
<DayDrillDown
type={m.type}
dayKey={m.dayKey as string}
@@ -781,13 +1044,43 @@ export function MeasurementList({
: false;
// v1.4.38 W-D P1-1 — see desktop counterpart.
const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`;
const isSelected = selectedIds.has(m.id);
return (
<div
key={m.id}
className="bg-card border-border rounded-lg border p-3"
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border rounded-lg border p-3 data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5 overflow-hidden">
<div className="flex items-center gap-2 overflow-hidden">
{/* v1.15.13 — multi-select checkbox in a 44px tap
target; absent for synthetic grouped rows. */}
{!isGrouped && (
// v1.15.13 MEDIUM-1 — a Radix Checkbox renders a
// 16px `<button role=checkbox>`; a wrapping
// `<label>` does NOT forward taps to a button (label
// forwarding only works for native form controls),
// so the effective tap target was 16px (fails WCAG
// 2.5.5). The 44px button below owns the whole hit
// area and is the single toggle source; the inner
// Checkbox is a `pointer-events-none` controlled
// visual, so a tap can fire the handler exactly once.
<button
type="button"
role="checkbox"
aria-checked={isSelected}
aria-label={t("dataList.selectRow")}
onClick={() => onToggleRow(m.id)}
className="focus-visible:ring-ring/50 flex size-11 shrink-0 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
>
<Checkbox
checked={isSelected}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none"
/>
</button>
)}
{Icon && (
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${TYPE_COLORS[m.type] ?? ""}`}
@@ -898,7 +1191,12 @@ export function MeasurementList({
</Button>
<DeleteButton
className="size-11"
iconClassName="h-4 w-4"
onConfirm={() => deleteMutation.mutate(m.id)}
title={t("measurements.deleteConfirmTitle")}
description={t(
"measurements.deleteConfirmDescription",
)}
/>
</>
)}
@@ -921,6 +1219,20 @@ export function MeasurementList({
</>
)}
{/* v1.15.13 — page-scoped multi-select action bar. */}
<SelectionActionBar
count={selectedOnPage}
onClear={clearSelection}
onConfirmDelete={onConfirmBulkDelete}
isDeleting={bulkDeleteMutation.isPending}
confirmTitle={t("measurements.bulkDeleteConfirmTitle", {
count: String(selectedOnPage),
})}
confirmBody={t("measurements.bulkDeleteConfirmBody", {
count: String(selectedOnPage),
})}
/>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
@@ -936,7 +1248,7 @@ export function MeasurementList({
size="icon"
className="size-11"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
onClick={() => goToPage((p) => p - 1)}
aria-label={t("measurements.previousPage")}
>
<ChevronLeft className="h-4 w-4" />
@@ -946,7 +1258,7 @@ export function MeasurementList({
size="icon"
className="size-11"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
onClick={() => goToPage((p) => p + 1)}
aria-label={t("measurements.nextPage")}
>
<ChevronRight className="h-4 w-4" />
@@ -1279,83 +1591,3 @@ function SleepNightCaption({ m }: { m: Measurement }) {
);
}
function SortableHead({
column,
label,
currentSort,
currentDir,
onSort,
className,
}: {
column: string;
label: string;
currentSort: string;
currentDir: "asc" | "desc";
onSort: (col: string) => void;
className?: string;
}) {
const isActive = currentSort === column;
return (
<TableHead className={className}>
<button
type="button"
onClick={() => onSort(column)}
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
>
{label}
{isActive ? (
currentDir === "asc" ? (
<ArrowUp className="h-3.5 w-3.5" />
) : (
<ArrowDown className="h-3.5 w-3.5" />
)
) : (
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
)}
</button>
</TableHead>
);
}
function DeleteButton({
onConfirm,
className = "size-11",
}: {
onConfirm: () => void;
className?: string;
}) {
const { t } = useTranslations();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`text-destructive ${className}`}
aria-label={t("common.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("measurements.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("measurements.deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onConfirm}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.15.13 — the new source filter + date range + page-scoped
* multi-select chrome on the mood management list. Mirrors the
* measurements-list guard.
*/
const baseEntries = [
{
id: "e-1",
date: "2026-05-09",
mood: "GUT",
score: 4,
tags: ["work"],
tagKeys: [],
note: null,
source: "MANUAL",
moodLoggedAt: "2026-05-09T20:00:00.000Z",
},
{
id: "e-2",
date: "2026-05-08",
mood: "OKAY",
score: 3,
tags: [],
tagKeys: [],
note: null,
source: "MOODLOG",
moodLoggedAt: "2026-05-08T20:00:00.000Z",
},
];
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/mood",
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { entries: baseEntries, meta: { total: 2 } },
isLoading: false,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "marc", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MoodList } from "../mood-list";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<MoodList />
</I18nProvider>,
);
}
describe("MoodList — filter bar + multi-select chrome", () => {
it("renders the source filter control", () => {
const html = render("en");
expect(html).toContain("Filter by source");
});
it("renders the date-range labels wired to from/to", () => {
const html = render("en");
expect(html).toContain('for="mood-from"');
expect(html).toContain('for="mood-to"');
});
it("renders labelled selection checkboxes (per-row + select-all)", () => {
const html = render("en");
const boxes = html.match(/data-slot="checkbox"/g);
expect((boxes?.length ?? 0)).toBeGreaterThanOrEqual(3);
expect(html).toContain('aria-label="Select all on this page"');
expect(html).toContain('aria-label="Select row"');
});
it("renders the localised non-manual source label, not the raw enum", () => {
const html = render("en");
// MOODLOG row paints its localised badge label.
expect(html).toContain("moodLog");
expect(html).not.toContain(">MOODLOG<");
});
});
+305 -119
View File
@@ -21,7 +21,6 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
@@ -38,7 +37,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DateTimeInput } from "@/components/ui/date-input";
import { DateInput, DateTimeInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import {
Table,
@@ -56,9 +55,6 @@ import {
Trash2,
ChevronLeft,
ChevronRight,
ArrowUp,
ArrowDown,
ArrowUpDown,
MoreHorizontal,
} from "lucide-react";
import { useId, useState } from "react";
@@ -70,7 +66,23 @@ import {
MOOD_LABEL_KEYS,
MOOD_SCORE_BY_ENUM,
} from "@/lib/mood/labels";
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
import {
invalidateKeys,
moodDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { moodSourceEnum } from "@/lib/validations/moodlog";
import { Checkbox } from "@/components/ui/checkbox";
import {
SortableHead,
DeleteButton,
SelectionActionBar,
toggleId,
toggleSelectAll,
selectAllState,
selectedIdsOnPage,
selectedCountOnPage,
} from "@/components/data-list";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { MoodTagPicker } from "./mood-tag-picker";
@@ -125,10 +137,19 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [moodFilter, setMoodFilterRaw] = useState<string>("ALL");
// v1.15.13 — management-list source filter + optional date range.
const [sourceFilter, setSourceFilterRaw] = useState<string>("ALL");
const [fromDay, setFromDayRaw] = useState<string>("");
const [toDay, setToDayRaw] = useState<string>("");
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<string>("moodLoggedAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
// v1.15.13 — page-scoped multi-select selection (current page ids only).
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(),
);
const [editing, setEditing] = useState<MoodEntry | null>(null);
const [editMood, setEditMood] = useState("");
const { getRadioProps: getEditMoodRadioProps } = useRovingRadioGroup({
@@ -156,9 +177,37 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
null,
);
// v1.15.13 — every filter / page / sort change resets pagination AND
// clears the page-scoped selection.
const clearSelection = () => setSelectedIds(new Set());
const setMoodFilter = (value: string) => {
setMoodFilterRaw(value);
setPage(1);
clearSelection();
};
const setSourceFilter = (value: string) => {
setSourceFilterRaw(value);
setPage(1);
clearSelection();
};
const setFromDay = (value: string) => {
setFromDayRaw(value);
setPage(1);
clearSelection();
};
const setToDay = (value: string) => {
setToDayRaw(value);
setPage(1);
clearSelection();
};
const goToPage = (updater: (p: number) => number) => {
setPage(updater);
clearSelection();
};
function toggleSort(column: string) {
@@ -169,19 +218,32 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
setSortDir(column === "moodLoggedAt" ? "desc" : "asc");
}
setPage(1);
clearSelection();
}
// v1.15.13 — mood list `from`/`to` are YYYY-MM-DD (the date inputs hand
// back exactly that), so they pass through unchanged. `ALL` clears the
// source filter.
const fromParam = fromDay || undefined;
const toParam = toDay || undefined;
const sourceParam = sourceFilter === "ALL" ? undefined : sourceFilter;
const { data, isLoading } = useQuery({
queryKey: [
"mood-entries",
moodFilter === "ALL" ? undefined : moodFilter,
queryKey: queryKeys.moodEntriesList({
mood: moodFilter === "ALL" ? undefined : moodFilter,
source: sourceParam,
from: fromParam,
to: toParam,
page,
sortBy,
sortDir,
],
}),
queryFn: async () => {
const params = new URLSearchParams();
if (moodFilter !== "ALL") params.set("mood", moodFilter);
if (sourceParam) params.set("source", sourceParam);
if (fromParam) params.set("from", fromParam);
if (toParam) params.set("to", toParam);
params.set("limit", String(PAGE_SIZE));
params.set("offset", String((page - 1) * PAGE_SIZE));
params.set("sortBy", sortBy);
@@ -216,6 +278,49 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
},
});
// v1.15.13 — page-scoped bulk soft-delete, mirroring the measurements list.
const bulkDeleteMutation = useMutation({
mutationFn: async (ids: string[]) => {
const res = await fetch("/api/mood-entries/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
if (!res.ok) throw new Error("Bulk delete failed");
const json = (await res.json()) as { data: { deleted: number } };
return json.data.deleted;
},
onSuccess: async (deleted) => {
await invalidateKeys(queryClient, moodDependentKeys);
clearSelection();
toast.success(t("mood.bulkDeleteSuccess", { count: String(deleted) }));
},
onError: () => {
toast.error(t("mood.bulkDeleteError"));
},
});
const pageIds = (data?.entries ?? []).map((e) => e.id);
// Selection reads intersect with the painted page, so a stale id never
// counts; page / filter / sort changes clear selection. No prune effect.
const selectAll = selectAllState(selectedIds, pageIds);
const selectedOnPage = selectedCountOnPage(selectedIds, pageIds);
function onToggleRow(id: string) {
setSelectedIds((prev) => toggleId(prev, id));
}
function onToggleSelectAll() {
setSelectedIds((prev) => toggleSelectAll(prev, pageIds));
}
function onConfirmBulkDelete() {
const ids = selectedIdsOnPage(selectedIds, pageIds);
if (ids.length === 0) return;
bulkDeleteMutation.mutate(ids);
}
const updateMutation = useMutation({
mutationFn: async ({
id,
@@ -321,20 +426,77 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
return (
<>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Select value={moodFilter} onValueChange={setMoodFilter}>
<SelectTrigger className="w-48">
<SelectValue placeholder={t("mood.allMoods")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">{t("mood.allMoods")}</SelectItem>
{MOOD_LEVELS_LIST.map((val) => (
<SelectItem key={val} value={val}>
{MOOD_SCORES[val]} ({t(MOOD_LABEL_KEYS[val])})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
<div className="flex flex-col gap-1">
<Label className="text-muted-foreground text-xs">
{t("mood.moodLevel")}
</Label>
<Select value={moodFilter} onValueChange={setMoodFilter}>
<SelectTrigger className="w-full sm:w-44">
<SelectValue placeholder={t("mood.allMoods")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">{t("mood.allMoods")}</SelectItem>
{MOOD_LEVELS_LIST.map((val) => (
<SelectItem key={val} value={val}>
{MOOD_SCORES[val]} ({t(MOOD_LABEL_KEYS[val])})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* v1.15.13 — source filter. */}
<div className="flex flex-col gap-1">
<Label className="text-muted-foreground text-xs">
{t("mood.filterBySource")}
</Label>
<Select value={sourceFilter} onValueChange={setSourceFilter}>
<SelectTrigger
className="w-full sm:w-40"
aria-label={t("mood.filterBySource")}
>
<SelectValue placeholder={t("dataList.allSources")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">{t("dataList.allSources")}</SelectItem>
{MOOD_SOURCE_OPTIONS.map((src) => (
<SelectItem key={src} value={src}>
{formatMoodSource(src, t)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* v1.15.13 — optional date range, wired to from/to. */}
<div className="flex flex-col gap-1">
<Label htmlFor="mood-from" className="text-muted-foreground text-xs">
{t("dataList.dateFrom")}
</Label>
<DateInput
id="mood-from"
className="w-full sm:w-40"
value={fromDay}
max={toDay || undefined}
onChange={(e) => setFromDay(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="mood-to" className="text-muted-foreground text-xs">
{t("dataList.dateTo")}
</Label>
<DateInput
id="mood-to"
className="w-full sm:w-40"
value={toDay}
min={fromDay || undefined}
onChange={(e) => setToDay(e.target.value)}
/>
</div>
</div>
{data?.meta?.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("mood.entryCount", {
@@ -388,13 +550,28 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10 pl-4">
{/* v1.15.13 — select-all-on-page header checkbox. */}
<Checkbox
checked={
selectAll === "all"
? true
: selectAll === "some"
? "indeterminate"
: false
}
disabled={pageIds.length === 0}
onCheckedChange={onToggleSelectAll}
aria-label={t("dataList.selectAll")}
/>
</TableHead>
<SortableHead
column="mood"
label={t("mood.moodLevel")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
className="w-36 pl-4"
className="w-36"
/>
<TableHead>{t("mood.tags")}</TableHead>
<SortableHead
@@ -418,9 +595,21 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</TableRow>
</TableHeader>
<TableBody>
{data.entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="pl-4 font-semibold tabular-nums">
{data.entries.map((entry) => {
const isSelected = selectedIds.has(entry.id);
return (
<TableRow
key={entry.id}
data-state={isSelected ? "selected" : undefined}
>
<TableCell className="pl-4">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
/>
</TableCell>
<TableCell className="font-semibold tabular-nums">
{entry.score}{" "}
<span className="text-muted-foreground font-normal">
(
@@ -457,7 +646,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
<TableCell>
{entry.source !== "MANUAL" && (
<Badge variant="outline" className="text-xs">
{t("mood.sourceMoodlog")}
{formatMoodSource(entry.source, t)}
</Badge>
)}
</TableCell>
@@ -474,11 +663,14 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(entry.id)}
title={t("mood.deleteConfirmTitle")}
description={t("mood.deleteConfirmDescription")}
/>
</div>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</div>
@@ -493,13 +685,38 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
guard at `e2e/mood-card-mobile.spec.ts` queries to assert
"exactly one occurrence of the score per row". */}
<div className="space-y-2 md:hidden">
{data.entries.map((entry) => (
{data.entries.map((entry) => {
const isSelected = selectedIds.has(entry.id);
return (
<div
key={entry.id}
data-testid="mood-row"
className="bg-card border-border flex items-center justify-between rounded-lg border p-3"
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border flex items-center justify-between rounded-lg border p-3 data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5"
>
<div className="flex items-center gap-2.5 overflow-hidden">
<div className="flex items-center gap-2 overflow-hidden">
{/* v1.15.13 MEDIUM-1 — a Radix Checkbox renders a 16px
`<button role=checkbox>`; a wrapping `<label>` does
NOT forward taps to a button, so the effective tap
target was 16px (fails WCAG 2.5.5). The 44px button
owns the whole hit area and is the single toggle
source; the inner Checkbox is a `pointer-events-none`
controlled visual, so a tap fires the handler once. */}
<button
type="button"
role="checkbox"
aria-checked={isSelected}
aria-label={t("dataList.selectRow")}
onClick={() => onToggleRow(entry.id)}
className="focus-visible:ring-ring/50 flex size-11 shrink-0 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
>
<Checkbox
checked={isSelected}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none"
/>
</button>
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<span
data-testid="mood-row-score"
@@ -546,15 +763,32 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(entry.id)}
mobile
iconClassName="h-4 w-4"
title={t("mood.deleteConfirmTitle")}
description={t("mood.deleteConfirmDescription")}
/>
</div>
</div>
))}
);
})}
</div>
</>
)}
{/* v1.15.13 — page-scoped multi-select action bar. */}
<SelectionActionBar
count={selectedOnPage}
onClear={clearSelection}
onConfirmDelete={onConfirmBulkDelete}
isDeleting={bulkDeleteMutation.isPending}
confirmTitle={t("mood.bulkDeleteConfirmTitle", {
count: String(selectedOnPage),
})}
confirmBody={t("mood.bulkDeleteConfirmBody", {
count: String(selectedOnPage),
})}
/>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
@@ -565,19 +799,25 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
})}
</span>
<div className="flex gap-1">
{/* v1.15.13 L2 — match the measurements list's 44px pagination
target (was `size="sm"`, below the WCAG 2.5.5 floor). */}
<Button
variant="ghost"
size="sm"
size="icon"
className="size-11"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
onClick={() => goToPage((p) => p - 1)}
aria-label={t("mood.previousPage")}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
size="icon"
className="size-11"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
onClick={() => goToPage((p) => p + 1)}
aria-label={t("mood.nextPage")}
>
<ChevronRight className="h-4 w-4" />
</Button>
@@ -797,85 +1037,31 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
);
}
function SortableHead({
column,
label,
currentSort,
currentDir,
onSort,
className,
}: {
column: string;
label: string;
currentSort: string;
currentDir: "asc" | "desc";
onSort: (col: string) => void;
className?: string;
}) {
const isActive = currentSort === column;
return (
<TableHead className={className}>
<button
type="button"
onClick={() => onSort(column)}
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
>
{label}
{isActive ? (
currentDir === "asc" ? (
<ArrowUp className="h-3.5 w-3.5" />
) : (
<ArrowDown className="h-3.5 w-3.5" />
)
) : (
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
)}
</button>
</TableHead>
);
/**
* v1.15.13 — render a `MoodEntry.source` value with its localized label.
* MANUAL is shown via the source filter only (a manual row carries no
* badge in the table). MOODLOG keeps its existing `mood.sourceMoodlog`
* key; the rest fall back to their own keys.
*/
function formatMoodSource(
source: string,
t: ReturnType<typeof useTranslations>["t"],
): string {
switch (source) {
case "MANUAL":
return t("mood.sourceManual");
case "MOODLOG":
return t("mood.sourceMoodlog");
case "WEB":
return t("mood.sourceWeb");
case "TELEGRAM":
return t("mood.sourceTelegram");
case "DAYLIO":
return t("mood.sourceDaylio");
default:
return source;
}
}
function DeleteButton({
onConfirm,
mobile = false,
}: {
onConfirm: () => void;
mobile?: boolean;
}) {
const { t } = useTranslations();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className={
mobile
? "text-destructive min-h-11 min-w-11"
: "text-destructive h-8 w-8"
}
aria-label={t("common.delete")}
>
<Trash2 className={mobile ? "h-4 w-4" : "h-3.5 w-3.5"} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("mood.deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("mood.deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onConfirm}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
/** v1.15.13 — mood source enum values offered in the source filter. */
const MOOD_SOURCE_OPTIONS = moodSourceEnum.options;
+48
View File
@@ -579,6 +579,26 @@ const deleteByExternalIdsResponse = z
})
.meta({ id: "MeasurementsDeleteByExternalIdsResult" });
// v1.15.13 — multi-select bulk soft-delete for the measurements
// management list. Ids are scoped to the caller; forged / foreign ids
// are silent no-ops (no existence leak). Soft-delete (tombstone), so the
// rows surface in the `/api/sync/changes` delta feed.
const bulkDeleteMeasurementsRequest = z
.object({
ids: z.array(z.string().min(1)).min(1).max(200),
})
.meta({
id: "MeasurementsBulkDeleteRequest",
description:
"1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).",
});
const bulkDeleteMeasurementsResponse = z
.object({
deleted: z.number().int().nonnegative(),
})
.meta({ id: "MeasurementsBulkDeleteResult" });
const measurementResource = z
.object({
id: z.string(),
@@ -3444,6 +3464,34 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/measurements/bulk-delete": {
post: {
tags: ["Measurements"],
summary: "Bulk soft-delete measurements",
description:
"Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.",
requestBody: {
required: true,
content: {
"application/json": { schema: bulkDeleteMeasurementsRequest },
},
},
responses: {
"200": {
description: "Bulk delete processed.",
content: {
"application/json": {
schema: dataEnvelope(
bulkDeleteMeasurementsResponse,
"MeasurementsBulkDeleteResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/sleep/night": {
get: {
tags: ["Measurements"],
+60
View File
@@ -41,6 +41,66 @@ export const queryKeys = {
measurements: () => ["measurements"] as const,
moodEntries: () => ["mood-entries"] as const,
/**
* v1.15.13 — the measurements management-list read with its full filter
* + sort + pagination state baked into the key so the cache slot is
* correct for every filter combination (a `sourceEq` / date-range /
* type / page / sort change re-keys instead of poisoning a shared
* slot). Rides under the `["measurements"]` prefix so
* `measurementDependentKeys` (and a bulk-delete invalidation) reaches
* every slot at once. `mode` distinguishes the synthetic day-grouped /
* sleep-night branches from the plain `raw` list.
*/
measurementsList: (params: {
type: string | undefined;
sourceEq: string | undefined;
from: string | undefined;
to: string | undefined;
page: number;
sortBy: string;
sortDir: string;
mode: "raw" | "groupBy=day" | "sleep-night";
}) =>
[
"measurements",
"list",
params.type ?? null,
params.sourceEq ?? null,
params.from ?? null,
params.to ?? null,
params.page,
params.sortBy,
params.sortDir,
params.mode,
] as const,
/**
* v1.15.13 — the mood management-list read with its filter + sort +
* pagination state baked into the key, mirroring `measurementsList`.
* Rides under the `["mood-entries"]` prefix so `moodDependentKeys`
* (and a bulk-delete invalidation) reaches every slot.
*/
moodEntriesList: (params: {
mood: string | undefined;
source: string | undefined;
from: string | undefined;
to: string | undefined;
page: number;
sortBy: string;
sortDir: string;
}) =>
[
"mood-entries",
"list",
params.mood ?? null,
params.source ?? null,
params.from ?? null,
params.to ?? null,
params.page,
params.sortBy,
params.sortDir,
] as const,
/**
* v1.11.5 — last-night hypnogram (`GET /api/sleep/night`). The `date`
* discriminator lets the night-picker step back through recent nights
@@ -236,6 +236,48 @@ describe("measurement validation", () => {
});
});
describe("listMeasurementsSchema — v1.15.13 sourceEq filter", () => {
it("accepts a valid MeasurementSource on sourceEq", () => {
const parsed = listMeasurementsSchema.safeParse({ sourceEq: "WITHINGS" });
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.sourceEq).toBe("WITHINGS");
}
});
it("leaves sourceEq undefined when omitted", () => {
const parsed = listMeasurementsSchema.safeParse({});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.sourceEq).toBeUndefined();
}
});
it("rejects an unknown sourceEq value", () => {
const parsed = listMeasurementsSchema.safeParse({ sourceEq: "BOGUS" });
expect(parsed.success).toBe(false);
});
it("rejects the rollup opt-in value on sourceEq (distinct from source)", () => {
// `rollup` is the rollup-tier opt-in for `source`, NOT a valid
// MeasurementSource filter — sourceEq must not accept it.
const parsed = listMeasurementsSchema.safeParse({ sourceEq: "rollup" });
expect(parsed.success).toBe(false);
});
it("accepts source=rollup and sourceEq together (independent params)", () => {
const parsed = listMeasurementsSchema.safeParse({
source: "rollup",
sourceEq: "APPLE_HEALTH",
});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.source).toBe("rollup");
expect(parsed.data.sourceEq).toBe("APPLE_HEALTH");
}
});
});
// v1.4.38 — the per-day drill-down branch always returns at most
// 1000 rows (one pathological phone-only stepCount day). Push the
// cap into the validator so a caller asking for more sees a 422
+6
View File
@@ -586,6 +586,12 @@ export const listMeasurementsSchema = z
// when the rollup bucket set is empty for the requested window so
// brand-new accounts still see correct data on their first chart.
source: z.enum(["rollup"]).optional(),
// v1.15.13 — management-list source FILTER. The `source` key above is
// the rollup-tier opt-in (`["rollup"]`), NOT a source filter, so the
// filter rides a distinct param. Validated against `MeasurementSource`;
// threaded into the plain-list `where`. Backed by the `(userId, source,
// measuredAt)` index (migration 0136).
sourceEq: measurementSourceEnum.optional(),
// v1.4.37 W7c — list-view "one row per day" mode for cumulative
// types (steps, active energy, distance, flights, daylight). When
// `groupBy=day` is set and `type` is a cumulative HK type, the route
+5
View File
@@ -165,6 +165,11 @@ export const updateMoodEntrySchema = z.object({
export const listMoodEntriesSchema = z.object({
mood: moodLevelEnum.optional(),
// v1.15.13 — source filter for the management list. Validated against
// the mood source set; threaded into the list `where` (the `userId` +
// `deletedAt: null` pins stay). Backed by the `(userId, source,
// moodLoggedAt)` index (migration 0136).
source: moodSourceEnum.optional(),
from: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
@@ -0,0 +1,250 @@
/**
* v1.15.13 — bulk soft-delete + source filter, real-Postgres integration.
*
* Covers, against the real testcontainer:
* - `POST /api/measurements/bulk-delete` soft-deletes only the caller's
* rows; a foreign id is a silent no-op (no existence leak), bumps
* `syncVersion`, sets `deletedAt`, and the count reflects owned rows
* only.
* - Idempotency replay returns the same `deleted` count without
* re-mutating (a second distinct call would re-bump syncVersion).
* - `POST /api/mood-entries/bulk-delete` mirrors the same contract.
* - `sourceEq` (measurements) + `source` (mood) narrow the list reads.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER_A = "user-bulkdel-a";
const USER_B = "user-bulkdel-b";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
async function seedUser(id: string, username: string) {
const prisma = getPrismaClient();
await prisma.user.create({
data: { id, username, email: `${username}@example.test`, timezone: "UTC" },
});
}
async function loginAs(id: string) {
const session = await getPrismaClient().session.create({
data: { userId: id, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
cookieJar.clear();
cookieJar.set("healthlog_session", session.id);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await seedUser(USER_A, "bulkdel-a");
await seedUser(USER_B, "bulkdel-b");
});
function postReq(path: string, body: unknown, idempotencyKey?: string) {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: JSON.stringify(body),
});
}
describe("POST /api/measurements/bulk-delete (real Postgres)", () => {
it("soft-deletes owned rows, skips a foreign id, bumps syncVersion", async () => {
const prisma = getPrismaClient();
const now = new Date();
const mine = await prisma.measurement.createManyAndReturn({
data: [
{ userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: now },
{ userId: USER_A, type: "WEIGHT", value: 81, unit: "kg", source: "MANUAL", measuredAt: new Date(now.getTime() - 1000) },
],
});
const theirs = await prisma.measurement.create({
data: { userId: USER_B, type: "WEIGHT", value: 70, unit: "kg", source: "MANUAL", measuredAt: now },
});
await loginAs(USER_A);
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
const res = await POST(
postReq("/api/measurements/bulk-delete", {
ids: [mine[0].id, mine[1].id, theirs.id],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { deleted: number } };
// Only the two owned rows count; the foreign id is a silent no-op.
expect(body.data.deleted).toBe(2);
const minedAfter = await prisma.measurement.findMany({
where: { id: { in: [mine[0].id, mine[1].id] } },
});
for (const row of minedAfter) {
expect(row.deletedAt).not.toBeNull();
// Measurement.syncVersion defaults to 1; one bulk delete bumps it to 2.
expect(row.syncVersion).toBe(2);
}
// The other user's row is untouched (default syncVersion = 1).
const theirsAfter = await prisma.measurement.findUnique({
where: { id: theirs.id },
});
expect(theirsAfter!.deletedAt).toBeNull();
expect(theirsAfter!.syncVersion).toBe(1);
});
it("idempotency replay returns the cached count without re-mutating", async () => {
const prisma = getPrismaClient();
const row = await prisma.measurement.create({
data: { userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: new Date() },
});
await loginAs(USER_A);
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
const key = "idem-meas-bulk-1";
const first = await POST(
postReq("/api/measurements/bulk-delete", { ids: [row.id] }, key),
);
expect(first.status).toBe(200);
expect(((await first.json()) as { data: { deleted: number } }).data.deleted).toBe(1);
// Replay with the SAME key → cached body, same count, no re-mutation.
const replay = await POST(
postReq("/api/measurements/bulk-delete", { ids: [row.id] }, key),
);
expect(replay.status).toBe(200);
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
expect(((await replay.json()) as { data: { deleted: number } }).data.deleted).toBe(1);
// Measurement.syncVersion defaults to 1; the single real delete bumped
// it to 2 and the replay did NOT re-run the mutation (so it stays 2).
const after = await prisma.measurement.findUnique({ where: { id: row.id } });
expect(after!.syncVersion).toBe(2);
});
it("rejects a >200-id batch with 422", async () => {
await loginAs(USER_A);
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
const ids = Array.from({ length: 201 }, (_, i) => `id-${i}`);
const res = await POST(postReq("/api/measurements/bulk-delete", { ids }));
expect(res.status).toBe(422);
});
});
describe("POST /api/mood-entries/bulk-delete (real Postgres)", () => {
it("soft-deletes owned rows, skips a foreign id, bumps syncVersion", async () => {
const prisma = getPrismaClient();
const mine = await prisma.moodEntry.createManyAndReturn({
data: [
{ userId: USER_A, date: "2026-01-01", mood: "GUT", score: 4, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T08:00:00Z"), tz: "UTC" },
{ userId: USER_A, date: "2026-01-02", mood: "OKAY", score: 3, source: "MANUAL", moodLoggedAt: new Date("2026-01-02T08:00:00Z"), tz: "UTC" },
],
});
const theirs = await prisma.moodEntry.create({
data: { userId: USER_B, date: "2026-01-01", mood: "LAUSIG", score: 1, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T09:00:00Z"), tz: "UTC" },
});
await loginAs(USER_A);
const { POST } = await import("@/app/api/mood-entries/bulk-delete/route");
const res = await POST(
postReq("/api/mood-entries/bulk-delete", {
ids: [mine[0].id, mine[1].id, theirs.id],
}),
);
expect(res.status).toBe(200);
expect(((await res.json()) as { data: { deleted: number } }).data.deleted).toBe(2);
const after = await prisma.moodEntry.findMany({
where: { id: { in: [mine[0].id, mine[1].id] } },
});
for (const row of after) {
expect(row.deletedAt).not.toBeNull();
expect(row.syncVersion).toBe(1);
}
const theirsAfter = await prisma.moodEntry.findUnique({
where: { id: theirs.id },
});
expect(theirsAfter!.deletedAt).toBeNull();
});
});
describe("source filter narrows the list reads (real Postgres)", () => {
it("measurements sourceEq returns only the matching source", async () => {
const prisma = getPrismaClient();
const now = new Date();
await prisma.measurement.createMany({
data: [
{ userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: now },
{ userId: USER_A, type: "WEIGHT", value: 81, unit: "kg", source: "WITHINGS", measuredAt: new Date(now.getTime() - 1000) },
{ userId: USER_A, type: "WEIGHT", value: 82, unit: "kg", source: "WITHINGS", measuredAt: new Date(now.getTime() - 2000) },
],
});
await loginAs(USER_A);
const { GET } = await import("@/app/api/measurements/route");
const res = await GET(
new NextRequest("http://localhost/api/measurements?sourceEq=WITHINGS"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
measurements: Array<{ source: string }>;
meta: { total: number };
};
};
expect(body.data.measurements).toHaveLength(2);
expect(body.data.measurements.every((m) => m.source === "WITHINGS")).toBe(true);
expect(body.data.meta.total).toBe(2);
});
it("mood source returns only the matching source", async () => {
const prisma = getPrismaClient();
await prisma.moodEntry.createMany({
data: [
{ userId: USER_A, date: "2026-01-01", mood: "GUT", score: 4, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T08:00:00Z"), tz: "UTC" },
{ userId: USER_A, date: "2026-01-02", mood: "OKAY", score: 3, source: "TELEGRAM", moodLoggedAt: new Date("2026-01-02T08:00:00Z"), tz: "UTC" },
],
});
await loginAs(USER_A);
const { GET } = await import("@/app/api/mood-entries/route");
const res = await GET(
new NextRequest("http://localhost/api/mood-entries?source=TELEGRAM"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { entries: Array<{ source: string }>; meta: { total: number } };
};
expect(body.data.entries).toHaveLength(1);
expect(body.data.entries[0].source).toBe("TELEGRAM");
expect(body.data.meta.total).toBe(1);
});
});