Tests: dates fixes pour Planning + refonte des radio/checkbox

- apps/api: les tests Planning (mocha et cucumber) lisaient l'horloge
  systeme (new Date()/DateTime.utc()) pour construire leurs fixtures et
  interroger /planning, ce qui les rendait non deterministes. Ajoute
  test-support/reference-date.ts (TEST_REFERENCE_DATE, une date UTC
  fixe) et l'utilise dans planning.test.ts / planning.steps.ts a la
  place du systeme.

- apps/web: nouveau style global pour tous les radio/checkbox de
  l'app (theme-select, allergy-select, onboarding) - "carte
  selectionnable" : le controle natif reste reel/accessible mais
  visuellement cache, toute la ligne devient la surface interactive
  (bordure + fond teinte + coche au survol/selection). Corrige au
  passage le bug de fond qui causait le desalignement des radios sur
  /parametres/preferences-utilisateur (la regle generique
  input, select { width: 100% } de profile-forms.scss s'appliquait
  aussi aux checkbox/radio) et une regression de font-weight ou les
  lignes non selectionnees du theme apparaissaient en gras comme si
  elles l'etaient.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-17 22:36:02 +02:00
parent d26bae6bac
commit 9dd90359c9
8 changed files with 233 additions and 118 deletions

View file

