Tips & Tricks (Actualizado: 2/6/2026)

Calendario de producción con Claude Code: React, TypeScript, zonas horarias y tests

Implementa un calendario React/TypeScript con zonas horarias, accesibilidad, API de disponibilidad, rangos y pruebas.

Calendario de producción con Claude Code: React, TypeScript, zonas horarias y tests

Un calendario parece una cuadricula sencilla hasta que entra en produccion. Entonces aparecen los finales de mes, los anos bisiestos, el horario de verano, las zonas horarias, los dias cerrados, la navegacion con teclado y las respuestas de API que llegan fuera de orden. Por eso no basta con pedirle a Claude Code que pinte un calendario bonito.

En este articulo construiremos un calendario de reservas con React y TypeScript. Un valor date-only es una fecha sin hora, como 2026-06-02. La zona horaria define que significa “hoy” y a que hora ocurre una reserva. ARIA son atributos que ayudan a lectores de pantalla y otras tecnologias de asistencia a entender el rol y el estado del widget. Si estos conceptos se mezclan, un usuario puede elegir 2 de junio y el servidor guardar 1 de junio.

Casos de uso reales

El componente esta pensado para flujos donde el fallo cuesta dinero o confianza.

CasoFunciones necesariasRiesgo en produccion
Reservas de clinicas, salones o consultoriasAPI de disponibilidad, dias cerrados, slots por zona horariaEl usuario reserva un dia sin capacidad
Planificacion en un SaaSCambio de mes, seleccion de rango, estado auditableEquipos remotos ven fechas distintas
Calendario editorialFecha seleccionada, indicador de hoy, conteo de eventosLa fecha publicada no coincide con la vista
Hoteles o eventosRango de inicio y finSe guarda un rango invalido o se calcula mal el precio

La parte de accesibilidad conecta con la guia de accesibilidad con Claude Code y la parte de contrato de API con pruebas de API con Claude Code. Para las reglas oficiales, usa MDN Date, MDN Intl.DateTimeFormat, el patron Grid de WAI-ARIA, Testing Library y la guia de estado en React.

Especifica restricciones para Claude Code

Un buen prompt no describe solo la interfaz. Tambien fija las reglas que no se deben romper.

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

La arquitectura se mantiene pequena y comprobable.

flowchart LR
  A["date-utils.ts<br/>calculo de fechas"] --> B["availability-api.ts<br/>capacidad"]
  B --> C["Calendar.tsx<br/>render e interaccion"]
  C --> D["calendar.css<br/>estados visuales"]
  C --> E["Calendar.test.tsx<br/>regresion"]

Separar el calculo de fechas del JSX es la decision clave. Claude Code puede iterar rapido sobre el diseno, pero si la logica de mes, rango y disponibilidad queda dentro del componente, los errores de zona horaria seran dificiles de revisar. Con funciones puras puedes pedir pruebas de frontera antes de tocar la UI.

Utilidades de fecha

Copia esto en src/calendar/date-utils.ts. Las fechas seleccionadas se guardan como YYYY-MM-DD; la cuadricula mensual se calcula con aritmetica UTC; y la zona horaria solo se usa para saber que dia es “hoy” para el usuario.

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;
}

El error tipico es guardar la seleccion con toISOString(). Ese metodo representa un instante en UTC, no una fecha de negocio. Para dias de reserva, publicacion o vencimiento, guarda un string date-only y trata los slots con hora por separado.

API mock de disponibilidad

Guarda esto como src/calendar/availability-api.ts. En produccion se cambia por fetch, pero el contrato se mantiene: rango visible, zona horaria y una senal para cancelar.

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;
}

Esto evita que una respuesta vieja sobrescriba el mes actual. Si el usuario cambia de junio a julio y vuelve a junio, el request lento de julio no debe pintar la pantalla final.

Componente Calendar

Este es src/calendar/Calendar.tsx. Incluye seleccion simple, rango, disponibilidad asincrona, dias cerrados y navegacion con teclado.

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>
  );
}

La tecnica de roving tabIndex evita que el usuario tenga que pulsar Tab por cada dia visible. Tab entra al calendario y las flechas se encargan de moverse dentro de la cuadricula.

CSS y pruebas

src/calendar/calendar.css muestra el estado con texto y color.

.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;
}

Instala las dependencias de prueba.

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

src/calendar/Calendar.test.tsx comprueba comportamiento visible para el usuario.

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",
    });
  });
});

Errores frecuentes y resultado

Primero, no guardes fechas de negocio con toISOString(). Segundo, no confundas locale con zona horaria: el locale decide el idioma, la zona horaria decide el significado de la hora. Tercero, no comuniques disponibilidad solo con color; usa texto, foco visible y aria-disabled. Cuarto, no dejes las pruebas para el final. Pide a Claude Code desde el primer prompt pruebas de cierre, rango y teclado.

Para equipos que repiten componentes de formularios, reservas y APIs, conviene convertir estas reglas en plantillas reutilizables. Puedes organizar prompts, revisiones y checklists en productos de Claude Code para reducir retrabajo.

Probe el codigo en un proyecto Vite + React + TypeScript con Vitest y Testing Library. Funcionaron la seleccion de fecha disponible, el rechazo de fecha cerrada y la seleccion de rango por teclado. El primer ajuste fue la consulta del test: el nombre accesible real incluye la fecha y el estado, asi que conviene probar lo que el usuario realmente percibe. Antes de publicar agregaria festivos reales, validacion del servidor, mensajes de error y limites de capacidad.

#Claude Code #calendario #React #TypeScript #accesibilidad
Gratis

PDF gratis: cheatsheet de Claude Code

Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.

Cuidamos tus datos y no enviamos spam.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.