Calendário de produção com Claude Code: React, TypeScript, fusos e testes
Calendário React/TypeScript com fusos horários, acessibilidade, API de disponibilidade, intervalo e testes.
Um calendário parece uma grade simples até entrar em produção. Depois aparecem fim de mês, ano bissexto, horário de verão, fusos horários, dias fechados, navegação por teclado e respostas de API que chegam fora de ordem. Por isso, pedir ao Claude Code apenas uma interface bonita é insuficiente.
Neste guia vamos criar um calendário de reservas com React e TypeScript. Um valor date-only é uma data sem hora, como 2026-06-02. O fuso horário define o que é “hoje” e a que hora acontece uma reserva. ARIA são atributos que informam papel e estado do widget a tecnologias assistivas. Se esses conceitos forem misturados, o usuário escolhe 2 de junho e o servidor pode salvar 1 de junho.
Casos de uso
O componente é útil em fluxos onde uma data errada gera retrabalho ou perda de receita.
| Caso | Recursos necessários | Risco |
|---|---|---|
| Clínica, salão ou consultoria | API de disponibilidade, dias fechados, slots por fuso | Usuário reserva sem capacidade |
| Planejamento em SaaS | Troca de mês, seleção de intervalo, estado auditável | Times remotos veem datas deslocadas |
| Calendário editorial | Data selecionada, destaque de hoje, contagem de eventos | Publicação cai no dia errado |
| Hotel ou evento | Data inicial e final | Intervalo inválido ou preço incorreto |
A acessibilidade se conecta ao guia de acessibilidade com Claude Code e o contrato de API se conecta a testes de API com Claude Code. Para regras oficiais, consulte MDN Date, MDN Intl.DateTimeFormat, WAI-ARIA Grid Pattern, Testing Library e a documentação React sobre estado.
Defina restrições para o Claude Code
O prompt inicial deve registrar as invariantes do componente.
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
A arquitetura fica pequena e verificável.
flowchart LR
A["date-utils.ts<br/>cálculo de datas"] --> B["availability-api.ts<br/>capacidade"]
B --> C["Calendar.tsx<br/>renderização e interação"]
C --> D["calendar.css<br/>estados visuais"]
C --> E["Calendar.test.tsx<br/>regressão"]
Separar cálculo de datas do JSX é a decisão mais importante. Claude Code consegue alterar UI rapidamente, mas se regras de mês, intervalo e disponibilidade ficarem espalhadas no componente, bugs de fuso serão difíceis de revisar. Funções puras tornam os testes de fronteira mais diretos.
Utilitários de data
Salve como src/calendar/date-utils.ts. A data selecionada fica em YYYY-MM-DD, a grade usa aritmética UTC e o fuso só é usado para calcular o “hoje” do usuário.
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;
}
O erro comum é usar toISOString() para salvar a data escolhida. Esse método representa um instante em UTC, não uma data de negócio. Datas de reserva e publicação devem ficar como strings date-only; horários reais são tratados em slots separados.
API mock de disponibilidade
Crie src/calendar/availability-api.ts. Em produção, troque o mock por uma chamada HTTP, mantendo o intervalo, o fuso e o signal.
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;
}
Isso evita que uma resposta antiga sobrescreva o mês atual. Quando o usuário navega rapidamente, a requisição lenta de um mês anterior precisa ser cancelada ou ignorada.
Componente Calendar
Salve como src/calendar/Calendar.tsx. Ele inclui seleção simples, intervalo, disponibilidade assíncrona, dias fechados e 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>
);
}
O roving tabIndex deixa apenas a data ativa na ordem de Tab. Dentro da grade, as setas movem o foco, o que é melhor do que tabular por dezenas de dias.
CSS e testes
src/calendar/calendar.css exibe estado com texto e cor.
.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;
}
Instale as dependências de teste.
npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
src/calendar/Calendar.test.tsx valida comportamento.
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",
});
});
});
Armadilhas e resultado
Primeiro, não salve datas de negócio com toISOString(). Segundo, não confunda locale com fuso horário. Terceiro, não comunique disponibilidade apenas por cor. Quarto, não deixe os testes para depois; peça ao Claude Code desde o início testes de fim de mês, dias fechados, intervalo e teclado.
Para equipes que criam componentes parecidos com frequência, vale transformar essas regras em prompts e checklists. Os produtos Claude Code podem reunir geração de componente, revisão, testes e integração de API.
Colei o código em um projeto Vite + React + TypeScript e rodei Vitest com Testing Library. A seleção de data disponível, a rejeição de data fechada e a seleção de intervalo por teclado funcionaram. O primeiro ajuste foi no nome acessível usado pelo teste: ele deve consultar o que o usuário realmente percebe, ou seja, data mais estado. Antes de publicar, adicionaria feriados reais, validação no servidor, mensagens de erro e limites de capacidade.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Como pedir ao Claude Code para mexer em um único arquivo
Do desastre em que um 'deixa melhor' alterou 40 linhas nasceu um template de prompt que limita o escopo, valida e permite reverter.
Recuperar de negações de permissão no Claude Code sem enfraquecer guardrails
Transforme um comando negado em plano seguro com motivo, alternativa, provas e critérios de nova tentativa.
Claude Code Harness Smoke Test: prova de 15 minutos antes de confiar em um agente
Um smoke test para escopo, áreas bloqueadas, comandos de prova, URL pública e CTAs de receita no Claude Code.