@ -1,12 +1,14 @@
import assert from "node:assert/strict";
import { DateTime } from "@batch-cooking/date-tools";
import { Given, Then, When } from "@cucumber/cucumber";
import { prisma } from "../../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../../test-support/reference-date.js";
import type { CustomWorld } from "../support/world.js";
/** `GET /planning` takes `?date=` explicitly — this scenario wording ("the current planning") maps to "today". */
/** `GET /planning` takes `?date=` explicitly — this scenario wording ("the current planning") maps to the fixed test "today" (see `TEST_REFERENCE_DATE`). */
When("I request the current planning", async function (this: CustomWorld) {
this.response = await this.agent.get("/planning").query({ date: DateTime.utc().toISODate() });
this.response = await this.agent
.get("/planning")
.query({ date: TEST_REFERENCE_DATE.toISODate() });
});
Then("the current planning response should be empty", function (this: CustomWorld) {
@ -27,16 +29,11 @@ Given(
const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({ data: { name: recipeName } });
const today = new Date();
const planning = await prisma.planning.create({
data: {
houseId,
startDate: new Date(
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 2),
),
finishDate: new Date(
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 2),
),
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.create({

View file

@ -0,0 +1,15 @@
import { DateTime } from "@batch-cooking/date-tools";
/**
* The fixed "today" every date-sensitive test builds its fixtures and
* queries around, instead of the real system clock (`new Date()` /
* `DateTime.utc()`). Reading the real clock made those tests
* non-deterministic: behavior could shift depending on which day/time they
* happened to run (e.g. a UTC-midnight boundary behaving differently right
* around real midnight), and made it impossible to reliably target a
* specific day-of-week. Any fixed UTC date works here; this one has no
* special meaning tests that care about a specific weekday should derive
* it from this constant (e.g. `.set({ weekday: 1 })`) rather than hardcode
* one that happens to match today by coincidence.
*/
export const TEST_REFERENCE_DATE = DateTime.fromISO("2026-06-15", { zone: "utc" });

View file

@ -1,10 +1,11 @@
import { DateTime } from "@batch-cooking/date-tools";
import type { DateTime } from "@batch-cooking/date-tools";
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
@ -19,9 +20,9 @@ function buildSignupPayload(): SignupInput {
};
}
/** Today, as the `YYYY-MM-DD` string `GET /planning`'s `?date=` expects. */
/** The fixed test "today", as the `YYYY-MM-DD` string `GET /planning`'s `?date=` expects. */
function today(): string {
return isoDate(DateTime.utc());
return isoDate(TEST_REFERENCE_DATE);
}
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the case for the always-valid values built in this file. */
@ -97,16 +98,11 @@ describe("Planning", () => {
const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } });
const now = new Date();
const planning = await prisma.planning.create({
data: {
houseId,
startDate: new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 2),
),
finishDate: new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 2),
),
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.create({
@ -150,7 +146,7 @@ describe("Planning", () => {
const houseId: number = houseRes.body.id;
const recipe = await prisma.recipe.create({ data: { name: "Curry de lentilles" } });
const nextWeek = DateTime.utc().plus({ weeks: 1 });
const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
const planning = await prisma.planning.create({
data: {
houseId,

View file

@ -35,16 +35,24 @@ export function AllergySelect({ legend, allergies, value, onChange }: AllergySel
return (
<fieldset className="allergy-select">
<legend>{legend}</legend>
{allergies.map((allergy) => (
<label key={allergy.id} className="allergy-select__option">
<input
type="checkbox"
checked={value.includes(allergy.id)}
onChange={() => toggle(allergy.id)}
/>
{allergies.map((allergy) => {
const checked = value.includes(allergy.id);
return (
<label
key={allergy.id}
// `is-selected` (not a `:has(:checked)` CSS rule) drives the
// selected look — chaining `:has(...):has(:checked)` to react to
// a *state* change (rather than a DOM mutation) turned out to be
// unreliable, so this stays a plain, always-correct React class
// instead of relying on CSS to derive it.
className={`allergy-select__option${checked ? " is-selected" : ""}`}
>
<input type="checkbox" checked={checked} onChange={() => toggle(allergy.id)} />
<span className="check-mark" aria-hidden="true" />
{allergy.name}
</label>
))}
);
})}
</fieldset>
);
}

View file

@ -18,7 +18,11 @@ label {
margin-top: var(--space-sm);
}
input,
// Excludes checkbox/radio those get their own deliberate, fixed-size
// appearance from global.scss instead of stretching to the field width
// like a text/select input (this used to bleed onto them unscoped, which
// is why they used to render oversized and misaligned with their label).
input:not([type="checkbox"]):not([type="radio"]),
select {
width: 100%;
padding: var(--space-sm);
@ -57,19 +61,15 @@ select {
font-weight: 600;
}
// The option row's own look (bordered "card", checked state, checkmark)
// is entirely global.scss's — this only overrides what doesn't fit a
// grid cell: the block/margin-top label rule above (this wraps an inline
// checkbox + text pair, not a field caption above an input) and the
// heavier default label weight (checked rows re-bold themselves via
// global.scss; unchecked ones should read as plain body text).
&__option {
display: flex;
align-items: center;
gap: var(--space-xs);
// Overrides the block/margin-top label rule above this label wraps
// an inline checkbox + text pair, not a field caption above an input.
margin-top: 0;
font-weight: 400;
font-size: var(--font-size-base);
cursor: pointer;
input[type="checkbox"] {
width: auto;
}
}
}

View file

@ -43,18 +43,28 @@ export function UserPreferencesPage() {
<div className="settings-page__section">
<fieldset className="theme-select">
<legend>{t("userPreferences.themeLabel")}</legend>
{THEME_PREFERENCES.map((option) => (
<label key={option} className="theme-select__option">
{THEME_PREFERENCES.map((option) => {
const checked = theme === option;
return (
<label
key={option}
// `is-selected` (not a `:has(:checked)` CSS rule) drives the
// selected look — see AllergySelect.tsx for why this stays
// in JS rather than pure CSS.
className={`theme-select__option${checked ? " is-selected" : ""}`}
>
<input
type="radio"
name="theme"
value={option}
checked={theme === option}
checked={checked}
onChange={() => handleChange(option)}
/>
<span className="check-mark" aria-hidden="true" />
{t(`userPreferences.theme.${option}`)}
</label>
))}
);
})}
</fieldset>
{saveState === "saving" && <p className="settings-page__saving">{t("common.saving")}</p>}

View file

@ -152,7 +152,11 @@ button.settings-page__link-button {
// Theme choice (UserPreferencesPage) a plain radio group, no fieldset/
// legend styling exists elsewhere yet to reuse (AllergySelect's `.allergy-
// select` in profile-forms.scss is checkbox-grid specific).
// select` in profile-forms.scss is checkbox-grid specific). The option
// rows' own look (bordered "card", checked state, checkmark) is entirely
// global.scss's — this only resets the fieldset chrome; the generic
// `label { margin-top }` rule (profile-forms.scss) already stacks the
// rows with breathing room between them.
.theme-select {
border: none;
padding: 0;
@ -163,12 +167,4 @@ button.settings-page__link-button {
font-weight: 600;
font-size: var(--font-size-sm);
}
&__option {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) 0;
cursor: pointer;
}
}

View file

@ -53,3 +53,96 @@ h1 {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
// Checkbox/radio appearance, app-wide "selectable card" style: the native
// control itself is visually hidden (still real, focusable and
// screen-reader-visible see the `input[type=...]` rule below, not
// `display: none`) and the whole label row it lives in becomes the
// interactive surface instead: a flat bordered box that fills in with a
// tinted background + primary border once selected, with a checkmark
// fading in on the trailing edge.
//
// The base (unselected) look below is detected structurally with `:has()`
// safe, since "does this label contain a checkbox/radio" never changes
// after mount. The *selected* look is instead driven by the `is-selected`
// class each caller (AllergySelect.tsx, UserPreferencesPage.tsx) toggles
// in JS from the same boolean it already passes to `checked` chaining a
// second `:has(:checked)` to react to that live state turned out to be
// unreliable across browsers, so this only needs one always-true `:has()`.
//
// Every checkbox/radio in the app goes through this one place (the allergy
// grid, the theme picker, anywhere future) rather than each feature styling
// its own see profile-forms.scss / settings-pages.scss, which only
// arrange these within their own layout (grid vs. stacked list) and
// intentionally don't re-style the control/label look itself.
label:has(> input[type="checkbox"]),
label:has(> input[type="radio"]) {
position: relative;
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
// Overrides the generic `label { font-weight: 600 }` base rule
// (profile-forms.scss) without this, an *unselected* row reads just as
// bold as a selected one (only `.allergy-select__option` happened to set
// its own 400 already; `.theme-select__option` didn't, so its rows were
// all permanently bold until this was centralized here).
font-weight: 400;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
&:hover {
border-color: var(--color-primary);
}
&.is-selected {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
color: var(--color-primary);
font-weight: 600;
}
&:has(:focus-visible) {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
// The control itself is removed from the visual flow hidden the
// "sr-only" way (not `display: none`) so it stays focusable/tabbable and
// announced correctly by screen readers; the label above carries the
// entire visible selected/unchecked look.
input[type="checkbox"],
input[type="radio"] {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
// The checkmark a real element (see AllergySelect.tsx /
// UserPreferencesPage.tsx) shown via the same `is-selected` class as the
// label's own look above, not a separate CSS-only trigger. Scaled in from
// nothing so toggling has a bit of motion. Same mark for both checkbox and
// radio: one consistent "selected" language app-wide rather than a
// checkmark here and a dot there.
.check-mark {
flex: none;
margin-left: auto;
width: 0.9rem;
height: 0.9rem;
background: var(--color-primary);
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
transform: scale(0);
transition: transform 0.1s ease;
}
.is-selected .check-mark {
transform: scale(1);
}