diff --git a/web/src/App.tsx b/web/src/App.tsx index b0464f4..e513e03 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,6 +16,7 @@ import { useToast } from './contexts/ToastContext' import ResetPasswordScreen from './components/ResetPasswordScreen' import { resolvePreferences } from './lib/preferences' import { useThemePref } from './lib/theme' +import { ApplicationsProvider } from './contexts/ApplicationsContext' import type { Demographics } from './types/user' type Screen = 'loading' | 'auth' | 'welcome-back' | 'splash' | 'picker' | 'timeline' | 'demographics' | 'analyzing' | 'dashboard' @@ -148,7 +149,14 @@ export default function App() { case 'analyzing': return case 'dashboard': - return + // The college list is written from the dashboard, the calendar and + // Application Tracking; one provider around all of them keeps it a + // single record rather than three copies racing each other. + return ( + + + + ) } } diff --git a/web/src/components/ApplicationStatusTab.test.tsx b/web/src/components/ApplicationStatusTab.test.tsx index 92c992a..ea33e1d 100644 --- a/web/src/components/ApplicationStatusTab.test.tsx +++ b/web/src/components/ApplicationStatusTab.test.tsx @@ -25,6 +25,13 @@ const APPS = [ const schoolRow = (name: string) => screen.getAllByRole('button', { name: new RegExp(name) }).find((b) => b.classList.contains('ast-row'))! +// A task with no date shows an "Add date" button, not an empty input: an +// empty date box is a blank square that says nothing about what it is for. +const openDateBox = async (label: string) => { + await userEvent.click(screen.getByRole('button', { name: `Add a due date for ${label}` })) + return screen.getByLabelText(`Due date for ${label}`) +} + const renderTab = (apps = APPS, initialOpenId: string | null = null) => { const onUpdate = vi.fn() const onRemove = vi.fn() @@ -151,14 +158,14 @@ describe('ApplicationStatusTab', () => { it('dates a per-school task on that school, and a shared task on every school', async () => { const { onUpdate, onSetSharedDue } = renderTab() await userEvent.click(schoolRow('Alpha College')) - const essayBox = screen.getByLabelText('Due date for Draft the supplemental essay(s)') + const essayBox = await openDateBox('Draft the supplemental essay(s)') fireEvent.focus(essayBox) fireEvent.change(essayBox, { target: { value: '2026-10-12' } }) fireEvent.blur(essayBox) const [id, fields] = onUpdate.mock.calls.at(-1)! expect(id).toBe('sc-1') expect(fields.tasks.find((t: { id: string }) => t.id === 'essays').due).toBe('2026-10-12') - const recsBox = screen.getByLabelText('Due date for Request teacher recommendations') + const recsBox = await openDateBox('Request teacher recommendations') fireEvent.focus(recsBox) fireEvent.change(recsBox, { target: { value: '2026-10-15' } }) fireEvent.blur(recsBox) @@ -175,7 +182,7 @@ describe('ApplicationStatusTab', () => { it('saves the date box once typing settles, not each in-between value', async () => { const { onUpdate } = renderTab() await userEvent.click(schoolRow('Alpha College')) - const box = screen.getByLabelText('Due date for Draft the supplemental essay(s)') + const box = await openDateBox('Draft the supplemental essay(s)') onUpdate.mockClear() fireEvent.focus(box) // Typing a year, then a month: half-typed years and January-on-the-way-to-November. diff --git a/web/src/components/ApplicationTrackingModule.tsx b/web/src/components/ApplicationTrackingModule.tsx index 7c06d79..ef42379 100644 --- a/web/src/components/ApplicationTrackingModule.tsx +++ b/web/src/components/ApplicationTrackingModule.tsx @@ -13,11 +13,13 @@ import { useCallback, useMemo, useRef, + type ReactNode, } from 'react' import { useAuth } from '../contexts/AuthContext' import { markIntroSeen } from '../lib/profiles' import { MODULE_COLORS } from '../lib/designTokens' -import { useModuleChecklist, useModuleData } from '../lib/useModuleState' +import { useModuleChecklist } from '../lib/useModuleState' +import { useApplications } from '../contexts/ApplicationsContext' import { useToast } from '../contexts/ToastContext' import { APPLICATIONS_CHECKLIST, @@ -46,7 +48,6 @@ import { domainOf } from '../lib/collegeLogo' const MC = MODULE_COLORS.applications const MODULE_NAME = 'applications' const TOUR_INTRO_KEY = 'applications-module-tour' -const APPS_DATA_KEY = 'apps' /* ─── primitives ─── */ @@ -73,9 +74,12 @@ interface Props { onClose: () => void /** Close the module and open the profile page, where household income is editable. */ onEditIncome?: () => void + /** 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 ApplicationTrackingModule({ open, onClose, onEditIncome }: Props) { +export default function ApplicationTrackingModule({ open, onClose, onEditIncome, aside }: Props) { const { user, profile, refreshProfile } = useAuth() const tourSeen = profile?.settings?.intros_seen?.includes(TOUR_INTRO_KEY) ?? false const [showTour, setShowTour] = useState(false) @@ -92,8 +96,10 @@ export default function ApplicationTrackingModule({ open, onClose, onEditIncome const [feeWaiverDismissed, setFeeWaiverDismissed] = useState(false) const { progress, handleToggle, handleMarkComplete } = useModuleChecklist(MODULE_NAME, open) const toast = useToast() - const { data: storedApps, saveData: persistApps, dataRef: appsRef, loadFailed: appsLoadFailed } = - useModuleData(MODULE_NAME, APPS_DATA_KEY, open) + // Shared with the dashboard and the deadline panels rather than loaded + // again here: the list persists as one whole array, so a second copy meant + // whichever view saved last replaced what the other had just written. + const { apps: storedApps, saveApps: persistApps, appsRef, loadFailed: appsLoadFailed } = useApplications() // Views get only categories they know; what's stored is left as it is. const apps = useMemo(() => storedApps.map(withKnownCategory), [storedApps]) useEffect(() => { @@ -185,7 +191,7 @@ export default function ApplicationTrackingModule({ open, onClose, onEditIncome && shouldShowFeeWaiverNotice(incomeLevel, profile?.settings?.intros_seen) const content = - tab === 'overview' ? : + tab === 'overview' ? : tab === 'discover' ? openInStatus(null)} onRemove={handleRemoveApp} /> : switchTab('discover')} apps={apps} onUpdate={handleUpdateApp} onRemove={handleRemoveApp} onSetShared={handleSetSharedTask} onSetSharedDue={handleSetSharedDue} gradeStartIdx={profile?.grade_start_idx} initialOpenId={statusSchool} /> diff --git a/web/src/components/CalendarPage.test.tsx b/web/src/components/CalendarPage.test.tsx index 73de7a1..63050fd 100644 --- a/web/src/components/CalendarPage.test.tsx +++ b/web/src/components/CalendarPage.test.tsx @@ -22,6 +22,12 @@ vi.mock('../contexts/ToastContext', () => ({ })) import CalendarPage from './CalendarPage' +import { ApplicationsProvider } from '../contexts/ApplicationsContext' + +// The college list is owned by the provider, so the page needs it in scope. +const renderPage = () => render( + , +) const SENIOR = 10 /** Pinned so the calendar opens on a known month regardless of the real date. */ @@ -48,7 +54,7 @@ describe('CalendarPage', () => { it('pins a tracked scholarship on its deadline date', async () => { H.getTrackerItems.mockResolvedValue([tracked()]) - render() + renderPage() // The grid names it on the 15th; the detail panel below shows whichever // day is selected, which on arrival is today. expect(await screen.findByText('Coca-Cola Scholars')).toBeInTheDocument() @@ -56,7 +62,7 @@ describe('CalendarPage', () => { it('opens on today and shows that day underneath', async () => { H.getTrackerItems.mockResolvedValue([tracked({ deadline: 'Sep 4, 2026', deadlineDate: '2026-09-04' })]) - render() + renderPage() expect(await screen.findByRole('heading', { name: /Friday, September 4/ })).toBeInTheDocument() // Named twice now: the pin in the cell and the row in the day panel. expect(screen.getAllByText('Coca-Cola Scholars').length).toBeGreaterThanOrEqual(2) @@ -64,7 +70,7 @@ describe('CalendarPage', () => { it('shows a picked day in the panel below the grid', async () => { H.getTrackerItems.mockResolvedValue([tracked()]) - render() + renderPage() await screen.findByText('Coca-Cola Scholars') // Nothing is due today, so the panel says so until a day is picked. expect(screen.getByText('Nothing on this day')).toBeInTheDocument() @@ -80,7 +86,7 @@ describe('CalendarPage', () => { H.getTrackerItems.mockResolvedValue([ tracked({ deadline: 'September 15 (annual)', deadlineDate: null }), ]) - render() + renderPage() await userEvent.click(await screen.findByRole('button', { name: /September 15.*1 due/ })) expect(screen.getByText('est.')).toBeInTheDocument() }) @@ -89,7 +95,7 @@ describe('CalendarPage', () => { H.getTrackerItems.mockResolvedValue([ tracked({ deadline: 'Varies - check official site', deadlineDate: null }), ]) - render() + renderPage() expect(await screen.findByText(/Add colleges in Application Tracking/)).toBeInTheDocument() expect(screen.queryByText('Coca-Cola Scholars')).not.toBeInTheDocument() }) @@ -116,7 +122,7 @@ describe('CalendarPage', () => { it('exports the chosen group, named after it', async () => { mixedList() - render() + renderPage() const { ids, filename, opts } = await exportWith('early') expect(ids).toEqual(['app-harvard-EA']) expect(filename).toBe('edvifi-early.ics') @@ -126,7 +132,7 @@ describe('CalendarPage', () => { it('exports everything under "all"', async () => { mixedList() - render() + renderPage() const { ids, filename } = await exportWith('all') expect(ids.sort()).toEqual( ['app-harvard-EA', 'app-yale-RD', 'fafsa-priority', 'scholarship-t1'].sort(), @@ -142,7 +148,7 @@ describe('CalendarPage', () => { // Recurring text, so the date is inferred rather than known. tracked({ deadline: 'September 15 (annual)', deadlineDate: null }), ]) - render() + renderPage() await screen.findAllByText('Coca-Cola Scholars') const { ids, filename } = await exportWith('all', ['Confirmed dates only']) @@ -156,7 +162,7 @@ describe('CalendarPage', () => { H.getTrackerItems.mockResolvedValue([ tracked({ deadline: 'September 15 (annual)', deadlineDate: null }), ]) - render() + renderPage() await userEvent.selectOptions(await screen.findByLabelText('Export'), 'scholarships') expect(screen.getByRole('button', { name: 'Add to my calendar' })).toBeEnabled() @@ -167,7 +173,7 @@ describe('CalendarPage', () => { it('shows a count against every group so an empty one is visible', async () => { H.getTrackerItems.mockResolvedValue([tracked()]) - render() + renderPage() // 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: /Scholarships only — 1/ })).toBeInTheDocument() diff --git a/web/src/components/CollegeDiscoverTab.test.tsx b/web/src/components/CollegeDiscoverTab.test.tsx index dcfd869..04a8da9 100644 --- a/web/src/components/CollegeDiscoverTab.test.tsx +++ b/web/src/components/CollegeDiscoverTab.test.tsx @@ -29,6 +29,9 @@ const H = vi.hoisted(() => { mk({ scorecard_id: 1, slug: 'state_flagship', name: 'State Flagship University', institution_type: '4yr' }), mk({ scorecard_id: 2, slug: 'local_cc', name: 'Local Community College', institution_type: '2yr', ownership: 'public', admit_rate: null, avg_net_price_cents: 300000, transfer_rate: 0.3 }), mk({ scorecard_id: 3, slug: 'private_college', name: 'Private Liberal Arts College', ownership: 'private_nonprofit' }), + // Another region entirely, so the region filter has something to remove. + mk({ scorecard_id: 4, slug: 'harborside', name: 'Harborside College', state: 'MA', city: 'Boston', + region: 'Northeast', latitude: 42.36, longitude: -71.06, size: 4000, ownership: 'private_nonprofit' }), ] const studentProfile = { gpa: 3.6, satTotal: 1300, act: null, intendedFields: ['engineering'], familyIncomeCents: 6000000, @@ -166,12 +169,12 @@ describe('CollegeDiscoverTab', () => { renderTab() const grid = () => screen.getByText(/^Showing \d+ of \d+$/).textContent await user.click(screen.getByRole('button', { name: 'Private' })) - expect(grid()).toBe('Showing 1 of 1') + expect(grid()).toBe('Showing 2 of 2') // the liberal-arts college + Harborside expect(screen.getAllByText('Private Liberal Arts College').length).toBeGreaterThan(0) await user.click(screen.getByRole('button', { name: 'Public' })) expect(grid()).toBe('Showing 2 of 2') // flagship + community college await user.click(screen.getByRole('button', { name: 'Public' })) // click again clears it - expect(grid()).toBe('Showing 3 of 3') + expect(grid()).toBe('Showing 4 of 4') }) it('removes a school from its card with one click when it has no progress', async () => { @@ -190,3 +193,26 @@ describe('CollegeDiscoverTab', () => { expect(onRemove).toHaveBeenCalledWith('sc-1') }) }) + +describe('CollegeDiscoverTab — region', () => { + it('narrows the list to the chosen region', async () => { + const user = userEvent.setup() + renderTab() + expect(screen.getAllByText('Harborside College').length).toBeGreaterThan(0) + + await user.selectOptions(screen.getByRole('combobox', { name: 'Region' }), 'Pacific') + // Gone from the grid and from the hidden-gems strip alike: once a student + // says where they are looking, an out-of-region card is a leak. + expect(screen.queryByText('Harborside College')).not.toBeInTheDocument() + expect(screen.getByText('State Flagship University')).toBeInTheDocument() + }) + + it('marks the student\'s own region without preselecting it', () => { + renderTab() + // homeState is CA. Browsing Discover is often about going somewhere else, + // so the filter opens on everything. + expect(screen.getByRole('combobox', { name: 'Region' })).toHaveValue('all') + expect(screen.getByRole('option', { name: 'Pacific (yours)' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'New England' })).toBeInTheDocument() + }) +}) diff --git a/web/src/components/CollegeDiscoverTab.tsx b/web/src/components/CollegeDiscoverTab.tsx index 5af3aed..1c86fc2 100644 --- a/web/src/components/CollegeDiscoverTab.tsx +++ b/web/src/components/CollegeDiscoverTab.tsx @@ -24,6 +24,9 @@ import { suggestTransferPath, pickAffordableAlternatives, collegeDistanceMi, + SEARCH_REGIONS, + searchRegionOf, + type SearchRegion, type College, type CollegeMatch, type AdmissionBand, @@ -345,6 +348,7 @@ export default function CollegeDiscoverTab({ const [pathwayFilter, setPathwayFilter] = useState<'all' | PathwayType>('all') const [search, setSearch] = useState('') const [sortBy, setSortBy] = useState<'fit' | 'price' | 'odds' | 'distance'>('fit') + const [regionFilter, setRegionFilter] = useState('all') const [affordableOnly, setAffordableOnly] = useState(false) // Public includes community colleges (they're public by definition); private // covers nonprofit and for-profit. @@ -372,6 +376,9 @@ export default function CollegeDiscoverTab({ () => ({ ...studentProfile, homeState: studentProfile.homeState ?? geoState }), [studentProfile, geoState], ) + // Marked in the list rather than preselected: a student browsing Discover is + // often looking for somewhere they are not. + const homeRegion = searchRegionOf(effectiveProfile.homeState) // Don't fetch until the student has finished onboarding (avoids a shortlist that's never shown). const { rows, loading, error } = useColleges(open && prefs.completed, effectiveProfile, origin) @@ -430,6 +437,12 @@ export default function CollegeDiscoverTab({ // screen (dimmed) rather than emptying the grid; later keystrokes keep the // previous search's cards the same way. Swapping in a short loading line // shrank the page and threw the student's scroll position back up. + // A territory has no search region, so an explicit pick is the only thing + // that ever hides it — it is never silently dropped from "All regions". + const inChosenRegion = useCallback( + (c: College) => regionFilter === 'all' || searchRegionOf(c.state) === regionFilter, + [regionFilter], + ) const pendingSearch = searching && dbSearching const showSearchList = searching && !(pendingSearch && searchScored.length === 0) const visible = useMemo( @@ -444,9 +457,10 @@ export default function CollegeDiscoverTab({ if (pathwayFilter !== 'all' && s.match.pathway !== pathwayFilter) return false if (ownershipFilter !== 'all' && (s.college.ownership === 'public') !== (ownershipFilter === 'public')) return false if (affordableOnly && !(s.match.netPriceForYouCents != null && s.match.netPriceForYouCents <= AFFORDABLE_MAX_CENTS)) return false + if (!inChosenRegion(s.college)) return false return true }), - [showSearchList, searchScored, scored, prefs.openToTransfer, prefs.openToTrade, pathwayFilter, ownershipFilter, affordableOnly], + [showSearchList, searchScored, scored, prefs.openToTransfer, prefs.openToTrade, pathwayFilter, ownershipFilter, affordableOnly, inChosenRegion], ) // "Best odds" ranks by how likely admission is (open first … reach last). @@ -477,8 +491,11 @@ export default function CollegeDiscoverTab({ // Respect the pathway toggles (same as the main list) so this doesn't leak a // community/transfer or trade school the student opted out of. .filter((s) => !(s.match.pathway === 'community_transfer' && !prefs.openToTransfer) && !(s.match.pathway === 'career_technical' && !prefs.openToTrade)) + // Same reasoning for the region: a gem two time zones away is a leak, + // not a find, once the student has said where they are looking. + .filter((s) => inChosenRegion(s.college)) .slice(0, 3) - }, [scored, topMatches, prefs.openToTransfer, prefs.openToTrade]) + }, [scored, topMatches, prefs.openToTransfer, prefs.openToTrade, inChosenRegion]) const added = (c: College) => existingIds.includes(collegeAppId(c)) // Progress worth confirming before removal: a status past "not started", or a ticked task. @@ -532,6 +549,10 @@ export default function CollegeDiscoverTab({ border: `1px solid ${active ? color : C.border}`, background: active ? color : C.white, color: active ? C.white : C.text, }) + const selectStyle: CSSProperties = { + fontFamily: "'Outfit',sans-serif", fontSize: 12.5, color: C.text, background: C.white, + border: `1px solid ${C.border}`, borderRadius: 8, padding: '7px 10px', cursor: 'pointer', outline: 'none', + } const filterBtn = (key: 'all' | PathwayType): CSSProperties => chipStyle(pathwayFilter === key, key === 'all' ? OWNERSHIP_COLOR : FILTER_COLOR[key]) // Colored dot inside an inactive filter; white once it's switched on. @@ -594,8 +615,17 @@ export default function CollegeDiscoverTab({
- { setRegionFilter(e.target.value as SearchRegion | 'all'); setVisibleCount(40) }} + style={selectStyle}> + + {SEARCH_REGIONS.map((r) => ( + + ))} + + 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