diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e2f0601c3..6bc757ed4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -15,9 +15,19 @@ concurrency: cancel-in-progress: true jobs: - e2e: + # The suite outgrew the runner the same way the integration suite did: the + # single job passed at ~18 minutes against a 20-minute cap, and the settings + # geometry measurements pushed it over. Same remedy, split rather than raise: + # two Playwright shards, each with its own database and production build, and + # a gate job below that carries the exact name branch protection requires. + shard: runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: [1, 2] + name: e2e shard ${{ matrix.shard }}/2 services: postgres: image: postgres:16-alpine @@ -87,7 +97,7 @@ jobs: # chart-runtime boundary (a duplicated ~92 KB gz recharts chunk). - run: pnpm bundle-check - - run: pnpm exec playwright test + - run: pnpm exec playwright test --shard=${{ matrix.shard }}/2 env: E2E_BASE_URL: http://localhost:3000 @@ -95,7 +105,23 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: playwright-report + name: playwright-report-shard-${{ matrix.shard }} path: playwright-report/ retention-days: 7 if-no-files-found: ignore + + # Branch protection requires a check named exactly "e2e". This job carries + # that name and answers for both shards: it fails when any shard failed or + # was cancelled, so a half-green matrix cannot satisfy the gate. + e2e: + runs-on: ubuntu-latest + needs: shard + if: always() + timeout-minutes: 5 + steps: + - name: Every shard finished green + run: | + if [ "${{ needs.shard.result }}" != "success" ]; then + echo "shard result: ${{ needs.shard.result }}" + exit 1 + fi diff --git a/.gitleaks.toml b/.gitleaks.toml index 1b98529ce..b5d9433d0 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -28,6 +28,10 @@ regexes = [ # Coach cadence-suggestion label keys are dotted message keys, not secrets; # one trips the generic-api-key entropy heuristic. '''coach\.reminderSuggestion\.cadence''', + # Level-A mood dimension label and anchor keys are dotted message keys, not + # secrets; they sit on fields named `labelKey` / `lowAnchorKey`, and the + # generic-api-key rule reads any `*Key: ""` as a token. + '''mood\.dimension\.''', ] # Specific commit fingerprints from prior false-positive findings (kept for reproducibility). diff --git a/CHANGELOG.md b/CHANGELOG.md index 540c25071..4966d947b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ ## [Unreleased] +### Added + +- A mood entry now records five separate values instead of one. The quick + check-in is unchanged: pick a face, save, done in a tap, and its answer maps + onto a new pleasantness scale so nothing about that entry is second class. + Underneath it, a section that stays closed until you open it holds five + sliders running 0 to 10, each with wording at both ends: how the day felt, + how much strain it carried, how much energy there was, how connected you + were, and how able to act you felt. Anything you leave alone stays empty + rather than landing in the middle, and clearing a slider takes the answer + back. Entries you have already logged carry the mapped value for the first + scale and nothing invented for the other four. + +- The mood page trends all five, over seven, thirty or ninety days, each drawn + the way you answered it. A scale you have never filled in is named as not + recorded rather than drawn flat through the middle. The daily briefing and + the assistant read all five as well, each with its own age, so a value from + last week is stated with its date instead of as today's. + +- Factor ratings are no longer offered on a new entry. A one-to-five score for + work stress and a nought-to-ten stress scale are two answers to the same + question, and only the second is asked now. Nothing was removed: ratings + already logged still show, still edit, and still feed the breakdowns that + read them. + ### Security - The PDF renderer behind the document vault moves to a release that closes a diff --git a/SECURITY.md b/SECURITY.md index 787a2c731..5210c4ba8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -64,7 +64,7 @@ For more details, see the [security documentation](https://docs.healthlog.dev/se HealthLog Docker images are built and published from this repository's CI: -- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — runs on every push to `main` and every `v*` tag +- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — publishes on every `v*` tag; pull requests build the image without publishing it - **Registry**: [`ghcr.io/mbombeck/healthlog`](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog) - **Architectures**: `linux/amd64` and `linux/arm64` - **Provenance attestation**: each image carries a [SLSA build provenance](https://slsa.dev/spec/v1.0/provenance) statement linking it back to the GitHub Actions run, the commit SHA, and the workflow definition diff --git a/docs/self-hosting/geolite2.md b/docs/self-hosting/geolite2.md index b9dacd0a0..4e69c1c3e 100644 --- a/docs/self-hosting/geolite2.md +++ b/docs/self-hosting/geolite2.md @@ -3,15 +3,15 @@ The admin login overview shows a coarse `City, CC` location and a carrier next to every sign-in. Two tiers can produce that: -- **Online.** A third-party lookup, `https://ipwho.is` by default. Free, - keyless, and on by default — which also means every login IP address of - every account on the instance leaves the host. - **Offline.** MaxMind's GeoLite2-City and GeoLite2-ASN databases read from a local directory. The ASN database is the authoritative source - for the carrier and the AS number. Note that the resolver is - online-first: having the databases in place is not by itself enough to - stop the outbound lookup, see "Turning the online lookup off entirely" - below. + for the carrier and the AS number. The resolver reads these first: an + address the local databases can place never leaves the host. +- **Online.** A third-party lookup, `https://ipwho.is` by default. Free, + keyless, and the only tier on a host without the databases — which + means every login IP address of every account on that instance leaves + the host. With the databases mounted it sees only the addresses they + could not place, and you can switch it off entirely (below). The offline tier is optional and absent by default. The published image only carries the databases when the image build had a MaxMind licence key @@ -73,8 +73,8 @@ is the subject of this page. ## Turning the online lookup off entirely -Once the offline tier resolves, the third-party lookup is redundant. To -stop all IP egress: +With the databases mounted, the third-party lookup already only sees +addresses they could not place. To remove that remainder too: ```env IP_GEO_LOOKUP_DISABLED="1" @@ -127,6 +127,8 @@ the value is in `.env` but not on the `environment:` whitelist — compare against `docker-compose.yml`. **The carrier column is empty.** The carrier also comes from the online -provider's ISP field, so it is normally filled even without the ASN -database. If both tiers are off (`IP_GEO_LOOKUP_DISABLED="1"` and no -`GeoLite2-ASN.mmdb`), there is no source for it and it stays empty. +provider's ISP field, but the online lookup only runs for an address the +City database could not place. On a host with `GeoLite2-City.mmdb` and no +`GeoLite2-ASN.mmdb`, a location that resolves locally has no carrier to +go with it — mount the ASN database too. With neither database, the +online provider fills both, unless `IP_GEO_LOOKUP_DISABLED="1"` is set. diff --git a/eslint.config.mjs b/eslint.config.mjs index 9f0e9130f..36fb7684e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -126,6 +126,7 @@ const eslintConfig = defineConfig([ "src/components/cycle/bbt-chart.tsx", "src/components/insights/derived/delta-sparkline.tsx", "src/components/insights/intraday-pulse-chart.tsx", + "src/components/insights/mood/mood-dimension-trends.tsx", "src/components/insights/mood/mood-distribution-chart.tsx", "src/components/insights/mood/mood-time-of-day-chart.tsx", "src/components/insights/mood/mood-weekday-chart.tsx", diff --git a/messages/de.json b/messages/de.json index 002901486..329c13522 100644 --- a/messages/de.json +++ b/messages/de.json @@ -887,11 +887,42 @@ "iconGroupMisc": "Sonstiges" }, "sectionMoreTags": "Weitere Tags", - "sectionFactors": "Faktoren", "sectionNoteDetails": "Notiz & Details", "quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt", "quickTagsExpand": "Weitere", - "loadError": "Stimmungseinträge konnten nicht geladen werden." + "loadError": "Stimmungseinträge konnten nicht geladen werden.", + "sectionLevelA": "Fünf Skalen", + "sectionLevelAHelp": "Optional. Was du nicht angibst, bleibt leer.", + "dimensionNotAnswered": "nicht angegeben", + "dimensionClear": "leeren", + "dimensionValueText": "{value} von {max}: {low} bis {high}", + "dimension": { + "a1": { + "label": "Stimmung", + "low": "sehr unangenehm", + "high": "sehr angenehm" + }, + "a2": { + "label": "Stress", + "low": "vollkommen entspannt", + "high": "extrem belastet" + }, + "a3": { + "label": "Energie", + "low": "erschöpft", + "high": "energiegeladen" + }, + "a4": { + "label": "Verbundenheit", + "low": "isoliert", + "high": "verstanden und unterstützt" + }, + "a5": { + "label": "Stabilität", + "low": "vollständig überfordert", + "high": "sehr handlungsfähig" + } + } }, "medications": { "title": "Medikamente", @@ -3613,7 +3644,15 @@ "statLabel": "Wie das berechnet wurde", "explainerLabel": "Wie das berechnet wurde" }, - "inTargetTitle": "Im Zielbereich" + "inTargetTitle": "Im Zielbereich", + "dimensions": { + "title": "Fünf Skalen", + "none": "Für diese fünf Skalen ist noch nichts erfasst.", + "window": "{days} Tage", + "noneInWindow": "nichts im Zeitraum", + "higherIsMore": "höher = {anchor}", + "notRecorded": "Nicht erfasst: {list}." + } }, "derived": { "coverage": { @@ -6485,13 +6524,13 @@ "outcomeFailed": "Fehlgeschlagen", "auditDetails": "Details", "deleteAllData": "Alle Daten global löschen", - "deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Nutzer.", + "deleteAllDescription": "Löscht alle Datensätze aller Konten; die Konten selbst und ihre Zugangsdaten bleiben erhalten.", "deleteAllConfirm": "Alle Daten wirklich löschen?", - "deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen, offene Authentifizierungs-Challenges, Benachrichtigungskanäle (Telegram, ntfy, Web Push), Push-Abonnements und in der Warteschlange befindliche Telegram-Nachrichtenlöschungen aller Nutzer unwiderruflich. Nutzerkonten, das Audit-Log und eingereichtes Feedback bleiben erhalten, damit der Löschvorgang nachvollziehbar bleibt und Bug-Reports weiter triagierbar sind.", + "deleteAllConfirmDescription": "Dieser Vorgang löscht unwiderruflich alle Datensätze aller Konten in der gesamten Datenbank, nicht eine Auswahl von Tabellen. Erhalten bleiben: die Nutzerkonten und ihre Anmelde-Zugangsdaten, die Konfiguration der Instanz und die Berechtigungen, über die betreute Profile erreichbar bleiben. Ein Beleg für diesen Vorgang wird danach ins Audit-Log geschrieben; die Audit-Historie davor geht mit allem anderen.", "deleteButton": "Alle Daten löschen", "deleteAllPromptLabel": "Tippe {token} zum Fortfahren", "finalDelete": "Endgültig löschen", - "deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen, {apiTokens} API-Tokens, {withingsConnections} Withings-Verbindungen, {authChallenges} Auth-Challenges, {notificationChannels} Benachrichtigungskanäle, {pushSubscriptions} Push-Abonnements, {telegramScheduledDeletions} eingeplante Telegram-Löschungen. Feedback bleibt erhalten.", + "deletedResult": "Gelöscht: {rows} Zeilen in {tables} Tabellen. Nutzerkonten und Anmelde-Zugangsdaten bleiben erhalten.", "authRegister": "Registrierung", "authLogin": "Anmeldung", "authLoginPasskey": "Passkey-Anmeldung", diff --git a/messages/en.json b/messages/en.json index 5de55fb34..76cc7a265 100644 --- a/messages/en.json +++ b/messages/en.json @@ -887,11 +887,42 @@ "iconGroupMisc": "Other" }, "sectionMoreTags": "More tags", - "sectionFactors": "Factors", "sectionNoteDetails": "Note & details", "quickTagsHelp": "Your frequent tags — one tap", "quickTagsExpand": "More", - "loadError": "Could not load your mood entries." + "loadError": "Could not load your mood entries.", + "sectionLevelA": "Five scales", + "sectionLevelAHelp": "Optional. Anything you leave alone stays empty.", + "dimensionNotAnswered": "not recorded", + "dimensionClear": "clear", + "dimensionValueText": "{value} of {max}: {low} to {high}", + "dimension": { + "a1": { + "label": "Mood", + "low": "very unpleasant", + "high": "very pleasant" + }, + "a2": { + "label": "Stress", + "low": "completely relaxed", + "high": "extremely strained" + }, + "a3": { + "label": "Energy", + "low": "drained", + "high": "full of energy" + }, + "a4": { + "label": "Connectedness", + "low": "isolated", + "high": "understood and supported" + }, + "a5": { + "label": "Stability", + "low": "completely overwhelmed", + "high": "fully able to act" + } + } }, "medications": { "title": "Medications", @@ -3613,7 +3644,15 @@ "statLabel": "How this was computed", "explainerLabel": "How this was computed" }, - "inTargetTitle": "In your range" + "inTargetTitle": "In your range", + "dimensions": { + "title": "Five scales", + "none": "Nothing recorded for these five scales yet.", + "window": "{days} days", + "noneInWindow": "nothing in this range", + "higherIsMore": "higher = {anchor}", + "notRecorded": "Not recorded: {list}." + } }, "derived": { "coverage": { @@ -6485,13 +6524,13 @@ "outcomeFailed": "Failed", "auditDetails": "Details", "deleteAllData": "Delete all data globally", - "deleteAllDescription": "Deletes all measurement, medication, integration, and log data for all users.", + "deleteAllDescription": "Deletes every record every account holds; the accounts and their sign-in credentials are kept.", "deleteAllConfirm": "Really delete all data?", - "deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake events, API tokens, Withings connections, open authentication challenges, notification channels (Telegram, ntfy, Web Push), push subscriptions, and queued Telegram message deletions for all users. User accounts, the audit log, and submitted feedback are preserved so the wipe itself remains traceable and bug reports stay triageable.", + "deleteAllConfirmDescription": "This permanently deletes every account's records across the whole database, not a selection of tables. Kept: the user accounts and the credentials that sign into them, the instance configuration, and the delegation grants that keep managed profiles reachable. A receipt for this wipe is written to the audit log afterwards; the audit history before it goes with everything else.", "deleteButton": "Delete all data", "deleteAllPromptLabel": "Type {token} to continue", "finalDelete": "Delete permanently", - "deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.", + "deletedResult": "Deleted {rows} rows across {tables} tables. User accounts and sign-in credentials were kept.", "authRegister": "Registration", "authLogin": "Login", "authLoginPasskey": "Passkey login", diff --git a/messages/es.json b/messages/es.json index fbb40c18b..9dadf056b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -887,11 +887,42 @@ "iconGroupMisc": "Otros" }, "sectionMoreTags": "Más etiquetas", - "sectionFactors": "Factores", "sectionNoteDetails": "Nota y detalles", "quickTagsHelp": "Tus etiquetas frecuentes: un toque", "quickTagsExpand": "Más", - "loadError": "No se pudieron cargar tus registros de estado de ánimo." + "loadError": "No se pudieron cargar tus registros de estado de ánimo.", + "sectionLevelA": "Cinco escalas", + "sectionLevelAHelp": "Opcional. Lo que no toques queda vacío.", + "dimensionNotAnswered": "sin indicar", + "dimensionClear": "borrar", + "dimensionValueText": "{value} de {max}: {low} a {high}", + "dimension": { + "a1": { + "label": "Estado de ánimo", + "low": "muy desagradable", + "high": "muy agradable" + }, + "a2": { + "label": "Estrés", + "low": "completamente relajado", + "high": "extremadamente tenso" + }, + "a3": { + "label": "Energía", + "low": "agotado", + "high": "lleno de energía" + }, + "a4": { + "label": "Conexión", + "low": "aislado", + "high": "comprendido y apoyado" + }, + "a5": { + "label": "Estabilidad", + "low": "completamente desbordado", + "high": "con plena capacidad de acción" + } + } }, "medications": { "title": "Medicamentos", @@ -3613,7 +3644,15 @@ "statLabel": "Cómo se calculó esto", "explainerLabel": "Cómo se calculó esto" }, - "inTargetTitle": "En tu rango" + "inTargetTitle": "En tu rango", + "dimensions": { + "title": "Cinco escalas", + "none": "Todavía no hay nada registrado en estas cinco escalas.", + "window": "{days} días", + "noneInWindow": "nada en este periodo", + "higherIsMore": "más alto = {anchor}", + "notRecorded": "Sin registrar: {list}." + } }, "derived": { "coverage": { @@ -6485,13 +6524,13 @@ "outcomeFailed": "Fallido", "auditDetails": "Detalles", "deleteAllData": "Eliminar todos los datos globalmente", - "deleteAllDescription": "Elimina todos los datos de mediciones, medicación, integraciones y registros de todos los usuarios.", + "deleteAllDescription": "Elimina todos los registros de todas las cuentas; las cuentas y sus credenciales de inicio de sesión se conservan.", "deleteAllConfirm": "¿Eliminar realmente todos los datos?", - "deleteAllConfirmDescription": "Esto eliminará permanentemente todas las mediciones, medicamentos, eventos de toma, tokens de API, conexiones de Withings, retos de autenticación abiertos, canales de notificación (Telegram, ntfy, Web Push), suscripciones push y eliminaciones de mensajes de Telegram en cola de todos los usuarios. Las cuentas de usuario, el registro de auditoría y los comentarios enviados se conservan para que el borrado siga siendo rastreable y los informes de errores se puedan seguir clasificando.", + "deleteAllConfirmDescription": "Esto elimina permanentemente todos los registros de todas las cuentas en toda la base de datos, no una selección de tablas. Se conservan: las cuentas de usuario y sus credenciales de inicio de sesión, la configuración de la instancia y las concesiones de acceso que mantienen accesibles los perfiles gestionados. Después se escribe un comprobante de este borrado en el registro de auditoría; el historial anterior se va con todo lo demás.", "deleteButton": "Eliminar todos los datos", "deleteAllPromptLabel": "Escribe {token} para continuar", "finalDelete": "Eliminar permanentemente", - "deletedResult": "Deleted: {measurements} measurements, {medications} Medicamentos,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.", + "deletedResult": "Eliminadas {rows} filas en {tables} tablas. Se conservaron las cuentas de usuario y las credenciales de inicio de sesión.", "authRegister": "Registro", "authLogin": "Iniciar sesión", "authLoginPasskey": "Inicio de sesión con passkey", diff --git a/messages/fr.json b/messages/fr.json index d7bb3e704..553da5fe7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -887,11 +887,42 @@ "iconGroupMisc": "Autres" }, "sectionMoreTags": "Plus de tags", - "sectionFactors": "Facteurs", "sectionNoteDetails": "Note et détails", "quickTagsHelp": "Vos tags fréquents — en un geste", "quickTagsExpand": "Plus", - "loadError": "Impossible de charger vos entrées d'humeur." + "loadError": "Impossible de charger vos entrées d'humeur.", + "sectionLevelA": "Cinq échelles", + "sectionLevelAHelp": "Facultatif. Ce que vous ne touchez pas reste vide.", + "dimensionNotAnswered": "non renseigné", + "dimensionClear": "effacer", + "dimensionValueText": "{value} sur {max} : {low} à {high}", + "dimension": { + "a1": { + "label": "Humeur", + "low": "très désagréable", + "high": "très agréable" + }, + "a2": { + "label": "Stress", + "low": "parfaitement détendu", + "high": "extrêmement tendu" + }, + "a3": { + "label": "Énergie", + "low": "épuisé", + "high": "plein d'énergie" + }, + "a4": { + "label": "Lien social", + "low": "isolé", + "high": "compris et soutenu" + }, + "a5": { + "label": "Stabilité", + "low": "complètement dépassé", + "high": "pleinement capable d'agir" + } + } }, "medications": { "title": "Médicaments", @@ -3613,7 +3644,15 @@ "statLabel": "Comment ceci est calculé", "explainerLabel": "Comment ceci est calculé" }, - "inTargetTitle": "Dans votre plage" + "inTargetTitle": "Dans votre plage", + "dimensions": { + "title": "Cinq échelles", + "none": "Rien n'est encore enregistré pour ces cinq échelles.", + "window": "{days} jours", + "noneInWindow": "rien sur cette période", + "higherIsMore": "plus haut = {anchor}", + "notRecorded": "Non renseigné : {list}." + } }, "derived": { "coverage": { @@ -6485,13 +6524,13 @@ "outcomeFailed": "Échec", "auditDetails": "Détails", "deleteAllData": "Supprimer toutes les données globalement", - "deleteAllDescription": "Supprime toutes les données de mesures, de médicaments, d'intégrations et de journaux pour tous les utilisateurs.", + "deleteAllDescription": "Supprime tous les enregistrements de tous les comptes ; les comptes et leurs identifiants de connexion sont conservés.", "deleteAllConfirm": "Vraiment supprimer toutes les données ?", - "deleteAllConfirmDescription": "Cette action supprimera définitivement toutes les mesures, médicaments, événements de prise, jetons d'API, connexions Withings, défis d'authentification ouverts, canaux de notification (Telegram, ntfy, Web Push), abonnements push et suppressions de messages Telegram en file d'attente pour tous les utilisateurs. Les comptes utilisateurs, le journal d'audit et les retours soumis sont conservés afin que l'effacement reste traçable et que les rapports de bug restent triables.", + "deleteAllConfirmDescription": "Cette action supprime définitivement tous les enregistrements de tous les comptes dans l'ensemble de la base de données, et non une sélection de tables. Sont conservés : les comptes utilisateurs et leurs identifiants de connexion, la configuration de l'instance et les délégations qui gardent les profils gérés accessibles. Un reçu de cet effacement est ensuite écrit dans le journal d'audit ; l'historique antérieur part avec le reste.", "deleteButton": "Supprimer toutes les données", "deleteAllPromptLabel": "Tapez {token} pour continuer", "finalDelete": "Supprimer définitivement", - "deletedResult": "Deleted: {measurements} measurements, {medications} Médicaments,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.", + "deletedResult": "{rows} lignes supprimées dans {tables} tables. Les comptes utilisateurs et les identifiants de connexion ont été conservés.", "authRegister": "Inscription", "authLogin": "Connexion", "authLoginPasskey": "Connexion par passkey", diff --git a/messages/it.json b/messages/it.json index ed3102ce9..792bb59bf 100644 --- a/messages/it.json +++ b/messages/it.json @@ -887,11 +887,42 @@ "iconGroupMisc": "Altro" }, "sectionMoreTags": "Altri tag", - "sectionFactors": "Fattori", "sectionNoteDetails": "Nota e dettagli", "quickTagsHelp": "I tuoi tag frequenti: un tocco", "quickTagsExpand": "Altro", - "loadError": "Impossibile caricare le voci dell'umore." + "loadError": "Impossibile caricare le voci dell'umore.", + "sectionLevelA": "Cinque scale", + "sectionLevelAHelp": "Facoltativo. Quello che non tocchi resta vuoto.", + "dimensionNotAnswered": "non indicato", + "dimensionClear": "cancella", + "dimensionValueText": "{value} su {max}: da {low} a {high}", + "dimension": { + "a1": { + "label": "Umore", + "low": "molto spiacevole", + "high": "molto piacevole" + }, + "a2": { + "label": "Stress", + "low": "completamente rilassato", + "high": "estremamente sotto pressione" + }, + "a3": { + "label": "Energia", + "low": "esausto", + "high": "pieno di energia" + }, + "a4": { + "label": "Connessione", + "low": "isolato", + "high": "capito e sostenuto" + }, + "a5": { + "label": "Stabilità", + "low": "completamente sopraffatto", + "high": "pienamente capace di agire" + } + } }, "medications": { "title": "Farmaci", @@ -3613,7 +3644,15 @@ "statLabel": "Come è stato calcolato", "explainerLabel": "Come è stato calcolato" }, - "inTargetTitle": "Nel tuo intervallo" + "inTargetTitle": "Nel tuo intervallo", + "dimensions": { + "title": "Cinque scale", + "none": "Per queste cinque scale non c'è ancora nulla.", + "window": "{days} giorni", + "noneInWindow": "nulla in questo periodo", + "higherIsMore": "più alto = {anchor}", + "notRecorded": "Non registrato: {list}." + } }, "derived": { "coverage": { @@ -6485,13 +6524,13 @@ "outcomeFailed": "Non riuscito", "auditDetails": "Dettagli", "deleteAllData": "Elimina tutti i dati globalmente", - "deleteAllDescription": "Elimina tutti i dati di misurazioni, farmaci, integrazioni e log di tutti gli utenti.", + "deleteAllDescription": "Elimina tutti i record di tutti gli account; gli account e le credenziali di accesso vengono conservati.", "deleteAllConfirm": "Eliminare davvero tutti i dati?", - "deleteAllConfirmDescription": "Questa operazione eliminerà definitivamente tutte le misurazioni, i farmaci, gli eventi di assunzione, i token API, le connessioni Withings, le challenge di autenticazione aperte, i canali di notifica (Telegram, ntfy, Web Push), le sottoscrizioni push e le eliminazioni di messaggi Telegram in coda di tutti gli utenti. Gli account utente, il log di audit e i feedback inviati vengono conservati affinché la cancellazione resti tracciabile e le segnalazioni di bug restino gestibili.", + "deleteAllConfirmDescription": "Questa operazione elimina definitivamente tutti i record di tutti gli account nell'intero database, non una selezione di tabelle. Vengono conservati: gli account utente e le loro credenziali di accesso, la configurazione dell'istanza e le deleghe che mantengono raggiungibili i profili gestiti. Al termine nel log di audit viene scritta una ricevuta di questa cancellazione; la cronologia precedente se ne va con tutto il resto.", "deleteButton": "Elimina tutti i dati", "deleteAllPromptLabel": "Digita {token} per continuare", "finalDelete": "Elimina definitivamente", - "deletedResult": "Deleted: {measurements} measurements, {medications} Farmaci,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.", + "deletedResult": "Eliminate {rows} righe in {tables} tabelle. Account utente e credenziali di accesso conservati.", "authRegister": "Registrazione", "authLogin": "Accedi", "authLoginPasskey": "Accesso con passkey", diff --git a/messages/pl.json b/messages/pl.json index fb438950b..0c8475bde 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -887,11 +887,42 @@ "iconGroupMisc": "Inne" }, "sectionMoreTags": "Więcej tagów", - "sectionFactors": "Czynniki", "sectionNoteDetails": "Notatka i szczegóły", "quickTagsHelp": "Twoje częste tagi — jedno dotknięcie", "quickTagsExpand": "Więcej", - "loadError": "Nie udało się załadować wpisów nastroju." + "loadError": "Nie udało się załadować wpisów nastroju.", + "sectionLevelA": "Pięć skal", + "sectionLevelAHelp": "Opcjonalne. To, czego nie ruszysz, zostaje puste.", + "dimensionNotAnswered": "nie podano", + "dimensionClear": "wyczyść", + "dimensionValueText": "{value} z {max}: od {low} do {high}", + "dimension": { + "a1": { + "label": "Nastrój", + "low": "bardzo nieprzyjemnie", + "high": "bardzo przyjemnie" + }, + "a2": { + "label": "Stres", + "low": "całkowicie odprężony", + "high": "skrajnie obciążony" + }, + "a3": { + "label": "Energia", + "low": "wyczerpany", + "high": "pełen energii" + }, + "a4": { + "label": "Więź", + "low": "odizolowany", + "high": "rozumiany i wspierany" + }, + "a5": { + "label": "Stabilność", + "low": "całkowicie przytłoczony", + "high": "w pełni zdolny do działania" + } + } }, "medications": { "title": "Leki", @@ -3613,7 +3644,15 @@ "statLabel": "Jak to obliczono", "explainerLabel": "Jak to obliczono" }, - "inTargetTitle": "W Twoim zakresie" + "inTargetTitle": "W Twoim zakresie", + "dimensions": { + "title": "Pięć skal", + "none": "Dla tych pięciu skal nie ma jeszcze żadnych danych.", + "window": "{days} dni", + "noneInWindow": "brak w tym okresie", + "higherIsMore": "wyżej = {anchor}", + "notRecorded": "Nie zapisano: {list}." + } }, "derived": { "coverage": { @@ -6485,13 +6524,13 @@ "outcomeFailed": "Nieudane", "auditDetails": "Szczegóły", "deleteAllData": "Usuń wszystkie dane globalnie", - "deleteAllDescription": "Usuwa wszystkie dane pomiarów, leków, integracji i logów wszystkich użytkowników.", + "deleteAllDescription": "Usuwa wszystkie rekordy wszystkich kont; konta i dane logowania zostają zachowane.", "deleteAllConfirm": "Na pewno usunąć wszystkie dane?", - "deleteAllConfirmDescription": "Spowoduje to trwałe usunięcie wszystkich pomiarów, leków, zdarzeń przyjęcia, tokenów API, połączeń Withings, otwartych wyzwań uwierzytelniania, kanałów powiadomień (Telegram, ntfy, Web Push), subskrypcji push oraz zakolejkowanych usunięć wiadomości Telegram wszystkich użytkowników. Konta użytkowników, dziennik audytu i przesłane opinie zostają zachowane, aby samo usunięcie pozostało możliwe do prześledzenia, a zgłoszenia błędów — do obsłużenia.", + "deleteAllConfirmDescription": "Ta operacja trwale usuwa wszystkie rekordy wszystkich kont w całej bazie danych, a nie wybrane tabele. Zachowane zostają: konta użytkowników i ich dane logowania, konfiguracja instancji oraz uprawnienia, dzięki którym profile zarządzane pozostają dostępne. Po zakończeniu do dziennika audytu trafia potwierdzenie tego usunięcia; wcześniejsza historia znika razem z resztą.", "deleteButton": "Usuń wszystkie dane", "deleteAllPromptLabel": "Wpisz {token}, aby kontynuować", "finalDelete": "Usuń trwale", - "deletedResult": "Deleted: {measurements} measurements, {medications} Leki,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.", + "deletedResult": "Usunięto {rows} wierszy w {tables} tabelach. Konta użytkowników i dane logowania zostały zachowane.", "authRegister": "Rejestracja", "authLogin": "Zaloguj", "authLoginPasskey": "Logowanie kluczem passkey", diff --git a/prisma/migrations/0310_mood_level_a_dimensions/migration.sql b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql new file mode 100644 index 000000000..e07fec8ee --- /dev/null +++ b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql @@ -0,0 +1,49 @@ +-- v1.37 — the five level-A self-state values on a mood entry. +-- +-- A mood entry has carried one 1-5 value since the first release. That value +-- keeps its scale and its meaning; these five ride beside it, each 0-10 with +-- its own anchors: pleasantness, stress, energy, connectedness and stability. +-- +-- All five are nullable, which is what makes this an additive change rather +-- than a table rewrite, and nullable is also the honest shape: only the first +-- one can be derived from what a row already carries, and the other four stay +-- empty until somebody answers them. A default would fill a hundred thousand +-- historical rows with a number nobody chose. +-- +-- No CHECK constraint on the range. Bounds are enforced server-side, the same +-- way `mood_entry_tag_links.rating` documents: a future change to the scale +-- must not strand rows written under the old one. +ALTER TABLE "mood_entries" + ADD COLUMN "mood_a1" INTEGER, + ADD COLUMN "stress_a2" INTEGER, + ADD COLUMN "energy_a3" INTEGER, + ADD COLUMN "connection_a4" INTEGER, + ADD COLUMN "stability_a5" INTEGER; + +-- Map every entry written before these columns existed onto the pleasantness +-- scale. The five-point label is the only thing a historical row can answer +-- from, so it is the only column filled: stress, energy, connectedness and +-- stability stay NULL, because there is nothing to derive them from and an +-- invented number would be indistinguishable from one somebody gave. +-- +-- The anchors are 1/3/5/7/9, matching `MOOD_A1_MAP` in +-- `src/lib/validations/mood.ts` — the runtime map and these arms are compared +-- to each other by a test, so the two cannot drift apart. Not 0 and 10 at the +-- ends: the five-point endpoints mean as bad or as good as that instrument +-- can express, not as bad or as good as a person can feel, and pinning them +-- to the extremes would make every mapped row read as more extreme than every +-- hand-set one. +-- +-- `WHERE mood_a1 IS NULL` makes a re-run a no-op instead of an overwrite. A +-- migration re-applied against a database that has since taken real answers +-- must not replace them with the mapped value. +UPDATE mood_entries + SET mood_a1 = CASE mood + WHEN 'LAUSIG' THEN 1 + WHEN 'SCHLECHT' THEN 3 + WHEN 'OKAY' THEN 5 + WHEN 'GUT' THEN 7 + WHEN 'SUPER_GUT' THEN 9 + END + WHERE mood_a1 IS NULL + AND mood IN ('LAUSIG', 'SCHLECHT', 'OKAY', 'GUT', 'SUPER_GUT'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7adb641be..affc5e70c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4016,6 +4016,48 @@ model MoodEntry { date String // YYYY-MM-DD — anchored to `tz` when not null, else legacy Europe/Berlin mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG score Int // 1-5 + /// v1.37 — the five level-A self-state values, each 0-10 with its own + /// anchors. `MOOD_DIMENSIONS` (`src/lib/mood/dimensions.ts`) is the one + /// table binding a column to its label, its anchors and its orientation. + /// + /// `score` above stays the 1-5 legacy axis and is not re-scaled: eleven + /// surfaces derive numbers from it on that assumption, three of them into + /// prose an assistant then states as fact. The two axes coexist by design. + /// `moodA1` is derived from the five-point label on every write, so a quick + /// check-in is a full entry rather than a degraded one; A2 to A5 are only + /// ever set by hand, and stay NULL when nobody answered them. Absence reads + /// as absence — a fabricated middle value would poison both the trend and + /// any later model trained on these columns. + /// + /// Queryable plaintext, deliberately — the same trade `CycleDayLog` + /// documents at its `basalBodyTempC`. Encrypting them would remove the + /// trend read, the factor crosstab and every aggregate at once, and the + /// values are self-ratings on a fixed scale rather than prose. The free-text + /// note beside them stays encrypted, because that is where the prose is. + /// + /// `stressA2` is stored literally, exactly as the user set it against the + /// anchors they read, so a higher value means more stress and a worse day. + /// A surface that needs "up is better" flips the sign through the `inverse` + /// flag on the dimension table, never inline. The same rule already governs + /// `MoodTag.inverse`, and it exists because a stored value that disagrees + /// with the label the user saw cannot be recovered afterwards. + /// + /// A delegate holding a READ grant sees these columns, because a grant is + /// whole-record by design and carries no per-domain scope. That is + /// deliberate, not an oversight: withholding part of a record from a + /// delegate would be a new capability, not a field select. + /// + /// None of these reaches the health score, and that too is a decision. The + /// WELLBEING pillar reads PHQ-9, GAD-7 and WHO-5 and cites the papers its + /// thresholds come from; a daily self-rated slider is a different kind of + /// evidence, and averaging the two would weaken the line that makes the + /// score worth showing to a doctor. The reasoning is written out at + /// `src/lib/analytics/score/wellbeing.ts`. + moodA1 Int? @map("mood_a1") // 0-10, pleasantness + stressA2 Int? @map("stress_a2") // 0-10, inverse-oriented + energyA3 Int? @map("energy_a3") // 0-10 + connectionA4 Int? @map("connection_a4") // 0-10 + stabilityA5 Int? @map("stability_a5") // 0-10 tags String? // JSON array of tag keys /// v1.4.30 — first-class free-text note. Replaces the /// `tags: ["note:"]` workaround the iOS app shipped through @@ -4178,6 +4220,16 @@ model MoodTag { /// mood *factor* the user scores per entry (e.g. work, social, sleep /// quality); the score rides on `MoodEntryTagLink.rating`. Keeps one /// taxonomy + one ingestion path for both flavours. + /// + /// v1.37 — `RATED` is frozen, not removed. The capture sheet stopped + /// offering factor ratings when the level-A columns arrived on `MoodEntry`: + /// a 1-5 score for "work stress" and a 0-10 stress self-state are different + /// questions, and asking both on one entry invites two answers to one + /// thing. Nothing was deleted — no tag row, no link, no rating — so every + /// existing score keeps rendering, keeps editing and keeps feeding the + /// factor crosstab. Re-enabling the offer on the create sheet is a decision + /// about what to ask, not a bug fix; do not restore it because it looks + /// like an omission. kind String @default("BINARY") @map("kind") // "BINARY" | "RATED" /// v1.12.0 — inclusive lower / upper bound of a RATED factor's scale /// (e.g. 1..5, or 1..2 for a Yes/No factor). Ignored for BINARY tags. diff --git a/scripts/__tests__/ci-determinism.test.ts b/scripts/__tests__/ci-determinism.test.ts index 1a8cfac66..34bac3b29 100644 --- a/scripts/__tests__/ci-determinism.test.ts +++ b/scripts/__tests__/ci-determinism.test.ts @@ -77,14 +77,19 @@ describe("Playwright CI determinism", () => { const workflow = parse( readRepoFile(".github/workflows/e2e.yml"), ) as Workflow; - const upload = workflow.jobs.e2e?.steps?.find((step) => + // The suite runs as a shard matrix since the single job outgrew its + // timeout; the running job is `shard`, while `e2e` is the gate that + // carries the required check name and runs no browser. The report + // therefore lives on the shard job, named per shard so the two + // artifacts cannot clobber each other. + const upload = workflow.jobs.shard?.steps?.find((step) => step.uses?.startsWith("actions/upload-artifact@"), ); expect(upload).toBeDefined(); expect(upload?.if).toBe("always()"); expect(upload?.with).toMatchObject({ - name: "playwright-report", + name: "playwright-report-shard-${{ matrix.shard }}", path: "playwright-report/", "retention-days": 7, "if-no-files-found": "ignore", @@ -95,7 +100,7 @@ describe("Playwright CI determinism", () => { const workflow = parse( readRepoFile(".github/workflows/e2e.yml"), ) as Workflow; - const env = workflow.jobs.e2e?.env; + const env = workflow.jobs.shard?.env; expect(env?.API_TOKEN_HMAC_KEY).toMatch(/^[0-9a-f]{64}$/); expect(env?.SESSION_COOKIE_SECURE).toBe("false"); diff --git a/src/__tests__/admin-data-wipe-completeness.test.ts b/src/__tests__/admin-data-wipe-completeness.test.ts new file mode 100644 index 000000000..b7782808b --- /dev/null +++ b/src/__tests__/admin-data-wipe-completeness.test.ts @@ -0,0 +1,170 @@ +/** + * Structural guard binding `prisma/schema.prisma` to the ADMIN wipe. + * + * The per-account wipe grew a derived plan and a completeness test after it + * was found deleting thirteen tables out of eighty-odd. The admin wipe — the + * one an operator runs before handing a box on, "Deletes all data for all + * users" — kept its inline list of nine `deleteMany` calls, written when nine + * was most of the schema. It was still nine against a hundred and twenty-two + * models: laboratory results, documents, cycle logs, journals, mood, workouts, + * sleep and Coach conversations all survived a confirmation the operator typed + * by hand. Nothing failed, because nothing was asking. + * + * So the route reads `ADMIN_WIPE_MODELS` — `WIPE_MODELS` minus the entries in + * `ADMIN_WIPE_EXEMPT` — and this file asks the question its sibling asks for + * the account wipe. + * + * What it proves: + * 1. every model carrying a `userId` column is deleted by the admin wipe or + * excused by a named list with a written reason; + * 2. every model without one either reaches an admin-wiped model through a + * chain of required `onDelete: Cascade` relations or is declared + * instance-scoped; + * 3. every admin-only exemption names a real model that the per-account wipe + * DOES delete, so the entry is a deliberate subtraction rather than a + * stale name nobody re-read; + * 4. the route carries no table list of its own — it iterates the plan. + * + * Its honest limit is the same as its sibling's: it proves classification, not + * judgement. It cannot tell that a model was excused for a bad reason, only + * that somebody had to write the reason down. And rule 4 is source-shaped: it + * reads the route file for `deleteMany` calls on a literal delegate, so a + * future inline list built some other way would slip it. + * + * Mutation check: remove any entry from `WIPE_MODELS` and this goes red naming + * that model as unclassified; put an inline `tx.measurement.deleteMany({})` + * back into the route and rule 4 goes red naming it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, it, expect } from "vitest"; + +import { + parseWipeSchema, + reachedByCascade, +} from "@/__tests__/helpers/wipe-schema-shape"; +import { + ADMIN_WIPE_EXEMPT, + ADMIN_WIPE_MODELS, + INSTANCE_SCOPED, + WIPE_EXEMPT, + WIPE_MODELS, +} from "@/lib/data-wipe/wipe-plan"; + +const ROUTE = join( + process.cwd(), + "src", + "app", + "api", + "admin", + "data", + "route.ts", +); + +const models = parseWipeSchema(); +const adminWipeSet = new Set(ADMIN_WIPE_MODELS); + +/** Every list that is allowed to answer "why did this model survive?". */ +function survivorReason(model: string): string | null { + return ( + ADMIN_WIPE_EXEMPT[model] ?? + WIPE_EXEMPT[model] ?? + INSTANCE_SCOPED[model] ?? + null + ); +} + +describe("admin data-wipe completeness", () => { + it("deletes essentially the whole user-scoped schema", () => { + // A floor, not a target. The defect this file exists for was a wipe that + // covered nine tables while reporting success; a silent shrink back + // towards that number fails here even if every survivor is classified. + expect(ADMIN_WIPE_MODELS.length).toBeGreaterThan(80); + }); + + it("every model with a userId column is wiped or excused with a reason", () => { + const userScoped = [...models] + .filter(([, shape]) => shape.scalars.includes("userId")) + .map(([name]) => name); + + expect(userScoped.length).toBeGreaterThan(80); + + const unclassified = userScoped.filter( + (name) => !adminWipeSet.has(name) && survivorReason(name) === null, + ); + + if (unclassified.length > 0) { + throw new Error( + `${unclassified.length} user-scoped model(s) survive the admin wipe with nothing saying why:\n` + + unclassified.map((n) => ` ❌ ${n}`).join("\n") + + "\n\nAn operator who types DELETE ALL before handing this server on " + + "expects these rows gone. Add each to WIPE_MODELS, or to " + + "ADMIN_WIPE_EXEMPT / WIPE_EXEMPT / INSTANCE_SCOPED with the reason " + + "keeping it is not keeping somebody's record.", + ); + } + }); + + it("every model without a userId column is reached by cascade or declared instance-scoped", () => { + const covered = reachedByCascade(models, adminWipeSet); + + const orphans = [...models] + .filter(([, shape]) => !shape.scalars.includes("userId")) + .map(([name]) => name) + .filter((name) => !covered.has(name) && survivorReason(name) === null); + + if (orphans.length > 0) { + throw new Error( + `${orphans.length} model(s) carry no userId, are not removed by any cascade from an admin-wiped row, and are not declared instance-scoped:\n` + + orphans.map((n) => ` ❌ ${n}`).join("\n"), + ); + } + }); + + it("excuses only models the per-account wipe does delete, each with a reason", () => { + for (const [name, reason] of Object.entries(ADMIN_WIPE_EXEMPT)) { + expect( + models.has(name), + `ADMIN_WIPE_EXEMPT names "${name}", which is not a model`, + ).toBe(true); + // The list exists to record where the two wipes DIFFER. A name the + // account wipe does not delete either is not a difference; it is a + // stale entry hiding behind a plausible reason. + expect( + (WIPE_MODELS as readonly string[]).includes(name), + `ADMIN_WIPE_EXEMPT names "${name}", which the per-account wipe does not delete either — the entry records no difference`, + ).toBe(true); + expect( + reason.length, + `ADMIN_WIPE_EXEMPT.${name} needs a real reason`, + ).toBeGreaterThan(30); + } + }); + + it("derives the model set instead of listing tables in the route", () => { + const source = readFileSync(ROUTE, "utf8"); + + expect( + source.includes("ADMIN_WIPE_MODELS"), + "the admin wipe route no longer reads the shared plan", + ).toBe(true); + + // `tx.measurement.deleteMany(` and friends — the shape the inline list + // had. The loop calls `resolveGlobalWipeDelegate(tx, model).deleteMany`, + // which names no table and does not match. + const inline = [ + ...source.matchAll( + /\b(?:tx|prisma)\s*\.\s*(\w+)\s*\.\s*deleteMany\s*\(/g, + ), + ].map((m) => m[1]); + + expect( + inline, + `the admin wipe names tables inline again:\n${inline.join("\n")}\n\n` + + "That list is how it drifted to nine tables: it was correct the day " + + "it was written and nothing noticed when the schema moved past it. " + + "Delete through ADMIN_WIPE_MODELS.", + ).toEqual([]); + }); +}); diff --git a/src/__tests__/data-wipe-completeness.test.ts b/src/__tests__/data-wipe-completeness.test.ts index 745992427..af4b2d4b5 100644 --- a/src/__tests__/data-wipe-completeness.test.ts +++ b/src/__tests__/data-wipe-completeness.test.ts @@ -35,11 +35,12 @@ * that model; add a column to `model User` without classifying it and it goes * red naming the column. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, it, expect } from "vitest"; +import { + parseWipeSchema, + reachedByCascade, +} from "@/__tests__/helpers/wipe-schema-shape"; import { INSTANCE_SCOPED, USER_KEPT_FIELDS, @@ -51,61 +52,7 @@ import { wipeWhere, } from "@/lib/data-wipe/wipe-plan"; -const SCHEMA = join(process.cwd(), "prisma", "schema.prisma"); - -interface Relation { - /** Target model name. */ - target: string; - cascade: boolean; - /** `true` when the relation field is optional (`Model?`). */ - optional: boolean; -} - -interface ModelShape { - scalars: string[]; - relations: Relation[]; -} - -function parseSchema(schema: string): Map { - const blocks = [...schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)].map( - (m) => ({ name: m[1], body: m[2] }), - ); - const modelNames = new Set(blocks.map((b) => b.name)); - - const out = new Map(); - for (const block of blocks) { - const scalars: string[] = []; - const relations: Relation[] = []; - for (const rawLine of block.body.split("\n")) { - const line = rawLine.trim(); - if (!line || line.startsWith("//") || line.startsWith("@@")) continue; - const match = /^(\w+)\s+(\w+)(\[\])?(\?)?/.exec(line); - if (!match) continue; - const [, field, type, isList, isOptional] = match; - if (modelNames.has(type)) { - // A list side of a relation carries no column and cannot cascade. - if (isList) continue; - relations.push({ - target: type, - cascade: line.includes("onDelete: Cascade"), - optional: Boolean(isOptional), - }); - continue; - } - if (isList && type === type.toUpperCase()) { - // Scalar / enum list column — still a column. - scalars.push(field); - continue; - } - scalars.push(field); - } - out.set(block.name, { scalars, relations }); - } - return out; -} - -const schemaText = readFileSync(SCHEMA, "utf8"); -const models = parseSchema(schemaText); +const models = parseWipeSchema(); const wipeSet = new Set(WIPE_MODELS); describe("data-wipe completeness", () => { @@ -158,21 +105,7 @@ describe("data-wipe completeness", () => { it("every model without a userId column is reached by cascade or declared instance-scoped", () => { // Fixpoint over required cascade relations: a model is covered when the // database removes it as a consequence of a row the wipe deletes. - const covered = new Set(wipeSet); - for (;;) { - let grew = false; - for (const [name, shape] of models) { - if (covered.has(name)) continue; - const reached = shape.relations.some( - (r) => r.cascade && !r.optional && covered.has(r.target), - ); - if (reached) { - covered.add(name); - grew = true; - } - } - if (!grew) break; - } + const covered = reachedByCascade(models, wipeSet); const orphans = [...models] .filter(([, shape]) => !shape.scalars.includes("userId")) diff --git a/src/__tests__/helpers/wipe-schema-shape.ts b/src/__tests__/helpers/wipe-schema-shape.ts new file mode 100644 index 000000000..1e6f915f2 --- /dev/null +++ b/src/__tests__/helpers/wipe-schema-shape.ts @@ -0,0 +1,102 @@ +/** + * The shape of `prisma/schema.prisma` the two wipe guards read. + * + * Both of them ask the same two questions — which models carry a `userId`, and + * which models the database removes on its own when a row they do delete goes + * — so the parser answering them lives in one place. Two copies would drift, + * and a guard that answers from its own private idea of the schema is a guard + * that can be green for the wrong reason. + * + * Deliberately regex-shaped rather than a real parser: it runs inside + * `pnpm test` with no database and no Prisma engine, and model blocks plus + * field lines are the most stable syntax in the file. Every consumer asserts a + * floor on how much it found, so a parser that silently stops matching fails + * loudly instead of passing vacuously. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface Relation { + /** Target model name. */ + target: string; + cascade: boolean; + /** `true` when the relation field is optional (`Model?`). */ + optional: boolean; +} + +export interface ModelShape { + scalars: string[]; + relations: Relation[]; +} + +export const WIPE_SCHEMA_PATH = join(process.cwd(), "prisma", "schema.prisma"); + +export function parseWipeSchema( + schemaPath: string = WIPE_SCHEMA_PATH, +): Map { + const schema = readFileSync(schemaPath, "utf8"); + const blocks = [...schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)].map( + (m) => ({ name: m[1], body: m[2] }), + ); + const modelNames = new Set(blocks.map((b) => b.name)); + + const out = new Map(); + for (const block of blocks) { + const scalars: string[] = []; + const relations: Relation[] = []; + for (const rawLine of block.body.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("//") || line.startsWith("@@")) continue; + const match = /^(\w+)\s+(\w+)(\[\])?(\?)?/.exec(line); + if (!match) continue; + const [, field, type, isList, isOptional] = match; + if (modelNames.has(type)) { + // A list side of a relation carries no column and cannot cascade. + if (isList) continue; + relations.push({ + target: type, + cascade: line.includes("onDelete: Cascade"), + optional: Boolean(isOptional), + }); + continue; + } + if (isList && type === type.toUpperCase()) { + // Scalar / enum list column — still a column. + scalars.push(field); + continue; + } + scalars.push(field); + } + out.set(block.name, { scalars, relations }); + } + return out; +} + +/** + * Every model the database itself removes once the seed models are deleted. + * + * A fixpoint over REQUIRED cascade relations: an optional relation survives + * its parent, so it proves nothing about the row going away. The result + * includes the seeds. + */ +export function reachedByCascade( + models: Map, + seeds: Iterable, +): Set { + const covered = new Set(seeds); + for (;;) { + let grew = false; + for (const [name, shape] of models) { + if (covered.has(name)) continue; + const reached = shape.relations.some( + (r) => r.cascade && !r.optional && covered.has(r.target), + ); + if (reached) { + covered.add(name); + grew = true; + } + } + if (!grew) break; + } + return covered; +} diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index 1feac2a38..cb5a0d316 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -53,6 +53,12 @@ const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]); const DYNAMIC_ALLOWLIST_PREFIXES = [ "mood.tag", "mood.tagCategory", + // `mood.dimension`: the five level-A dimensions are a table + // (`src/lib/mood/dimensions.ts`) whose rows carry their own label and anchor + // keys, so every leaf is resolved from a value rather than written as a + // literal. The table is the reason the keys cannot drift apart from the + // columns they describe. + "mood.dimension", // The whole namespace is a runtime lookup table: the client resolves an // error envelope's `meta.errorCode` through it (`localizedApiError`), so no // leaf ever appears as a literal at a call site. Adding a key here is only diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 4f3597843..a6196cf0c 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -774,6 +774,19 @@ const handler = apiHandler( date: entry.date, mood: entry.mood, score: entry.score, + // The five level-A values, carried the same way `syncVersion` + // is: absent in the file means the file predates the columns, + // and an upsert whose update arm stated them would wipe live + // answers off every matched row when an older backup is + // restored over a going concern. A file that has them writes + // them, including an explicit null; a file that does not says + // nothing. Never derived — filling one in here would put a + // number the person never gave into a restored row. + ...(entry.a1 === undefined ? {} : { moodA1: entry.a1 }), + ...(entry.a2 === undefined ? {} : { stressA2: entry.a2 }), + ...(entry.a3 === undefined ? {} : { energyA3: entry.a3 }), + ...(entry.a4 === undefined ? {} : { connectionA4: entry.a4 }), + ...(entry.a5 === undefined ? {} : { stabilityA5: entry.a5 }), tags: entry.tags ?? null, // The note comes back the way every other dual-column note // does: ciphertext verbatim when the file carries it, and a diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts index abd69fd15..9fcbb9afe 100644 --- a/src/app/api/admin/data/route.ts +++ b/src/app/api/admin/data/route.ts @@ -6,12 +6,36 @@ import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { NextRequest, NextResponse } from "next/server"; import { invalidateAllCaches } from "@/lib/cache/invalidate"; +import { + ADMIN_WIPE_MODELS, + resolveGlobalWipeDelegate, + USER_RESET, + wipeDelegateKey, + WIPE_TRANSACTION_MAX_WAIT_MS, + WIPE_TRANSACTION_TIMEOUT_MS, +} from "@/lib/data-wipe/wipe-plan"; export const dynamic = "force-dynamic"; /** - * Admin-only global wipe of all health/integration data. + * Admin-only global wipe of every account's data. * Keeps users/passkeys so access to the app remains possible. + * + * The set of tables is not written here. It is declared once in + * `@/lib/data-wipe/wipe-plan` — the same declaration the per-account wipe + * reads — and held against `prisma/schema.prisma` by + * `src/__tests__/data-wipe-completeness.test.ts` plus its admin-scope sibling, + * so a model added to the schema cannot quietly land outside this wipe. It had + * done exactly that: the inline list this route used to carry named nine + * tables, written when nine was most of the schema, and stayed at nine while + * the schema grew past a hundred. Laboratory results, documents, cycle logs, + * journals, mood, workouts, sleep and Coach conversations all survived the + * wipe an operator runs before handing the box on. + * + * `ADMIN_WIPE_MODELS` is `WIPE_MODELS` minus the entries in + * `ADMIN_WIPE_EXEMPT`, and every survivor — sign-in credentials, instance + * configuration, the delegation grants that keep managed profiles reachable — + * carries its reason in that file. */ export const DELETE = apiHandler(async (request: NextRequest) => { const ip = getClientIp(request); @@ -59,72 +83,61 @@ export const DELETE = apiHandler(async (request: NextRequest) => { } // Audit-log the *intent* before the transaction begins so the trail - // describing "an admin is about to wipe data" survives even if the - // operation crashes midway. AuditLog rows are intentionally NOT - // deleted as part of the wipe — the entire point of an audit trail - // is to outlive the data it documents. + // describing "an admin is about to wipe data" survives a crash midway or a + // rollback. On success the transaction takes this row with the rest of the + // history — an audit trail is a record of who did what from where, which is + // exactly the personal data an operator clearing a box means to remove — and + // the receipt written after the commit replaces it. await auditLog("admin.data.clear.start", { userId: user.id, ipAddress: getClientIp(request), details: { confirm: "DELETE ALL" }, }); - const result = await prisma.$transaction(async (tx) => { - const measurements = await tx.measurement.deleteMany({}); - const intakeEvents = await tx.medicationIntakeEvent.deleteMany({}); - const medications = await tx.medication.deleteMany({}); - const apiTokens = await tx.apiToken.deleteMany({}); - const withingsConnections = await tx.withingsConnection.deleteMany({}); - const authChallenges = await tx.authChallenge.deleteMany({}); + const counts = await prisma.$transaction( + async (tx) => { + const perModel: Record = {}; + for (const model of ADMIN_WIPE_MODELS) { + const { count } = await resolveGlobalWipeDelegate(tx, model).deleteMany( + {}, + ); + if (count > 0) perModel[wipeDelegateKey(model)] = count; + } - // v1.4.6 left these out, so encrypted Telegram bot tokens - // (NotificationChannel.config) and Web Push endpoints survived a - // wipe. Cascading via NotificationPreference is handled by the - // schema's onDelete: Cascade on the channel FK. - const notificationChannels = await tx.notificationChannel.deleteMany({}); - const pushSubscriptions = await tx.pushSubscription.deleteMany({}); - const telegramScheduledDeletions = - await tx.telegramScheduledDeletion.deleteMany({}); + // Every account row survives; the personal data carried on its own + // columns does not. Same classification contract as the model list. + await tx.user.updateMany({ data: USER_RESET }); - await tx.user.updateMany({ - data: { - heightCm: null, - dateOfBirth: null, - gender: null, - insightsPrivacyMode: "aggregated", - insightsCachedAt: null, - insightsCachedText: null, - telegramBotToken: null, - telegramChatId: null, - telegramEnabled: false, - withingsClientIdEncrypted: null, - withingsClientSecretEncrypted: null, - onboardingCompletedAt: null, - }, - }); + return perModel; + }, + { + timeout: WIPE_TRANSACTION_TIMEOUT_MS, + maxWait: WIPE_TRANSACTION_MAX_WAIT_MS, + }, + ); - return { - measurements: measurements.count, - intakeEvents: intakeEvents.count, - medications: medications.count, - apiTokens: apiTokens.count, - withingsConnections: withingsConnections.count, - authChallenges: authChallenges.count, - notificationChannels: notificationChannels.count, - pushSubscriptions: pushSubscriptions.count, - telegramScheduledDeletions: telegramScheduledDeletions.count, - }; - }); + const deletedRows = Object.values(counts).reduce((sum, n) => sum + n, 0); + // Written after the transaction commits, so the row cannot be removed by the + // AuditLog delete inside it. The instance's whole audit history goes; this + // one row stays as the receipt for the erasure the operator asked for. await auditLog("admin.data.clear", { userId: user.id, ipAddress: getClientIp(request), - details: result, + details: { deletedRows, models: counts }, + }); + + annotate({ + meta: { + deleted_rows: deletedRows, + models_wiped: ADMIN_WIPE_MODELS.length, + models_with_rows: Object.keys(counts).length, + }, }); // v1.16.9 — the wipe touched every user's rows; clear every cache // bucket so no per-user payload survives the reset. invalidateAllCaches(); - return apiSuccess({ cleared: true, ...result }); + return apiSuccess({ cleared: true, deletedRows, models: counts }); }); diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index f1c32149f..e81f51a54 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -39,6 +39,7 @@ import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { isP2002, isP2025 } from "@/lib/prisma-errors"; import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { deriveA1 } from "@/lib/mood/level-a"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; // Derived from canonical enum so round-trip export → import covers every @@ -364,6 +365,12 @@ export const POST = apiHandler(async (request: NextRequest) => { date: e.date, mood: e.mood, score: e.score, + // v1.37 — pleasantness derived from the row's own label, not + // from the file's `score`. This is the one writer whose score + // comes out of the file rather than out of the label, so the + // two can disagree; the column that records a self-assessment + // has to agree with the label the user actually picked. + moodA1: deriveA1(e.mood), tags: e.tags || null, moodLoggedAt: loggedAt, }, @@ -372,6 +379,7 @@ export const POST = apiHandler(async (request: NextRequest) => { date: e.date, mood: e.mood, score: e.score, + moodA1: deriveA1(e.mood), tags: e.tags || null, source: "IMPORT", externalId: e.externalId, @@ -385,6 +393,9 @@ export const POST = apiHandler(async (request: NextRequest) => { date: e.date, mood: e.mood, score: e.score, + // See the upsert arm above: derived from the label, because + // an imported file's `score` is its own field. + moodA1: deriveA1(e.mood), tags: e.tags || null, source: "IMPORT", moodLoggedAt: loggedAt, diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index b6f1c4412..c3f09b24c 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -15,6 +15,7 @@ import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { deriveA1, shapeLevelA } from "@/lib/mood/level-a"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; @@ -57,7 +58,11 @@ export const GET = apiHandler( meta: { moodEntryId: id }, }); - return apiSuccess({ ...shapeMoodNote(entry), tags: parseTags(entry.tags) }); + return apiSuccess({ + // v1.37 — level-A values under the keys the write path takes. + ...shapeLevelA(shapeMoodNote(entry)), + tags: parseTags(entry.tags), + }); }, ); @@ -120,7 +125,32 @@ export const PUT = apiHandler( if (data.mood !== undefined) { updateData.mood = data.mood; updateData.score = getScoreForMood(data.mood); + // v1.37 — pleasantness follows the label the same way `score` does, so + // an entry corrected from "okay" to "bad" cannot keep yesterday's A1. + // Only when the label actually moved, though: the edit dialog sends the + // whole entry back on every save, and re-deriving on an unchanged label + // would overwrite a value the person set by hand every time they + // corrected the timestamp. An explicit `a1` below overrides either way. + if (data.mood !== existing.mood) { + updateData.moodA1 = deriveA1(data.mood); + } } + // v1.37 — level-A values are per-field on this path: omitted keeps the + // stored value, an explicit number replaces it, an explicit null clears + // it. Built one at a time from the parsed body, never spread. + // + // Pleasantness is the exception, and it is the same exception the create + // path makes: an entry always carries a five-point label, so it always + // implies a pleasantness value, and a null falls back to the derivation + // rather than emptying the column. One rule on both routes — the capture + // surfaces therefore offer no clear control for it. + if (data.a1 !== undefined) { + updateData.moodA1 = data.a1 ?? deriveA1(data.mood ?? existing.mood); + } + if (data.a2 !== undefined) updateData.stressA2 = data.a2; + if (data.a3 !== undefined) updateData.energyA3 = data.a3; + if (data.a4 !== undefined) updateData.connectionA4 = data.a4; + if (data.a5 !== undefined) updateData.stabilityA5 = data.a5; if (data.moodLoggedAt !== undefined) { // v1.4.25 W7b — re-anchor the row's `date` to the user's current // displayTimezone. Also refresh the `tz` column so the row's @@ -290,7 +320,8 @@ export const PUT = apiHandler( } return apiSuccess({ - ...entry, + // v1.37 — level-A values under the keys the write path takes. + ...shapeLevelA(entry), tags: parseTags(entry.tags), // v1.8.5 — surface the persisted structured-tag keys so a client // hydrating from the update response renders the tag set without a diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index a8f3bd3d2..938be2a60 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -51,6 +51,7 @@ import { type UnstableExternalIdShape, } from "@/lib/validations/external-id"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { deriveA1 } from "@/lib/mood/level-a"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; @@ -276,6 +277,11 @@ async function postBulk(request: NextRequest): Promise { const date = moodDateKey(entry.moodLoggedAt, tz); const score = getScoreForMood(entry.mood); + // v1.37 — pleasantness derived from the same label the score comes from. + // The batch request shape is unchanged: this path carries no level-A + // input, so A2 to A5 are left alone rather than written as nulls, and a + // row that already carries hand-set values keeps them across a re-import. + const moodA1 = deriveA1(entry.mood); try { // v1.12.1 — when the entry carries a source-stable `externalId`, @@ -347,6 +353,7 @@ async function postBulk(request: NextRequest): Promise { tz, mood: entry.mood, score, + moodA1, tags: entry.tags ? JSON.stringify(entry.tags) : null, note: null, noteEncrypted: encryptNote(entry.note ?? null), @@ -363,6 +370,7 @@ async function postBulk(request: NextRequest): Promise { // the same row. mood: entry.mood, score, + moodA1, tags: entry.tags ? JSON.stringify(entry.tags) : null, note: null, noteEncrypted: encryptNote(entry.note ?? null), diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index e92baa7d1..cc3ef97c8 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -24,6 +24,7 @@ import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { @@ -125,7 +126,11 @@ export const GET = apiHandler(async (request: NextRequest) => { const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({ // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. - ...shapeMoodNote(e), + // v1.37 — the five level-A values under the keys the create path takes, + // so the edit form reads back what it wrote instead of mapping column + // names to wire names by hand. The column spelling is stripped, not + // joined: one number under two names is a choice nobody made. + ...shapeLevelA(shapeMoodNote(e)), tags: parseTags(e.tags), // v1.8.5 — flat list of binary structured-tag keys attached to the // entry (rated factors are surfaced separately below). @@ -206,6 +211,17 @@ async function postMoodEntry(request: NextRequest) { source, externalId, } = parsed.data; + // v1.37 — the five level-A values, built field by field from the parsed + // body (never spread) and resolved once for every branch below. A1 falls + // back to the derivation from `mood`; the other four are whatever the user + // set, or nothing. + const levelA = levelAForWrite(mood, { + a1: parsed.data.a1, + a2: parsed.data.a2, + a3: parsed.data.a3, + a4: parsed.data.a4, + a5: parsed.data.a5, + }); // v1.4.25 W7b (Decision A) — anchor the `date` string to the user's // current displayTimezone and store the resolved zone on the row. // Legacy rows with `tz IS NULL` continue to read as Europe/Berlin @@ -265,6 +281,7 @@ async function postMoodEntry(request: NextRequest) { tz, mood, score, + ...levelA, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -272,11 +289,19 @@ async function postMoodEntry(request: NextRequest) { externalId, moodLoggedAt, }, + // A re-post restates pleasantness, because the label it carries + // always implies one and a stale A1 beside a changed mood would + // contradict the entry's own face. It does NOT restate the other + // four: `levelA` carries them only when this request did, so a + // client re-posting without sliders — the phone, whose build has + // none — leaves the stress and energy somebody answered on the + // web exactly where they were. An explicit null clears one. update: { date, tz, mood, score, + ...levelA, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -290,6 +315,9 @@ async function postMoodEntry(request: NextRequest) { tz, mood, score, + // A fresh row: an omitted dimension lands as NULL, which is + // the same thing the upsert's arms mean by omitting it. + ...levelA, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -374,7 +402,8 @@ async function postMoodEntry(request: NextRequest) { return apiSuccess( { // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. - ...shapeMoodNote(entry), + // v1.37 — level-A values under their wire keys (see the list read). + ...shapeLevelA(shapeMoodNote(entry)), tags: parseTags(entry.tags), // v1.8.5 — surface the persisted structured-tag keys so a client // hydrating from the create response renders the tag set without a diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx index dabfea3b1..7548a514c 100644 --- a/src/components/admin/danger-zone-section.tsx +++ b/src/components/admin/danger-zone-section.tsx @@ -47,16 +47,14 @@ export function DangerZoneSection() { // Idempotency-Key prevents a double-submit from re-running the // destructive transaction, matching the Backups restore path. const idempotencyKey = `admin-data-wipe-${crypto.randomUUID()}`; + // The server returns one count per table that actually held rows. It + // used to return nine fixed names, which is how nobody noticed the + // wipe had stopped covering the schema: the result line read the same + // whether it had cleared nine tables or ninety. return apiDelete<{ - measurements: number; - intakeEvents: number; - medications: number; - apiTokens: number; - withingsConnections: number; - authChallenges: number; - notificationChannels: number; - pushSubscriptions: number; - telegramScheduledDeletions: number; + cleared: boolean; + deletedRows: number; + models: Record; }>( "/api/admin/data", { confirm: WIPE_CONFIRM_TOKEN }, @@ -67,15 +65,8 @@ export function DangerZoneSection() { queryClient.invalidateQueries(); setWipeMsg( t("admin.deletedResult", { - measurements: data.measurements, - medications: data.medications, - intakeEvents: data.intakeEvents, - apiTokens: data.apiTokens, - withingsConnections: data.withingsConnections, - authChallenges: data.authChallenges, - notificationChannels: data.notificationChannels, - pushSubscriptions: data.pushSubscriptions, - telegramScheduledDeletions: data.telegramScheduledDeletions, + rows: data.deletedRows, + tables: Object.keys(data.models).length, }), ); }, diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts index 6fc198934..788235dc0 100644 --- a/src/components/charts/chart-runtime.ts +++ b/src/components/charts/chart-runtime.ts @@ -33,6 +33,7 @@ export { HostMetricsChart } from "@/components/admin/host-metrics-chart"; export { CustomMetricChart } from "@/components/custom-metrics/custom-metric-chart"; export { BbtChart } from "@/components/cycle/bbt-chart"; export { DeltaSparkline } from "@/components/insights/derived/delta-sparkline"; +export { MoodDimensionTrends } from "@/components/insights/mood/mood-dimension-trends"; export { MoodDistributionChart } from "@/components/insights/mood/mood-distribution-chart"; export { MoodTimeOfDayChart } from "@/components/insights/mood/mood-time-of-day-chart"; export { MoodWeekdayChart } from "@/components/insights/mood/mood-weekday-chart"; diff --git a/src/components/insights/mood/mood-dimension-trends.tsx b/src/components/insights/mood/mood-dimension-trends.tsx new file mode 100644 index 000000000..2b06f7ca8 --- /dev/null +++ b/src/components/insights/mood/mood-dimension-trends.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { useTranslations } from "@/lib/i18n/context"; +import { + MOOD_DIMENSIONS, + MOOD_DIMENSION_WINDOWS, + type MoodDimensionWindow, +} from "@/lib/mood/dimensions"; +import { cn } from "@/lib/utils"; + +/** + * v1.37 — the five level-A dimensions over time. + * + * Each dimension is drawn in the orientation the person answered it in and + * under the label they read, so a high stress line means a stressful stretch + * rather than a good one. Nothing is flipped for the picture's convenience; + * the legend says which way each scale runs. + * + * A dimension nobody has answered is not drawn and is named underneath as not + * recorded. Drawing it flat through the middle would put five answers on the + * chart for a question that was asked once. + */ + +export interface MoodDimensionPointData { + date: string; + value: number; + samples: number; +} + +export interface MoodDimensionSummaryData { + key: string; + present: boolean; + inverse: boolean; + min: number; + max: number; + count: number; + avg7: number | null; + avg30: number | null; + avg90: number | null; + latest: number | null; + latestDate: string | null; + newestDaysAgo: number | null; + series: MoodDimensionPointData[]; +} + +/** One colour per dimension, from the same semantic set the other charts use. */ +const COLOR_BY_KEY: Record = { + a1: "var(--chart-1)", + a2: "var(--destructive)", + a3: "var(--dracula-orange)", + a4: "var(--info)", + a5: "var(--success)", +}; + +function meanForWindow( + summary: MoodDimensionSummaryData, + window: MoodDimensionWindow, +): number | null { + if (window === 7) return summary.avg7; + if (window === 30) return summary.avg30; + return summary.avg90; +} + +export function MoodDimensionTrends({ + dimensions, +}: { + dimensions: MoodDimensionSummaryData[]; +}) { + const { t } = useTranslations(); + const [window, setWindow] = useState(30); + + const present = dimensions.filter((d) => d.present); + const missing = dimensions.filter((d) => !d.present); + + const chartData = useMemo(() => { + const byDate = new Map>(); + for (const summary of present) { + for (const point of summary.series) { + const row = byDate.get(point.date) ?? { date: point.date }; + row[summary.key] = point.value; + byDate.set(point.date, row); + } + } + const rows = [...byDate.values()].sort((a, b) => + String(a.date).localeCompare(String(b.date)), + ); + // The series carry the longest window; the shorter ones are a slice of the + // same points rather than a second read. + return rows.slice(Math.max(0, rows.length - window)); + }, [present, window]); + + const labelFor = (key: string) => { + const dimension = MOOD_DIMENSIONS.find((d) => d.key === key); + return dimension ? t(dimension.labelKey) : key; + }; + + if (present.length === 0) { + return ( +

