From 6d8a60557d6a1ae475ebc64cada70b69c7e2ca56 Mon Sep 17 00:00:00 2001 From: Danial Beg Date: Tue, 29 Sep 2026 16:46:40 -0700 Subject: [PATCH 1/4] Show two modules, and get the week ahead onto the first screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Knowledge Library and College Essays come off the dashboard, behind flags that are off unless the environment says otherwise โ€” so a missing variable hides a feature rather than shipping one nobody meant to ship. Neither module is touched; the dashboard simply offers no way in, and does not mount what it cannot open. The rest is vertical space, because the week strip was below the fold on load and it is the thing a student came to read. Four cards in two rows become two in one row, which is most of it. The colour band on a card drops from 90px to 46px: it is identification, not decoration, and at 90px each card spent more height on an emoji than on what it said. The fee-waiver notice keeps its full case inside the Applications module, where there is room for it and a reason to be reading it, and says one line on the dashboard. Fees run $50-$90 a school and waivers cover them: that is the part worth interrupting for. Counted from the CSS, what sits above the week strip goes from about 646px to about 325px with the notice showing. That is arithmetic on the rules, not a measurement of a rendered page, so it is the direction that is certain rather than the number. --- web/.env.example | 5 ++++ web/src/components/Dashboard.tsx | 37 +++++++++++++++----------- web/src/components/FeeWaiverNotice.tsx | 18 +++++++++---- web/src/index.css | 13 +++++---- web/src/lib/features.ts | 23 ++++++++++++++++ web/src/vite-env.d.ts | 4 +++ 6 files changed, 75 insertions(+), 25 deletions(-) create mode 100644 web/src/lib/features.ts diff --git a/web/.env.example b/web/.env.example index ea2a39f..433c7ce 100644 --- a/web/.env.example +++ b/web/.env.example @@ -39,3 +39,8 @@ VITE_TURNSTILE_SITE_KEY= # PostHog error tracking. Unset means errors stay in the console. VITE_POSTHOG_KEY= VITE_POSTHOG_HOST=https://us.i.posthog.com + +# Dashboard modules that are built but not currently offered. Set to "true" to +# show them; anything else (including absent) keeps them hidden. +VITE_FEATURE_KNOWLEDGE_LIBRARY= +VITE_FEATURE_ESSAYS= diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index 3dfc9a6..50cecf6 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -29,6 +29,7 @@ import type { Demographics } from '../types/user' import FinancialAidModule from './FinancialAidModule' import ApplicationTrackingModule from './ApplicationTrackingModule' import FeeWaiverNotice from './FeeWaiverNotice' +import { FEATURES, moduleEnabled } from '../lib/features' import { feeWaiverEligibility, shouldShowFeeWaiverNotice, FEE_WAIVER_NOTICE_KEY } from '../lib/feeWaivers' import EssaysModule from './EssaysModule' import KnowledgeLibraryModule from './KnowledgeLibraryModule' @@ -95,6 +96,8 @@ interface Props { onSignOut?: () => void } +/** Every module the dashboard knows. What it offers is this, filtered by + * the feature flags โ€” see lib/features.ts. */ const MODULES: { key: string; sub: string; color: string; emoji: string }[] = [ { key: 'Knowledge Library', sub: 'Start Here', color: '#3F5BA9', emoji: '๐Ÿ“š' }, { key: 'Financial Aid', sub: 'Scholarship Hunt', color: '#C47A12', emoji: '๐Ÿ’ฐ' }, @@ -255,7 +258,7 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P // Sort modules by need (lower answer = higher priority) // TODO: use shared constants for module key mapping - const sorted = [...MODULES].sort((a, b) => { + const sorted = MODULES.filter((m) => moduleEnabled(m.key)).sort((a, b) => { const aScore = answers[a.key.toLowerCase().replace(/ /g, '-')] ?? 2 const bScore = answers[b.key.toLowerCase().replace(/ /g, '-')] ?? 2 return aScore - bScore @@ -599,13 +602,15 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P /> - setOpenModule(null)}> - setOpenModule(null)} - onOpenModule={(key) => setOpenModule(key)} - /> - + {FEATURES.knowledgeLibrary && ( + setOpenModule(null)}> + setOpenModule(null)} + onOpenModule={(key) => setOpenModule(key)} + /> + + )} setOpenModule(null)}> - setOpenModule(null)}> - setOpenModule(null)} - aside={deadlineRail} - /> - + {FEATURES.essays && ( + setOpenModule(null)}> + setOpenModule(null)} + aside={deadlineRail} + /> + + )} {/* FAFSA intro โ†’ definition โ†’ module chain */} diff --git a/web/src/components/FeeWaiverNotice.tsx b/web/src/components/FeeWaiverNotice.tsx index 7fbf643..bad8417 100644 --- a/web/src/components/FeeWaiverNotice.tsx +++ b/web/src/components/FeeWaiverNotice.tsx @@ -26,6 +26,14 @@ interface Props { onDismiss: () => void } +/** One line instead of three. The dashboard's job is to get a student to the + * week ahead; the full case for waivers is made inside the module, where + * there is room for it and a reason to be reading it. */ +const SHORT = { + likely: 'Fees run $50โ€“$90 a school, and waivers cover them in full.', + unknown: 'Add your household income and we can tell you whether you qualify.', +} as const + const COPY = { likely: { title: 'You likely qualify for application fee waivers', @@ -64,9 +72,9 @@ export default function FeeWaiverNotice({ eligibility, variant, onPrimary, onDis border: `1px solid ${C.border}`, borderLeft: `3px solid ${MC}`, borderRadius: 14, - padding: '14px 16px', + padding: variant === 'dashboard' ? '11px 14px' : '14px 16px', boxShadow: C.shadow1, - marginBottom: variant === 'dashboard' ? 20 : 16, + marginBottom: variant === 'dashboard' ? 14 : 16, }} >
-
-

