mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
1 parent
2f44654dbe
commit
a635c1de05
10 files changed
+160
-15
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
Reference in new issue
Block a user