+ {t("insights.mood.dimensions.none")} +

+ ); + } + + return ( +
+
+ {MOOD_DIMENSION_WINDOWS.map((option) => ( + + ))} +
+ +
+ + + + value.slice(5)} + minTickGap={24} + /> + + [ + String(value ?? ""), + labelFor(String(name)), + ]} + /> + {present.map((summary) => ( + + ))} + + +
+ +
    + {present.map((summary) => { + const mean = meanForWindow(summary, window); + const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key); + return ( +
  • + + + + {mean === null + ? t("insights.mood.dimensions.noneInWindow") + : mean} + +
  • + ); + })} +
+ + {missing.length > 0 ? ( +

+ {t("insights.mood.dimensions.notRecorded", { + list: missing.map((d) => labelFor(d.key)).join(", "), + })} +

+ ) : null} +
+ ); +} diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index de4e6c7d4..19c64ac55 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -34,12 +34,25 @@ import { MoodHeatmap } from "@/components/charts/mood-heatmap"; // without a layout shift (charts stay Recharts, visually identical). Types // stay value-free imports so they don't drag the chunk back in. import type { MoodDistributionRow } from "./mood-distribution-chart"; +import type { MoodDimensionSummaryData } from "./mood-dimension-trends"; import type { MoodWeekdayRow } from "./mood-weekday-chart"; import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart"; // v1.16.7 established the shared-barrel pattern for the trio; all three // loaders now resolve the app-wide chart-runtime boundary, so they share // the ONE recharts chunk with every other chart surface: the cards reveal // together, and recharts ships exactly once for the whole app. +const MoodDimensionTrends = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.MoodDimensionTrends, + })), + { + ssr: false, + loading: () => ( + + ), + }, +); const MoodDistributionChart = dynamic( () => import("@/components/charts/chart-runtime").then((mod) => ({ @@ -127,6 +140,9 @@ interface MoodInsightsResponse { cells: Array<{ date: string; score: number; samples: number }>; }; distribution: MoodDistributionRow[]; + // Optional only to tolerate a stale pre-v1.37 cached payload during a + // rollout; the live endpoint always populates it. + dimensions?: MoodDimensionSummaryData[]; weekday: MoodWeekdayRow[]; timeOfDay: MoodTimeOfDayPattern; stability: MoodStabilityData | null; @@ -258,6 +274,8 @@ export function MoodInsightsSections({ const hasCrosstab = crosstabRows.length > 0; const factorCrosstabRows = data.factorCrosstab ?? []; const hasFactorCrosstab = factorCrosstabRows.length > 0; + const dimensionSummaries = data.dimensions ?? []; + const hasDimensions = dimensionSummaries.some((d) => d.present); // v1.12.7 — the Stimmungskalender is lifted above the line chart on the page, // so it renders as its own region here. @@ -334,6 +352,15 @@ export function MoodInsightsSections({ renders nothing when both halves are empty. */} + {/* v1.37 — the five level-A dimensions. Renders only once at least one + of them carries a value; an account that has never opened the detail + section sees the page it saw before. */} + {hasDimensions && ( + + + + )} +
{ expect(formSrc).toMatch(/const isDirty =\s*\n?\s*mood !== ""/); expect(formSrc).toContain('tagsInput.trim() !== ""'); expect(formSrc).toContain("tagKeys.length > 0"); - expect(formSrc).toContain("ratedFactors.length > 0"); + // v1.37 — a level-A value the user set is typed input too. The rated + // factors this line used to name are no longer captured here; see the + // level-A describe below. + expect(formSrc).toContain("Object.values(levelA).some((v) => v !== null)"); expect(formSrc).toContain('note.trim() !== ""'); // The `isDirty` expression itself must not read `moodLoggedAt`. const isDirtyExpr = formSrc.slice( @@ -115,31 +118,51 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => { }); }); -describe("MoodForm — rated factors (v1.12.0)", () => { - it("lifts a ratedFactors set and threads it to the tag picker", () => { - expect(formSrc).toContain("const [ratedFactors, setRatedFactors]"); - expect(formSrc).toMatch( - /function rateFactor\(key: string, rating: number \| null\)/, - ); - // The picker receives both the binary toggle and the factor rate - // callbacks so it can render BINARY chips and RATED controls. - expect(formSrc).toMatch( - / { + it("no longer offers a factor rating on a new entry", () => { + // A 1-5 score for "work stress" and a 0-10 stress self-state are two + // answers to one question. The create sheet asks the second one only. + // Nothing was deleted: the catalogue rows, the stored ratings and the + // edit dialog below all still work, which the next describe pins. + expect(formSrc).not.toContain("onRateFactor="); + expect(formSrc).not.toContain('mode="rated"'); + expect(formSrc).not.toContain("ratedFactors"); + // The binary structured-tag contract stays byte-identical. + expect(formSrc).toContain('mode="binary"'); + expect(formSrc).toContain( + "tagKeys: tagKeys.length > 0 ? tagKeys : undefined,", ); }); - it("sends ratedFactors as a parallel array on POST (omitted when empty)", () => { - expect(formSrc).toContain( - "ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,", + it("offers the five level-A sliders and sends only the answered ones", () => { + expect(formSrc).toContain(" 0 ? tagKeys : undefined,", + expect(formSrc).toContain("onA1Touched={() => setA1Touched(true)}"); + // Nothing here defaults a value: the state starts empty and stays empty. + expect(formSrc).toContain("useState(EMPTY_LEVEL_A)"); + }); + + it("offers no clear control for pleasantness, matching the write path", () => { + // An entry always carries a label, so it always implies a pleasantness + // value and both write routes derive one from a null. A clear button here + // would be a control the server undoes on the way to the database. + const fieldsSrc = readFileSync( + resolve(__dirname, "../mood-level-a-fields.tsx"), + "utf8", + ); + expect(fieldsSrc).toMatch( + /onClear=\{\s*dimension\.key === "a1"\s*\?\s*undefined/, ); }); }); describe("MoodList — rated factor editing", () => { + // Unchanged by v1.37 on purpose: the create sheet stopped asking, and an + // entry that already carries a rating must still be correctable. it("hydrates, renders, and submits the entry's current ratedFactors", () => { expect(listSrc).toContain("ratedFactors: RatedFactor[];"); expect(listSrc).toContain( @@ -156,6 +179,18 @@ describe("MoodList — rated factor editing", () => { }); }); +describe("MoodList — level A on the edit path (v1.37)", () => { + it("seeds the five values from the entry and sends them back, nulls included", () => { + // What the capture sheet writes, this dialog has to show. A value visible + // in a trend and nowhere else is a value the person cannot correct. + expect(listSrc).toContain("levelA: levelAFromEntry(entry)"); + expect(listSrc).toContain("setEditLevelA(seed.levelA)"); + expect(listSrc).toContain(" { it("surfaces a toast on a rejected delete instead of failing silently", () => { expect(listSrc).toContain('import { toast } from "sonner"'); diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index ce89a80e8..7c779cf26 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -31,7 +31,6 @@ import { MoreHorizontal, Plus, RotateCcw, - SlidersHorizontal, StickyNote, } from "lucide-react"; import { toast } from "sonner"; @@ -45,7 +44,14 @@ import { import { apiPost } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section"; -import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker"; +import { MoodTagPicker } from "./mood-tag-picker"; +import { + EMPTY_LEVEL_A, + MoodLevelASection, + levelACreatePayload, + seedA1FromMood, + type LevelAState, +} from "./mood-level-a-fields"; import { MoodQuickTags } from "./mood-quick-tags"; import { useRecentTags } from "./recent-tags"; import { moodFaceIcon } from "./mood-tag-icons"; @@ -114,14 +120,17 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { const { getRadioProps: getMoodRadioProps } = useRovingRadioGroup({ count: MOOD_LEVELS.length, selectedIndex: MOOD_LEVELS.findIndex((l) => l.value === mood), - onSelect: (index) => setMood(MOOD_LEVELS[index]!.value), + onSelect: (index) => pickMood(MOOD_LEVELS[index]!.value), }); + // v1.37 — the five level-A values. Pleasantness follows the picked face + // until the user moves it themselves; the other four start and stay empty + // unless somebody answers them. + const [levelA, setLevelA] = useState(EMPTY_LEVEL_A); + const [a1Touched, setA1Touched] = useState(false); + const [levelAOpen, setLevelAOpen] = useState(false); const [tagsInput, setTagsInput] = useState(""); // v1.8.5 — structured-tag keys picked from the taxonomy catalog. const [tagKeys, setTagKeys] = useState([]); - // v1.12.0 — rated mood factors (FACTOR-kind catalog tags scored on - // their own 1..scaleMax scale). Sent as `ratedFactors: [{key,rating}]`. - const [ratedFactors, setRatedFactors] = useState([]); // v1.8.5 (C1) — first-class free-text note. The model + API already // accepted `note`; the web form was the only surface that couldn't // write it. @@ -142,24 +151,25 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { mood !== "" || tagsInput.trim() !== "" || tagKeys.length > 0 || - ratedFactors.length > 0 || + // A level-A value the user set counts as typed input: Reset must ask + // before it discards an answer that took five slider moves. + Object.values(levelA).some((v) => v !== null) || note.trim() !== ""; + // v1.37 — picking a face suggests a pleasantness value; the suggestion + // stands until the user moves that slider, after which the two are + // independent. Moving the slider never changes which face is lit. + function pickMood(value: string) { + setMood(value); + setLevelA((prev) => seedA1FromMood(prev, value, a1Touched)); + } + function toggleTagKey(key: string) { setTagKeys((prev) => prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key], ); } - // v1.12.0 — set or clear a rated factor's score. `rating === null` - // removes the factor from the set (re-tapping the active step clears). - function rateFactor(key: string, rating: number | null) { - setRatedFactors((prev) => { - const rest = prev.filter((f) => f.key !== key); - return rating === null ? rest : [...rest, { key, rating }]; - }); - } - // v1.4.27 MB3 — error banner descriptor for the timestamp input. const errorId = useId(); const errorDescriptor = error ? errorId : undefined; @@ -171,9 +181,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { function resetForm() { setMood(""); + setLevelA(EMPTY_LEVEL_A); + setA1Touched(false); + setLevelAOpen(false); setTagsInput(""); setTagKeys([]); - setRatedFactors([]); setNote(""); setMoodLoggedAt(getDefaultMoodLoggedAtValue()); setError(null); @@ -209,9 +221,10 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { mood, tags: tags.length > 0 ? tags : undefined, tagKeys: tagKeys.length > 0 ? tagKeys : undefined, - // v1.12.0 — rated factors as a parallel [{key,rating}] array. - ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined, note: trimmedNote.length > 0 ? trimmedNote : undefined, + // v1.37 — only the values somebody answered. An untouched slider + // sends nothing, and the server writes NULL rather than a middle. + ...levelACreatePayload(levelA), moodLoggedAt: timestamp, }); @@ -324,7 +337,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { aria-label={t(level.labelKey)} data-slot="mood-face" data-mood={level.value} - onClick={() => setMood(level.value)} + onClick={() => pickMood(level.value)} {...getMoodRadioProps(index)} // v1.12 — selected-state unified with the tag tiles and // factor steps: `border-primary bg-primary/15 text-primary` @@ -392,21 +405,20 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { /> - {/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept - exactly as the existing segmented control. */} - } - summary={} - > - - + {/* v1.37 — the five level-A values, closed by default. The rated + factors that used to sit here are no longer offered on a new + entry: a 1-5 score for "work stress" and a 0-10 stress + self-state are different questions, and asking both invites + two answers to one thing. Existing ratings keep working + everywhere they already do, including the edit dialog, and no + catalogue row was removed. */} + setA1Touched(true)} + open={levelAOpen} + onOpenChange={setLevelAOpen} + /> {/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags, and the note field. The badge counts whichever of these carry diff --git a/src/components/mood/mood-level-a-fields.tsx b/src/components/mood/mood-level-a-fields.tsx new file mode 100644 index 000000000..2269908ee --- /dev/null +++ b/src/components/mood/mood-level-a-fields.tsx @@ -0,0 +1,194 @@ +"use client"; + +/** + * The five level-A sliders, and the state rules around them. + * + * One component for both mood surfaces — the create sheet and the list's edit + * dialog — because what one writes the other has to be able to show. A value a + * user set on Monday and cannot see on Tuesday is worse than one they were + * never offered. + * + * The section is closed by default and nothing in it is required. The five + * faces above it remain a complete entry on their own: one tap still saves. + */ +import { Gauge } from "lucide-react"; + +import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section"; +import { SliderField } from "@/components/ui/slider"; +import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions"; +import { getA1ForMood } from "@/lib/validations/mood"; +import { useTranslations } from "@/lib/i18n/context"; + +/** The five values as the form holds them. `null` means unanswered. */ +export interface LevelAState { + a1: number | null; + a2: number | null; + a3: number | null; + a4: number | null; + a5: number | null; +} + +export const EMPTY_LEVEL_A: LevelAState = { + a1: null, + a2: null, + a3: null, + a4: null, + a5: null, +}; + +/** How many of the five carry an answer — the collapsed section's badge. */ +export function levelAAnsweredCount(state: LevelAState): number { + return MOOD_DIMENSIONS.filter((d) => state[d.key] !== null).length; +} + +/** + * The five values as the API takes them, with the unanswered ones left out. + * + * Omitted rather than sent as null on the create path: absent means "nothing + * to say", and the server writes NULL for it. Nothing here ever sends a + * default, which is the whole reason the columns are nullable. + */ +export function levelACreatePayload( + state: LevelAState, +): Partial> { + const out: Partial> = {}; + for (const dimension of MOOD_DIMENSIONS) { + const value = state[dimension.key]; + if (value !== null) out[dimension.key] = value; + } + return out; +} + +/** + * The five values as the edit path takes them — nulls included. + * + * The edit path replaces rather than adds, so a value the user cleared has to + * travel as an explicit null. Sending nothing would leave the old answer in + * place and the dialog would lie about what it just saved. + */ +export function levelAUpdatePayload(state: LevelAState): LevelAState { + return { ...state }; +} + +/** + * Seed the state from an entry being edited. + * + * The reader is the API's own wire shape, so a row that has never carried + * these values arrives as `undefined` and lands as `null` — unanswered, which + * is what it is. + */ +export function levelAFromEntry(entry: { + a1?: number | null; + a2?: number | null; + a3?: number | null; + a4?: number | null; + a5?: number | null; +}): LevelAState { + return { + a1: entry.a1 ?? null, + a2: entry.a2 ?? null, + a3: entry.a3 ?? null, + a4: entry.a4 ?? null, + a5: entry.a5 ?? null, + }; +} + +/** + * Re-seed pleasantness from a freshly picked face — but only while the user + * has not moved that slider themselves. + * + * This is the one interaction in the sheet that will confuse a later reader, + * so it is written down here rather than inferred from two call sites: picking + * a face suggests a pleasantness value, and moving the slider takes the + * suggestion over. After that the faces and the slider are independent, and in + * particular moving the slider never changes which face is lit — the two are + * different questions at different resolutions, not two views of one answer. + */ +export function seedA1FromMood( + state: LevelAState, + mood: string, + a1Touched: boolean, +): LevelAState { + if (a1Touched) return state; + return { ...state, a1: mood ? getA1ForMood(mood) : null }; +} + +interface MoodLevelASectionProps { + value: LevelAState; + onChange: (next: LevelAState) => void; + /** Called when the user moves pleasantness themselves, which ends the re-seeding. */ + onA1Touched?: () => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function MoodLevelASection({ + value, + onChange, + onA1Touched, + open, + onOpenChange, +}: MoodLevelASectionProps) { + const { t } = useTranslations(); + + return ( + } + open={open} + onOpenChange={onOpenChange} + summary={} + > +
+

+ {t("mood.sectionLevelAHelp")} +

+ {MOOD_DIMENSIONS.map((dimension) => { + const current = value[dimension.key]; + const low = t(dimension.lowAnchorKey); + const high = t(dimension.highAnchorKey); + return ( + onChange({ ...value, [dimension.key]: null }) + } + onValueChange={(next) => { + if (dimension.key === "a1") onA1Touched?.(); + onChange({ ...value, [dimension.key]: next }); + }} + /> + ); + })} +
+
+ ); +} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 7cd920d1c..94b5d7cc9 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -87,6 +87,13 @@ import { } from "@/components/data-list"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker"; +import { + EMPTY_LEVEL_A, + MoodLevelASection, + levelAFromEntry, + levelAUpdatePayload, + type LevelAState, +} from "./mood-level-a-fields"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; @@ -104,6 +111,12 @@ interface MoodEntry { // v1.8.5 — structured-tag keys + free-text note. tagKeys: string[]; ratedFactors: RatedFactor[]; + // v1.37 — the five level-A values, absent on entries written before them. + a1?: number | null; + a2?: number | null; + a3?: number | null; + a4?: number | null; + a5?: number | null; note: string | null; source: string; moodLoggedAt: string; @@ -185,6 +198,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { const [editTagsInput, setEditTagsInput] = useState(""); const [editTagKeys, setEditTagKeys] = useState([]); const [editRatedFactors, setEditRatedFactors] = useState([]); + // v1.37 — the five level-A values on the edit path. Seeded from the entry, + // so what the capture sheet wrote is visible and changeable here. + const [editLevelA, setEditLevelA] = useState(EMPTY_LEVEL_A); const [editNote, setEditNote] = useState(""); const [editMoodLoggedAt, setEditMoodLoggedAt] = useState(""); const [editSeed, setEditSeed] = useState<{ @@ -192,6 +208,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { tagsInput: string; tagKeys: string[]; ratedFactors: RatedFactor[]; + levelA: LevelAState; note: string; moodLoggedAt: string; }>({ @@ -199,6 +216,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { tagsInput: "", tagKeys: [], ratedFactors: [], + levelA: EMPTY_LEVEL_A, note: "", moodLoggedAt: "", }); @@ -391,6 +409,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { tags, tagKeys, ratedFactors, + levelA, note, moodLoggedAt, }: { @@ -399,6 +418,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { tags: string[] | null; tagKeys: string[]; ratedFactors: RatedFactor[]; + levelA: LevelAState; note: string | null; moodLoggedAt: string; }) => { @@ -407,6 +427,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { tags, tagKeys, ratedFactors, + // Nulls included: this path replaces rather than adds, so a value the + // user cleared has to travel as an explicit null or the old answer + // survives an edit that visibly removed it. + ...levelAUpdatePayload(levelA), note, moodLoggedAt, }); @@ -421,6 +445,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { ratedFactors: [...editRatedFactors].sort((a, b) => a.key.localeCompare(b.key), ), + levelA: editLevelA, note: editNote, moodLoggedAt: editMoodLoggedAt, }); @@ -439,6 +464,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { ratedFactors: [...editRatedFactors].sort((a, b) => a.key.localeCompare(b.key), ), + levelA: editLevelA, note: editNote, moodLoggedAt: editMoodLoggedAt, }, @@ -455,6 +481,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { ratedFactors: [...(entry.ratedFactors ?? [])].sort((a, b) => a.key.localeCompare(b.key), ), + levelA: levelAFromEntry(entry), note: entry.note ?? "", moodLoggedAt: toDateTimeLocalValue(entry.moodLoggedAt), }; @@ -463,6 +490,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { setEditTagsInput(seed.tagsInput); setEditTagKeys(seed.tagKeys); setEditRatedFactors(seed.ratedFactors); + setEditLevelA(seed.levelA); setEditNote(seed.note); setEditMoodLoggedAt(seed.moodLoggedAt); setEditSeed(seed); @@ -515,6 +543,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { tags: tags.length > 0 ? tags : null, tagKeys: editTagKeys, ratedFactors: editRatedFactors, + levelA: editLevelA, note: trimmedNote.length > 0 ? trimmedNote : null, moodLoggedAt: measuredDate.toISOString(), }); @@ -1008,6 +1037,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { />
+ {/* v1.37 — the five level-A values, seeded from the entry. + Pleasantness is not re-seeded from the face here: this dialog + edits an entry that already has an answer, and replacing it + with the derived one would quietly discard what the person + set. */} + + {/* v1.8.5 (C1) — free-text note. */}