Skip to content

Commit 4d547ac

Browse files
committed
Added auxillary methods to allow for CRUD of composite audits.
1 parent 0fff9e6 commit 4d547ac

5 files changed

Lines changed: 451 additions & 0 deletions

File tree

lib/backend/storage.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { browser } from "wxt/browser";
55
import type {
66
AuditHistoryData,
77
CachedAuditData,
8+
CachedCompositeAudit,
9+
CompositeAuditData,
810
CourseId,
911
DegreeAuditCardProps,
1012
PlannedCourseOutline,
@@ -285,3 +287,118 @@ export async function getUncachedAuditIds(
285287
}
286288
return uncached;
287289
}
290+
291+
/**
292+
* Helper function: Loads each saved audit from the per-audit cache and combines them into the
293+
* composite model. IDs with no cached data (never scraped or failed to scrape)
294+
* are skipped, so the remaining audits still load. Display names come from audit
295+
* history, matching the provider's naming logic.
296+
*
297+
* @param auditIds - The IDs of the audits to load and combine.
298+
* @returns A composite holding one entry per successfully loaded audit.
299+
*/
300+
export async function loadCompositeAuditData(
301+
auditIds: string[],
302+
options?: {
303+
// Injectable for tests; defaults to the real storage readers.
304+
getData?: (id: string) => Promise<CachedAuditData | null>;
305+
getHistory?: () => Promise<AuditHistoryData | null>;
306+
},
307+
): Promise<CompositeAuditData> {
308+
const getData = options?.getData ?? getAuditData;
309+
const getHistory = options?.getHistory ?? getAuditHistory;
310+
311+
const history = await getHistory();
312+
const audits: CachedAuditData[] = [];
313+
314+
for (const id of auditIds) {
315+
const data = await getData(id);
316+
if (!data) continue; // not cached yet — skip so the others still load
317+
const card = history?.audits.find((a) => a.auditId === id);
318+
const name = card?.title ?? card?.majors?.join("; ") ?? id;
319+
audits.push({ ...data, name });
320+
}
321+
322+
return { audits };
323+
}
324+
325+
// ---- Saved Composites (a named grouping of audits the user views together) ----
326+
const COMPOSITES_KEY = "compositeAudits";
327+
328+
// Get all saved composites (returns an empty list if none).
329+
export async function getCachedComposites(): Promise<CachedCompositeAudit[]> {
330+
try {
331+
const result = await browser.storage.local.get(COMPOSITES_KEY);
332+
return (result[COMPOSITES_KEY] as CachedCompositeAudit[]) ?? [];
333+
} catch (e) {
334+
console.error("Failed to get composites from storage:", e);
335+
return [];
336+
}
337+
}
338+
339+
async function setCachedComposites(
340+
composites: CachedCompositeAudit[],
341+
): Promise<void> {
342+
await browser.storage.local.set({ [COMPOSITES_KEY]: composites });
343+
}
344+
345+
/**
346+
* Creates a new composite from a set of audits, frontend facing method "create
347+
* composite" audit. Persists only the name + member ids, then builds and
348+
* returns the composite so the caller can render it immediately without a second
349+
* round-trip.
350+
*
351+
* @param name - The user-facing label for the composite.
352+
* @param auditIds - The IDs of the audits that make up the composite.
353+
* @returns The stored record and the freshly built composite.
354+
*/
355+
export async function createComposite(
356+
name: string,
357+
auditIds: string[],
358+
): Promise<{ saved: CachedCompositeAudit; composite: CompositeAuditData }> {
359+
const saved: CachedCompositeAudit = {
360+
id: crypto.randomUUID(),
361+
name,
362+
auditIds,
363+
};
364+
const composites = await getCachedComposites();
365+
await setCachedComposites([...composites, saved]);
366+
const composite = await loadCompositeAuditData(auditIds);
367+
return { saved, composite };
368+
}
369+
370+
371+
export async function updateCachedComposite(
372+
id: string,
373+
patch: Partial<Pick<CachedCompositeAudit, "name" | "auditIds">>,
374+
): Promise<CachedCompositeAudit | null> {
375+
const composites = await getCachedComposites();
376+
const existing = composites.find((c) => c.id === id);
377+
if (!existing) return null;
378+
const updated: CachedCompositeAudit = { ...existing, ...patch, id };
379+
await setCachedComposites(composites.map((c) => (c.id === id ? updated : c)));
380+
return updated;
381+
}
382+
383+
export async function deleteCachedComposite(id: string): Promise<boolean> {
384+
const composites = await getCachedComposites();
385+
const next = composites.filter((c) => c.id !== id);
386+
if (next.length === composites.length) return false;
387+
await setCachedComposites(next);
388+
return true;
389+
}
390+
391+
/**
392+
* Reopens a saved composite by rebuilding it from the per-audit cache.
393+
*
394+
* @param id - The ID of the composite to load.
395+
* @returns The built composite, or null if no composite has that ID.
396+
*/
397+
export async function loadCompositeAudit(
398+
id: string,
399+
): Promise<CompositeAuditData | null> {
400+
const composites = await getCachedComposites();
401+
const composite = composites.find((c) => c.id === id);
402+
if (!composite) return null;
403+
return loadCompositeAuditData(composite.auditIds);
404+
}

