From a5511d94789220f248fa6b9ebc600190c9c207cf Mon Sep 17 00:00:00 2001 From: Danial Beg Date: Mon, 28 Sep 2026 21:56:19 -0700 Subject: [PATCH 01/10] Open a deadline instead of finishing it by accident MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole row was one button that marked the deadline done. People clicked it expecting the row to open, ticked something off instead, and watched it vanish — done deadlines are hidden, so the row disappeared under the cursor, which reads as the app losing it rather than as the app agreeing. The row body now opens a detail panel. Finishing something is a deliberate act: the tick box, which is its own small target, or the labelled "Mark as complete" button inside the panel. A deadline ticked off here also stays where it is, struck through, rather than being filtered out mid-click. Long enough to see what happened and to undo it. The panel holds what there was nowhere to put before: the full date and how long is left, why a date is estimated, a jump to that day in the calendar, and the student's own notes. Notes are new — they attach to any deadline by event id rather than to the underlying record, because most deadlines are not the student's to edit. A college's date is derived and a scholarship's belongs to the tracker; a note is the one thing that can hang on either, and it survives the event being re-derived on the next load. Saved when the field is left, not per keystroke, since every keystroke is a settings write. Week-strip events open their day too. They were not clickable at all, and at that size a tap is as likely to be a mis-tap, so they lead to the day panel where a deadline can actually be worked on rather than toggling anything. Every action inside an opened row names its deadline. Several rows can be open at once, and "Remove" alone says nothing about what it removes. The sidebar is called Overview. --- web/src/components/CalendarPage.tsx | 3 +- web/src/components/Dashboard.tsx | 3 + web/src/components/DeadlinePanel.test.tsx | 65 +++++++- web/src/components/DeadlinePanel.tsx | 34 +++- web/src/components/DeadlineRow.tsx | 188 ++++++++++++++++------ web/src/components/WeekOverview.tsx | 9 +- web/src/data/applicationDeadlines.ts | 3 + web/src/index.css | 58 ++++++- web/src/lib/personalDeadlines.ts | 39 +++++ web/src/lib/useDeadlineEvents.ts | 33 +++- 10 files changed, 358 insertions(+), 77 deletions(-) diff --git a/web/src/components/CalendarPage.tsx b/web/src/components/CalendarPage.tsx index f1c8cb0..265c596 100644 --- a/web/src/components/CalendarPage.tsx +++ b/web/src/components/CalendarPage.tsx @@ -75,7 +75,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, removeOwn, correctDate, setNote } = useDeadlineEvents(startIdx, { visibility: deadlinePrefs, onNotice: toast.info, }) @@ -282,6 +282,7 @@ export default function CalendarPage({ startIdx, initialDay }: Props) { onToggle={toggleDone} onRemove={removeOwn} onCorrect={correctDate} + onNote={setNote} fullModule /> )) diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index 0460daf..685b0e6 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(() => { diff --git a/web/src/components/DeadlinePanel.test.tsx b/web/src/components/DeadlinePanel.test.tsx index 07dac3c..54798dd 100644 --- a/web/src/components/DeadlinePanel.test.tsx +++ b/web/src/components/DeadlinePanel.test.tsx @@ -15,6 +15,9 @@ const ev = (over: Partial & { id: string }): DeadlineEvent => ({ }) const noop = () => {} +/** The row body, which opens the deadline. Its name starts with the title, + * which the tick-off button's does not. */ +const open = (title: string) => screen.getByRole('button', { name: new RegExp(`^${title} —`) }) const renderPanel = (events: DeadlineEvent[], over: Partial[0]> = {}) => render( { it('lists only the week, but says what is coming after it', () => { renderPanel([ev({ id: 'soon', date: day(2) }), ev({ id: 'far', date: day(30) })]) // Rows stop at a week; the calendar holds the rest. - expect(screen.getByRole('button', { name: /soon/ })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /far/ })).not.toBeInTheDocument() + expect(open('soon')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /^far —/ })).not.toBeInTheDocument() // But "how long have I got" is the question a student is really asking, // and nothing inside a seven-day window can answer it. expect(screen.getByText(/After this week/)).toHaveTextContent('about 4 weeks') @@ -83,17 +86,56 @@ describe('DeadlinePanel', () => { const onToggle = vi.fn() const event = ev({ id: 'harvard', sourceRef: 'harvard' }) renderPanel([event], { onToggle }) - await userEvent.click(screen.getByRole('button', { name: /harvard/ })) + await userEvent.click(screen.getByRole('button', { name: 'Tick off harvard' })) expect(onToggle).toHaveBeenCalledWith(event) }) - it('offers removal only for dates the student set themselves', () => { + it('opens the deadline when the row is clicked, and ticks nothing off', async () => { + // Students clicked the row expecting it to open and marked things done by + // accident, which then vanished from the list. + const onToggle = vi.fn() + renderPanel([ev({ id: 'harvard', sourceRef: 'harvard' })], { onToggle }) + await userEvent.click(open('harvard')) + expect(onToggle).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Mark harvard as complete' })).toBeInTheDocument() + }) + + it('finishes a deadline from the button inside it', async () => { + const onToggle = vi.fn() + const event = ev({ id: 'harvard', sourceRef: 'harvard' }) + renderPanel([event], { onToggle }) + await userEvent.click(open('harvard')) + await userEvent.click(screen.getByRole('button', { name: 'Mark harvard as complete' })) + expect(onToggle).toHaveBeenCalledWith(event) + }) + + it('keeps a deadline on screen after it is ticked off', async () => { + // It used to disappear under the cursor, which reads as the app losing it. + renderPanel([ev({ id: 'harvard', sourceRef: 'harvard' })]) + await userEvent.click(screen.getByRole('button', { name: 'Tick off harvard' })) + expect(screen.getByRole('button', { name: /^harvard —/ })).toBeInTheDocument() + }) + + it('saves a note when the field is left, not on every keystroke', async () => { + const onNote = vi.fn() + renderPanel([ev({ id: 'harvard', sourceRef: 'harvard' })], { onNote }) + await userEvent.click(open('harvard')) + await userEvent.type(screen.getByLabelText('Your notes'), 'ask Ms Patel') + expect(onNote).not.toHaveBeenCalled() + await userEvent.tab() + expect(onNote).toHaveBeenCalledWith('harvard', 'ask Ms Patel') + }) + + it('offers removal only for dates the student set themselves', async () => { renderPanel([ ev({ id: 'mine', source: 'self', category: 'own' }), ev({ id: 'theirs' }), ]) // A college's deadline is a fact, not something to delete. + // Both live inside the opened row now. + await userEvent.click(open('mine')) expect(screen.getByRole('button', { name: 'Remove mine' })).toBeInTheDocument() + await userEvent.click(open('theirs')) expect(screen.queryByRole('button', { name: 'Remove theirs' })).not.toBeInTheDocument() }) @@ -182,7 +224,8 @@ describe('DeadlinePanel — a date we invented', () => { it('takes the real date from the student', async () => { const onCorrect = vi.fn() renderPanel([invented()], { onCorrect }) - await userEvent.click(screen.getByRole('button', { name: 'Set date' })) + await userEvent.click(open('Ohio State — Early Action')) + await userEvent.click(screen.getByRole('button', { name: 'Set the real date for Ohio State — Early Action' })) const field = screen.getByLabelText(/Ohio State deadline, from their site/) await userEvent.clear(field) await userEvent.type(field, '2026-11-15') @@ -190,9 +233,13 @@ describe('DeadlinePanel — a date we invented', () => { expect(onCorrect).toHaveBeenCalledWith('osu', '2026-11-15') }) - it('offers no correction once the deadline is done', () => { + it('offers no correction once the deadline is done', async () => { renderPanel([invented({ done: true })], { }) - expect(screen.queryByRole('button', { name: 'Set date' })).not.toBeInTheDocument() + // Done rows are hidden, so reveal it first — otherwise this passes + // whether or not the correction is offered. + await userEvent.click(screen.getByRole('button', { name: 'Show completed' })) + await userEvent.click(open('Ohio State — Early Action')) + expect(screen.queryByRole('button', { name: /Set the real date/ })).not.toBeInTheDocument() }) }) @@ -225,12 +272,14 @@ describe('DeadlinePanel — a date that belongs to neither module', () => { expect(screen.queryByText('Financial Aid')).not.toBeInTheDocument() }) - it('offers “remove” on the student’s own dates but not on task dates', () => { + it('offers “remove” on the student’s own dates but not on task dates', async () => { renderPanel([ ev({ id: 'own-1', title: 'Driving test', shortTitle: 'Driving test', source: 'self', category: 'own', module: 'Custom' }), ev({ id: 'task-sc-1::essays', title: 'Draft the essay — Alpha', shortTitle: 'Draft the essay', source: 'self', category: 'own', isTask: true }), ]) + await userEvent.click(open('Driving test')) expect(screen.getByRole('button', { name: 'Remove Driving test' })).toBeInTheDocument() + await userEvent.click(open('Draft the essay — Alpha')) expect(screen.queryByRole('button', { name: 'Remove Draft the essay — Alpha' })).not.toBeInTheDocument() }) }) diff --git a/web/src/components/DeadlinePanel.tsx b/web/src/components/DeadlinePanel.tsx index 46b88d0..2fe423a 100644 --- a/web/src/components/DeadlinePanel.tsx +++ b/web/src/components/DeadlinePanel.tsx @@ -9,7 +9,7 @@ * this size can show honestly; the calendar holds the rest. */ -import { useState } from 'react' +import { useCallback, useState } from 'react' import { bucketDeadlines, daysUntil, @@ -31,14 +31,18 @@ interface Props { onAdd: (title: string, date: string, module: DeadlineModule) => void onRemove: (id: string) => void onCorrect: (id: string, iso: string | null) => void + onNote?: (id: string, text: string) => void onOpenCalendar: () => void + /** Open the calendar on one deadline's day. */ + onOpenDay?: (day: Date) => void failed: boolean /** Days ahead that still count as urgent, from the settings page. */ urgentWindow?: number } export default function DeadlinePanel({ - events, now, onToggle, onAdd, onRemove, onCorrect, onOpenCalendar, failed, urgentWindow, + events, now, onToggle, onAdd, onRemove, onCorrect, onNote, onOpenCalendar, onOpenDay, + failed, urgentWindow, }: Props) { const [showDone, setShowDone] = useState(false) const [adding, setAdding] = useState(false) @@ -46,8 +50,26 @@ export default function DeadlinePanel({ const [date, setDate] = useState('') const [module, setModule] = useState('Application Tracking') + // Ticking something off used to delete it from the screen mid-click, which + // read as the app losing it. Anything finished *here* stays put, struck + // through, until the panel is next built — long enough to see what happened + // and to undo a mistake. + const [justDone, setJustDone] = useState>(() => new Set()) + const toggle = useCallback((event: DeadlineEvent) => { + // State, not a ref: the row only stays on screen if this re-renders. The + // updater is add/delete on a copy, so StrictMode running it twice lands + // on the same set. + setJustDone((prev) => { + const next = new Set(prev) + if (event.done) next.delete(event.id) + else next.add(event.id) + return next + }) + onToggle(event) + }, [onToggle]) + const inWindow = events.filter((e) => daysUntil(e, now) <= WEEK_AHEAD) - const visible = showDone ? inWindow : inWindow.filter((e) => !e.done) + const visible = showDone ? inWindow : inWindow.filter((e) => !e.done || justDone.has(e.id)) const doneCount = inWindow.filter((e) => e.done).length const buckets = bucketDeadlines(visible, now, urgentWindow) @@ -68,7 +90,7 @@ export default function DeadlinePanel({ return (
-

Deadlines

+

Overview

{visible.filter((e) => !e.done).length} open
@@ -95,9 +117,11 @@ export default function DeadlinePanel({ key={event.id} event={event} now={now} - onToggle={onToggle} + onToggle={toggle} onRemove={onRemove} onCorrect={onCorrect} + onNote={onNote} + onOpenDay={onOpenDay} /> ))}
diff --git a/web/src/components/DeadlineRow.tsx b/web/src/components/DeadlineRow.tsx index 0603d34..748baed 100644 --- a/web/src/components/DeadlineRow.tsx +++ b/web/src/components/DeadlineRow.tsx @@ -1,16 +1,22 @@ /** - * DeadlineRow — one deadline as a tickable row. + * DeadlineRow — one deadline, expandable. * - * Shared by the dashboard panel and the calendar's day detail, which the + * Shared by the Overview panel and the calendar's day detail, which the * artifact draws identically. Keeping one component means a change to how a * deadline reads happens once. + * + * The whole row used to be a single button that marked the deadline done. + * Students clicked it expecting the row to open, and instead ticked something + * off — which then vanished from the list, because done deadlines are hidden. + * So the row body opens a detail panel now, and finishing something is an + * explicit act: the checkbox, or the labelled button inside the panel. */ -import { useState } from 'react' +import { useId, useState } from 'react' import { kindLabel, daysUntil, DEADLINE_TYPE_MEANING, MODULE_SHORT_LABEL, type DeadlineEvent, } from '../data/applicationDeadlines' -import { toIsoDay } from '../lib/personalDeadlines' +import { toIsoDay, NOTE_MAX } from '../lib/personalDeadlines' /** What each kind of estimate actually means, in a sentence. */ const ESTIMATE_HINT: Record = { @@ -29,31 +35,52 @@ interface Props { onRemove?: (id: string) => void /** Lets the student replace a date we guessed with the real one. */ onCorrect?: (id: string, iso: string | null) => void + /** The student's own note against this deadline. Omit where there is + * nowhere to save one. */ + onNote?: (id: string, text: string) => void + /** Jump to this deadline's day. Omitted inside the calendar, which is + * already showing it. */ + onOpenDay?: (day: Date) => void } -export default function DeadlineRow({ event, now, onToggle, fullModule, onRemove, onCorrect }: Props) { +export default function DeadlineRow({ + event, now, onToggle, fullModule, onRemove, onCorrect, onNote, onOpenDay, +}: Props) { const offset = daysUntil(event, now) const kind = kindLabel(event) + const [open, setOpen] = useState(false) const [fixing, setFixing] = useState(false) const [typed, setTyped] = useState(() => toIsoDay(event.date)) + const [note, setNote] = useState(event.note ?? '') + const panelId = useId() // 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 + return ( -
+
+ {/* Ticking off stays one click, but from a target you have to mean. */} + + - {invented && onCorrect && ( - - )} - {/* Only the student's own standalone dates can be removed here. A task's - date is cleared on its school's page; removeOwn wouldn't find it. */} - {onRemove && event.source === 'self' && !event.isTask && ( - - )} - {fixing && onCorrect && ( -
{ e.preventDefault(); onCorrect(event.id, typed); setFixing(false) }} - > - - setTyped(e.target.value)} - /> - -
+ + {open && ( +
+

+ {event.dateDisplay} + {offset === 0 ? ' · today' : offset > 0 ? ` · in ${offset} day${offset === 1 ? '' : 's'}` + : ` · ${Math.abs(offset)} day${Math.abs(offset) === 1 ? '' : 's'} ago`} +

+ {event.estimated && ( +

{ESTIMATE_HINT[event.estimateReason ?? 'cycle-year']}

+ )} + +
+ {/* Every action here names its deadline: several rows can be open + at once, so "Remove" alone says nothing about what it removes, + to a screen reader or to a test. */} + + {onOpenDay && ( + + )} + {invented && onCorrect && ( + + )} + {/* Only the student's own standalone dates can be removed here. A + task's date is cleared on its school's page; removeOwn wouldn't + find it. */} + {onRemove && event.source === 'self' && !event.isTask && ( + + )} +
+ + {fixing && onCorrect && ( +
{ e.preventDefault(); onCorrect(event.id, typed); setFixing(false) }} + > + + setTyped(e.target.value)} + /> + +
+ )} + + {onNote && ( +
+ +