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.
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.
| Caso | Funciones necesarias | Riesgo en produccion |
|---|---|---|
| Reservas de clinicas, salones o consultorias | API de disponibilidad, dias cerrados, slots por zona horaria | El usuario reserva un dia sin capacidad |
| Planificacion en un SaaS | Cambio de mes, seleccion de rango, estado auditable | Equipos remotos ven fechas distintas |
| Calendario editorial | Fecha seleccionada, indicador de hoy, conteo de eventos | La fecha publicada no coincide con la vista |
| Hoteles o eventos | Rango de inicio y fin | Se 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.
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.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Escalera de permisos de Claude Code para ampliar acceso sin perder control
Pasa de read-only a ediciones limitadas, comandos de prueba y checks de deploy con menos riesgo.
Claude Code Small PR Proof Pack: cambios pequeños que sí se pueden revisar
Un paquete de prueba para PRs de Claude Code: diff, checks, URL pública, CTA y rollback.
Gate de revisión antes del commit con Claude Code
Cómo revisar con Claude Code antes del commit: diff, build, URL pública, Gumroad, consultoría, tests y archivos ajenos.