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
This commit is contained in:
Marc-André Bombeck committed 2026-09-18 18:51:38 +02:00
1 parent 1fe90f3fab
commit bff083f532
2 files changed
+77 -2

No files matched your search

@@ -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);
}
});
}
});
+12 -2
View File
@@ -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)