From 957897abbe987d1a80b9e7ea62436d8adae6b5b0 Mon Sep 17 00:00:00 2001 From: Danial Beg Date: Sat, 26 Sep 2026 12:22:46 -0700 Subject: [PATCH 1/2] Say a date can go there, and show what's due inside modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a student could not find. An undated task showed a 34px dashed box with transparent text — a blank square that says nothing about what it is for, so nobody clicked it. Until there is a date, the control is now a labelled "+ Add date" button that swaps to a focused date input; once set, the input shows the date as before. The checklist stays a checklist rather than a wall of pickers, which is why the box was small to begin with, but it no longer hides the feature. The button carries its own accessible name, so the task it belongs to is announced. The deadline panel now also sits beside every module's Overview, so "what is actually due" no longer requires leaving the module. That was not possible before. The college list persists as one whole array and had two owners — Application Tracking and the dashboard's deadline hook — so whichever saved last replaced what the other had just written. The dashboard dodged it by pausing its hook whilst a module was open, which is exactly what ruled out showing the panel inside one. So the list has a single owner now: ApplicationsContext, wrapped around everything that reads or writes it. Both callers share that one array, a change made in either place is visible in the other with no refetch, and the pause becomes a refresh key — entering or leaving a module refetches the sources this hook still owns, without the panel ever going dark. A new test covers the case the pause existed for: a college added while the panel is live survives a tick. Scholarship tracker items look like the same problem and are not — they are rows in their own table written one at a time, so concurrent writers cannot overwrite each other. Left alone. The rail is built once on the dashboard and passed down, rather than each module calling the hook: a second instance would give the student's ticked-off list two owners and reintroduce the bug one layer down. --- web/src/App.tsx | 10 +- .../components/ApplicationStatusTab.test.tsx | 13 ++- .../components/ApplicationTrackingModule.tsx | 18 ++-- web/src/components/CalendarPage.test.tsx | 26 ++++-- web/src/components/Dashboard.tsx | 38 +++++--- web/src/components/EssaysModule.tsx | 8 +- web/src/components/FinancialAidModule.tsx | 16 +++- web/src/components/ModuleOverviewTab.tsx | 13 ++- web/src/components/SchoolApplicationPage.tsx | 42 +++++++-- web/src/contexts/ApplicationsContext.tsx | 58 ++++++++++++ web/src/index.css | 15 +++ web/src/lib/useDeadlineEvents.test.tsx | 93 ++++++++++++++----- web/src/lib/useDeadlineEvents.ts | 46 ++++----- 13 files changed, 301 insertions(+), 95 deletions(-) create mode 100644 web/src/contexts/ApplicationsContext.tsx 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/Dashboard.tsx b/web/src/components/Dashboard.tsx index 36354ed..0460daf 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -205,11 +205,12 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P [profile?.settings], ) // Next-due deadline per module card, derived from the student's college list. - // Re-fetch whenever we return to the dashboard so newly-added colleges surface. const { events: deadlineEvents, failed: deadlinesFailed, toggleDone, addOwn, removeOwn, correctDate, } = useDeadlineEvents(startIdx, { - active: !openModule, + // Entering or leaving a module refetches, so a scholarship added in one + // shows up without the panel ever going dark. + refreshKey: openModule ?? '', visibility: deadlinePrefs, // A tick edits a record in another module; say which, or the student // never learns the two are the same thing. @@ -218,6 +219,24 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P // One clock for every dated view on this page, so the week strip, the panel // and the module chips can't disagree about which day is today. const now = useMemo(() => new Date(), []) + // The same panel the sidebar shows, handed to each module's Overview so a + // student can see and tick what's due without leaving the module. Built + // here, from the one hook above, so every copy writes through the same + // handlers — a module loading its own would put a second owner on the + // student's ticked-off list. + const deadlineRail = ( + { setOpenModule(null); setCalendarDay(null); setPage('calendar') }} + /> + ) const nextDueByModule = useMemo(() => { // A module card's "next due": not something already ticked off, and not a // date the student put on one of its tasks (those are steps toward a @@ -560,17 +579,7 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.5, duration: 0.5, ease: EASE_OUT }} > - { setCalendarDay(null); setPage('calendar') }} - /> + {deadlineRail} @@ -579,6 +588,7 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P open={openModule === 'Financial Aid'} onClose={() => setOpenModule(null)} year={startIdx <= 0 ? 9 : startIdx <= 1 ? 10 : startIdx <= 2 ? 11 : 12} + aside={deadlineRail} /> @@ -595,6 +605,7 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P open={openModule === 'Application Tracking'} onClose={() => setOpenModule(null)} onEditIncome={() => { setOpenModule(null); setPage('profile') }} + aside={deadlineRail} /> @@ -602,6 +613,7 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P setOpenModule(null)} + aside={deadlineRail} /> diff --git a/web/src/components/EssaysModule.tsx b/web/src/components/EssaysModule.tsx index 641de07..b00ce73 100644 --- a/web/src/components/EssaysModule.tsx +++ b/web/src/components/EssaysModule.tsx @@ -12,6 +12,7 @@ import { useEffect, useMemo, type CSSProperties, + type ReactNode, } from 'react' import { useAuth } from '../contexts/AuthContext' import { useToast } from '../contexts/ToastContext' @@ -376,9 +377,12 @@ const inputStyle: CSSProperties = { interface Props { open: boolean onClose: () => 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 EssaysModule({ open, onClose }: Props) { +export default function EssaysModule({ open, onClose, aside }: Props) { const { user, profile, refreshProfile } = useAuth() const tourSeen = profile?.settings?.intros_seen?.includes(TOUR_INTRO_KEY) ?? false const [showTour, setShowTour] = useState(false) @@ -402,7 +406,7 @@ export default function EssaysModule({ open, onClose }: Props) { const content = tab === 'overview' - ? + ? : return ( diff --git a/web/src/components/FinancialAidModule.tsx b/web/src/components/FinancialAidModule.tsx index 3d94010..9cd7d66 100644 --- a/web/src/components/FinancialAidModule.tsx +++ b/web/src/components/FinancialAidModule.tsx @@ -388,11 +388,12 @@ const ModuleTabNav = ({ TAB: OVERVIEW ═══════════════════════════════════════════════════════════════ */ interface OverviewTabProps { + aside?: ReactNode progress: ChecklistProgressMap onToggle: (itemId: string) => void } -const OverviewTab = ({ progress, onToggle }: OverviewTabProps) => { +const OverviewTab = ({ progress, onToggle, aside }: OverviewTabProps) => { const [activeContentId, setActiveContentId] = useState(null) const [expanded, setExpanded] = useState>({ 0: true, 1: true, 2: true, 3: true }) const toggle = (i: number) => setExpanded((p) => ({ ...p, [i]: !p[i] })) @@ -439,7 +440,8 @@ const OverviewTab = ({ progress, onToggle }: OverviewTabProps) => { } return ( -
+
+
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/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 From 288891362f82e9feb139b38f64b8f1a400284d98 Mon Sep 17 00:00:00 2001 From: Danial Beg Date: Sat, 26 Sep 2026 12:38:26 -0700 Subject: [PATCH 2/2] Let Discover narrow by region, not just sort by distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Nearest" only reorders the list — a student set on the Northeast still had Boston first and Arizona fifth. Region is a filter, which is the thing they actually wanted. The colleges table already carries a region, but it is the four census ones, and they are far too coarse to browse by: "West" is Seattle, Phoenix and Honolulu together, "Northeast" runs Maine to Pennsylvania. So this adds a finer set — New England, Mid-Atlantic, Southeast, Midwest, South Central, Mountain West, Pacific — derived from the state, narrow enough that picking one meaningfully shortens the list. The census map stays exactly as it is. It is stored on every college row and feeds the distance dimension of the match score; re-cutting it would silently restate every student's results for a UI change. The student's own region is labelled "(yours)" rather than preselected — someone browsing Discover is often looking for somewhere they are not. The filter also applies to hidden gems, which already take care not to leak a pathway the student opted out of; an out-of-region gem is the same kind of leak once they have said where they are looking. Territories have no search region, so an explicit pick is the only thing that ever hides them. Tests cover that all 50 states and DC land in exactly one region — a missing state would silently vanish from the list the moment anyone filtered. --- .../components/CollegeDiscoverTab.test.tsx | 30 ++++++++++++++- web/src/components/CollegeDiscoverTab.tsx | 38 +++++++++++++++++-- web/src/lib/collegeMatch.test.ts | 20 ++++++++++ web/src/lib/collegeMatch.ts | 36 ++++++++++++++++++ 4 files changed, 118 insertions(+), 6 deletions(-) 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) => ( + + ))} + +