diff --git a/messages/de.json b/messages/de.json index 1bd0337a4..378ac8b0e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -58,11 +58,11 @@ "classification": "Klassifikation", "bmiTitle": "Body-Mass-Index (BMI)", "bmiRow": "BMI: {bmi} kg/m² — Klassifikation: {class} (WHO)", - "complianceTitle": "Medikamenten-Compliance", + "complianceTitle": "Therapietreue", "glp1Title": "GLP-1-Therapie", "glp1WeightSummary": "Gewicht im Berichtszeitraum: {start} kg → {end} kg ({delta} kg)", "glp1CurrentDose": "Aktuelle Dosis: {value} {unit} (seit {since})", - "glp1Compliance": "Compliance: {taken} / {total} ({rate}%)", + "glp1Compliance": "Therapietreue: {taken} / {total} ({rate}%)", "glp1SideEffectsTitle": "Erfasste Nebenwirkungen", "colGlp1Date": "Datum", "colGlp1Dose": "Dosis", @@ -73,7 +73,7 @@ "colSkipped": "Übersprungen", "colMissed": "Verpasst", "colTotal": "Gesamt", - "colComplianceRate": "Compliance-Rate", + "colComplianceRate": "Therapietreue", "moodTitle": "Stimmungsverlauf", "moodSummary": "Durchschnitt: {avg} / 5,0 · Einträge: {count} · Bereich: {min}–{max}", "colMood": "Stimmung", @@ -144,7 +144,7 @@ "bmi": "Body-Mass-Index (BMI)", "mood": "Stimmung", "moodSensitive": "Sensibel — standardmäßig aus.", - "compliance": "Medikamenten-Compliance", + "compliance": "Therapietreue", "sleep": "Schlafdauer", "cycle": "Menstruationszyklus", "empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt.", @@ -155,7 +155,7 @@ "summaryTitle": "Klinische Zusammenfassung", "summaryReadings": "Über {days} Tage erfasst: {count} Messwerte über {params} Parameter.", "summaryTrend": "{label}: zuletzt {latest} {arrow}", - "summaryAdherence": "Medikamenten-Einnahmetreue: {rate}% (gewichteter Mittelwert).", + "summaryAdherence": "Medikamenten-Therapietreue: {rate}% (gewichteter Mittelwert).", "chartsTitle": "Trend-Diagramme", "aiSummaryTitle": "KI-Zusammenfassung — nicht klinisch validiert", "aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.", @@ -192,6 +192,9 @@ "offlineBanner": { "message": "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist." }, + "demoBanner": { + "message": "Demo-Modus — Änderungen werden nicht gespeichert." + }, "nav": { "dashboard": "Dashboard", "measurements": "Messungen", @@ -251,6 +254,7 @@ "hasAccount": "Bereits ein Konto?", "loginFailed": "Anmeldung fehlgeschlagen", "registerFailed": "Registrierung fehlgeschlagen", + "invitedBanner": "Du wurdest eingeladen — erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.", "createAccount": "Konto erstellen", "newAccount": "Neues Konto erstellen", "backToPasskey": "Zurück zu Passkey", @@ -314,7 +318,7 @@ "confirm": "Verwerfen", "cancel": "Weiter bearbeiten" }, - "compliance7d": "Einnahmetreue (7 Tage)", + "compliance7d": "Therapietreue (7 Tage)", "mood": "Stimmung", "customizeTitle": "Dashboard-Layout", "layoutReset": "Auf Defaults zurücksetzen", @@ -646,7 +650,7 @@ "slotError": "Der gewählte Slot konnte keiner Uhrzeit zugeordnet werden. Bitte wähle einen anderen Slot oder „Kein bestimmter Slot“." }, "emptyTitle": "Noch keine Medikamente", - "emptyDescription": "Lege Medikamente, Dosierungen und Einnahmefenster an, um Compliance zu erfassen.", + "emptyDescription": "Lege Medikamente, Dosierungen und Einnahmefenster an, um die Therapietreue zu erfassen.", "loadFailed": "Medikamente konnten nicht geladen werden", "retryLoad": "Erneut laden", "name": "Name", @@ -660,7 +664,7 @@ "intakeHistory": "Einnahme-Verlauf", "openDetailPage": "Medikament-Detailseite öffnen", "deleteConfirm": "Medikament löschen?", - "compliance": "Einnahmetreue", + "compliance": "Therapietreue", "importIntakes": "Einnahmen importieren", "importDescription": "JSON-Array mit Einnahme-Daten einfügen. Erwartetes Format:", "importUploadFile": "JSON-Datei hochladen", @@ -716,9 +720,11 @@ "takeNow": "Jetzt einnehmen", "overdue": "Überfällig", "veryOverdue": "Stark überfällig", - "compliance7d": "7-Tage-Compliance", - "compliance30d": "30-Tage-Compliance", - "complianceWindow": "{days}-Tage-Compliance", + "compliance7d": "Therapietreue (7 Tage)", + "compliance30d": "Therapietreue (30 Tage)", + "complianceWindow": "Therapietreue ({days} Tage)", + "complianceInfo": "Anteil der erfassten an den erwarteten Dosen. Wöchentliche Pläne zählen wochenweise.", + "complianceInfoLabel": "Was bedeutet Therapietreue?", "complianceDoses": "{count} Dosen", "lastIntake": "Letzte Einnahme:", "nextIntake": "Nächste Einnahme:", @@ -1046,6 +1052,8 @@ "subline": "Du kannst alles unten nochmal prüfen, bevor du speicherst.", "remindersLabel": "Erinnerungen aktiv", "remindersDescription": "Push, Telegram oder ntfy — je nach deinen Einstellungen.", + "noChannelHint": "Erinnerungen brauchen einen Benachrichtigungskanal — aktuell ist keiner aktiv.", + "noChannelHintLink": "Benachrichtigungen einrichten", "multiScheduleNote": "Dieses Medikament hat mehrere Zeitpläne. Den Detail-Editor öffnen wir in einer späteren Version.", "short": "Fertig" } @@ -1363,6 +1371,18 @@ "body": "Die Einnahme liegt knapp außerhalb des Fensters von {slot}. Diesem Slot zuordnen oder als außerplanmäßigen Eintrag speichern?", "keepAdHoc": "Außerplanmäßig", "pin": "Zuordnen" + }, + "pin": { + "action": "Slot {time} zuordnen", + "release": "Zuordnung lösen", + "pinnedTag": "zugeordnet", + "pinnedToast": "Einnahme dem Slot zugeordnet", + "unpinnedToast": "Zuordnung gelöst", + "dueContext": "fällig gewesen: {time} ({delta})", + "confirmTitle": "Slot {time} zuordnen?", + "confirmBody": "Die Einnahme zählt für diesen Slot als verspätet – die Quote wird nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.", + "confirmCancel": "Abbrechen", + "confirmAction": "Zuordnen" } }, "notifications": { @@ -1571,6 +1591,7 @@ "charts": { "loadingLabel": "Diagramm wird geladen…", "loadingSlowHint": "Auswertungen werden berechnet — das kann einen Moment dauern.", + "adjustTargets": "Zielwerte anpassen", "weight": "Gewicht", "bloodPressure": "Blutdruck", "pulse": "Puls", @@ -1695,7 +1716,7 @@ "lastMonth": "Vergleich nicht verfügbar — noch keine Daten aus dem Vormonat", "lastYear": "Vergleich nicht verfügbar — noch keine Daten aus dem Vorjahr" }, - "notAvailableForCompliance": "Vergleich für Compliance nicht verfügbar" + "notAvailableForCompliance": "Vergleich für Therapietreue nicht verfügbar" }, "insights": { "emptyTitle": "Noch nicht genug Daten", @@ -1803,13 +1824,13 @@ "componentBp": "Blutdruck", "componentWeight": "Gewicht", "componentMood": "Stimmung", - "componentCompliance": "Einnahmetreue", + "componentCompliance": "Therapietreue", "disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.", "deltaExplainer": { "trigger": "Was bedeutet diese Veränderung?", "title": "So wird die Veränderung berechnet", "description": "Veränderung gegenüber letzter Woche: {delta}", - "body": "Der Score kombiniert Blutdruck, Gewicht, Stimmung und Einnahmetreue. Wir vergleichen den heutigen Stand mit demselben Zeitpunkt vor einer Woche. Erfasse eine frische Messung oder einen Stimmungs-Check, um den Wert nach oben zu schubsen." + "body": "Der Score kombiniert Blutdruck, Gewicht, Stimmung und Therapietreue. Wir vergleichen den heutigen Stand mit demselben Zeitpunkt vor einer Woche. Erfasse eine frische Messung oder einen Stimmungs-Check, um den Wert nach oben zu schubsen." }, "provenance": { "toggle": "Zusammensetzung", @@ -1911,8 +1932,8 @@ "confidenceModerate": "Mittlere Konfidenz", "confidenceLow": "Geringe Konfidenz", "card": { - "bpComplianceTitle": "BD × Medikamenten-Compliance", - "bpComplianceSubtitle": "Täglicher Systolischer gegen Compliance-%", + "bpComplianceTitle": "BD × Therapietreue", + "bpComplianceSubtitle": "Täglicher Systolischer gegen Therapietreue in %", "moodPulseTitle": "Stimmung × Ruhepuls", "moodPulseSubtitle": "Stimmung und Ruhepuls am selben Tag", "weightWeekdayTitle": "Gewicht × Wochentag", @@ -1953,7 +1974,7 @@ "weight": "Gewicht", "pulse": "Puls", "mood": "Stimmung", - "compliance": "Einnahmetreue", + "compliance": "Therapietreue", "general": "Allgemein", "hrv": "HRV", "sleep": "Schlaf", @@ -2220,13 +2241,13 @@ "weightTitle": "Gewicht", "weightBody": "Das Körpergewicht ist eines der einfachsten Signale für die langfristige Energiebilanz und die allgemeine Gesundheit. Der Trend über Wochen zählt weit mehr als ein einzelner Wert, der mit Flüssigkeit, Ernährung und Tageszeit schwankt.", "bmiTitle": "Body-Mass-Index (BMI)", - "bmiBody": "Der BMI ist das Gewicht geteilt durch die Körpergröße zum Quadrat und ordnet Sie einer groben Gewichtskategorie zu. Er ist ein Orientierungswert, keine Diagnose, und unterscheidet nicht zwischen Muskeln und Fett.", + "bmiBody": "Der BMI ist dein Gewicht geteilt durch deine Körpergröße zum Quadrat und ordnet dich einer groben Gewichtskategorie zu. Er ist ein Orientierungswert, keine Diagnose, und unterscheidet nicht zwischen Muskeln und Fett.", "sleepTitle": "Schlaf", "sleepBody": "Die Schlafdauer ist die tatsächlich geschlafene Zeit pro Nacht, aufgeteilt in Leicht-, Tief- und REM-Schlaf. Die meisten Erwachsenen brauchen etwa sieben bis neun Stunden; gleichmäßige Menge und Qualität unterstützen Erholung, Stimmung und Stoffwechsel.", "moodTitle": "Stimmung", - "moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie Sie sich fühlen. Regelmäßiges Protokollieren zeigt Muster – wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.", + "moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie du dich fühlst. Regelmäßiges Protokollieren zeigt Muster – wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.", "medicationsTitle": "Medikamenten-Therapietreue", - "medicationsBody": "Die Therapietreue zeigt, wie zuverlässig Sie Ihre Medikamente wie verordnet einnehmen, hier als Anteil der erwarteten Dosen, die Sie erfasst haben. Eine gleichmäßige Einnahme entscheidet oft darüber, ob eine Therapie wie beabsichtigt wirkt.", + "medicationsBody": "Die Therapietreue zeigt, wie zuverlässig du deine Medikamente wie verordnet einnimmst, hier als Anteil der erwarteten Dosen, die du erfasst hast. Eine gleichmäßige Einnahme entscheidet oft darüber, ob eine Therapie wie beabsichtigt wirkt.", "restingHrTitle": "Ruhepuls", "restingHrBody": "Der Ruhepuls ist die Herzfrequenz in völliger Ruhe, meist über Nacht gemessen. Ein niedriger Ruhepuls deutet in der Regel auf eine bessere Herz-Kreislauf-Fitness hin, ein steigender Trend kann ein frühes Zeichen für Stress oder Krankheit sein.", "hrvTitle": "Herzfrequenzvariabilität (HRV)", @@ -2234,11 +2255,11 @@ "oxygenSaturationTitle": "Sauerstoffsättigung (SpO₂)", "oxygenSaturationBody": "Die SpO₂ ist der Prozentsatz der roten Blutkörperchen, die Sauerstoff transportieren. Werte von 95–100 % sind in Ruhe normal; Werte, die dauerhaft unter 90 % liegen, verdienen Aufmerksamkeit, kurze nächtliche Abfälle sind jedoch häufig.", "bodyTemperatureTitle": "Körpertemperatur", - "bodyTemperatureBody": "Die Körpertemperatur spiegelt Ihren Kernwärmezustand wider, normalerweise um 37 °C mit kleinen Tagesschwankungen. Ein anhaltender Anstieg kann auf eine Infektion hindeuten; nächtliche Messungen am Handgelenk erfassen vor allem relative Änderungen, keinen klinischen Wert.", + "bodyTemperatureBody": "Die Körpertemperatur spiegelt deinen Kernwärmezustand wider, normalerweise um 37 °C mit kleinen Tagesschwankungen. Ein anhaltender Anstieg kann auf eine Infektion hindeuten; nächtliche Messungen am Handgelenk erfassen vor allem relative Änderungen, keinen klinischen Wert.", "activeEnergyTitle": "Aktive Energie", - "activeEnergyBody": "Die aktive Energie sind die Kalorien, die Sie durch Bewegung verbrennen, zusätzlich zum Grundumsatz in Ruhe. Sie ist ein direktes Maß dafür, wie körperlich aktiv Ihr Tag war.", + "activeEnergyBody": "Die aktive Energie sind die Kalorien, die du durch Bewegung verbrennst, zusätzlich zum Grundumsatz in Ruhe. Sie ist ein direktes Maß dafür, wie körperlich aktiv dein Tag war.", "workoutsTitle": "Workouts", - "workoutsBody": "Workouts sind die strukturierten Trainingseinheiten, die Ihre Geräte mit Art, Dauer und Energie aufzeichnen. Ihre Erfassung zeigt, wie sich regelmäßiges Training über Wochen summiert und wie es mit Ihren anderen Gesundheitssignalen zusammenhängt.", + "workoutsBody": "Workouts sind die strukturierten Trainingseinheiten, die deine Geräte mit Art, Dauer und Energie aufzeichnen. Ihre Erfassung zeigt, wie sich regelmäßiges Training über Wochen summiert und wie es mit deinen anderen Gesundheitssignalen zusammenhängt.", "respiratoryRateTitle": "Atemfrequenz", "respiratoryRateBody": "Die Atemfrequenz ist die Anzahl der Atemzüge pro Minute. In Ruhe atmen die meisten Erwachsenen etwa zwölf- bis zwanzigmal pro Minute; ein anhaltender Anstieg kann mit Krankheit, Anstrengung oder Stress einhergehen.", "bodyWaterTitle": "Gesamtkörperwasser", @@ -2303,11 +2324,11 @@ "stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht — ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.", "breathingDisturbancesTitle": "Atemaussetzer im Schlaf", "breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden.", - "readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Ein höherer Wert bedeutet, dass dein System erholt und belastbar wirkt; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten und deinem Körper Zeit zum Aufholen zu geben. Sieh ihn als Orientierung für die Tagesplanung, nicht als Urteil.", - "sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen und wägt ab, wie lange und wie effizient du geschlafen hast, wie regelmäßig deine Zeiten waren und wie sich Leicht-, Tief- und REM-Schlaf verteilen. Er soll auf einen Blick zeigen, ob die Nacht dich wirklich erholt hat. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.", - "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat und wie viel Kapazität du heute für Belastung hast — sie stützt sich auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut. Er ist eine Orientierung, wie stark du dich einbringen kannst, keine starre Grenze.", - "strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Sobald genügend Verlauf vorliegt, wird sie an deinem eigenen üblichen Aufwand gemessen und spiegelt so, was speziell für dich fordernd ist. Nutze sie, um anstrengende Tage mit ruhigeren auszugleichen und dein Training tragfähig zu halten.", - "stressBody": "Stress ist hier eine Schätzung der körperlichen Last auf deinem Nervensystem, vor allem aus Mustern der Herzfrequenzvariabilität abgelesen — nicht daraus, wie du dich bewusst fühlst. Ein niedrigerer Wert deutet auf einen ruhigeren, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen. Er ist ein beschreibender Näherungswert, um Trends zu erkennen, keine klinische Messung." + "readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Ein höherer Wert heißt erholt und belastbar; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten.", + "sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen — aus Dauer, Effizienz, Regelmäßigkeit und der Verteilung von Leicht-, Tief- und REM-Schlaf. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.", + "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.", + "strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Sobald genügend Verlauf vorliegt, wird sie an deinem eigenen üblichen Aufwand gemessen, nicht an einem fremden Maßstab.", + "stressBody": "Stress ist hier eine Schätzung der körperlichen Last auf deinem Nervensystem, vor allem aus Mustern der Herzfrequenzvariabilität abgelesen — nicht daraus, wie du dich bewusst fühlst. Ein niedrigerer Wert deutet auf einen ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen." }, "stats": { "label": "Zusammenfassende Statistik", @@ -2361,10 +2382,10 @@ } }, "correlations": "Korrelationen", - "medicationCompliance": "Medikamenten-Compliance", + "medicationCompliance": "Therapietreue", "dayStreak": "{count} Tage Serie", - "compliance7d": "7-Tage-Compliance", - "compliance30d": "30-Tage-Compliance", + "compliance7d": "Therapietreue (7 Tage)", + "compliance30d": "Therapietreue (30 Tage)", "taken": "genommen", "skipped": "übersprungen", "missed": "verpasst", @@ -2380,7 +2401,7 @@ "weightSectionTitle": "Gewicht", "pulseSectionTitle": "Puls", "bmiSectionTitle": "BMI", - "complianceNoData": "Keine Compliance-Daten verfügbar", + "complianceNoData": "Keine Daten zur Therapietreue verfügbar", "generate": "Neue Analyse erstellen", "moodSectionTitle": "Stimmung", "confidence": "Konfidenz", @@ -2413,7 +2434,7 @@ }, "medication": { "title": "Noch keine Medikamente angelegt", - "description": "Lege ein Medikament an, dann erscheinen Einnahme-Compliance, Wirkstoffspiegel und Eskalations-Verlauf.", + "description": "Lege ein Medikament an, dann erscheinen Therapietreue, Wirkstoffspiegel und Eskalations-Verlauf.", "cta": "Medikament hinzufügen" }, "sleep": { @@ -3331,7 +3352,7 @@ "measurementDescription": "Ein Messpunkt reicht, um die Kachel zu aktivieren — der Trend zeichnet sich ab dem 5. Eintrag.", "measurementCta": "Messung hinzufügen", "medicationTitle": "Medikament hinzufügen", - "medicationDescription": "Verfolge Compliance und erhalte Erinnerungen genau dann, wenn du sie brauchst.", + "medicationDescription": "Verfolge die Therapietreue und erhalte Erinnerungen genau dann, wenn du sie brauchst.", "medicationCta": "Medikament hinzufügen", "withingsTitle": "Withings verbinden (optional)", "withingsDescription": "Synchronisiere Gewicht, Blutdruck und Puls automatisch aus deinen Withings-Geräten.", @@ -3386,6 +3407,8 @@ "sources": { "title": "Quellen-Priorität", "description": "Lege fest, welche Quelle führt, wenn mehrere am selben Tag dieselbe Metrik liefern.", + "integrationsHint": "Neue Datenquellen verbindest oder trennst du auf der Integrationen-Seite.", + "integrationsHintLink": "Integrationen öffnen", "cardTitle": "Quellen-Priorität pro Metrik", "help": "Wenn mehrere Quellen denselben Wert am selben Tag liefern (z. B. Withings + Apple Health beide 1.200 Schritte), wird die oberste Quelle als kanonisch verwendet. Andere Quellen bleiben gespeichert für die Audit-Spur.", "resetDefaults": "Auf Standard zurücksetzen", @@ -3459,7 +3482,7 @@ "downloadFailed": "Der Export konnte nicht heruntergeladen werden. Bitte versuche es erneut.", "hero": { "eyebrow": "Arzttermin", - "valueStatement": "Ein druckbarer PDF-Bericht für den Arzttermin — Vitalwerte, BMI, Blutdruck-Klassifikation, Medikamenten-Compliance und (optional) Stimmungsverlauf, alles auf wenigen Seiten.", + "valueStatement": "Ein druckbarer PDF-Bericht für den Arzttermin — Vitalwerte, BMI, Blutdruck-Klassifikation, Therapietreue und (optional) Stimmungsverlauf, alles auf wenigen Seiten.", "cta": "Bericht konfigurieren & generieren", "formatHint": "PDF · druckfertig" }, @@ -3473,7 +3496,7 @@ "cards": { "doctorReport": { "title": "Arztbericht", - "description": "Druckbares PDF für den Arzttermin — Vitalwerte, BMI, BD-Klassifikation, Medikamenten-Compliance, Stimmung." + "description": "Druckbares PDF für den Arzttermin — Vitalwerte, BMI, BD-Klassifikation, Therapietreue, Stimmung." }, "measurementsCsv": { "title": "Messwerte", @@ -3655,8 +3678,8 @@ "codexDisconnected": "ChatGPT-Verbindung getrennt.", "codexConnectionFailed": "ChatGPT-Verbindung fehlgeschlagen. Bitte versuche es erneut.", "rawData": "Rohdaten mitsenden", - "rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte Rohdaten der letzten 30 Tage gesendet. Dazu zählen einzelne Messpunkte je Bereich (z. B. Gewicht, Blutdruck, Puls) mit Zeitbezug, damit der Provider zeitliche Muster, Ausreißer und Zusammenhänge zuverlässiger erkennen kann. Nicht übertragen werden Name, E-Mail oder direkte Kontodaten.", - "rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum). Keine einzelnen Messpunkte und keine exakten Zeitbezüge. Das ist datensparsamer, der Provider kann dafür aber weniger fein auf Muster und kurzfristige Schwankungen eingehen.", + "rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte einzelne Messpunkte der letzten 30 Tage mit Zeitbezug gesendet — ohne Name, E-Mail oder direkte Kontodaten.", + "rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum) — ohne einzelne Messpunkte und exakte Zeitbezüge.", "rawDataWarning": "Rohdaten aktiv: Der Provider erhält zusätzlich anonymisierte Messpunkte der letzten 30 Tage für eine präzisere Auswertung.", "regenerateInsights": "Reports neu generieren", "regenerateSuccess": "Reports wurden neu generiert", @@ -3788,6 +3811,19 @@ "savedHidden": "Coach ist ausgeblendet.", "savedShown": "Coach ist sichtbar.", "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen." + }, + "aboutMe": { + "title": "Über mich", + "description": "Persönlicher Kontext, den Coach und Tagesbriefing zusätzlich zu deinen Daten lesen.", + "placeholder": "Z. B. Schichtarbeit, vegetarische Ernährung, Knieverletzung, Training für einen Halbmarathon …", + "charCount": "{used} / {max} Zeichen", + "clear": "Leeren", + "save": "Speichern", + "savedToast": "Über-mich-Notiz gespeichert.", + "clearedToast": "Über-mich-Notiz gelöscht.", + "saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut.", + "loadError": "Die Notiz konnte nicht geladen werden.", + "hint": "Wird als zusätzlicher Kontext an Coach und Tagesbriefing übergeben. Leer lassen, um nichts mitzusenden." } }, "dashboard": { @@ -3817,6 +3853,21 @@ "withingsSyncResult": "{count} Messwerte synchronisiert", "withingsFullSyncResult": "{count} Messwerte vollständig synchronisiert", "withingsSyncFailed": "Sync fehlgeschlagen", + "withingsOauthConnected": "Withings verbunden — der erste Sync startet im Hintergrund.", + "withingsOauthFailed": "Withings-Verbindung fehlgeschlagen", + "withingsOauthError": { + "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Starte die Verbindung bitte erneut im selben Browser.", + "replay": "Dieser Bestätigungslink wurde bereits verwendet. Starte die Verbindung bitte erneut.", + "state": "Die Antwort von Withings passte zu keiner offenen Verbindungsanfrage. Starte die Verbindung bitte erneut.", + "expired": "Die Verbindungsanfrage ist abgelaufen. Starte die Verbindung erneut und schließe die Freigabe innerhalb weniger Minuten ab.", + "cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Starte die Verbindung bitte aus deinem eigenen Konto neu.", + "nocode": "Withings hat keinen Freigabe-Code übermittelt. Versuche es erneut und erlaube den Zugriff im Withings-Dialog.", + "nocreds": "Es sind keine Withings-API-Zugangsdaten hinterlegt. Speichere zuerst Client-ID und Client-Secret, dann verbinde erneut.", + "token": "Der Token-Austausch mit Withings ist fehlgeschlagen. Prüfe Client-ID und Client-Secret und versuche es erneut.", + "generic": "Unbekannter Fehler bei der Verbindung. Starte die Verbindung bitte erneut." + }, + "integrationsSourcesHint": "Liefern mehrere Quellen dieselbe Metrik, entscheidet die Quellen-Priorität, welcher Wert zählt.", + "integrationsSourcesHintLink": "Quellen-Priorität öffnen", "withingsSynchronize": "Synchronisieren", "withingsDisconnect": "Trennen", "withingsDisconnectTitle": "Withings-Verbindung trennen?", @@ -4062,7 +4113,7 @@ "distance": "Distanz", "sleep": "Schlaf", "glucose": "Blutzucker", - "medications": "Medikamente & Einnahmetreue", + "medications": "Medikamente & Therapietreue", "mood": "Stimmung (standardmäßig aus — sensible Daten)", "bmi": "BMI", "includeCharts": "Trend-Diagramme einbetten (nur PDF)", @@ -4253,6 +4304,36 @@ "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Issue-Token", "bugReportTokenPlaceholder": "ghp_...", + "invites": { + "title": "Einladungen", + "description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung. Der Link wird nur einmal angezeigt.", + "createLabel": "Neue Einladung:", + "createDays": "{days} Tage", + "createdToast": "Einladung erstellt.", + "createError": "Einladung konnte nicht erstellt werden.", + "tokenTitle": "Einladungslink", + "tokenOnceHint": "Wird nur jetzt angezeigt — kopiere den Link oder lass den QR-Code scannen.", + "copyLink": "Link kopieren", + "copiedToast": "Link kopiert.", + "copyError": "Kopieren fehlgeschlagen.", + "qrAlt": "QR-Code des Einladungslinks", + "loadError": "Einladungen konnten nicht geladen werden.", + "listEmpty": "Keine Einladungen vorhanden.", + "expires": "läuft ab am {date}", + "uses": "{used} / {max} genutzt", + "createdMeta": "Erstellt am {date} von {by}", + "usedBy": "zuletzt genutzt von {username}", + "statusActive": "Aktiv", + "statusExpired": "Abgelaufen", + "statusExhausted": "Aufgebraucht", + "deleteAria": "Einladung löschen", + "deleteConfirmTitle": "Einladung löschen?", + "deleteConfirmBody": "Der Einladungslink wird sofort ungültig. Bereits registrierte Konten bleiben bestehen.", + "deleteConfirmAction": "Löschen", + "cancel": "Abbrechen", + "deletedToast": "Einladung gelöscht.", + "deleteError": "Einladung konnte nicht gelöscht werden." + }, "userManagement": "Benutzerverwaltung", "userName": "Name", "userEmail": "E-Mail", @@ -4649,7 +4730,7 @@ }, "compliance801": { "title": "Treue 80% · 1T", - "description": "Erreiche an einem Tag eine 30-Tage-Einnahmetreue von mindestens 80%." + "description": "Erreiche an einem Tag eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen1": { "title": "BMI im Grün · 1T", @@ -4669,7 +4750,7 @@ }, "compliance807": { "title": "Treue 80% · 7T", - "description": "Erreiche 7 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%." + "description": "Erreiche 7 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen7": { "title": "BMI im Grün · 7T", @@ -4689,7 +4770,7 @@ }, "compliance8030": { "title": "Treue 80% · 30T", - "description": "Erreiche 30 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%." + "description": "Erreiche 30 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen30": { "title": "BMI im Grün · 30T", @@ -4709,7 +4790,7 @@ }, "compliance80180": { "title": "Treue 80% · 180T", - "description": "Erreiche 180 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%." + "description": "Erreiche 180 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen180": { "title": "BMI im Grün · 180T", @@ -4729,7 +4810,7 @@ }, "compliance80360": { "title": "Treue 80% · 360T", - "description": "Erreiche 360 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%." + "description": "Erreiche 360 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen360": { "title": "BMI im Grün · 360T", @@ -4916,12 +4997,22 @@ "hourAria": "Stunde der Stimmungs-Erinnerung", "hourSavedToast": "Erinnerungszeit aktualisiert." }, + "coachNudge": { + "title": "Proaktiver Coach", + "description": "Meldet sich höchstens einmal pro Woche, wenn deine Daten einen Blick in den Coach nahelegen — etwa bei niedriger Therapietreue oder erhöhtem Blutdruck-Wochenmittel.", + "toggleAria": "Proaktive Coach-Hinweise umschalten", + "statusOn": "Aktiv — max. 1× pro Woche", + "statusOff": "Aus", + "enabledToast": "Proaktive Coach-Hinweise aktiviert.", + "disabledToast": "Proaktive Coach-Hinweise deaktiviert.", + "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen." + }, "eventMedicationReminder": "Medikamenten-Erinnerung", "eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.", "eventMeasurementAnomaly": "Auffälliger Messwert", "eventMeasurementAnomalyDesc": "Wird gesendet, wenn ein Wert deutlich außerhalb deines üblichen Bereichs liegt.", - "eventComplianceLow": "Niedrige Einnahmequote", - "eventComplianceLowDesc": "Wird gesendet, wenn deine Einnahmequote in den letzten Tagen unter 80 % fällt.", + "eventComplianceLow": "Niedrige Therapietreue", + "eventComplianceLowDesc": "Wird gesendet, wenn deine Therapietreue in den letzten Tagen unter 80 % fällt.", "eventWithingsSyncFailed": "Withings-Sync fehlgeschlagen", "eventWithingsSyncFailedDesc": "Wird gesendet, wenn die Withings-Synchronisierung mehrere Versuche in Folge scheitert.", "eventSystemAlert": "Systemmeldung", @@ -5102,6 +5193,14 @@ "dailyTitle": "Stimmung erfassen", "dailyBody": "Wie geht es dir heute?" }, + "coachNudges": { + "complianceTitle": "Dein Coach hätte einen Gedanken dazu", + "complianceBody": "Deine Einnahmen waren diese Woche lückenhaft. Sprich kurz mit dem Coach — vielleicht lässt sich der Plan vereinfachen.", + "bpTitle": "Blutdruck im Wochenmittel erhöht", + "bpBody": "Dein Blutdruck lag diese Woche im Schnitt über deinem Ziel. Der Coach kann die Werte mit dir einordnen.", + "scoreTitle": "Dein Erholungswert fällt", + "scoreBody": "Dein Erholungswert ist diese Woche deutlich gesunken. Der Coach hilft dir, mögliche Ursachen einzugrenzen." + }, "clinicianView": { "title": "Geteilte Gesundheitsakte", "period": "Berichtszeitraum: {start} – {end}", @@ -5109,10 +5208,10 @@ "provenance": "Dies ist eine schreibgeschützte Zusammenfassung, die von der betreffenden Person geteilt wurde, exportiert aus ihren selbst erfassten HealthLog-Daten. Die Werte sind selbst erhoben und können geräteübertragene Messwerte enthalten; behandeln Sie sie als von der Patientin/dem Patienten berichtet, nicht als klinische Untersuchung.", "vitals": "Vitalwerte", "labs": "Laborwerte", - "medications": "Medikamente & Einnahmetreue", + "medications": "Medikamente & Therapietreue", "bmi": "Body-Mass-Index (BMI)", "statSummary": "zuletzt {latest} (Ø {avg}, Bereich {min}–{max})", - "adherence": "{rate} Einnahmetreue", + "adherence": "{rate} Therapietreue", "noAdherence": "—", "glucose": { "FASTING": "Glukose (nüchtern)", diff --git a/messages/en.json b/messages/en.json index 12ea7fd8a..f39b413fe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -58,11 +58,11 @@ "classification": "Classification", "bmiTitle": "Body Mass Index (BMI)", "bmiRow": "BMI: {bmi} kg/m² — Classification: {class} (WHO)", - "complianceTitle": "Medication compliance", + "complianceTitle": "Medication adherence", "glp1Title": "GLP-1 therapy", "glp1WeightSummary": "Weight over report period: {start} kg → {end} kg ({delta} kg)", "glp1CurrentDose": "Current dose: {value} {unit} (since {since})", - "glp1Compliance": "Compliance: {taken} / {total} ({rate}%)", + "glp1Compliance": "Adherence: {taken} / {total} ({rate}%)", "glp1SideEffectsTitle": "Side effects logged", "colGlp1Date": "Date", "colGlp1Dose": "Dose", @@ -73,7 +73,7 @@ "colSkipped": "Skipped", "colMissed": "Missed", "colTotal": "Total", - "colComplianceRate": "Compliance rate", + "colComplianceRate": "Adherence rate", "moodTitle": "Mood trajectory", "moodSummary": "Average: {avg} / 5.0 · Entries: {count} · Range: {min}–{max}", "colMood": "Mood", @@ -144,7 +144,7 @@ "bmi": "Body Mass Index (BMI)", "mood": "Mood", "moodSensitive": "Sensitive — off by default.", - "compliance": "Medication compliance", + "compliance": "Medication adherence", "sleep": "Sleep duration", "cycle": "Menstrual cycle", "empty": "No data was recorded in this date range yet. The report will still include the cover page.", @@ -192,6 +192,9 @@ "offlineBanner": { "message": "No connection — your changes will save once you're back online." }, + "demoBanner": { + "message": "Demo mode — changes are not saved." + }, "nav": { "dashboard": "Dashboard", "measurements": "Measurements", @@ -251,6 +254,7 @@ "hasAccount": "Already have an account?", "loginFailed": "Login failed", "registerFailed": "Registration failed", + "invitedBanner": "You have been invited — create your account even while registration is otherwise closed.", "createAccount": "Create account", "newAccount": "Create new account", "backToPasskey": "Back to Passkey", @@ -314,7 +318,7 @@ "confirm": "Discard", "cancel": "Keep editing" }, - "compliance7d": "Compliance (7 days)", + "compliance7d": "Adherence (7 days)", "mood": "Mood", "customizeTitle": "Dashboard layout", "layoutReset": "Reset to defaults", @@ -646,7 +650,7 @@ "slotError": "The selected slot couldn’t be matched to a time. Pick another slot or “No specific slot”." }, "emptyTitle": "No medications yet", - "emptyDescription": "Add medications, dosages, and reminder windows to track compliance.", + "emptyDescription": "Add medications, dosages, and reminder windows to track adherence.", "loadFailed": "Could not load medications", "retryLoad": "Retry", "name": "Name", @@ -660,7 +664,7 @@ "intakeHistory": "Intake history", "openDetailPage": "Open medication detail page", "deleteConfirm": "Delete medication?", - "compliance": "Compliance", + "compliance": "Adherence", "importIntakes": "Import intakes", "importDescription": "Paste a JSON array with intake data. Expected format:", "importUploadFile": "Upload JSON file", @@ -716,9 +720,11 @@ "takeNow": "Take now", "overdue": "Overdue", "veryOverdue": "Very overdue", - "compliance7d": "7-day compliance", - "compliance30d": "30-day compliance", - "complianceWindow": "{days}-day compliance", + "compliance7d": "Adherence (7 days)", + "compliance30d": "Adherence (30 days)", + "complianceWindow": "Adherence ({days} days)", + "complianceInfo": "Share of expected doses you logged. Weekly schedules count week by week.", + "complianceInfoLabel": "What does adherence mean?", "complianceDoses": "{count} doses", "lastIntake": "Last intake:", "nextIntake": "Next intake:", @@ -1046,6 +1052,8 @@ "subline": "Review everything below before you save.", "remindersLabel": "Reminders on", "remindersDescription": "Push, Telegram or ntfy — based on your settings.", + "noChannelHint": "Reminders need a notification channel — none is active right now.", + "noChannelHintLink": "Set up notifications", "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.", "short": "Done" } @@ -1363,6 +1371,18 @@ "body": "This intake falls just outside the {slot} window. Attribute it to that slot, or keep it as an off-schedule entry?", "keepAdHoc": "Off-schedule", "pin": "Attribute" + }, + "pin": { + "action": "Attribute to slot {time}", + "release": "Release attribution", + "pinnedTag": "attributed", + "pinnedToast": "Intake attributed to slot", + "unpinnedToast": "Attribution released", + "dueContext": "was due: {time} ({delta})", + "confirmTitle": "Attribute to slot {time}?", + "confirmBody": "The intake counts as taken late for this slot – the rate is never flattered. You can release the attribution at any time.", + "confirmCancel": "Cancel", + "confirmAction": "Attribute" } }, "notifications": { @@ -1571,6 +1591,7 @@ "charts": { "loadingLabel": "Loading chart…", "loadingSlowHint": "Computing analytics — this can take a moment.", + "adjustTargets": "Adjust targets", "weight": "Weight", "bloodPressure": "Blood Pressure", "pulse": "Pulse", @@ -1695,7 +1716,7 @@ "lastMonth": "Comparison unavailable — no data from last month yet", "lastYear": "Comparison unavailable — no data from last year yet" }, - "notAvailableForCompliance": "Comparison N/A for compliance" + "notAvailableForCompliance": "Comparison N/A for adherence" }, "insights": { "noAnalysisYet": "No assessment yet.", @@ -1803,13 +1824,13 @@ "componentBp": "BP", "componentWeight": "Weight", "componentMood": "Mood", - "componentCompliance": "Meds", + "componentCompliance": "Adherence", "disclaimer": "Indicative — not a clinical assessment.", "deltaExplainer": { "trigger": "What does this delta mean?", "title": "How the delta is computed", "description": "Change vs last week: {delta}", - "body": "The score combines BP, weight, mood and meds. We compare today's score with the same time last week. Log a fresh reading or a mood check to nudge it up." + "body": "The score combines BP, weight, mood and medication adherence. We compare today's score with the same time last week. Log a fresh reading or a mood check to nudge it up." }, "provenance": { "toggle": "Driven by", @@ -1911,8 +1932,8 @@ "confidenceModerate": "Moderate confidence", "confidenceLow": "Low confidence", "card": { - "bpComplianceTitle": "BP × medication compliance", - "bpComplianceSubtitle": "Daily systolic against compliance %", + "bpComplianceTitle": "BP × medication adherence", + "bpComplianceSubtitle": "Daily systolic against adherence in %", "moodPulseTitle": "Mood × resting pulse", "moodPulseSubtitle": "Same-day mood and resting pulse", "weightWeekdayTitle": "Weight × weekday", @@ -1953,7 +1974,7 @@ "weight": "Weight", "pulse": "Pulse", "mood": "Mood", - "compliance": "Compliance", + "compliance": "Adherence", "general": "General", "hrv": "HRV", "sleep": "Sleep", @@ -2303,11 +2324,11 @@ "stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance — a steady pace is a good sign, and a falling one is worth watching.", "breathingDisturbancesTitle": "Sleep breathing disturbances", "breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician.", - "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. A higher score means your system looks recovered and resourced; a lower one is a gentle cue to ease off and let your body catch up. Treat it as guidance for pacing the day, not a verdict.", - "sleepScoreBody": "Your sleep score distils a whole night into one number, weighing how long you slept, how efficiently, how regular your timing was, and the balance of light, deep, and REM. It is meant to show at a glance whether the night truly restored you. Watch the pattern across weeks rather than chasing a perfect score on any single night.", - "recoveryBody": "Recovery reflects how well your body has bounced back and how much capacity you have to take on load today, drawing on overnight signals like heart-rate variability and resting heart rate. A strong score is a green light to push; a softer one suggests your system is still rebuilding. It is a guide for how hard to lean in, not a limit.", - "strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. Once you have enough history it is judged against your own typical effort, so it reflects what is demanding for you specifically. Use it to balance hard days with easier ones and keep your training sustainable.", - "stressBody": "Stress here is an estimate of physiological load on your nervous system, read mainly from heart-rate-variability patterns rather than how you consciously feel. A lower score points to a calmer, well-regulated state; a higher one is an invitation to build in recovery. It is a descriptive proxy to notice trends, not a clinical measurement." + "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. A higher score means recovered and resourced; a lower one is a gentle cue to ease off.", + "sleepScoreBody": "Your sleep score distils a whole night into one number — duration, efficiency, regularity, and the balance of light, deep, and REM. Watch the pattern across weeks rather than chasing a perfect score on any single night.", + "recoveryBody": "Recovery reflects how well your body has bounced back, drawing on overnight signals like heart-rate variability and resting heart rate. A strong score is a green light to push; a softer one suggests your system is still rebuilding.", + "strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. Once you have enough history it is judged against your own typical effort, not someone else's baseline.", + "stressBody": "Stress here is an estimate of physiological load on your nervous system, read mainly from heart-rate-variability patterns rather than how you consciously feel. A lower score points to a calm, well-regulated state; a higher one is an invitation to build in recovery." }, "stats": { "label": "Summary statistics", @@ -2361,10 +2382,10 @@ } }, "correlations": "Correlations", - "medicationCompliance": "Medication Compliance", + "medicationCompliance": "Medication adherence", "dayStreak": "{count} day streak", - "compliance7d": "7-day compliance", - "compliance30d": "30-day compliance", + "compliance7d": "Adherence (7 days)", + "compliance30d": "Adherence (30 days)", "taken": "taken", "skipped": "skipped", "missed": "missed", @@ -2380,7 +2401,7 @@ "weightSectionTitle": "Weight", "pulseSectionTitle": "Pulse", "bmiSectionTitle": "BMI", - "complianceNoData": "No compliance data available.", + "complianceNoData": "No adherence data available.", "generate": "Generate new analysis", "moodSectionTitle": "Mood", "confidence": "Confidence", @@ -2413,7 +2434,7 @@ }, "medication": { "title": "No medications set up yet", - "description": "Set up a medication and intake compliance, drug level and titration timeline will appear here.", + "description": "Set up a medication and adherence, drug level and titration timeline will appear here.", "cta": "Add medication" }, "sleep": { @@ -3331,7 +3352,7 @@ "measurementDescription": "One reading unlocks the tile — the trend itself draws from your 5th reading.", "measurementCta": "Add measurement", "medicationTitle": "Add a medication", - "medicationDescription": "Track compliance and get reminders right when you need them.", + "medicationDescription": "Track adherence and get reminders right when you need them.", "medicationCta": "Add medication", "withingsTitle": "Connect Withings (optional)", "withingsDescription": "Auto-sync weight, BP and pulse from your Withings devices.", @@ -3386,6 +3407,8 @@ "sources": { "title": "Source priority", "description": "Pick which ingest source wins when more than one records the same metric for the same day.", + "integrationsHint": "Connect or disconnect data sources on the Integrations page.", + "integrationsHintLink": "Open integrations", "cardTitle": "Source priority by metric", "help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.", "resetDefaults": "Reset to defaults", @@ -3459,7 +3482,7 @@ "downloadFailed": "Couldn't download the export. Please try again.", "hero": { "eyebrow": "Doctor visit", - "valueStatement": "A printable PDF report for your doctor's appointment — vitals, BMI, blood-pressure classification, medication compliance and (optionally) mood, all on a few pages.", + "valueStatement": "A printable PDF report for your doctor's appointment — vitals, BMI, blood-pressure classification, medication adherence and (optionally) mood, all on a few pages.", "cta": "Configure & generate report", "formatHint": "PDF · print-ready" }, @@ -3473,7 +3496,7 @@ "cards": { "doctorReport": { "title": "Doctor Report", - "description": "Printable PDF for the doctor's appointment — vitals, BMI, BP classification, medication compliance, mood." + "description": "Printable PDF for the doctor's appointment — vitals, BMI, BP classification, medication adherence, mood." }, "measurementsCsv": { "title": "Measurements", @@ -3655,8 +3678,8 @@ "codexDisconnected": "ChatGPT connection removed.", "codexConnectionFailed": "ChatGPT connection failed. Please try again.", "rawData": "Send raw data", - "rawDataOnDescription": "Aggregated metrics plus anonymized raw points from the last 30 days are sent. This includes per-metric measurements (for example weight, blood pressure, pulse) with time context so the provider can detect patterns, outliers, and correlations more reliably. Name, email, and direct account identifiers are not transmitted.", - "rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum). No individual points and no exact time context. This is more privacy-preserving, but the provider can be less precise for short-term patterns and fluctuations.", + "rawDataOnDescription": "Aggregated metrics plus anonymized individual points from the last 30 days with time context are sent — never your name, email, or direct account identifiers.", + "rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum) — no individual points and no exact time context.", "rawDataWarning": "Raw mode enabled: the provider receives additional anonymized points from the last 30 days for higher analysis accuracy.", "regenerateInsights": "Regenerate reports", "regenerateSuccess": "Reports regenerated successfully", @@ -3788,6 +3811,19 @@ "savedHidden": "Coach hidden.", "savedShown": "Coach visible.", "saveError": "Could not save the change. Please try again." + }, + "aboutMe": { + "title": "About me", + "description": "Personal context the Coach and the daily briefing read alongside your data.", + "placeholder": "E.g. shift work, vegetarian diet, knee injury, training for a half marathon…", + "charCount": "{used} / {max} characters", + "clear": "Clear", + "save": "Save", + "savedToast": "About-me note saved.", + "clearedToast": "About-me note cleared.", + "saveError": "Could not save. Please try again.", + "loadError": "The note could not be loaded.", + "hint": "Passed to the Coach and the daily briefing as additional context. Leave empty to send nothing." } }, "dashboard": { @@ -3817,6 +3853,21 @@ "withingsSyncResult": "{count} measurements synchronized", "withingsFullSyncResult": "{count} measurements fully synchronized", "withingsSyncFailed": "Sync failed", + "withingsOauthConnected": "Withings connected — the first sync starts in the background.", + "withingsOauthFailed": "Withings connection failed", + "withingsOauthError": { + "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", + "replay": "This confirmation link has already been used. Please start the connection again.", + "state": "The response from Withings did not match any open connection request. Please start the connection again.", + "expired": "The connection request expired. Start the connection again and complete the approval within a few minutes.", + "cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.", + "nocode": "Withings did not return an authorization code. Try again and allow access in the Withings dialog.", + "nocreds": "No Withings API credentials are stored. Save the client ID and client secret first, then connect again.", + "token": "The token exchange with Withings failed. Check the client ID and client secret and try again.", + "generic": "Unknown error while connecting. Please start the connection again." + }, + "integrationsSourcesHint": "When several sources report the same metric, the source priority decides which value counts.", + "integrationsSourcesHintLink": "Open source priority", "withingsSynchronize": "Synchronize", "withingsDisconnect": "Disconnect", "withingsDisconnectTitle": "Disconnect Withings?", @@ -4253,6 +4304,36 @@ "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Issue token", "bugReportTokenPlaceholder": "ghp_...", + "invites": { + "title": "Invites", + "description": "Registration invite links — they work even while registration is closed. The link is shown only once.", + "createLabel": "New invite:", + "createDays": "{days} days", + "createdToast": "Invite created.", + "createError": "Could not create the invite.", + "tokenTitle": "Invite link", + "tokenOnceHint": "Shown only now — copy the link or have the QR code scanned.", + "copyLink": "Copy link", + "copiedToast": "Link copied.", + "copyError": "Copy failed.", + "qrAlt": "QR code for the invite link", + "loadError": "Could not load the invites.", + "listEmpty": "No invites yet.", + "expires": "expires {date}", + "uses": "{used} / {max} used", + "createdMeta": "Created {date} by {by}", + "usedBy": "last used by {username}", + "statusActive": "Active", + "statusExpired": "Expired", + "statusExhausted": "Used up", + "deleteAria": "Delete invite", + "deleteConfirmTitle": "Delete this invite?", + "deleteConfirmBody": "The invite link becomes invalid immediately. Accounts already registered through it remain.", + "deleteConfirmAction": "Delete", + "cancel": "Cancel", + "deletedToast": "Invite deleted.", + "deleteError": "Could not delete the invite." + }, "userManagement": "User Management", "userName": "Name", "userEmail": "Email", @@ -4916,12 +4997,22 @@ "hourAria": "Mood reminder hour", "hourSavedToast": "Reminder time updated." }, + "coachNudge": { + "title": "Proactive Coach", + "description": "Reaches out at most once a week when your data suggests a look at the Coach — for example low medication adherence or an elevated weekly blood-pressure average.", + "toggleAria": "Toggle proactive Coach nudges", + "statusOn": "Active — max. once a week", + "statusOff": "Off", + "enabledToast": "Proactive Coach nudges enabled.", + "disabledToast": "Proactive Coach nudges disabled.", + "saveError": "Could not save. Please try again later." + }, "eventMedicationReminder": "Medication reminder", "eventMedicationReminderDesc": "Sent at each scheduled dose time.", "eventMeasurementAnomaly": "Unusual measurement", "eventMeasurementAnomalyDesc": "Sent when a reading is well outside your usual range.", - "eventComplianceLow": "Low compliance", - "eventComplianceLowDesc": "Sent when your intake rate over the last few days drops below 80 %.", + "eventComplianceLow": "Low adherence", + "eventComplianceLowDesc": "Sent when your adherence over the last few days drops below 80 %.", "eventWithingsSyncFailed": "Withings sync failed", "eventWithingsSyncFailedDesc": "Sent when Withings synchronisation fails multiple attempts in a row.", "eventSystemAlert": "System notice", @@ -5102,6 +5193,14 @@ "dailyTitle": "Log your mood", "dailyBody": "How are you feeling today?" }, + "coachNudges": { + "complianceTitle": "Your Coach has a thought on this", + "complianceBody": "Your intakes were patchy this week. Have a quick word with the Coach — maybe the plan can be simplified.", + "bpTitle": "Blood pressure elevated this week", + "bpBody": "Your blood pressure averaged above your target this week. The Coach can help you put the readings in context.", + "scoreTitle": "Your recovery score is dropping", + "scoreBody": "Your recovery score fell noticeably this week. The Coach can help you narrow down possible causes." + }, "clinicianView": { "title": "Shared health record", "period": "Reporting period: {start} – {end}", diff --git a/messages/es.json b/messages/es.json index 173fa8525..6ed2968aa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -192,6 +192,9 @@ "offlineBanner": { "message": "Sin conexión — tus cambios se guardarán cuando vuelvas a estar en línea." }, + "demoBanner": { + "message": "Modo demo — los cambios no se guardan." + }, "nav": { "dashboard": "Panel", "measurements": "Mediciones", @@ -251,6 +254,7 @@ "hasAccount": "¿Ya tienes cuenta?", "loginFailed": "No se ha podido iniciar sesión", "registerFailed": "No se ha podido completar el registro", + "invitedBanner": "Te han invitado: crea tu cuenta aunque el registro esté cerrado.", "createAccount": "Crear cuenta", "newAccount": "Crear cuenta nueva", "backToPasskey": "Volver a passkey", @@ -719,6 +723,8 @@ "compliance7d": "Cumplimiento 7 días", "compliance30d": "Cumplimiento 30 días", "complianceWindow": "Cumplimiento {days} días", + "complianceInfo": "Proporción de dosis registradas respecto a las esperadas. Las pautas semanales se cuentan semana a semana.", + "complianceInfoLabel": "¿Qué significa el cumplimiento?", "complianceDoses": "{count} dosis", "lastIntake": "Última toma:", "nextIntake": "Próxima toma:", @@ -1046,6 +1052,8 @@ "subline": "Revisa todo abajo antes de guardar.", "remindersLabel": "Recordatorios activados", "remindersDescription": "Push, Telegram o ntfy — según tus ajustes.", + "noChannelHint": "Los recordatorios necesitan un canal de notificaciones — ahora mismo no hay ninguno activo.", + "noChannelHintLink": "Configurar notificaciones", "multiScheduleNote": "Este medicamento tiene varios horarios. El editor detallado volverá en una versión posterior.", "short": "Listo" } @@ -1363,6 +1371,18 @@ "body": "Esta toma queda justo fuera de la ventana de {slot}. ¿Asignarla a ese horario o guardarla como entrada fuera de horario?", "keepAdHoc": "Fuera de horario", "pin": "Asignar" + }, + "pin": { + "action": "Asignar al horario de las {time}", + "release": "Quitar asignación", + "pinnedTag": "asignada", + "pinnedToast": "Toma asignada al horario", + "unpinnedToast": "Asignación quitada", + "dueContext": "prevista: {time} ({delta})", + "confirmTitle": "¿Asignar al horario de las {time}?", + "confirmBody": "La toma contará como tomada con retraso para este horario; el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.", + "confirmCancel": "Cancelar", + "confirmAction": "Asignar" } }, "notifications": { @@ -1571,6 +1591,7 @@ "charts": { "loadingLabel": "Cargando la gráfica…", "loadingSlowHint": "Calculando los análisis — puede tardar un momento.", + "adjustTargets": "Ajustar objetivos", "weight": "Peso", "bloodPressure": "Tensión arterial", "pulse": "Pulso", @@ -2303,11 +2324,11 @@ "stairDescentSpeedBody": "Es lo rápido que bajas un tramo de escaleras. Junto con la velocidad de subida refleja el control de piernas y el equilibrio: un ritmo estable es buena señal y uno que baja conviene vigilar.", "breathingDisturbancesTitle": "Alteraciones respiratorias del sueño", "breathingDisturbancesBody": "Tu Apple Watch puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel que Apple marca como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional.", - "readinessBody": "La preparación es una lectura diaria de lo listo que está tu cuerpo para afrontar el día, combinando la frecuencia cardíaca en reposo, la variabilidad de la frecuencia cardíaca, el sueño y la regularidad de tus últimas noches. Una puntuación más alta indica que tu organismo se ve recuperado y con recursos; una más baja es un aviso suave para bajar el ritmo y dejar que tu cuerpo se ponga al día. Tómala como una guía para dosificar el día, no como un veredicto.", - "sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número, sopesando cuánto dormiste, con qué eficiencia, qué tan regular fue tu horario y el equilibrio entre sueño ligero, profundo y REM. Su objetivo es mostrar de un vistazo si la noche realmente te repuso. Fíjate en el patrón a lo largo de las semanas en lugar de perseguir una puntuación perfecta una sola noche.", - "recoveryBody": "La recuperación refleja lo bien que tu cuerpo se ha repuesto y cuánta capacidad tienes hoy para asumir carga, a partir de señales nocturnas como la variabilidad de la frecuencia cardíaca y la frecuencia en reposo. Una puntuación alta es luz verde para exigirte; una más baja sugiere que tu organismo aún se está reconstruyendo. Es una guía sobre cuánto esforzarte, no un límite.", - "strainBody": "El esfuerzo capta cuánta carga cardiovascular y física le has impuesto a tu cuerpo, desde los entrenamientos hasta un día activo de pie. Cuando ya hay suficiente historial, se valora frente a tu propio esfuerzo habitual, de modo que refleja lo que resulta exigente para ti en concreto. Úsalo para equilibrar los días duros con otros más suaves y mantener un entrenamiento sostenible.", - "stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado más calmado y bien regulado; una más alta es una invitación a incorporar recuperación. Es una aproximación descriptiva para detectar tendencias, no una medición clínica." + "readinessBody": "La preparación es una lectura diaria de lo listo que está tu cuerpo para afrontar el día, combinando la frecuencia cardíaca en reposo, la variabilidad de la frecuencia cardíaca, el sueño y la regularidad de tus últimas noches. Una puntuación más alta indica que tu organismo se ve recuperado y con recursos; una más baja es un aviso suave para bajar el ritmo.", + "sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número — duración, eficiencia, regularidad y el equilibrio entre sueño ligero, profundo y REM. Fíjate en el patrón a lo largo de las semanas en lugar de perseguir una puntuación perfecta una sola noche.", + "recoveryBody": "La recuperación refleja lo bien que tu cuerpo se ha repuesto, a partir de señales nocturnas como la variabilidad de la frecuencia cardíaca y la frecuencia en reposo. Una puntuación alta es luz verde para exigirte; una más baja sugiere que tu organismo aún se está reconstruyendo.", + "strainBody": "El esfuerzo capta cuánta carga cardiovascular y física le has impuesto a tu cuerpo, desde los entrenamientos hasta un día activo de pie. Cuando ya hay suficiente historial, se valora frente a tu propio esfuerzo habitual, no frente a un estándar ajeno.", + "stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado calmado y bien regulado; una más alta es una invitación a incorporar recuperación." }, "stats": { "label": "Estadísticas resumidas", @@ -3386,6 +3407,8 @@ "sources": { "title": "Prioridad de fuentes", "description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.", + "integrationsHint": "Conecta o desconecta fuentes de datos en la página de integraciones.", + "integrationsHintLink": "Abrir integraciones", "cardTitle": "Prioridad de fuentes por métrica", "help": "Cuando más de una fuente entrega el mismo valor el mismo día (p. ej. Withings y Apple Health informan ambos 1200 pasos), la fuente superior cuenta como la lectura canónica. Las demás fuentes se conservan para la traza de auditoría.", "resetDefaults": "Restablecer valores predeterminados", @@ -3655,8 +3678,8 @@ "codexDisconnected": "Conexión con ChatGPT eliminada.", "codexConnectionFailed": "Falló la conexión con ChatGPT. Inténtalo de nuevo.", "rawData": "Enviar datos en bruto", - "rawDataOnDescription": "Se envían métricas agregadas más puntos en bruto anonimizados de los últimos 30 días. Esto incluye mediciones por métrica (por ejemplo, peso, presión arterial, pulso) con contexto temporal para que el proveedor pueda detectar patrones, valores atípicos y correlaciones de forma más fiable. No se transmiten el nombre, el correo electrónico ni los identificadores directos de la cuenta.", - "rawDataOffDescription": "Solo se envían métricas resumidas (por ejemplo, promedios, tendencias, mínimo/máximo). Sin puntos individuales ni contexto temporal exacto. Esto preserva mejor la privacidad, pero el proveedor puede ser menos preciso con los patrones y las fluctuaciones a corto plazo.", + "rawDataOnDescription": "Se envían métricas agregadas más puntos individuales anonimizados de los últimos 30 días con contexto temporal — nunca el nombre, el correo electrónico ni los identificadores directos de la cuenta.", + "rawDataOffDescription": "Solo se envían métricas resumidas (por ejemplo, promedios, tendencias, mínimo/máximo) — sin puntos individuales ni contexto temporal exacto.", "rawDataWarning": "Modo en bruto activado: el proveedor recibe puntos anonimizados adicionales de los últimos 30 días para un análisis más preciso.", "regenerateInsights": "Regenerar informes", "regenerateSuccess": "Informes regenerados correctamente", @@ -3788,6 +3811,19 @@ "savedHidden": "Coach oculto.", "savedShown": "Coach visible.", "saveError": "No se pudo guardar el cambio. Inténtalo de nuevo." + }, + "aboutMe": { + "title": "Sobre mí", + "description": "Contexto personal que el Coach y el informe diario leen junto con tus datos.", + "placeholder": "P. ej. trabajo por turnos, dieta vegetariana, lesión de rodilla, entrenamiento para una media maratón…", + "charCount": "{used} / {max} caracteres", + "clear": "Vaciar", + "save": "Guardar", + "savedToast": "Nota «Sobre mí» guardada.", + "clearedToast": "Nota «Sobre mí» eliminada.", + "saveError": "No se pudo guardar. Inténtalo de nuevo.", + "loadError": "No se pudo cargar la nota.", + "hint": "Se envía al Coach y al informe diario como contexto adicional. Déjalo vacío para no enviar nada." } }, "dashboard": { @@ -3817,6 +3853,21 @@ "withingsSyncResult": "{count} mediciones sincronizadas", "withingsFullSyncResult": "{count} mediciones sincronizadas por completo", "withingsSyncFailed": "Falló la sincronización", + "withingsOauthConnected": "Withings conectado — la primera sincronización comienza en segundo plano.", + "withingsOauthFailed": "Error al conectar con Withings", + "withingsOauthError": { + "csrf1": "La comprobación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.", + "replay": "Este enlace de confirmación ya se ha utilizado. Inicia la conexión de nuevo.", + "state": "La respuesta de Withings no coincide con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.", + "expired": "La solicitud de conexión ha caducado. Inicia la conexión de nuevo y completa la autorización en pocos minutos.", + "cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.", + "nocode": "Withings no devolvió ningún código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Withings.", + "nocreds": "No hay credenciales de la API de Withings guardadas. Guarda primero el Client ID y el Client Secret y vuelve a conectar.", + "token": "El intercambio de tokens con Withings falló. Comprueba el Client ID y el Client Secret e inténtalo de nuevo.", + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + }, + "integrationsSourcesHint": "Si varias fuentes aportan la misma métrica, la prioridad de fuentes decide qué valor cuenta.", + "integrationsSourcesHintLink": "Abrir prioridad de fuentes", "withingsSynchronize": "Sincronizar", "withingsDisconnect": "Desconectar", "withingsDisconnectTitle": "¿Desconectar Withings?", @@ -4253,6 +4304,36 @@ "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Token de issues", "bugReportTokenPlaceholder": "ghp_...", + "invites": { + "title": "Invitaciones", + "description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado. El enlace se muestra solo una vez.", + "createLabel": "Nueva invitación:", + "createDays": "{days} días", + "createdToast": "Invitación creada.", + "createError": "No se pudo crear la invitación.", + "tokenTitle": "Enlace de invitación", + "tokenOnceHint": "Se muestra solo ahora: copia el enlace o deja que escaneen el código QR.", + "copyLink": "Copiar enlace", + "copiedToast": "Enlace copiado.", + "copyError": "No se pudo copiar.", + "qrAlt": "Código QR del enlace de invitación", + "loadError": "No se pudieron cargar las invitaciones.", + "listEmpty": "Todavía no hay invitaciones.", + "expires": "caduca el {date}", + "uses": "{used} / {max} usados", + "createdMeta": "Creada el {date} por {by}", + "usedBy": "usada por última vez por {username}", + "statusActive": "Activa", + "statusExpired": "Caducada", + "statusExhausted": "Agotada", + "deleteAria": "Eliminar invitación", + "deleteConfirmTitle": "¿Eliminar esta invitación?", + "deleteConfirmBody": "El enlace de invitación dejará de ser válido de inmediato. Las cuentas ya registradas con él se mantienen.", + "deleteConfirmAction": "Eliminar", + "cancel": "Cancelar", + "deletedToast": "Invitación eliminada.", + "deleteError": "No se pudo eliminar la invitación." + }, "userManagement": "Gestión de usuarios", "userName": "Nombre", "userEmail": "Correo electrónico", @@ -4916,6 +4997,16 @@ "hourAria": "Hora del recordatorio de ánimo", "hourSavedToast": "Hora del recordatorio actualizada." }, + "coachNudge": { + "title": "Coach proactivo", + "description": "Se pone en contacto como máximo una vez por semana cuando tus datos sugieren consultar al Coach, por ejemplo baja adherencia a la medicación o una media semanal de tensión elevada.", + "toggleAria": "Activar o desactivar los avisos proactivos del Coach", + "statusOn": "Activo — máx. 1 vez por semana", + "statusOff": "Desactivado", + "enabledToast": "Avisos proactivos del Coach activados.", + "disabledToast": "Avisos proactivos del Coach desactivados.", + "saveError": "No se pudo guardar. Inténtalo de nuevo más tarde." + }, "eventMedicationReminder": "Recordatorio de medicación", "eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.", "eventMeasurementAnomaly": "Medición inusual", @@ -5102,6 +5193,14 @@ "dailyTitle": "Registrar el ánimo", "dailyBody": "¿Cómo te sientes hoy?" }, + "coachNudges": { + "complianceTitle": "Tu Coach tiene algo que comentarte", + "complianceBody": "Tus tomas han sido irregulares esta semana. Habla un momento con el Coach: quizá se pueda simplificar el plan.", + "bpTitle": "Tensión elevada esta semana", + "bpBody": "Tu tensión media de esta semana superó tu objetivo. El Coach puede ayudarte a poner los valores en contexto.", + "scoreTitle": "Tu puntuación de recuperación baja", + "scoreBody": "Tu puntuación de recuperación ha caído notablemente esta semana. El Coach puede ayudarte a acotar las posibles causas." + }, "clinicianView": { "title": "Registro de salud compartido", "period": "Período del informe: {start} – {end}", diff --git a/messages/fr.json b/messages/fr.json index 362d7cd9d..9554d507d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -192,6 +192,9 @@ "offlineBanner": { "message": "Pas de connexion — tes modifications seront enregistrées dès que tu seras de nouveau en ligne." }, + "demoBanner": { + "message": "Mode démo — les modifications ne sont pas enregistrées." + }, "nav": { "dashboard": "Tableau de bord", "measurements": "Mesures", @@ -251,6 +254,7 @@ "hasAccount": "Déjà un compte ?", "loginFailed": "Échec de la connexion", "registerFailed": "Échec de l’inscription", + "invitedBanner": "Vous avez été invité — créez votre compte même si les inscriptions sont fermées.", "createAccount": "Créer un compte", "newAccount": "Créer un nouveau compte", "backToPasskey": "Revenir à la passkey", @@ -719,6 +723,8 @@ "compliance7d": "Observance 7 jours", "compliance30d": "Observance 30 jours", "complianceWindow": "Observance {days} jours", + "complianceInfo": "Part des doses attendues que vous avez enregistrées. Les plans hebdomadaires comptent semaine par semaine.", + "complianceInfoLabel": "Que signifie l'observance ?", "complianceDoses": "{count} doses", "lastIntake": "Dernière prise :", "nextIntake": "Prochaine prise :", @@ -1046,6 +1052,8 @@ "subline": "Vérifiez tout ci-dessous avant d’enregistrer.", "remindersLabel": "Rappels activés", "remindersDescription": "Push, Telegram ou ntfy — selon vos réglages.", + "noChannelHint": "Les rappels nécessitent un canal de notification — aucun n'est actif pour le moment.", + "noChannelHintLink": "Configurer les notifications", "multiScheduleNote": "Ce médicament a plusieurs horaires. L’éditeur détaillé reviendra dans une version ultérieure.", "short": "Fini" } @@ -1363,6 +1371,18 @@ "body": "Cette prise se situe juste en dehors de la fenêtre de {slot}. La rattacher à ce créneau ou la conserver comme entrée hors planning ?", "keepAdHoc": "Hors planning", "pin": "Rattacher" + }, + "pin": { + "action": "Rattacher au créneau de {time}", + "release": "Détacher du créneau", + "pinnedTag": "rattachée", + "pinnedToast": "Prise rattachée au créneau", + "unpinnedToast": "Prise détachée du créneau", + "dueContext": "prévue à {time} ({delta})", + "confirmTitle": "Rattacher au créneau de {time} ?", + "confirmBody": "La prise comptera comme prise en retard pour ce créneau – le taux n'est pas embelli. Le rattachement peut être retiré à tout moment.", + "confirmCancel": "Annuler", + "confirmAction": "Rattacher" } }, "notifications": { @@ -1571,6 +1591,7 @@ "charts": { "loadingLabel": "Chargement du graphique…", "loadingSlowHint": "Calcul des analyses — cela peut prendre un instant.", + "adjustTargets": "Ajuster les valeurs cibles", "weight": "Poids", "bloodPressure": "Tension artérielle", "pulse": "Pouls", @@ -2303,11 +2324,11 @@ "stairDescentSpeedBody": "C'est la vitesse à laquelle vous descendez une volée de marches. Avec la vitesse de montée, elle reflète le contrôle des jambes et l'équilibre : un rythme stable est bon signe, un rythme en baisse mérite attention.", "breathingDisturbancesTitle": "Troubles respiratoires du sommeil", "breathingDisturbancesBody": "Votre Apple Watch peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau qu'Apple signale comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel.", - "readinessBody": "La forme du jour est une lecture quotidienne unique de la disposition de votre corps à affronter la journée, mêlant fréquence cardiaque au repos, variabilité de la fréquence cardiaque, sommeil et régularité de vos dernières nuits. Un score élevé signifie que votre organisme paraît récupéré et disponible ; un score plus bas est une invitation douce à lever le pied et à laisser votre corps rattraper. Voyez-le comme un repère pour doser la journée, pas comme un verdict.", - "sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre, en pesant la durée de votre sommeil, son efficacité, la régularité de vos horaires et l'équilibre entre sommeil léger, profond et paradoxal. Il vise à montrer d'un coup d'œil si la nuit vous a vraiment restauré. Observez la tendance sur plusieurs semaines plutôt que de courir après un score parfait sur une seule nuit.", - "recoveryBody": "La récupération reflète la qualité du rétablissement de votre corps et la capacité dont vous disposez aujourd'hui pour encaisser de la charge, à partir de signaux nocturnes comme la variabilité de la fréquence cardiaque et la fréquence au repos. Un score élevé est un feu vert pour pousser ; un score plus bas suggère que votre organisme se reconstruit encore. C'est un repère sur l'intensité à adopter, pas une limite.", - "strainBody": "La charge mesure l'effort cardiovasculaire et physique que vous avez imposé à votre corps, des entraînements à une journée active debout. Dès qu'il y a assez d'historique, elle est évaluée par rapport à votre propre effort habituel et reflète ainsi ce qui est exigeant pour vous en particulier. Servez-vous-en pour équilibrer les journées intenses et les plus douces, et garder un entraînement tenable.", - "stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état plus calme et bien régulé ; un score plus élevé invite à prévoir de la récupération. C'est une approximation descriptive pour repérer des tendances, pas une mesure clinique." + "readinessBody": "La forme du jour est une lecture quotidienne unique de la disposition de votre corps à affronter la journée, mêlant fréquence cardiaque au repos, variabilité de la fréquence cardiaque, sommeil et régularité de vos dernières nuits. Un score élevé signifie que votre organisme paraît récupéré et disponible ; un score plus bas est une invitation douce à lever le pied.", + "sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre — durée, efficacité, régularité des horaires et équilibre entre sommeil léger, profond et paradoxal. Observez la tendance sur plusieurs semaines plutôt que de courir après un score parfait sur une seule nuit.", + "recoveryBody": "La récupération reflète la qualité du rétablissement de votre corps, à partir de signaux nocturnes comme la variabilité de la fréquence cardiaque et la fréquence au repos. Un score élevé est un feu vert pour pousser ; un score plus bas suggère que votre organisme se reconstruit encore.", + "strainBody": "La charge mesure l'effort cardiovasculaire et physique que vous avez imposé à votre corps, des entraînements à une journée active debout. Dès qu'il y a assez d'historique, elle est évaluée par rapport à votre propre effort habituel, pas à un standard extérieur.", + "stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état calme et bien régulé ; un score plus élevé invite à prévoir de la récupération." }, "stats": { "label": "Statistiques récapitulatives", @@ -3386,6 +3407,8 @@ "sources": { "title": "Priorité des sources", "description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.", + "integrationsHint": "Connectez ou déconnectez des sources de données sur la page Intégrations.", + "integrationsHintLink": "Ouvrir les intégrations", "cardTitle": "Priorité des sources par métrique", "help": "Lorsque plusieurs sources fournissent la même valeur le même jour (p. ex. Withings et Apple Health indiquent tous deux 1 200 pas), la source en tête compte comme la lecture canonique. Les autres sources restent enregistrées pour la piste d’audit.", "resetDefaults": "Rétablir les valeurs par défaut", @@ -3655,8 +3678,8 @@ "codexDisconnected": "Connexion à ChatGPT supprimée.", "codexConnectionFailed": "Échec de la connexion à ChatGPT. Veuillez réessayer.", "rawData": "Envoyer les données brutes", - "rawDataOnDescription": "Des métriques agrégées sont envoyées, ainsi que des points bruts anonymisés des 30 derniers jours. Cela inclut des mesures par métrique (par exemple poids, tension, pouls) avec un contexte temporel, afin que le fournisseur puisse détecter plus fiablement les tendances, les valeurs aberrantes et les corrélations. Le nom, l’e-mail et les identifiants de compte directs ne sont pas transmis.", - "rawDataOffDescription": "Seules des métriques résumées sont envoyées (par exemple moyennes, tendances, minimum/maximum). Aucun point individuel ni contexte temporel exact. Cela préserve mieux la vie privée, mais le fournisseur peut être moins précis pour les tendances et fluctuations à court terme.", + "rawDataOnDescription": "Des métriques agrégées et des points individuels anonymisés des 30 derniers jours avec contexte temporel sont envoyés — jamais le nom, l’e-mail ni les identifiants de compte directs.", + "rawDataOffDescription": "Seules des métriques résumées sont envoyées (par exemple moyennes, tendances, minimum/maximum) — aucun point individuel ni contexte temporel exact.", "rawDataWarning": "Mode brut activé : le fournisseur reçoit des points anonymisés supplémentaires des 30 derniers jours pour une analyse plus précise.", "regenerateInsights": "Régénérer les rapports", "regenerateSuccess": "Rapports régénérés", @@ -3788,6 +3811,19 @@ "savedHidden": "Coach masqué.", "savedShown": "Coach visible.", "saveError": "Impossible d'enregistrer la modification. Veuillez réessayer." + }, + "aboutMe": { + "title": "À propos de moi", + "description": "Contexte personnel que le Coach et le briefing quotidien lisent en plus de vos données.", + "placeholder": "Ex. travail posté, régime végétarien, blessure au genou, entraînement pour un semi-marathon…", + "charCount": "{used} / {max} caractères", + "clear": "Vider", + "save": "Enregistrer", + "savedToast": "Note « À propos de moi » enregistrée.", + "clearedToast": "Note « À propos de moi » supprimée.", + "saveError": "Impossible d'enregistrer. Veuillez réessayer.", + "loadError": "Impossible de charger la note.", + "hint": "Transmis au Coach et au briefing quotidien comme contexte supplémentaire. Laissez vide pour ne rien envoyer." } }, "dashboard": { @@ -3817,6 +3853,21 @@ "withingsSyncResult": "{count} mesures synchronisées", "withingsFullSyncResult": "{count} mesures entièrement synchronisées", "withingsSyncFailed": "Échec de la synchronisation", + "withingsOauthConnected": "Withings connecté — la première synchronisation démarre en arrière-plan.", + "withingsOauthFailed": "Échec de la connexion Withings", + "withingsOauthError": { + "csrf1": "Le contrôle de sécurité a échoué car le cookie du navigateur manquait. Relancez la connexion dans le même navigateur.", + "replay": "Ce lien de confirmation a déjà été utilisé. Relancez la connexion.", + "state": "La réponse de Withings ne correspond à aucune demande de connexion en cours. Relancez la connexion.", + "expired": "La demande de connexion a expiré. Relancez la connexion et terminez l'autorisation en quelques minutes.", + "cross_user": "La réponse appartient à une demande d'un autre compte. Relancez la connexion depuis votre propre compte.", + "nocode": "Withings n'a renvoyé aucun code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Withings.", + "nocreds": "Aucun identifiant d'API Withings n'est enregistré. Enregistrez d'abord le Client ID et le Client Secret, puis reconnectez.", + "token": "L'échange de jetons avec Withings a échoué. Vérifiez le Client ID et le Client Secret, puis réessayez.", + "generic": "Erreur inconnue lors de la connexion. Relancez la connexion." + }, + "integrationsSourcesHint": "Quand plusieurs sources fournissent la même métrique, la priorité des sources décide quelle valeur compte.", + "integrationsSourcesHintLink": "Ouvrir la priorité des sources", "withingsSynchronize": "Synchroniser", "withingsDisconnect": "Déconnecter", "withingsDisconnectTitle": "Déconnecter Withings ?", @@ -4253,6 +4304,36 @@ "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Jeton d'issue", "bugReportTokenPlaceholder": "ghp_...", + "invites": { + "title": "Invitations", + "description": "Liens d'invitation pour l'inscription — ils fonctionnent même lorsque les inscriptions sont fermées. Le lien n'est affiché qu'une seule fois.", + "createLabel": "Nouvelle invitation :", + "createDays": "{days} jours", + "createdToast": "Invitation créée.", + "createError": "Impossible de créer l'invitation.", + "tokenTitle": "Lien d'invitation", + "tokenOnceHint": "Affiché uniquement maintenant — copiez le lien ou faites scanner le code QR.", + "copyLink": "Copier le lien", + "copiedToast": "Lien copié.", + "copyError": "Échec de la copie.", + "qrAlt": "Code QR du lien d'invitation", + "loadError": "Impossible de charger les invitations.", + "listEmpty": "Aucune invitation pour l'instant.", + "expires": "expire le {date}", + "uses": "{used} / {max} utilisés", + "createdMeta": "Créée le {date} par {by}", + "usedBy": "utilisée en dernier par {username}", + "statusActive": "Active", + "statusExpired": "Expirée", + "statusExhausted": "Épuisée", + "deleteAria": "Supprimer l'invitation", + "deleteConfirmTitle": "Supprimer cette invitation ?", + "deleteConfirmBody": "Le lien d'invitation devient immédiatement invalide. Les comptes déjà créés avec ce lien sont conservés.", + "deleteConfirmAction": "Supprimer", + "cancel": "Annuler", + "deletedToast": "Invitation supprimée.", + "deleteError": "Impossible de supprimer l'invitation." + }, "userManagement": "Gestion des utilisateurs", "userName": "Nom", "userEmail": "E-mail", @@ -4916,6 +4997,16 @@ "hourAria": "Heure du rappel d’humeur", "hourSavedToast": "Heure du rappel mise à jour." }, + "coachNudge": { + "title": "Coach proactif", + "description": "Vous contacte au plus une fois par semaine lorsque vos données suggèrent de consulter le Coach — par exemple une faible observance ou une moyenne hebdomadaire de tension élevée.", + "toggleAria": "Activer ou désactiver les rappels proactifs du Coach", + "statusOn": "Actif — max. 1 fois par semaine", + "statusOff": "Désactivé", + "enabledToast": "Rappels proactifs du Coach activés.", + "disabledToast": "Rappels proactifs du Coach désactivés.", + "saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard." + }, "eventMedicationReminder": "Rappel de médicament", "eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.", "eventMeasurementAnomaly": "Mesure inhabituelle", @@ -5102,6 +5193,14 @@ "dailyTitle": "Enregistrer votre humeur", "dailyBody": "Comment vous sentez-vous aujourd’hui ?" }, + "coachNudges": { + "complianceTitle": "Votre Coach a une remarque à ce sujet", + "complianceBody": "Vos prises ont été irrégulières cette semaine. Échangez avec le Coach — le plan peut peut-être être simplifié.", + "bpTitle": "Tension élevée cette semaine", + "bpBody": "Votre tension moyenne de la semaine a dépassé votre objectif. Le Coach peut vous aider à mettre les valeurs en perspective.", + "scoreTitle": "Votre score de récupération baisse", + "scoreBody": "Votre score de récupération a nettement chuté cette semaine. Le Coach peut vous aider à cerner les causes possibles." + }, "clinicianView": { "title": "Dossier de santé partagé", "period": "Période couverte : {start} – {end}", diff --git a/messages/it.json b/messages/it.json index eb842daf4..27da1b873 100644 --- a/messages/it.json +++ b/messages/it.json @@ -192,6 +192,9 @@ "offlineBanner": { "message": "Nessuna connessione — le tue modifiche verranno salvate appena torni online." }, + "demoBanner": { + "message": "Modalità demo — le modifiche non vengono salvate." + }, "nav": { "dashboard": "Dashboard", "measurements": "Misurazioni", @@ -251,6 +254,7 @@ "hasAccount": "Hai già un account?", "loginFailed": "Accesso non riuscito", "registerFailed": "Registrazione non riuscita", + "invitedBanner": "Sei stato invitato — crea il tuo account anche se la registrazione è chiusa.", "createAccount": "Crea account", "newAccount": "Crea un nuovo account", "backToPasskey": "Torna a passkey", @@ -719,6 +723,8 @@ "compliance7d": "Aderenza 7 giorni", "compliance30d": "Aderenza 30 giorni", "complianceWindow": "Aderenza {days} giorni", + "complianceInfo": "Quota delle dosi attese che hai registrato. I piani settimanali contano settimana per settimana.", + "complianceInfoLabel": "Cosa significa aderenza?", "complianceDoses": "{count} dosi", "lastIntake": "Ultima assunzione:", "nextIntake": "Prossima assunzione:", @@ -1046,6 +1052,8 @@ "subline": "Controlla tutto qui sotto prima di salvare.", "remindersLabel": "Promemoria attivi", "remindersDescription": "Push, Telegram o ntfy — in base alle tue impostazioni.", + "noChannelHint": "I promemoria richiedono un canale di notifica — al momento nessuno è attivo.", + "noChannelHintLink": "Configura le notifiche", "multiScheduleNote": "Questo farmaco ha più orari. L’editor dettagliato tornerà in una versione successiva.", "short": "Fatto" } @@ -1363,6 +1371,18 @@ "body": "Questa assunzione cade appena fuori dalla finestra di {slot}. Attribuirla a quello slot o tenerla come voce fuori programma?", "keepAdHoc": "Fuori programma", "pin": "Attribuisci" + }, + "pin": { + "action": "Attribuisci allo slot delle {time}", + "release": "Rimuovi attribuzione", + "pinnedTag": "attribuita", + "pinnedToast": "Assunzione attribuita allo slot", + "unpinnedToast": "Attribuzione rimossa", + "dueContext": "prevista alle {time} ({delta})", + "confirmTitle": "Attribuire allo slot delle {time}?", + "confirmBody": "L'assunzione conterà come in ritardo per questo slot – la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.", + "confirmCancel": "Annulla", + "confirmAction": "Attribuisci" } }, "notifications": { @@ -1571,6 +1591,7 @@ "charts": { "loadingLabel": "Caricamento del grafico…", "loadingSlowHint": "Calcolo delle analisi — può richiedere un momento.", + "adjustTargets": "Regola i valori target", "weight": "Peso", "bloodPressure": "Pressione arteriosa", "pulse": "Pulsazioni", @@ -2303,11 +2324,11 @@ "stairDescentSpeedBody": "È la velocità con cui scendi una rampa di scale. Insieme alla velocità in salita riflette il controllo delle gambe e l'equilibrio: un ritmo stabile è un buon segno, mentre uno in calo va tenuto d'occhio.", "breathingDisturbancesTitle": "Disturbi respiratori nel sonno", "breathingDisturbancesBody": "Il tuo Apple Watch può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello che Apple segnala come elevato, soprattutto su molte notti, andrebbe segnalato a un medico.", - "readinessBody": "La prontezza è una lettura giornaliera di quanto il tuo corpo sia pronto ad affrontare la giornata, combinando frequenza cardiaca a riposo, variabilità della frequenza cardiaca, sonno e regolarità delle ultime notti. Un punteggio più alto indica che il tuo organismo appare recuperato e pieno di risorse; uno più basso è un invito gentile a rallentare e lasciare che il corpo recuperi. Consideralo una guida per dosare la giornata, non un verdetto.", - "sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero, valutando quanto hai dormito, con quale efficienza, quanto regolari sono stati i tuoi orari e l'equilibrio tra sonno leggero, profondo e REM. Serve a mostrare a colpo d'occhio se la notte ti ha davvero ristorato. Osserva l'andamento nell'arco delle settimane invece di rincorrere un punteggio perfetto in una singola notte.", - "recoveryBody": "Il recupero riflette quanto bene il tuo corpo si è ripreso e quanta capacità hai oggi per sostenere il carico, basandosi su segnali notturni come la variabilità della frequenza cardiaca e la frequenza a riposo. Un punteggio alto è il via libera per spingere; uno più basso suggerisce che il tuo organismo si sta ancora ricostruendo. È una guida su quanto impegnarti, non un limite.", - "strainBody": "Lo sforzo misura quanto carico cardiovascolare e fisico hai imposto al tuo corpo, dagli allenamenti a una giornata attiva in piedi. Quando c'è abbastanza storico, viene valutato rispetto al tuo sforzo abituale e riflette così ciò che è impegnativo per te in particolare. Usalo per bilanciare le giornate intense con altre più leggere e mantenere un allenamento sostenibile.", - "stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato più calmo e ben regolato; uno più alto è un invito a inserire del recupero. È un'approssimazione descrittiva per notare le tendenze, non una misurazione clinica." + "readinessBody": "La prontezza è una lettura giornaliera di quanto il tuo corpo sia pronto ad affrontare la giornata, combinando frequenza cardiaca a riposo, variabilità della frequenza cardiaca, sonno e regolarità delle ultime notti. Un punteggio più alto indica che il tuo organismo appare recuperato e pieno di risorse; uno più basso è un invito gentile a rallentare.", + "sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero — durata, efficienza, regolarità degli orari ed equilibrio tra sonno leggero, profondo e REM. Osserva l'andamento nell'arco delle settimane invece di rincorrere un punteggio perfetto in una singola notte.", + "recoveryBody": "Il recupero riflette quanto bene il tuo corpo si è ripreso, basandosi su segnali notturni come la variabilità della frequenza cardiaca e la frequenza a riposo. Un punteggio alto è il via libera per spingere; uno più basso suggerisce che il tuo organismo si sta ancora ricostruendo.", + "strainBody": "Lo sforzo misura quanto carico cardiovascolare e fisico hai imposto al tuo corpo, dagli allenamenti a una giornata attiva in piedi. Quando c'è abbastanza storico, viene valutato rispetto al tuo sforzo abituale, non rispetto a uno standard esterno.", + "stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato calmo e ben regolato; uno più alto è un invito a inserire del recupero." }, "stats": { "label": "Statistiche riepilogative", @@ -3386,6 +3407,8 @@ "sources": { "title": "Priorità delle fonti", "description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.", + "integrationsHint": "Collega o scollega le fonti dati nella pagina Integrazioni.", + "integrationsHintLink": "Apri le integrazioni", "cardTitle": "Priorità delle fonti per metrica", "help": "Quando più fonti forniscono lo stesso valore nello stesso giorno (ad es. Withings e Apple Health riportano entrambe 1.200 passi), la fonte in cima conta come lettura canonica. Le altre fonti restano memorizzate per la traccia di controllo.", "resetDefaults": "Ripristina i valori predefiniti", @@ -3655,8 +3678,8 @@ "codexDisconnected": "Connessione a ChatGPT rimossa.", "codexConnectionFailed": "Connessione a ChatGPT non riuscita. Riprova.", "rawData": "Invia dati grezzi", - "rawDataOnDescription": "Vengono inviate metriche aggregate più punti grezzi anonimizzati degli ultimi 30 giorni. Ciò include misurazioni per singola metrica (ad esempio peso, pressione, polso) con contesto temporale, in modo che il provider possa rilevare in modo più affidabile schemi, valori anomali e correlazioni. Nome, e-mail e identificatori diretti dell’account non vengono trasmessi.", - "rawDataOffDescription": "Vengono inviate solo metriche riassuntive (ad esempio medie, tendenze, minimo/massimo). Nessun punto individuale e nessun contesto temporale preciso. Questo tutela meglio la privacy, ma il provider può essere meno preciso su schemi e oscillazioni a breve termine.", + "rawDataOnDescription": "Vengono inviate metriche aggregate più punti individuali anonimizzati degli ultimi 30 giorni con contesto temporale — mai nome, e-mail o identificatori diretti dell’account.", + "rawDataOffDescription": "Vengono inviate solo metriche riassuntive (ad esempio medie, tendenze, minimo/massimo) — nessun punto individuale e nessun contesto temporale preciso.", "rawDataWarning": "Modalità grezza attiva: il provider riceve ulteriori punti anonimizzati degli ultimi 30 giorni per un’analisi più precisa.", "regenerateInsights": "Rigenera i report", "regenerateSuccess": "Report rigenerati correttamente", @@ -3788,6 +3811,19 @@ "savedHidden": "Coach nascosto.", "savedShown": "Coach visibile.", "saveError": "Impossibile salvare la modifica. Riprova." + }, + "aboutMe": { + "title": "Su di me", + "description": "Contesto personale che il Coach e il briefing giornaliero leggono insieme ai tuoi dati.", + "placeholder": "Es. lavoro a turni, dieta vegetariana, infortunio al ginocchio, allenamento per una mezza maratona…", + "charCount": "{used} / {max} caratteri", + "clear": "Svuota", + "save": "Salva", + "savedToast": "Nota «Su di me» salvata.", + "clearedToast": "Nota «Su di me» eliminata.", + "saveError": "Impossibile salvare. Riprova.", + "loadError": "Impossibile caricare la nota.", + "hint": "Inviato al Coach e al briefing giornaliero come contesto aggiuntivo. Lascia vuoto per non inviare nulla." } }, "dashboard": { @@ -3817,6 +3853,21 @@ "withingsSyncResult": "{count} misurazioni sincronizzate", "withingsFullSyncResult": "{count} misurazioni sincronizzate completamente", "withingsSyncFailed": "Sincronizzazione non riuscita", + "withingsOauthConnected": "Withings collegato — la prima sincronizzazione parte in background.", + "withingsOauthFailed": "Collegamento a Withings non riuscito", + "withingsOauthError": { + "csrf1": "Il controllo di sicurezza non è riuscito perché mancava il cookie del browser. Avvia di nuovo il collegamento nello stesso browser.", + "replay": "Questo link di conferma è già stato utilizzato. Avvia di nuovo il collegamento.", + "state": "La risposta di Withings non corrisponde a nessuna richiesta di collegamento aperta. Avvia di nuovo il collegamento.", + "expired": "La richiesta di collegamento è scaduta. Avvia di nuovo il collegamento e completa l'autorizzazione entro pochi minuti.", + "cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia il collegamento dal tuo account.", + "nocode": "Withings non ha restituito alcun codice di autorizzazione. Riprova e consenti l'accesso nella finestra di Withings.", + "nocreds": "Non sono salvate credenziali API di Withings. Salva prima Client ID e Client Secret, poi ricollega.", + "token": "Lo scambio di token con Withings non è riuscito. Controlla Client ID e Client Secret e riprova.", + "generic": "Errore sconosciuto durante il collegamento. Avvia di nuovo il collegamento." + }, + "integrationsSourcesHint": "Se più fonti forniscono la stessa metrica, la priorità delle fonti decide quale valore conta.", + "integrationsSourcesHintLink": "Apri la priorità delle fonti", "withingsSynchronize": "Sincronizza", "withingsDisconnect": "Disconnetti", "withingsDisconnectTitle": "Disconnettere Withings?", @@ -4253,6 +4304,36 @@ "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Token delle issue", "bugReportTokenPlaceholder": "ghp_...", + "invites": { + "title": "Inviti", + "description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa. Il link viene mostrato una sola volta.", + "createLabel": "Nuovo invito:", + "createDays": "{days} giorni", + "createdToast": "Invito creato.", + "createError": "Impossibile creare l'invito.", + "tokenTitle": "Link di invito", + "tokenOnceHint": "Mostrato solo adesso: copia il link o fai scansionare il codice QR.", + "copyLink": "Copia link", + "copiedToast": "Link copiato.", + "copyError": "Copia non riuscita.", + "qrAlt": "Codice QR del link di invito", + "loadError": "Impossibile caricare gli inviti.", + "listEmpty": "Ancora nessun invito.", + "expires": "scade il {date}", + "uses": "{used} / {max} usati", + "createdMeta": "Creato il {date} da {by}", + "usedBy": "usato per ultimo da {username}", + "statusActive": "Attivo", + "statusExpired": "Scaduto", + "statusExhausted": "Esaurito", + "deleteAria": "Elimina invito", + "deleteConfirmTitle": "Eliminare questo invito?", + "deleteConfirmBody": "Il link di invito diventa subito non valido. Gli account già registrati con questo link restano.", + "deleteConfirmAction": "Elimina", + "cancel": "Annulla", + "deletedToast": "Invito eliminato.", + "deleteError": "Impossibile eliminare l'invito." + }, "userManagement": "Gestione utenti", "userName": "Nome", "userEmail": "E-mail", @@ -4916,6 +4997,16 @@ "hourAria": "Ora del promemoria dell’umore", "hourSavedToast": "Ora del promemoria aggiornata." }, + "coachNudge": { + "title": "Coach proattivo", + "description": "Si fa sentire al massimo una volta a settimana quando i tuoi dati suggeriscono di consultare il Coach — ad esempio bassa aderenza terapeutica o una media settimanale della pressione elevata.", + "toggleAria": "Attiva o disattiva gli avvisi proattivi del Coach", + "statusOn": "Attivo — max 1 volta a settimana", + "statusOff": "Disattivato", + "enabledToast": "Avvisi proattivi del Coach attivati.", + "disabledToast": "Avvisi proattivi del Coach disattivati.", + "saveError": "Impossibile salvare. Riprova più tardi." + }, "eventMedicationReminder": "Promemoria farmaco", "eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.", "eventMeasurementAnomaly": "Misurazione insolita", @@ -5102,6 +5193,14 @@ "dailyTitle": "Registra il tuo umore", "dailyBody": "Come ti senti oggi?" }, + "coachNudges": { + "complianceTitle": "Il tuo Coach ha un'osservazione", + "complianceBody": "Le tue assunzioni sono state discontinue questa settimana. Parla un attimo con il Coach: forse il piano si può semplificare.", + "bpTitle": "Pressione elevata questa settimana", + "bpBody": "La tua pressione media di questa settimana ha superato il tuo obiettivo. Il Coach può aiutarti a contestualizzare i valori.", + "scoreTitle": "Il tuo punteggio di recupero sta calando", + "scoreBody": "Il tuo punteggio di recupero è calato sensibilmente questa settimana. Il Coach può aiutarti a circoscrivere le possibili cause." + }, "clinicianView": { "title": "Cartella sanitaria condivisa", "period": "Periodo del report: {start} – {end}", diff --git a/messages/pl.json b/messages/pl.json index 65f77219c..e10ed0e5d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -192,6 +192,9 @@ "offlineBanner": { "message": "Brak połączenia — twoje zmiany zostaną zapisane, gdy ponownie pojawisz się online." }, + "demoBanner": { + "message": "Tryb demo — zmiany nie są zapisywane." + }, "nav": { "dashboard": "Panel", "measurements": "Pomiary", @@ -251,6 +254,7 @@ "hasAccount": "Masz już konto?", "loginFailed": "Logowanie nie powiodło się", "registerFailed": "Rejestracja nie powiodła się", + "invitedBanner": "Otrzymano zaproszenie — utwórz konto, nawet jeśli rejestracja jest zamknięta.", "createAccount": "Utwórz konto", "newAccount": "Utwórz nowe konto", "backToPasskey": "Wróć do passkey", @@ -719,6 +723,8 @@ "compliance7d": "Przestrzeganie 7 dni", "compliance30d": "Przestrzeganie 30 dni", "complianceWindow": "Przestrzeganie {days} dni", + "complianceInfo": "Udział zarejestrowanych dawek względem oczekiwanych. Plany tygodniowe liczone są tydzień po tygodniu.", + "complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?", "complianceDoses": "{count} dawek", "lastIntake": "Ostatnie przyjęcie:", "nextIntake": "Następne przyjęcie:", @@ -1046,6 +1052,8 @@ "subline": "Sprawdź wszystko poniżej przed zapisaniem.", "remindersLabel": "Przypomnienia włączone", "remindersDescription": "Push, Telegram lub ntfy — w zależności od ustawień.", + "noChannelHint": "Przypomnienia wymagają kanału powiadomień — obecnie żaden nie jest aktywny.", + "noChannelHintLink": "Skonfiguruj powiadomienia", "multiScheduleNote": "Ten lek ma wiele harmonogramów. Szczegółowy edytor wróci w kolejnej wersji.", "short": "Gotowe" } @@ -1363,6 +1371,18 @@ "body": "To przyjęcie wypada tuż poza oknem {slot}. Przypisać je do tego slotu czy zachować jako wpis poza planem?", "keepAdHoc": "Poza planem", "pin": "Przypisz" + }, + "pin": { + "action": "Przypisz do slotu {time}", + "release": "Usuń przypisanie", + "pinnedTag": "przypisana", + "pinnedToast": "Dawka przypisana do slotu", + "unpinnedToast": "Przypisanie usunięte", + "dueContext": "termin: {time} ({delta})", + "confirmTitle": "Przypisać do slotu {time}?", + "confirmBody": "Dawka zostanie policzona jako przyjęta z opóźnieniem dla tego slotu – wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.", + "confirmCancel": "Anuluj", + "confirmAction": "Przypisz" } }, "notifications": { @@ -1571,6 +1591,7 @@ "charts": { "loadingLabel": "Ładowanie wykresu…", "loadingSlowHint": "Obliczanie analiz — to może chwilę potrwać.", + "adjustTargets": "Dostosuj wartości docelowe", "weight": "Waga", "bloodPressure": "Ciśnienie tętnicze", "pulse": "Tętno", @@ -2303,11 +2324,11 @@ "stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę — stałe tempo to dobry znak, a spadające warto obserwować.", "breathingDisturbancesTitle": "Zaburzenia oddychania we śnie", "breathingDisturbancesBody": "Twój Apple Watch potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom, który Apple oznacza jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem.", - "readinessBody": "Gotowość to pojedynczy dzienny odczyt tego, jak przygotowane jest Twoje ciało, by zmierzyć się z dniem — łączy tętno spoczynkowe, zmienność rytmu serca, sen i regularność ostatnich nocy. Wyższy wynik oznacza, że Twój organizm wygląda na wypoczęty i pełen zasobów; niższy to delikatna wskazówka, by zwolnić tempo i dać ciału nadrobić zaległości. Traktuj go jako wskazówkę do rozłożenia sił w ciągu dnia, a nie jako wyrok.", - "sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie, biorąc pod uwagę, jak długo i jak efektywnie spałeś, jak regularne były Twoje pory oraz proporcje snu lekkiego, głębokiego i REM. Ma pokazać na pierwszy rzut oka, czy noc naprawdę Cię zregenerowała. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.", - "recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało i ile masz dziś zapasu, by przyjąć obciążenie — opiera się na nocnych sygnałach, takich jak zmienność rytmu serca i tętno spoczynkowe. Wysoki wynik to zielone światło, by przyspieszyć; niższy sugeruje, że organizm wciąż się regeneruje. To wskazówka, jak mocno się angażować, a nie sztywna granica.", - "strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało — od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, więc odzwierciedla to, co jest wymagające właśnie dla Ciebie. Wykorzystaj je, by równoważyć ciężkie dni lżejszymi i utrzymać trening na zrównoważonym poziomie.", - "stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojniejszy, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację. To opisowe przybliżenie pozwalające dostrzegać trendy, a nie pomiar kliniczny." + "readinessBody": "Gotowość to pojedynczy dzienny odczyt tego, jak przygotowane jest Twoje ciało, by zmierzyć się z dniem — łączy tętno spoczynkowe, zmienność rytmu serca, sen i regularność ostatnich nocy. Wyższy wynik oznacza, że Twój organizm wygląda na wypoczęty i pełen zasobów; niższy to delikatna wskazówka, by zwolnić tempo.", + "sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie — długość, efektywność, regularność pór oraz proporcje snu lekkiego, głębokiego i REM. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.", + "recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało — opiera się na nocnych sygnałach, takich jak zmienność rytmu serca i tętno spoczynkowe. Wysoki wynik to zielone światło, by przyspieszyć; niższy sugeruje, że organizm wciąż się regeneruje.", + "strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało — od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, a nie cudzej miary.", + "stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojny, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację." }, "stats": { "label": "Statystyki podsumowujące", @@ -3386,6 +3407,8 @@ "sources": { "title": "Priorytet źródeł", "description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.", + "integrationsHint": "Źródła danych podłączysz lub odłączysz na stronie integracji.", + "integrationsHintLink": "Otwórz integracje", "cardTitle": "Priorytet źródeł według metryki", "help": "Gdy więcej niż jedno źródło dostarcza tę samą wartość tego samego dnia (np. Withings i Apple Health raportują po 1200 kroków), źródło na górze liczy się jako odczyt kanoniczny. Pozostałe źródła pozostają zapisane na potrzeby ścieżki audytu.", "resetDefaults": "Przywróć wartości domyślne", @@ -3655,8 +3678,8 @@ "codexDisconnected": "Połączenie z ChatGPT usunięte.", "codexConnectionFailed": "Połączenie z ChatGPT nie powiodło się. Spróbuj ponownie.", "rawData": "Wysyłaj surowe dane", - "rawDataOnDescription": "Wysyłane są zagregowane metryki oraz zanonimizowane surowe punkty z ostatnich 30 dni. Obejmuje to pomiary dla poszczególnych metryk (np. waga, ciśnienie, tętno) z kontekstem czasowym, aby dostawca mógł rzetelniej wykrywać wzorce, wartości odstające i korelacje. Imię, adres e-mail i bezpośrednie identyfikatory konta nie są przesyłane.", - "rawDataOffDescription": "Wysyłane są tylko podsumowane metryki (np. średnie, trendy, minimum/maksimum). Bez pojedynczych punktów i bez dokładnego kontekstu czasowego. Lepiej chroni to prywatność, ale dostawca może być mniej precyzyjny w przypadku wzorców i krótkoterminowych wahań.", + "rawDataOnDescription": "Wysyłane są zagregowane metryki oraz zanonimizowane pojedyncze punkty z ostatnich 30 dni z kontekstem czasowym — nigdy imię, adres e-mail ani bezpośrednie identyfikatory konta.", + "rawDataOffDescription": "Wysyłane są tylko podsumowane metryki (np. średnie, trendy, minimum/maksimum) — bez pojedynczych punktów i bez dokładnego kontekstu czasowego.", "rawDataWarning": "Tryb surowych danych włączony: dostawca otrzymuje dodatkowe zanonimizowane punkty z ostatnich 30 dni dla dokładniejszej analizy.", "regenerateInsights": "Wygeneruj raporty ponownie", "regenerateSuccess": "Raporty wygenerowane ponownie", @@ -3788,6 +3811,19 @@ "savedHidden": "Coach ukryty.", "savedShown": "Coach widoczny.", "saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie." + }, + "aboutMe": { + "title": "O mnie", + "description": "Osobisty kontekst, który Coach i codzienny briefing czytają obok Twoich danych.", + "placeholder": "Np. praca zmianowa, dieta wegetariańska, kontuzja kolana, trening do półmaratonu…", + "charCount": "{used} / {max} znaków", + "clear": "Wyczyść", + "save": "Zapisz", + "savedToast": "Notatka „O mnie” zapisana.", + "clearedToast": "Notatka „O mnie” usunięta.", + "saveError": "Nie udało się zapisać. Spróbuj ponownie.", + "loadError": "Nie udało się wczytać notatki.", + "hint": "Przekazywane Coachowi i codziennemu briefingowi jako dodatkowy kontekst. Zostaw puste, aby nic nie wysyłać." } }, "dashboard": { @@ -3817,6 +3853,21 @@ "withingsSyncResult": "{count} pomiarów zsynchronizowano", "withingsFullSyncResult": "{count} pomiarów w pełni zsynchronizowano", "withingsSyncFailed": "Synchronizacja nie powiodła się", + "withingsOauthConnected": "Withings połączony — pierwsza synchronizacja rozpocznie się w tle.", + "withingsOauthFailed": "Połączenie z Withings nie powiodło się", + "withingsOauthError": { + "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", + "replay": "Ten link potwierdzający został już użyty. Rozpocznij połączenie ponownie.", + "state": "Odpowiedź Withings nie pasuje do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.", + "expired": "Żądanie połączenia wygasło. Rozpocznij połączenie ponownie i dokończ autoryzację w ciągu kilku minut.", + "cross_user": "Odpowiedź należy do żądania innego konta. Rozpocznij połączenie ponownie z własnego konta.", + "nocode": "Withings nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Withings.", + "nocreds": "Brak zapisanych danych dostępowych API Withings. Najpierw zapisz Client ID i Client Secret, potem połącz ponownie.", + "token": "Wymiana tokenów z Withings nie powiodła się. Sprawdź Client ID i Client Secret i spróbuj ponownie.", + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + }, + "integrationsSourcesHint": "Gdy kilka źródeł dostarcza tę samą metrykę, priorytet źródeł decyduje, która wartość się liczy.", + "integrationsSourcesHintLink": "Otwórz priorytet źródeł", "withingsSynchronize": "Synchronizuj", "withingsDisconnect": "Rozłącz", "withingsDisconnectTitle": "Rozłączyć Withings?", @@ -4253,6 +4304,36 @@ "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Token zgłoszeń", "bugReportTokenPlaceholder": "ghp_...", + "invites": { + "title": "Zaproszenia", + "description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji. Link jest wyświetlany tylko raz.", + "createLabel": "Nowe zaproszenie:", + "createDays": "{days} dni", + "createdToast": "Zaproszenie utworzone.", + "createError": "Nie udało się utworzyć zaproszenia.", + "tokenTitle": "Link z zaproszeniem", + "tokenOnceHint": "Wyświetlane tylko teraz — skopiuj link lub udostępnij kod QR do zeskanowania.", + "copyLink": "Kopiuj link", + "copiedToast": "Link skopiowany.", + "copyError": "Kopiowanie nie powiodło się.", + "qrAlt": "Kod QR linku z zaproszeniem", + "loadError": "Nie udało się wczytać zaproszeń.", + "listEmpty": "Brak zaproszeń.", + "expires": "wygasa {date}", + "uses": "{used} / {max} wykorzystane", + "createdMeta": "Utworzono {date} przez {by}", + "usedBy": "ostatnio użyte przez {username}", + "statusActive": "Aktywne", + "statusExpired": "Wygasłe", + "statusExhausted": "Wyczerpane", + "deleteAria": "Usuń zaproszenie", + "deleteConfirmTitle": "Usunąć to zaproszenie?", + "deleteConfirmBody": "Link z zaproszeniem natychmiast straci ważność. Konta już zarejestrowane przez ten link pozostaną.", + "deleteConfirmAction": "Usuń", + "cancel": "Anuluj", + "deletedToast": "Zaproszenie usunięte.", + "deleteError": "Nie udało się usunąć zaproszenia." + }, "userManagement": "Zarządzanie użytkownikami", "userName": "Nazwa", "userEmail": "E-mail", @@ -4916,6 +4997,16 @@ "hourAria": "Godzina przypomnienia o nastroju", "hourSavedToast": "Godzina przypomnienia zaktualizowana." }, + "coachNudge": { + "title": "Proaktywny Coach", + "description": "Odzywa się najwyżej raz w tygodniu, gdy Twoje dane sugerują zajrzenie do Coacha — np. niska systematyczność przyjmowania leków lub podwyższona tygodniowa średnia ciśnienia.", + "toggleAria": "Przełącz proaktywne podpowiedzi Coacha", + "statusOn": "Aktywne — maks. 1 raz w tygodniu", + "statusOff": "Wyłączone", + "enabledToast": "Proaktywne podpowiedzi Coacha włączone.", + "disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.", + "saveError": "Nie udało się zapisać. Spróbuj ponownie później." + }, "eventMedicationReminder": "Przypomnienie o leku", "eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.", "eventMeasurementAnomaly": "Nietypowy pomiar", @@ -5102,6 +5193,14 @@ "dailyTitle": "Zapisz nastrój", "dailyBody": "Jak się dzisiaj czujesz?" }, + "coachNudges": { + "complianceTitle": "Twój Coach ma na ten temat przemyślenie", + "complianceBody": "W tym tygodniu Twoje dawki były przyjmowane nieregularnie. Porozmawiaj chwilę z Coachem — może plan da się uprościć.", + "bpTitle": "Podwyższone ciśnienie w tym tygodniu", + "bpBody": "Twoje średnie ciśnienie w tym tygodniu przekroczyło cel. Coach pomoże Ci umieścić wyniki w kontekście.", + "scoreTitle": "Twój wskaźnik regeneracji spada", + "scoreBody": "Twój wskaźnik regeneracji wyraźnie spadł w tym tygodniu. Coach pomoże Ci zawęzić możliwe przyczyny." + }, "clinicianView": { "title": "Udostępniona dokumentacja zdrowotna", "period": "Okres raportu: {start} – {end}", diff --git a/package.json b/package.json index 140dd5235..4b0f75384 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "p-limit": "^7.3.0", "pg-boss": "^12.18.2", "prisma": "^7.8.0", + "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "react": "19.2.5", "react-dom": "19.2.5", @@ -99,6 +100,7 @@ "@testcontainers/postgresql": "^12.0.1", "@types/node": "^25.9.2", "@types/pg": "^8.20.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "@types/web-push": "^3.6.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87f6dcc29..7e4d11f33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: prisma: specifier: ^7.8.0 version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -153,6 +156,9 @@ importers: '@types/pg': specifier: ^8.20.0 version: 8.20.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^19 version: 19.2.14 @@ -2851,6 +2857,9 @@ packages: '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/raf@3.4.3': resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} @@ -3430,6 +3439,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} @@ -3481,6 +3494,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3685,6 +3701,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} @@ -3752,6 +3772,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + docker-compose@1.4.2: resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==} engines: {node: '>= 6.0.0'} @@ -4161,6 +4184,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -4846,6 +4873,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -5199,6 +5230,10 @@ packages: oxc-resolver@11.20.0: resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -5207,10 +5242,18 @@ packages: resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} engines: {node: '>=20'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -5353,6 +5396,10 @@ packages: engines: {node: '>=18'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5528,6 +5575,11 @@ packages: resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} engines: {node: '>=16.0.0'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -5689,6 +5741,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -5799,6 +5854,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@3.1.0: resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} @@ -6411,6 +6469,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} @@ -6434,6 +6495,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -6469,6 +6534,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6486,10 +6554,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -9227,6 +9303,10 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.9.2 + '@types/raf@3.4.3': optional: true @@ -9857,6 +9937,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001792: {} caniuse-lite@1.0.30001793: {} @@ -9906,6 +9988,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -10077,6 +10165,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} dedent@1.7.2: {} @@ -10122,6 +10212,8 @@ snapshots: diff@8.0.4: {} + dijkstrajs@1.0.3: {} + docker-compose@1.4.2: dependencies: yaml: 2.9.0 @@ -10761,6 +10853,11 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -11387,6 +11484,10 @@ snapshots: lines-and-columns@1.2.4: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -11778,6 +11879,10 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -11786,10 +11891,16 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} pako@2.1.0: {} @@ -11913,6 +12024,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-selector-parser@7.1.1: @@ -12046,6 +12159,12 @@ snapshots: pvutils@1.1.5: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -12280,6 +12399,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + reselect@5.1.1: {} resolve-from@4.0.0: {} @@ -12427,6 +12548,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + set-cookie-parser@3.1.0: {} set-function-length@1.2.2: @@ -13197,6 +13320,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 @@ -13222,6 +13347,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -13247,6 +13378,8 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -13255,8 +13388,27 @@ snapshots: yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/prisma/migrations/0139_v11520_intake_attribution_source/migration.sql b/prisma/migrations/0139_v11520_intake_attribution_source/migration.sql new file mode 100644 index 000000000..1baec338b --- /dev/null +++ b/prisma/migrations/0139_v11520_intake_attribution_source/migration.sql @@ -0,0 +1,16 @@ +-- v1.15.20 — provenance of an intake row's slot binding. +-- +-- An off-window take can now be deliberately pinned onto a scheduled slot +-- ("diesem Slot zuordnen"). The column records HOW the row's `scheduled_for` +-- binding came to be: AUTO = window-band attribution (or a skip / pending +-- mint anchored on its slot), USER_PIN = a deliberate user pin. The read +-- ledger binds USER_PIN rows by their stored anchor (like skips) instead of +-- re-running takenAt-band attribution, so a pinned take outside the late +-- tail stays on its slot — rendered taken-late, never on-time-washed. +-- +-- Additive + non-destructive: a new NOT NULL column with a default, no +-- backfill needed. Existing rows read AUTO. +CREATE TYPE "intake_attribution_source" AS ENUM ('AUTO', 'USER_PIN'); + +ALTER TABLE "medication_intake_events" + ADD COLUMN "attribution_source" "intake_attribution_source" NOT NULL DEFAULT 'AUTO'; diff --git a/prisma/migrations/0140_v11520_user_health_profile/migration.sql b/prisma/migrations/0140_v11520_user_health_profile/migration.sql new file mode 100644 index 000000000..20191dfd7 --- /dev/null +++ b/prisma/migrations/0140_v11520_user_health_profile/migration.sql @@ -0,0 +1,22 @@ +-- v1.15.20 — user-authored "about me" self-description for the AI surfaces. +-- +-- 1:1 with users, lazily created on first save. The free text is encrypted +-- at rest (AES-256-GCM through the shared Bytes codec, the CoachFact +-- precedent) — `about_me_encrypted` carries the `"."` +-- ciphertext as UTF-8 bytes and is NULL when the user cleared (or never +-- wrote) the text. Additive + non-destructive. +CREATE TABLE "user_health_profiles" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "about_me_encrypted" BYTEA, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_health_profiles_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "user_health_profiles_user_id_key" ON "user_health_profiles"("user_id"); + +ALTER TABLE "user_health_profiles" + ADD CONSTRAINT "user_health_profiles_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0141_v11520_invite_tokens/migration.sql b/prisma/migrations/0141_v11520_invite_tokens/migration.sql new file mode 100644 index 000000000..5b6947b6c --- /dev/null +++ b/prisma/migrations/0141_v11520_invite_tokens/migration.sql @@ -0,0 +1,32 @@ +-- v1.15.20 — registration invite tokens. +-- +-- Admits a signup while open registration is disabled. Only the +-- HMAC-SHA256 hash of the raw token is stored (the ApiToken scheme); +-- `expires_at` is mandatory (route-capped at 30 days), `uses`/`max_uses` +-- carry the consumption budget, `used_by` tracks the most recent consumer +-- and survives account deletion as NULL. Additive + non-destructive. +CREATE TABLE "invite_tokens" ( + "id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "created_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "used_at" TIMESTAMP(3), + "used_by" TEXT, + "max_uses" INTEGER NOT NULL DEFAULT 1, + "uses" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "invite_tokens_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "invite_tokens_token_hash_key" ON "invite_tokens"("token_hash"); + +CREATE INDEX "invite_tokens_expires_at_idx" ON "invite_tokens"("expires_at"); + +ALTER TABLE "invite_tokens" + ADD CONSTRAINT "invite_tokens_created_by_fkey" + FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "invite_tokens" + ADD CONSTRAINT "invite_tokens_used_by_fkey" + FOREIGN KEY ("used_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbd24fb9e..3fb6d10a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -511,6 +511,16 @@ model User { // seeded catalogue). cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms") + // v1.15.20 — user-authored "about me" context for the AI surfaces + // (1:1, encrypted free text). See `UserHealthProfile`. + healthProfile UserHealthProfile? + // v1.15.20 — registration invite tokens (closed-registration flow). + // `InviteCreator` = admin who minted the invite; `InviteConsumer` = + // the account that registered through it (last consumer wins on a + // multi-use invite). + invitesCreated InviteToken[] @relation("InviteCreator") + invitesUsed InviteToken[] @relation("InviteConsumer") + @@map("users") } @@ -1428,6 +1438,17 @@ enum IntakeSource { @@map("intake_source") } +/// v1.15.20 — provenance of an intake row's slot binding. `USER_PIN` marks +/// a deliberate "diesem Slot zuordnen" decision on an off-window take; the +/// ledger then binds the row by its `scheduledFor` anchor and renders it +/// as taken-late + "zugeordnet" (no compliance flattering, unpinnable). +enum IntakeAttributionSource { + AUTO + USER_PIN + + @@map("intake_attribution_source") +} + enum ReminderPhase { GREEN YELLOW @@ -1479,6 +1500,15 @@ model MedicationIntakeEvent { /// legacy row defaults to false (a user skip or a live pending row, /// neither of which is an auto-miss). autoMissed Boolean @default(false) @map("auto_missed") + /// v1.15.20 — how this row's `scheduledFor` slot binding came to be. + /// `AUTO` (default) = the write path attributed the take by window-band + /// membership (or the row is a skip / pending mint anchored on its slot). + /// `USER_PIN` = the user deliberately pinned an off-window take onto a + /// named slot ("diesem Slot zuordnen"). The read ledger binds USER_PIN + /// rows by their `scheduledFor` anchor (like skips) instead of re-running + /// takenAt-band attribution, so a pinned take outside the late tail stays + /// on its slot — visibly marked, counted late, never on-time-washed. + attributionSource IntakeAttributionSource @default(AUTO) @map("attribution_source") source IntakeSource @default(WEB) idempotencyKey String? @unique @map("idempotency_key") createdAt DateTime @default(now()) @map("created_at") @@ -1800,6 +1830,35 @@ model ApiToken { @@map("api_tokens") } +// v1.15.20 — registration invite tokens. When the operator has disabled +// open registration (`AppSettings.registrationEnabled = false`), a valid, +// unexpired invite token still admits a signup. The raw `hlv_` token +// is shown exactly once at mint time; only its HMAC-SHA256 hash (keyed by +// `API_TOKEN_HMAC_KEY`, the same scheme as `ApiToken.tokenHash`) is +// stored. `expiresAt` is mandatory and capped at 30 days by the admin +// route. `uses`/`maxUses` make multi-use invites possible; the register +// path consumes a use with an atomic guarded increment so a concurrent +// signup can never overshoot `maxUses`. `usedBy` tracks the most recent +// consumer (informational; SetNull on account deletion so the invite +// audit row outlives the account). +model InviteToken { + id String @id @default(cuid()) + tokenHash String @unique @map("token_hash") + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + usedAt DateTime? @map("used_at") + usedBy String? @map("used_by") + maxUses Int @default(1) @map("max_uses") + uses Int @default(0) + + creator User @relation("InviteCreator", fields: [createdBy], references: [id], onDelete: Cascade) + consumer User? @relation("InviteConsumer", fields: [usedBy], references: [id], onDelete: SetNull) + + @@index([expiresAt]) + @@map("invite_tokens") +} + // ─── Refresh Tokens (native client short-lived access + rotating refresh) ─ model RefreshToken { @@ -3007,6 +3066,28 @@ model CoachFact { @@map("coach_facts") } +// v1.15.20 — user-authored "about me" self-description the AI surfaces +// (Coach system prompt + daily-briefing user prompt) inject as a clearly +// delimited, user-provided context block. 1:1 with User and lazily +// created on first save so existing accounts pay nothing. The free text +// is encrypted at rest (AES-256-GCM via the shared Bytes codec, exactly +// like `CoachFact.factEncrypted`) — it is unstructured health prose and +// must never land in the table as plaintext. A cleared profile keeps the +// row with a NULL payload so `updatedAt` documents the deletion instant. +model UserHealthProfile { + id String @id @default(cuid()) + userId String @unique @map("user_id") + // Free text, hard-capped at 4 000 chars by the route's Zod schema + // BEFORE encryption. NULL = cleared / never written. + aboutMeEncrypted Bytes? @map("about_me_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("user_health_profiles") +} + // Per-user, per-day token-spend ledger for the AI Coach. The dispatcher // reads `totalTokens` against `MAX_TOKENS_PER_USER_PER_DAY` before // hitting any provider, so a runaway client cannot drain the operator's diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 304cbf512..fec6b7113 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -31,6 +31,7 @@ import { LoginOverviewSection } from "@/components/admin/login-overview-section" import { RemindersSection } from "@/components/admin/reminders-section"; import { ServicesSection } from "@/components/admin/services-section"; import { SystemStatusSection } from "@/components/admin/system-status-section"; +import { InviteTokensSection } from "@/components/admin/invite-tokens-section"; import { UserManagementSection } from "@/components/admin/user-management-section"; import type { AdminSectionSlug } from "@/components/admin/section-slugs"; @@ -138,7 +139,12 @@ export function AdminSectionRenderer({ title={t("admin.section.users.title")} subtitle={t("admin.section.users.subtitle")} > - +
+ + {/* v1.15.20 — registration invites live with the accounts + they admit. */} + +
); case "api-tokens": diff --git a/src/app/api/admin/invites/[id]/route.ts b/src/app/api/admin/invites/[id]/route.ts new file mode 100644 index 000000000..da5fcbe78 --- /dev/null +++ b/src/app/api/admin/invites/[id]/route.ts @@ -0,0 +1,41 @@ +/** + * DELETE /api/admin/invites/{id} — revoke a registration invite. + * + * v1.15.20 — hard delete: an invite row carries no health data and no + * plaintext secret, and the audit log already records mint + revoke, so + * keeping a tombstone would only grow the table. Idempotent — deleting + * an unknown id returns `{ deleted: false }` rather than 404 so a + * double-tap in the admin UI never surfaces an error. + */ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; + +export const dynamic = "force-dynamic"; + +export const DELETE = apiHandler( + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { user } = await requireAdmin(); + const { id } = await params; + + const { count } = await prisma.inviteToken.deleteMany({ where: { id } }); + const deleted = count > 0; + + if (deleted) { + await auditLog("admin.invite.deleted", { + userId: user.id, + ipAddress: getClientIp(req), + details: { inviteId: id }, + }); + } + + annotate({ + action: { name: "admin.invite.deleted" }, + meta: { deleted }, + }); + + return apiSuccess({ deleted }); + }, +); diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts new file mode 100644 index 000000000..92b6a1829 --- /dev/null +++ b/src/app/api/admin/invites/route.ts @@ -0,0 +1,110 @@ +/** + * GET /api/admin/invites — list all registration invites. + * POST /api/admin/invites — mint a new invite token. + * + * v1.15.20 — closed-registration invite flow. The raw `hlv_` token + * appears EXACTLY ONCE: in the POST response, alongside the composed + * registration URL. Only the HMAC-SHA256 hash is persisted (the + * `ApiToken` scheme), so neither the GET list nor a database leak can + * reproduce a usable invite. + * + * Both verbs are `requireAdmin()` — cookie-only by construction; a + * Bearer token can never mint or enumerate invites. + */ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { hashToken } from "@/lib/auth/hmac"; +import { + buildInviteUrl, + generateInviteToken, +} from "@/lib/auth/invite-token"; +import { inviteCreateSchema } from "@/lib/validations/invite"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + await requireAdmin(); + annotate({ action: { name: "admin.invites.list" } }); + + const invites = await prisma.inviteToken.findMany({ + select: { + id: true, + createdAt: true, + expiresAt: true, + usedAt: true, + uses: true, + maxUses: true, + creator: { select: { id: true, username: true } }, + consumer: { select: { id: true, username: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + return apiSuccess(invites); +}); + +export const POST = apiHandler(async (req: Request) => { + const { user } = await requireAdmin(); + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = inviteCreateSchema.safeParse(body ?? {}); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const { expiresInDays, maxUses } = parsed.data; + const rawToken = generateInviteToken(); + const expiresAt = new Date( + Date.now() + expiresInDays * 24 * 60 * 60 * 1000, + ); + + const invite = await prisma.inviteToken.create({ + data: { + tokenHash: hashToken(rawToken), + createdBy: user.id, + expiresAt, + maxUses, + }, + select: { + id: true, + createdAt: true, + expiresAt: true, + uses: true, + maxUses: true, + }, + }); + + // The audit row carries metadata only — never the raw token. + await auditLog("admin.invite.created", { + userId: user.id, + ipAddress: getClientIp(req), + details: { inviteId: invite.id, expiresInDays, maxUses }, + }); + + annotate({ + action: { name: "admin.invite.created" }, + meta: { expiresInDays, maxUses }, + }); + + return apiSuccess( + { + ...invite, + // Shown exactly once. Neither value is persisted in plaintext. + token: rawToken, + url: buildInviteUrl(rawToken, req.url), + }, + 201, + ); +}); diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index b6ba3810f..3e8d598b9 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -91,6 +91,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); @@ -111,6 +112,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); @@ -133,6 +135,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); }); }); @@ -166,6 +169,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -175,6 +179,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }, }, }); @@ -188,11 +193,13 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }, next: { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }, changed: ["medication"], }), @@ -254,6 +261,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }, }, }); @@ -279,6 +287,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -288,6 +297,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }, }, }); @@ -316,6 +326,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 9 }, cycle: { clientManaged: false }, + coach: { nudgesEnabled: true }, }, }, }); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 3a3080207..d287a80a7 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -1,4 +1,8 @@ import { prisma } from "@/lib/db"; +import { + consumeInviteToken, + recordInviteConsumer, +} from "@/lib/auth/invite-token"; import { registerSchema } from "@/lib/validations/auth"; import { hashPassword, checkPasswordStrength } from "@/lib/auth/password"; import { createSession } from "@/lib/auth/session"; @@ -57,10 +61,6 @@ export const POST = apiHandler(async (request: NextRequest) => { } catch { // Table may not exist yet; allow registration } - if (!registrationEnabled) { - return apiError("Registration is disabled", 403); - } - const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); @@ -73,7 +73,21 @@ export const POST = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const { email, username, password, timezone: timezoneInput } = parsed.data; + const { + email, + username, + password, + timezone: timezoneInput, + inviteToken, + } = parsed.data; + + // v1.15.20 — closed registration still admits a valid invite. The + // token itself is 32 random bytes behind a keyed hash and the surface + // is rate-limited (5 / 15 min / IP), so a distinct "invalid invite" + // message is a UX win, not an oracle. + if (!registrationEnabled && !inviteToken) { + return apiError("Registration is disabled", 403); + } // v1.4.25 W7 — accept a browser-detected timezone from the // registration form. Validate against the runtime IANA list; on @@ -109,6 +123,23 @@ export const POST = apiHandler(async (request: NextRequest) => { } const passwordHash = await hashPassword(password); + // v1.15.20 — consume the invite LAST, after every other validation, + // so a taken username or weak password can never burn one of the + // invite's uses. The guarded increment inside `consumeInviteToken` + // makes the last-use race safe under concurrent signups. + let inviteId: string | null = null; + if (!registrationEnabled && inviteToken) { + const consumed = await consumeInviteToken(inviteToken); + if (!consumed.ok) { + annotate({ + action: { name: "auth.register.invite_rejected" }, + meta: { reason: consumed.reason }, + }); + return apiError("Invalid or expired invite", 403); + } + inviteId = consumed.inviteId; + } + // First user becomes admin const role = userCount === 0 ? "ADMIN" : "USER"; @@ -130,10 +161,16 @@ export const POST = apiHandler(async (request: NextRequest) => { const ua = request.headers.get("user-agent"); await createSession(user.id, true, ip, ua); + // Stamp the consumer onto the invite (informational, best-effort — + // the use itself was already counted atomically above). + if (inviteId) { + await recordInviteConsumer(inviteId, user.id); + } + await auditLog("auth.register", { userId: user.id, ipAddress: ip, - details: { method: "password", timezone }, + details: { method: "password", timezone, invited: inviteId !== null }, }); annotate({ action: { name: "auth.register" } }); diff --git a/src/app/api/coach/about-me/route.ts b/src/app/api/coach/about-me/route.ts new file mode 100644 index 000000000..8de3f1ff1 --- /dev/null +++ b/src/app/api/coach/about-me/route.ts @@ -0,0 +1,144 @@ +/** + * GET /api/coach/about-me — the caller's "about me" self-description. + * PUT /api/coach/about-me — write (or clear) the self-description. + * + * v1.15.20 — the user-authored context the Coach system prompt and the + * daily briefing inject as a delimited, user-provided block (see + * `src/lib/ai/coach/about-me.ts`). Free text, hard-capped at 4 000 chars, + * encrypted at rest through the shared Bytes codec. An empty / whitespace + * PUT clears the text (the row is kept with a NULL payload so `updatedAt` + * documents the deletion instant). + * + * Plain text end to end: the client renders the value as a React text + * child only — no markdown library exists in the tree and none may be + * added (XSS posture, see the contributor notes). + * + * Ownership: the user id always comes from `requireAuth()`; the body + * carries only the text. Audit rows never contain the text itself — only + * its length — because it is free-form health prose. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { + decryptFromBytes, + encryptToBytes, +} from "@/lib/ai/coach/bytes-codec"; +import { + ABOUT_ME_MAX_CHARS, + aboutMePutSchema, +} from "@/lib/validations/about-me"; + +const PUT_RATE_LIMIT = 30; +const PUT_WINDOW_MS = 60_000; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const row = await prisma.userHealthProfile.findUnique({ + where: { userId: user.id }, + select: { aboutMeEncrypted: true, updatedAt: true }, + }); + + let aboutMe: string | null = null; + if (row?.aboutMeEncrypted) { + try { + aboutMe = decryptFromBytes(row.aboutMeEncrypted); + } catch { + // Fail closed per row — never surface ciphertext. The user sees an + // empty editor and can re-write; the stale ciphertext is replaced + // on the next save. + aboutMe = null; + } + } + + annotate({ + action: { name: "coach.about_me.get" }, + meta: { present: aboutMe !== null }, + }); + + return apiSuccess({ + aboutMe, + updatedAt: row?.updatedAt?.toISOString() ?? null, + maxChars: ABOUT_ME_MAX_CHARS, + }); +}); + +export const PUT = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `coach-about-me:put:${user.id}`, + PUT_RATE_LIMIT, + PUT_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = aboutMePutSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const text = parsed.data.aboutMe.trim(); + const cleared = text.length === 0; + const payload = cleared ? null : encryptToBytes(text); + + const row = await prisma.userHealthProfile.upsert({ + where: { userId: user.id }, + create: { + userId: user.id, + aboutMeEncrypted: payload, + }, + update: { + aboutMeEncrypted: payload, + }, + select: { updatedAt: true }, + }); + + // The audit row carries the length only — the text is free-form health + // prose and must not land in the (plaintext) audit details. + await auditLog( + cleared ? "coach.about_me.cleared" : "coach.about_me.updated", + { + userId: user.id, + ipAddress: getClientIp(req), + details: { length: text.length }, + }, + ); + + annotate({ + action: { + name: cleared ? "coach.about_me.cleared" : "coach.about_me.updated", + }, + meta: { length: text.length }, + }); + + return apiSuccess({ + aboutMe: cleared ? null : text, + updatedAt: row.updatedAt.toISOString(), + maxChars: ABOUT_ME_MAX_CHARS, + }); +}); + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 90acb8440..05ec7a680 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -64,6 +64,7 @@ import { } from "@/lib/ai/coach/budget"; import { detectRefusal } from "@/lib/ai/coach/refusal"; import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt"; +import { getAboutMeForUser } from "@/lib/ai/coach/about-me"; import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues"; import { parseCoachPrefs } from "@/lib/validations/coach-prefs"; @@ -313,7 +314,12 @@ async function handleChatRequest(request: NextRequest): Promise { ? { ...(scope ?? {}), window: coachPrefs.defaultWindow } : scope; const snapshot = await buildCoachSnapshot(userId, effectiveScope); - const systemPrompt = getCoachSystemPrompt(locale, coachPrefs); + // v1.15.20 — the user-authored "about me" self-description (Settings → + // AI) rides the system prompt as a delimited, user-provided context + // block. Fail-open: a missing / undecryptable text yields null and the + // prompt is byte-identical to the pre-feature one. + const aboutMe = await getAboutMeForUser(userId); + const systemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe); const allTurns: CoachTurn[] = [ ...priorTurns, { role: "user", content: message }, diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 5b59b3ad3..03966341e 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -26,6 +26,10 @@ import { type ComparisonSnapshot, } from "@/lib/ai/prompts/insight-system-prompt"; import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator"; +import { + buildAboutMeInsightBlock, + getAboutMeForUser, +} from "@/lib/ai/coach/about-me"; import { metricsFromPresentSections } from "@/lib/ai/medical-references"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { @@ -544,6 +548,13 @@ export const POST = apiHandler(async (request: NextRequest) => { if (plateauContext) { userPrompt += buildGlp1PlateauPrompt(plateauContext, locale); } + // v1.15.20 — fold the user-authored "about me" self-description + // (Settings → AI) into the briefing as a delimited, user-provided + // SYSTEM CONTEXT block. Null (no text / undecryptable) costs nothing. + const aboutMe = await getAboutMeForUser(userId); + if (aboutMe) { + userPrompt += buildAboutMeInsightBlock(aboutMe, locale); + } // v1.10.0 — fold a notable derived wellness signal (readiness / recovery // shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block. // Reads the one derived contract; null (the common case) costs nothing. diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 73e56ac8c..26150ab1f 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -1,9 +1,9 @@ "use client"; import { useId, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, UserPlus } from "lucide-react"; +import { Loader2, MailOpen, UserPlus } from "lucide-react"; import { Logo } from "@/components/ui/logo"; import Link from "next/link"; import { Button } from "@/components/ui/button"; @@ -16,6 +16,11 @@ import { queryKeys } from "@/lib/query-keys"; export default function RegisterPage() { const router = useRouter(); + const searchParams = useSearchParams(); + // v1.15.20 — invite deep link (`/auth/register?invite=hlv_…`). The + // token rides the signup POST so a closed-registration instance still + // admits the invited user; an open instance simply ignores it. + const inviteToken = searchParams.get("invite"); const queryClient = useQueryClient(); const [email, setEmail] = useState(""); const [username, setUsername] = useState(""); @@ -45,7 +50,13 @@ export default function RegisterPage() { const res = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, username, password, timezone }), + body: JSON.stringify({ + email, + username, + password, + timezone, + ...(inviteToken ? { inviteToken } : {}), + }), }); const json = await res.json(); @@ -85,6 +96,17 @@ export default function RegisterPage() { + {inviteToken && ( +
+
+ )} +
diff --git a/src/components/admin/invite-tokens-section.tsx b/src/components/admin/invite-tokens-section.tsx new file mode 100644 index 000000000..88bf5ee8e --- /dev/null +++ b/src/components/admin/invite-tokens-section.tsx @@ -0,0 +1,309 @@ +"use client"; + +/** + * v1.15.20 — admin card for registration invites (Users section). + * + * Mint an invite (7 / 14 / 30-day lifetime), hand it over as a link or + * QR code, see the open invites, revoke one. The raw token + URL + QR + * appear exactly once — straight from the POST response — and are gone + * after a reload; the list endpoint only ever returns metadata. + * + * The QR data-URL is rendered inside the mutation (not in an effect): + * `qrcode`'s `toDataURL` is async, so folding it into `mutationFn` + * keeps the component free of setState-in-effect churn. + */ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Copy, Loader2, Ticket, Trash2 } from "lucide-react"; +import QRCode from "qrcode"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatDate } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +interface AdminInvite { + id: string; + createdAt: string; + expiresAt: string; + usedAt: string | null; + uses: number; + maxUses: number; + creator: { id: string; username: string } | null; + consumer: { id: string; username: string } | null; +} + +interface MintedInvite { + id: string; + token: string; + url: string; + expiresAt: string; + qrDataUrl: string; +} + +type InviteStatus = "active" | "expired" | "exhausted"; + +function deriveStatus(invite: AdminInvite, now: Date): InviteStatus { + if (invite.uses >= invite.maxUses) return "exhausted"; + if (new Date(invite.expiresAt).getTime() <= now.getTime()) return "expired"; + return "active"; +} + +const TTL_CHOICES = [7, 14, 30] as const; + +export function InviteTokensSection() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [minted, setMinted] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data: invites, isError } = useQuery({ + queryKey: queryKeys.adminInvites(), + queryFn: async () => { + const res = await fetch("/api/admin/invites"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as AdminInvite[]; + }, + }); + + const create = useMutation({ + mutationKey: queryKeys.adminInvites(), + mutationFn: async (expiresInDays: number): Promise => { + const res = await fetch("/api/admin/invites", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expiresInDays }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()).data as Omit; + const qrDataUrl = await QRCode.toDataURL(data.url, { + width: 240, + margin: 1, + }); + return { ...data, qrDataUrl }; + }, + onSuccess: (invite) => { + setMinted(invite); + toast.success(t("admin.invites.createdToast")); + queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() }); + }, + onError: () => { + toast.error(t("admin.invites.createError")); + }, + }); + + const remove = useMutation({ + mutationFn: async (id: string) => { + const res = await fetch(`/api/admin/invites/${id}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + }, + onSuccess: (_data, id) => { + toast.success(t("admin.invites.deletedToast")); + if (minted?.id === id) setMinted(null); + queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() }); + }, + onError: () => { + toast.error(t("admin.invites.deleteError")); + }, + }); + + async function copyUrl(url: string) { + try { + await navigator.clipboard.writeText(url); + toast.success(t("admin.invites.copiedToast")); + } catch { + toast.error(t("admin.invites.copyError")); + } + } + + const now = new Date(); + + return ( +
+
+
+

+ {t("admin.invites.description")} +

+ +
+ + {t("admin.invites.createLabel")} + + {TTL_CHOICES.map((days) => ( + + ))} +
+ + {minted && ( +
+

{t("admin.invites.tokenTitle")}

+

+ {t("admin.invites.tokenOnceHint")} +

+

+ {minted.url} +

+
+ {/* eslint-disable-next-line @next/next/no-img-element -- data-URL QR, no remote loader involved */} + {t("admin.invites.qrAlt")} + +
+
+ )} + +
+ {isError && ( +

+ {t("admin.invites.loadError")} +

+ )} + {invites && invites.length === 0 && ( +

+ {t("admin.invites.listEmpty")} +

+ )} + {invites?.map((invite) => { + const status = deriveStatus(invite, now); + // Literal keys so the i18n call-site coverage guard sees them. + const statusLabel = + status === "active" + ? t("admin.invites.statusActive") + : status === "expired" + ? t("admin.invites.statusExpired") + : t("admin.invites.statusExhausted"); + return ( +
+
+
+ + {statusLabel} + + + {t("admin.invites.expires", { + date: formatDate(invite.expiresAt), + })} + + + {t("admin.invites.uses", { + used: invite.uses, + max: invite.maxUses, + })} + +
+

+ {t("admin.invites.createdMeta", { + date: formatDate(invite.createdAt), + by: invite.creator?.username ?? "—", + })} + {invite.consumer + ? ` · ${t("admin.invites.usedBy", { + username: invite.consumer.username, + })}` + : ""} +

+
+ +
+ ); + })} +
+ + { + if (!open) setDeleteTarget(null); + }} + > + + + + {t("admin.invites.deleteConfirmTitle")} + + + {t("admin.invites.deleteConfirmBody")} + + + + {t("admin.invites.cancel")} + { + if (deleteTarget) remove.mutate(deleteTarget.id); + setDeleteTarget(null); + }} + > + {t("admin.invites.deleteConfirmAction")} + + + + +
+ ); +} diff --git a/src/components/settings/about-me-section.tsx b/src/components/settings/about-me-section.tsx new file mode 100644 index 000000000..83505500b --- /dev/null +++ b/src/components/settings/about-me-section.tsx @@ -0,0 +1,170 @@ +"use client"; + +/** + * v1.15.20 — Settings → AI "About me" panel. + * + * Free-text self-description the user writes for the AI surfaces. The + * server stores it encrypted (`UserHealthProfile.aboutMeEncrypted`) and + * injects it into the Coach system prompt + the daily briefing as a + * delimited, user-provided context block. + * + * GET /api/coach/about-me — { aboutMe, updatedAt, maxChars } + * PUT /api/coach/about-me — { aboutMe } (empty string clears) + * + * Plain text only — the value is rendered exclusively through a + * `