/* ============================================================ Calendar — live on GET /interview/fetch, scoped to the visible month. The month grid is the prototype's, unchanged. What moved is the data source and the window: `from_date`/`to_date` are sent for the month on screen, so paging back a year is one small request rather than a filter over everything ever scheduled. Each month is its own query key, so revisiting a month you already looked at repaints from cache. "Today" is real time now. The prototype pinned TODAY to 2026-07-09 so its generated dates stayed stable; with live rows that pin would highlight the wrong cell and show an empty agenda every day of the year. ============================================================ */ import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import PageHeader from '../ui/PageHeader' import { Avatar, EmptyState, Icon } from '../ui/primitives' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as interviewsApi from '../api/interviews' import { byInboxId, useApplications } from '../lib/useApplications' import { avatarColor, initials as initialsOf } from '../data/seed' /* Round -> event colour. Unknown rounds fall through to blue rather than vanishing; `interview_type` is free text, so an unrecognised value is normal. */ const EVENT_COLORS = { 'Phone Screen': 'b-blue', Technical: 'b-indigo', 'System Design': 'b-purple', 'Onsite Loop': 'b-teal', 'Hiring Manager': 'b-amber', 'Culture Fit': 'b-green', 'Final Round': 'b-red', } const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] /** The prototype's hand-built month grid, unchanged in behaviour. */ function buildCells(year, month) { const startDow = new Date(year, month, 1).getDay() const daysInMonth = new Date(year, month + 1, 0).getDate() const prevDays = new Date(year, month, 0).getDate() const cells = [] for (let i = startDow - 1; i >= 0; i--) cells.push({ day: prevDays - i, other: true }) for (let d = 1; d <= daysInMonth; d++) cells.push({ day: d, other: false, date: new Date(year, month, d) }) while (cells.length % 7 !== 0 || cells.length < 42) { cells.push({ day: cells.length - daysInMonth - startDow + 1, other: true }) } return cells.slice(0, 42) } export default function Calendar() { const navigate = useNavigate() const today = useMemo(() => new Date(), []) const [{ year, month }, setView] = useState({ year: today.getFullYear(), month: today.getMonth() }) /* Half-open [from, to): the route filters `interview_date >= from` and `< to`, so passing the 1st of the next month includes the whole month without an off-by-one on the last day. */ const from = useMemo(() => new Date(year, month, 1), [year, month]) const to = useMemo(() => new Date(year, month + 1, 1), [year, month]) const monthQuery = useQuery({ queryKey: qk.interviews.range({ month: `${year}-${String(month + 1).padStart(2, '0')}` }), queryFn: async () => { const res = await interviewsApi.listRange({ fromDate: from.toISOString(), toDate: to.toISOString(), top: 500, }) const rows = Array.isArray(res?.data) ? res.data : [] return rows.map(interviewsApi.toInterviewView) }, }) /* Job title and the candidate's user id are not on the interview row; the application supplies both. One extra request for the whole screen. */ const appsQuery = useApplications() const appByInbox = useMemo(() => byInboxId(appsQuery.data), [appsQuery.data]) const events = useMemo( () => (monthQuery.data ?? []) .filter((iv) => iv.when) .map((iv) => { const app = appByInbox.get(iv.inboxId) return { ...iv, jobTitle: iv.jobTitle || app?.jobTitle || null, userId: app?.userId ?? null, } }), [monthQuery.data, appByInbox], ) /* One pass into a day bucket, so the 42 cells below are lookups rather than 42 filters over the month. */ const byDay = useMemo(() => { const map = new Map() for (const e of events) { const key = e.when.toDateString() if (!map.has(key)) map.set(key, []) map.get(key).push(e) } for (const list of map.values()) list.sort((a, b) => a.when - b.when) return map }, [events]) const cells = useMemo(() => buildCells(year, month), [year, month]) const monthName = new Date(year, month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) const todayKey = today.toDateString() const todayIvs = byDay.get(todayKey) ?? [] const step = (delta) => setView(({ year: y, month: m }) => { const next = m + delta if (next < 0) return { year: y - 1, month: 11 } if (next > 11) return { year: y + 1, month: 0 } return { year: y, month: next } }) const openCandidate = (userId) => { if (!userId) return navigate('/candidates', { state: { openCandidate: userId } }) } return (
{monthName}
} /> {monthQuery.isError ? (
{friendlyAuthError(monthQuery.error, 'The server did not return interviews.')} {' '}This screen needs the candidates.view permission.
) : (
{DOW.map((d) =>
{d}
)} {cells.map((c, i) => { const dayEvents = !c.other && c.date ? (byDay.get(c.date.toDateString()) ?? []) : [] const isToday = !c.other && c.date && c.date.toDateString() === todayKey return (
{c.day}
{dayEvents.slice(0, 3).map((iv) => (
openCandidate(iv.userId)} > {iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} {iv.candidate.split(' ')[0]}
))} {dayEvents.length > 3 && (
+{dayEvents.length - 3} more
)}
) })}

Today

{today.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
{monthQuery.isPending ? (

Loading…

) : todayIvs.length === 0 ? (

No interviews today

) : ( todayIvs.map((iv) => (
openCandidate(iv.userId)} >
{iv.candidate}
{iv.type}
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
{iv.webLink && ( e.stopPropagation()} > Open in Outlook )}
)) )}
)}
) }