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

+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() }),