A month-view calendar with event creation, multi-day events, and correct date-boundary handling.
Illustration coming soon
HOW TO USE THIS CHALLENGE
1. Read the briefClarify decisions before coding.
2. Build from memoryUse the 100-minute target.
3. Study the guideCompare architecture, tests, and trade-offs.
REQUIREMENTS
Render a month grid with correct day-of-week alignment and leading/trailing days from adjacent months.
Create, edit, and delete events by clicking/dragging on a day cell.
Render multi-day events as a single spanning bar across the days they cover, not a separate entry per day.
Navigate between months (and jump to 'today').
Handle overlapping events on the same day with a legible stacked layout.
EDGE CASES
Month/year boundaries (Dec → Jan) and leap years must compute the grid correctly.
Time zone handling: an event's displayed day must not shift due to UTC/local conversion bugs, especially near midnight.
A multi-day event that starts before the visible month or ends after it should still render its visible portion correctly, clipped at the grid edge.
Many overlapping events in one day shouldn't overflow the cell unreadably — cap visible rows with a '+N more' affordance.
ACCESSIBILITY
The grid should be navigable with arrow keys between days, with roving tabindex so Tab doesn't stop on every single day cell.
Each day cell needs an accessible name including the full date (e.g. 'Tuesday, March 4, 3 events'), not just the day number.
The event creation/edit form must be reachable via keyboard, not only via mouse drag on the grid.
SUGGESTED APPROACH
Compute the grid purely from a given month/year: first weekday offset, days in month, and leading/trailing days from adjacent months, all derived, not stored.
Normalize all event dates to a consistent representation (e.g. UTC midnight or a date-only string) at the data boundary to avoid time-zone drift in day placement.
Represent a multi-day event as start/end dates and compute, per visible week row, which columns it spans, rendering one bar per row rather than one cell per day.
Use a date library (or a small set of well-tested pure date-math helpers) rather than hand-rolling arithmetic with the native Date object's mutable, zero-indexed-month API.
Multi-day events render as a single spanning bar, correctly clipped at week/month edges.
No timezone-induced off-by-one-day bugs in event placement.
Grid supports keyboard navigation with sensible roving focus.
01Understand the product before coding
Learning goals
Render a month grid with correct day-of-week alignment and leading/trailing days from adjacent months.
Create, edit, and delete events by clicking/dragging on a day cell.
Render multi-day events as a single spanning bar across the days they cover, not a separate entry per day.
Navigate between months (and jump to 'today').
Decisions to state aloud
Month/year boundaries (Dec → Jan) and leap years must compute the grid correctly.
Time zone handling: an event's displayed day must not shift due to UTC/local conversion bugs, especially near midnight.
A multi-day event that starts before the visible month or ends after it should still render its visible portion correctly, clipped at the grid edge.
Many overlapping events in one day shouldn't overflow the cell unreadably — cap visible rows with a '+N more' affordance.
02State model and invariants
Represent calendar dates as date-only ISO strings and timed instants separately. Events use exclusive end boundaries and a declared time zone. Visible month, focused date, selected date, and editor state are independent.
Find the locale-configured week start containing the month's first day, then generate complete weeks through the week containing the last day. Adjacent-month cells remain real dates.
TypeScript
1functionmonthGrid(month: PlainDate, weekStartsOn:number){2const start =startOfWeek(month.with({ day:1}), weekStartsOn);3const end =endOfWeek(month.with({ day: month.daysInMonth }), weekStartsOn);4const days: PlainDate[]=[];5for(let day = start;compare(day, end)<=0; day = day.add({ days:1})) days.push(day);6returnchunk(days,7);7}
Step 2
Split multi-day events into week segments
Intersect each event's half-open date range with each visible week. The clipped start and end produce grid-column start and span, so one event renders once per crossed week, not once per day.
Sort segments by start then longer duration, place each into the first lane whose previous segment ends before this begins, and cap visible lanes with a keyboard-operable more-events control.
Step 4
Implement grid navigation
Roving tabindex keeps one day in the Tab sequence. Arrows move by day or week, Home/End move within a week, and Page Up/Down change months while preserving a valid day where possible.
SOLComplete solution, explained simply
Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.
We are building a month calendar that draws the surrounding days to fill complete weeks, shows a multi-day event as one continuous bar that stacks when events overlap, and moves the keyboard focus one day or one week at a time. The one big idea: treat a calendar day as a plain YYYY-MM-DD string (never a timestamp, so no time zone can shift it), use an exclusive end date, and clip each event against each week to get a column and a span.
Calendar.tsx
1import{ useMemo, useState }from"react";23type DateKey= string;// "YYYY-MM-DD"4type CalendarEvent={id: string; title: string; start:DateKey; endExclusive:DateKey};56// --- date-only helpers: no times, so no time zone can move a day ---7constparts=(k:DateKey)=> k.split("-").map(Number)as[number, number, number];8const addDays =(k:DateKey,n: number):DateKey=>{9const[y, m, d]=parts(k);10returnnewDate(Date.UTC(y, m -1, d + n)).toISOString().slice(0,10);11};12constweekday=(k:DateKey)=>{const[y, m, d]=parts(k);returnnewDate(Date.UTC(y, m -1, d)).getUTCDay();};13constdiffDays=(a:DateKey,b:DateKey)=>Math.round((Date.parse(b)-Date.parse(a))/86_400_000);14conststartOfWeek=(k:DateKey,weekStartsOn: number)=>addDays(k,-((weekday(k)- weekStartsOn +7)%7));15consttodayKey=()=>newDate().toISOString().slice(0,10);16constlongLabel=(k:DateKey)=>17newDate(k +"T00:00:00Z").toLocaleDateString(undefined,{18weekday:"long",month:"long",day:"numeric",timeZone:"UTC",19});2021functionbuildGrid(month:DateKey,weekStartsOn: number):DateKey[][]{22const[y, m]=parts(month);23const first = month.slice(0,7)+"-01";24const lastDay =newDate(Date.UTC(y, m,0)).getUTCDate();25const last = month.slice(0,7)+"-"+String(lastDay).padStart(2,"0");26const gridStart =startOfWeek(first, weekStartsOn);27const gridEndExclusive =addDays(startOfWeek(last, weekStartsOn),7);28constweeks:DateKey[][]=[];29for(let d = gridStart; d < gridEndExclusive; d =addDays(d,7)){30 weeks.push(Array.from({length:7},(_, i)=>addDays(d, i)));31}32return weeks;33}3435// Clip one event to one week. Returns the grid column (1..7) and how many days it spans, or null.36functionsegmentForWeek(event:CalendarEvent,weekStart:DateKey){37const weekEndExclusive =addDays(weekStart,7);38const segStart = event.start> weekStart ? event.start: weekStart;39const segEndExclusive = event.endExclusive< weekEndExclusive ? event.endExclusive: weekEndExclusive;40if(segStart >= segEndExclusive)returnnull;41return{column:diffDays(weekStart, segStart)+1,span:diffDays(segStart, segEndExclusive)};42}4344// Stack overlapping segments: put each in the first lane that is free at its start column.45function assignLanes<Textends{column: number; span: number }>(segments:T[]){46constlaneFreeFrom: number[]=[];47return segments48.slice()49.sort((a, b)=> a.column- b.column|| b.span- a.span)50.map((seg)=>{51let lane = laneFreeFrom.findIndex((free)=> free <= seg.column);52if(lane ===-1) lane = laneFreeFrom.length;53 laneFreeFrom[lane]= seg.column+ seg.span;54return{...seg, lane };55});56}5758exportfunctionCalendar({ month, events, weekStartsOn =0}:{59month:DateKey; events:CalendarEvent[]; weekStartsOn?: number;60}){61const weeks =useMemo(()=>buildGrid(month, weekStartsOn),[month, weekStartsOn]);62const flat =useMemo(()=> weeks.flat(),[weeks]);63const[, monthNum]=parts(month);64const[focused, setFocused]= useState<DateKey>(65()=> flat.find((d)=>parts(d)[1]=== monthNum)?? flat[0],66);6768functionmove(deltaDays: number){69const target =addDays(focused, deltaDays);70if(flat.includes(target))setFocused(target);71}72functiononKeyDown(e:React.KeyboardEvent){73constarrows:Record<string, number>={ArrowRight:1,ArrowLeft:-1,ArrowDown:7,ArrowUp:-7};74if(e.keyin arrows){ e.preventDefault();move(arrows[e.key]);}75elseif(e.key==="Home"){ e.preventDefault();setFocused(startOfWeek(focused, weekStartsOn));}76elseif(e.key==="End"){ e.preventDefault();setFocused(addDays(startOfWeek(focused, weekStartsOn),6));}77}7879return(80<div role="grid" aria-label={"Calendar for "+ month.slice(0,7)} onKeyDown={onKeyDown}>81{weeks.map((week)=>{82const segs =assignLanes(83 events84.map((ev)=>{85const s =segmentForWeek(ev, week[0]);86return s ?{...s, ev }:null;87})88.filter((s): s is {column: number; span: number; ev:CalendarEvent}=> s !==null),89);90return(91<div92 key={week[0]}93 role="row"94 style={{display:"grid",gridTemplateColumns:"repeat(7, 1fr)"}}95>96{week.map((day)=>(97<div98 key={day}99 role="gridcell"100 tabIndex={day === focused ?0:-1}101 aria-current={day ===todayKey()?"date":undefined}102 aria-label={longLabel(day)}103 onClick={()=>setFocused(day)}104 style={{gridRow:1,opacity:parts(day)[1]=== monthNum ?1:0.4}}105>106{parts(day)[2]}107</div>108))}109{segs.map((s)=>(110<div111 key={s.ev.id+ week[0]}112 style={{gridColumn: s.column+" / span "+ s.span,gridRow: s.lane+2}}113>114{s.ev.title}115</div>116))}117</div>118);119})}120</div>121);122}
How each part works
A day is a string, not a timestamp
A calendar day like '2026-03-05' is a label, not a moment in time. If you store it as a Date it becomes midnight in some time zone, and formatting it elsewhere can show the 4th or the 6th. Keeping it as 'YYYY-MM-DD' and only using UTC math (Date.UTC) means the day never drifts.
Events use an exclusive end date
endExclusive is the day after the event's last day. A one-day event on the 5th has start '...-05' and endExclusive '...-06'. Half-open ranges make every calculation — length, does-it-cross-this-week, do-two-events-overlap — a simple comparison with no 'add one day at the end' special case.
buildGrid fills complete weeks
Take the first of the month, walk back to the start of its week; take the last of the month, walk forward to the end of its week; then slice that range into rows of seven. The leading and trailing cells are real dates from the neighboring months, just dimmed.
segmentForWeek clips an event to one week
For each week, the visible piece of an event starts at the later of (event start, week start) and ends at the earlier of (event end, week end). If that piece is empty, the event does not appear that week. Otherwise it gives a starting column (1 to 7) and a day span, so a 10-day event renders as one bar in each week it touches, not ten separate day marks.
assignLanes stacks overlapping bars
Sort segments by start column (longer first on ties), then drop each into the first lane whose previous bar has already ended before this one starts. laneFreeFrom[lane] tracks where each lane becomes free again. Overlapping events end up on different rows so they do not sit on top of each other.
Roving focus over the flat day list
Only the focused day has tabIndex 0, so Tab jumps past the whole calendar. Arrow keys add or subtract 1 or 7 days and move focus if the result is still in the visible grid. Home and End jump to the start and end of the current week. The grid is CSS grid, with row 1 for day numbers and rows 2+ for event lanes.
Why this is correct
Represent calendar days as date-only strings with UTC-only math, so no time zone can shift an event onto the wrong day.
Exclusive end dates turn duration, week-crossing, and overlap checks into plain comparisons with no end-of-day special cases.
Generate the grid from week boundaries and keep neighboring-month cells as real, navigable dates.
Render a multi-day event once per week it crosses, as a clipped column-and-span bar, not once per day.
Lane packing places overlapping event bars on separate rows; roving tabindex over the flat day list drives keyboard navigation.