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
Escada de segurança de permissões no Claude Code
Amplie de read-only para edições limitadas, comandos de prova e deploy checks sem perder controle.
Claude Code Small PR Proof Pack: pequenas mudanças fáceis de revisar
Um pacote de prova para PRs do Claude Code: diff, checks, URL pública, CTA e rollback.
Gate de revisão antes do commit com Claude Code
Revisão antes do commit com Claude Code: diff, build, URL pública, Gumroad, consultoria, testes e arquivos fora do escopo.