Skip to main content
강홍재/ James
← Work
EMBA2026 · Solo Builder· Started(First Commit date)

Degree Planner

A degree-requirement calculator that moves the SKKU EMBA graduation worksheet to the web - enter your courses and it computes requirement fulfillment and the attainable degree. It began as a standalone app on Supabase and in August moved into the EMBA app as a web-view-only screen, and along the way a path where autosave could overwrite data was caught.

  • React 19
  • TypeScript
  • Supabase
  • EMBA

Setup

Problem

EMBA members had to check graduation requirements (total credits, Core/Advanced, international-language, convergence/global) one by one on a paper worksheet. A miscalculation pushes graduation back.

Context

An internal tool for SKKU EMBA members. It moves the school's official worksheet to the web, but draws a line as an "assistive tool" - the final call belongs to the registrar. First commit on 29 June 2026, 165 commits and 74 PRs by 3 September. On 15 August Supabase was removed and the data moved to the EMBA app's backend, turning a standalone web app into a web-view-only screen inside the app. During that migration a path was found where autosave could overwrite 11 courses and 33 credits on the server with one device's 4 courses, and half of the work since went into closing paths of that kind.

Users

EMBA members computing their degree requirements, and an admin managing course offerings per semester.

Build

What I did
  • The requirement engine - computes total credits, requirement fulfillment, and the attainable degree from undergrad major status and per-semester courses. On 30 August a completed-semesters rule (4 semesters) was added as the first condition of the final verdict and shown at the top of the results tab
  • Round one (Supabase) - an admin CMS (atomic-write RPCs), Google login with roster-based approval (RLS), SheetJS Excel import. All handed to the EMBA admin and member system and removed in the 15 August migration
  • Round two (EMBA backend) - a Bearer JWT API client, authentication via window.EMBA_USER injected by the host app, reference data as a single source with the bundled seed removed, conditional save (updatedAt, server wins on 409)
  • Result and timetable image export - fixed a save that failed silently in the Android app WebView, in the order app handler, clipboard, capture guidance, and made the button label say what is actually possible (save or copy)
  • Team timetable - the pre-migration RPC replaced by GET /degree/team-timetable
Product decisions
  • Pinning it as an "assistive tool" - it must not be mistaken for replacing the degree decision, so the boundary (the registrar confirms) is stated in the copy
  • Conflicts are not merged; the server is the answer - a plan is one JSON blob with no automatic merge. On a 409 the screen is realigned to the server plan with the message that another device saved first
  • If the server plan cannot be read, autosave stops - collapsing "nothing yet" and "could not read" into the same null lets a stale device overwrite the latest server state. Four states (loaded, empty, unavailable, error), and only error blocks saving
  • Web view only - no login screen, the host app's token is taken as given, and token expiry is handled by the app reloading the web view. Email is never passed into the web view
  • Designed on the premise that app releases are expensive and rare - a saveImage handler the app does not have yet is pre-wired on the web side, and the app tolerates both backend schemas if one is rolled back
