Tips & Tricks (Atualizado: 02/06/2026)

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.

Calendário de produção com Claude Code: React, TypeScript, fusos 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.

CasoRecursos necessáriosRisco
Clínica, salão ou consultoriaAPI de disponibilidade, dias fechados, slots por fusoUsuário reserva sem capacidade
Planejamento em SaaSTroca de mês, seleção de intervalo, estado auditávelTimes remotos veem datas deslocadas
Calendário editorialData selecionada, destaque de hoje, contagem de eventosPublicação cai no dia errado
Hotel ou eventoData inicial e finalIntervalo 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.

#Claude Code #calendário #React #TypeScript #acessibilidade
Grátis

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.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.