Claude Code से production calendar component: React, TypeScript, time zone और tests
React/TypeScript कैलेंडर: टाइम ज़ोन, एक्सेसिबिलिटी, उपलब्धता API, रेंज और टेस्ट।
Calendar component demo में आसान लगता है, लेकिन production में वही component जल्दी जटिल हो जाता है। महीने का अंत, leap year, daylight saving, user का time zone, बंद दिन, keyboard navigation और availability API की देर से आने वाली response, सब एक साथ असर डालते हैं। इसलिए Claude Code से सिर्फ “सुंदर calendar बनाओ” कहना काफी नहीं है।
इस लेख में हम React और TypeScript से booking calendar बनाएंगे। date-only value का मतलब है ऐसी तारीख जिसमें time नहीं होता, जैसे 2026-06-02। time zone तय करता है कि किसी user के लिए “आज” और booking slot का समय क्या है। ARIA वे attributes हैं जो screen reader जैसे assistive tools को widget की role और state बताते हैं। इन तीन बातों को अलग न रखा जाए तो user 2 June चुन सकता है और server 1 June save कर सकता है।
उपयोग के मामले
यह component उन जगहों पर काम आता है जहां गलत तारीख सीधा नुकसान कर सकती है।
| Use case | जरूरी capability | Production risk |
|---|---|---|
| clinic, salon या coaching booking | availability API, closed days, time-zone slots | user unavailable day book कर देता है |
| SaaS admin scheduling | month switch, range selection, audit-friendly state | remote teams अलग date देखते हैं |
| editorial calendar | selected date, today highlight, event count | publish date और UI match नहीं करते |
| hotel या event booking | start और end date range | invalid range या गलत price save होता है |
Accessibility के लिए Claude Code accessibility guide और API contract के लिए Claude Code API testing guide उपयोगी हैं। Official references में MDN Date, MDN Intl.DateTimeFormat, WAI-ARIA Grid Pattern, Testing Library और React state docs शामिल हैं।
Claude Code को constraints दें
पहला prompt UI description नहीं, implementation contract होना चाहिए।
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
Architecture छोटी रखें।
flowchart LR
A["date-utils.ts<br/>date math"] --> B["availability-api.ts<br/>capacity"]
B --> C["Calendar.tsx<br/>render और interaction"]
C --> D["calendar.css<br/>visual states"]
C --> E["Calendar.test.tsx<br/>regression tests"]
Date logic को JSX से अलग रखना सबसे जरूरी decision है। Claude Code UI जल्दी बदल सकता है, लेकिन month grid, range और availability logic component के अंदर फैल जाए तो edge cases review करना मुश्किल हो जाता है। Pure functions होने पर Claude Code से पहले boundary tests लिखवाना आसान होता है।
Date utilities
इसे src/calendar/date-utils.ts में रखें। Selected day YYYY-MM-DD में save होता है, month grid UTC arithmetic से बनता है, और time zone सिर्फ user के “today” को निकालने में इस्तेमाल होता है।
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;
}
Common pitfall है selected date को toISOString() से save करना। यह UTC instant है, business date नहीं। Booking day, publish day और deadline date-only string रहने चाहिए; actual time slots को अलग time zone के साथ रखें।
Availability API mock
इसे src/calendar/availability-api.ts में रखें। Production में HTTP API आएगी, लेकिन request shape वही रखें: visible range, time zone और abort 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;
}
यह stale response problem रोकता है। User जल्दी month बदलता है तो पुराने request की slow response current screen को overwrite नहीं करनी चाहिए।
Calendar component
src/calendar/Calendar.tsx single date, range, async availability, closed days और keyboard navigation संभालता है।
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>
);
}
Roving tabIndex का मतलब है Tab order में सिर्फ active day रहता है। Grid के अंदर arrow keys focus move करती हैं, इसलिए keyboard user को 35 या 42 buttons पर बार-बार Tab नहीं करना पड़ता।
CSS और tests
State को सिर्फ color से नहीं, text से भी दिखाएं। यह src/calendar/calendar.css है।
.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;
}
Testing dependencies install करें।
npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
src/calendar/Calendar.test.tsx user behavior verify करता है।
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",
});
});
});
Pitfalls और result
पहला pitfall: business date को toISOString() से save न करें। दूसरा: locale और time zone अलग रखें। locale display language है, time zone booking time का आधार है। तीसरा: status सिर्फ color से न बताएं; visible text, focus और aria-disabled दें। चौथा: Claude Code से tests बाद में न लिखवाएं। Month boundary, closed day, range sorting और keyboard पहले prompt में होने चाहिए।
अगर आपकी team बार-बार calendar, form और API components बनाती है, तो constraints को reusable prompts में रखें। Claude Code products में component generation, review, testing और API integration checklists रखे जा सकते हैं।
मैंने इस code को Vite + React + TypeScript project में paste करके Vitest और Testing Library से चलाया। Available date selection, closed date rejection और keyboard range selection काम किए। पहला adjustment accessible name के कारण था: test को वही name query करना चाहिए जो user perceive करता है, यानी date plus status। Production से पहले real holidays, server validation, error copy और capacity limits जोड़ना चाहिए।
मुफ़्त PDF: Claude Code cheatsheet
Email डालें और commands, review habits तथा safe workflow वाली एक-page PDF पाएँ.
हम आपका data सुरक्षित रखते हैं और spam नहीं भेजते.
लेखक के बारे में
Masa
Claude Code workflow और team adoption पर काम करने वाला engineer.
संबंधित लेख
Claude Code permission safety ladder: access धीरे-धीरे बढ़ाएं
read-only से limited edits, proof commands और deploy checks तक permission बढ़ाने की सुरक्षित ladder.
Claude Code Small PR Proof Pack: छोटे PR को review-ready बनाना
Claude Code PR के लिए diff, checks, public URL, CTA path और rollback वाला practical proof pack.
Claude Code Review Gate Before Commit: diff, test, public URL और CTA जांच
Claude Code से commit से पहले review gate बनाएं: diff, build, public URL, Gumroad, consultation, tests और unrelated files।