QA considerations
  • Same input, same result - the requirement calc is a side-effect-free pure function with constants, pinned by unit tests on the tricky boundary combos (exactly 45 credits, business 6 / non-business 12, Advanced 18, 4 semesters completed)
  • Owning the false-positive cost - a wrong "you can graduate" slips a real graduation, so instead of an automated verdict a Disclaimer states the registrar has the final say
  • Separating "could not read" from "nothing there" - total failure on reference data returns null (keep the cache), success with zero rows returns an empty array (clear it). The plan follows the same rule. Collapsing them once nearly lost data
  • Conflicts with another device - send the updatedAt that was read, adopt the server plan on 409, never overwrite again. And never save merely on open (a save that fired on every open was caught in #70)
  • Does the app survive a backend rollback - a sort that holds if course codes come back as numbers, and old area labels normalised whether they arrive from the server, the cache, or the plan
  • No silent failures - "save image" did nothing in the Android app WebView. Do not trust a share API just because it exists, tell user cancellation apart from an environment failure, and say so when it cannot be done. 17 cases
  • No dev API in a store build - zero occurrences of dev/api in dist as the release gate

Outcome

Metrics

In internal use for this cohort. It opens only inside the EMBA app's web view, so there are no public metrics. 165 commits (2026-06-29 to 09-03), 74 PRs, 104 tests (from 30 on 1 July), version 0.1.0.

Process (planning → build)

The same template from problem and hypothesis through scope, spec (SSOT), build, the QA gate, and release - how this was planned and driven.

Problem & hypothesis

EMBA members had to cross-check total credits, Core, Advanced, international-language, and convergence/global requirements by hand on an Excel worksheet - matching course codes and areas themselves - and a miscalculation pushes graduation back. The hypothesis: base the calculation on the user's actually-entered courses (UserCourse) rather than a semester's offering list, so the same input yields the same result even when a past course is no longer offered (docs/ssot.md section 5 core principle).

Skills · Problem framing · hypothesis

Scope & priority

Scope was pinned first in docs/ssot.md section 4 - the requirement engine, result dashboard, and semester/course/offering management are core; school-system integration, automated grade collection, course registration, and replacing the official graduation review are explicitly out. Following the section 20 Phase 1-6 order, the calculator came first and Admin, Excel import, and ops features followed; login-based storage was originally out of MVP but was later actually added as Google login, roster approval, and per-account storage.

Skills · Scoping · prioritization

Spec-first (SSOT)

docs/ssot.md is a literal Single Source of Truth, defining the requirement rules (section 9), calculation logic (section 13), data model (section 11), and screens (section 7) ahead of the code. The implementation visibly tracks it - constants.ts cites SSOT section 9, each step in calculateRequirements.ts cites its 13.x subsection in comments, and the test describe blocks carry the same SSOT section numbers.

Skills · Spec · documentation

Build & iteration

Round one (29 June to 15 August) was a React 19 + Vite + TypeScript + Tailwind v4 + Radix (shadcn) front end over a Supabase backend. The requirement calculation was pulled into a side-effect-free pure function (calculateRequirements.ts) with thresholds in constants.ts, and the DB side centred on an atomic-write RPC (replace_semester_courses), roster-based approval RLS, and a SheetJS Excel import. Round two (PR #58, 15 August) removed the Supabase and xlsx dependencies plus the admin screens and Excel parser, 49 files and 5,010 lines, and moved to the EMBA backend (NestJS, MySQL). Reference data (semesters, courses, offerings) has the backend as its single source and localStorage as a first-paint cache. Authentication is the host Flutter app injecting window.EMBA_USER (member ID, cohort, team, access token) before the document loads, so there is no login screen. Course management moved to the EMBA admin, and the 75-course bundled seed was removed in #64 so that courses deleted in production could no longer linger in the app. The pure function and constants survived intact, and the completed-semesters requirement (4 semesters) was added in the same place.

Skills · Execution · delivery

Verification (QA gate)

Trust was defined as "same input, same result" and "rules match the school's standard," so the requirement logic was isolated into pure functions and constants and pinned by unit tests on the tricky crossovers - exactly 45 credits, business 6 vs non-business 12, Advanced totaling 18 but bunched into one area so the four-area rule fails, and conversely four areas met but the total short of 18. Tests grew from 30 on 1 July to 104 across 16 files, and CI (ci.yml) runs typecheck, lint, test, and build on Node 22 for every push and PR. The most important QA decision was owning the cost of a false positive: a wrong "you can graduate" would actually slip a member's graduation, so instead of an automated verdict a Disclaimer keeps reminding that the registrar has the final say, and a partially-met state is softened via isPartiallyMet. The tests added after the migration centre on separating "could not read" from "nothing there". If the server plan fetch fails, autosave stops and says why on screen; if nothing was ever saved, the local plan is uploaded; if the backend is unreachable, local-only. Conditional save sends the updatedAt it read, and on a 409 it adopts the server plan and does not overwrite again. A sort that survives the backend rolling course codes from strings back to numbers, area-label normalisation at all three read points (server response, cache, plan), and 17 cases of Android WebView image saving are all pinned by tests.

Skills · Quality · risk

Release

This app is not deployed to a server. The build output is copied into the EMBA app repo as a Flutter asset, served in-app by InAppLocalhostServer (port 8091), and ships through the stores. Since 30 August the app loads the remote bundle (card web /planner) first and falls back to the asset. The pre-release gate is one grep line in the README: the string dev/api must appear zero times in dist. Cloudflare Pages was a development-only path and was removed on 20 August along with wrangler.toml. HashRouter and the relative-path build (base:"./") remain. Version is 0.1.0; the build identifier is the short git hash kept only as a data-build attribute on the footer, with the visible label removed. Search indexing is blocked with noindex. Because the bundle goes through the stores and cannot be rolled back instantly, that premise underlies decisions like the defensive sort.

Skills · Release

Tech stack
  • React 19
  • TypeScript
  • Vite
  • Tailwind v4
  • Vitest
  • EMBA 백엔드 API (NestJS · MySQL)
  • Flutter InAppWebView 에셋 번들
  • Supabase (1차, 2026-08 이관으로 제거)