+
+
Scholarship Hunt
@@ -505,6 +507,9 @@ const OverviewTab = ({ progress, onToggle }: OverviewTabProps) => {
)
})}
Click any item to open its content
+
+
+ {aside &&
}
)
}
@@ -2500,9 +2505,12 @@ interface Props {
open: boolean
onClose: () => void
year?: number
+ /** Deadline rail for the Overview tab. Built by the dashboard so every
+ * panel shares one set of handlers and one copy of the student's dates. */
+ aside?: ReactNode
}
-export default function FinancialAidModule({ open, onClose, year = 11 }: Props) {
+export default function FinancialAidModule({ open, onClose, year = 11, aside }: Props) {
const { user, profile, refreshProfile } = useAuth()
const toast = useToast()
// Held in a ref so the load effect below re-runs only when the module
@@ -2663,7 +2671,7 @@ export default function FinancialAidModule({ open, onClose, year = 11 }: Props)
const yearMeta = YEARS[year] ?? YEARS[11]
const content =
- tab === 'overview' ?
:
+ tab === 'overview' ?
:
tab === 'scholarships' ?
:
tab === 'scholarship-search' ?
:
tab === 'deadlines' ?
:
diff --git a/web/src/components/ModuleOverviewTab.tsx b/web/src/components/ModuleOverviewTab.tsx
index a03cf31..9d3b216 100644
--- a/web/src/components/ModuleOverviewTab.tsx
+++ b/web/src/components/ModuleOverviewTab.tsx
@@ -7,7 +7,7 @@
* checklist data, content map, accent, and copy.
*/
-import { useState } from 'react'
+import { useState, type ReactNode } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { C, EASE_OUT } from '../lib/designTokens'
import { Bar, Ring } from './moduleUI'
@@ -33,6 +33,10 @@ interface Props
{
title: string
subtitle: string
itemTypeIcon: Record
+ /** Deadline rail beside the checklist. Built once by the dashboard and
+ * passed down, so every panel shares the one set of handlers rather than
+ * each module loading its own copy of the student's dates. */
+ aside?: ReactNode
}
export default function ModuleOverviewTab({
@@ -47,6 +51,7 @@ export default function ModuleOverviewTab({
title,
subtitle,
itemTypeIcon,
+ aside,
}: Props) {
const [expanded, setExpanded] = useState>(() =>
Object.fromEntries(checklist.map((_, i) => [i, true])))
@@ -73,7 +78,8 @@ export default function ModuleOverviewTab({
}
return (
-
+
+
{title}
{subtitle}
@@ -140,6 +146,9 @@ export default function ModuleOverviewTab({
)
})}
+
+
+ {aside &&
}
)
}
diff --git a/web/src/components/SchoolApplicationPage.tsx b/web/src/components/SchoolApplicationPage.tsx
index a7db8ce..f076a50 100644
--- a/web/src/components/SchoolApplicationPage.tsx
+++ b/web/src/components/SchoolApplicationPage.tsx
@@ -49,6 +49,10 @@ const font = "'Outfit',sans-serif"
* reports '' for a half-edited value too, so leaving mid-retype (or the window
* losing focus) reverts instead of erasing the date (for a shared task, on
* every school). Leaving the box saves a complete date or reverts.
+ *
+ * Before there is a date, the control is a labelled button rather than the
+ * input: an empty date input is a blank box that says nothing about what it
+ * is for, so the feature went unfound.
*/
const DUE_SAVE_DELAY_MS = 600
const plausibleDay = (v: string) => {
@@ -64,6 +68,8 @@ function TaskDueInput({ due, label, hint, onCommit }: {
}) {
// null = not editing: show the saved value.
const [draft, setDraft] = useState(null)
+ // Swapped in by the "Add date" button, so the input arrives focused.
+ const [adding, setAdding] = useState(false)
const timer = useRef | null>(null)
const pending = useRef(null) // plausible value waiting to save
const latest = useRef({ due, onCommit })
@@ -87,9 +93,30 @@ function TaskDueInput({ due, label, hint, onCommit }: {
// Full size while being edited, even with every segment wiped, so the
// student isn't retyping into a collapsed, invisible chip.
const set = draft !== null || !!value
+ if (!set && !adding) {
+ return (
+
+ )
+ }
return (
setDraft(due ?? '')}
onChange={(e) => {
@@ -104,16 +131,16 @@ function TaskDueInput({ due, label, hint, onCommit }: {
if (e.currentTarget.validity.badInput) flush('', false)
else flush(draft ?? '', true)
setDraft(null)
+ setAdding(false)
}}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
aria-label={`Due date for ${label}`}
title={hint}
style={{
- flexShrink: 0, width: set ? 132 : 34, padding: '3px 6px',
- border: `1px ${set ? 'solid' : 'dashed'} ${C.border}`,
- borderRadius: 7, background: set ? C.white : 'transparent',
- fontFamily: font, fontSize: 11.5,
- color: set ? C.text : 'transparent',
+ flexShrink: 0, width: 132, padding: '3px 6px',
+ border: `1px solid ${C.border}`,
+ borderRadius: 7, background: C.white,
+ fontFamily: font, fontSize: 11.5, color: C.text,
cursor: 'pointer', outline: 'none',
}}
/>
@@ -300,8 +327,9 @@ export default function SchoolApplicationPage({
{task.done ? '✓' : ''}
{task.label}
- {/* Empty until they set one, so an undated checklist stays
- a checklist rather than a wall of date pickers. */}
+ {/* A button until they set one, so an undated checklist
+ stays a checklist rather than a wall of date pickers
+ — but still says a date can go here. */}
+ /**
+ * Replace the list. Resolves false when the write was refused (no completed
+ * read yet) or rolled back — never partially applied.
+ */
+ saveApps: (next: ApplicationEntry[]) => Promise
+ loadFailed: boolean
+}
+
+const Ctx = createContext(null)
+
+export function ApplicationsProvider({ children }: { children: ReactNode }) {
+ // Always loading: the list feeds the dashboard, every module overview and
+ // Application Tracking, so there is no point at which it is not wanted.
+ const { data, saveData, dataRef, loadFailed } =
+ useModuleData(APPLICATIONS_MODULE, APPLICATIONS_DATA_KEY, true)
+ const value = useMemo(
+ () => ({ apps: data, appsRef: dataRef, saveApps: saveData, loadFailed }),
+ [data, dataRef, saveData, loadFailed],
+ )
+ return {children}
+}
+
+// eslint-disable-next-line react-refresh/only-export-components -- hook co-located with its provider
+export function useApplications(): ApplicationsValue {
+ const v = useContext(Ctx)
+ if (!v) throw new Error('useApplications must be used inside ')
+ return v
+}
diff --git a/web/src/index.css b/web/src/index.css
index e0444a1..827795a 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -3150,6 +3150,21 @@ button {
min-height: 40px;
}
.msh-content { overflow-y: visible !important; }
+ /* The deadline rail goes under the checklist rather than beside it. */
+ .mov { flex-direction: column !important; gap: 18px !important; }
+ .mov-main { max-width: none !important; }
+ .mov-aside { width: 100% !important; position: static !important; }
+}
+
+/* ---- the deadline rail on a module overview ----
+ Same panel as the dashboard's, so a student never has to leave a module to
+ see what is actually due. Sticky so it stays put while the checklist
+ scrolls; the width matches the dashboard aside so the panel keeps its
+ proportions in both places. */
+.mov-aside {
+ width: 300px; flex: none; position: sticky; top: 0;
+ border: 1px solid var(--border); border-radius: 12px;
+ background: var(--card-bg); padding: 14px 15px;
}
/* The application round, with what it commits you to on hover/long-press.
diff --git a/web/src/lib/collegeMatch.test.ts b/web/src/lib/collegeMatch.test.ts
index f5480e1..e21c62f 100644
--- a/web/src/lib/collegeMatch.test.ts
+++ b/web/src/lib/collegeMatch.test.ts
@@ -10,6 +10,8 @@ import {
suggestTransferPath,
pickAffordableAlternatives,
regionOf,
+ searchRegionOf,
+ SEARCH_REGIONS,
formatNetPrice,
haversineMiles,
collegeDistanceMi,
@@ -263,6 +265,24 @@ describe('regionOf & formatNetPrice', () => {
expect(regionOf('IL')).toBe('Midwest')
expect(regionOf(null)).toBeNull()
})
+ it('puts every state and DC in exactly one search region', () => {
+ // A state missing from the map would silently vanish from the Discover
+ // list the moment anyone picked a region.
+ const STATES = ('AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO '
+ + 'MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY').split(' ')
+ expect(STATES).toHaveLength(51)
+ for (const st of STATES) expect(SEARCH_REGIONS).toContain(searchRegionOf(st))
+ })
+ it('gives territories and unknowns no search region rather than guessing', () => {
+ expect(searchRegionOf('PR')).toBeNull()
+ expect(searchRegionOf(null)).toBeNull()
+ })
+ it('splits the regions finer than the census ones', () => {
+ // The whole point: these two share a census region and not a search one.
+ expect(regionOf('WA')).toBe(regionOf('AZ'))
+ expect(searchRegionOf('WA')).toBe('Pacific')
+ expect(searchRegionOf('AZ')).toBe('Mountain West')
+ })
it('formats net price', () => {
expect(formatNetPrice(842300)).toBe('~$8,423/yr after aid')
expect(formatNetPrice(null)).toMatch(/varies/)
diff --git a/web/src/lib/collegeMatch.ts b/web/src/lib/collegeMatch.ts
index 824ce7a..454956f 100644
--- a/web/src/lib/collegeMatch.ts
+++ b/web/src/lib/collegeMatch.ts
@@ -84,6 +84,42 @@ for (const [region, states] of Object.entries({
export const regionOf = (state: string | null | undefined): string | null =>
state ? CENSUS_REGIONS[state] ?? 'Territories' : null
+/**
+ * The regions a student actually searches in.
+ *
+ * The four Census regions above score a college's distance and are far too
+ * coarse to browse by: "West" is Seattle, Phoenix and Honolulu together, and
+ * "Northeast" runs from Maine to Pennsylvania. These are the finer splits
+ * people mean by "the Northeast" or "the Pacific Northwest" — narrow enough
+ * that picking one meaningfully shortens the list.
+ *
+ * Kept separate rather than replacing the Census map: that one is stored on
+ * every college row and feeds the match score, so changing it would silently
+ * restate every student's results.
+ */
+export const SEARCH_REGIONS = [
+ 'New England', 'Mid-Atlantic', 'Southeast', 'Midwest',
+ 'South Central', 'Mountain West', 'Pacific',
+] as const
+export type SearchRegion = (typeof SEARCH_REGIONS)[number]
+
+const SEARCH_REGION_OF: Record = {}
+for (const [region, states] of Object.entries({
+ 'New England': 'CT ME MA NH RI VT',
+ 'Mid-Atlantic': 'NJ NY PA DE MD DC',
+ Southeast: 'VA WV NC SC GA FL AL MS TN KY',
+ Midwest: 'OH IN IL MI WI MN IA MO ND SD NE KS',
+ 'South Central': 'TX OK AR LA',
+ 'Mountain West': 'MT ID WY CO UT NV AZ NM',
+ Pacific: 'CA OR WA AK HI',
+} satisfies Record)) {
+ for (const st of states.split(' ')) SEARCH_REGION_OF[st] = region as SearchRegion
+}
+
+/** Null for territories and anything without a state — never guessed at. */
+export const searchRegionOf = (state: string | null | undefined): SearchRegion | null =>
+ (state && SEARCH_REGION_OF[state]) || null
+
export function incomeBracketFromCents(cents?: number | null): IncomeBracket | null {
if (cents == null) return null
const d = cents / 100
diff --git a/web/src/lib/useDeadlineEvents.test.tsx b/web/src/lib/useDeadlineEvents.test.tsx
index c494b86..3fb6266 100644
--- a/web/src/lib/useDeadlineEvents.test.tsx
+++ b/web/src/lib/useDeadlineEvents.test.tsx
@@ -17,6 +17,13 @@ vi.mock('./fafsaData', () => ({
}))
import { useDeadlineEvents } from './useDeadlineEvents'
+import { ApplicationsProvider, useApplications } from '../contexts/ApplicationsContext'
+
+// The college list lives in the provider now, so every render needs it. Its
+// I/O is the same getModuleData/setModuleData pair already mocked above.
+const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
const SENIOR = 10
@@ -38,7 +45,7 @@ describe('useDeadlineEvents', () => {
H.getModuleData.mockResolvedValue(apps)
H.getTrackerItems.mockResolvedValue([scholarship])
- const { result } = renderHook(() => useDeadlineEvents(SENIOR))
+ const { result } = renderHook(() => useDeadlineEvents(SENIOR), { wrapper })
await waitFor(() => expect(result.current.events.length).toBeGreaterThan(1))
const ids = result.current.events.map((e) => e.id)
@@ -54,7 +61,7 @@ describe('useDeadlineEvents', () => {
H.getModuleData.mockResolvedValue(apps)
H.getTrackerItems.mockRejectedValue(new Error('offline'))
- const { result } = renderHook(() => useDeadlineEvents(SENIOR))
+ const { result } = renderHook(() => useDeadlineEvents(SENIOR), { wrapper })
await waitFor(() => expect(result.current.events.length).toBeGreaterThan(0))
expect(result.current.events.every((e) => !e.id.startsWith('scholarship-'))).toBe(true)
})
@@ -63,7 +70,7 @@ describe('useDeadlineEvents', () => {
H.getModuleData.mockRejectedValue(new Error('offline'))
H.getTrackerItems.mockResolvedValue([scholarship])
- const { result } = renderHook(() => useDeadlineEvents(SENIOR))
+ const { result } = renderHook(() => useDeadlineEvents(SENIOR), { wrapper })
await waitFor(() => expect(result.current.events.length).toBeGreaterThan(0))
expect(result.current.events.map((e) => e.id)).toContain('scholarship-t1')
})
@@ -73,26 +80,27 @@ describe('useDeadlineEvents', () => {
// view tells the student to add what they already added.
H.getModuleData.mockResolvedValue(apps)
H.getTrackerItems.mockRejectedValue(new Error('offline'))
- const { result } = renderHook(() => useDeadlineEvents(SENIOR))
+ const { result } = renderHook(() => useDeadlineEvents(SENIOR), { wrapper })
await waitFor(() => expect(result.current.failed).toBe(true))
})
- it('does not fetch while inactive, and fetches once it becomes active', async () => {
+ it('refetches when the key changes, without ever going dark', async () => {
H.getModuleData.mockResolvedValue(apps)
H.getTrackerItems.mockResolvedValue([scholarship])
- // The dashboard pauses whilst a module is open, then refetches on the way
- // back so a scholarship just added shows up.
+ // The dashboard passes the open module, so opening one and coming back
+ // both pick up a scholarship added meanwhile. It used to pause instead,
+ // which is why the panel could not be shown inside a module.
const { result, rerender } = renderHook(
- ({ active }) => useDeadlineEvents(SENIOR, { active }),
- { initialProps: { active: false } },
+ ({ refreshKey }) => useDeadlineEvents(SENIOR, { refreshKey }),
+ { initialProps: { refreshKey: '' }, wrapper },
)
- expect(H.getTrackerItems).not.toHaveBeenCalled()
- expect(result.current.events).toEqual([])
-
- rerender({ active: true })
await waitFor(() => expect(result.current.events.length).toBeGreaterThan(0))
expect(H.getTrackerItems).toHaveBeenCalledTimes(1)
+
+ rerender({ refreshKey: 'Financial Aid' })
+ await waitFor(() => expect(H.getTrackerItems).toHaveBeenCalledTimes(2))
+ expect(result.current.events.length).toBeGreaterThan(0)
})
})
@@ -113,7 +121,7 @@ describe('useDeadlineEvents — a tick writes through to the owning record', ()
Promise.resolve(over[key] ?? []))
const tick = async (id: string) => {
- const { result } = renderHook(() => useDeadlineEvents(SENIOR))
+ const { result } = renderHook(() => useDeadlineEvents(SENIOR), { wrapper })
await waitFor(() => expect(result.current.events.some((e) => e.id === id)).toBe(true))
const event = result.current.events.find((e) => e.id === id)!
await act(async () => { result.current.toggleDone(event) })
@@ -143,7 +151,7 @@ describe('useDeadlineEvents — a tick writes through to the owning record', ()
// The mis-tap case: a not-started application ticked and immediately
// untapped used to be left in-progress for good.
stored({ apps: app('not-started') })
- const { result } = renderHook(() => useDeadlineEvents(SENIOR))
+ const { result } = renderHook(() => useDeadlineEvents(SENIOR), { wrapper })
await waitFor(() => expect(result.current.events.some((e) => e.id === 'app-harvard-EA')).toBe(true))
const find = () => result.current.events.find((e) => e.id === 'app-harvard-EA')!
@@ -157,7 +165,7 @@ describe('useDeadlineEvents — a tick writes through to the owning record', ()
it('tells the view which module a tick just changed', async () => {
const onNotice = vi.fn()
stored({ apps: app('not-started') })
- const { result } = renderHook(() => useDeadlineEvents(SENIOR, { onNotice }))
+ const { result } = renderHook(() => useDeadlineEvents(SENIOR, { onNotice }), { wrapper })
await waitFor(() => expect(result.current.events.some((e) => e.id === 'app-harvard-EA')).toBe(true))
await act(async () => {
result.current.toggleDone(result.current.events.find((e) => e.id === 'app-harvard-EA')!)
@@ -249,7 +257,7 @@ describe('useDeadlineEvents — a dated college task', () => {
it('shows up beside every other deadline', async () => {
stored({ apps: withTask() })
- const { result } = renderHook(() => useDeadlineEvents(SENIOR))
+ const { result } = renderHook(() => useDeadlineEvents(SENIOR), { wrapper })
await waitFor(() => expect(result.current.events.length).toBeGreaterThan(0))
const task = result.current.events.find((e) => e.id.startsWith('task-'))
@@ -263,7 +271,7 @@ describe('useDeadlineEvents — a dated college task', () => {
// Otherwise the calendar and the college's own checklist disagree about
// whether the same thing is done.
stored({ apps: withTask() })
- const { result } = renderHook(() => useDeadlineEvents(SENIOR))
+ const { result } = renderHook(() => useDeadlineEvents(SENIOR), { wrapper })
await waitFor(() => expect(result.current.events.some((e) => e.id.startsWith('task-'))).toBe(true))
const event = result.current.events.find((e) => e.id.startsWith('task-'))!
@@ -277,7 +285,7 @@ describe('useDeadlineEvents — a dated college task', () => {
it('unticks it again', async () => {
stored({ apps: withTask(true) })
- const { result } = renderHook(() => useDeadlineEvents(SENIOR))
+ const { result } = renderHook(() => useDeadlineEvents(SENIOR), { wrapper })
await waitFor(() => expect(result.current.events.some((e) => e.id.startsWith('task-'))).toBe(true))
const event = result.current.events.find((e) => e.id.startsWith('task-'))!
@@ -297,7 +305,7 @@ describe('useDeadlineEvents — a dated college task', () => {
tasks: [{ id: 'recs', label: 'Request teacher recommendations', done: false, phase: 'before', due: '2026-10-02' }],
})
stored({ apps: [recs('sc-1', 'Alpha'), recs('sc-2', 'Beta')] })
- const { result } = renderHook(() => useDeadlineEvents(SENIOR))
+ const { result } = renderHook(() => useDeadlineEvents(SENIOR), { wrapper })
await waitFor(() => expect(result.current.events.some((e) => e.id.startsWith('task-'))).toBe(true))
const taskEvents = result.current.events.filter((e) => e.id.startsWith('task-'))
@@ -314,7 +322,7 @@ describe('useDeadlineEvents — a dated college task', () => {
let resolveApps: (v: unknown) => void = () => {}
H.getModuleData.mockImplementation((_m: string, key: string) =>
key === 'apps' ? new Promise((r) => { resolveApps = r }) : Promise.resolve([]))
- const { result } = renderHook(() => useDeadlineEvents(SENIOR))
+ const { result } = renderHook(() => useDeadlineEvents(SENIOR), { wrapper })
const fake = { id: 'task-cal-poly::c-1', done: false, shortTitle: 'Ask Ms. Reyes' } as unknown as Parameters[0]
await act(async () => { result.current.toggleDone(fake) })
expect(H.setModuleData.mock.calls.some(([, k]) => k === 'apps')).toBe(false)
@@ -322,3 +330,46 @@ describe('useDeadlineEvents — a dated college task', () => {
await waitFor(() => expect(result.current.events.some((e) => e.id.startsWith('task-'))).toBe(true))
})
})
+
+/**
+ * The reason the college list has one owner.
+ *
+ * Both of these write the whole array. While the panel kept its own copy, a
+ * tick saved the list as it was when the panel last loaded — erasing a school
+ * added in Application Tracking since. The dashboard dodged it by pausing the
+ * panel whilst a module was open, which is exactly what stopped the panel
+ * being shown inside a module.
+ */
+describe('useDeadlineEvents — one owner for the college list', () => {
+ beforeEach(() => {
+ H.getModuleData.mockReset().mockResolvedValue(apps)
+ H.getTrackerItems.mockReset().mockResolvedValue([])
+ H.setModuleData.mockReset().mockResolvedValue(undefined)
+ H.updateTrackerStatus.mockReset().mockResolvedValue(undefined)
+ })
+
+ it('keeps a college added elsewhere when a deadline is ticked', async () => {
+ const { result } = renderHook(
+ () => ({ panel: useDeadlineEvents(SENIOR), list: useApplications() }),
+ { wrapper },
+ )
+ await waitFor(() => expect(result.current.panel.events.length).toBeGreaterThan(0))
+
+ // Application Tracking adds a school, the way opening the module would.
+ await act(async () => {
+ await result.current.list.saveApps([
+ ...result.current.list.appsRef.current,
+ { collegeId: 'yale', category: 'unranked', deadlineType: 'RD', status: 'not-started' },
+ ])
+ })
+
+ // The panel — live, not paused — ticks Harvard off.
+ const harvard = result.current.panel.events.find((e) => e.id.startsWith('app-'))
+ expect(harvard).toBeDefined()
+ act(() => { result.current.panel.toggleDone(harvard!) })
+
+ const written = H.setModuleData.mock.calls.at(-1)![2] as { collegeId: string; status: string }[]
+ expect(written.map((a) => a.collegeId)).toContain('yale')
+ expect(written.find((a) => a.collegeId === 'harvard')!.status).toBe('submitted')
+ })
+})
diff --git a/web/src/lib/useDeadlineEvents.ts b/web/src/lib/useDeadlineEvents.ts
index 8322880..bcf12b7 100644
--- a/web/src/lib/useDeadlineEvents.ts
+++ b/web/src/lib/useDeadlineEvents.ts
@@ -18,7 +18,6 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { getModuleData, setModuleData } from './moduleProgress'
import { getTrackerItems, updateTrackerStatus, type TrackerItem } from './fafsaData'
import {
getDoneIds,
@@ -34,8 +33,6 @@ import {
type DeadlineOverrides,
} from './personalDeadlines'
import {
- APPLICATIONS_MODULE,
- APPLICATIONS_DATA_KEY,
deriveDeadlineEvents,
deriveScholarshipEvents,
mergeDeadlineEvents,
@@ -47,7 +44,8 @@ import {
import {
deriveTaskEvents, isSharedTask, parseTaskEventId, setSharedTask, tasksForEntry,
} from '../data/applicationTasks'
-import type { ApplicationEntry, AppTask } from '../data/applicationsChecklist'
+import type { AppTask } from '../data/applicationsChecklist'
+import { useApplications } from '../contexts/ApplicationsContext'
/**
* State plus a ref that always holds its latest value. Setting updates both
@@ -62,9 +60,13 @@ function useLatest(initial: T) {
}
export interface UseDeadlineEventsOptions {
- /** Skip fetching while false — e.g. the dashboard pauses whilst a module is
- * open, then refetches on the way back so new entries surface. */
- active?: boolean
+ /** Refetch the sources this hook owns whenever this changes — the dashboard
+ * passes the open module, so leaving or entering one picks up a scholarship
+ * added meanwhile. It used to *pause* instead, because the college list had
+ * two owners and a paused copy could not clobber the other; the list has one
+ * owner now (see ApplicationsContext), so staying live is safe and lets the
+ * panel render inside a module. */
+ refreshKey?: string
/** The student's standing deadline preferences, from the settings page.
* Applied here so every dated view filters identically; omitted means no
* filtering, which is what the pure tests want. */
@@ -98,12 +100,14 @@ export interface DeadlineEventsResult {
export function useDeadlineEvents(
gradeStartIdx: number | null | undefined,
- { active = true, visibility, onNotice }: UseDeadlineEventsOptions = {},
+ { refreshKey = '', visibility, onNotice }: UseDeadlineEventsOptions = {},
): DeadlineEventsResult {
+ // The college list is shared with Application Tracking rather than fetched
+ // here, so the two can never save over each other.
+ const { apps, appsRef, saveApps } = useApplications()
// Each list keeps its latest value in a ref, so every handler computes the
// next value once and writes it once, outside a state updater (StrictMode
// runs updaters twice), and two handlers can't overwrite each other's change.
- const [apps, setApps, appsRef] = useLatest([])
const [scholarships, setScholarships, scholarshipsRef] = useLatest([])
const [own, setOwn, ownRef] = useLatest([])
const [doneIds, setDoneIds, doneIdsRef] = useLatest([])
@@ -112,18 +116,14 @@ export function useDeadlineEvents(
// on a list still being refetched would save the copy from before the pause
// over whatever changed meanwhile (a college added in the module), so each
// writer does nothing until its list is loaded; the gap is a moment.
- const ready = useRef({ apps: false, scholarships: false, own: false, doneIds: false, overrides: false })
+ const ready = useRef({ scholarships: false, own: false, doneIds: false, overrides: false })
const [failed, setFailed] = useState(false)
useEffect(() => {
- if (!active) return
let cancelled = false
const r = ready.current
- r.apps = r.scholarships = r.own = r.doneIds = r.overrides = false
+ r.scholarships = r.own = r.doneIds = r.overrides = false
const fail = () => { if (!cancelled) setFailed(true) }
- getModuleData(APPLICATIONS_MODULE, APPLICATIONS_DATA_KEY)
- .then((data) => { if (!cancelled) { setApps(data ?? []); r.apps = true } })
- .catch(fail)
getTrackerItems()
.then((items) => { if (!cancelled) { setScholarships(items); r.scholarships = true } })
.catch(fail)
@@ -138,7 +138,7 @@ export function useDeadlineEvents(
.catch(fail)
return () => { cancelled = true }
// The setters are stable (useLatest), listed only to satisfy the rule.
- }, [active, setApps, setScholarships, setOwn, setDoneIds, setOverrides])
+ }, [refreshKey, setScholarships, setOwn, setDoneIds, setOverrides])
/**
* Local state moves first and the write follows, so a tick feels immediate.
@@ -159,7 +159,6 @@ export function useDeadlineEvents(
const priorStatus = useRef(new Map())
const setAppStatus = useCallback((collegeId: string, done: boolean, title: string) => {
- if (!ready.current.apps) return
const prev = appsRef.current
const current = prev.find((a) => a.collegeId === collegeId)?.status
if (done && current) priorStatus.current.set(collegeId, current)
@@ -170,13 +169,11 @@ export function useDeadlineEvents(
: a,
)
if (!done) priorStatus.current.delete(collegeId)
- setApps(next)
- setModuleData(APPLICATIONS_MODULE, APPLICATIONS_DATA_KEY, next)
- .catch(() => setFailed(true))
+ saveApps(next).then((ok) => { if (!ok) setFailed(true) })
onNotice?.(done
? `${title} marked submitted in Application Tracking.`
: `${title} moved back in Application Tracking.`)
- }, [onNotice, appsRef, setApps])
+ }, [onNotice, appsRef, saveApps])
/** The tracker's mirror of the above. */
const setScholarshipStatus = useCallback((itemId: string, done: boolean, title: string) => {
@@ -196,7 +193,6 @@ export function useDeadlineEvents(
/** Flip one task's done flag inside its college's entry. */
const setTaskDone = useCallback((collegeId: string, taskId: string, done: boolean, title: string) => {
- if (!ready.current.apps) return
const prev = appsRef.current
// A shared task is ticked for every school that needs it, as on the pages.
const owner = prev.find((a) => a.collegeId === collegeId)
@@ -206,13 +202,11 @@ export function useDeadlineEvents(
const tasks: AppTask[] = tasksForEntry(a).map((t) => (t.id === taskId ? { ...t, done } : t))
return { ...a, tasks }
})
- setApps(next)
- setModuleData(APPLICATIONS_MODULE, APPLICATIONS_DATA_KEY, next)
- .catch(() => setFailed(true))
+ saveApps(next).then((ok) => { if (!ok) setFailed(true) })
onNotice?.(done
? `${title} ticked off in Application Tracking.`
: `${title} reopened in Application Tracking.`)
- }, [onNotice, appsRef, setApps])
+ }, [onNotice, appsRef, saveApps])
const toggleDone = useCallback((event: DeadlineEvent) => {
const done = !event.done