Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -148,7 +149,14 @@ export default function App() {
case 'analyzing':
return <AnalyzingScreen key="analyzing" onComplete={handleAnalyzingComplete} />
case 'dashboard':
return <Dashboard key="dashboard" startIdx={dashStartIdx} answers={dashAnswers} firstName={dashName} onSignOut={handleSignOut} />
// 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 (
<ApplicationsProvider key="dashboard">
<Dashboard startIdx={dashStartIdx} answers={dashAnswers} firstName={dashName} onSignOut={handleSignOut} />
</ApplicationsProvider>
)
}
}

Expand Down
13 changes: 10 additions & 3 deletions web/src/components/ApplicationStatusTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down
18 changes: 12 additions & 6 deletions web/src/components/ApplicationTrackingModule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ─── */

Expand All @@ -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)
Expand All @@ -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<ApplicationEntry>(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(() => {
Expand Down Expand Up @@ -185,7 +191,7 @@ export default function ApplicationTrackingModule({ open, onClose, onEditIncome
&& shouldShowFeeWaiverNotice(incomeLevel, profile?.settings?.intros_seen)

const content =
tab === 'overview' ? <ModuleOverviewTab progress={progress} onToggle={handleToggle} onMarkComplete={handleMarkComplete} checklist={APPLICATIONS_CHECKLIST} contentMap={APPLICATIONS_CONTENT_MAP} allIds={APPLICATIONS_ALL_IDS} totalItems={APPLICATIONS_TOTAL_ITEMS} accent={MC} title="Application Strategy Checklist" subtitle={"Click an item title to read it. Click the circle to cycle status: empty → in-progress → done."} itemTypeIcon={itemTypeIcon} /> :
tab === 'overview' ? <ModuleOverviewTab progress={progress} onToggle={handleToggle} onMarkComplete={handleMarkComplete} checklist={APPLICATIONS_CHECKLIST} contentMap={APPLICATIONS_CONTENT_MAP} allIds={APPLICATIONS_ALL_IDS} totalItems={APPLICATIONS_TOTAL_ITEMS} accent={MC} title="Application Strategy Checklist" subtitle={"Click an item title to read it. Click the circle to cycle status: empty → in-progress → done."} itemTypeIcon={itemTypeIcon} aside={aside} /> :
tab === 'discover' ? <CollegeDiscoverTab open={open} apps={apps} onAdd={handleAddFromDiscover} onOpenSchool={openInStatus} onManageList={() => openInStatus(null)} onRemove={handleRemoveApp} /> :
<ApplicationStatusTab key={statusSchool ?? ''} onFindColleges={() => switchTab('discover')} apps={apps} onUpdate={handleUpdateApp} onRemove={handleRemoveApp} onSetShared={handleSetSharedTask} onSetSharedDue={handleSetSharedDue} gradeStartIdx={profile?.grade_start_idx} initialOpenId={statusSchool} />

Expand Down
26 changes: 16 additions & 10 deletions web/src/components/CalendarPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ApplicationsProvider><CalendarPage startIdx={SENIOR} /></ApplicationsProvider>,
)

const SENIOR = 10
/** Pinned so the calendar opens on a known month regardless of the real date. */
Expand All @@ -48,23 +54,23 @@ describe('CalendarPage', () => {

it('pins a tracked scholarship on its deadline date', async () => {
H.getTrackerItems.mockResolvedValue([tracked()])
render(<CalendarPage startIdx={SENIOR} />)
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()
})

it('opens on today and shows that day underneath', async () => {
H.getTrackerItems.mockResolvedValue([tracked({ deadline: 'Sep 4, 2026', deadlineDate: '2026-09-04' })])
render(<CalendarPage startIdx={SENIOR} />)
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)
})

it('shows a picked day in the panel below the grid', async () => {
H.getTrackerItems.mockResolvedValue([tracked()])
render(<CalendarPage startIdx={SENIOR} />)
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()
Expand All @@ -80,7 +86,7 @@ describe('CalendarPage', () => {
H.getTrackerItems.mockResolvedValue([
tracked({ deadline: 'September 15 (annual)', deadlineDate: null }),
])
render(<CalendarPage startIdx={SENIOR} />)
renderPage()
await userEvent.click(await screen.findByRole('button', { name: /September 15.*1 due/ }))
expect(screen.getByText('est.')).toBeInTheDocument()
})
Expand All @@ -89,7 +95,7 @@ describe('CalendarPage', () => {
H.getTrackerItems.mockResolvedValue([
tracked({ deadline: 'Varies - check official site', deadlineDate: null }),
])
render(<CalendarPage startIdx={SENIOR} />)
renderPage()
expect(await screen.findByText(/Add colleges in Application Tracking/)).toBeInTheDocument()
expect(screen.queryByText('Coca-Cola Scholars')).not.toBeInTheDocument()
})
Expand All @@ -116,7 +122,7 @@ describe('CalendarPage', () => {

it('exports the chosen group, named after it', async () => {
mixedList()
render(<CalendarPage startIdx={SENIOR} />)
renderPage()
const { ids, filename, opts } = await exportWith('early')
expect(ids).toEqual(['app-harvard-EA'])
expect(filename).toBe('edvifi-early.ics')
Expand All @@ -126,7 +132,7 @@ describe('CalendarPage', () => {

it('exports everything under "all"', async () => {
mixedList()
render(<CalendarPage startIdx={SENIOR} />)
renderPage()
const { ids, filename } = await exportWith('all')
expect(ids.sort()).toEqual(
['app-harvard-EA', 'app-yale-RD', 'fafsa-priority', 'scholarship-t1'].sort(),
Expand All @@ -142,7 +148,7 @@ describe('CalendarPage', () => {
// Recurring text, so the date is inferred rather than known.
tracked({ deadline: 'September 15 (annual)', deadlineDate: null }),
])
render(<CalendarPage startIdx={SENIOR} />)
renderPage()
await screen.findAllByText('Coca-Cola Scholars')

const { ids, filename } = await exportWith('all', ['Confirmed dates only'])
Expand All @@ -156,7 +162,7 @@ describe('CalendarPage', () => {
H.getTrackerItems.mockResolvedValue([
tracked({ deadline: 'September 15 (annual)', deadlineDate: null }),
])
render(<CalendarPage startIdx={SENIOR} />)
renderPage()

await userEvent.selectOptions(await screen.findByLabelText('Export'), 'scholarships')
expect(screen.getByRole('button', { name: 'Add to my calendar' })).toBeEnabled()
Expand All @@ -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(<CalendarPage startIdx={SENIOR} />)
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()
Expand Down
30 changes: 28 additions & 2 deletions web/src/components/CollegeDiscoverTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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()
})
})
Loading
Loading