diff --git a/bundle-budget.json b/bundle-budget.json index e45168d89..cc062e9d9 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,12 +1,12 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route.", "routeBaselineKbGz": { - "/page": 447, - "/insights/page": 434, - "/measurements/page": 433, - "/insights/mood/page": 447 + "/page": 429, + "/insights/page": 368, + "/measurements/page": 411, + "/insights/mood/page": 379 }, "routeDriftAllowanceKbGz": 20, - "totalClientKbGz": 3790, + "totalClientKbGz": 4100, "maxRechartsChunks": 1 } diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ef8050582..05180b2c4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -21940,6 +21940,276 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "502": *a60 + /api/symptoms/definitions: + get: + tags: + - Symptoms + summary: List your own symptoms (v1.40) + description: The record's active symptom definitions with their 30-day summary; `includeHidden=true` adds the hidden + ones. Rides the illness module. + parameters: + - in: query + name: includeHidden + schema: + type: string + enum: + - "true" + - "false" + responses: + "200": + description: The definitions. + content: + application/json: + schema: + $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" + "401": *a1 + "403": &a62 + description: >- + `illness.disabled`: the record has the illness module switched off (or the operator turned it off + server-wide); symptoms ride that module. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + post: + tags: + - Symptoms + summary: Define a symptom (v1.40) + description: Creates one definition. Idempotent under `Idempotency-Key`. Audits as `symptoms.definition.create` without + the label. Rate-limited to 30 a minute. + parameters: + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSymptomDefinitionRequest" + responses: + "201": + description: Definition created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" + "401": *a1 + "403": *a62 + "409": *a7 + "422": *a4 + "429": *a5 + /api/symptoms/definitions/{id}: + patch: + tags: + - Symptoms + summary: Edit, hide or show a symptom (v1.40) + description: Partial edit. Audits as `symptoms.definition.update`, naming a label change without quoting it. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateSymptomDefinitionRequest" + responses: + "200": + description: Definition updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" + "401": *a1 + "403": *a62 + "404": &a63 + description: "`symptoms.definition.notFound`: no definition with this id on the record." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + delete: + tags: + - Symptoms + summary: Hide or delete a symptom (v1.40) + description: Hides the definition and keeps its history. `?purge=true` deletes it and every occurrence of it. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: query + name: purge + schema: + type: string + enum: + - "true" + - "false" + responses: + "200": + description: Hidden or deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" + "401": *a1 + "403": *a62 + "404": *a63 + "422": *a4 + "429": *a5 + /api/symptoms/events: + get: + tags: + - Symptoms + summary: List symptom occurrences (v1.40) + description: Occurrences of the record's symptoms, newest first, narrowed by the optional query. + parameters: + - in: query + name: from + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - in: query + name: to + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - in: query + name: definitionId + schema: + type: string + minLength: 1 + maxLength: 40 + - in: query + name: episodeId + schema: + type: string + minLength: 1 + maxLength: 40 + - in: query + name: limit + schema: + default: 200 + type: integer + minimum: 1 + maximum: 500 + responses: + "200": + description: A page of occurrences. + content: + application/json: + schema: + $ref: "#/components/schemas/ListSymptomEventsEnvelope" + "401": *a1 + "403": *a62 + "422": *a4 + "429": *a5 + post: + tags: + - Symptoms + summary: Log a symptom occurrence (v1.40) + description: Creates one occurrence. Idempotent under `Idempotency-Key`. A hidden definition can still be logged + against; another record's definition 404s (`symptoms.definition.notFound`). Audits as `symptoms.event.create` + without the note. + parameters: + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSymptomEventRequest" + responses: + "201": + description: Occurrence logged. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSymptomEventEnvelope" + "401": *a1 + "403": *a62 + "404": *a63 + "409": *a7 + "422": *a4 + "429": *a5 + /api/symptoms/events/{id}: + patch: + tags: + - Symptoms + summary: Edit a symptom occurrence (v1.40) + description: Partial edit. Audits as `symptoms.event.update`, naming a note change without quoting it. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateSymptomEventRequest" + responses: + "200": + description: Occurrence updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateSymptomEventEnvelope" + "401": *a1 + "403": *a62 + "404": &a64 + description: "`symptoms.event.notFound`: no occurrence with this id on the record." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + delete: + tags: + - Symptoms + summary: Delete a symptom occurrence (v1.40) + description: Removes the occurrence. Audits as `symptoms.event.delete`. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteSymptomEventEnvelope" + "401": *a1 + "403": *a62 + "404": *a64 + "422": *a4 + "429": *a5 /api/bugreport: post: security: [] @@ -31687,6 +31957,97 @@ components: leaves the singleton instance settings — registration, MFA requirement, default locale and timezone, module availability, notification and AI configuration, document cap and quota — exactly as they are. Send `true` only when rebuilding a host from a snapshot, because those settings apply to every account on the installation." + CreateSymptomDefinitionRequest: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + icon: + anyOf: + - type: string + - type: "null" + required: + - label + description: Define a symptom of your own. `label` (1-40 characters) is encrypted at rest; `icon` is one of the cycle + custom-symptom icon names, null for the default glyph. At most eight active definitions per account (422 + `symptoms.definition.limitReached`). + UpdateSymptomDefinitionRequest: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + icon: + anyOf: + - type: string + - type: "null" + isActive: + type: boolean + sortOrder: + type: integer + minimum: 0 + maximum: 1000 + description: "Partial edit; an omitted key leaves the column untouched. `isActive: false` hides the symptom (history + kept, slot freed); `isActive: true` shows it again and re-checks the cap." + CreateSymptomEventRequest: + type: object + properties: + definitionId: + type: string + minLength: 1 + maxLength: 40 + occurredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + intensity: + type: integer + minimum: 0 + maximum: 10 + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + episodeId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + required: + - definitionId + - intensity + description: Log one occurrence. `intensity` is 0-10 (0 none, 10 the worst imaginable). `occurredAt` defaults to now and + may not lie in the future beyond five minutes. `episodeId`, when given, must name one of the record's own + illness episodes that is still open (422 `symptoms.episode.notOpen` otherwise). The note is encrypted at rest. + UpdateSymptomEventRequest: + type: object + properties: + occurredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + intensity: + type: integer + minimum: 0 + maximum: 10 + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + episodeId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + description: "Partial edit of one occurrence. `note: null` clears the note, `episodeId: null` drops the link. A changed + `episodeId` must name an open episode; an unchanged link to an episode that has since resolved is kept." MedicationTreatmentClass: type: string enum: @@ -31988,14 +32349,15 @@ components: sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, - source-priority.body.invalid_shape; tokens — tokens.documents.ceiling_reached, - tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; vaccination — - vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, - vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, - vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — - workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, - workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, - ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, + source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, + symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; + tokens — tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, + tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid, + vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found, + vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, + workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) + — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, @@ -36349,6 +36711,86 @@ components: - onsetAt - dayLogs additionalProperties: {} + symptomDefinitions: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + label: + type: string + minLength: 1 + labelEncrypted: + type: string + minLength: 1 + icon: + anyOf: + - type: string + - type: "null" + sortOrder: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + isActive: + type: boolean + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + events: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + occurredAt: + type: string + minLength: 1 + intensity: + type: integer + minimum: 0 + maximum: 10 + note: + anyOf: + - type: string + - type: "null" + noteEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + episodeId: + anyOf: + - type: string + minLength: 1 + - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + required: + - occurredAt + - intensity + - createdAt + - updatedAt + additionalProperties: {} + required: + - sortOrder + - isActive + - createdAt + - updatedAt + - events + additionalProperties: {} allergies: default: [] type: array @@ -38864,6 +39306,7 @@ components: - labResults - biomarkers - illnessEpisodes + - symptomDefinitions - allergies - familyHistory - workouts @@ -75705,6 +76148,293 @@ components: - data - error additionalProperties: false + ListSymptomDefinitionsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomDefinitionList" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SymptomDefinitionList: + type: object + properties: + definitions: + type: array + items: + $ref: "#/components/schemas/SymptomDefinition" + limit: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - definitions + - limit + additionalProperties: false + description: The record's symptom definitions in display order, and the active-definition cap. + SymptomDefinition: + type: object + properties: + id: + type: string + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + sortOrder: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + isActive: + type: boolean + recent: + type: object + properties: + count30d: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + maxIntensity30d: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + lastOccurredAt: + anyOf: + - type: string + - type: "null" + required: + - count30d + - maxIntensity30d + - lastOccurredAt + additionalProperties: false + required: + - id + - label + - icon + - sortOrder + - isActive + - recent + additionalProperties: false + description: "A symptom the person defined. `label` is decrypted (null only on an unreadable value). `recent` is + computed server-side: occurrences and the highest intensity over the last 30 days, and the last occurrence ever. + Each active definition is also the correlation channel `SYMPTOM:`." + CreateSymptomDefinitionEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomDefinition" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateSymptomDefinitionEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomDefinition" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteSymptomDefinitionEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomDefinitionDeleted" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SymptomDefinitionDeleted: + type: object + properties: + deleted: + type: boolean + const: true + purged: + type: boolean + eventsDeleted: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - deleted + - purged + - eventsDeleted + additionalProperties: false + description: "`purged: false` means the definition was hidden and its history kept; `purged: true` means it and + `eventsDeleted` occurrences were removed." + ListSymptomEventsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomEventList" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SymptomEventList: + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/SymptomEvent" + limit: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + hasMore: + type: boolean + required: + - events + - limit + - hasMore + additionalProperties: false + description: A page of occurrences, newest first. `hasMore` is true when the page is full; page on with `to` set to the + oldest `occurredAt` seen. + SymptomEvent: + type: object + properties: + id: + type: string + definitionId: + type: string + occurredAt: + type: string + intensity: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + note: + anyOf: + - type: string + - type: "null" + episodeId: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - definitionId + - occurredAt + - intensity + - note + - episodeId + - createdAt + - updatedAt + additionalProperties: false + description: "One occurrence of a defined symptom: when, how strong (0-10), the decrypted note, and the illness episode + it was filed against (null when none)." + CreateSymptomEventEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomEvent" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateSymptomEventEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SymptomEvent" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteSymptomEventEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + const: true + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false __schema0: anyOf: - type: string diff --git a/messages/de.json b/messages/de.json index e55d7d7d1..26ece40e6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -140,6 +140,18 @@ "unit": { "locked": "Dieser Marker hat schon Werte, deshalb kannst du seine Einheit nicht ändern. Die gespeicherten Werte würden sonst in der neuen Einheit angezeigt, ohne umgerechnet zu werden." } + }, + "symptoms": { + "definition": { + "limitReached": "Du hast 8 Symptome. Blende eins aus, um ein neues anzulegen.", + "notFound": "Dieses Symptom gibt es nicht mehr." + }, + "episode": { + "notOpen": "Diese Erkrankung ist nicht mehr offen. Wähle eine andere oder keine." + }, + "event": { + "notFound": "Diesen Eintrag gibt es nicht mehr." + } } }, "doctorReport": { @@ -410,7 +422,9 @@ "mood": "Stimmung", "moodDescription": "Stimmung und Tags erfassen", "workout": "Workout", - "workoutDescription": "Eine Einheit, die kein Gerät aufgezeichnet hat" + "workoutDescription": "Eine Einheit, die kein Gerät aufgezeichnet hat", + "symptom": "Symptom", + "symptomDescription": "Eines deiner eigenen Symptome, mit Stärke" }, "cycle": "Zyklus", "labs": "Laborwerte", @@ -649,7 +663,8 @@ "title": "Sichere deinen Verschlüsselungsschlüssel", "body": "Ohne eine Kopie des Schlüssels lassen sich deine Daten nach einer Neuinstallation nicht wiederherstellen. Es dauert eine Minute.", "action": "Zum Schritt" - } + }, + "quickAddSymptom": "Symptom erfassen" }, "measurements": { "title": "Messungen", @@ -7478,7 +7493,8 @@ "restoreFailureUnexpected": "Die Wiederherstellung ist an einem unerwarteten Fehler gescheitert und wurde zurückgerollt. Das Server-Log nennt den Fehler.", "restoreFailureKeyMissing": "Die Sicherung braucht den Verschlüsselungsschlüssel {keys}, mit dem dieser Server sie nicht öffnen kann. Nimm den Schlüssel wieder in ENCRYPTION_KEYS auf, starte neu und starte die Wiederherstellung erneut.", "restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.", - "previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu." + "previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu.", + "restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)" }, "danger-zone": { "title": "Gefahrenzone", @@ -11270,7 +11286,13 @@ "documentDiscard": "{name} hat ein Dokument verworfen", "medicationIntakeImportFinished": "Der von {name} gestartete Einnahme-Import ist fertig", "medicationOneShotReconciled": "Ein einmaliges Medikament wurde nach einer Einnahme von {name} geöffnet oder geschlossen", - "vaccinationBoosterPlanned": "{name} hat eine Auffrischung geplant" + "vaccinationBoosterPlanned": "{name} hat eine Auffrischung geplant", + "symptomEventCreate": "{name} hat ein Symptom erfasst", + "symptomEventUpdate": "{name} hat einen Symptom-Eintrag bearbeitet", + "symptomEventDelete": "{name} hat einen Symptom-Eintrag gelöscht", + "symptomDefinitionCreate": "{name} hat ein Symptom angelegt", + "symptomDefinitionUpdate": "{name} hat ein Symptom geändert", + "symptomDefinitionDelete": "{name} hat ein Symptom entfernt" }, "section": { "measurements": { @@ -11501,5 +11523,66 @@ "waitingForWorker": "Warte auf den Hintergrunddienst. Ändert sich das nicht, läuft der Worker auf dem Server vielleicht nicht.", "errorWorkerUnavailable": "Der Hintergrunddienst ist gerade nicht erreichbar. Versuch es gleich noch einmal oder bitte die Person, die den Server betreibt, ihn zu prüfen.", "errorTimedOut": "Das Lesen hat länger gedauert, als deine KI-Antwortzeit erlaubt. Versuch es noch einmal oder erhöhe die Antwortzeit in den KI-Einstellungen." + }, + "symptoms": { + "add": "Neues Symptom", + "nameLabel": "Name", + "namePlaceholder": "z. B. Aura", + "iconLabel": "Symbol", + "limitReached": "Du hast 8 Symptome. Blende eins aus, um ein neues anzulegen.", + "saveError": "Das Symptom konnte nicht gespeichert werden. Bitte versuch es noch einmal.", + "nameRequired": "Gib dem Symptom einen Namen.", + "unreadableLabel": "Unlesbares Symptom", + "intensityPill": "{value}/10", + "entry": { + "sheetTitle": "Symptom erfassen", + "sheetDescription": "Eines deiner eigenen Symptome, wie stark und wann.", + "symptom": "Symptom", + "symptomRequired": "Wähle ein Symptom.", + "intensity": "Stärke", + "intensityRequired": "Gib an, wie stark es war.", + "intensityUnset": "Nicht gesetzt", + "anchorLow": "Keine", + "anchorHigh": "Stärkste vorstellbare", + "intensityValueText": "{value} von {max} ({low} bis {high})", + "when": "Wann", + "during": "Während einer Erkrankung", + "duringNone": "Nicht verknüpft", + "note": "Notiz", + "notePlaceholder": "Was war sonst noch?", + "reset": "Formular zurücksetzen", + "emptyHint": "Leg dein erstes Symptom an, zum Beispiel Aura oder Kopfschmerz.", + "emptyHintNoManage": "Für diesen Datensatz sind noch keine Symptome angelegt.", + "limitHint": "Bis zu {limit} Symptome. Blende auf der Krankheitsseite eins aus, um ein neues anzulegen." + }, + "section": { + "title": "Deine Symptome", + "log": "Erfassen", + "loadError": "Deine Symptome konnten nicht geladen werden.", + "summaryOne": "Einmal in 30 Tagen, {max}/10, zuletzt {date}", + "summaryFew": "{count}-mal in 30 Tagen, bis {max}/10, zuletzt {date}", + "summaryOther": "{count}-mal in 30 Tagen, bis {max}/10, zuletzt {date}", + "summaryNoneRecent": "Nicht in den letzten 30 Tagen, zuletzt {date}", + "summaryNever": "Noch nicht erfasst", + "menuLabel": "Optionen für {symptom}", + "eventMenuLabel": "Optionen für diesen Eintrag {symptom}", + "hide": "Ausblenden", + "show": "Wieder einblenden", + "deleteTitle": "Symptom löschen?", + "deleteBody": "„{symptom}“ und alle Einträge dazu werden gelöscht. Um den Verlauf zu behalten, blende es stattdessen aus.", + "deleteEventTitle": "Eintrag löschen?", + "deleteEventBody": "Dieser Eintrag wird gelöscht.", + "hiddenOne": "Ausgeblendet (1)", + "hiddenFew": "Ausgeblendet ({count})", + "hiddenOther": "Ausgeblendet ({count})", + "recent": "Letzte 30 Tage", + "recentEmpty": "In den letzten 30 Tagen nichts erfasst.", + "editEventTitle": "{symptom} bearbeiten", + "editSymptomTitle": "Symptom bearbeiten" + }, + "episode": { + "title": "Erfasste Symptome", + "filed": "zu dieser Erkrankung erfasst" + } } } diff --git a/messages/en.json b/messages/en.json index d150c222a..e94c08bce 100644 --- a/messages/en.json +++ b/messages/en.json @@ -140,6 +140,18 @@ "unit": { "locked": "This marker already has readings, so its unit can't be changed. The stored values would be shown in the new unit without being converted." } + }, + "symptoms": { + "definition": { + "limitReached": "You have 8 symptoms. Hide one to add another.", + "notFound": "This symptom no longer exists." + }, + "episode": { + "notOpen": "That illness is no longer open. Choose another or none." + }, + "event": { + "notFound": "This entry no longer exists." + } } }, "doctorReport": { @@ -410,7 +422,9 @@ "mood": "Mood", "moodDescription": "Log your mood and tags", "workout": "Workout", - "workoutDescription": "A session your devices did not record" + "workoutDescription": "A session your devices did not record", + "symptom": "Symptom", + "symptomDescription": "One of your own symptoms, with how strong" }, "cycle": "Cycle", "labs": "Labs", @@ -649,7 +663,8 @@ "title": "Back up your encryption key", "body": "Without a copy of the key, your data cannot be recovered after a reinstall. It takes a minute.", "action": "Open the step" - } + }, + "quickAddSymptom": "Log symptom" }, "measurements": { "title": "Measurements", @@ -7478,7 +7493,8 @@ "restoreFailureUnexpected": "The restore stopped on an unexpected error and was rolled back. The server log names the error.", "restoreFailureKeyMissing": "The backup needs encryption key {keys}, which this server cannot open it with. Add the key back to ENCRYPTION_KEYS, restart, and start the restore again.", "restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.", - "previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first." + "previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first.", + "restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)" }, "danger-zone": { "title": "Danger Zone", @@ -11270,7 +11286,13 @@ "documentDiscard": "{name} discarded a document", "medicationIntakeImportFinished": "The dose import {name} started has finished", "medicationOneShotReconciled": "A one-off medication was opened or closed after a dose {name} recorded", - "vaccinationBoosterPlanned": "{name} planned a booster" + "vaccinationBoosterPlanned": "{name} planned a booster", + "symptomEventCreate": "{name} logged a symptom", + "symptomEventUpdate": "{name} edited a symptom entry", + "symptomEventDelete": "{name} deleted a symptom entry", + "symptomDefinitionCreate": "{name} added a symptom", + "symptomDefinitionUpdate": "{name} changed a symptom", + "symptomDefinitionDelete": "{name} removed a symptom" }, "section": { "measurements": { @@ -11501,5 +11523,66 @@ "waitingForWorker": "Waiting for the background worker. If this does not change, the server's worker may not be running.", "errorWorkerUnavailable": "The background worker is not available right now. Try again in a moment, or ask the server operator to check it.", "errorTimedOut": "Reading took longer than your AI response time allows. Try again, or raise the response time in the AI settings." + }, + "symptoms": { + "add": "New symptom", + "nameLabel": "Name", + "namePlaceholder": "e.g. Aura", + "iconLabel": "Icon", + "limitReached": "You have 8 symptoms. Hide one to add another.", + "saveError": "Couldn't save the symptom. Please try again.", + "nameRequired": "Give the symptom a name.", + "unreadableLabel": "Unreadable symptom", + "intensityPill": "{value}/10", + "entry": { + "sheetTitle": "Log symptom", + "sheetDescription": "One of your own symptoms, how strong and when.", + "symptom": "Symptom", + "symptomRequired": "Choose a symptom.", + "intensity": "Intensity", + "intensityRequired": "Set how strong it was.", + "intensityUnset": "Not set", + "anchorLow": "None", + "anchorHigh": "Worst imaginable", + "intensityValueText": "{value} of {max} ({low} to {high})", + "when": "When", + "during": "During an illness", + "duringNone": "Not linked", + "note": "Note", + "notePlaceholder": "What else was going on?", + "reset": "Reset form", + "emptyHint": "Add your first symptom, for example aura or headache.", + "emptyHintNoManage": "No symptoms have been set up for this record yet.", + "limitHint": "Up to {limit} symptoms. Hide one on the illness page to add another." + }, + "section": { + "title": "Your symptoms", + "log": "Log", + "loadError": "Couldn't load your symptoms.", + "summaryOne": "Once in 30 days, {max}/10, last {date}", + "summaryFew": "{count} times in 30 days, up to {max}/10, last {date}", + "summaryOther": "{count} times in 30 days, up to {max}/10, last {date}", + "summaryNoneRecent": "Not in the last 30 days, last {date}", + "summaryNever": "Not logged yet", + "menuLabel": "Options for {symptom}", + "eventMenuLabel": "Options for this {symptom} entry", + "hide": "Hide", + "show": "Show again", + "deleteTitle": "Delete symptom?", + "deleteBody": "\"{symptom}\" and every entry of it will be deleted. To keep the history, hide it instead.", + "deleteEventTitle": "Delete entry?", + "deleteEventBody": "This entry will be deleted.", + "hiddenOne": "Hidden (1)", + "hiddenFew": "Hidden ({count})", + "hiddenOther": "Hidden ({count})", + "recent": "Last 30 days", + "recentEmpty": "Nothing logged in the last 30 days.", + "editEventTitle": "Edit {symptom}", + "editSymptomTitle": "Edit symptom" + }, + "episode": { + "title": "Symptoms logged", + "filed": "logged with this illness" + } } } diff --git a/messages/es.json b/messages/es.json index 490bf1c87..a2fff14e5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -140,6 +140,18 @@ "unit": { "locked": "Este marcador ya tiene valores, así que no puedes cambiar su unidad. Los valores guardados se mostrarían en la nueva unidad sin convertirse." } + }, + "symptoms": { + "definition": { + "limitReached": "Tienes 8 síntomas. Oculta uno para añadir otro.", + "notFound": "Este síntoma ya no existe." + }, + "episode": { + "notOpen": "Esa enfermedad ya no está abierta. Elige otra o ninguna." + }, + "event": { + "notFound": "Esta entrada ya no existe." + } } }, "doctorReport": { @@ -410,7 +422,9 @@ "mood": "Estado de ánimo", "moodDescription": "Registra tu estado de ánimo y etiquetas", "workout": "Entrenamiento", - "workoutDescription": "Una sesión que tus dispositivos no registraron" + "workoutDescription": "Una sesión que tus dispositivos no registraron", + "symptom": "Síntoma", + "symptomDescription": "Uno de tus propios síntomas, con su intensidad" }, "cycle": "Ciclo", "labs": "Análisis", @@ -649,7 +663,8 @@ "title": "Haz una copia de tu clave de cifrado", "body": "Sin una copia de la clave, tus datos no se pueden recuperar tras una reinstalación. Es cuestión de un minuto.", "action": "Abrir el paso" - } + }, + "quickAddSymptom": "Registrar síntoma" }, "measurements": { "title": "Mediciones", @@ -7478,7 +7493,8 @@ "restoreFailureUnexpected": "La restauración se detuvo por un error inesperado y se deshizo. El registro del servidor indica el error.", "restoreFailureKeyMissing": "La copia necesita la clave de cifrado {keys}, con la que este servidor no puede abrirla. Vuelve a añadir la clave a ENCRYPTION_KEYS, reinicia e inicia de nuevo la restauración.", "restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.", - "previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero." + "previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero.", + "restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)" }, "danger-zone": { "title": "Zona de peligro", @@ -11270,7 +11286,13 @@ "documentDiscard": "{name} descartó un documento", "medicationIntakeImportFinished": "La importación de dosis que inició {name} ha terminado", "medicationOneShotReconciled": "Un medicamento de una sola toma se abrió o se cerró tras una dosis que registró {name}", - "vaccinationBoosterPlanned": "{name} planificó un refuerzo" + "vaccinationBoosterPlanned": "{name} planificó un refuerzo", + "symptomEventCreate": "{name} registró un síntoma", + "symptomEventUpdate": "{name} editó una entrada de síntoma", + "symptomEventDelete": "{name} eliminó una entrada de síntoma", + "symptomDefinitionCreate": "{name} añadió un síntoma", + "symptomDefinitionUpdate": "{name} cambió un síntoma", + "symptomDefinitionDelete": "{name} quitó un síntoma" }, "section": { "measurements": { @@ -11501,5 +11523,66 @@ "waitingForWorker": "Esperando al proceso en segundo plano. Si no cambia, puede que el worker del servidor no esté en marcha.", "errorWorkerUnavailable": "El proceso en segundo plano no está disponible ahora mismo. Inténtalo de nuevo en un momento o pide a quien administra el servidor que lo revise.", "errorTimedOut": "La lectura tardó más de lo que permite tu tiempo de respuesta de IA. Inténtalo de nuevo o aumenta el tiempo de respuesta en los ajustes de IA." + }, + "symptoms": { + "add": "Nuevo síntoma", + "nameLabel": "Nombre", + "namePlaceholder": "p. ej. Aura", + "iconLabel": "Icono", + "limitReached": "Tienes 8 síntomas. Oculta uno para añadir otro.", + "saveError": "No se pudo guardar el síntoma. Inténtalo de nuevo.", + "nameRequired": "Ponle un nombre al síntoma.", + "unreadableLabel": "Síntoma ilegible", + "intensityPill": "{value}/10", + "entry": { + "sheetTitle": "Registrar síntoma", + "sheetDescription": "Uno de tus propios síntomas, su intensidad y cuándo.", + "symptom": "Síntoma", + "symptomRequired": "Elige un síntoma.", + "intensity": "Intensidad", + "intensityRequired": "Indica lo fuerte que fue.", + "intensityUnset": "Sin indicar", + "anchorLow": "Ninguna", + "anchorHigh": "La peor imaginable", + "intensityValueText": "{value} de {max} ({low} a {high})", + "when": "Cuándo", + "during": "Durante una enfermedad", + "duringNone": "Sin vincular", + "note": "Nota", + "notePlaceholder": "¿Qué más estaba pasando?", + "reset": "Restablecer formulario", + "emptyHint": "Añade tu primer síntoma, por ejemplo aura o dolor de cabeza.", + "emptyHintNoManage": "Este registro aún no tiene síntomas configurados.", + "limitHint": "Hasta {limit} síntomas. Oculta uno en la página de enfermedades para añadir otro." + }, + "section": { + "title": "Tus síntomas", + "log": "Registrar", + "loadError": "No se pudieron cargar tus síntomas.", + "summaryOne": "Una vez en 30 días, {max}/10, la última {date}", + "summaryFew": "{count} veces en 30 días, hasta {max}/10, la última {date}", + "summaryOther": "{count} veces en 30 días, hasta {max}/10, la última {date}", + "summaryNoneRecent": "No en los últimos 30 días, la última {date}", + "summaryNever": "Aún sin registrar", + "menuLabel": "Opciones de {symptom}", + "eventMenuLabel": "Opciones de esta entrada de {symptom}", + "hide": "Ocultar", + "show": "Mostrar de nuevo", + "deleteTitle": "¿Eliminar síntoma?", + "deleteBody": "Se eliminarán «{symptom}» y todas sus entradas. Para conservar el historial, ocúltalo.", + "deleteEventTitle": "¿Eliminar entrada?", + "deleteEventBody": "Esta entrada se eliminará.", + "hiddenOne": "Ocultos (1)", + "hiddenFew": "Ocultos ({count})", + "hiddenOther": "Ocultos ({count})", + "recent": "Últimos 30 días", + "recentEmpty": "Nada registrado en los últimos 30 días.", + "editEventTitle": "Editar {symptom}", + "editSymptomTitle": "Editar síntoma" + }, + "episode": { + "title": "Síntomas registrados", + "filed": "registrado con esta enfermedad" + } } } diff --git a/messages/fr.json b/messages/fr.json index b1bdd8c8e..572487db8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -140,6 +140,18 @@ "unit": { "locked": "Ce marqueur a déjà des valeurs, son unité ne peut donc pas être modifiée. Les valeurs enregistrées s'afficheraient dans la nouvelle unité sans être converties." } + }, + "symptoms": { + "definition": { + "limitReached": "Tu as 8 symptômes. Masques-en un pour en ajouter un autre.", + "notFound": "Ce symptôme n'existe plus." + }, + "episode": { + "notOpen": "Cette maladie n'est plus ouverte. Choisis-en une autre ou aucune." + }, + "event": { + "notFound": "Cette entrée n'existe plus." + } } }, "doctorReport": { @@ -410,7 +422,9 @@ "mood": "Humeur", "moodDescription": "Enregistrez votre humeur et des étiquettes", "workout": "Entraînement", - "workoutDescription": "Une séance que vos appareils n’ont pas enregistrée" + "workoutDescription": "Une séance que vos appareils n’ont pas enregistrée", + "symptom": "Symptôme", + "symptomDescription": "L'un de tes propres symptômes, avec son intensité" }, "cycle": "Cycle", "labs": "Analyses", @@ -649,7 +663,8 @@ "title": "Sauvegarde ta clé de chiffrement", "body": "Sans copie de la clé, tes données ne peuvent pas être récupérées après une réinstallation. Ça prend une minute.", "action": "Ouvrir l'étape" - } + }, + "quickAddSymptom": "Noter un symptôme" }, "measurements": { "title": "Mesures", @@ -7478,7 +7493,8 @@ "restoreFailureUnexpected": "La restauration s'est arrêtée sur une erreur inattendue et a été annulée. Le journal du serveur indique l'erreur.", "restoreFailureKeyMissing": "La sauvegarde a besoin de la clé de chiffrement {keys}, avec laquelle ce serveur ne peut pas l'ouvrir. Remettez la clé dans ENCRYPTION_KEYS, redémarrez, puis relancez la restauration.", "restoreFailureForeignReference": "Le fichier renvoie à des éléments qui appartiennent à un autre compte. Rien n'a été écrit.", - "previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez." + "previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez.", + "restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)" }, "danger-zone": { "title": "Zone de danger", @@ -11270,7 +11286,13 @@ "documentDiscard": "{name} a écarté un document", "medicationIntakeImportFinished": "L'import de prises lancé par {name} est terminé", "medicationOneShotReconciled": "Un médicament à prise unique a été rouvert ou clos après une prise enregistrée par {name}", - "vaccinationBoosterPlanned": "{name} a planifié un rappel" + "vaccinationBoosterPlanned": "{name} a planifié un rappel", + "symptomEventCreate": "{name} a noté un symptôme", + "symptomEventUpdate": "{name} a modifié une entrée de symptôme", + "symptomEventDelete": "{name} a supprimé une entrée de symptôme", + "symptomDefinitionCreate": "{name} a ajouté un symptôme", + "symptomDefinitionUpdate": "{name} a modifié un symptôme", + "symptomDefinitionDelete": "{name} a retiré un symptôme" }, "section": { "measurements": { @@ -11501,5 +11523,66 @@ "waitingForWorker": "En attente du traitement en arrière-plan. Si rien ne change, le worker du serveur n'est peut-être pas lancé.", "errorWorkerUnavailable": "Le traitement en arrière-plan n'est pas disponible pour le moment. Réessayez dans un instant ou demandez à la personne qui gère le serveur de le vérifier.", "errorTimedOut": "La lecture a pris plus de temps que votre délai de réponse IA ne le permet. Réessayez ou augmentez ce délai dans les réglages IA." + }, + "symptoms": { + "add": "Nouveau symptôme", + "nameLabel": "Nom", + "namePlaceholder": "p. ex. Aura", + "iconLabel": "Icône", + "limitReached": "Tu as 8 symptômes. Masques-en un pour en ajouter un autre.", + "saveError": "Impossible d'enregistrer le symptôme. Réessaie.", + "nameRequired": "Donne un nom au symptôme.", + "unreadableLabel": "Symptôme illisible", + "intensityPill": "{value}/10", + "entry": { + "sheetTitle": "Noter un symptôme", + "sheetDescription": "L'un de tes propres symptômes, son intensité et quand.", + "symptom": "Symptôme", + "symptomRequired": "Choisis un symptôme.", + "intensity": "Intensité", + "intensityRequired": "Indique à quel point c'était fort.", + "intensityUnset": "Non renseignée", + "anchorLow": "Aucune", + "anchorHigh": "La pire imaginable", + "intensityValueText": "{value} sur {max} ({low} à {high})", + "when": "Quand", + "during": "Pendant une maladie", + "duringNone": "Non lié", + "note": "Note", + "notePlaceholder": "Qu'est-ce qui se passait d'autre ?", + "reset": "Réinitialiser le formulaire", + "emptyHint": "Ajoute ton premier symptôme, par exemple aura ou mal de tête.", + "emptyHintNoManage": "Aucun symptôme n'est encore configuré pour ce dossier.", + "limitHint": "Jusqu'à {limit} symptômes. Masques-en un sur la page des maladies pour en ajouter un autre." + }, + "section": { + "title": "Tes symptômes", + "log": "Noter", + "loadError": "Impossible de charger tes symptômes.", + "summaryOne": "Une fois en 30 jours, {max}/10, la dernière {date}", + "summaryFew": "{count} fois en 30 jours, jusqu'à {max}/10, la dernière {date}", + "summaryOther": "{count} fois en 30 jours, jusqu'à {max}/10, la dernière {date}", + "summaryNoneRecent": "Pas dans les 30 derniers jours, la dernière {date}", + "summaryNever": "Pas encore noté", + "menuLabel": "Options pour {symptom}", + "eventMenuLabel": "Options pour cette entrée {symptom}", + "hide": "Masquer", + "show": "Afficher à nouveau", + "deleteTitle": "Supprimer le symptôme ?", + "deleteBody": "« {symptom} » et toutes ses entrées seront supprimés. Pour garder l'historique, masque-le plutôt.", + "deleteEventTitle": "Supprimer l'entrée ?", + "deleteEventBody": "Cette entrée sera supprimée.", + "hiddenOne": "Masqués (1)", + "hiddenFew": "Masqués ({count})", + "hiddenOther": "Masqués ({count})", + "recent": "30 derniers jours", + "recentEmpty": "Rien de noté ces 30 derniers jours.", + "editEventTitle": "Modifier {symptom}", + "editSymptomTitle": "Modifier le symptôme" + }, + "episode": { + "title": "Symptômes notés", + "filed": "noté avec cette maladie" + } } } diff --git a/messages/it.json b/messages/it.json index 4b170bea9..58d510fb7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -140,6 +140,18 @@ "unit": { "locked": "Questo marcatore ha già dei valori, quindi non puoi cambiarne l'unità. I valori salvati verrebbero mostrati nella nuova unità senza essere convertiti." } + }, + "symptoms": { + "definition": { + "limitReached": "Hai 8 sintomi. Nascondine uno per aggiungerne un altro.", + "notFound": "Questo sintomo non esiste più." + }, + "episode": { + "notOpen": "Quella malattia non è più aperta. Scegline un'altra o nessuna." + }, + "event": { + "notFound": "Questa voce non esiste più." + } } }, "doctorReport": { @@ -410,7 +422,9 @@ "mood": "Umore", "moodDescription": "Registra umore e tag", "workout": "Allenamento", - "workoutDescription": "Una sessione che i tuoi dispositivi non hanno registrato" + "workoutDescription": "Una sessione che i tuoi dispositivi non hanno registrato", + "symptom": "Sintomo", + "symptomDescription": "Uno dei tuoi sintomi, con la sua intensità" }, "cycle": "Ciclo", "labs": "Esami", @@ -649,7 +663,8 @@ "title": "Fai un backup della tua chiave di cifratura", "body": "Senza una copia della chiave, i tuoi dati non si possono recuperare dopo una reinstallazione. Ci vuole un minuto.", "action": "Apri il passaggio" - } + }, + "quickAddSymptom": "Registra sintomo" }, "measurements": { "title": "Misurazioni", @@ -7478,7 +7493,8 @@ "restoreFailureUnexpected": "Il ripristino si è fermato per un errore imprevisto ed è stato annullato. Il log del server indica l'errore.", "restoreFailureKeyMissing": "Il backup richiede la chiave di cifratura {keys}, con cui questo server non riesce ad aprirlo. Aggiungi di nuovo la chiave a ENCRYPTION_KEYS, riavvia e avvia di nuovo il ripristino.", "restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.", - "previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia." + "previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia.", + "restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)" }, "danger-zone": { "title": "Zona pericolosa", @@ -11270,7 +11286,13 @@ "documentDiscard": "{name} ha scartato un documento", "medicationIntakeImportFinished": "L'importazione delle dosi avviata da {name} è terminata", "medicationOneShotReconciled": "Un farmaco monodose è stato riaperto o chiuso dopo una dose registrata da {name}", - "vaccinationBoosterPlanned": "{name} ha pianificato un richiamo" + "vaccinationBoosterPlanned": "{name} ha pianificato un richiamo", + "symptomEventCreate": "{name} ha registrato un sintomo", + "symptomEventUpdate": "{name} ha modificato una voce di sintomo", + "symptomEventDelete": "{name} ha eliminato una voce di sintomo", + "symptomDefinitionCreate": "{name} ha aggiunto un sintomo", + "symptomDefinitionUpdate": "{name} ha modificato un sintomo", + "symptomDefinitionDelete": "{name} ha rimosso un sintomo" }, "section": { "measurements": { @@ -11501,5 +11523,66 @@ "waitingForWorker": "In attesa del processo in background. Se non cambia, il worker del server potrebbe non essere attivo.", "errorWorkerUnavailable": "Il processo in background non è disponibile al momento. Riprova tra poco o chiedi a chi gestisce il server di controllarlo.", "errorTimedOut": "La lettura ha richiesto più tempo di quanto consenta il tuo tempo di risposta dell'IA. Riprova o aumenta il tempo di risposta nelle impostazioni IA." + }, + "symptoms": { + "add": "Nuovo sintomo", + "nameLabel": "Nome", + "namePlaceholder": "es. Aura", + "iconLabel": "Icona", + "limitReached": "Hai 8 sintomi. Nascondine uno per aggiungerne un altro.", + "saveError": "Impossibile salvare il sintomo. Riprova.", + "nameRequired": "Dai un nome al sintomo.", + "unreadableLabel": "Sintomo illeggibile", + "intensityPill": "{value}/10", + "entry": { + "sheetTitle": "Registra sintomo", + "sheetDescription": "Uno dei tuoi sintomi, quanto forte e quando.", + "symptom": "Sintomo", + "symptomRequired": "Scegli un sintomo.", + "intensity": "Intensità", + "intensityRequired": "Indica quanto era forte.", + "intensityUnset": "Non impostata", + "anchorLow": "Nessuna", + "anchorHigh": "La peggiore immaginabile", + "intensityValueText": "{value} su {max} (da {low} a {high})", + "when": "Quando", + "during": "Durante una malattia", + "duringNone": "Non collegato", + "note": "Nota", + "notePlaceholder": "Cos'altro stava succedendo?", + "reset": "Reimposta modulo", + "emptyHint": "Aggiungi il tuo primo sintomo, per esempio aura o mal di testa.", + "emptyHintNoManage": "Per questo profilo non sono ancora stati creati sintomi.", + "limitHint": "Fino a {limit} sintomi. Nascondine uno nella pagina delle malattie per aggiungerne un altro." + }, + "section": { + "title": "I tuoi sintomi", + "log": "Registra", + "loadError": "Impossibile caricare i tuoi sintomi.", + "summaryOne": "Una volta in 30 giorni, {max}/10, l'ultima {date}", + "summaryFew": "{count} volte in 30 giorni, fino a {max}/10, l'ultima {date}", + "summaryOther": "{count} volte in 30 giorni, fino a {max}/10, l'ultima {date}", + "summaryNoneRecent": "Non negli ultimi 30 giorni, l'ultima {date}", + "summaryNever": "Non ancora registrato", + "menuLabel": "Opzioni per {symptom}", + "eventMenuLabel": "Opzioni per questa voce di {symptom}", + "hide": "Nascondi", + "show": "Mostra di nuovo", + "deleteTitle": "Eliminare il sintomo?", + "deleteBody": "«{symptom}» e tutte le sue voci verranno eliminati. Per conservare la cronologia, nascondilo invece.", + "deleteEventTitle": "Eliminare la voce?", + "deleteEventBody": "Questa voce verrà eliminata.", + "hiddenOne": "Nascosti (1)", + "hiddenFew": "Nascosti ({count})", + "hiddenOther": "Nascosti ({count})", + "recent": "Ultimi 30 giorni", + "recentEmpty": "Niente registrato negli ultimi 30 giorni.", + "editEventTitle": "Modifica {symptom}", + "editSymptomTitle": "Modifica sintomo" + }, + "episode": { + "title": "Sintomi registrati", + "filed": "registrato con questa malattia" + } } } diff --git a/messages/ko.json b/messages/ko.json index 7879563eb..47f95296b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -140,6 +140,18 @@ "unit": { "locked": "이 마커에는 이미 기록된 값이 있어서 단위를 바꿀 수 없어요. 저장된 값이 변환되지 않은 채 새 단위로 표시되기 때문이에요." } + }, + "symptoms": { + "definition": { + "limitReached": "증상이 8개입니다. 하나를 숨기면 새로 추가할 수 있습니다.", + "notFound": "이 증상은 더 이상 없습니다." + }, + "episode": { + "notOpen": "그 질병은 더 이상 진행 중이 아닙니다. 다른 질병을 선택하거나 선택하지 마세요." + }, + "event": { + "notFound": "이 기록은 더 이상 없습니다." + } } }, "doctorReport": { @@ -410,7 +422,9 @@ "mood": "기분", "moodDescription": "기분과 태그 기록하기", "workout": "운동", - "workoutDescription": "기기가 기록하지 못한 운동" + "workoutDescription": "기기가 기록하지 못한 운동", + "symptom": "증상", + "symptomDescription": "직접 정한 증상과 강도" }, "cycle": "생리 주기", "labs": "검사", @@ -649,7 +663,8 @@ "title": "암호화 키를 백업하세요", "body": "키 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 1분이면 됩니다.", "action": "단계 열기" - } + }, + "quickAddSymptom": "증상 기록" }, "measurements": { "title": "측정", @@ -7457,7 +7472,8 @@ "restoreFailureUnexpected": "예상하지 못한 오류로 복원이 멈췄고 되돌려졌어요. 서버 로그에 오류가 나와 있어요.", "restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.", "restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.", - "previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요." + "previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.", + "restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)" }, "danger-zone": { "title": "위험 구역", @@ -11270,7 +11286,13 @@ "documentDiscard": "{name} 님이 문서를 폐기했어요", "medicationIntakeImportFinished": "{name} 님이 시작한 복용 기록 불러오기가 끝났어요", "medicationOneShotReconciled": "{name} 님이 기록한 복용에 따라 일회성 약이 시작되거나 종료됐어요", - "vaccinationBoosterPlanned": "{name} 님이 추가 접종을 계획했어요" + "vaccinationBoosterPlanned": "{name} 님이 추가 접종을 계획했어요", + "symptomEventCreate": "{name}님이 증상을 기록했습니다", + "symptomEventUpdate": "{name}님이 증상 기록을 편집했습니다", + "symptomEventDelete": "{name}님이 증상 기록을 삭제했습니다", + "symptomDefinitionCreate": "{name}님이 증상을 추가했습니다", + "symptomDefinitionUpdate": "{name}님이 증상을 변경했습니다", + "symptomDefinitionDelete": "{name}님이 증상을 제거했습니다" }, "section": { "measurements": { @@ -11501,5 +11523,66 @@ "waitingForWorker": "백그라운드 작업자를 기다리고 있어요. 계속 그대로라면 서버의 작업자가 실행 중이 아닐 수 있어요.", "errorWorkerUnavailable": "지금은 백그라운드 작업자를 사용할 수 없어요. 잠시 후 다시 시도하거나 서버 운영자에게 확인을 요청하세요.", "errorTimedOut": "AI 응답 시간 설정보다 읽기가 오래 걸렸어요. 다시 시도하거나 AI 설정에서 응답 시간을 늘려 주세요." + }, + "symptoms": { + "add": "새 증상", + "nameLabel": "이름", + "namePlaceholder": "예: 전조", + "iconLabel": "아이콘", + "limitReached": "증상이 8개입니다. 하나를 숨기면 새로 추가할 수 있습니다.", + "saveError": "증상을 저장하지 못했습니다. 다시 시도하세요.", + "nameRequired": "증상 이름을 입력하세요.", + "unreadableLabel": "읽을 수 없는 증상", + "intensityPill": "{value}/10", + "entry": { + "sheetTitle": "증상 기록", + "sheetDescription": "직접 정한 증상과 강도, 시각을 기록합니다.", + "symptom": "증상", + "symptomRequired": "증상을 선택하세요.", + "intensity": "강도", + "intensityRequired": "얼마나 심했는지 정하세요.", + "intensityUnset": "미설정", + "anchorLow": "없음", + "anchorHigh": "상상할 수 있는 최악", + "intensityValueText": "{max} 중 {value} ({low}부터 {high}까지)", + "when": "시각", + "during": "질병 기간 중", + "duringNone": "연결 안 함", + "note": "메모", + "notePlaceholder": "그 밖에 어떤 일이 있었나요?", + "reset": "양식 초기화", + "emptyHint": "첫 증상을 추가하세요. 예: 전조, 두통.", + "emptyHintNoManage": "이 기록에는 아직 설정된 증상이 없습니다.", + "limitHint": "증상은 최대 {limit}개입니다. 질병 페이지에서 하나를 숨기면 새로 추가할 수 있습니다." + }, + "section": { + "title": "내 증상", + "log": "기록", + "loadError": "증상을 불러오지 못했습니다.", + "summaryOne": "30일 동안 1회, {max}/10, 마지막 {date}", + "summaryFew": "30일 동안 {count}회, 최대 {max}/10, 마지막 {date}", + "summaryOther": "30일 동안 {count}회, 최대 {max}/10, 마지막 {date}", + "summaryNoneRecent": "최근 30일 동안 없음, 마지막 {date}", + "summaryNever": "아직 기록 없음", + "menuLabel": "{symptom} 옵션", + "eventMenuLabel": "이 {symptom} 기록의 옵션", + "hide": "숨기기", + "show": "다시 표시", + "deleteTitle": "증상을 삭제할까요?", + "deleteBody": "\"{symptom}\"과 모든 기록이 삭제됩니다. 기록을 남기려면 대신 숨기세요.", + "deleteEventTitle": "기록을 삭제할까요?", + "deleteEventBody": "이 기록이 삭제됩니다.", + "hiddenOne": "숨김 (1)", + "hiddenFew": "숨김 ({count})", + "hiddenOther": "숨김 ({count})", + "recent": "최근 30일", + "recentEmpty": "최근 30일 동안 기록이 없습니다.", + "editEventTitle": "{symptom} 편집", + "editSymptomTitle": "증상 편집" + }, + "episode": { + "title": "기록된 증상", + "filed": "이 질병과 함께 기록됨" + } } } diff --git a/messages/pl.json b/messages/pl.json index 0c30434c6..3cee9f5c9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -140,6 +140,18 @@ "unit": { "locked": "Ten marker ma już wyniki, więc nie możesz zmienić jego jednostki. Zapisane wartości byłyby pokazywane w nowej jednostce bez przeliczenia." } + }, + "symptoms": { + "definition": { + "limitReached": "Masz 8 objawów. Ukryj jeden, aby dodać kolejny.", + "notFound": "Ten objaw już nie istnieje." + }, + "episode": { + "notOpen": "Ta choroba nie jest już otwarta. Wybierz inną lub żadną." + }, + "event": { + "notFound": "Ten wpis już nie istnieje." + } } }, "doctorReport": { @@ -410,7 +422,9 @@ "mood": "Nastrój", "moodDescription": "Zapisz nastrój i tagi", "workout": "Trening", - "workoutDescription": "Sesja, której nie zapisały twoje urządzenia" + "workoutDescription": "Sesja, której nie zapisały twoje urządzenia", + "symptom": "Objaw", + "symptomDescription": "Jeden z twoich objawów, z nasileniem" }, "cycle": "Cykl", "labs": "Wyniki badań", @@ -649,7 +663,8 @@ "title": "Zrób kopię klucza szyfrowania", "body": "Bez kopii klucza Twoich danych nie da się odzyskać po ponownej instalacji. To zajmie minutę.", "action": "Otwórz krok" - } + }, + "quickAddSymptom": "Zapisz objaw" }, "measurements": { "title": "Pomiary", @@ -7478,7 +7493,8 @@ "restoreFailureUnexpected": "Przywracanie zatrzymało się na nieoczekiwanym błędzie i zostało wycofane. Dziennik serwera podaje błąd.", "restoreFailureKeyMissing": "Kopia wymaga klucza szyfrowania {keys}, którym ten serwer nie może jej otworzyć. Dodaj klucz z powrotem do ENCRYPTION_KEYS, uruchom ponownie i rozpocznij przywracanie jeszcze raz.", "restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.", - "previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie." + "previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie.", + "restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)" }, "danger-zone": { "title": "Strefa zagrożenia", @@ -11270,7 +11286,13 @@ "documentDiscard": "{name} odrzucił dokument", "medicationIntakeImportFinished": "Import przyjęć rozpoczęty przez {name} zakończył się", "medicationOneShotReconciled": "Lek jednorazowy został otwarty lub zamknięty po dawce zapisanej przez {name}", - "vaccinationBoosterPlanned": "{name} zaplanował dawkę przypominającą" + "vaccinationBoosterPlanned": "{name} zaplanował dawkę przypominającą", + "symptomEventCreate": "{name} zapisał(a) objaw", + "symptomEventUpdate": "{name} edytował(a) wpis objawu", + "symptomEventDelete": "{name} usunął(-ęła) wpis objawu", + "symptomDefinitionCreate": "{name} dodał(a) objaw", + "symptomDefinitionUpdate": "{name} zmienił(a) objaw", + "symptomDefinitionDelete": "{name} usunął(-ęła) objaw" }, "section": { "measurements": { @@ -11501,5 +11523,66 @@ "waitingForWorker": "Oczekiwanie na proces w tle. Jeśli nic się nie zmienia, worker na serwerze może nie działać.", "errorWorkerUnavailable": "Proces w tle jest teraz niedostępny. Spróbuj ponownie za chwilę albo poproś administratora serwera o sprawdzenie go.", "errorTimedOut": "Odczyt trwał dłużej, niż pozwala ustawiony czas odpowiedzi AI. Spróbuj ponownie albo wydłuż czas odpowiedzi w ustawieniach AI." + }, + "symptoms": { + "add": "Nowy objaw", + "nameLabel": "Nazwa", + "namePlaceholder": "np. Aura", + "iconLabel": "Ikona", + "limitReached": "Masz 8 objawów. Ukryj jeden, aby dodać kolejny.", + "saveError": "Nie udało się zapisać objawu. Spróbuj ponownie.", + "nameRequired": "Nadaj objawowi nazwę.", + "unreadableLabel": "Nieczytelny objaw", + "intensityPill": "{value}/10", + "entry": { + "sheetTitle": "Zapisz objaw", + "sheetDescription": "Jeden z twoich objawów, jak silny i kiedy.", + "symptom": "Objaw", + "symptomRequired": "Wybierz objaw.", + "intensity": "Nasilenie", + "intensityRequired": "Określ, jak silny był.", + "intensityUnset": "Nie ustawiono", + "anchorLow": "Brak", + "anchorHigh": "Najgorszy wyobrażalny", + "intensityValueText": "{value} z {max} ({low} do {high})", + "when": "Kiedy", + "during": "W trakcie choroby", + "duringNone": "Bez powiązania", + "note": "Notatka", + "notePlaceholder": "Co jeszcze się działo?", + "reset": "Wyczyść formularz", + "emptyHint": "Dodaj swój pierwszy objaw, na przykład aurę lub ból głowy.", + "emptyHintNoManage": "Dla tego profilu nie ma jeszcze objawów.", + "limitHint": "Do {limit} objawów. Ukryj jeden na stronie chorób, aby dodać kolejny." + }, + "section": { + "title": "Twoje objawy", + "log": "Zapisz", + "loadError": "Nie udało się wczytać twoich objawów.", + "summaryOne": "Raz w ciągu 30 dni, {max}/10, ostatnio {date}", + "summaryFew": "{count} razy w ciągu 30 dni, do {max}/10, ostatnio {date}", + "summaryOther": "{count} razy w ciągu 30 dni, do {max}/10, ostatnio {date}", + "summaryNoneRecent": "Nie w ostatnich 30 dniach, ostatnio {date}", + "summaryNever": "Jeszcze nie zapisano", + "menuLabel": "Opcje dla {symptom}", + "eventMenuLabel": "Opcje tego wpisu: {symptom}", + "hide": "Ukryj", + "show": "Pokaż ponownie", + "deleteTitle": "Usunąć objaw?", + "deleteBody": "„{symptom}” i wszystkie jego wpisy zostaną usunięte. Aby zachować historię, ukryj go.", + "deleteEventTitle": "Usunąć wpis?", + "deleteEventBody": "Ten wpis zostanie usunięty.", + "hiddenOne": "Ukryte (1)", + "hiddenFew": "Ukryte ({count})", + "hiddenOther": "Ukryte ({count})", + "recent": "Ostatnie 30 dni", + "recentEmpty": "Nic nie zapisano w ostatnich 30 dniach.", + "editEventTitle": "Edytuj: {symptom}", + "editSymptomTitle": "Edytuj objaw" + }, + "episode": { + "title": "Zapisane objawy", + "filed": "zapisano przy tej chorobie" + } } } diff --git a/prisma/migrations/0369_symptom_definitions_and_events/migration.sql b/prisma/migrations/0369_symptom_definitions_and_events/migration.sql new file mode 100644 index 000000000..a6dc2d55a --- /dev/null +++ b/prisma/migrations/0369_symptom_definitions_and_events/migration.sql @@ -0,0 +1,73 @@ +-- Person-defined symptoms and their timestamped events. A definition is the +-- person's own name for a symptom (encrypted); an event is one occurrence with +-- a 0-10 intensity and an optional link to an illness episode. Idempotent, so +-- a re-run of a half-applied migration converges. +CREATE TABLE IF NOT EXISTS "symptom_definitions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "label_encrypted" BYTEA NOT NULL, + "icon" TEXT, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "symptom_definitions_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "symptom_definitions_user_id_sort_order_idx" + ON "symptom_definitions" ("user_id", "sort_order"); + +DO $$ BEGIN + ALTER TABLE "symptom_definitions" + ADD CONSTRAINT "symptom_definitions_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS "symptom_events" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "definition_id" TEXT NOT NULL, + "occurred_at" TIMESTAMP(3) NOT NULL, + "intensity" INTEGER NOT NULL, + "note_encrypted" BYTEA, + "episode_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "symptom_events_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "symptom_events_user_id_occurred_at_idx" + ON "symptom_events" ("user_id", "occurred_at"); +CREATE INDEX IF NOT EXISTS "symptom_events_definition_id_occurred_at_idx" + ON "symptom_events" ("definition_id", "occurred_at"); +CREATE INDEX IF NOT EXISTS "symptom_events_episode_id_idx" + ON "symptom_events" ("episode_id"); + +DO $$ BEGIN + ALTER TABLE "symptom_events" + ADD CONSTRAINT "symptom_events_intensity_check" + CHECK ("intensity" >= 0 AND "intensity" <= 10); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "symptom_events" + ADD CONSTRAINT "symptom_events_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "symptom_events" + ADD CONSTRAINT "symptom_events_definition_id_fkey" + FOREIGN KEY ("definition_id") REFERENCES "symptom_definitions"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "symptom_events" + ADD CONSTRAINT "symptom_events_episode_id_fkey" + FOREIGN KEY ("episode_id") REFERENCES "illness_episodes"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6db2a6b14..840c15b49 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -911,6 +911,8 @@ model User { // per-day timeline mirroring the CycleDayLog shape. illnessEpisodes IllnessEpisode[] illnessDayLogs IllnessDayLog[] + symptomDefinitions SymptomDefinition[] + symptomEvents SymptomEvent[] // v1.25 (W-RECORDS) — structured health-record entries. Allergies map to // FHIR AllergyIntolerance; family-history entries map to FHIR // FamilyMemberHistory. Reference records (not time-series signals); the @@ -7726,6 +7728,8 @@ model IllnessEpisode { dayLogs IllnessDayLog[] documentLinks DocumentConditionLink[] encounterLinks EncounterConditionLink[] + /// Symptom events the person filed against this episode while it was open. + symptomEvents SymptomEvent[] @@index([userId, deletedAt]) @@index([userId, onsetAt]) @@ -7814,6 +7818,68 @@ model IllnessSymptomLink { @@map("illness_symptom_links") } +/// A symptom the person defined for themselves ("aura", "headache"), v1.40. +/// +/// Separate from the seeded `IllnessSymptom` catalogue on the illness day log: +/// that one is a fixed list of cold symptoms rated 1-3 per episode day, this one +/// is the person's own vocabulary, logged as timestamped events with a 0-10 +/// intensity whether or not an episode is open. Rides the `illness` module. +/// At most eight active definitions per account +/// (`MAX_ACTIVE_SYMPTOM_DEFINITIONS`), and each active one is its own +/// correlation channel `SYMPTOM:`, so the cap also bounds the matrix. +model SymptomDefinition { + id String @id @default(cuid()) + userId String @map("user_id") + /// The person's own name for the symptom, AES-256-GCM encrypted at rest. A + /// symptom name is health data in the person's own words. + labelEncrypted Bytes @map("label_encrypted") + /// Lucide icon name from `SYMPTOM_ICON_ALLOWLIST` (the cycle custom-symptom + /// list, which the iOS client already maps). NULL = the default glyph. + icon String? + sortOrder Int @default(0) @map("sort_order") + /// FALSE = hidden: not offered in the quick entry, not a correlation + /// channel, history kept. Does not count toward the cap. + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + events SymptomEvent[] + + @@index([userId, sortOrder]) + @@map("symptom_definitions") +} + +/// One occurrence of a person-defined symptom: when, how strong (0-10, the +/// same numeric rating scale the pain score uses), an optional note, and an +/// optional link to the illness episode it happened during. v1.40. +model SymptomEvent { + id String @id @default(cuid()) + userId String @map("user_id") + definitionId String @map("definition_id") + /// The instant it happened (UTC). Day grouping is the reader's concern. + occurredAt DateTime @map("occurred_at") + /// 0-10, CHECK-constrained in the migration. + intensity Int + /// Optional free-text note, AES-256-GCM encrypted at rest. + noteEncrypted Bytes? @map("note_encrypted") + /// The episode the person said this happened during. Only an open episode + /// of the same account can be linked (the route checks); deleting the + /// episode row keeps the event and drops the link. + episodeId String? @map("episode_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + definition SymptomDefinition @relation(fields: [definitionId], references: [id], onDelete: Cascade) + episode IllnessEpisode? @relation(fields: [episodeId], references: [id], onDelete: SetNull) + + @@index([userId, occurredAt]) + @@index([definitionId, occurredAt]) + @@index([episodeId]) + @@map("symptom_events") +} + // ─── Structured health records (v1.25 W-RECORDS) ────────────────── // // Allergies + family history kept as STRUCTURED records (not time-series diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index a814f2365..a3cd1742a 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -594,6 +594,21 @@ async function main() { prisma.illnessDayLog, ), ); + // v1.40 — person-defined symptoms: the name and the per-occurrence note. + results.push( + await rotateBytesColumn( + "SymptomDefinition", + "labelEncrypted", + prisma.symptomDefinition, + ), + ); + results.push( + await rotateBytesColumn( + "SymptomEvent", + "noteEncrypted", + prisma.symptomEvent, + ), + ); // ───── v1.19.0 ECG waveform (Bytes column) ───── // "waveformEncrypted" holds the JSON-encoded micro-volt sample array in the diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 49b4bf75f..14d7e3f44 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -331,6 +331,14 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "restorable", confirm: ["AlertDialog"], }, + { + file: "components/symptoms/use-symptoms.ts", + destroys: + "one logged symptom entry, or a person-defined symptom with every entry of it", + triggers: ["components/symptoms/symptoms-section.tsx"], + recovery: "permanent", + confirm: ["AlertDialog"], + }, { file: "components/labs/biomarker-manager.tsx", destroys: "a biomarker AND every lab reading recorded against it", diff --git a/src/__tests__/discovery-matrix-guard.test.ts b/src/__tests__/discovery-matrix-guard.test.ts index ca4d3c9f2..128b5e8ac 100644 --- a/src/__tests__/discovery-matrix-guard.test.ts +++ b/src/__tests__/discovery-matrix-guard.test.ts @@ -91,6 +91,7 @@ describe("T1 — only the assembler fetches discovery channels", () => { "fetchSymptomSeries", "fetchEnvironmentSeries", "fetchCustomMetricBehaviourSeries", + "fetchSymptomEventSeries", "fetchMoodWindowSeries", "fetchMoodFactorWindowSeries", "fetchMeasurementDailySeriesTiered", diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 9a5e17cd0..e13fa70b6 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -991,6 +991,22 @@ const DELEGABLE_ROUTES: Record = { domain: "illness", why: "The day logs of one episode. The episode-scoped queries are safe because `loadOwnedEpisode(id, user.id)` runs first and now resolves against the record; the upsert arm keeps `requireAuth()`.", }, + "app/api/symptoms/definitions/route.ts": { + domain: "illness", + why: "The record's own symptom list. Module-gated on the record on both arms and scoped `userId: user.id`; the create arm is MANAGE (see the manage literal), because the vocabulary is the owner's and a delegate who may log an occurrence picks from it.", + }, + "app/api/symptoms/definitions/[id]/route.ts": { + domain: "illness", + why: "One symptom definition of the record, fetch-then-guard against the resolved user. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line.", + }, + "app/api/symptoms/events/route.ts": { + domain: "illness", + why: "The record's symptom occurrences. Module-gated on the record; the definition and the optional episode a create names are both re-narrowed to the resolved user before anything is written, so a delegate cannot file one record's occurrence under another's episode. The create arm is a delegable write.", + }, + "app/api/symptoms/events/[id]/route.ts": { + domain: "illness", + why: "One symptom occurrence of the record, fetch-then-guard against the resolved user. Reached only through this module's MANAGE arm.", + }, "app/api/workouts/route.ts": { domain: "measurements", why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", @@ -1427,6 +1443,8 @@ const DELEGABLE_WRITE_ROUTES: Record = { "Adding an analyte to the record's catalogue. A name the record already tracks is the ordinary 409 from the same `(userId, name)` uniqueness the owner would hit themselves.", "app/api/illness/episodes/route.ts": "Opening an illness episode. The module gate runs against the RECORD before the write, so a delegate cannot create an episode inside a record whose owner switched the module off.", + "app/api/symptoms/events/route.ts": + "Logging a symptom occurrence. The definition and the episode the body names are looked up under the RECORD, and an episode must still be open, so a delegate cannot attach an occurrence to an episode the owner closed or to another record's.", "app/api/encounters/route.ts": "Filing a visit, or booking one. Every id the body may carry — the practitioner, the checkup it closes, the three link arrays — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's document to another's visit. Booking mints the appointment reminder under the RECORD, which is correct: it is the owner's appointment and the owner's channels that should be nudged about it.", "app/api/practitioners/route.ts": @@ -1596,6 +1614,21 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: ["C3"], why: "Writing a day of an episode. The upsert revives a tombstoned day and replaces that day's symptom links, so C3 names the date, the previous symptom set, and the revival.", }, + "app/api/symptoms/definitions/route.ts": { + domain: "illness", + conditions: ["C1"], + why: "Defining a symptom on the record. Capped at eight active ones; C1 so a manager burns their own bucket rather than the owner's.", + }, + "app/api/symptoms/definitions/[id]/route.ts": { + domain: "illness", + conditions: ["C1", "C3", "C4"], + why: "Renaming, hiding and deleting a symptom. Hiding keeps the history; the purge is hard and takes the occurrences with it, so C3 names the definition and the count. C4 on the edit, over the plaintext fields only; a rename is named, never quoted. C1 on the bucket.", + }, + "app/api/symptoms/events/[id]/route.ts": { + domain: "illness", + conditions: ["C3", "C4"], + why: "Correcting and removing one occurrence. The delete is hard, so C3 carries its time and intensity; C4 on the edit, the encrypted note named and never quoted.", + }, "app/api/mood-entries/route.ts": { domain: "mind", conditions: ["C6"], @@ -1960,8 +1993,13 @@ const ACTOR_ROUTES: Record = { * v1.39.2 -- the body-site view adds one: a read on the record list in the * visits' domain, with conditions joined only under an illness grant. * 231 -> 232. + * + * v1.40 -- person-defined symptoms add eight: four modules on the record + * list, the occurrence create on the write literal, and the definition create, + * the definition edit/delete and the occurrence edit/delete on the manage + * literal. 232 -> 240. */ -const FROZEN_ENTRY_COUNT = 232; +const FROZEN_ENTRY_COUNT = 240; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2722,7 +2760,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(77); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(83); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 383bf15d3..e27f224c3 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -289,6 +289,9 @@ const PINNED_AFFORDANCES: Record< "src/components/mood/manage/tag-groups-card.tsx": { "toast.success": 4 }, "src/components/mood/manage/tag-manager-card.tsx": { "toast.success": 2 }, "src/components/mood/mood-form.tsx": { "toast.success": 1 }, + // The symptom quick entry confirms a save exactly as the measurement and + // mood forms beside it do. + "src/components/symptoms/symptom-entry-form.tsx": { "toast.success": 1 }, "src/components/mood/mood-list.tsx": { "toast.success": 3 }, "src/components/onboarding/done-screen.tsx": { CheckCircle2: 2 }, "src/components/records/allergy-form.tsx": { "toast.success": 1 }, diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 34bfae2db..f775c3e5d 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(77); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(83); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index a6a01a3e6..70aef1269 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -71,6 +71,7 @@ vi.mock("@/lib/db", () => ({ documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, extractedFact: { findMany: vi.fn().mockResolvedValue([]) }, ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, + symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -163,6 +164,7 @@ beforeEach(() => { vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never); vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never); vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never); vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue( diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 45cb98d38..08c17d48f 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -72,6 +72,7 @@ vi.mock("@/lib/db", () => ({ documentConditionLink: { findMany: vi.fn() }, extractedFact: { findMany: vi.fn() }, ecgRecording: { findMany: vi.fn() }, + symptomDefinition: { findMany: vi.fn() }, onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) }, environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, @@ -208,6 +209,7 @@ beforeEach(() => { ); vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never); vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never); vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never); }); diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 3df29eb17..7585249ba 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -63,6 +63,7 @@ vi.mock("@/lib/db", () => ({ documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, extractedFact: { findMany: vi.fn().mockResolvedValue([]) }, ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, + symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 47fa6a4a2..42413f24f 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -31,6 +31,7 @@ vi.mock("@/lib/db", () => ({ // channel produces a point. environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([]) }, + symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -153,6 +154,7 @@ beforeEach(() => { prisma.environmentContext.findMany as ReturnType ).mockResolvedValue([]); vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never); }); const callGet = GET as unknown as (req: NextRequest) => Promise; diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index f93c5262f..c94021409 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -236,6 +236,7 @@ async function buildCorrelationsResponse( // available for the scan. environment_days: diagnostics.environmentDays, custom_metric_channels: diagnostics.customMetricChannels, + symptom_event_channels: diagnostics.symptomEventChannels, custom_metric_days: diagnostics.customMetricDays, }, }); diff --git a/src/app/api/symptoms/definitions/[id]/route.ts b/src/app/api/symptoms/definitions/[id]/route.ts new file mode 100644 index 000000000..e578f4a30 --- /dev/null +++ b/src/app/api/symptoms/definitions/[id]/route.ts @@ -0,0 +1,232 @@ +/** + * One person-defined symptom (v1.40). + * + * PATCH /api/symptoms/definitions/{id} — rename, change the icon, reorder, + * hide or show again. Showing a hidden one re-clears the cap. + * DELETE /api/symptoms/definitions/{id} — hide by default (history kept); + * `?purge=true` deletes the definition and every event of it. + * + * Owner-scoped: another account's id is a 404. Module-gated on the record. + * The label is never logged. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + destroyedDetails, + overwriteDetails, +} from "@/lib/sharing/audit-details"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { + encryptSymptomText, + symptomDefinitionUpdateSchema, + toSymptomDefinitionDTO, + MAX_ACTIVE_SYMPTOM_DEFINITIONS, +} from "@/lib/symptoms/server"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +function notFound(): Response { + return apiError("Symptom not found", 404, { + errorCode: "symptoms.definition.notFound", + }); +} + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user, actor } = await requireRecordAuth("manage", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `symptoms:definition:${actor.id}`, + 30, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many requests, try again later", 429); + } + + const { id } = await params; + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = symptomDefinitionUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "symptoms.invalid", + }); + } + + const existing = await prisma.symptomDefinition.findFirst({ + where: { id, userId: user.id }, + select: { id: true, icon: true, isActive: true, sortOrder: true }, + }); + if (!existing) return notFound(); + + // A hidden definition does not hold a slot; showing it again takes one. + if (parsed.data.isActive === true && !existing.isActive) { + const activeCount = await prisma.symptomDefinition.count({ + where: { userId: user.id, isActive: true }, + }); + if (activeCount >= MAX_ACTIVE_SYMPTOM_DEFINITIONS) { + return apiError( + `Symptom limit reached (${MAX_ACTIVE_SYMPTOM_DEFINITIONS})`, + 422, + { + errorCode: "symptoms.definition.limitReached", + limit: MAX_ACTIVE_SYMPTOM_DEFINITIONS, + }, + ); + } + } + + const updated = await prisma.symptomDefinition.update({ + where: { id: existing.id }, + data: { + ...(parsed.data.label !== undefined + ? { labelEncrypted: encryptSymptomText(parsed.data.label) } + : {}), + ...(parsed.data.icon !== undefined ? { icon: parsed.data.icon } : {}), + ...(parsed.data.isActive !== undefined + ? { isActive: parsed.data.isActive } + : {}), + ...(parsed.data.sortOrder !== undefined + ? { sortOrder: parsed.data.sortOrder } + : {}), + }, + select: { + id: true, + labelEncrypted: true, + icon: true, + sortOrder: true, + isActive: true, + }, + }); + + await auditLog("symptoms.definition.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + definitionId: id, + ...overwriteDetails({ + before: { + icon: existing.icon, + isActive: existing.isActive, + sortOrder: existing.sortOrder, + }, + after: { + icon: updated.icon, + isActive: updated.isActive, + sortOrder: updated.sortOrder, + }, + redacted: parsed.data.label !== undefined ? ["label"] : [], + }), + }, + }); + + // Hiding or showing one changes the correlation channel set and the + // Coach's symptom block. + if (parsed.data.isActive !== undefined) + invalidateUserHealthContext(user.id); + + annotate({ + action: { + name: "symptoms.definition.update", + entity_type: "symptom_definition", + entity_id: id, + }, + }); + + return apiSuccess(toSymptomDefinitionDTO(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user, actor } = await requireRecordAuth("manage", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `symptoms:definition:${actor.id}`, + 30, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many requests, try again later", 429); + } + + const { id } = await params; + const existing = await prisma.symptomDefinition.findFirst({ + where: { id, userId: user.id }, + select: { + id: true, + createdAt: true, + _count: { select: { events: true } }, + }, + }); + if (!existing) return notFound(); + + const purge = request.nextUrl.searchParams.get("purge") === "true"; + if (purge) { + // The FK cascade removes the events with it. + await prisma.symptomDefinition.delete({ where: { id: existing.id } }); + } else { + await prisma.symptomDefinition.update({ + where: { id: existing.id }, + data: { isActive: false }, + }); + } + + await auditLog("symptoms.definition.delete", { + userId: user.id, + ipAddress: getClientIp(request), + // C3 on the purge arm: the definition and its events are gone, so the + // audit row names what went. The label stays out (the person's words). + details: purge + ? destroyedDetails({ + model: "SymptomDefinition", + id, + label: null, + effectiveAt: existing.createdAt, + extra: { eventsDeleted: existing._count.events }, + }) + : { definitionId: id, purge }, + }); + + invalidateUserHealthContext(user.id); + + annotate({ + action: { + name: "symptoms.definition.delete", + entity_type: "symptom_definition", + entity_id: id, + }, + meta: { purge }, + }); + + return apiSuccess({ + deleted: true, + purged: purge, + eventsDeleted: purge ? existing._count.events : 0, + }); + }, +); diff --git a/src/app/api/symptoms/definitions/route.ts b/src/app/api/symptoms/definitions/route.ts new file mode 100644 index 000000000..ddcc7df04 --- /dev/null +++ b/src/app/api/symptoms/definitions/route.ts @@ -0,0 +1,139 @@ +/** + * Person-defined symptoms (v1.40). + * + * GET /api/symptoms/definitions — the record's own symptom list with a + * 30-day summary per symptom. `?includeHidden=true` adds hidden ones. + * POST /api/symptoms/definitions — define one (`{ label, icon? }`). At most + * eight active definitions; the label is encrypted at rest. + * + * Rides the illness module: an account (or instance) with it off answers 403 + * `illness.disabled` even to a valid Bearer token. The label is the person's + * own words for a health complaint, so it never reaches an audit row or a + * wide event. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { withIdempotency } from "@/lib/idempotency"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { + encryptSymptomText, + listSymptomDefinitions, + symptomDefinitionCreateSchema, + toSymptomDefinitionDTO, + MAX_ACTIVE_SYMPTOM_DEFINITIONS, +} from "@/lib/symptoms/server"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const includeHidden = + request.nextUrl.searchParams.get("includeHidden") === "true"; + const definitions = await listSymptomDefinitions(user.id, { includeHidden }); + + annotate({ + action: { name: "symptoms.definition.list" }, + meta: { count: definitions.length, includeHidden }, + }); + + return apiSuccess({ definitions, limit: MAX_ACTIVE_SYMPTOM_DEFINITIONS }); +}); + +/** + * Wrapped in `withIdempotency`: the label is ciphertext under a fresh IV, so + * no unique index can catch a replayed create, and a retry after a lost + * response would otherwise mint a second "aura" and burn a slot of eight. + */ +export const POST = apiHandler(withIdempotency<[NextRequest]>(postDefinition)); + +async function postDefinition(request: NextRequest): Promise { + // MANAGE, like the cycle module's own symptom list: the vocabulary of a + // record is the owner's, a delegate who may log an occurrence picks from it. + const { user, actor } = await requireRecordAuth("manage", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `symptoms:definition:${actor.id}`, + 30, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many requests, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = symptomDefinitionCreateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "symptoms.invalid", + }); + } + + const activeCount = await prisma.symptomDefinition.count({ + where: { userId: user.id, isActive: true }, + }); + if (activeCount >= MAX_ACTIVE_SYMPTOM_DEFINITIONS) { + return apiError( + `Symptom limit reached (${MAX_ACTIVE_SYMPTOM_DEFINITIONS})`, + 422, + { + errorCode: "symptoms.definition.limitReached", + limit: MAX_ACTIVE_SYMPTOM_DEFINITIONS, + }, + ); + } + + const created = await prisma.symptomDefinition.create({ + data: { + userId: user.id, + labelEncrypted: encryptSymptomText(parsed.data.label), + icon: parsed.data.icon ?? null, + sortOrder: activeCount, + }, + select: { + id: true, + labelEncrypted: true, + icon: true, + sortOrder: true, + isActive: true, + }, + }); + + await auditLog("symptoms.definition.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { definitionId: created.id, icon: created.icon }, + }); + + annotate({ + action: { + name: "symptoms.definition.create", + entity_type: "symptom_definition", + entity_id: created.id, + }, + meta: { icon: created.icon }, + }); + + return apiSuccess(toSymptomDefinitionDTO(created), 201); +} diff --git a/src/app/api/symptoms/events/[id]/route.ts b/src/app/api/symptoms/events/[id]/route.ts new file mode 100644 index 000000000..82928b38a --- /dev/null +++ b/src/app/api/symptoms/events/[id]/route.ts @@ -0,0 +1,192 @@ +/** + * One occurrence of a person-defined symptom (v1.40). + * + * PATCH /api/symptoms/events/{id} — change the time, intensity, note or + * episode link. A new link must name an open episode of the record; + * an unchanged link to an episode that has since resolved is kept. + * DELETE /api/symptoms/events/{id} — remove it. + * + * Owner-scoped (another account's id is a 404), module-gated on the record, + * MANAGE on a shared record. The note is never logged. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + destroyedDetails, + overwriteDetails, +} from "@/lib/sharing/audit-details"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { + encryptSymptomText, + isLinkableEpisode, + symptomEventUpdateSchema, + toSymptomEventDTO, +} from "@/lib/symptoms/server"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +function notFound(): Response { + return apiError("Symptom event not found", 404, { + errorCode: "symptoms.event.notFound", + }); +} + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = symptomEventUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "symptoms.invalid", + }); + } + const entry = parsed.data; + + const existing = await prisma.symptomEvent.findFirst({ + where: { id, userId: user.id }, + select: { + id: true, + occurredAt: true, + intensity: true, + episodeId: true, + }, + }); + if (!existing) return notFound(); + + if ( + entry.episodeId && + entry.episodeId !== existing.episodeId && + !(await isLinkableEpisode(user.id, entry.episodeId)) + ) { + return apiError("Episode is not open", 422, { + errorCode: "symptoms.episode.notOpen", + }); + } + + const updated = await prisma.symptomEvent.update({ + where: { id: existing.id }, + data: { + ...(entry.occurredAt !== undefined + ? { occurredAt: entry.occurredAt } + : {}), + ...(entry.intensity !== undefined + ? { intensity: entry.intensity } + : {}), + ...(entry.note !== undefined + ? { + noteEncrypted: entry.note ? encryptSymptomText(entry.note) : null, + } + : {}), + ...(entry.episodeId !== undefined + ? { episodeId: entry.episodeId ?? null } + : {}), + }, + }); + + await auditLog("symptoms.event.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + eventId: id, + ...overwriteDetails({ + before: { + occurredAt: existing.occurredAt, + intensity: existing.intensity, + episodeId: existing.episodeId, + }, + after: { + occurredAt: updated.occurredAt, + intensity: updated.intensity, + episodeId: updated.episodeId, + }, + redacted: entry.note !== undefined ? ["note"] : [], + }), + }, + }); + + invalidateUserHealthContext(user.id); + + annotate({ + action: { + name: "symptoms.event.update", + entity_type: "symptom_event", + entity_id: id, + }, + }); + + return apiSuccess(toSymptomEventDTO(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const existing = await prisma.symptomEvent.findFirst({ + where: { id, userId: user.id }, + select: { + id: true, + definitionId: true, + occurredAt: true, + intensity: true, + }, + }); + if (!existing) return notFound(); + + await prisma.symptomEvent.delete({ where: { id: existing.id } }); + + await auditLog("symptoms.event.delete", { + userId: user.id, + ipAddress: getClientIp(request), + // C3: the row is gone after this; the audit row says what it was. The + // symptom's name is the person's own words and stays out of it, so the + // label is the definition id. + details: destroyedDetails({ + model: "SymptomEvent", + id, + label: existing.definitionId, + effectiveAt: existing.occurredAt, + extra: { intensity: existing.intensity }, + }), + }); + + invalidateUserHealthContext(user.id); + + annotate({ + action: { + name: "symptoms.event.delete", + entity_type: "symptom_event", + entity_id: id, + }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/symptoms/events/route.ts b/src/app/api/symptoms/events/route.ts new file mode 100644 index 000000000..447502a0b --- /dev/null +++ b/src/app/api/symptoms/events/route.ts @@ -0,0 +1,166 @@ +/** + * Occurrences of person-defined symptoms (v1.40). + * + * GET /api/symptoms/events — newest first, optionally narrowed by + * `from` / `to` (ISO instants), `definitionId` and `episodeId`; at most + * 500 per page. + * POST /api/symptoms/events — log one (`{ definitionId, intensity, + * occurredAt?, note?, episodeId? }`). `occurredAt` defaults to now and + * may not lie in the future beyond the shared five-minute skew; + * `episodeId` must name one of the record's own episodes that is still + * open. The note is encrypted at rest. + * + * Module-gated on the record (`illness.disabled`). `userId` comes from auth + * and is never a body field. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { withIdempotency } from "@/lib/idempotency"; +import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import type { Prisma } from "@/generated/prisma/client"; +import { + encryptSymptomText, + isLinkableEpisode, + symptomEventCreateSchema, + symptomEventListQuerySchema, + toSymptomEventDTO, +} from "@/lib/symptoms/server"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const sp = request.nextUrl.searchParams; + const parsed = symptomEventListQuerySchema.safeParse({ + from: sp.get("from") ?? undefined, + to: sp.get("to") ?? undefined, + definitionId: sp.get("definitionId") ?? undefined, + episodeId: sp.get("episodeId") ?? undefined, + limit: sp.get("limit") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "symptoms.invalid", + }); + } + const q = parsed.data; + + const occurredAt: Prisma.DateTimeFilter = {}; + if (q.from) occurredAt.gte = new Date(q.from); + if (q.to) occurredAt.lte = new Date(q.to); + + const rows = await prisma.symptomEvent.findMany({ + where: { + userId: user.id, + ...(q.from || q.to ? { occurredAt } : {}), + ...(q.definitionId ? { definitionId: q.definitionId } : {}), + ...(q.episodeId ? { episodeId: q.episodeId } : {}), + }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + take: q.limit, + }); + + annotate({ + action: { name: "symptoms.event.list" }, + meta: { count: rows.length, limit: q.limit }, + }); + + return apiSuccess({ + events: rows.map(toSymptomEventDTO), + limit: q.limit, + hasMore: rows.length === q.limit, + }); +}); + +// A retry from a flaky connection re-sends the same `Idempotency-Key` and gets +// the first answer back instead of a second occurrence at the same minute. +export const POST = apiHandler(withIdempotency<[NextRequest]>(postEvent)); + +async function postEvent(request: NextRequest): Promise { + // WRITE, like opening an episode or adding a reading: logging what happened + // is an admitted delegated write; editing or removing it is MANAGE. + const { user } = await requireRecordAuth("write", "illness"); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = symptomEventCreateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "symptoms.invalid", + }); + } + const entry = parsed.data; + + // A hidden definition stays loggable from an older client's cached list; + // only another account's id (or none) is refused. + const definition = await prisma.symptomDefinition.findFirst({ + where: { id: entry.definitionId, userId: user.id }, + select: { id: true }, + }); + if (!definition) { + return apiError("Symptom not found", 404, { + errorCode: "symptoms.definition.notFound", + }); + } + + if (entry.episodeId && !(await isLinkableEpisode(user.id, entry.episodeId))) { + return apiError("Episode is not open", 422, { + errorCode: "symptoms.episode.notOpen", + }); + } + + const created = await prisma.symptomEvent.create({ + data: { + userId: user.id, + definitionId: definition.id, + occurredAt: entry.occurredAt ?? new Date(), + intensity: entry.intensity, + noteEncrypted: entry.note ? encryptSymptomText(entry.note) : null, + episodeId: entry.episodeId ?? null, + }, + }); + + await auditLog("symptoms.event.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + eventId: created.id, + definitionId: created.definitionId, + linked: created.episodeId !== null, + }, + }); + + invalidateUserHealthContext(user.id); + + annotate({ + action: { + name: "symptoms.event.create", + entity_type: "symptom_event", + entity_id: created.id, + }, + meta: { linked: created.episodeId !== null }, + }); + + return apiSuccess(toSymptomEventDTO(created), 201); +} diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index bcd8cca24..3a0550cc5 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -461,6 +461,9 @@ function catalogueLabel( if (catalogue === "ecgReference") { return t("admin.section.backups.restoreSkippedEcgReference"); } + if (catalogue === "symptomEpisodeReference") { + return t("admin.section.backups.restoreSkippedSymptomEpisodeReference"); + } if (catalogue === "medicationTarget") { return t("admin.section.backups.restoreSkippedMedicationTarget"); } diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx index 0e5579c44..ab0909ef2 100644 --- a/src/components/dashboard/__tests__/dashboard-header.test.tsx +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -153,7 +153,8 @@ describe(" — every entry follows its module, as the capture p { mood: false }, { medications: false }, { workouts: false }, - { mood: false, medications: false, workouts: false }, + { illness: false }, + { mood: false, medications: false, workouts: false, illness: false }, ]; for (const modules of cases) { modulesRef.value = modules; @@ -170,6 +171,7 @@ describe(" — every entry follows its module, as the capture p measurement: "Log measurement", medication: "Log medication intake", mood: "Log mood", + symptom: "Log symptom", workout: "Log workout", } as const; for (const kind of CAPTURE_KIND_ORDER) { diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index db6ad7cc0..c969eb495 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -12,7 +12,15 @@ */ import { useMemo } from "react"; import Link from "next/link"; -import { Activity, Footprints, Pill, Plus, Waves, Wrench } from "lucide-react"; +import { + Activity, + Footprints, + Pill, + Plus, + Stethoscope, + Waves, + Wrench, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -62,6 +70,7 @@ export function DashboardHeader({ const canAddMeasurement = offered.includes("measurement"); const canAddIntake = offered.includes("medication"); const canAddMood = offered.includes("mood"); + const canAddSymptom = offered.includes("symptom"); const canAddWorkout = offered.includes("workout"); const canAddAnything = offered.length > 0; @@ -198,6 +207,12 @@ export function DashboardHeader({ {t("dashboard.quickAddMedicationIntake")} )} + {canAddSymptom && ( + onQuickEntry("symptom")}> + + )} {canAddWorkout && ( onQuickEntry("workout")}>