Tips & Tricks (Mis à jour: 02/06/2026)

Calendrier de production avec Claude Code: React, TypeScript, fuseaux et tests

Calendrier React/TypeScript fiable: fuseaux horaires, accessibilité, API de disponibilité, plages et tests.

Calendrier de production avec Claude Code: React, TypeScript, fuseaux et tests

Un calendrier semble simple tant qu’il reste une maquette. En production, il doit gerer les fins de mois, les annees bissextiles, les changements d’heure, les fuseaux horaires, les jours fermes, le clavier et les reponses d’API qui arrivent dans le mauvais ordre. Demander a Claude Code de faire “un calendrier joli” ne suffit pas.

Nous allons construire un composant React/TypeScript pour la reservation. Une valeur date-only est une date sans heure, par exemple 2026-06-02. Le fuseau horaire donne le sens de “aujourd’hui” et des creneaux. ARIA designe les attributs qui expliquent le role et l’etat d’un widget aux technologies d’assistance. Si ces notions se melangent, l’utilisateur choisit le 2 juin et le serveur stocke le 1er juin.

Cas d’usage

Ce composant vise des flux ou une erreur de date a un impact direct.

CasFonctions requisesRisque
Cabinet medical, salon, coachingAPI de disponibilite, jours fermes, creneaux par fuseauReservation possible sans capacite
Planning SaaSChangement de mois, selection de plage, etat traçableEquipes distantes voient une autre date
Calendrier editorialDate selectionnee, aujourd’hui, nombre d’evenementsPublication au mauvais jour
Hotel ou evenementDate de debut et de finPlage invalide ou prix faux

La partie accessibilite prolonge le guide accessibilite Claude Code, et la partie contrat d’API rejoint les tests d’API avec Claude Code. Les references officielles sont MDN Date, MDN Intl.DateTimeFormat, le WAI-ARIA Grid Pattern, Testing Library et la documentation React sur la gestion de l’etat.

Contraindre Claude Code

Le prompt initial doit fixer les invariants, pas seulement le rendu attendu.

Build a booking calendar in React and TypeScript.
- Store selected days as YYYY-MM-DD date-only strings
- Do not use Date.toISOString() for user-selected dates
- Put month grids, ranges, and labels in date-utils.ts
- Make the availability API abortable with AbortController
- Implement role="grid", aria-selected, aria-disabled, and keyboard movement
- Support single-date and range selection
- Include CSS and Testing Library tests

L’architecture reste volontairement petite.

flowchart LR
  A["date-utils.ts<br/>calcul de dates"] --> B["availability-api.ts<br/>capacite"]
  B --> C["Calendar.tsx<br/>rendu et interaction"]
  C --> D["calendar.css<br/>etats visuels"]
  C --> E["Calendar.test.tsx<br/>tests de regression"]

La separation du calcul de date et du JSX est essentielle. Claude Code sait modifier rapidement une interface, mais si les regles de mois, de plage et de disponibilite sont dispersees dans le composant, les bugs de fuseau deviennent difficiles a localiser. Les fonctions pures permettent aussi de demander des tests de limites avant l’interface.

Utilitaires de date

Copiez ce fichier dans src/calendar/date-utils.ts. Les dates selectionnees sont stockees en YYYY-MM-DD; la grille mensuelle utilise une arithmetique UTC; le fuseau horaire sert seulement a calculer le “aujourd’hui” de l’utilisateur.

export type ISODate = `${number}-${number}-${number}`;

const pad2 = (value: number) => String(value).padStart(2, "0");

export function makeISODate(year: number, month: number, day: number): ISODate {
  return `${year}-${pad2(month)}-${pad2(day)}` as ISODate;
}

export function readISODate(value: ISODate) {
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
  if (!match) throw new Error(`Invalid ISO date: ${value}`);

  const year = Number(match[1]);
  const month = Number(match[2]);
  const day = Number(match[3]);
  const check = new Date(Date.UTC(year, month - 1, day));

  if (
    check.getUTCFullYear() !== year ||
    check.getUTCMonth() !== month - 1 ||
    check.getUTCDate() !== day
  ) {
    throw new Error(`Invalid calendar date: ${value}`);
  }

  return { year, month, day };
}

function toUTCDate(value: ISODate) {
  const { year, month, day } = readISODate(value);
  return new Date(Date.UTC(year, month - 1, day));
}

