Skip to content

Commit 0fff9e6

Browse files
authored
Completed feature and added ability to change audit name. (#161)
1 parent 6add6f1 commit 0fff9e6

7 files changed

Lines changed: 303 additions & 22 deletions

File tree

entrypoints/components/audit-card.tsx

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,36 @@ const DegreeAuditCard: React.FC<DegreeAuditCardProps> = ({
1919
isExpanded = false,
2020
onToggle,
2121
onMenuClick,
22+
onRename,
2223
}) => {
2324
const [menuOpen, setMenuOpen] = React.useState(false);
25+
const [isEditing, setIsEditing] = React.useState(false);
26+
const [draftTitle, setDraftTitle] = React.useState(title);
2427

2528
React.useEffect(() => {
2629
if (!isSelected) {
2730
setMenuOpen(false);
2831
}
2932
}, [isSelected]);
3033

34+
const saveRename = () => {
35+
const nextTitle = draftTitle.trim();
36+
setIsEditing(false);
37+
setMenuOpen(false);
38+
39+
if (nextTitle && nextTitle !== title) {
40+
onRename?.(nextTitle);
41+
} else {
42+
setDraftTitle(title);
43+
}
44+
};
45+
46+
const startRename = () => {
47+
setDraftTitle(title);
48+
setIsEditing(true);
49+
setMenuOpen(false);
50+
};
51+
3152
return (
3253
<div
3354
className={`relative rounded-[8px] px-4 py-[12px] w-full transition-all duration-200 cursor-pointer ${
@@ -42,13 +63,38 @@ const DegreeAuditCard: React.FC<DegreeAuditCardProps> = ({
4263
>
4364
<div className="flex items-center justify-between">
4465
{/* Title */}
45-
<div
46-
className={`font-bold text-[18px] leading-tight ${
47-
isSelected ? "text-white" : "text-dap-orange"
48-
}`}
49-
>
50-
{title}
51-
</div>
66+
{isEditing ? (
67+
<input
68+
autoFocus
69+
className="w-[160px] rounded border border-dap-border bg-background px-1 py-1 text-[18px] font-bold leading-tight text-text outline-none"
70+
value={draftTitle}
71+
onChange={(e) => setDraftTitle(e.target.value)}
72+
onBlur={saveRename}
73+
onClick={(e) => e.stopPropagation()}
74+
onFocus={(e) => e.currentTarget.select()}
75+
onKeyDown={(e) => {
76+
if (e.key === "Enter") {
77+
e.currentTarget.blur();
78+
}
79+
if (e.key === "Escape") {
80+
setDraftTitle(title);
81+
setIsEditing(false);
82+
}
83+
}}
84+
/>
85+
) : (
86+
<div
87+
className={`min-w-0 flex-1 truncate font-bold text-[18px] leading-tight ${
88+
isSelected ? "text-white" : "text-dap-orange"
89+
}`}
90+
onDoubleClick={(e) => {
91+
e.stopPropagation();
92+
startRename();
93+
}}
94+
>
95+
{title}
96+
</div>
97+
)}
5298

5399
<div className="flex items-center gap-2">
54100
{/* Percentage Badge */}
@@ -86,7 +132,10 @@ const DegreeAuditCard: React.FC<DegreeAuditCardProps> = ({
86132
className="absolute right-0 top-full z-30 mt-2 min-w-[180px] rounded-[8px] border border-dap-border bg-background p-2 shadow-lg"
87133
onClick={(e) => e.stopPropagation()}
88134
>
89-
<button className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-[15px] hover:bg-hover-bg">
135+
<button
136+
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-[15px] hover:bg-hover-bg"
137+
onClick={startRename}
138+
>
90139
<PencilSimpleLine size={20} className="shrink-0" />
91140
<span>Rename</span>
92141
</button>

entrypoints/degree-audit/components/sidebar.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import { useAuditContext } from "../providers/audit-provider";
1717

1818
const Sidebar = () => {
1919
const { sidebarIsOpen, toggleSidebar } = usePreferences();
20-
const { currentAuditId, setCurrentAuditId, history } = useAuditContext();
20+
const { currentAuditId, setCurrentAuditId, history, renameAuditTitle } =
21+
useAuditContext();
2122
// const [audits, setAudits] = useState<DegreeAuditCardProps[]>([]); //history
2223
// const [loading, setLoading] = useState(true);
2324

@@ -97,6 +98,11 @@ const Sidebar = () => {
9798
setCurrentAuditId(audit.auditId); // No page refresh, just update state
9899
}
99100
}}
101+
onRename={(title) => {
102+
if (audit.auditId) {
103+
renameAuditTitle(audit.auditId, title);
104+
}
105+
}}
100106
onMenuClick={() => {
101107
console.log("Menu clicked for", audit.title);
102108
}}

entrypoints/degree-audit/providers/audit-provider.tsx

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
import { usePreferences } from "@/entrypoints/degree-audit/providers/preferences-provider";
2-
import { calculateWeightedDegreeCompletion } from "@/lib/audit-calculations";
2+
import {
3+
calculateWeightedDegreeCompletion,
4+
getDuplicateCourseRequirementFlags,
5+
getCompositeAuditRequirements,
6+
} from "@/lib/audit-calculations";
37
import {
48
addPlannedCourse as addPlannedCourseToStorage,
59
getAuditData,
610
getAuditHistory,
711
removePlannedCourse as removePlannedCourseFromStorage,
12+
renameAudit,
813
wipeAllPlannedCourses as wipeAllPlannedCoursesFromStorage,
914
} from "@/lib/backend/storage";
1015
import {
1116
AuditHistoryData,
1217
AuditRequirement,
18+
CompositeAuditData,
19+
CompositeAuditRequirement,
1320
Course,
1421
CourseId,
1522
CurrentAuditProgress,
1623
DegreeAuditCardProps,
24+
DuplicateCourseRequirementFlag,
1725
PlannedCourseOutline,
1826
RequirementRule,
1927
StringSemester,
@@ -35,10 +43,14 @@ type AuditRequirementLike = Omit<AuditRequirement, "rule"> & {
3543
interface AuditContextType {
3644
sections: AuditRequirement[];
3745
courses: Course[];
46+
compositeAuditData: CompositeAuditData;
47+
compositeRequirements: CompositeAuditRequirement[];
48+
duplicateCourseFlags: DuplicateCourseRequirementFlag[];
3849
history: AuditHistoryData;
3950
currentAuditId: string;
4051
currentAudit: DegreeAuditCardProps;
4152
setCurrentAuditId: (id: string) => void;
53+
renameAuditTitle: (auditId: string, title: string) => Promise<boolean>;
4254
progresses: CurrentAuditProgress;
4355
semesters: SemesterInfo;
4456
getCourseById: (id: CourseId) => Course;
@@ -98,6 +110,13 @@ function normalizeRequirements(
98110
}));
99111
}
100112

113+
function getAuditDisplayName(
114+
audit: DegreeAuditCardProps | undefined,
115+
auditId: string,
116+
) {
117+
return audit?.title ?? audit?.majors?.join("; ") ?? auditId;
118+
}
119+
101120
export const AuditContextProvider = ({
102121
children,
103122
}: {
@@ -111,8 +130,19 @@ export const AuditContextProvider = ({
111130

112131
const [courseDict, setCourseDict] = useState<Record<CourseId, Course>>({});
113132
const [sections, setSections] = useState<AuditRequirement[]>([]);
133+
const [compositeAuditData, setCompositeAuditData] =
134+
useState<CompositeAuditData>({ audits: [] });
114135
const [history, setHistory] = useState<AuditHistoryData>();
115136

137+
const compositeRequirements = useMemo(
138+
() => getCompositeAuditRequirements(compositeAuditData),
139+
[compositeAuditData],
140+
);
141+
const duplicateCourseFlags = useMemo(
142+
() => getDuplicateCourseRequirementFlags(compositeAuditData),
143+
[compositeAuditData],
144+
);
145+
116146
const progresses = useMemo(
117147
() => calculateWeightedDegreeCompletion(sections ?? [], courseDict),
118148
[sections, courseDict],
@@ -230,6 +260,25 @@ export const AuditContextProvider = ({
230260
return numRemoved;
231261
}
232262

263+
// Keep the provider state in sync after storage accepts the rename.
264+
async function renameAuditTitle(auditId: string, title: string) {
265+
const cleanTitle = title.trim();
266+
if (!cleanTitle) {
267+
return false;
268+
}
269+
270+
const updatedHistory = await renameAudit(auditId, cleanTitle);
271+
if (!updatedHistory) {
272+
return false;
273+
}
274+
275+
setHistory(updatedHistory);
276+
setCompositeAuditData((prev) => ({
277+
audits: prev.audits.map((audit) => ({ ...audit, name: cleanTitle })),
278+
}));
279+
return true;
280+
}
281+
233282
const currentAudit = useMemo(() => {
234283
return history?.audits.find((a) => a.auditId === currentAuditId) ?? {};
235284
}, [history, currentAuditId]);
@@ -267,19 +316,21 @@ export const AuditContextProvider = ({
267316
// Load requirements from cache
268317
const cached = await getAuditData(currentAuditId!);
269318
if (cached) {
270-
setSections(
271-
cached.requirements.map((section) => ({
272-
...section,
273-
rules: section.rules.map((rule) => ({
274-
...rule,
275-
progressUnit: rule.progressUnit ?? "hours",
276-
courses: rule.courses,
277-
})),
278-
})),
279-
);
319+
const namedAudit = {
320+
...cached,
321+
name: getAuditDisplayName(matchingAudit, currentAuditId!),
322+
};
323+
const composite = { audits: [namedAudit] };
324+
setCompositeAuditData(composite);
325+
setSections(getCompositeAuditRequirements(composite));
280326
console.log("[Main] courses", cached.courses);
281327
setCourseDict(cached.courses);
282-
} else console.warn(`[Main] Audit ${currentAuditId} not in cache.`);
328+
} else {
329+
setCompositeAuditData({ audits: [] });
330+
setSections([]);
331+
setCourseDict({});
332+
console.warn(`[Main] Audit ${currentAuditId} not in cache.`);
333+
}
283334

284335
setLoaded(true);
285336
} catch (error) {
@@ -300,6 +351,9 @@ export const AuditContextProvider = ({
300351
value={{
301352
sections,
302353
courses: Object.values(courseDict),
354+
compositeAuditData,
355+
compositeRequirements,
356+
duplicateCourseFlags,
303357
history: history!,
304358
semesters,
305359
currentAuditId,
@@ -309,6 +363,7 @@ export const AuditContextProvider = ({
309363
setCurrentAuditId(id);
310364
updateLastAuditId(id);
311365
},
366+
renameAuditTitle,
312367
moveCourseToNewSemester,
313368
progresses,
314369
getCourseById: (id) => {

lib/audit-calculations.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,85 @@
11
import type {
22
AuditRequirement,
3+
CompositeAuditData,
4+
CompositeAuditRequirement,
35
Course,
6+
CourseCode,
47
CourseId,
58
CurrentAuditProgress,
9+
DuplicateCourseRequirementFlag,
610
} from "./general-types";
711

12+
// Give unnamed audits a readable fallback so the UI never shows a blank source.
13+
function getAuditName(
14+
audit: CompositeAuditData["audits"][number],
15+
index: number,
16+
) {
17+
return audit.name ?? `Degree Audit ${index + 1}`;
18+
}
19+
20+
// Find course codes that appear in requirements from more than one audit.
21+
export function getDuplicateCourseRequirementFlags(
22+
composite: CompositeAuditData,
23+
): DuplicateCourseRequirementFlag[] {
24+
const courseAudits = new Map<CourseCode, Set<string>>();
25+
26+
composite.audits.forEach((audit, auditIndex) => {
27+
const auditName = getAuditName(audit, auditIndex);
28+
29+
audit.requirements.forEach((requirement) => {
30+
requirement.rules.forEach((rule) => {
31+
rule.courses.forEach((courseId) => {
32+
const course = audit.courses[courseId];
33+
if (!course) return;
34+
35+
const auditNames = courseAudits.get(course.code) ?? new Set<string>();
36+
auditNames.add(auditName);
37+
courseAudits.set(course.code, auditNames);
38+
});
39+
});
40+
});
41+
});
42+
43+
return Array.from(courseAudits.entries())
44+
.filter(([, auditNames]) => auditNames.size > 1)
45+
.map(([courseCode, auditNames]) => ({
46+
courseCode,
47+
auditNames: Array.from(auditNames),
48+
}));
49+
}
50+
51+
// Build the composite requirement list used by views that need all audits together.
52+
export function getCompositeAuditRequirements(
53+
composite: CompositeAuditData,
54+
): CompositeAuditRequirement[] {
55+
const duplicateCodes = new Set(
56+
getDuplicateCourseRequirementFlags(composite).map(
57+
(flag) => flag.courseCode,
58+
),
59+
);
60+
61+
return composite.audits.flatMap((audit, auditIndex) => {
62+
const auditName = getAuditName(audit, auditIndex);
63+
64+
return audit.requirements.map((requirement) => ({
65+
...requirement,
66+
auditName,
67+
duplicateCourseCodes: Array.from(
68+
new Set(
69+
requirement.rules.flatMap((rule) =>
70+
rule.courses
71+
.map((courseId) => audit.courses[courseId]?.code)
72+
.filter(
73+
(code): code is CourseCode =>
74+
!!code && duplicateCodes.has(code),
75+
),
76+
),
77+
),
78+
),
79+
}));
80+
});
81+
}
82+
883
export function calculateWeightedDegreeCompletion(
984
sections: AuditRequirement[],
1085
courses: Record<CourseId, Course>,

lib/backend/storage.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,30 @@ export async function saveAuditHistory(
3131
}
3232
}
3333

34+
// Rename only the history entry because that is what the sidebar displays.
35+
export async function renameAudit(
36+
auditId: string,
37+
title: string,
38+
): Promise<AuditHistoryData | null> {
39+
try {
40+
const history = await getAuditHistory();
41+
if (!history) {
42+
return null;
43+
}
44+
45+
const audits = history.audits.map((audit) =>
46+
audit.auditId === auditId ? { ...audit, title } : audit,
47+
);
48+
const updatedHistory = { ...history, audits, timestamp: Date.now() };
49+
50+
await browser.storage.local.set({ [STORAGE_KEY]: updatedHistory });
51+
return updatedHistory;
52+
} catch (e) {
53+
console.error("Failed to rename audit:", e);
54+
return null;
55+
}
56+
}
57+
3458
/**
3559
* Adds a course to the audit history.
3660
* @param auditId - The ID of the audit to add the course to.

0 commit comments

Comments
 (0)