255 lines
10 KiB
JavaScript
255 lines
10 KiB
JavaScript
/* ============================================================
|
||
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 is hydrated from the pipeline board; user id prefers the
|
||
interview row so unassigned applications (absent from the board) still
|
||
open a profile. 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: iv.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 (
|
||
<div className="page">
|
||
<PageHeader
|
||
title="Calendar"
|
||
sub={`Interview schedule at a glance${monthQuery.isSuccess ? ` · ${events.length} this month` : ''}`}
|
||
actions={<>
|
||
<div className="flex items-center gap-8">
|
||
<button className="btn btn-icon btn-secondary" onClick={() => step(-1)} aria-label="Previous month">
|
||
<Icon name="chevron-left" />
|
||
</button>
|
||
<span className="fw-600" style={{ minWidth: 140, textAlign: 'center' }}>{monthName}</span>
|
||
<button className="btn btn-icon btn-secondary" onClick={() => step(1)} aria-label="Next month">
|
||
<Icon name="chevron-right" />
|
||
</button>
|
||
</div>
|
||
<button
|
||
className="btn btn-secondary"
|
||
onClick={() => setView({ year: today.getFullYear(), month: today.getMonth() })}
|
||
>
|
||
Today
|
||
</button>
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={() => navigate('/interviews', { state: { openSchedule: true } })}
|
||
>
|
||
<Icon name="plus" /> Schedule
|
||
</button>
|
||
</>}
|
||
/>
|
||
|
||
{monthQuery.isError ? (
|
||
<div className="card">
|
||
<div className="card-body">
|
||
<EmptyState icon="alert" title="Couldn’t load the calendar">
|
||
{friendlyAuthError(monthQuery.error, 'The server did not return interviews.')}
|
||
{' '}This screen needs the <code>candidates.view</code> permission.
|
||
</EmptyState>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="grid g-2-1">
|
||
<div className="card">
|
||
<div className="card-body">
|
||
<div className="cal-grid">
|
||
{DOW.map((d) => <div className="cal-dow" key={d}>{d}</div>)}
|
||
{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 (
|
||
<div className={`cal-cell ${c.other ? 'other' : ''} ${isToday ? 'today' : ''}`} key={i}>
|
||
<div className="cal-date">{c.day}</div>
|
||
{dayEvents.slice(0, 3).map((iv) => (
|
||
<div
|
||
key={iv.id}
|
||
className={`cal-event ${EVENT_COLORS[iv.type] || 'b-blue'}`}
|
||
title={`${iv.candidate} · ${iv.type}${iv.jobTitle ? ` · ${iv.jobTitle}` : ''}`}
|
||
onClick={() => openCandidate(iv.userId)}
|
||
>
|
||
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} {iv.candidate.split(' ')[0]}
|
||
</div>
|
||
))}
|
||
{dayEvents.length > 3 && (
|
||
<div className="cal-event b-gray">+{dayEvents.length - 3} more</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card" style={{ alignSelf: 'start' }}>
|
||
<div className="card-head">
|
||
<div>
|
||
<h3>Today</h3>
|
||
<span className="ch-sub">
|
||
{today.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="card-body">
|
||
<div className="list-tight">
|
||
{monthQuery.isPending ? (
|
||
<p className="text-muted">Loading…</p>
|
||
) : todayIvs.length === 0 ? (
|
||
<p className="text-muted">No interviews today</p>
|
||
) : (
|
||
todayIvs.map((iv) => (
|
||
<div
|
||
key={iv.id}
|
||
className="list-row"
|
||
style={{ cursor: iv.userId ? 'pointer' : 'default' }}
|
||
onClick={() => openCandidate(iv.userId)}
|
||
>
|
||
<Avatar
|
||
name={iv.candidate}
|
||
initials={initialsOf(iv.candidate)}
|
||
color={avatarColor(iv.candidate)}
|
||
/>
|
||
<div className="lr-main">
|
||
<div className="lr-title">{iv.candidate}</div>
|
||
<div className="lr-sub">{iv.type}</div>
|
||
</div>
|
||
<div className="lr-right">
|
||
<div className="fw-600 text-sm">
|
||
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
||
</div>
|
||
{iv.webLink && (
|
||
<a
|
||
className="lr-sub"
|
||
href={iv.webLink}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
Open in Outlook
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|