export function addDaysISO(value: ISODate, amount: number): ISODate {
  const date = toUTCDate(value);
  date.setUTCDate(date.getUTCDate() + amount);
  return makeISODate(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
}

export function addMonthsISO(value: ISODate, amount: number): ISODate {
  const { year, month } = readISODate(value);
  const date = new Date(Date.UTC(year, month - 1 + amount, 1));
  return makeISODate(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
}

export function startOfMonthISO(value: ISODate): ISODate {
  const { year, month } = readISODate(value);
  return makeISODate(year, month, 1);
}

export function daysInMonthISO(value: ISODate): number {
  const { year, month } = readISODate(value);
  return new Date(Date.UTC(year, month, 0)).getUTCDate();
}

export function weekdayIndex(value: ISODate): number {
  return toUTCDate(value).getUTCDay();
}

export function buildMonthGrid(monthISO: ISODate, weekStartsOn: 0 | 1 = 0): ISODate[] {
  const first = startOfMonthISO(monthISO);
  const leading = (weekdayIndex(first) - weekStartsOn + 7) % 7;
  const start = addDaysISO(first, -leading);
  const visibleDays = Math.ceil((leading + daysInMonthISO(first)) / 7) * 7;

  return Array.from({ length: visibleDays }, (_, index) => addDaysISO(start, index));
}

export function todayISO(timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone): ISODate {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).formatToParts(new Date());

  const get = (type: string) => parts.find((part) => part.type === type)?.value;
  return `${get("year")}-${get("month")}-${get("day")}` as ISODate;
}

export function formatISODate(
  value: ISODate,
  locale: string,
  options: Intl.DateTimeFormatOptions = {},
) {
  const { year, month, day } = readISODate(value);
  return new Intl.DateTimeFormat(locale, { timeZone: "UTC", ...options }).format(
    new Date(Date.UTC(year, month - 1, day, 12)),
  );
}

export function compareISODate(a: ISODate, b: ISODate) {
  return a.localeCompare(b);
}

export function normalizeRange(start: ISODate, end: ISODate) {
  return compareISODate(start, end) <= 0 ? { start, end } : { start: end, end: start };
}

export function isISODateInRange(day: ISODate, start?: ISODate, end?: ISODate) {
  if (!start || !end) return false;
  return compareISODate(day, start) >= 0 && compareISODate(day, end) <= 0;
}

Le piege classique est toISOString() pour une date selectionnee. Cette methode produit un instant UTC, pas une date metier. Une date de reservation ou de publication doit rester une chaine date-only; seuls les creneaux horaires portent une heure et un fuseau.

API de disponibilite

Placez ce mock dans src/calendar/availability-api.ts. En production, vous remplacerez l’attente par un appel HTTP, mais le contrat reste utile.

import { addDaysISO, compareISODate, weekdayIndex, type ISODate } from "./date-utils";

export type AvailabilityStatus = "available" | "limited" | "closed";

export type DayAvailability = {
  status: AvailabilityStatus;
  slots: string[];
};

export type AvailabilityByDate = Record<ISODate, DayAvailability>;

export type AvailabilityRequest = {
  start: ISODate;
  end: ISODate;
  timeZone: string;
  signal?: AbortSignal;
};

function wait(ms: number, signal?: AbortSignal) {
  return new Promise<void>((resolve, reject) => {
    const id = window.setTimeout(resolve, ms);
    signal?.addEventListener("abort", () => {
      window.clearTimeout(id);
      reject(new DOMException("Request aborted", "AbortError"));
    });
  });
}

export async function fetchAvailability({
  start,
  end,
  timeZone,
  signal,
}: AvailabilityRequest): Promise<AvailabilityByDate> {
  await wait(180, signal);

  const result: AvailabilityByDate = {};
  for (let day = start; compareISODate(day, end) <= 0; day = addDaysISO(day, 1)) {
    const weekday = weekdayIndex(day);
    const closed = weekday === 0;
    const limited = weekday === 6;

    result[day] = {
      status: closed ? "closed" : limited ? "limited" : "available",
      slots: closed
        ? []
        : limited
          ? [`10:00 ${timeZone}`, `13:00 ${timeZone}`]
          : [`09:00 ${timeZone}`, `11:00 ${timeZone}`, `15:00 ${timeZone}`],
    };
  }

  return result;
}

Le but est d’eviter qu’une vieille reponse remplace le mois courant. Lorsqu’un utilisateur navigue vite, AbortController protege l’interface contre les resultats qui ne sont plus pertinents.

Composant Calendar

Voici src/calendar/Calendar.tsx. Il couvre la selection simple, la selection de plage, la disponibilite asynchrone, les jours fermes et le clavier.

import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import {
  addDaysISO,
  addMonthsISO,
  buildMonthGrid,
  compareISODate,
  formatISODate,
  isISODateInRange,
  normalizeRange,
  startOfMonthISO,
  todayISO,
  type ISODate,
} from "./date-utils";
import {
  fetchAvailability,
  type AvailabilityByDate,
  type AvailabilityRequest,
} from "./availability-api";
import "./calendar.css";

type CalendarMode = "single" | "range";
type RangeValue = { start?: ISODate; end?: ISODate };
type LoadAvailability = (request: AvailabilityRequest) => Promise<AvailabilityByDate>;

type CalendarProps = {
  locale?: string;
  timeZone?: string;
  initialMonth?: ISODate;
  weekStartsOn?: 0 | 1;
  mode?: CalendarMode;
  selected?: ISODate;
  range?: RangeValue;
  onSelectDate?: (date: ISODate) => void;
  onSelectRange?: (range: RangeValue) => void;
  loadAvailability?: LoadAvailability;
};

const weekDayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

export function Calendar({
  locale = "en-US",
  timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone,
  initialMonth = todayISO(timeZone),
  weekStartsOn = 0,
  mode = "single",
  selected,
  range,
  onSelectDate,
  onSelectRange,
  loadAvailability = fetchAvailability,
}: CalendarProps) {
  const [month, setMonth] = useState(startOfMonthISO(initialMonth));
  const [activeDate, setActiveDate] = useState(initialMonth);
  const [internalSelected, setInternalSelected] = useState<ISODate | undefined>(selected);
  const [internalRange, setInternalRange] = useState<RangeValue>(range ?? {});
  const [availability, setAvailability] = useState<AvailabilityByDate>({});
  const [error, setError] = useState("");
  const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});

  const days = useMemo(() => buildMonthGrid(month, weekStartsOn), [month, weekStartsOn]);
  const visibleStart = days[0];
  const visibleEnd = days[days.length - 1];
  const selectedDate = selected ?? internalSelected;
  const selectedRange = range ?? internalRange;
  const currentMonthLabel = formatISODate(month, locale, { month: "long", year: "numeric" });
  const today = todayISO(timeZone);

  useEffect(() => {
    const controller = new AbortController();
    setError("");

    loadAvailability({
      start: visibleStart,
      end: visibleEnd,
      timeZone,
      signal: controller.signal,
    })
      .then(setAvailability)
      .catch((reason: unknown) => {
        if (reason instanceof DOMException && reason.name === "AbortError") return;
        setError("Availability could not be loaded.");
      });

    return () => controller.abort();
  }, [loadAvailability, timeZone, visibleStart, visibleEnd]);

  useEffect(() => {
    buttonRefs.current[activeDate]?.focus();
  }, [activeDate]);

  function goToMonth(nextMonth: ISODate) {
    setMonth(nextMonth);
    setActiveDate(nextMonth);
  }

  function commitDate(day: ISODate) {
    if (availability[day]?.status === "closed") return;

    if (mode === "single") {
      setInternalSelected(day);
      onSelectDate?.(day);
      return;
    }

    const nextRange =
      !selectedRange.start || selectedRange.end
        ? { start: day, end: undefined }
        : normalizeRange(selectedRange.start, day);

    setInternalRange(nextRange);
    onSelectRange?.(nextRange);
  }

  function moveActive(offset: number) {
    const current = days.indexOf(activeDate);
    const next = days[Math.min(Math.max(current + offset, 0), days.length - 1)];
    setActiveDate(next);
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (event.key === "ArrowLeft") moveActive(-1);
    else if (event.key === "ArrowRight") moveActive(1);
    else if (event.key === "ArrowUp") moveActive(-7);
    else if (event.key === "ArrowDown") moveActive(7);
    else if (event.key === "Home") setActiveDate(days[0]);
    else if (event.key === "End") setActiveDate(days[days.length - 1]);
    else if (event.key === "PageUp") goToMonth(addMonthsISO(month, -1));
    else if (event.key === "PageDown") goToMonth(addMonthsISO(month, 1));
    else if (event.key === "Enter" || event.key === " ") commitDate(activeDate);
    else return;

    event.preventDefault();
  }

  return (
    <section className="calendar" aria-labelledby="calendar-heading">
      <div className="calendar__toolbar">
        <button type="button" onClick={() => goToMonth(addMonthsISO(month, -1))}>
          Previous
        </button>
        <h2 id="calendar-heading" aria-live="polite">
          {currentMonthLabel}
        </h2>
        <button type="button" onClick={() => goToMonth(addMonthsISO(month, 1))}>
          Next
        </button>
      </div>

      <div className="calendar__grid" role="grid" aria-labelledby="calendar-heading" onKeyDown={handleKeyDown}>
        {weekDayLabels.map((label) => (
          <div className="calendar__weekday" role="columnheader" key={label}>
            {label}
          </div>
        ))}

        {days.map((day) => {
          const inCurrentMonth = startOfMonthISO(day) === month;
          const dayAvailability = availability[day];
          const status = dayAvailability?.status ?? "loading";
          const isClosed = status === "closed";
          const isSelected = mode === "single" && selectedDate === day;
          const isRangeDay =
            mode === "range" && isISODateInRange(day, selectedRange.start, selectedRange.end ?? selectedRange.start);

          return (
            <button
              key={day}
              ref={(node) => {
                buttonRefs.current[day] = node;
              }}
              type="button"
              role="gridcell"
              tabIndex={activeDate === day ? 0 : -1}
              aria-selected={isSelected || isRangeDay}
              aria-disabled={isClosed}
              aria-label={`${formatISODate(day, locale, {
                weekday: "long",
                year: "numeric",
                month: "long",
                day: "numeric",
              })}: ${status === "loading" ? "loading availability" : status}`}
              data-outside-month={!inCurrentMonth}
              data-status={status}
              data-today={day === today}
              className="calendar__day"
              onClick={() => commitDate(day)}
            >
              <span className="calendar__date">
                {formatISODate(day, locale, { day: "numeric" })}
              </span>
              <span className="calendar__status">
                {status === "loading" ? "Loading" : status}
              </span>
              <span className="sr-only">
                {formatISODate(day, locale, {
                  weekday: "long",
                  year: "numeric",
                  month: "long",
                  day: "numeric",
                })}
              </span>
            </button>
          );
        })}
      </div>

      {error && <p className="calendar__error">{error}</p>}
      <p className="calendar__timezone">Availability shown in {timeZone}</p>
    </section>
  );
}

