From bff083f532b805126b846f4c624ec226da6bc747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 18 Sep 2026 18:51:38 +0200 Subject: [PATCH 1/2] fix(cycle): accept a symptom logged without an intensity Picking a symptom without tapping an intensity is how most of them are logged. The sheet sends severity null for those, the schema took a number or nothing at all, and the day log came back 422 with two issues for two symptoms. All the sheet could say was "Could not save. Try again.", and the cycle saved fine as soon as the symptoms were removed, which sent people looking in the wrong place. Nothing else disagreed: the link row has always stored an unrated symptom as a plain presence, and the writer already maps a non-number to NULL. Only the gate at the door refused it, on both the create and the patch path, and no test touched either schema. Refs #1001 --- .../__tests__/cycle-day-log-symptoms.test.ts | 65 +++++++++++++++++++ src/lib/validations/cycle.ts | 14 +++- 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 src/lib/validations/__tests__/cycle-day-log-symptoms.test.ts diff --git a/src/lib/validations/__tests__/cycle-day-log-symptoms.test.ts b/src/lib/validations/__tests__/cycle-day-log-symptoms.test.ts new file mode 100644 index 000000000..021c808db --- /dev/null +++ b/src/lib/validations/__tests__/cycle-day-log-symptoms.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { + cycleDayLogInputSchema, + cycleDayLogPatchSchema, +} from "@/lib/validations/cycle"; + +/** + * A symptom picked without an intensity is the ordinary way to log one. + * + * The sheet holds the picker as key to intensity and sends `severity: null` + * for every symptom the user did not rate, which is most of them. The schema + * took a number or nothing at all, so those entries came back 422 and the + * sheet showed "Could not save. Try again." with no way to tell why. The link + * row has always stored an unrated symptom as a plain presence; only the + * gate at the door disagreed, and nothing tested it. + */ +const BASE_INPUT = { + date: "2026-09-17", + loggedAt: new Date().toISOString(), +} as const; + +describe("cycle day-log symptoms accept an unrated selection", () => { + for (const [name, schema] of [ + ["create", cycleDayLogInputSchema], + ["patch", cycleDayLogPatchSchema], + ] as const) { + it(`${name}: takes a symptom with no intensity at all`, () => { + const parsed = schema.safeParse({ + ...BASE_INPUT, + symptoms: [{ key: "cramps" }], + }); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it(`${name}: takes an explicit null intensity, which is what the sheet sends`, () => { + const parsed = schema.safeParse({ + ...BASE_INPUT, + symptoms: [ + { key: "cramps", severity: null }, + { key: "back_pain", severity: null }, + ], + }); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it(`${name}: still takes a rated symptom`, () => { + const parsed = schema.safeParse({ + ...BASE_INPUT, + symptoms: [{ key: "cramps", severity: 3 }], + }); + expect(parsed.success).toBe(true); + }); + + it(`${name}: still refuses an intensity outside 1 to 4`, () => { + for (const severity of [0, 5, 2.5]) { + const parsed = schema.safeParse({ + ...BASE_INPUT, + symptoms: [{ key: "cramps", severity }], + }); + expect(parsed.success, `severity ${severity}`).toBe(false); + } + }); + } +}); diff --git a/src/lib/validations/cycle.ts b/src/lib/validations/cycle.ts index dad5f0ce3..86fbd8cab 100644 --- a/src/lib/validations/cycle.ts +++ b/src/lib/validations/cycle.ts @@ -131,7 +131,12 @@ export const cycleDayLogInputSchema = z.object({ .array( z.object({ key: z.string().min(1).max(80), - severity: z.number().int().min(1).max(4).optional(), + // NULL is the ordinary case, not an edge: picking a symptom without + // tapping an intensity is how most entries are logged, and the link + // row stores that as a plain presence. Rejecting it refused every + // unrated symptom with a 422 the sheet could only show as + // "Could not save". + severity: z.number().int().min(1).max(4).nullable().optional(), }), ) .max(40) @@ -181,7 +186,12 @@ export const cycleDayLogPatchSchema = z.object({ .array( z.object({ key: z.string().min(1).max(80), - severity: z.number().int().min(1).max(4).optional(), + // NULL is the ordinary case, not an edge: picking a symptom without + // tapping an intensity is how most entries are logged, and the link + // row stores that as a plain presence. Rejecting it refused every + // unrated symptom with a 422 the sheet could only show as + // "Could not save". + severity: z.number().int().min(1).max(4).nullable().optional(), }), ) .max(40) From 6113855b190b72fd334e1aff7953c058157826e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 18 Sep 2026 19:10:39 +0200 Subject: [PATCH 2/2] chore(openapi): regenerate after the symptom severity change --- docs/api/openapi.yaml | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fcadfa331..d52c8ad1c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20721,9 +20721,11 @@ components: minLength: 1 maxLength: 80 severity: - type: integer - minimum: 1 - maximum: 4 + anyOf: + - type: integer + minimum: 1 + maximum: 4 + - type: "null" required: - key note: @@ -20859,9 +20861,11 @@ components: minLength: 1 maxLength: 80 severity: - type: integer - minimum: 1 - maximum: 4 + anyOf: + - type: integer + minimum: 1 + maximum: 4 + - type: "null" required: - key note: @@ -20975,9 +20979,11 @@ components: minLength: 1 maxLength: 80 severity: - type: integer - minimum: 1 - maximum: 4 + anyOf: + - type: integer + minimum: 1 + maximum: 4 + - type: "null" required: - key note: