diff --git a/web/src/components/AddDeadlineForm.tsx b/web/src/components/AddDeadlineForm.tsx new file mode 100644 index 0000000..cb791c9 --- /dev/null +++ b/web/src/components/AddDeadlineForm.tsx @@ -0,0 +1,73 @@ +/** + * AddDeadlineForm — a date the student sets themselves. + * + * Shared by the Overview rail and the calendar, which want the same three + * fields and differ only in what the date starts as: the rail has no day in + * mind, the calendar is already looking at one. + */ + +import { useId, useState } from 'react' +import { DEADLINE_MODULES, type DeadlineModule } from '../data/applicationDeadlines' + +interface Props { + /** Prefilled day as `YYYY-MM-DD`; blank leaves the field empty. */ + initialDate?: string + onAdd: (title: string, date: string, module: DeadlineModule) => void + onCancel: () => void +} + +export default function AddDeadlineForm({ initialDate = '', onAdd, onCancel }: Props) { + const [title, setTitle] = useState('') + const [date, setDate] = useState(initialDate) + const [module, setModule] = useState('Application Tracking') + const id = useId() + + return ( +
{ + e.preventDefault() + if (!title.trim() || !date) return + onAdd(title, date, module) + }} + > + + setTitle(e.target.value)} + placeholder="Ask Ms. Reyes for a reference" + maxLength={120} + autoFocus + /> + + setDate(e.target.value)} + /> + + +
+ + +
+
+ ) +} diff --git a/web/src/components/CalendarPage.test.tsx b/web/src/components/CalendarPage.test.tsx index 63050fd..65fee81 100644 --- a/web/src/components/CalendarPage.test.tsx +++ b/web/src/components/CalendarPage.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' // Real derivation and real rendering; only the two data fetches are mocked. @@ -52,6 +52,9 @@ describe('CalendarPage', () => { }) afterEach(() => vi.useRealTimers()) + /** Export lives behind a button now; open it before reaching its controls. */ + const openExport = () => userEvent.click(screen.getByRole('button', { name: 'Export dates' })) + it('pins a tracked scholarship on its deadline date', async () => { H.getTrackerItems.mockResolvedValue([tracked()]) renderPage() @@ -113,9 +116,10 @@ describe('CalendarPage', () => { } const exportWith = async (groupId: string, toggles: string[] = []) => { - await userEvent.selectOptions(await screen.findByLabelText('Export'), groupId) + await openExport() + await userEvent.selectOptions(screen.getByLabelText('Which dates'), groupId) for (const t of toggles) await userEvent.click(screen.getByLabelText(t)) - await userEvent.click(screen.getByRole('button', { name: 'Add to my calendar' })) + await userEvent.click(screen.getByRole('button', { name: 'Download .ics' })) const [events, filename, opts] = H.downloadIcs.mock.calls.at(-1)! return { ids: (events as { id: string }[]).map((e) => e.id), filename, opts } } @@ -164,21 +168,81 @@ describe('CalendarPage', () => { ]) renderPage() - await userEvent.selectOptions(await screen.findByLabelText('Export'), 'scholarships') - expect(screen.getByRole('button', { name: 'Add to my calendar' })).toBeEnabled() + await openExport() + await userEvent.selectOptions(screen.getByLabelText('Which dates'), 'scholarships') + expect(screen.getByRole('button', { name: 'Download .ics' })).toBeEnabled() // The only scholarship has an estimated date, so nothing is left to send. await userEvent.click(screen.getByLabelText('Confirmed dates only')) - expect(screen.getByRole('button', { name: 'Add to my calendar' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Download .ics' })).toBeDisabled() }) it('shows a count against every group so an empty one is visible', async () => { H.getTrackerItems.mockResolvedValue([tracked()]) renderPage() + await screen.findByText('Coca-Cola Scholars') + await openExport() // Only a scholarship is tracked: no college deadlines exist to export. - expect(await screen.findByRole('option', { name: /Every college deadline — 0/ })).toBeInTheDocument() + expect(screen.getByRole('option', { name: /Every college deadline — 0/ })).toBeInTheDocument() expect(screen.getByRole('option', { name: /Scholarships only — 1/ })).toBeInTheDocument() - await userEvent.selectOptions(screen.getByLabelText('Export'), 'applications') - expect(screen.getByRole('button', { name: 'Add to my calendar' })).toBeDisabled() + await userEvent.selectOptions(screen.getByLabelText('Which dates'), 'applications') + expect(screen.getByRole('button', { name: 'Download .ics' })).toBeDisabled() + }) + it('opens a deadline from its tile, without changing the selected day', async () => { + H.getTrackerItems.mockResolvedValue([tracked()]) + renderPage() + await screen.findByText('Coca-Cola Scholars') + // The day panel still shows today, which has nothing on it. + expect(screen.getByText('Nothing on this day')).toBeInTheDocument() + + // The tile entry, not the day behind it. + const tile = screen.getAllByRole('button', { name: /Coca-Cola Scholars/ })[0] + await userEvent.click(tile) + + const panel = await screen.findByRole('dialog', { name: 'Coca-Cola Scholars' }) + expect(within(panel).getByLabelText('Your notes')).toBeInTheDocument() + expect(within(panel).getByRole('button', { name: /Mark Coca-Cola Scholars as complete/ })).toBeInTheDocument() + // Reading a deadline is not picking its day. + expect(screen.getByText('Nothing on this day')).toBeInTheDocument() + }) + + it('adds your own date on the day you picked, prefilled', async () => { + H.getTrackerItems.mockResolvedValue([]) + renderPage() + await screen.findByRole('heading', { name: /Friday, September 4/ }) + + await userEvent.click(screen.getByRole('button', { name: '+ Add your own date' })) + // The day already chosen is the day it lands on — no retyping it. + expect(screen.getByLabelText('When?')).toHaveValue('2026-09-04') + await userEvent.type(screen.getByLabelText('What is it?'), 'Ask for a reference') + expect(screen.getByRole('button', { name: 'Add it' })).toBeEnabled() + }) + + it('opens the day list centred, and a tile beside itself', async () => { + H.getTrackerItems.mockResolvedValue([tracked({ deadline: 'Sep 4, 2026', deadlineDate: '2026-09-04' })]) + renderPage() + await screen.findByRole('heading', { name: /Friday, September 4/ }) + + // The day list runs the full width, so its detail is a centred modal. + const rows = screen.getAllByRole('button', { name: /^Coca-Cola Scholars —/ }) + await userEvent.click(rows[rows.length - 1]) + const dialog = screen.getByRole('dialog', { name: 'Coca-Cola Scholars' }) + expect(dialog).toHaveAttribute('aria-modal', 'true') + // Focus goes in, since it claims the rest of the page is inert. + expect(within(dialog).getByRole('button', { name: /^Close/ })).toHaveFocus() + + await userEvent.keyboard('{Escape}') + // A tile has something to sit beside, so it stays a flyout. + await userEvent.click(screen.getAllByRole('button', { name: /Coca-Cola Scholars/ })[0]) + expect(screen.getByRole('dialog', { name: 'Coca-Cola Scholars' })).not.toHaveAttribute('aria-modal') + }) + + it('says the export is a copy, not a live link', async () => { + H.getTrackerItems.mockResolvedValue([tracked()]) + renderPage() + // "Add to my calendar" read as though it added something to this one. + await openExport() + expect(screen.getByRole('button', { name: 'Download .ics' })).toBeInTheDocument() + expect(screen.getByText(/copy, not a live link/)).toBeInTheDocument() }) }) diff --git a/web/src/components/CalendarPage.tsx b/web/src/components/CalendarPage.tsx index f1c8cb0..628e750 100644 --- a/web/src/components/CalendarPage.tsx +++ b/web/src/components/CalendarPage.tsx @@ -7,7 +7,8 @@ * Picking a day is the only state the grid holds; today is picked on arrival. */ -import { useMemo, useState } from 'react' +import { useId, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' import { motion } from 'framer-motion' import { useDeadlineEvents } from '../lib/useDeadlineEvents' import { downloadIcs } from '../lib/calendarExport' @@ -17,8 +18,13 @@ import { selectDeadlines, eventsOn, sameDay, + type DeadlineEvent, } from '../data/applicationDeadlines' import DeadlineRow from './DeadlineRow' +import DeadlineDetail, { type DeadlineActions } from './DeadlineDetail' +import AddDeadlineForm from './AddDeadlineForm' +import { useAnchoredPanel, PANEL_W } from '../lib/useAnchoredPanel' +import { toIsoDay } from '../lib/personalDeadlines' import { useAuth } from '../contexts/AuthContext' import { useToast } from '../contexts/ToastContext' import { resolveDeadlinePreferences } from '../lib/preferences' @@ -62,6 +68,7 @@ export default function CalendarPage({ startIdx, initialDay }: Props) { const opening = initialDay ?? now const [year, setYear] = useState(opening.getFullYear()) const [month, setMonth] = useState(opening.getMonth()) + const [adding, setAdding] = useState(false) const [selected, setSelected] = useState( new Date(opening.getFullYear(), opening.getMonth(), opening.getDate()), ) @@ -75,7 +82,7 @@ export default function CalendarPage({ startIdx, initialDay }: Props) { () => resolveDeadlinePreferences(profile?.settings), [profile?.settings], ) - const { events, failed, toggleDone, removeOwn, correctDate } = useDeadlineEvents(startIdx, { + const { events, failed, toggleDone, addOwn, removeOwn, correctDate, setNote } = useDeadlineEvents(startIdx, { visibility: deadlinePrefs, onNotice: toast.info, }) @@ -124,72 +131,17 @@ export default function CalendarPage({ startIdx, initialDay }: Props) {

Calendar

Your upcoming deadlines and milestones.

-
-
- - - -
-
- - -
-
+ @@ -221,32 +173,56 @@ export default function CalendarPage({ startIdx, initialDay }: Props) { const isToday = sameDay(date, now) const isSelected = sameDay(date, selected) const pins = tasks.slice(0, PINS_PER_CELL) + const dayName = date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) return ( - setSelected(date)} - // The grid is a picker, so the name has to say what picking it - // gets you — the cell's own text is a number and two fragments. - aria-label={`${date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}, ${ - tasks.length === 0 ? 'nothing due' : `${tasks.length} due`}`} - aria-pressed={isSelected} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 + i * 0.008, duration: 0.3 }} > + )} - + {/* Google-calendar habit: the empty part of a day is where you + put something new. */} + + ) })} @@ -259,9 +235,23 @@ export default function CalendarPage({ startIdx, initialDay }: Props) { animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.4, duration: 0.5, ease: EASE_OUT }} > -

- {selected.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })} -