Le roving tabIndex garde seulement la date active dans l’ordre de tabulation. L’utilisateur entre dans le calendrier avec Tab, puis se deplace avec les fleches.

CSS et tests

Le CSS affiche l’etat avec du texte et pas seulement de la couleur. Enregistrez-le dans src/calendar/calendar.css.

.calendar {
  max-width: 720px;
  color: #172033;
  font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

.calendar__toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 16px;
}

.calendar__toolbar button {
  border: 1px solid #b9c2d0;
  border-radius: 8px;
  background: #ffffff;
  padding: 8px 12px;
  font-weight: 600;
}

.calendar__grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  border: 1px solid #d7deea;
  border-radius: 8px;
  overflow: hidden;
}

.calendar__weekday,
.calendar__day {
  min-height: 72px;
  border: 0;
  border-right: 1px solid #d7deea;
  border-bottom: 1px solid #d7deea;
  background: #ffffff;
}

.calendar__weekday {
  min-height: auto;
  padding: 10px 4px;
  background: #f5f7fb;
  font-size: 0.82rem;
  font-weight: 700;
  text-align: center;
}

.calendar__day {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: space-between;
  gap: 8px;
  padding: 10px;
  text-align: left;
  cursor: pointer;
}

.calendar__day:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: -3px;
}

