fix(cycle): v1.15.1 QA reconcile — purge, rate limits, marker contrast, token discipline

- Add CYCLE_FERTILE_SOON to the cycle-purge push_attempts eventType list so a
  data reset leaves no dated reproductive-intent delivery trace.
- Rate-limit the custom-symptom mutations (POST create, PATCH, DELETE) under a
  shared cycle:symptom:custom:<userId> bucket (30/min), mirroring the insights
  route, so a session can't flood the encrypted-label catalogue.
- Re-run the active-count cap inside PATCH when a symptom reactivates
  (hidden → active); a hidden row doesn't count toward the cap, so without the
  recheck a user could exceed it by hiding then re-enabling rows.
- Gate the log-day "limit reached" copy on the cap errorCode rather than any
  create failure, so a transient/validation error no longer masquerades as the
  cap being hit; thread the returned key so a new custom symptom auto-selects.
- Move the history-chart bar fill alphas into named phase-tokens
  (HISTORY_REST_OPACITY / HISTORY_PERIOD_OPACITY) so bar intensity lives in one
  token source alongside FLOW_OPACITY.
- Strengthen the confirmed-ovulation calendar marker (ring 1.5px → 2px, fill
  alpha 0.16 → 0.22) so it stays distinct from the predicted dot on small light
  screens; keeps its data-attr + aria naming.
- Raise the phase-education chip border from /10 to /15 for legible contrast on
  the tinted tile.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 17:19:59 +02:00
1 parent 2f44654dbe
commit a635c1de05
10 files changed
+160 -15

No files matched your search