+
+

+ {selected.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })} +

+ {!adding && ( + + )} +
+ {adding && ( + { addOwn(t, d, m); setAdding(false) }} + onCancel={() => setAdding(false)} + /> + )} {selectedEvents.length === 0 ? (
Nothing on this day @@ -282,7 +272,9 @@ export default function CalendarPage({ startIdx, initialDay }: Props) { onToggle={toggleDone} onRemove={removeOwn} onCorrect={correctDate} + onNote={setNote} fullModule + modal /> )) )} @@ -290,3 +282,148 @@ export default function CalendarPage({ startIdx, initialDay }: Props) {
) } + +/** + * One deadline inside a day cell. + * + * Opens the same panel the Overview rail and the week strip use, anchored to + * the entry, so a date can be read, ticked, noted or removed without leaving + * the month. + */ +function CalendarEntry({ event, now, ...actions }: DeadlineActions & { + event: DeadlineEvent + now: Date +}) { + const panelId = useId() + const { anchorRef, panelRef, open, pos, toggle, close } = useAnchoredPanel() + return ( + <> + + {open && ( + + )} + + ) +} + +/** + * Exporting, behind one button. + * + * A select, two checkboxes, a button and two lines of explanation sat open + * beside the page title and read as the busiest thing on the screen — for + * something a student does once a term, if that. It opens on demand now and + * the explanation comes with it, where there is room to say it properly. + */ +function ExportMenu({ + events, groupId, setGroupId, selection, exportEvents, + upcomingOnly, setUpcomingOnly, confirmedOnly, setConfirmedOnly, +}: { + events: DeadlineEvent[] + groupId: string + setGroupId: (id: string) => void + selection: Parameters[1] + exportEvents: DeadlineEvent[] + upcomingOnly: boolean + setUpcomingOnly: (v: boolean) => void + confirmedOnly: boolean + setConfirmedOnly: (v: boolean) => void +}) { + const panelId = useId() + const { anchorRef, panelRef, open, pos, toggle, close } = useAnchoredPanel() + return ( + <> + + {open && createPortal( + , + document.body, + )} + + ) +} diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index 0460daf..3dfc9a6 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -207,6 +207,7 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P // Next-due deadline per module card, derived from the student's college list. const { events: deadlineEvents, failed: deadlinesFailed, toggleDone, addOwn, removeOwn, correctDate, + setNote, } = useDeadlineEvents(startIdx, { // Entering or leaving a module refetches, so a scholarship added in one // shows up without the panel ever going dark. @@ -233,8 +234,10 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P onAdd={addOwn} onRemove={removeOwn} onCorrect={correctDate} + onNote={setNote} urgentWindow={deadlinePrefs.urgentWindow} onOpenCalendar={() => { setOpenModule(null); setCalendarDay(null); setPage('calendar') }} + onOpenDay={(day) => { setOpenModule(null); setCalendarDay(day); setPage('calendar') }} /> ) const nextDueByModule = useMemo(() => { @@ -412,6 +415,10 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P events={deadlineEvents} now={now} onOpenDay={(day) => { setCalendarDay(day); setPage('calendar') }} + onToggle={toggleDone} + onRemove={removeOwn} + onCorrect={correctDate} + onNote={setNote} /> ) : null} diff --git a/web/src/components/DeadlineDetail.tsx b/web/src/components/DeadlineDetail.tsx new file mode 100644 index 0000000..c7ef2e5 --- /dev/null +++ b/web/src/components/DeadlineDetail.tsx @@ -0,0 +1,243 @@ +/** + * DeadlineDetail — the panel that opens beside a deadline. + * + * Shared by the Overview rail, the calendar's day panel and the week strip, + * which reach it from very different triggers: a full-width row, and a chip a + * few characters wide inside a grid column. Where it sits and when it closes + * belong to the trigger, which owns the anchor — see useAnchoredPanel. This + * draws the contents. + */ + +import { useEffect, useId, useRef, useState, type RefObject } from 'react' +import { createPortal } from 'react-dom' +import { daysUntil, ESTIMATE_HINT, type DeadlineEvent } from '../data/applicationDeadlines' +import { toIsoDay, NOTE_MAX } from '../lib/personalDeadlines' +import { PANEL_W, type PanelPos } from '../lib/useAnchoredPanel' + +/** The handlers a surface can offer. Each is optional: the calendar is already + * showing the day, a read-only list has nothing to write to. */ +export interface DeadlineActions { + onToggle: (event: DeadlineEvent) => void + onRemove?: (id: string) => void + onCorrect?: (id: string, iso: string | null) => void + onNote?: (id: string, text: string) => void + onOpenDay?: (day: Date) => void +} + +interface Props extends DeadlineActions { + event: DeadlineEvent + now: Date + /** + * Centred over the page instead of beside the trigger. For the calendar's + * day list, which runs the full width: there is nothing to sit beside, and + * a flyout there covers the month for no reason. + */ + modal?: boolean + /** Where to draw it; null lays it out as a bottom sheet. */ + pos: PanelPos | null + panelRef: RefObject + onClose: () => void + /** Id the trigger points at with aria-controls. */ + panelId: string +} + +export default function DeadlineDetail({ + event, now, pos, modal, panelRef, onClose, panelId, + onToggle, onRemove, onCorrect, onNote, onOpenDay, +}: Props) { + const offset = daysUntil(event, now) + const [fixing, setFixing] = useState(false) + const [confirmingDelete, setConfirmingDelete] = useState(false) + const [typed, setTyped] = useState(() => toIsoDay(event.date)) + const [note, setNote] = useState(event.note ?? '') + const fieldId = useId() + const closeRef = useRef(null) + + // A modal claims aria-modal, so focus has to actually go into it and come + // back out again — otherwise a screen reader is told the rest of the page is + // inert while the keyboard is still sitting in it. The flyout is a + // disclosure and leaves focus where it was. + useEffect(() => { + if (!modal) return + const previous = document.activeElement as HTMLElement | null + closeRef.current?.focus() + return () => previous?.focus?.() + }, [modal]) + + // We hold no deadline for this school at all — the date on screen came from + // a typical date for the round, not from them. Saying "est." for that is far + // too quiet, so it gets a sentence and a way to put it right. + const invented = event.estimateReason === 'no-source' && !event.done + // A date the student added and can take back. A date they set on a college's + // task is also theirs, but it belongs to that task: removeOwn would not find + // it, so it is cleared where it lives. Both look the same on this panel, and + // the difference has to be said rather than left as a missing button. + const isOwnDate = event.source === 'self' && !event.isTask + const isTaskDate = event.source === 'self' && event.isTask + + const body = ( +