.calendar__day[aria-selected="true"] {
  background: #dbeafe;
  box-shadow: inset 0 0 0 2px #2563eb;
}

.calendar__day[aria-disabled="true"] {
  color: #7b8494;
  cursor: not-allowed;
  background: #f3f4f6;
}

.calendar__day[data-outside-month="true"] {
  color: #9aa3b2;
}

.calendar__day[data-today="true"] .calendar__date {
  border-radius: 999px;
  background: #172033;
  color: #ffffff;
  padding: 2px 8px;
}

.calendar__status {
  border-radius: 999px;
  background: #eef2ff;
  padding: 2px 8px;
  font-size: 0.72rem;
  font-weight: 700;
}

.calendar__day[data-status="limited"] .calendar__status {
  background: #fef3c7;
}

.calendar__day[data-status="closed"] .calendar__status {
  background: #e5e7eb;
}

.calendar__error {
  color: #b91c1c;
  font-weight: 700;
}

.calendar__timezone {
  color: #566070;
  font-size: 0.9rem;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Installez les dependances de test.

npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom

Puis creez src/calendar/Calendar.test.tsx.

import "@testing-library/jest-dom/vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, test, vi } from "vitest";
import { Calendar } from "./Calendar";
import type { AvailabilityByDate, AvailabilityRequest } from "./availability-api";

const availability: AvailabilityByDate = {
  "2026-06-01": { status: "available", slots: ["09:00 Asia/Tokyo"] },
  "2026-06-02": { status: "limited", slots: ["10:00 Asia/Tokyo"] },
  "2026-06-03": { status: "closed", slots: [] },
};

function loadAvailability(_: AvailabilityRequest) {
  return Promise.resolve(availability);
}

describe("Calendar", () => {
  test("selects an available date", async () => {
    const onSelectDate = vi.fn();
    render(
      <Calendar
        initialMonth="2026-06-01"
        timeZone="Asia/Tokyo"
        loadAvailability={loadAvailability}
        onSelectDate={onSelectDate}
      />,
    );

    await waitFor(() => expect(screen.getAllByText("available").length).toBeGreaterThan(0));
    await userEvent.click(screen.getByRole("gridcell", { name: /Monday, June 1, 2026: available/i }));

    expect(onSelectDate).toHaveBeenCalledWith("2026-06-01");
  });

  test("does not select a closed date", async () => {
    const onSelectDate = vi.fn();
    render(
      <Calendar
        initialMonth="2026-06-01"
        timeZone="Asia/Tokyo"
        loadAvailability={loadAvailability}
        onSelectDate={onSelectDate}
      />,
    );

    await waitFor(() => expect(screen.getByText("closed")).toBeInTheDocument());
    await userEvent.click(screen.getByRole("gridcell", { name: /Wednesday, June 3, 2026: closed/i }));

    expect(onSelectDate).not.toHaveBeenCalled();
  });

  test("supports keyboard navigation and range selection", async () => {
    const onSelectRange = vi.fn();
    render(
      <Calendar
        mode="range"
        initialMonth="2026-06-01"
        timeZone="Asia/Tokyo"
        loadAvailability={loadAvailability}
        onSelectRange={onSelectRange}
      />,
    );

    await waitFor(() => expect(screen.getAllByText("available").length).toBeGreaterThan(0));
    const firstDay = screen.getByRole("gridcell", { name: /Monday, June 1, 2026: available/i });
    firstDay.focus();
    await userEvent.keyboard("{Enter}{ArrowRight}{Enter}");

    expect(onSelectRange).toHaveBeenLastCalledWith({
      start: "2026-06-01",
      end: "2026-06-02",
    });
  });
});

Pieges et resultat pratique

Premier piege: utiliser toISOString() pour une date metier. Deuxieme: confondre locale et fuseau horaire. Troisieme: transmettre l’etat uniquement par la couleur. Quatrieme: demander l’interface a Claude Code avant les tests. Les cas de mois, jours fermes, plage et clavier doivent etre dans le premier prompt.

Pour une equipe qui repete ce type de composant, transformez ces contraintes en prompts et checklists reutilisables. Les produits Claude Code peuvent servir de base pour regrouper generation de composant, revue, tests et integration API.

J’ai colle le code dans un projet Vite + React + TypeScript et l’ai verifie avec Vitest et Testing Library. La selection d’une date disponible, le refus d’un jour ferme et la selection de plage au clavier fonctionnent. Le premier ajustement concernait le nom accessible: le test doit interroger ce que l’utilisateur entend vraiment, c’est-a-dire la date plus l’etat. Avant la mise en production, j’ajouterais les jours feries, la validation serveur, les messages d’erreur et les limites de capacite.

#Claude Code #calendrier #React #TypeScript #accessibilité
Gratuit

PDF gratuit: cheatsheet Claude Code

Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.

Nous protégeons vos données et n'envoyons pas de spam.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.