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
26 changes: 23 additions & 3 deletions web/src/components/SchoolApplicationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import { useEffect, useRef, useState } from 'react'
import { C, MODULE_COLORS } from '../lib/designTokens'
import { Bar, CollegeLogo, SecLabel } from './moduleUI'
import JourneyStepper from './JourneyStepper'
import { TASK_PHASES, tasksForEntry, isSharedTask } from '../data/applicationTasks'
import { TASK_PHASES, tasksForEntry, isSharedTask, suggestedWaiverDue, WAIVER_LEAD_DAYS } from '../data/applicationTasks'
import { DEADLINE_TYPE_LABEL, DEADLINE_TYPE_MEANING, daysLabel, urgencyColor, type DeadlineEvent } from '../data/applicationDeadlines'
import NetPriceFact from './NetPriceFact'
import { toIsoDay } from '../lib/personalDeadlines'
import {
APP_STATUS_META,
CATEGORY_META,
Expand Down Expand Up @@ -60,10 +61,13 @@ const plausibleDay = (v: string) => {
return /^\d{4}-\d{2}-\d{2}$/.test(v) && year >= 2000 && year <= 2100
}

function TaskDueInput({ due, label, hint, onCommit }: {
function TaskDueInput({ due, label, hint, suggested, onCommit }: {
due?: string
label: string
hint: string
/** A sensible date to open on, as `YYYY-MM-DD`. The student still has to
* save it — nothing is written until they do. */
suggested?: string
onCommit: (due: string | undefined) => void
}) {
// null = not editing: show the saved value.
Expand Down Expand Up @@ -97,7 +101,7 @@ function TaskDueInput({ due, label, hint, onCommit }: {
return (
<button
type="button"
onClick={() => setAdding(true)}
onClick={() => { if (suggested) setDraft(suggested); setAdding(true) }}
aria-label={`Add a due date for ${label}`}
title={hint}
style={{
Expand Down Expand Up @@ -186,6 +190,9 @@ export default function SchoolApplicationPage({
const cat = CATEGORY_META[app.category]
const preSubmission = app.status === 'not-started' || app.status === 'in-progress'
const urgent = preSubmission ? urgencyColor(daysLeft) : null
// A month before this school's deadline, for the fee-waiver task. Null when
// we hold no deadline to count back from.
const waiverSuggestion = deadline ? toIsoDay(suggestedWaiverDue(deadline.date)) : undefined

const setDue = (task: AppTask, due: string | undefined) => {
if (isSharedTask(task)) onSetSharedDue(task.id, due)
Expand Down Expand Up @@ -334,8 +341,21 @@ export default function SchoolApplicationPage({
due={task.due}
label={task.label}
hint={task.due ? 'Your date for this task' : shared && n > 1 ? 'Set your own date (for every school that needs this)' : 'Set your own date for this task'}
// The waiver is the one task whose date is not a
// preference: the counselor needs the lead time, so
// the box opens on a month before this school's
// deadline rather than on nothing.
suggested={task.id === 'waiver' ? waiverSuggestion : undefined}
onCommit={(due) => setDue(task, due)}
/>
{task.id === 'waiver' && !task.done && (
<span
title={`Counselors confirm waiver eligibility for a whole cohort each autumn. ${WAIVER_LEAD_DAYS} days gives them room, and you room to chase it.`}
style={{ fontFamily: font, fontSize: 11, fontWeight: 600, color: C.textMuted, background: C.surface, border: `1px solid ${C.border}`, borderRadius: 99, padding: '2px 8px', whiteSpace: 'nowrap' }}
>
ask ~{WAIVER_LEAD_DAYS} days ahead
</span>
)}
{shared && n > 1 && (
<span title="Done once, counts for every school that needs it" style={{ fontFamily: font, fontSize: 11, fontWeight: 600, color: MC, background: `${MC}12`, borderRadius: 99, padding: '2px 8px', whiteSpace: 'nowrap' }}>
Shared with {n - 1} other {n - 1 === 1 ? 'school' : 'schools'}
Expand Down
71 changes: 67 additions & 4 deletions web/src/data/applicationTasks.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { defaultTasksFor, deriveTaskEvents, initialTasksFor, setSharedTask, sharedTaskSummary, tasksForEntry, tasksForRound, updateSharedTask } from './applicationTasks'
import { defaultTasksFor, deriveTaskEvents, initialTasksFor, setSharedTask, sharedTaskSummary, tasksForEntry, tasksForRound, updateSharedTask, suggestedWaiverDue, WAIVER_LEAD_DAYS } from './applicationTasks'
import { withKnownCategory, type ApplicationEntry } from './applicationsChecklist'

const app = (collegeId: string, fields: Partial<ApplicationEntry> = {}): ApplicationEntry => ({
Expand All @@ -16,7 +16,9 @@ describe('shared tasks', () => {
app('c', { institutionType: '2yr' }),
]
const byId = Object.fromEntries(sharedTaskSummary(apps).map((s) => [s.id, s.total]))
expect(byId).toEqual({ recs: 2, transcript: 3, css: 1 })
// The waiver follows the four-year schools: 'c' is a community college,
// which almost never charges an application fee to waive.
expect(byId).toEqual({ recs: 2, transcript: 3, css: 1, waiver: 2 })
})

it('ignores withdrawn schools', () => {
Expand All @@ -38,8 +40,19 @@ describe('shared tasks', () => {
})

it('does not treat a custom task with a shared id as shared', () => {
const a = app('a', { tasks: [{ id: 'recs', label: 'Mine', done: false, phase: 'after', custom: true }] })
expect(setSharedTask([a], 'recs', true)[0]).toBe(a)
// A real saved list, plus a custom task that happens to reuse the id.
const base = app('a')
const a = app('a', {
tasks: [
...defaultTasksFor(base),
{ id: 'recs', label: 'Mine', done: false, phase: 'after', custom: true },
],
})
const [out] = setSharedTask([a], 'recs', true)
const byCustom = Object.fromEntries(
(out.tasks ?? []).filter((t) => t.id === 'recs').map((t) => [t.custom ? 'custom' : 'default', t.done]),
)
expect(byCustom).toEqual({ default: true, custom: false })
})

it('carries finished shared tasks over to a newly added school', () => {
Expand Down Expand Up @@ -137,3 +150,53 @@ describe('withKnownCategory', () => {
})
})


describe('fee waivers need lead time', () => {
const idsOf = (a: ApplicationEntry) => defaultTasksFor(a).map((t) => t.id)
const phaseOf = (a: ApplicationEntry, id: string) => defaultTasksFor(a).find((t) => t.id === id)?.phase

it('asks about the waiver before applying, and pays at submit', () => {
// One task used to do both: "Pay the application fee (or apply for a
// waiver)", in the submit phase. A waiver cannot be a submit-day job.
const a = app('a')
expect(phaseOf(a, 'waiver')).toBe('before')
expect(phaseOf(a, 'fee')).toBe('submit')
})

it('is one request for the whole list, unlike the fee itself', () => {
// A granted Common App waiver applies at every Common App school; paying
// is per school.
const [out] = setSharedTask([app('a'), app('b')], 'waiver', true)
expect(out.tasks?.find((t) => t.id === 'waiver')?.done).toBe(true)
const [feeOut] = setSharedTask([app('a'), app('b')], 'fee', true)
expect(feeOut.tasks?.find((t) => t.id === 'fee')?.done).not.toBe(true)
})

it('leaves community colleges alone', () => {
// 611 of the 1,128 schools in the Common App grid charge nothing to apply,
// and two-year schools are overwhelmingly among them.
expect(idsOf(app('c', { institutionType: '2yr' }))).not.toContain('waiver')
expect(idsOf(app('a'))).toContain('waiver')
})

it('counts back a month from the deadline', () => {
expect(WAIVER_LEAD_DAYS).toBe(30)
// Across a month boundary, which naive date arithmetic gets wrong.
expect(suggestedWaiverDue(new Date(2027, 0, 2)).toDateString()).toBe(new Date(2026, 11, 3).toDateString())
})

it('reaches a list saved before the task existed', () => {
// Default tasks cannot be deleted, so a saved list missing one is simply
// old — a student who added a school last month should not be the only one
// without this step.
const old = defaultTasksFor(app('a')).filter((t) => t.id !== 'waiver')
const topped = tasksForEntry(app('a', { tasks: old }))
expect(topped.find((t) => t.id === 'waiver')).toBeDefined()
expect(topped.find((t) => t.id === 'waiver')?.phase).toBe('before')
})

it('leaves a complete list exactly as it was', () => {
const entry = app('a', { tasks: defaultTasksFor(app('a')) })
expect(tasksForEntry(entry)).toBe(entry.tasks)
})
})
58 changes: 52 additions & 6 deletions web/src/data/applicationTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,17 @@ export function defaultTasksFor(app: ApplicationEntry): AppTask[] {
tasks.push(t('agreement', `Review & sign the ${app.deadlineType} agreement`, 'before'))
}

// Asking for the waiver is not a submit-day job. The request goes through
// the counselor, who is doing the same for a whole cohort in October and
// November, so it belongs with the work done ahead of the deadline.
//
// Not for community colleges and trade schools: most charge nothing to
// apply, and a step about waiving a fee that does not exist is noise on the
// one list that should be shortest.
if (fourYear) tasks.push(t('waiver', 'Ask your counselor about a fee waiver', 'before'))

// ── Submit ──
tasks.push(t('fee', 'Pay the application fee (or apply for a waiver)', 'submit'))
tasks.push(t('fee', 'Pay the application fee, if one is owed', 'submit'))
tasks.push(t('transcript', 'Send your official transcript (via counselor)', 'submit'))
if (fourYear) {
tasks.push(t('scores', 'Send test scores (or confirm test-optional)', 'submit'))
Expand Down Expand Up @@ -84,9 +93,26 @@ export function tasksForRound(app: ApplicationEntry, deadlineType: ApplicationEn
return app.tasks.filter((t) => !(t.id === 'agreement' && !t.custom))
}

/** The current task list for an entry — its saved tasks, or the seeded default. */
/**
* The current task list for an entry — its saved tasks, or the seeded default.
*
* A saved list is also topped up with any default task it is missing. Default
* tasks cannot be deleted (only custom ones can), so a list without one was
* saved before that task existed, and a student who added a school last month
* should not be the only one without the fee-waiver step.
*/
export function tasksForEntry(app: ApplicationEntry): AppTask[] {
return app.tasks ?? defaultTasksFor(app)
if (!app.tasks) return defaultTasksFor(app)
const saved = new Set(app.tasks.filter((t) => !t.custom).map((t) => t.id))
const missing = defaultTasksFor(app).filter((t) => !saved.has(t.id))
if (missing.length === 0) return app.tasks
// Each goes in with its own phase's tasks, so the list keeps its order.
const out = [...app.tasks]
for (const task of missing) {
const last = out.map((t) => t.phase).lastIndexOf(task.phase)
out.splice(last + 1, 0, task)
}
return out
}

/** { done, total } progress for an entry. */
Expand All @@ -99,10 +125,30 @@ export function taskProgress(app: ApplicationEntry): { done: number; total: numb
* Tasks that are done once for the whole list rather than once per school.
* Teacher recommendations are requested once through the Common App, the
* counselor sends one transcript that every school receives, and a single CSS
* Profile goes to all the schools that need it. Test scores, fees and essays
* are deliberately not here: each school needs its own.
* Profile goes to all the schools that need it.
*
* The fee *waiver* is one request too — a Common App waiver, once granted,
* applies at every Common App school on the list. Paying a fee is not, which
* is why the two are separate tasks. Test scores and essays stay per-school.
*/
export const SHARED_TASK_IDS: ReadonlySet<string> = new Set(['recs', 'transcript', 'css'])
export const SHARED_TASK_IDS: ReadonlySet<string> = new Set(['recs', 'transcript', 'css', 'waiver'])

/**
* How long before a deadline to start asking about a fee waiver.
*
* The form itself is quick; the counselor is the queue. They are confirming
* eligibility for a whole cohort through the autumn, and a request made in the
* last week competes with everyone else's. A month leaves room for that and
* for a second attempt if the first goes astray.
*/
export const WAIVER_LEAD_DAYS = 30

/** The date to aim for, given when the application is actually due. */
export function suggestedWaiverDue(deadline: Date): Date {
const d = new Date(deadline)
d.setDate(d.getDate() - WAIVER_LEAD_DAYS)
return d
}

export const isSharedTask = (task: AppTask): boolean => !task.custom && SHARED_TASK_IDS.has(task.id)

Expand Down
Loading