Skip to content

Commit 672ea13

Browse files
authored
refactor feature architecture (#181)
* refactor: co-locate audit session and preference state * refactor: separate course search and planner features * refactor: organize audit UI and extension surfaces * docs: align architecture with feature ownership * button fix
1 parent 6d79524 commit 672ea13

45 files changed

Lines changed: 643 additions & 303 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ _(Add a screenshot or a GIF of your extension in action here!)_
2121

2222
- **Frontend:** React, TypeScript
2323
- **Styling:** Tailwind CSS
24-
- **State Management:** Redux Toolkit
25-
- **Backend & Data:** Supabase (for storing user-created plans)
26-
- **Platform:** Chrome Extension API (Manifest V3)
24+
- **State Management:** React Context and local component state
25+
- **Data:** Browser storage for audits/preferences and IndexedDB (Dexie) for the course catalog; no external backend
26+
- **Platform:** Chrome (Manifest V3) and Firefox extension APIs via WXT
2727
- **Build Tools:** WXT, Vite, Bun
2828

2929
## Installation

docs/architecture.md

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
# Architecture
2+
3+
Degree Audit Plus uses feature-owned modules with one-way dependencies. Code
4+
that changes for the same reason lives together; shared folders are reserved for
5+
application vocabulary, reusable UI primitives, and the extension message
6+
protocol.
7+
8+
The main state seam is `features/audit/audit-provider.tsx`. The main persisted
9+
state seam is `features/audit/audit-storage.ts`. No separate planner store or
10+
global state library is needed.
11+
12+
## Layout
13+
14+
```text
15+
features/
16+
├── audit/ # Audit state, persistence, mutations, calculations, UI
17+
│ └── ui/ # Audit-specific pages, cards, panels, graph, navigation
18+
├── audit-scraping/ # UT audit acquisition and browser controllers
19+
├── catalog/ # Catalog data, IndexedDB, mapping, catalog parser
20+
│ └── scraping/ # Developer catalog-refresh parser
21+
├── course-search/ # Audit-aware catalog search and add-course modal
22+
├── planner/ # Planner UI and local interaction state
23+
├── session/ # UT authentication/session knowledge
24+
├── preferences/ # Preference persistence and React state
25+
├── popup/ # Popup surface and popup-only state
26+
└── banner/ # UT-page banner
27+
28+
domain/ # Framework-free application types and semester helpers
29+
components/ui/ # Reusable UI primitives
30+
lib/browser/ # Typed extension message protocol
31+
entrypoints/ # WXT registration and feature composition
32+
scripts/catalog/ # Developer-only catalog refresh and validation
33+
```
34+
35+
## Dependency direction
36+
37+
```mermaid
38+
flowchart TD
39+
Domain["domain"]
40+
Catalog["catalog"]
41+
Preferences["preferences"]
42+
AuditCore["audit core"]
43+
CourseSearch["course-search"]
44+
Planner["planner"]
45+
AuditUI["audit/ui"]
46+
Session["session"]
47+
Messages["lib/browser"]
48+
Scraping["audit-scraping"]
49+
Surfaces["popup + banner"]
50+
Entrypoints["entrypoints"]
51+
52+
Catalog --> Domain
53+
Preferences --> Domain
54+
AuditCore --> Domain
55+
AuditCore --> Preferences
56+
CourseSearch --> Catalog
57+
CourseSearch --> AuditCore
58+
CourseSearch --> Domain
59+
Planner --> AuditCore
60+
Planner --> CourseSearch
61+
Planner --> Domain
62+
Planner --> AuditUI
63+
AuditUI --> AuditCore
64+
AuditUI --> CourseSearch
65+
AuditUI --> Preferences
66+
Scraping --> AuditCore
67+
Scraping --> Session
68+
Scraping --> Messages
69+
Surfaces --> AuditCore
70+
Surfaces --> Session
71+
Surfaces --> Messages
72+
Entrypoints --> AuditUI
73+
Entrypoints --> Planner
74+
Entrypoints --> Scraping
75+
Entrypoints --> Surfaces
76+
```
77+
78+
The important constraints are:
79+
80+
- Catalog is data-only. It imports domain types, Dexie, and its own files; it
81+
never imports Audit, Course Search, Planner, or React.
82+
- Audit core never imports Audit UI, Course Search, Planner, Popup, Banner, or
83+
Audit Scraping.
84+
- Course Search is the explicit join between Audit and Catalog. Recommendation
85+
functions accept audit sections as arguments and do not use React.
86+
- Planner reuses Audit as the persisted state owner. Drag, menu, and preview
87+
state stay local to Planner UI.
88+
- Audit Scraping contains no React/UI code. It writes through Audit Storage and
89+
uses Session for authentication knowledge.
90+
- Features never import entrypoints. Entrypoints register controllers and
91+
compose providers/components; features do not depend on them.
92+
93+
## Feature responsibilities
94+
95+
### Audit
96+
97+
`audit-provider.tsx` loads and observes the selected audit, owns URL and
98+
last-selected-audit fallback, derives sections/progress/courses/semesters, and
99+
persists user intents. Its public React interface remains `AuditContextProvider`
100+
and `useAuditContext()`.
101+
102+
`audit-storage.ts` is the only owner of audit browser-storage details. It owns
103+
the existing keys and stored shapes for:
104+
105+
- audit history (`getAuditHistory`, `observeAuditHistory`, `saveAuditHistory`,
106+
`renameAudit`);
107+
- individual audit data (`getAuditData`, `watchAuditData`, `saveAuditData`,
108+
`getUncachedAuditIds`); and
109+
- saved audit combinations (`getCachedComposites`, `createComposite`,
110+
`updateCachedComposite`, `deleteCachedComposite`, `loadCompositeAudit`).
111+
112+
`observeAuditHistory` hides the initial read plus subsequent storage watch behind
113+
one cleanup function. It prevents a delayed initial read from overwriting a newer
114+
watched update.
115+
116+
`audit-mutations.ts` contains immutable, browser-free changes to cached audit
117+
data: add, remove, wipe, and move planned courses. `audit-calculations.ts`
118+
contains side-effect-free composite/progress calculations. `section-groups.ts`
119+
contains the pure mapping from calculated sections to dashboard display groups.
120+
121+
`audit/ui/` may consume Audit core, Preferences, Course Search, domain types,
122+
and shared UI utilities. It never reads browser storage directly. Planner reuses
123+
the audit-owned degree side panel because that panel displays audit progress; the
124+
dependency remains one-way and Audit UI does not import Planner.
125+
126+
### Audit Scraping
127+
128+
- `audit-page-parser.ts` converts one UT audit result document to
129+
`CachedAuditData`.
130+
- `audit-history-parser.ts` converts history HTML to `AuditHistoryEntry[]`.
131+
- `parse-major.ts` owns UT program-name normalization.
132+
- `audit-history-sync.ts` fetches, parses, stores, and observes audit history,
133+
then requests uncached audit scrapes.
134+
- `content-controller.ts` responds to content-script messages, parses the
135+
visible result page, and records visible login state.
136+
- `background-controller.ts` coordinates scrape batches, timeouts, result
137+
persistence, sync status, dashboard opening, and new-audit runs.
138+
- `scraper-window.ts` hides minimized window/tab creation, page-load waiting,
139+
messaging, and cleanup.
140+
141+
Login-page recognition is intentionally not in a parser. It belongs to Session.
142+
143+
### Catalog and Course Search
144+
145+
Catalog owns the bundled course data and its IndexedDB representation:
146+
147+
- `catalog-db.ts` owns the Dexie schema and catalog queries.
148+
- `seed-catalog.ts` versions and seeds IndexedDB from the bundled JSON.
149+
- `department-map.ts` is static department data.
150+
- `catalog-course-mappers.ts` contains pure preview/planned-course transforms,
151+
filtering, and deduplication.
152+
- `scraping/catalog-parser.ts` parses UT catalog HTML for the developer refresh
153+
workflow in `scripts/catalog/`.
154+
155+
Course Search owns the audit-aware user flow:
156+
157+
- `course-recommendations.ts` joins audit requirements to Catalog queries using
158+
plain function arguments.
159+
- `course-modal-provider.tsx` owns modal scope, recommendations, and loading
160+
state, and receives current sections from Audit.
161+
- The remaining files render search results and the add-course flow. They call
162+
Audit Provider intents rather than accessing audit storage.
163+
164+
This split avoids an Audit ↔ Catalog dependency cycle: Audit UI can open Course
165+
Search, Course Search can read Audit core and Catalog, and Catalog remains
166+
independent.
167+
168+
### Planner
169+
170+
Planner has no storage or provider of its own. Persisted courses and semesters
171+
remain in Audit Provider; temporary DnD and menu state remains in Planner UI.
172+
Substantial future pure logic such as prerequisite validation or semester-load
173+
calculation may earn a planner calculation module, but no abstraction is added
174+
until that logic exists.
175+
176+
### Session and Preferences
177+
178+
`session/session.ts` is the only owner of UT authentication knowledge: the login
179+
cache, probe URL, cookie name/watch, login-page recognition, and login-tab
180+
opening. Popup and Audit Scraping consume this interface rather than knowing UT
181+
session details.
182+
183+
`preferences-storage.ts` owns preference keys, defaults, and typed WXT storage
184+
items. `preferences-provider.tsx` owns the React state and document theme for
185+
sidebar, luminosity, view mode, and last-selected audit.
186+
187+
### Popup and Banner
188+
189+
Popup owns popup-only presentation state such as `showAll`, `runningAudit`, login
190+
presentation, and sync indicators. Banner owns only its open/closed state and
191+
the first available audit ID. Both receive history through Audit Storage; they do
192+
not duplicate persistence logic.
193+
194+
## Runtime data flows
195+
196+
### Audit acquisition
197+
198+
```mermaid
199+
sequenceDiagram
200+
participant Content as audit content controller
201+
participant Sync as audit history sync
202+
participant Store as audit storage
203+
participant BG as background controller
204+
participant Window as scraper window
205+
participant Parser as audit page parser
206+
207+
Content->>Sync: startAuditHistorySync(document)
208+
Sync->>Store: saveAuditHistory(entries)
209+
Sync->>Store: getUncachedAuditIds(ids)
210+
Sync->>BG: SCRAPE_ALL_AUDITS
211+
loop each uncached audit
212+
BG->>Window: create scraper tab
213+
Window->>Content: RUN_SCRAPER
214+
Content->>Parser: parseAuditPage(document)
215+
Parser-->>Content: CachedAuditData
216+
Content->>BG: AUDIT_RESULTS
217+
BG->>Store: saveAuditData(id, audit)
218+
BG->>Window: close scraper tab
219+
end
220+
```
221+
222+
### Dashboard state
223+
224+
```mermaid
225+
flowchart LR
226+
History["Audit Storage history"] --> Provider["Audit Provider"]
227+
Data["Selected CachedAuditData"] --> Provider
228+
Preferences["last audit preference"] --> Provider
229+
Provider --> Calculations["pure calculations"]
230+
Provider --> AuditUI["Audit UI"]
231+
Provider --> Planner["Planner"]
232+
Provider --> Search["Course Search"]
233+
Search --> Catalog["Catalog queries"]
234+
AuditUI -->|intent| Provider
235+
Planner -->|intent| Provider
236+
Search -->|add-course intent| Provider
237+
Provider -->|persist| Data
238+
```
239+
240+
Only canonical audit data is stored. Requirements, progress, course maps, and
241+
semester groups are derived in the provider so views do not maintain competing
242+
copies of the same state.
243+
244+
## Shared code and entrypoints
245+
246+
`domain/` contains plain TypeScript vocabulary and semester helpers with no
247+
React, browser, storage, or Dexie dependencies. `components/ui/` contains only
248+
genuinely reusable UI primitives. `lib/browser/messages.ts` owns the typed
249+
extension message union and send/response helpers.
250+
251+
Entrypoints stay thin:
252+
253+
- `background.ts` registers the Audit background controller.
254+
- `content.tsx` starts the Audit content controller and mounts the UT-page
255+
banner.
256+
- `degree-audit/main.tsx` seeds Catalog, composes Preferences → Audit → Course
257+
Search providers, and selects Audit versus Planner view.
258+
- `popup-app/main.tsx` creates the popup root and renders Popup.
259+
260+
## Adding functionality
261+
262+
Start in the feature whose vocabulary and state the change belongs to. Add pure
263+
logic beside that feature's existing calculations/mutations when possible, put
264+
persistence behind its storage interface, and keep temporary interaction state
265+
local to the UI. A new cross-feature module is justified only when it represents
266+
a real user workflow, as Course Search does between Audit and Catalog.
267+
268+
Tests mirror ownership: audit state/storage tests live in `tests/audit`, browser
269+
controller tests in `tests/audit-scraping`, parser snapshots in `tests/scraping`,
270+
and catalog refresh tests in `tests/catalog`. Standalone validation scripts cover
271+
major parsing, requirement progress, composite loading, and saved combinations.

entrypoints/content.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { startAuditContentController } from "@/features/audit-scraping/content-controller";
22
import { createRoot } from "react-dom/client";
3-
import TryDAPBanner from "@/features/misc/try-dap-banner";
3+
import TryDAPBanner from "@/features/banner/try-dap-banner";
44
import "./styles/content.css";
55

66
function loadFonts(): void {

entrypoints/degree-audit/main.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@ import { cn } from "@/lib/utils";
44
import React from "react";
55
import ReactDOM from "react-dom/client";
66
import { HStack, VStack } from "@/components/ui/stack";
7-
import DegreeAuditPage from "@/features/degree-audit-app/audit-view/degree-audit-page";
8-
import CourseAddModal from "@/features/degree-audit-app/course-search/course-add-modal";
9-
import CourseModalContextProvider from "@/features/degree-audit-app/course-search/course-modal-provider";
10-
import DegreePlannerPage from "@/features/degree-audit-app/planner-view/degree-planner-page";
11-
import AuditContextProvider from "@/features/degree-audit-app/providers/audit-provider";
7+
import DegreeAuditPage from "@/features/audit/ui/degree-audit-page";
8+
import CourseAddModal from "@/features/course-search/course-add-modal";
9+
import CourseModalContextProvider from "@/features/course-search/course-modal-provider";
10+
import DegreePlannerPage from "@/features/planner/degree-planner-page";
11+
import AuditContextProvider from "@/features/audit/audit-provider";
1212
import {
1313
PreferencesProvider,
1414
usePreferences,
15-
} from "@/features/degree-audit-app/providers/preferences-provider";
16-
import Navbar from "@/features/degree-audit-app/shared/navbar";
17-
import Sidebar from "@/features/degree-audit-app/shared/sidebar";
15+
} from "@/features/preferences/preferences-provider";
16+
import Navbar from "@/features/audit/ui/navbar";
17+
import Sidebar from "@/features/audit/ui/sidebar";
1818
import ErrorBoundary from "@/components/error-boundary";
1919

2020
const App = () => {

features/audit-scraping/audit-history-sync.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import type { AuditHistoryEntry } from "@/domain/audit";
22
import {
33
getUncachedAuditIds,
44
saveAuditHistory,
5-
} from "@/lib/storage/audit-storage";
5+
} from "@/features/audit/audit-storage";
6+
import { isLoginPage } from "@/features/session/session";
67
import { sendRuntimeMessage } from "@/lib/browser/messages";
78
import { parseAuditHistory } from "./audit-history-parser";
8-
import { checkLoginRequired } from "./audit-page-parser";
99

1010
const AUDIT_HISTORY_URL =
1111
"https://utdirect.utexas.edu/apps/degree/audits/submissions/history/";
@@ -19,7 +19,7 @@ export async function fetchAuditHistory(): Promise<AuditHistoryEntry[]> {
1919
await response.text(),
2020
"text/html",
2121
);
22-
if (checkLoginRequired(document)) {
22+
if (isLoginPage(document)) {
2323
throw new Error("Not logged in to UT Direct");
2424
}
2525
// A logged-in student who has never requested an audit gets a history page

features/audit-scraping/audit-page-parser.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,6 @@ export function parseRequirementProgress(text: string): {
3535
};
3636
}
3737

38-
export function checkLoginRequired(doc: Document): boolean {
39-
return !!(
40-
doc.querySelector('form[action*="login"]') ||
41-
doc.querySelector('input[type="password"]')
42-
);
43-
}
44-
4538
export function getRuleStatus(classList: DOMTokenList): Status {
4639
if (classList.contains("fulfilled")) return "Completed";
4740
if (classList.contains("partial")) return "In Progress";

features/audit-scraping/background-controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CachedAuditData } from "@/domain/audit";
2-
import { saveAuditData } from "@/lib/storage/audit-storage";
2+
import { saveAuditData } from "@/features/audit/audit-storage";
33
import {
44
sendMessageResponse,
55
sendRuntimeMessage,
@@ -15,7 +15,7 @@ import {
1515
openLoginTab,
1616
refreshLoginState,
1717
registerSessionCookieWatcher,
18-
} from "../../lib/login-state";
18+
} from "@/features/session/session";
1919

2020
type ScrapeFailure = Extract<
2121
ExtensionMessage,

features/audit-scraping/content-controller.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import {
22
sendRuntimeMessage,
33
type ExtensionMessage,
44
} from "@/lib/browser/messages";
5-
import { checkLoginRequired, parseAuditPage } from "./audit-page-parser";
5+
import {
6+
isLoginPage,
7+
recordLoginStateFromPage,
8+
} from "@/features/session/session";
9+
import { parseAuditPage } from "./audit-page-parser";
610
import { startAuditHistorySync } from "./audit-history-sync";
7-
import { recordLoginStateFromPage } from "../../lib/login-state";
811

912
export function startAuditContentController(document: Document): void {
1013
recordLoginStateFromPage(document);
@@ -16,7 +19,7 @@ export function startAuditContentController(document: Document): void {
1619
browser.runtime.onMessage.addListener((message: ExtensionMessage) => {
1720
if (message.type !== "RUN_SCRAPER") return;
1821

19-
if (checkLoginRequired(document)) {
22+
if (isLoginPage(document)) {
2023
void sendRuntimeMessage({
2124
type: "AUDIT_SCRAPE_ERROR",
2225
auditId: message.auditId,

0 commit comments

Comments
 (0)