+7 -1
View File
@@ -68,7 +68,13 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const pushRows = await tx.pushAttempt.deleteMany({
where: {
userId: user.id,
eventType: { in: ["CYCLE_PERIOD_SOON", "CYCLE_PERIOD_CONFIRM"] },
eventType: {
in: [
"CYCLE_PERIOD_SOON",
"CYCLE_PERIOD_CONFIRM",
"CYCLE_FERTILE_SOON",
],
},
},
});
// Reset the reproductive INTENT carried on the profile (goal + cycle/period/
@@ -18,12 +18,14 @@ import {
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
updateCustomSymptomSchema,
decryptCustomLabel,
encryptCustomLabel,
isCustomSymptomKey,
MAX_CUSTOM_SYMPTOMS_PER_USER,
} from "@/lib/cycle/custom-symptoms";
export const dynamic = "force-dynamic";
@@ -38,6 +40,15 @@ export const PATCH = apiHandler(
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`cycle:symptom:custom:${user.id}`,
30,
60_000,
);
if (!rl.allowed) {
return apiError("Too many requests, try again later", 429);
}
const { key } = await params;
if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404);
@@ -50,10 +61,27 @@ export const PATCH = apiHandler(
const owned = await prisma.cycleSymptom.findFirst({
where: { key, userId: user.id },
select: { id: true },
select: { id: true, isActive: true },
});
if (!owned) return apiError("Custom symptom not found", 404);
// Reactivation (soft-hidden → active) re-enters the active set, so it must
// re-clear the per-user cap — a hidden row doesn't count toward it, and
// skipping the recheck here would let a user exceed the limit by hiding
// then re-enabling rows.
if (parsed.data.isActive === true && !owned.isActive) {
const activeCount = await prisma.cycleSymptom.count({
where: { userId: user.id, isActive: true },
});
if (activeCount >= MAX_CUSTOM_SYMPTOMS_PER_USER) {
return apiError(
`Custom symptom limit reached (${MAX_CUSTOM_SYMPTOMS_PER_USER})`,
422,
{ errorCode: "cycle.symptom.custom.limit" },
);
}
}
const updated = await prisma.cycleSymptom.update({
where: { id: owned.id },
data: {
@@ -91,6 +119,15 @@ export const DELETE = apiHandler(
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`cycle:symptom:custom:${user.id}`,
30,
60_000,
);
if (!rl.allowed) {
return apiError("Too many requests, try again later", 429);
}
const { key } = await params;
if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404);
@@ -33,6 +33,9 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/cycle/gate", () => ({
requireCycleEnabled: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 29 }),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -161,6 +164,39 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
expect(db.cycleSymptom.findFirst).not.toHaveBeenCalled();
});
it("rechecks the cap on reactivation (hidden → active) and 422s when full", async () => {
// A soft-hidden row doesn't count toward the active cap; flipping it back
// to active must re-clear the limit so hide-then-reenable can't bypass it.
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1", isActive: false });
db.cycleSymptom.count.mockResolvedValue(50);
const res = await PATCH(
jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", {
isActive: true,
}),
params("custom:abc"),
);
expect(res.status).toBe(422);
expect(db.cycleSymptom.update).not.toHaveBeenCalled();
});
it("does not recheck the cap when reactivation is not requested", async () => {
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1", isActive: true });
db.cycleSymptom.update.mockResolvedValue({
key: "custom:abc",
icon: "Tag",
isActive: true,
labelEncrypted: "enc:Renamed",
});
const res = await PATCH(
jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", {
label: "Renamed",
}),
params("custom:abc"),
);
expect(res.status).toBe(200);
expect(db.cycleSymptom.count).not.toHaveBeenCalled();
});
it("soft-deactivates by default and hard-deletes on ?purge=true", async () => {
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1" });
const soft = await DELETE(
@@ -26,6 +26,7 @@ import {
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
createCustomSymptomSchema,
@@ -71,6 +72,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
// Cap mutation rate so a single session can't flood the encrypted-label
// catalogue (each create runs an encrypt + audit write).
const rl = await checkRateLimit(`cycle:symptom:custom:${user.id}`, 30, 60_000);
if (!rl.allowed) {
return apiError("Too many requests, try again later", 429);
}
const parsed = createCustomSymptomSchema.safeParse(await request.json());
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
+2 -2
View File
@@ -254,11 +254,11 @@ export function CycleCalendar({
{isConfirmedOvulation ? (
<span
aria-hidden="true"
className="absolute inset-x-1 inset-y-2 rounded-full ring-[1.5px] ring-inset"
className="absolute inset-x-1 inset-y-2 rounded-full ring-2 ring-inset"
style={
{
backgroundColor: OVULATION_HUE,
opacity: 0.16,
opacity: 0.22,
"--tw-ring-color": OVULATION_HUE,
} as React.CSSProperties
}
+9 -3
View File
@@ -13,7 +13,13 @@ import { useTranslations } from "@/lib/i18n/context";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { cn } from "@/lib/utils";
import type { CycleHistoryResponse, MenstrualCycleDTO } from "./types";
import { FLOW_HUE, OVULATION_HUE, PHASE_HUE } from "./phase-tokens";
import {
FLOW_HUE,
OVULATION_HUE,
PHASE_HUE,
HISTORY_REST_OPACITY,
HISTORY_PERIOD_OPACITY,
} from "./phase-tokens";
/**
* v1.15.1 — the cycle-length history chart.
@@ -296,7 +302,7 @@ function BarField({
height={Math.max(restH, 0)}
rx={2}
fill={REST_HUE}
fillOpacity={0.5}
fillOpacity={HISTORY_REST_OPACITY}
/>
) : null}
{/* Period segment (lower) — rose, the bleeding portion. */}
@@ -308,7 +314,7 @@ function BarField({
height={periodH}
rx={2}
fill={FLOW_HUE}
fillOpacity={0.85}
fillOpacity={HISTORY_PERIOD_OPACITY}
data-period-segment="true"
>
<title>{periodLabel}</title>
+24 -6
View File
@@ -56,6 +56,8 @@ import {
useLogDay,
usePatchDayLog,
useStartPeriod,
CustomSymptomError,
CUSTOM_SYMPTOM_LIMIT_ERROR_CODE,
type CustomSymptomDTO,
type CycleDayLogPatch,
} from "./use-cycle";
@@ -556,7 +558,7 @@ export function LogDaySheet({
}}
/>
))}
<AddSymptomChip />
<AddSymptomChip onCreated={toggleSymptom} />
</div>
</div>
</div>
@@ -813,7 +815,11 @@ function SymptomChip({
* popover to mint a new custom symptom. Matches the chip grid (same rounded-
* full pill, same size) so it reads as part of the grid, not a foreign button.
*/
function AddSymptomChip() {
function AddSymptomChip({
onCreated,
}: {
onCreated: (key: string) => void;
}) {
const { t } = useTranslations();
const create = useCreateCustomSymptom();
const [openForm, setOpenForm] = useState(false);
@@ -828,12 +834,24 @@ function AddSymptomChip() {
async function handleCreate() {
const trimmed = label.trim();
if (!trimmed) return;
await create.mutateAsync({ label: trimmed, icon });
reset();
setOpenForm(false);
try {
const created = await create.mutateAsync({ label: trimmed, icon });
// Auto-select the freshly-minted symptom so the user doesn't have to tap
// the new chip after adding it.
onCreated(created.key);
reset();
setOpenForm(false);
} catch {
// Keep the popover open; `create.error` drives the inline message
// (cap copy on the limit errorCode, generic otherwise).
}
}
const limitReached = create.isError;
// Only the per-user cap surfaces the "limit reached" copy — a transient or
// validation error must not masquerade as the cap being hit.
const limitReached =
create.error instanceof CustomSymptomError &&
create.error.errorCode === CUSTOM_SYMPTOM_LIMIT_ERROR_CODE;
return (
<Popover
@@ -178,7 +178,7 @@ export function PhaseEducationCard({
return (
<li
key={row.symptomKey}
className="border-foreground/10 bg-background/55 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium"
className="border-foreground/15 bg-background/55 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium"
>
{Icon ? (
<Icon
+10
View File
@@ -44,6 +44,16 @@ export const FLOW_OPACITY: Record<Exclude<FlowLevel, "NONE">, number> = {
/** The default period-day fill alpha when a logged day carries no flow grade. */
export const FLOW_OPACITY_DEFAULT = 0.32;
/**
* History-chart bar fill alphas — the two-segment stacked bar (rest-of-cycle
* upper, period lower) keeps its intensity here so the chart and the calendar
* draw their fills from one token source rather than inline literals. The
* period segment sits denser than the rest span so the bleeding portion reads
* as the anchor of each bar.
*/
export const HISTORY_REST_OPACITY = 0.5;
export const HISTORY_PERIOD_OPACITY = 0.85;
/** Resolve the flow-shading alpha for a logged period day (graceful fallback). */
export function flowOpacity(flow: string | null | undefined): number {
if (flow && flow !== "NONE" && flow in FLOW_OPACITY) {
+25 -1
View File
@@ -133,6 +133,20 @@ export function useCustomSymptoms() {
});
}
/** The `errorCode` the create POST returns when the per-user cap is hit. */
export const CUSTOM_SYMPTOM_LIMIT_ERROR_CODE = "cycle.symptom.custom.limit";
/** An error that preserves the envelope `errorCode` so callers can branch. */
export class CustomSymptomError extends Error {
constructor(
public readonly errorCode: string | null,
public readonly status: number,
) {
super(errorCode ?? `Request failed: ${status}`);
this.name = "CustomSymptomError";
}
}
/** Create a custom symptom (mint `custom:<uuid>`, encrypt the label). */
export function useCreateCustomSymptom() {
const qc = useQueryClient();
@@ -143,7 +157,17 @@ export function useCreateCustomSymptom() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return unwrap<CustomSymptomDTO>(res);
if (!res.ok) {
// Preserve the envelope errorCode so the UI can tell the cap hit
// (cycle.symptom.custom.limit) apart from a transient/validation error.
const errorCode = await res
.json()
.then((j) => (j?.meta?.errorCode as string | undefined) ?? null)
.catch(() => null);
throw new CustomSymptomError(errorCode, res.status);
}
const json = await res.json();
return json.data as CustomSymptomDTO;
},
onSuccess: () =>
qc.invalidateQueries({ queryKey: queryKeys.cycleCustomSymptoms() }),