{mod.key}

+

{mod.key}

+

{mod.blurb}

+ {/* What is in there, and what is next. The pill used to + sit beside the title in the module's own colour, + which made the loudest thing on the row a date the + panel to the right already lists. */} +
+ {moduleStatus[mod.key] && {moduleStatus[mod.key]}} + {moduleStatus[mod.key] && nextDueByModule[mod.key] && ( + + )} {nextDueByModule[mod.key] && ( )}
-

{mod.blurb}

diff --git a/web/src/index.css b/web/src/index.css index 28555ba..a6b3cdd 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1488,35 +1488,33 @@ button { filter: drop-shadow(0 1px 2px rgba(0,0,0,0.12)); } .dash-module-body { flex: 1; min-width: 0; } -.dash-module-name-row { - display: flex; align-items: center; gap: 10px; - flex-wrap: wrap; margin-bottom: 3px; -} .dash-module-name { font-family: var(--font-display); - font-size: 17px; font-weight: 400; color: var(--text); - margin-bottom: 0; + font-size: 16.5px; font-weight: 400; color: var(--text); + margin: 0 0 3px; } +/* Where the module stands, and what is next: one quiet line, separated the + way the rest of the app separates facts. */ +.dash-module-meta { + display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; + margin-top: 7px; + font-family: var(--font-body); font-size: 11.5px; color: var(--text-faint); +} +.dash-module-sep { color: var(--text-faint); } .dash-module-due { - flex-shrink: 0; - display: inline-flex; align-items: center; gap: 5px; - font-family: var(--font-body, 'Outfit', sans-serif); - font-size: 11.5px; font-weight: 600; - padding: 3px 10px; border-radius: 999px; - border: 1px solid transparent; - white-space: nowrap; + border: 0; background: none; padding: 0; cursor: pointer; + font-family: var(--font-body); font-size: 11.5px; font-weight: 600; + color: var(--text-muted); white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; - cursor: pointer; - transition: filter 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; } -.dash-module-due:hover { filter: brightness(0.97); transform: translateY(-1px); box-shadow: 0 2px 6px rgba(60,35,10,0.10); } +.dash-module-due:hover { color: var(--c-go); text-decoration: underline; } .dash-module-due .dash-due-date { font-weight: 700; } .dash-module-due .dash-due-sep { opacity: 0.5; } /* The sentence that says what the module is for. */ .dash-module-sub { font-family: var(--font-body); font-size: 13px; line-height: 1.5; color: var(--text-muted); - margin: 0; max-width: 62ch; + margin: 0; max-width: 64ch; } .dash-module-open { flex: none; align-self: center; diff --git a/web/src/lib/moduleStatus.test.ts b/web/src/lib/moduleStatus.test.ts new file mode 100644 index 0000000..a4974a2 --- /dev/null +++ b/web/src/lib/moduleStatus.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' +import { moduleStatusLines } from './moduleStatus' + +const lines = (over: Partial[0]> = {}) => + moduleStatusLines({ colleges: 0, submitted: 0, scholarships: 0, ...over }) + +describe('moduleStatusLines', () => { + it('tells a new student there is nothing there yet, rather than showing a zero', () => { + expect(lines()['Application Tracking']).toBe('No colleges on your list yet') + expect(lines()['Financial Aid']).toBe('No scholarships tracked yet') + }) + + it('counts what is there, singular and plural', () => { + expect(lines({ colleges: 1 })['Application Tracking']).toBe('1 college') + expect(lines({ colleges: 6 })['Application Tracking']).toBe('6 colleges') + expect(lines({ scholarships: 1 })['Financial Aid']).toBe('1 scholarship tracked') + expect(lines({ scholarships: 4 })['Financial Aid']).toBe('4 scholarships tracked') + }) + + it('adds submitted only once something has been', () => { + // Nothing submitted is the normal state for most of the year, and a + // "0 submitted" against every school reads as a scoreboard. + expect(lines({ colleges: 6 })['Application Tracking']).toBe('6 colleges') + expect(lines({ colleges: 6, submitted: 2 })['Application Tracking']).toBe('6 colleges ยท 2 submitted') + }) +}) diff --git a/web/src/lib/moduleStatus.ts b/web/src/lib/moduleStatus.ts new file mode 100644 index 0000000..60df761 --- /dev/null +++ b/web/src/lib/moduleStatus.ts @@ -0,0 +1,29 @@ +/** + * Where a module stands, for its row on the dashboard. + * + * Both counts are already loaded for the deadline panel, so this costs no + * extra query. It exists to answer "is there anything in here yet" before the + * module is opened โ€” an empty tracker and a full one otherwise look the same + * from outside. + */ + +export interface ModuleCounts { + colleges: number + submitted: number + scholarships: number +} + +export function moduleStatusLines({ colleges, submitted, scholarships }: ModuleCounts): Record { + return { + // "No colleges yet" rather than "0 colleges": a student with an empty list + // needs to be told to start one, not handed a zero. + 'Application Tracking': colleges === 0 + ? 'No colleges on your list yet' + : `${plural(colleges, 'college')}${submitted > 0 ? ` ยท ${submitted} submitted` : ''}`, + 'Financial Aid': scholarships === 0 + ? 'No scholarships tracked yet' + : `${plural(scholarships, 'scholarship')} tracked`, + } +} + +const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}` diff --git a/web/src/lib/useDeadlineEvents.ts b/web/src/lib/useDeadlineEvents.ts index 47be37b..4990fad 100644 --- a/web/src/lib/useDeadlineEvents.ts +++ b/web/src/lib/useDeadlineEvents.ts @@ -103,6 +103,9 @@ export interface DeadlineEventsResult { correctDate: (id: string, iso: string | null) => void /** Write the student's note against an event, or clear it with ''. */ setNote: (id: string, text: string) => void + /** How many scholarships the student is tracking. The list is already + * loaded here; the dashboard wants the count and not the rows. */ + trackedScholarships: number } export function useDeadlineEvents( @@ -303,5 +306,5 @@ export function useDeadlineEvents( return visibility ? visibleDeadlines(corrected, { showEstimated, modules }) : corrected }, [apps, scholarships, own, doneIds, overrides, notes, gradeStartIdx, visibility, showEstimated, modules]) - return { events, failed, toggleDone, addOwn, removeOwn, correctDate, setNote } + return { events, failed, toggleDone, addOwn, removeOwn, correctDate, setNote, trackedScholarships: scholarships.length } } From b3dced2a778eec60f3211d8d295478879dbf90c4 Mon Sep 17 00:00:00 2001 From: Danial Beg Date: Tue, 29 Sep 2026 23:14:22 -0700 Subject: [PATCH 4/4] Open a module where it starts, not where it was left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Financial Aid and Application Tracking already began on Overview and Application Status. They just never went back there: both stay mounted while closed, holding the college list and their checklists, so the tab survived alongside the data. Someone who last glanced at Discover came back to Discover days later, with no memory of having gone there. Both now return to their opening tab when they reopen, and Application Tracking clears the school it had open inside Status too. Done by adjusting state on the open transition rather than in an effect, so the first paint is already the right tab โ€” an effect would render the old one and correct it a frame later. Nothing opens either module aimed at a particular tab, so there is nothing for this to override: the one deep link into Application Tracking is the fee-waiver notice, whose guidance sits above the tabs and shows whichever one is up. --- web/src/components/ApplicationTrackingModule.tsx | 11 +++++++++++ web/src/components/FinancialAidModule.tsx | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/web/src/components/ApplicationTrackingModule.tsx b/web/src/components/ApplicationTrackingModule.tsx index ef42379..ea0fa7d 100644 --- a/web/src/components/ApplicationTrackingModule.tsx +++ b/web/src/components/ApplicationTrackingModule.tsx @@ -90,6 +90,17 @@ export default function ApplicationTrackingModule({ open, onClose, onEditIncome, // (clicking a logo in Discover's list strip). Any other tab change clears it. const [statusSchool, setStatusSchool] = useState(null) const switchTab = useCallback((t: TabId) => { setStatusSchool(null); setTab(t) }, []) + + // The module stays mounted while closed โ€” it holds the college list and its + // checklist โ€” so without this it reopens wherever it was left. Someone who + // last looked at Discover came back to Discover, days later, with no memory + // of having gone there. Adjusted on the open transition rather than in an + // effect, so the first paint is already the right tab. + const [wasOpen, setWasOpen] = useState(open) + if (open !== wasOpen) { + setWasOpen(open) + if (open) { setTab('status'); setStatusSchool(null) } + } const openInStatus = useCallback((collegeId: string | null) => { setStatusSchool(collegeId); setTab('status') }, []) // Dismissal persists via mark_intro_seen, but that round-trips through a // profile refresh; track it locally so the banner goes away on the click. diff --git a/web/src/components/FinancialAidModule.tsx b/web/src/components/FinancialAidModule.tsx index 9cd7d66..7dc8c57 100644 --- a/web/src/components/FinancialAidModule.tsx +++ b/web/src/components/FinancialAidModule.tsx @@ -2529,6 +2529,15 @@ export default function FinancialAidModule({ open, onClose, year = 11, aside }: const isNarrow = useIsNarrow() const [tab, setTab] = useState('overview') + // Reopens on Overview rather than wherever it was left. The module stays + // mounted while closed, so the tab would otherwise survive from a visit the + // student no longer remembers. Adjusted on the open transition, not in an + // effect, so the first paint is already Overview. + const [wasOpen, setWasOpen] = useState(open) + if (open !== wasOpen) { + setWasOpen(open) + if (open) setTab('overview') + } const [progress, setProgress] = useState({}) const [progressError, setProgressError] = useState(null) const [userDemoTags, setUserDemoTags] = useState([])