mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(measurements): shared filter bar and multi-select bulk delete
Extract the duplicated list chrome — SortableHead and the single-row DeleteButton — into a shared src/components/data-list/ module and add a page-scoped multi-select with a selection action bar, used by both the measurements and mood management lists. Filter bar: each list keeps its primary Select (type / mood level) and gains a source Select (measurements sends sourceEq, mood sends source) plus an optional date range wired to the existing from/to params. Every filter resets pagination to page 1 and is baked into the query key via the centralised factory (queryKeys.measurementsList / moodEntriesList) so the cache stays correct per filter combination. Multi-select is scoped to the current page: a leading checkbox column on desktop, a checkbox on the mobile cards, and a select-all-on-page header checkbox. When at least one row is selected an action bar shows the count, a clear affordance, and a destructive Delete (N) behind an AlertDialog confirm that POSTs to the bulk-delete route, then invalidates the same dependent-key bundle the single delete busts and clears the selection. Selection clears on any page / filter / sort change; the selection math lives in a pure, unit-tested helper. The locked-type insights subpage keeps its focused single-metric view (source filter + date range suppressed). New strings land in a dataList namespace plus per-list source-filter and bulk-delete keys, translated across all six locales.
This commit is contained in:
1 parent
ca262d9839
commit
f2bd051fa7
17 files changed
+1515
-254
No files matched your search
+30
-2
@@ -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",
|
||||
@@ -598,7 +606,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 +5320,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"
|
||||
}
|
||||
}
|
||||
+30
-2
@@ -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",
|
||||
@@ -598,7 +606,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 +5320,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"
|
||||
}
|
||||
}
|
||||
+30
-2
@@ -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",
|
||||
@@ -598,7 +606,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 +5320,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"
|
||||
}
|
||||
}
|
||||
+30
-2
@@ -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",
|
||||
@@ -598,7 +606,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 +5320,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"
|
||||
}
|
||||
}
|
||||
+30
-2
@@ -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",
|
||||
@@ -598,7 +606,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 +5320,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"
|
||||
}
|
||||
}
|
||||
+30
-2
@@ -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",
|
||||
@@ -598,7 +606,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 +5320,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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,114 @@
|
||||
"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")}
|
||||
className="bg-card border-border sticky bottom-2 z-10 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>
|
||||
);
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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,26 @@ 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 && (
|
||||
<label className="flex size-11 shrink-0 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(m.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{Icon && (
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${TYPE_COLORS[m.type] ?? ""}`}
|
||||
@@ -898,7 +1174,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 +1202,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 +1231,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 +1241,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 +1574,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<");
|
||||
});
|
||||
});
|
||||
+283
-117
@@ -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,24 @@ 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 — multi-select checkbox in a 44px target. */}
|
||||
<label className="flex size-11 shrink-0 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(entry.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
/>
|
||||
</label>
|
||||
<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 +749,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">
|
||||
@@ -569,7 +789,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
onClick={() => goToPage((p) => p - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -577,7 +797,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
onClick={() => goToPage((p) => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -797,85 +1017,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;
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user