lib/general-types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ export interface CompositeAuditData {
5050
audits: CachedAuditData[];
5151
}
5252

53+
// A persisted composite. Stores only a name + the member audit ids; it does NOT cache audit
54+
// data. The full CompositeAuditData is rebuilt on demand from the per-audit cache
55+
// (auditData_<id>) so it never goes stale when planned courses change or audits re-scrape.
56+
export interface CachedCompositeAudit {
57+
id: string; // crypto.randomUUID()
58+
name: string; // user-facing label, e.g. "My Degree Plan"
59+
auditIds: string[]; // ordered member ids -> point into auditData_<id>
60+
}
61+
5362
/**
5463
* Simple way of expanding an object type one layer so it shows its children's contents
5564
*/

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"compile": "bun run tsc --noEmit",
1515
"test:parse-major": "bun run tests/validate-parse-major.ts",
1616
"test:requirement-progress": "bun run tests/validate-requirement-progress.ts",
17+
"test:composite-load": "bun run tests/validate-composite-audit-load.ts",
18+
"test:saved-composites": "bun run tests/validate-saved-composites.ts",
1719
"postinstall": "bun run wxt prepare",
1820
"prepare": "husky",
1921
"tailwind:init": "bun run tailwindcss init -p"
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import assert from "node:assert/strict";
2+
import {
3+
getCompositeAuditRequirements,
4+
getDuplicateCourseRequirementFlags,
5+
} from "../lib/audit-calculations";
6+
import { loadCompositeAuditData } from "../lib/backend/storage";
7+
import type { AuditHistoryData, CachedAuditData } from "../lib/general-types";
8+
9+
// --- Fixtures ---------------------------------------------------------------
10+
11+
// Two audits that share a course code (M 341) so the composite helpers have a
12+
// duplicate to flag once the audits are combined.
13+
const csAudit: CachedAuditData = {
14+
requirements: [
15+
{
16+
title: "Major Requirements",
17+
rules: [
18+
{
19+
text: "Linear Algebra",
20+
requiredHours: 3,
21+
appliedHours: 3,
22+
remainingHours: 0,
23+
progressUnit: "hours",
24+
status: "Completed",
25+
courses: ["cs-linear-algebra"],
26+
},
27+
],
28+
},
29+
],
30+
courses: {
31+
"cs-linear-algebra": {
32+
id: "cs-linear-algebra",
33+
code: "M 341",
34+
name: "Linear Algebra",
35+
hours: 3,
36+
semester: "Fall 2026",
37+
status: "Completed",
38+
type: "In-Residence",
39+
},
40+
},
41+
};
42+
43+
const mathMinorAudit: CachedAuditData = {
44+
requirements: [
45+
{
46+
title: "Minor Requirements",
47+
rules: [
48+
{
49+
text: "Linear Algebra",
50+
requiredHours: 3,
51+
appliedHours: 3,
52+
remainingHours: 0,
53+
progressUnit: "hours",
54+
status: "Completed",
55+
courses: ["minor-linear-algebra"],
56+
},
57+
],
58+
},
59+
],
60+
courses: {
61+
"minor-linear-algebra": {
62+
id: "minor-linear-algebra",
63+
code: "M 341",
64+
name: "Linear Algebra",
65+
hours: 3,
66+
semester: "Fall 2026",
67+
status: "Completed",
68+
type: "In-Residence",
69+
},
70+
},
71+
};
72+
73+
const cache: Record<string, CachedAuditData> = {
74+
"audit-cs": csAudit,
75+
"audit-math-minor": mathMinorAudit,
76+
};
77+
78+
const history: AuditHistoryData = {
79+
audits: [
80+
{
81+
auditId: "audit-cs",
82+
title: "Computer Science BS",
83+
majors: ["Computer Science"],
84+
},
85+
{
86+
auditId: "audit-math-minor",
87+
majors: ["Mathematics"],
88+
minors: ["Mathematics"],
89+
},
90+
],
91+
timestamp: 0,
92+
};
93+
94+
const mockGetData = (id: string) => Promise.resolve(cache[id] ?? null);
95+
const mockGetHistory = () => Promise.resolve(history);
96+
97+
// --- Tests ------------------------------------------------------------------
98+
99+
// Combines multiple saved audits into the composite (AC #2).
100+
{
101+
const composite = await loadCompositeAuditData(
102+
["audit-cs", "audit-math-minor"],
103+
{ getData: mockGetData, getHistory: mockGetHistory },
104+
);
105+
106+
assert.equal(composite.audits.length, 2);
107+
// Name resolves from history title, then majors, then the raw id.
108+
assert.equal(composite.audits[0].name, "Computer Science BS");
109+
assert.equal(composite.audits[1].name, "Mathematics");
110+
}
111+
112+
// Falls back to the raw id when history has no entry for the audit.
113+
{
114+
const composite = await loadCompositeAuditData(["audit-cs"], {
115+
getData: mockGetData,
116+
getHistory: () => Promise.resolve(null),
117+
});
118+
119+
assert.equal(composite.audits.length, 1);
120+
assert.equal(composite.audits[0].name, "audit-cs");
121+
}
122+
123+
// Skips uncached IDs so the remaining audits still load (AC #3, #4).
124+
{
125+
const composite = await loadCompositeAuditData(
126+
["audit-cs", "missing-audit", "audit-math-minor"],
127+
{ getData: mockGetData, getHistory: mockGetHistory },
128+
);
129+
130+
assert.equal(composite.audits.length, 2);
131+
assert.deepEqual(
132+
composite.audits.map((a) => a.name),
133+
["Computer Science BS", "Mathematics"],
134+
);
135+
}
136+
137+
// The combined composite is consumed correctly by the 6.1 helpers.
138+
{
139+
const composite = await loadCompositeAuditData(
140+
["audit-cs", "audit-math-minor"],
141+
{ getData: mockGetData, getHistory: mockGetHistory },
142+
);
143+
144+
const compositeRequirements = getCompositeAuditRequirements(composite);
145+
const duplicateFlags = getDuplicateCourseRequirementFlags(composite);
146+
147+
assert.equal(compositeRequirements.length, 2);
148+
assert.deepEqual(compositeRequirements[0].duplicateCourseCodes, ["M 341"]);
149+
assert.deepEqual(compositeRequirements[1].duplicateCourseCodes, ["M 341"]);
150+
151+
assert.equal(duplicateFlags.length, 1);
152+
assert.equal(duplicateFlags[0].courseCode, "M 341");
153+
assert.deepEqual(duplicateFlags[0].auditNames, [
154+
"Computer Science BS",
155+
"Mathematics",
156+
]);
157+
}
158+
159+
console.log("Composite audit load validation passed.");

0 commit comments

Comments
 (0)