Skip to content

Commit 7706840

Browse files
feat(web): add degree report starting term parser (#95)
Co-authored-by: Chenxin Yan <[email protected]>
1 parent 8afe5e1 commit 7706840

File tree

9 files changed

+151
-40
lines changed

9 files changed

+151
-40
lines changed

apps/web/src/app/dashboard/plan/components/plan-table.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import { cn } from "@/lib/utils";
2222
import DegreeProgreeUpload from "@/modules/report-parsing/components/degree-progress-upload";
2323
import type { UserCourse } from "@/modules/report-parsing/types";
24+
import type { StartingTerm } from "@/modules/report-parsing/utils/parse-starting-term";
2425
import type { Term, TermYear } from "@/utils/term";
2526
import {
2627
buildAcademicTimeline,
@@ -52,13 +53,22 @@ export default function PlanTable({ courses, student }: PlanTableProps) {
5253

5354
const importUserCourses = useMutation(api.userCourses.importUserCourses);
5455

56+
const updateStudent = useMutation(api.students.updateCurrentStudent);
57+
5558
const courseSearchId = useId();
5659

57-
const handleImportConfirm = async (coursesToImport: UserCourse[]) => {
60+
const handleImportConfirm = async (
61+
coursesToImport: UserCourse[],
62+
startingTerm: StartingTerm | null,
63+
) => {
5864
if (coursesToImport.length === 0) {
5965
return;
6066
}
6167

68+
if (startingTerm) {
69+
await updateStudent({ startingDate: startingTerm });
70+
}
71+
6272
const result = await importUserCourses({
6373
courses: coursesToImport,
6474
});

apps/web/src/app/onboarding/component/onboarding-form.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
} from "@/components/ui/select";
4141
import DegreeProgreeUpload from "@/modules/report-parsing/components/degree-progress-upload";
4242
import type { UserCourse } from "@/modules/report-parsing/types";
43+
import type { StartingTerm } from "@/modules/report-parsing/utils/parse-starting-term";
4344

4445
const dateSchema = z.object({
4546
year: z.number().int().min(2000).max(2100),
@@ -199,11 +200,18 @@ export function OnboardingForm() {
199200
},
200201
});
201202

202-
function handleConfirmImport(coursesToImport: UserCourse[]) {
203+
function handleConfirmImport(
204+
coursesToImport: UserCourse[],
205+
startingTerm: StartingTerm | null,
206+
) {
203207
if (coursesToImport.length === 0) {
204208
return;
205209
}
206210

211+
if (startingTerm) {
212+
form.setFieldValue("startingDate", startingTerm);
213+
}
214+
207215
form.setFieldValue("userCourses", coursesToImport);
208216
setIsFileLoaded(true);
209217
}

apps/web/src/modules/report-parsing/components/confirm-modal.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,18 +65,16 @@ export default function ConfirmModal({
6565
return (
6666
<Dialog open={open} onOpenChange={handleOpenChange}>
6767
<DialogContent className="flex flex-col gap-0 p-0 sm:max-h-[min(640px,80vh)] sm:max-w-2xl [&>button:last-child]:top-6">
68-
<DialogHeader className="shrink-0 space-y-0 text-left">
69-
<DialogTitle className="px-6 pt-6 text-xl">
68+
<DialogHeader className="px-6 gap-1 pb-3">
69+
<DialogTitle className="pt-6 text-xl">
7070
Confirm Course History
7171
</DialogTitle>
7272
<DialogDescription asChild>
73-
<div className="px-6 py-3">
74-
<p className="text-sm text-muted-foreground">
75-
We found {courses.length} course
76-
{courses.length !== 1 ? "s" : ""} in your Degree Progress
77-
Report. Please review and confirm.
78-
</p>
79-
</div>
73+
<p className="text-sm text-muted-foreground">
74+
We found {courses.length} course
75+
{courses.length !== 1 ? "s" : ""} in your Degree Progress Report.
76+
Please review and confirm.
77+
</p>
8078
</DialogDescription>
8179
</DialogHeader>
8280

apps/web/src/modules/report-parsing/components/degree-progress-upload.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,19 @@ import {
99
isDegreeProgressReport,
1010
} from "../utils/extract-pdf-text";
1111
import { parseCourseHistory } from "../utils/parse-course-history";
12+
import {
13+
extractStartingTerm,
14+
type StartingTerm,
15+
} from "../utils/parse-starting-term";
1216
import { transformToUserCourses } from "../utils/transform-to-user-courses";
1317
import ConfirmModal from "./confirm-modal";
1418

1519
type FileUploadButtonProps = {
1620
maxSizeMB?: number;
17-
onConfirm: (courses: UserCourse[]) => Promise<void> | void;
21+
onConfirm: (
22+
courses: UserCourse[],
23+
startingTerm: StartingTerm | null,
24+
) => Promise<void> | void;
1825
showFileLoaded?: boolean;
1926
onFileClick?: () => void;
2027
};
@@ -27,6 +34,7 @@ export default function DegreeProgreeUpload({
2734
}: FileUploadButtonProps) {
2835
const maxSize = maxSizeMB * 1024 * 1024;
2936
const [parsedCourses, setParsedCourses] = useState<UserCourse[]>([]);
37+
const [startingTerm, setStartingTerm] = useState<StartingTerm | null>(null);
3038
const [isModalOpen, setIsModalOpen] = useState(false);
3139
const [isImporting, setIsImporting] = useState(false);
3240
const [fileName, setFileName] = useState("");
@@ -51,6 +59,8 @@ export default function DegreeProgreeUpload({
5159
const file = fileData.file;
5260
if (!(file instanceof File)) return;
5361

62+
setStartingTerm(null);
63+
5464
// Verify it's a Degree Progress Report
5565
try {
5666
const ok = await isDegreeProgressReport(file);
@@ -59,6 +69,15 @@ export default function DegreeProgreeUpload({
5969
removeFile(fileData.id);
6070
return;
6171
}
72+
73+
try {
74+
const startingTerm = await extractStartingTerm(file);
75+
console.log(startingTerm);
76+
77+
setStartingTerm(startingTerm);
78+
} catch (e) {
79+
console.warn("Could not find starting term:", e);
80+
}
6281
} catch (err) {
6382
console.error("Error verifying PDF:", err);
6483
toast.error("Could not verify the PDF file.");
@@ -85,7 +104,7 @@ export default function DegreeProgreeUpload({
85104
const handleConfirm = async () => {
86105
setIsImporting(true);
87106
try {
88-
await onConfirm(parsedCourses);
107+
await onConfirm(parsedCourses, startingTerm);
89108

90109
setIsModalOpen(false);
91110

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { extractPdfText } from "./extract-pdf-text";
2+
3+
export interface StartingTerm {
4+
year: number;
5+
term: "spring" | "fall";
6+
}
7+
8+
/**
9+
* Parses the Requirement Term for "Undergraduate Career" from a full PDF.
10+
* @param file PDF file (Degree Progress Report)
11+
* @returns StartingTerm or null if not found
12+
*/
13+
export async function extractStartingTerm(
14+
file: File,
15+
): Promise<StartingTerm | null> {
16+
const text = await extractPdfText(file);
17+
18+
// Normalize whitespace and line breaks
19+
const normalized = text.replace(/\s+/g, " ").toLowerCase();
20+
21+
//only look for patter like: "undergraduate/graduate career fall 2023"
22+
const match = normalized.match(
23+
/(undergraduate|graduate)\s+career\s+(fall|spr)(?:g)?\s*(20\d{2})/,
24+
);
25+
26+
if (!match) {
27+
return null;
28+
}
29+
30+
const [, , termAbbr, yearStr] = match;
31+
32+
const year = parseInt(yearStr, 10);
33+
const lower = termAbbr.toLowerCase();
34+
35+
let term: "spring" | "fall";
36+
switch (lower) {
37+
case "spr":
38+
case "spring":
39+
term = "spring";
40+
break;
41+
case "fall":
42+
term = "fall";
43+
break;
44+
default:
45+
term = "fall";
46+
}
47+
48+
return {
49+
year,
50+
term,
51+
};
52+
}

packages/server/convex/_generated/api.d.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,6 @@ import type {
3535
FunctionReference,
3636
} from "convex/server";
3737

38-
/**
39-
* A utility for referencing Convex functions in your app's API.
40-
*
41-
* Usage:
42-
* ```js
43-
* const myFunctionReference = api.myModule.myFunction;
44-
* ```
45-
*/
4638
declare const fullApi: ApiFromModules<{
4739
appConfigs: typeof appConfigs;
4840
courseOfferings: typeof courseOfferings;
@@ -65,14 +57,30 @@ declare const fullApi: ApiFromModules<{
6557
userCourseOfferings: typeof userCourseOfferings;
6658
userCourses: typeof userCourses;
6759
}>;
68-
declare const fullApiWithMounts: typeof fullApi;
6960

61+
/**
62+
* A utility for referencing Convex functions in your app's public API.
63+
*
64+
* Usage:
65+
* ```js
66+
* const myFunctionReference = api.myModule.myFunction;
67+
* ```
68+
*/
7069
export declare const api: FilterApi<
71-
typeof fullApiWithMounts,
70+
typeof fullApi,
7271
FunctionReference<any, "public">
7372
>;
73+
74+
/**
75+
* A utility for referencing Convex functions in your app's internal API.
76+
*
77+
* Usage:
78+
* ```js
79+
* const myFunctionReference = internal.myModule.myFunction;
80+
* ```
81+
*/
7482
export declare const internal: FilterApi<
75-
typeof fullApiWithMounts,
83+
typeof fullApi,
7684
FunctionReference<any, "internal">
7785
>;
7886

packages/server/convex/_generated/server.d.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
import {
1212
ActionBuilder,
13-
AnyComponents,
1413
HttpActionBuilder,
1514
MutationBuilder,
1615
QueryBuilder,
@@ -19,15 +18,9 @@ import {
1918
GenericQueryCtx,
2019
GenericDatabaseReader,
2120
GenericDatabaseWriter,
22-
FunctionReference,
2321
} from "convex/server";
2422
import type { DataModel } from "./dataModel.js";
2523

26-
type GenericCtx =
27-
| GenericActionCtx<DataModel>
28-
| GenericMutationCtx<DataModel>
29-
| GenericQueryCtx<DataModel>;
30-
3124
/**
3225
* Define a query in this Convex app's public API.
3326
*
@@ -92,11 +85,12 @@ export declare const internalAction: ActionBuilder<DataModel, "internal">;
9285
/**
9386
* Define an HTTP action.
9487
*
95-
* This function will be used to respond to HTTP requests received by a Convex
96-
* deployment if the requests matches the path and method where this action
97-
* is routed. Be sure to route your action in `convex/http.js`.
88+
* The wrapped function will be used to respond to HTTP requests received
89+
* by a Convex deployment if the requests matches the path and method where
90+
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
9891
*
99-
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
92+
* @param func - The function. It receives an {@link ActionCtx} as its first argument
93+
* and a Fetch API `Request` object as its second.
10094
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
10195
*/
10296
export declare const httpAction: HttpActionBuilder;

packages/server/convex/_generated/server.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
internalActionGeneric,
1717
internalMutationGeneric,
1818
internalQueryGeneric,
19-
componentsGeneric,
2019
} from "convex/server";
2120

2221
/**
@@ -81,10 +80,14 @@ export const action = actionGeneric;
8180
export const internalAction = internalActionGeneric;
8281

8382
/**
84-
* Define a Convex HTTP action.
83+
* Define an HTTP action.
8584
*
86-
* @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
87-
* as its second.
88-
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
85+
* The wrapped function will be used to respond to HTTP requests received
86+
* by a Convex deployment if the requests matches the path and method where
87+
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
88+
*
89+
* @param func - The function. It receives an {@link ActionCtx} as its first argument
90+
* and a Fetch API `Request` object as its second.
91+
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
8992
*/
9093
export const httpAction = httpActionGeneric;

packages/server/convex/students.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { ConvexError } from "convex/values";
12
import { omit } from "convex-helpers";
23
import { getOneFrom } from "convex-helpers/server/relationships";
4+
import { partial } from "convex-helpers/validators";
35
import { protectedMutation, protectedQuery } from "./helpers/auth";
46
import { students } from "./schemas/students";
57

@@ -49,3 +51,20 @@ export const upsertCurrentStudent = protectedMutation({
4951
}
5052
},
5153
});
54+
55+
export const updateCurrentStudent = protectedMutation({
56+
args: partial(omit(students, ["userId"])),
57+
handler: async (ctx, args) => {
58+
const existing = await ctx.db
59+
.query("students")
60+
.withIndex("by_user_id", (q) => q.eq("userId", ctx.user.subject))
61+
.unique();
62+
63+
if (!existing) {
64+
throw new ConvexError("Student not found");
65+
}
66+
67+
await ctx.db.patch(existing._id, args);
68+
return existing._id;
69+
},
70+
});

0 commit comments

Comments
 (0)