diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ce06aa..e92f04a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,33 @@ jobs: fi echo "parity.json unchanged since 9b2bdc2" + # The Worker is deployed separately and was outside CI entirely, which meant + # `parseAction` - the last of the three topic restrictions, and the only one + # that runs on code we control rather than on a remote service's behaviour - + # had nothing checking it on any push. + worker: + name: worker types and tests + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 20 + cache: npm + cache-dependency-path: worker/package-lock.json + + - run: npm ci + working-directory: worker + + - name: Typecheck + run: npm run typecheck + working-directory: worker + + - name: Tests + run: npm test + working-directory: worker + rust: name: rust runs-on: windows-latest diff --git a/.impeccable/critique/2026-09-01T06-22-44Z__app-src-ui.md b/.impeccable/critique/2026-09-01T06-22-44Z__app-src-ui.md new file mode 100644 index 0000000..b4e2915 --- /dev/null +++ b/.impeccable/critique/2026-09-01T06-22-44Z__app-src-ui.md @@ -0,0 +1,101 @@ +--- +target: app/src/ui +total_score: 28 +max_score: 40 +na_heuristics: +p0_count: 0 +p1_count: 4 +timestamp: 2026-09-01T06-22-44Z +slug: app-src-ui +--- +Method: dual-agent (A: design review · B: detector + screenshot evidence) + +## Design Health Score — 28/40 (Good) + +| # | Heuristic | Score | Key Issue | +|---|---|---|---| +| 1 | Visibility of System Status | 2 | Ledger edits commit silently; only save feedback fires on FAILURE | +| 2 | Match System / Real World | 4 | Near-perfect; only the 14 raw column codes leak | +| 3 | User Control and Freedom | 2 | No undo anywhere; one hover-click deletes a subject + all its marks | +| 4 | Consistency and Standards | 3 | Four "selected" idioms; drawer tabs styled by inline styles, outside tokens | +| 5 | Error Prevention | 3 | Strong where present, undermined by unconfirmed row delete | +| 6 | Recognition Rather Than Recall | 3 | Glossary is one of three mutually exclusive tabs | +| 7 | Flexibility and Efficiency | 2 | Zero keyboard shortcuts; no sort/filter/search on 14-column grid | +| 8 | Aesthetic and Minimalist Design | 2 | Two class names draw one identical container; one elevation level | +| 9 | Error Recovery | 3 | Excellent copy, then a raw alert() in Data.tsx:565 | +| 10 | Help and Documentation | 3 | Glossary exists only on the Semester screen | + +## Design Specificity Verdict + +Authored for a KTU tracker, not reskinnable. Brand hue chosen because its job is status (H:178, clear of danger/warn/good). Mono justified ("every number is a mark"). Glossary is regulation text. No Inter, no purple gradient, no three-card grid. + +Deterministic scan: 0 findings on app/src/ui + index.html, BUT those scans never evaluated CSS (.tsx is regex-mode; HTML shell has no styles). Direct stylesheet scan found 3: two side-tab left-stripe borders, one un-waived overused-font (Space Grotesk waived only for a scratchpad doc path, not the app). No visual overlays; CLI-only. + +## Overall Impression + +"Everything is a card" is TRUE BUT LOCALIZED. Rendered container counts: Data 8, Home 6, Subject detail 2, Ledger 1, History 1. Ledger and History are already card-free (the 1 is the shared global notice). Disease is concentrated in Data and Home. + +Mechanism: .card and .tile are THE SAME DRAWING under two names — identical gradient border-box, identical --surface-1, identical 1px solid transparent. Differ only in radius (14px vs 6px) and padding. motion.css already treats them as one. A `card` grep undercounts by half: 8 .card + 5 Home tiles. + +Underneath: tokens define a 3-step elevation ramp; --surface-3 is used 3 times in the whole app (scrollbar thumb, hover, holiday stripe), never for content. --surface-1 carries every container. Hierarchy system built, one level used. + +The fix already exists in-repo: .claims is a borderless hairline-ruled list whose comment says "The grid version is the reflex and it flattens an argument into a brochure." Never propagated past one component. + +## What's Working + +1. Refusal to invent a number, enforced at every render site (dashes not 0.00, "no completed semester yet", withdrawn semesters excluded and said out loud). This is the trust position of a tool competing with the university's own portal. +2. Progressive disclosure via native details — keyboard/SR behavior free, styled as text affordance not competing with card primary action. +3. Measured color with a failing test behind it — --text-faint lifted 2.95:1 to 4.64:1; comments catch OKLCH values outside sRGB being silently clipped. + +## Priority Issues + +[P1] Two class names, one container, one elevation level. +Why: Operate mode = scanability. Data renders 8 identical rectangles; Home 5 tiles whose only differentiator is one amber border the CSS forbids reusing. Hierarchy falls entirely on typography while Home already spends two .huge figures + a --text-2xl stat + a gauge. Four focal points, one surface, no ranking. +Fix: collapse .card/.tile into one class with modifiers; convert Data's seven ops and Attendance's subject list to .claims-style ruled rows; PROMOTE the one or two real subjects per screen to --surface-2 so two levels exist. +Command: /impeccable layout + +[P1] Attendance is three screens stacked on one scroll. +Why: screen titled "How many classes can you miss?" but the day calendar and weekly timetable answer neither that nor each other. Student scrolls past two 820px-min-width tables to leave. +Fix: segmented control — Budget / Day by day / Timetable — using the existing .seg pattern. +Command: /impeccable layout + +[P1] Day-by-day calendar invisible to screen readers. +Why: cal-block is a bare div with a title and NO text content inside an otherwise-empty td. Browse mode yields an empty cell; no text equivalent anywhere. title is sole carrier in five places app-wide. +Fix: real (visually-hidden) text per cell — "Mon 18 Aug, period 3, absent". Never title alone. +Command: /impeccable harden + +[P1] Destructive delete, no confirm, no undo. +Why: .del invisible until hover, one click removes subject + every mark, and it is the LAST TAB STOP on every row. Recovery is "you exported a backup earlier." +Fix: optimistic removal + "Removed PCCST501 · Undo" 10s in the existing notice slot. +Command: /impeccable harden + +[P2] Real text defects from screenshots. +- Truncation: "Design and Analysis of Algorit…" in COURSE column (dark AND light); same name renders full in Home tiles, so the column is the constraint. +- Home "Needs attention": description column ~10-14 chars; breaks across 5 lines, one holding just "be". +- Drawer spacing: ATTENDANCE VS INTERNALS ~17px below preceding paragraph vs ~50px above sibling GOAL — attaches to wrong thing. +- Light mode: card borders near-invisible against cream ground; only the amber-flagged tile reads as bounded. +Command: /impeccable polish + +## Persona Red Flags + +Alex (power user): zero keyboard shortcuts (one onKeyDown app-wide). white-space:nowrap makes a 14-col row wider than viewport, so tabbing scrolls the table sideways under him; sticky header does not help because the COLUMNS move. Target grade is a select per row. No sort/filter/search. + +Sam (screen reader): strong baseline (sr-only announcements, type-enforced labels, scope on every header) then — calendar unreadable, .drawer-tabs is a tablist wearing aria-pressed (nothing announces the panel changed), expanded row is td colSpan=14 holding a 7-input form. + +Jordan (first-timer): Home's SECOND tile says "12 CIE marks lost" while CIE is defined only in the drawer's THIRD tab. Data offers 5 import routes with nothing marked as first choice, though Setup has a .route-tag and Home says the portal is fastest. "SET CODE" is dev language in caps. Drawer tabs: only the third is in her vocabulary and it is placed last. + +## Minor Observations + +- Three corner radii visible on Home at once: 14px tiles, 6px cards, 3px notice. +- colSpan={14} is a magic number 280 lines from the thead it must match. +- About a dozen inline style color var(--text-faint) where .faint already exists. +- screens.css is 957 lines; header still says "Screens beyond the ledger". +- Pointer-tracked spotlight fires on 2 of Home's 5 tiles, implying the other three are inert. +- Light and dark are pixel-position identical; only palette swaps. + +## Questions to Consider + +1. The ledger is where students live (flat hairlines, 3px radius); Home, read once a day, gets 14px corners, gradient border, spotlight, staggered mount. Which screen deserves the design budget? +2. Attendance appears three times (ledger column, Home tile, whole screen). What if it were a MODE you flip the table into rather than a fourth peer view? +3. The .claims comment already names the failure mode. So what made .card the default everywhere else? +4. The app will not print a number it does not know. Is it equally rigorous about not drawing a container it does not need? diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index d94129d..7cd058f 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -37,20 +37,28 @@ Verify the download first — see **Signing** below. ## What it talks to -Three hosts, and only three. There is **no telemetry, no analytics and no -crash reporting**; the only `fetch` in the entire frontend is the second row of -this table (`app/src/state/actions.ts`). +Five hosts, and only five — and the last two are reached only if a student +chooses to sign in, which nothing else in the app requires. There is **no +telemetry, no analytics and no crash reporting**. | Host | When | Why | |---|---|---| | `github.com` | A few seconds after launch | Update check — reads `releases/latest/download/latest.json` | | `raw.githubusercontent.com` | On launch | Course catalogue refresh, so a KTU curriculum revision does not need a new build | | **Your own portal** | Only when the student presses Sync, and only to the address they typed | Reads attendance and marks | +| `*.clerk.accounts.dev` | Only if the student signs in | Sign-in, which gates the question box and nothing else | +| `targetx-ask.*.workers.dev` | Only when a signed-in student asks a question the app could not answer locally | Returns a destination — a screen or a subject — never a figure | Nothing else. If your network blocks GitHub, the app still works: the update check and the catalogue refresh both fail quietly and the bundled catalogue is used. Portal sync is optional — everything can be typed or pasted in. +**Blocking the last two rows is supported and costs nothing but the question +box.** No marks, attendance or CGPA are sent to either host; what leaves is the +question text and the student's course codes and titles. If you would rather +students could not sign in at all, build from source with `VITE_CLERK_CLIENT_ID` +left empty and the feature is absent rather than merely blocked. + ### About the portal The student's portal password is held in a local variable for the duration of diff --git a/PRIVACY.md b/PRIVACY.md index 6d0a91a..c7ab317 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,7 +1,14 @@ # Privacy -TargetX is a desktop application that runs entirely on the machine it is -installed on. There is no TargetX account, no TargetX server, and no telemetry. +TargetX is a desktop application that runs on the machine it is installed on. +Every figure it shows is computed there. There is no telemetry, and no account +is needed to use any of it. + +One optional feature is different, and is set out in full below: the question +box can forward a question it could not answer locally to a server of ours, and +that is the one part that asks you to sign in. It never receives a mark, an +attendance figure or a CGPA. If you never sign in, nothing in this paragraph +applies to you and the rest of the app is unaffected. This document describes what the software actually does, and every claim in it can be checked against the source in this repository. Where a claim is about @@ -24,7 +31,8 @@ different one. ## What is sent over the network -TargetX makes network requests in exactly three situations, and no others. +TargetX makes network requests in exactly four situations, and no others. The +fourth happens only if you choose to sign in. 1. **When you press Sync.** It signs in to the college portal URL that you typed, and reads your attendance and internal marks. This is a request to @@ -36,6 +44,33 @@ TargetX makes network requests in exactly three situations, and no others. 3. **A few seconds after launch, to ask whether a newer version exists.** This is a request to GitHub's release feed. It carries no information about you beyond what any file download carries. See `app/src/sync/update.ts`. +4. **When you are signed in and ask a question the app could not answer by + itself.** This is the only request that reaches a server of ours, and the + only one that requires an account. Most questions never get here: attendance, + marks, standing and the regulations are all answered on your own machine, + offline, and only a phrasing the app does not recognise is forwarded. + + **What is sent:** your question, and the code and title of the subjects you + are registered for — the course list, so the question can be matched to a + subject. + + **What is not sent:** marks, attendance, CGPA, your name, your register + number, your password. None of it. See `app/src/state/ask.ts`, which is the + only file that decides what goes into that request. + + **What comes back is not an answer.** It is a destination: one of five + screens, or one of the subjects you sent. The reply is parsed into a fixed + type before the app acts on it, and anything outside that type — including a + subject code you did not send — is rejected. There is no field in it that + could carry a figure about you, so the model cannot state one even if asked + to. See `worker/src/schema.ts`. + + **What is logged:** the question text, the outcome, how many subjects were + sent, and how long it took — so the app can learn which phrasings it failed + to answer locally. Not logged: any account id, any token, or the course codes + themselves. Identity and content are deliberately kept apart, so the log is a + record of what people ask rather than a record of what a named student asks. + See `worker/src/log.ts`. Your marks are never sent anywhere, by anyone, for any reason. There is no endpoint that would receive them. diff --git a/README.md b/README.md index b7cb07b..73e681b 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ matters before an exam: *what do I have to score in this paper?* ### [**Download TargetX**](https://codedrichy.github.io/TargetX/) — Windows, macOS, Linux -Free for students, forever. No account, no server, no sign-up — your record is -a file on your own machine. +Free for students, forever. Your record is a file on your own machine, and every +figure on every screen is computed there. No account is needed to use any of +it — sign-in exists only for the question box, and nothing else changes if you +never do. --- @@ -61,6 +63,11 @@ after results. - **Your marks in without typing them.** Sign in to your college's etlab portal and it pulls every semester, attendance, series marks, grades and published SGPA in one pass. +- **Ask it in words.** Press `Ctrl K` and ask — *"can I skip tomorrow?"*, *"what + do I need to pass CN?"*, *"what is condonation?"*. **Tex** answers in the box + rather than dropping you on a screen to work it out. Every figure in the reply + is computed locally by the engine, so the answers are the same numbers the + rest of the app shows, and most questions never touch the network at all. - **Updates arrive on their own.** The app offers a new version a few seconds after launch. It never installs unasked, and a failed check is silent — being offline is not an error a student needs to see. @@ -98,13 +105,25 @@ it is the same tested code path whichever you use. ## Privacy -Nothing you enter leaves the machine. There is no account and no server. The app -makes network requests in exactly three situations: the portal sign-in you asked -for, the course-catalogue file, and the update check. All three are named and -located in the +Your marks, attendance and CGPA never leave the machine. The app makes network +requests in exactly four situations: the portal sign-in you asked for, the +course-catalogue file, the update check, and — only if you sign in and only when +you ask a question the app could not answer by itself — the question box. All +four are named and located in the [privacy statement](https://codedrichy.github.io/TargetX/privacy.html) ([`PRIVACY.md`](PRIVACY.md)). +The fourth one is new in 0.3.0 and is the only one that involves a server of +ours, so it is worth being exact about. What is sent is the question you typed +and the code and title of the subjects you are registered for. What is **not** +sent is every number: no marks, no attendance, no CGPA, no name, no register +number. What comes back is not an answer — it is a destination, drawn from a +fixed list of five screens and your own subjects, and it is rejected outright if +it names anything else. Every figure you then read was computed on your machine +by the same engine as the rest of the app. Sign-in gates that box and nothing +else; signed out, the app is fully usable and the box still answers everything +it can answer locally, which is most things. + Your portal password is used once, to sign in. It is never written to disk, never logged, never included in an export. That is enforced by a test, not by intention: `credential-containment.test.ts` puts a sentinel password through a @@ -124,10 +143,15 @@ Source-available under the **Business Source License 1.1** ([`LICENSE`](LICENSE) The source is public because the central claim — that your marks never leave your machine — is one you should be able to check rather than take on trust. -`app/src-tauri/src/etlab.rs` is where every network call lives, and there are no -others. That argument cannot be made from a closed repository, which is why this -is source-available rather than proprietary, and why it is not permissive. Not -open source under the OSI definition, and not described as such. +There are four places to check and no others: `app/src-tauri/src/etlab.rs` and +`ktu.rs` carry the portal transports, `app/src/sync/update.ts` the update check, +and `app/src/state/ask.ts` the question box — which is the only one that talks +to a server of ours, and the only file that decides what is put in that request. +The Worker on the other end is in `worker/`, and `worker/src/schema.ts` is the +type its reply has to parse into before the app will act on it. That argument +cannot be made from a closed repository, which is why this is source-available +rather than proprietary, and why it is not permissive. Not open source under the +OSI definition, and not described as such. Third-party components ship under their own terms ([`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md)). No external pull request @@ -158,11 +182,13 @@ calculation core. It is not part of the shipped app. ## Tests ``` -cd app && npm test +cd app && npm test # 763 +cd worker && npm test # 75 ``` -**487 tests across 44 files**, counted from a run on 2026-08-30, not estimated: -205 engine, 136 UI, 77 sync, 51 state, 16 styles, 2 data. +**838 tests across 68 files**, counted from a run on 2026-09-02, not estimated: +257 engine, 229 UI, 157 state, 102 sync, 16 styles, 2 data, and 75 in the +Worker. `engine/__tests__/parity.test.ts` is the load-bearing one. It replays a frozen corpus of generated course cases and semester rollups produced by the Python @@ -256,6 +282,9 @@ are not transcribed. | `app/src/data/curriculum.json` | Bundled course catalogue, refreshable from the repo | | `app/src/state/persist.ts` | Your data, saved atomically with three backups kept | | `app/src/sync/update.ts` | The update check. Never blocks startup, never installs unasked | +| `app/src/state/answers.ts` | Tex's answers. Every figure computed here, from the engine | +| `app/src/state/glossary.ts` | What the words mean. A lookup table, not a model | +| `worker/` | The question router. Names a screen and a subject; never a number | | `docs/` | The download page, served by GitHub Pages | | `legacy/` | The retired Python original, kept as the parity oracle | @@ -268,7 +297,7 @@ including the alternatives that were rejected and why. [`CHANGELOG.md`](CHANGELOG.md) is written for the student deciding whether an update is worth taking; its first section is the numbers that used to be wrong. [`DEPLOYMENT.md`](DEPLOYMENT.md) answers "what does this touch?" for a college -IT department — silent-install flags, the three hosts it contacts, where the +IT department — silent-install flags, the five hosts it contacts, where the record lives, and how to take version control away from the updater. [`SIGNING.md`](SIGNING.md) covers releasing and the two signatures a build carries. [`ACCESSIBILITY.md`](ACCESSIBILITY.md) is a dated self-assessment diff --git a/app/.env.example b/app/.env.example new file mode 100644 index 0000000..7a28117 --- /dev/null +++ b/app/.env.example @@ -0,0 +1,16 @@ +# Sign-in (optional). +# +# Both values are PUBLIC. The issuer appears in every token the app receives, +# and a PKCE client id is not a secret - that is the whole point of PKCE. There +# is no client secret here and there must never be one: this app is files on a +# student's disk, and anything shipped inside it is extractable. +# +# Leave both blank and the build simply has no accounts. Nothing else changes: +# every figure on every screen is computed on the machine from data the machine +# fetched, and sign-in gates the assistant and nothing else. +# +# Clerk Dashboard -> Configure -> OAuth Applications -> create one with: +# - a PUBLIC client (no secret), PKCE required +# - redirect URI http://127.0.0.1:*/callback (loopback, RFC 8252) +VITE_CLERK_ISSUER= +VITE_CLERK_CLIENT_ID= diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +.env diff --git a/app/package-lock.json b/app/package-lock.json index 19fe24a..85e84c7 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,18 +1,19 @@ { "name": "targetx", - "version": "0.1.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "targetx", - "version": "0.1.0", + "version": "0.3.0", "license": "BUSL-1.1", "dependencies": { "@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/space-grotesk": "^5.3.0", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-fs": "^2.5.1", + "@tauri-apps/plugin-opener": "^2.5.5", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", "pdfjs-dist": "^6.2.108", @@ -1913,6 +1914,15 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.5.tgz", + "integrity": "sha512-xvzGai5aQds8j8R8RsUK/lW6pGG50YgOYIPLzvkqmkwAj7dfySOD7sGtejRzvVdMmv1EQfKVEFh1MvmDp8QR0g==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-process": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", diff --git a/app/package.json b/app/package.json index 1d9c563..f181f7b 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "targetx", "private": true, - "version": "0.2.0", + "version": "0.3.0", "license": "BUSL-1.1", "type": "module", "scripts": { @@ -25,6 +25,7 @@ "@fontsource/space-grotesk": "^5.3.0", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-fs": "^2.5.1", + "@tauri-apps/plugin-opener": "^2.5.5", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", "pdfjs-dist": "^6.2.108", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 3856730..bd3f355 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -73,6 +73,137 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atk" version = "0.18.2" @@ -168,6 +299,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -349,7 +493,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -374,6 +518,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.2" @@ -832,6 +985,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -869,6 +1049,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1009,6 +1209,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1192,7 +1405,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -1368,6 +1581,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1664,6 +1883,25 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2343,6 +2581,17 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -2355,6 +2604,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "osakit" version = "0.3.1" @@ -2394,6 +2653,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2482,6 +2747,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2527,6 +2803,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2557,6 +2847,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2679,7 +2978,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -2727,6 +3026,17 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.2" @@ -2735,7 +3045,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -2750,7 +3079,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -3352,6 +3681,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3638,17 +3977,21 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "targetx" -version = "0.2.0" +version = "0.3.0" dependencies = [ + "base64 0.22.1", "keyring", "log", + "rand 0.8.8", "reqwest 0.12.28", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-fs", "tauri-plugin-log", + "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-updater", "tokio", @@ -3828,6 +4171,28 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-plugin-process" version = "2.3.1" @@ -4311,9 +4676,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4363,6 +4740,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5307,6 +5695,96 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5384,3 +5862,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.4", +] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 365e920..dd931fc 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "targetx" -version = "0.2.0" +version = "0.3.0" description = "KTU 2024 academic tracker" authors = ["CodedRichy"] license = "BUSL-1.1" @@ -25,11 +25,18 @@ tauri = { version = "2.11.3", features = [] } tauri-plugin-log = "2" # rustls rather than the system TLS stack: one less thing that differs # between a developer machine and a student's laptop. -reqwest = { version = "0.12", default-features = false, features = ["cookies", "rustls-tls", "charset"] } +reqwest = { version = "0.12", default-features = false, features = ["cookies", "rustls-tls", "charset", "json"] } tokio = { version = "1", features = ["rt", "macros"] } +# Sign-in (RFC 8252). PKCE needs a CSPRNG and SHA-256, and the challenge is +# base64url. No OAuth crate: the flow is one authorize URL and one form post, +# and a library for that would be more surface than the code it replaces. +sha2 = "0.10" +base64 = "0.22" +rand = "0.8" tauri-plugin-updater = "2.10.1" tauri-plugin-process = "2.3.1" tauri-plugin-fs = "2.5.1" +tauri-plugin-opener = "2" # Opt-in portal-credential storage (issue #2). Scoped to Windows, where the # store is Credential Manager (DPAPI, per-user). The native backend pulls only diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 820e100..374088a 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -24,6 +24,13 @@ "fs:allow-rename", "fs:scope-appdata", "fs:deny-webview-data-windows", - "fs:deny-webview-data-linux" + "fs:deny-webview-data-linux", + + { + "identifier": "opener:allow-open-url", + "allow": [ + { "url": "https://github.com/CodedRichy/TargetX/*" } + ] + } ] } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 7161ad5..279d762 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod creds; mod etlab; mod ktu; +mod oauth; use tauri::Manager; use tauri_plugin_log::{RotationStrategy, Target, TargetKind}; @@ -80,6 +81,20 @@ pub fn run() { // this webview also renders a college portal's HTML, and it has no business // being able to reach the rest of the disk. .plugin(tauri_plugin_fs::init()) + // Opening a URL in the student's own browser. Every external link in the + // app - the privacy statement, and "Report a problem" on both the Data + // screen and a failed sync - was an ``, which a webview + // does nothing with. The one that mattered was the third: a student whose + // sync had just broken was handed a dead link to the issue form. + // + // The objection to shipping this is real and is written up on + // `diagnostics_dir` below: this webview also renders a college portal's + // HTML, so handing it the ability to ask the OS to launch things is not + // free. It is answered by scope rather than by going without - the + // capability allows `opener:allow-open-url` against this project's own + // GitHub URLs and nothing else, so a hostile page in this webview can ask + // for the issue tracker and cannot ask for anything at all beyond it. + .plugin(tauri_plugin_opener::init()) // Registered unconditionally, and outside `setup`, so that anything which // fails during setup is itself logged. It used to be inside a // `debug_assertions` check, which meant the only builds that recorded a @@ -87,6 +102,7 @@ pub fn run() { .plugin(log_plugin()) .manage(etlab::Etlab::default()) .manage(ktu::Ktu::default()) + .manage(oauth::Oauth::default()) .invoke_handler(tauri::generate_handler![ etlab::etlab_start, etlab::etlab_reset, @@ -102,6 +118,11 @@ pub fn run() { creds::cred_load, creds::cred_delete, creds::cred_has, + oauth::oauth_begin, + oauth::oauth_finish, + oauth::oauth_resume, + oauth::oauth_sign_out, + oauth::oauth_has_account, log_error, diagnostics_dir, ]) diff --git a/app/src-tauri/src/oauth.rs b/app/src-tauri/src/oauth.rs new file mode 100644 index 0000000..fd0da8f --- /dev/null +++ b/app/src-tauri/src/oauth.rs @@ -0,0 +1,734 @@ +//! Sign-in, the way a desktop application is allowed to do it. +//! +//! This is RFC 8252, "OAuth 2.0 for Native Apps", and every part of it is the +//! way it is because a desktop binary cannot keep a secret: +//! +//! * **PKCE, not a client secret.** The app is files on the student's disk; +//! anything static shipped inside it is extractable. PKCE replaces the +//! secret with a per-attempt random verifier, so there is nothing to steal +//! that is worth stealing twice. +//! +//! * **The system browser, not an embedded webview.** The student types a +//! password on a page whose URL bar they can see, in the browser where +//! their password manager lives. An in-app webview is the phishing shape: +//! the application could read the password field, and the user has no way +//! to tell that it does not. +//! +//! * **A loopback redirect on one of a few fixed ports.** RFC 8252 says a +//! provider should accept any port on 127.0.0.1, and an OS-assigned +//! ephemeral port is what this originally used. Clerk - like most +//! providers - refuses a wildcard and wants exact redirect URIs +//! registered, so instead there is a short list of candidates and the +//! first free one wins. Several, not one, because a single hardcoded port +//! is a single point of failure: something else on the machine binds it +//! and sign-in is dead with no way for the student to fix it. The listener +//! accepts exactly one request and dies. +//! +//! * **The refresh token in the OS vault, never on disk in the clear.** Same +//! store `creds.rs` uses, for the same reason. +//! +//! The access token is returned to the frontend and held in memory only. It is +//! short-lived by design, it is the thing sent to our own Worker, and writing +//! it anywhere would be storing a bearer credential to save a refresh call. +//! +//! Endpoints are read from the provider's OpenID discovery document rather than +//! hardcoded. Clerk publishes one, and a URL shape we guessed today would be a +//! silent breakage the first time it changed. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// How long the browser half of the flow may take before we give the port back. +/// +/// Generous: this covers a student finding the browser window, typing a +/// password, and doing whatever their second factor asks. It exists so an +/// abandoned sign-in eventually frees the listener, not to hurry anybody. +const BROWSER_TIMEOUT: Duration = Duration::from_secs(300); + +/// The loopback ports sign-in may listen on, in order of preference. +/// +/// EVERY ONE OF THESE MUST BE REGISTERED AS A REDIRECT URI IN THE PROVIDER, as +/// `http://127.0.0.1:/callback`. A port missing there is a port that +/// fails only when the earlier ones happen to be busy - which is the worst kind +/// of bug, because it is rare, machine-specific and looks random. +/// +/// Chosen from the IANA dynamic range and away from the round numbers +/// development servers reach for, so a colleague running something on 8080 or +/// 3000 does not collide with a student signing in. +const CALLBACK_PORTS: [u16; 4] = [49731, 49732, 49733, 49734]; + +/// The vault slot the refresh token lives in. Distinct from the portal logins. +const ACCOUNT_KEY: &str = "targetx-account"; + +// --- discovery -------------------------------------------------------------- + +#[derive(Deserialize)] +struct Discovery { + authorization_endpoint: String, + token_endpoint: String, +} + +async fn discover(issuer: &str) -> Result { + let url = format!("{}/.well-known/openid-configuration", issuer.trim_end_matches('/')); + let res = reqwest::get(&url).await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("The sign-in provider did not answer ({}).", res.status())); + } + res.json::().await.map_err(|e| e.to_string()) +} + +// --- PKCE ------------------------------------------------------------------- + +fn random_urlsafe(bytes: usize) -> String { + let mut buf = vec![0u8; bytes]; + rand::thread_rng().fill_bytes(&mut buf); + URL_SAFE_NO_PAD.encode(buf) +} + +/// S256, not `plain`. `plain` sends the verifier itself in the authorization +/// request, which puts it in browser history and in any proxy log on the way. +fn challenge_for(verifier: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) +} + +// --- the one-shot loopback listener ---------------------------------------- + +/// What the browser came back with. +struct Callback { + code: Option, + state: Option, + error: Option, +} + +fn parse_query(target: &str) -> HashMap { + let mut out = HashMap::new(); + let Some(q) = target.split_once('?').map(|(_, q)| q) else { + return out; + }; + for pair in q.split('&') { + if let Some((k, v)) = pair.split_once('=') { + out.insert(k.to_string(), percent_decode(v)); + } + } + out +} + +/// Enough of percent-decoding for an OAuth callback: `%XX` and `+`. +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'%' if i + 2 < bytes.len() => { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""); + match u8::from_str_radix(hex, 16) { + Ok(b) => { + out.push(b); + i += 3; + } + Err(_) => { + out.push(bytes[i]); + i += 1; + } + } + } + b'+' => { + out.push(b' '); + i += 1; + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// The page the student is left looking at. Deliberately plain, self-contained +/// and offline: it renders in a browser tab we do not control, on a machine +/// that may have just come back from an auth flow with no network left. +const DONE_PAGE: &str = "\ +Signed in\ +\ +

Signed in

You can close this tab and go back to TargetX.

"; + +/// Bind an ephemeral loopback port and accept exactly one request on it. +/// +/// Returns the port immediately - the caller needs it to build the redirect URI +/// before the browser is opened - and a receiver that yields the callback when +/// the browser arrives. The thread ends after one request either way, so an +/// abandoned sign-in leaks a thread for at most `BROWSER_TIMEOUT`. +fn listen_once() -> Result<(u16, Receiver, Arc), String> { + // First free candidate wins. Bound to 127.0.0.1 and never 0.0.0.0: this + // accepts an authorization code, and it has no business being reachable + // from the campus network the machine is sitting on. + let listener = CALLBACK_PORTS + .iter() + .find_map(|p| TcpListener::bind(("127.0.0.1", *p)).ok()) + .ok_or_else(|| { + format!( + "Could not open a port to finish signing in. Something else is using all of {:?}.", + CALLBACK_PORTS + ) + })?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + let (tx, rx) = mpsc::channel(); + let cancelled = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&cancelled); + + std::thread::spawn(move || { + // `incoming` blocks; one iteration is all this listener is for. A + // browser preflighting the port (some do) would otherwise consume the + // single accept, so anything without a `code` or `error` is ignored and + // the loop waits for the real callback. + for stream in listener.incoming() { + // A sign-in that was abandoned and started again wakes this thread + // by connecting to the port; the flag is how it tells the difference + // between that and a browser arriving. Without it an abandoned + // attempt held its port for the full five-minute timeout, and four + // retries in five minutes exhausted every candidate. + if flag.load(Ordering::SeqCst) { + break; + } + let Ok(mut stream) = stream else { continue }; + + let mut line = String::new(); + if BufReader::new(&stream).read_line(&mut line).is_err() { + continue; + } + // "GET /callback?code=... HTTP/1.1" + let target = line.split_whitespace().nth(1).unwrap_or("").to_string(); + let params = parse_query(&target); + + let code = params.get("code").cloned(); + let error = params.get("error").cloned(); + if code.is_none() && error.is_none() { + let _ = stream.write_all(b"HTTP/1.1 204 No Content\r\n\r\n"); + continue; + } + + let body = DONE_PAGE.as_bytes(); + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .as_bytes(), + ); + let _ = stream.write_all(body); + let _ = stream.flush(); + + let _ = tx.send(Callback { + code, + state: params.get("state").cloned(), + error, + }); + break; + } + }); + + Ok((port, rx, cancelled)) +} + +// --- pending flow state ----------------------------------------------------- + +struct Pending { + port: u16, + cancelled: Arc, + verifier: String, + state: String, + redirect_uri: String, + token_endpoint: String, + client_id: String, + rx: Receiver, +} + +#[derive(Default)] +pub struct Oauth { + pending: Mutex>, +} + +// --- the wire shapes -------------------------------------------------------- + +#[derive(Serialize)] +pub struct Started { + /// Open this in the SYSTEM browser. Never in a webview - see the module doc. + pub authorize_url: String, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, + id_token: Option, +} + +/// The claims worth showing. Everything else in the token is ignored. +#[derive(Deserialize, Default)] +struct Claims { + name: Option, + email: Option, + picture: Option, +} + +/// What the frontend is told about the signed-in account. +/// +/// No tokens beyond the short-lived access token, and no claim we did not read +/// out of the provider's own response. +#[derive(Serialize)] +pub struct Session { + pub access_token: String, + /// Unix seconds. `None` when the provider declined to say. + pub expires_at: Option, + pub name: Option, + pub email: Option, + /// The avatar as a `data:` URI, already fetched and inlined. + /// + /// NOT the provider's URL. Handing the webview an https URL would mean a + /// request to Google's CDN every time the header rendered - telling them + /// this machine's IP and that the app is open, repeatedly, in an + /// application whose whole position is that it works without a network. It + /// is fetched once here and inlined, which also means it still draws + /// offline and needs no widening of the app's `img-src`. + pub avatar: Option, +} + +// --- commands --------------------------------------------------------------- + +/// Begin a sign-in. Returns the URL the caller must open in the system browser. +#[tauri::command] +pub async fn oauth_begin( + issuer: String, + client_id: String, + scopes: String, + oauth: tauri::State<'_, Oauth>, +) -> Result { + let disco = discover(&issuer).await?; + + let verifier = random_urlsafe(48); + let challenge = challenge_for(&verifier); + // CSRF: the value comes back in the callback and is compared. A callback + // that does not carry the state this process generated is not ours. + let state = random_urlsafe(24); + + let (port, rx, cancelled) = listen_once()?; + let redirect_uri = format!("http://127.0.0.1:{port}/callback"); + + let authorize_url = format!( + "{}?response_type=code&client_id={}&redirect_uri={}&scope={}\ + &state={}&code_challenge={}&code_challenge_method=S256", + disco.authorization_endpoint, + urlencode(&client_id), + urlencode(&redirect_uri), + urlencode(&scopes), + urlencode(&state), + urlencode(&challenge), + ); + + let mut slot = oauth.pending.lock().map_err(|_| "sign-in state was poisoned")?; + // Pressing Sign in again abandons the previous attempt. Releasing its port + // here rather than letting it time out is what keeps a run of failed + // attempts from walking through every candidate port. + if let Some(previous) = slot.take() { + release(&previous); + } + *slot = Some(Pending { + port, + cancelled, + verifier, + state, + redirect_uri, + token_endpoint: disco.token_endpoint, + client_id, + rx, + }); + + // Opened here rather than handed to the frontend to open, so the URL never + // crosses into the webview - the same webview that renders a college + // portal's HTML. It is returned as well, for the "open this manually" + // fallback when no browser is registered. + open_in_browser(&authorize_url); + + Ok(Started { authorize_url }) +} + +/// Wait for the browser, then exchange the code for tokens. +/// +/// Split from `oauth_begin` so the caller can open the browser in between. +/// Blocks for up to `BROWSER_TIMEOUT`. +#[tauri::command] +pub async fn oauth_finish(oauth: tauri::State<'_, Oauth>) -> Result { + let pending = oauth + .pending + .lock() + .map_err(|_| "sign-in state was poisoned")? + .take() + .ok_or("No sign-in is in progress.")?; + + // The channel recv blocks a thread; doing it on the async runtime's worker + // would stall every other command for up to five minutes. + let callback = tokio::task::spawn_blocking(move || { + let outcome = pending.rx.recv_timeout(BROWSER_TIMEOUT); + // Whatever happened - a browser, a timeout, a disconnect - this attempt + // is over and its port goes back. + release(&pending); + outcome.map(|cb| (cb, pending.verifier, pending.state, pending.redirect_uri, + pending.token_endpoint, pending.client_id)) + }) + .await + .map_err(|e| e.to_string())?; + + let (cb, verifier, state, redirect_uri, token_endpoint, client_id) = match callback { + Ok(v) => v, + Err(RecvTimeoutError::Timeout) => { + return Err("Sign-in timed out. The browser never came back.".into()) + } + Err(RecvTimeoutError::Disconnected) => { + return Err("Sign-in was interrupted before the browser answered.".into()) + } + }; + + if let Some(error) = cb.error { + return Err(format!("Sign-in was refused: {error}")); + } + // Compared before the code is used for anything. A mismatched state is an + // injected callback, and the correct response is to drop it silently rather + // than explain which half was wrong. + if cb.state.as_deref() != Some(state.as_str()) { + return Err("That sign-in response did not match this request.".into()); + } + let code = cb.code.ok_or("The provider returned no authorization code.")?; + + let client = reqwest::Client::new(); + let res = client + .post(&token_endpoint) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code.as_str()), + ("redirect_uri", redirect_uri.as_str()), + ("client_id", client_id.as_str()), + ("code_verifier", verifier.as_str()), + ]) + .send() + .await + .map_err(|e| e.to_string())?; + + if !res.status().is_success() { + // The body can carry provider internals; the status is all the student + // needs and all we are willing to put on a screen. + return Err(format!("The sign-in provider rejected the exchange ({}).", res.status())); + } + + let token: TokenResponse = res.json().await.map_err(|e| e.to_string())?; + if let Some(refresh) = token.refresh_token.as_deref() { + // Best-effort: a vault that will not take it means signing in again next + // launch, which is an inconvenience and not a failure of THIS sign-in. + let _ = vault_save(refresh); + } + + Ok(session_from(token).await) +} + +/// Trade a stored refresh token for a fresh access token, without a browser. +/// +/// Returns `Ok(None)` when there is nothing stored - not signed in is a normal +/// state, not an error. A refresh the provider REJECTS clears the stored token: +/// it has been revoked or has expired, and keeping it would mean failing this +/// way on every launch forever. +#[tauri::command] +pub async fn oauth_resume( + issuer: String, + client_id: String, +) -> Result, String> { + let Some(refresh) = vault_load() else { + return Ok(None); + }; + let disco = discover(&issuer).await?; + + let res = reqwest::Client::new() + .post(&disco.token_endpoint) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh.as_str()), + ("client_id", client_id.as_str()), + ]) + .send() + .await + .map_err(|e| e.to_string())?; + + if !res.status().is_success() { + let _ = vault_delete(); + return Ok(None); + } + + let token: TokenResponse = res.json().await.map_err(|e| e.to_string())?; + // Providers that rotate refresh tokens hand back a new one, and dropping it + // would sign the student out at the next launch. + if let Some(next) = token.refresh_token.as_deref() { + let _ = vault_save(next); + } + + Ok(Some(session_from(token).await)) +} + +/// Forget the account on this machine. +#[tauri::command] +pub fn oauth_sign_out() -> Result<(), String> { + vault_delete() +} + +/// Whether a refresh token is stored, without touching the network. +#[tauri::command] +pub fn oauth_has_account() -> bool { + vault_load().is_some() +} + +// --- helpers ---------------------------------------------------------------- + +/// Read the claims out of an ID token, without verifying its signature. +/// +/// Safe here and only here: this token came back on the direct, TLS-protected +/// response to our own PKCE exchange, which OpenID Connect Core 3.1.3.7 says +/// need not be validated - there is no third party in the path who could have +/// substituted it. The claims are used for a name and a picture and for +/// nothing that grants access; the WORKER verifies signatures properly, +/// because there the token arrives from a client we do not trust. +fn claims_of(id_token: &str) -> Claims { + let Some(payload) = id_token.split('.').nth(1) else { + return Claims::default(); + }; + let Ok(bytes) = URL_SAFE_NO_PAD.decode(payload) else { + return Claims::default(); + }; + serde_json::from_slice(&bytes).unwrap_or_default() +} + +/// The largest avatar worth inlining. +/// +/// A cap rather than trust: the URL comes from a token, the response comes from +/// somebody else's CDN, and this ends up in a `data:` URI held in memory. A +/// provider that served a 40MB image should cost us nothing. +const MAX_AVATAR_BYTES: usize = 512 * 1024; + +/// Fetch an avatar and inline it as a `data:` URI. +/// +/// Best-effort throughout. Every failure returns `None`, because an account +/// with no picture and an account whose picture would not load are the same +/// thing to a student, and neither is worth an error. +async fn inline_avatar(url: &str) -> Option { + // https only. A token claim is not a reason to make a plaintext request. + if !url.starts_with("https://") { + return None; + } + + let res = reqwest::Client::new().get(url).send().await.ok()?; + if !res.status().is_success() { + return None; + } + + // Trust the declared type only far enough to name it, and only if it is an + // image at all - this string goes into a `data:` URI. + let mime = res + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|v| v.split(';').next().unwrap_or("").trim().to_string()) + .filter(|v| matches!(v.as_str(), + "image/jpeg" | "image/png" | "image/webp" | "image/gif"))?; + + let bytes = res.bytes().await.ok()?; + if bytes.is_empty() || bytes.len() > MAX_AVATAR_BYTES { + return None; + } + + Some(format!("data:{mime};base64,{}", + base64::engine::general_purpose::STANDARD.encode(&bytes))) +} + +/// Build the session handed to the frontend, resolving the avatar if there is one. +async fn session_from(token: TokenResponse) -> Session { + let claims = token.id_token.as_deref().map(claims_of).unwrap_or_default(); + let avatar = match claims.picture.as_deref() { + Some(url) => inline_avatar(url).await, + None => None, + }; + + Session { + access_token: token.access_token, + expires_at: token.expires_in.map(|s| now_secs() + s), + name: claims.name, + email: claims.email, + avatar, + } +} + +/// Release a pending attempt's loopback port. +/// +/// Setting the flag alone is not enough: the listener thread is parked inside a +/// blocking `accept`, and nothing wakes it until a connection arrives. So one +/// is made, to itself, purely to let the loop come round and see the flag. The +/// connection is dropped immediately and carries no request. +fn release(pending: &Pending) { + pending.cancelled.store(true, Ordering::SeqCst); + let _ = std::net::TcpStream::connect(("127.0.0.1", pending.port)); +} + +/// Hand a URL to the operating system's default browser. +/// +/// Never through a shell. `cmd /c start` would parse the URL as a command line, +/// where an `&` alone changes what runs; `rundll32 url.dll,FileProtocolHandler` +/// takes the URL as one argument and hands it straight to the shell handler. +/// Best-effort by design: a machine with no registered browser is a machine +/// where the student uses the URL we also returned. +fn open_in_browser(url: &str) { + #[cfg(windows)] + let mut cmd = { + let mut c = std::process::Command::new("rundll32"); + c.args(["url.dll,FileProtocolHandler", url]); + c + }; + #[cfg(target_os = "macos")] + let mut cmd = { + let mut c = std::process::Command::new("open"); + c.arg(url); + c + }; + #[cfg(all(unix, not(target_os = "macos")))] + let mut cmd = { + let mut c = std::process::Command::new("xdg-open"); + c.arg(url); + c + }; + + let _ = cmd.spawn(); +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Percent-encode a query parameter value. +/// +/// Allow-list, not a deny-list. The unreserved set from RFC 3986 is escaped +/// through, everything else is encoded - so a character nobody thought about is +/// encoded rather than passed through into a URL. +fn urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +#[cfg(windows)] +fn vault_entry() -> Result { + keyring::Entry::new("TargetX-account", ACCOUNT_KEY).map_err(|e| e.to_string()) +} + +#[cfg(windows)] +fn vault_save(refresh: &str) -> Result<(), String> { + vault_entry()?.set_password(refresh).map_err(|e| e.to_string()) +} + +#[cfg(windows)] +fn vault_load() -> Option { + vault_entry().ok()?.get_password().ok() +} + +#[cfg(windows)] +fn vault_delete() -> Result<(), String> { + match vault_entry()?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(e.to_string()), + } +} + +// Same posture as `creds.rs`: the other platforms compile the commands as safe +// no-ops so the frontend can call them unconditionally and simply find that +// nothing was ever stored. A refresh token has nowhere safe to live here, and +// a file in the app directory is not an answer. +#[cfg(not(windows))] +fn vault_save(_: &str) -> Result<(), String> { + Err("Staying signed in is only available in the Windows build.".into()) +} + +#[cfg(not(windows))] +fn vault_load() -> Option { + None +} + +#[cfg(not(windows))] +fn vault_delete() -> Result<(), String> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn challenge_is_the_s256_of_the_verifier() { + // The worked example from RFC 7636 appendix B. If this drifts, every + // exchange fails at the provider with an opaque error. + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + assert_eq!( + challenge_for(verifier), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn query_parsing_decodes_escapes() { + let q = parse_query("/callback?code=a%2Bb&state=x%20y"); + assert_eq!(q.get("code").map(String::as_str), Some("a+b")); + assert_eq!(q.get("state").map(String::as_str), Some("x y")); + } + + #[test] + fn urlencode_escapes_everything_outside_the_unreserved_set() { + assert_eq!(urlencode("a b/c?d"), "a%20b%2Fc%3Fd"); + assert_eq!(urlencode("aZ0-._~"), "aZ0-._~"); + } + + #[test] + fn every_candidate_port_is_bindable_and_distinct() { + // A duplicate here would silently shrink the fallback list, and the + // registered redirect URIs in the provider would no longer match what + // the app can actually open. + let mut seen = CALLBACK_PORTS.to_vec(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!(seen.len(), CALLBACK_PORTS.len()); + // Dynamic/private range, so none of these is a registered service. + assert!(CALLBACK_PORTS.iter().all(|p| *p >= 49152)); + } + + #[test] + fn a_callback_with_neither_code_nor_error_is_not_a_result() { + let q = parse_query("/favicon.ico"); + assert!(q.get("code").is_none() && q.get("error").is_none()); + } +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 847df02..d155a11 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "TargetX", - "version": "0.2.0", + "version": "0.3.0", "identifier": "cv.codedrichy.targetx", "build": { "frontendDist": "../dist", diff --git a/app/src/engine/__tests__/absenceCost.test.ts b/app/src/engine/__tests__/absenceCost.test.ts new file mode 100644 index 0000000..6164f9b --- /dev/null +++ b/app/src/engine/__tests__/absenceCost.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { + ATTENDANCE_MARK_BANDS, ATTENDANCE_MARK_MAX, ATTENDANCE_MIN, + absenceCost, attendanceMarks, freeSkips, +} from "../index"; + +/** + * The cost of the next absence. + * + * Every figure asserted here is re-derived from the engine's own constants + * rather than copied out of a run, so a change to `ATTENDANCE_MARK_BANDS` or + * `ATTENDANCE_MIN` fails this file instead of leaving it quoting a rule KTU no + * longer has. + */ + +/** The percentage at which the full attendance mark starts. */ +const FULL_AT = Math.max(...ATTENDANCE_MARK_BANDS.map(([pct]) => pct)); + +describe("absenceCost prices a skip in marks, not only in percent", () => { + it("returns null without raw counts, because there is no denominator to move", () => { + expect(absenceCost(null, null)).toBeNull(); + expect(absenceCost(40, 0)).toBeNull(); + expect(absenceCost(40, null)).toBeNull(); + }); + + it("moves the denominator, not just the numerator", () => { + // 45 of 50 is 90%. One more class held and not attended is 45 of 51. + const cost = absenceCost(45, 50, 0, 1); + expect(cost!.before).toBeCloseTo(90, 2); + expect(cost!.after).toBeCloseTo((45 / 51) * 100, 2); + }); + + it("costs nothing when the skip stays inside the same band", () => { + // Well above the top band, so one absence cannot cross a step. + const cost = absenceCost(49, 50, 0, 1)!; + expect(cost.marksBefore).toBe(ATTENDANCE_MARK_MAX); + expect(cost.marksAfter).toBe(ATTENDANCE_MARK_MAX); + expect(cost.marksLost).toBe(0); + }); + + it("reports the mark a band crossing takes, for a student every portal calls fine", () => { + // Sits just above the full-marks line, which no other system mentions. + const held = 100; + const attended = Math.ceil((FULL_AT / 100) * held); + const cost = absenceCost(attended, held, 0, 1)!; + + expect(cost.before).toBeGreaterThanOrEqual(FULL_AT); + expect(cost.after).toBeLessThan(FULL_AT); + // Both sides agree with the band table itself - this test does not carry a + // second opinion about what a percentage is worth. + expect(cost.marksBefore).toBe(attendanceMarks(cost.before)); + expect(cost.marksAfter).toBe(attendanceMarks(cost.after)); + expect(cost.marksLost).toBeGreaterThan(0); + // And they are still eligible throughout, which is the whole point: the + // loss is invisible to any check that only asks about 75%. + expect(cost.eligibleBefore).toBe(true); + expect(cost.eligibleAfter).toBe(true); + }); + + it("reports the crossing of the eligibility floor", () => { + const held = 100; + const attended = ATTENDANCE_MIN; // exactly on the line at 100 held + const cost = absenceCost(attended, held, 0, 1)!; + expect(cost.eligibleBefore).toBe(true); + expect(cost.eligibleAfter).toBe(false); + }); + + it("never reports a negative loss, because missing a class cannot earn a mark", () => { + for (const attended of [10, 30, 44, 49, 50]) { + expect(absenceCost(attended, 50, 0, 3)!.marksLost).toBeGreaterThanOrEqual(0); + } + }); + + it("prices zero skips as no change at all", () => { + const cost = absenceCost(44, 50, 0, 0)!; + expect(cost.before).toBe(cost.after); + expect(cost.marksLost).toBe(0); + }); + + it("holds duty leave at today's credited value rather than growing it", () => { + // A larger `held` would mathematically allow more DL under the cap. The + // student is not acquiring duty leave by missing a class, and letting the + // cap rise would report the skip as cheaper than it is. + const withDl = absenceCost(40, 50, 5, 10)!; + const noDl = absenceCost(40, 50, 0, 10)!; + expect(withDl.after).toBeGreaterThan(noDl.after); + // The DL credited is bounded by today's cap, so the gap cannot widen with + // the extra held classes: both sides move down together. + expect(withDl.after).toBeLessThan(withDl.before); + }); + + it("scales with the course's own attendance maximum", () => { + const five = absenceCost(80, 100, 0, 20, 5)!; + const ten = absenceCost(80, 100, 0, 20, 10)!; + expect(ten.marksBefore).toBeCloseTo(five.marksBefore * 2, 2); + }); +}); + +describe("freeSkips is the budget that binds first", () => { + it("is null without raw counts", () => { + expect(freeSkips(null, null)).toBeNull(); + }); + + it("counts the absences that cost no mark at all", () => { + const n = freeSkips(45, 50)!; + // The claim, checked against absenceCost rather than asserted: n costs + // nothing and n+1 costs something. + expect(absenceCost(45, 50, 0, n)!.marksLost).toBe(0); + expect(absenceCost(45, 50, 0, n + 1)!.marksLost).toBeGreaterThan(0); + }); + + it("is zero for a student already on a band edge", () => { + const held = 100; + const attended = Math.ceil((FULL_AT / 100) * held); + expect(freeSkips(attended, held)).toBe(0); + }); + + it("is never more permissive than the eligibility budget it sits inside", () => { + // The marks line is at or above the eligibility line, so the number of + // absences that cost no mark can never exceed the number that keep the + // student eligible. If it did, the app would be telling a student a skip + // is free while it bars them from the exam. + for (const [attended, held] of [[45, 50], [80, 100], [38, 50], [90, 100]]) { + const free = freeSkips(attended!, held!)!; + const cost = absenceCost(attended!, held!, 0, free)!; + expect(cost.eligibleAfter).toBe(true); + } + }); +}); diff --git a/app/src/engine/__tests__/daywise.test.ts b/app/src/engine/__tests__/daywise.test.ts new file mode 100644 index 0000000..a260108 --- /dev/null +++ b/app/src/engine/__tests__/daywise.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { daywiseBySubject } from "../index"; +import type { AttendanceStatus, DaywiseAttendance } from "../types"; + +/** + * The day-by-day log, counted per subject. + * + * This is the second opinion on a figure the app previously took on trust. The + * portal publishes a percentage AND a period log; they should agree, and until + * now nothing compared them. + */ + +const day = (...periods: Array<[AttendanceStatus, string | null]>): DaywiseAttendance[number] => ({ + label: "1st", + periods: periods.map(([status, subject]) => ({ status, subject })), +}); + +describe("daywiseBySubject counts the way the regulation counts", () => { + it("is empty for an empty log", () => { + expect(daywiseBySubject([]).size).toBe(0); + }); + + it("counts a present period as attended and as held", () => { + const out = daywiseBySubject([day(["present", "CST303 Computer Networks"])]); + expect(out.get("CST303 Computer Networks")).toEqual({ attended: 1, held: 1 }); + }); + + it("counts an absence as held but not attended", () => { + const out = daywiseBySubject([day(["absent", "CST303"])]); + expect(out.get("CST303")).toEqual({ attended: 0, held: 1 }); + }); + + it("excludes holidays and empty periods from held, because they never happened", () => { + const out = daywiseBySubject([day( + ["present", "CST303"], ["holiday", "CST303"], ["none", "CST303"], + )]); + // One class ran. A holiday is not a class anybody missed. + expect(out.get("CST303")).toEqual({ attended: 1, held: 1 }); + }); + + it("credits on-duty and duty leave as attended, the way the portal does", () => { + for (const status of ["od", "dutyleave", "duty"] as AttendanceStatus[]) { + const out = daywiseBySubject([day([status, "CST303"])]); + // The class ran and the student was excused: held, and not against them. + expect(out.get("CST303")).toEqual({ attended: 1, held: 1 }); + } + }); + + it("counts a plain leave as an absence", () => { + // An approved leave arrives as one of the credited statuses. A bare + // "leave" is the student not being there, and treating it as neutral would + // report a rosier figure than the portal's own. + const out = daywiseBySubject([day(["leave", "CST303"])]); + expect(out.get("CST303")).toEqual({ attended: 0, held: 1 }); + }); + + it("ignores periods with no subject named", () => { + const out = daywiseBySubject([day(["present", null], ["absent", " "])]); + expect(out.size).toBe(0); + }); + + it("accumulates one subject across many days", () => { + const log = [ + day(["present", "CST303"], ["absent", "CST305"]), + day(["present", "CST303"], ["present", "CST305"]), + day(["absent", "CST303"], ["holiday", "CST305"]), + ]; + const out = daywiseBySubject(log); + expect(out.get("CST303")).toEqual({ attended: 2, held: 3 }); + expect(out.get("CST305")).toEqual({ attended: 1, held: 2 }); + }); + + it("trims the printed subject so one subject is not counted twice", () => { + const out = daywiseBySubject([day(["present", " CST303 "], ["absent", "CST303"])]); + expect(out.size).toBe(1); + expect(out.get("CST303")).toEqual({ attended: 1, held: 2 }); + }); + + it("never reports more attended than held", () => { + const statuses: AttendanceStatus[] = + ["present", "absent", "od", "dutyleave", "duty", "leave", "holiday", "none"]; + const out = daywiseBySubject([day( + ...statuses.map((s): [AttendanceStatus, string] => [s, "CST303"]), + )]); + const tally = out.get("CST303")!; + expect(tally.attended).toBeLessThanOrEqual(tally.held); + }); +}); diff --git a/app/src/engine/attendance.ts b/app/src/engine/attendance.ts index 5168d04..12253b6 100644 --- a/app/src/engine/attendance.ts +++ b/app/src/engine/attendance.ts @@ -1,7 +1,10 @@ import { ATTENDANCE_MARK_BANDS, ATTENDANCE_MARK_MAX, ATTENDANCE_MIN, DL_CAP_PCT, } from "./constants"; -import type { AttendanceBand, AttendancePlan, Course, MarkInput } from "./types"; +import type { + AbsenceCost, AttendanceBand, AttendancePlan, Course, DaywiseAttendance, + MarkInput, +} from "./types"; import { ceil, clamp, floor, round, toFloat, toOptionalFloat } from "./util"; /** CIE marks earned by attendance alone, per R 7.5.ii. */ @@ -208,3 +211,133 @@ export function effectiveAttendance( const stated = toOptionalFloat(course.attendance); return stated === null ? null : clamp(stated, 0, 100); } + +/** + * What the next absence actually costs. + * + * The question this app exists to answer, and the one every other system + * refuses: not "am I above 75%" but "if I skip tomorrow, what does it take from + * me". A student at 86% is told by every portal that they are fine. Missing two + * classes puts them at 84% and costs a CIE mark under R 7.5.ii, and nothing + * anywhere tells them that until the mark is gone. + * + * Composition, not new arithmetic: the percentage after `n` more absences is + * the same effective figure the rest of the engine uses, recomputed with the + * classes added to `held` and none of them attended, and the marks on each side + * come from `attendanceMarks`. There is deliberately no second opinion here + * about what a percentage is worth. + * + * Duty leave is NOT re-credited against the larger denominator. `creditDutyLeave` + * caps DL at a fraction of held, so a bigger `held` could mathematically allow + * more DL to count - but the student is not acquiring new duty leave by missing + * a class, and letting the cap rise here would report a skip as cheaper than it + * is. The credited figure is held at today's value. + * + * Returns null when the portal gave no raw counts. A percentage alone cannot + * answer this: without `held` there is no denominator to move. + */ +export function absenceCost( + attendedIn: MarkInput, heldIn: MarkInput, dutyLeave: MarkInput = 0, + skips: number = 1, + maxMarks: number = ATTENDANCE_MARK_MAX, + floorPct: number = ATTENDANCE_MIN, + dlCapPct: number = DL_CAP_PCT, +): AbsenceCost | null { + const attended = toOptionalFloat(attendedIn); + const held = toOptionalFloat(heldIn); + if (attended === null || held === null || held <= 0) return null; + const n = Math.max(0, Math.floor(skips)); + + const { credited } = creditDutyLeave(held, dutyLeave, dlCapPct); + const pctAt = (extra: number) => { + const denominator = held + extra; + if (denominator <= 0) return 0; + return round((Math.min(attended + credited, denominator) / denominator) * 100, 2); + }; + + const before = pctAt(0); + const after = pctAt(n); + const marksBefore = attendanceMarks(before, maxMarks) ?? 0; + const marksAfter = attendanceMarks(after, maxMarks) ?? 0; + + return { + skips: n, + before, after, + marksBefore, marksAfter, + marksLost: round(Math.max(0, marksBefore - marksAfter), 2), + eligibleBefore: before >= floorPct, + eligibleAfter: after >= floorPct, + }; +} + +/** + * The largest number of absences that costs nothing. + * + * Distinct from `AttendancePlan.skip`, which solves for eligibility alone and + * so answers "before I am barred from the exam". This solves for the marks: the + * last skip that leaves the CIE attendance mark untouched. For most students + * the two are far apart, and the smaller one is the one that binds first. + * + * Bounded rather than closed-form because the band table is a list of steps, + * not a formula, and a loop over it cannot disagree with `attendanceMarks` the + * way a re-derivation could. `held` is the bound: skipping every remaining + * class is the most anyone can do. + */ +export function freeSkips( + attendedIn: MarkInput, heldIn: MarkInput, dutyLeave: MarkInput = 0, + maxMarks: number = ATTENDANCE_MARK_MAX, + dlCapPct: number = DL_CAP_PCT, +): number | null { + const first = absenceCost(attendedIn, heldIn, dutyLeave, 0, maxMarks, ATTENDANCE_MIN, dlCapPct); + if (first === null) return null; + const held = toFloat(heldIn); + let n = 0; + while (n < held) { + const next = absenceCost(attendedIn, heldIn, dutyLeave, n + 1, maxMarks, + ATTENDANCE_MIN, dlCapPct); + if (next === null || next.marksAfter < first.marksBefore) break; + n += 1; + } + return n; +} + +/** + * Attendance counted per subject from the day-by-day record. + * + * The portal publishes two things that should agree: a percentage per subject, + * and a period-by-period log of what happened. TargetX has always shown both + * and never compared them - so a portal that miscounts, or a student who was + * marked absent for a class they attended, produced a figure with no way to + * check it. This is the second opinion. + * + * Counted the way the regulation counts: `held` excludes holidays and empty + * periods, because a class that never happened is not one anybody missed. + * Credited statuses (on duty, duty leave) count as HELD and as attended, since + * the class ran and the student was excused - which is exactly how the portal's + * own percentage treats them, so the two figures stay comparable. + * + * `leave` is deliberately counted as an absence. A leave a college has approved + * arrives as one of the credited statuses; a plain "leave" is the student not + * being there, and treating it as neutral would report a rosier figure than the + * portal's own. + */ +export function daywiseBySubject( + days: DaywiseAttendance, +): Map { + const out = new Map(); + for (const day of days) { + for (const period of day.periods) { + const subject = period.subject?.trim(); + if (!subject) continue; + if (period.status === "holiday" || period.status === "none") continue; + const row = out.get(subject) ?? { attended: 0, held: 0 }; + row.held += 1; + if (period.status === "present" || period.status === "od" + || period.status === "dutyleave" || period.status === "duty") { + row.attended += 1; + } + out.set(subject, row); + } + } + return out; +} diff --git a/app/src/engine/history.ts b/app/src/engine/history.ts index 982a2a7..615c6d4 100644 --- a/app/src/engine/history.ts +++ b/app/src/engine/history.ts @@ -19,8 +19,15 @@ export const HISTORY_RANK: Record = { etlab: 0, }; -/** Two SGPAs are "the same" within the precision either source prints. */ -const sameSgpa = (a: number, b: number): boolean => Math.abs(a - b) < 0.005; +/** + * Two SGPAs are "the same" within the precision either source prints. + * + * Exported because `setHistory` asks the same question when it decides whether + * a hand edit changed the figure or only the credits beside it. A second copy + * of this tolerance would be a second definition of "the student changed it", + * and the two would drift. + */ +export const sameSgpa = (a: number, b: number): boolean => Math.abs(a - b) < 0.005; /** * Fold one incoming history figure onto whatever is already stored for that diff --git a/app/src/engine/types.ts b/app/src/engine/types.ts index 2e3dd04..583ab8d 100644 --- a/app/src/engine/types.ts +++ b/app/src/engine/types.ts @@ -108,6 +108,29 @@ export interface AttendanceBand { atPct: number | null; } +/** + * What missing `skips` more classes would do to one course. + * + * Both sides are carried rather than only the difference, because the sentence + * a student needs is "86% and 4 marks becomes 84% and 3", not "you lose 1". A + * bare delta is the kind of figure that cannot show its working. + */ +export interface AbsenceCost { + /** How many further absences this priced. */ + skips: number; + /** Effective attendance now, and after those absences. */ + before: number; + after: number; + /** CIE marks earned by attendance, on each side (R 7.5.ii). */ + marksBefore: number; + marksAfter: number; + /** Never negative: missing a class cannot earn a mark. */ + marksLost: number; + /** Whether the course clears the eligibility floor, on each side. */ + eligibleBefore: boolean; + eligibleAfter: boolean; +} + export interface Evaluation { cie: number; cieMax: number; diff --git a/app/src/state/__tests__/auth.test.ts b/app/src/state/__tests__/auth.test.ts new file mode 100644 index 0000000..c6b2d5b --- /dev/null +++ b/app/src/state/__tests__/auth.test.ts @@ -0,0 +1,111 @@ +/** + * The account. + * + * What matters here is not the happy path - the flow itself lives in Rust and + * is exercised there. What matters is that this module cannot become a way to + * lock a student out of their own marks, and that it never hands the Worker a + * token that is about to expire. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const invoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ invoke: (...a: unknown[]) => invoke(...a) })); +vi.mock("../../sync/etlab", () => ({ canSync: () => true })); + +// Both are read at module load, so they have to be in place before the import. +// Neither is a secret: the issuer is in every token this app receives, and a +// PKCE client id is public by design - that is what PKCE is for. +vi.stubEnv("VITE_CLERK_ISSUER", "https://example.clerk.accounts.dev"); +vi.stubEnv("VITE_CLERK_CLIENT_ID", "client_test"); + +const { + accessToken, authConfigured, authError, resumeAccount, signIn, signOut, signedIn, +} = await import("../auth"); + +const soon = (secs: number) => Math.floor(Date.now() / 1000) + secs; + +beforeEach(async () => { + vi.clearAllMocks(); + invoke.mockResolvedValue(null); + await signOut(); + vi.clearAllMocks(); +}); + +describe("configuration", () => { + it("is on when the build was given a provider", () => { + expect(authConfigured()).toBe(true); + }); +}); + +describe("token freshness", () => { + it("withholds a token that expires within the minute", async () => { + invoke.mockImplementation((cmd: string) => + cmd === "oauth_begin" ? { authorize_url: "x" } + : { access_token: "tok", expires_at: soon(30) }); + await signIn(); + + expect(signedIn()).toBe(true); + // Signed in, but this token would 401 at the edge mid-flight, and a 401 + // there looks like a bug rather than an expiry. + expect(accessToken()).toBeNull(); + }); + + it("hands over a token with room to spare", async () => { + invoke.mockImplementation((cmd: string) => + cmd === "oauth_begin" ? { authorize_url: "x" } + : { access_token: "tok", expires_at: soon(600) }); + await signIn(); + expect(accessToken()).toBe("tok"); + }); + + it("trusts a provider that declined to give an expiry", async () => { + invoke.mockImplementation((cmd: string) => + cmd === "oauth_begin" ? { authorize_url: "x" } + : { access_token: "tok", expires_at: null }); + await signIn(); + expect(accessToken()).toBe("tok"); + }); +}); + +describe("failure never locks anyone out", () => { + it("reports a refused sign-in and leaves the app usable", async () => { + invoke.mockImplementation((cmd: string) => { + if (cmd === "oauth_begin") return { authorize_url: "x" }; + throw new Error("Sign-in timed out. The browser never came back."); + }); + + await expect(signIn()).resolves.toBeNull(); + expect(signedIn()).toBe(false); + expect(authError()).toMatch(/timed out/); + }); + + it("says nothing at launch when there is no stored account", async () => { + invoke.mockResolvedValue(null); + expect(await resumeAccount()).toBeNull(); + // Not signed in is a normal state. Nagging on every launch is not. + expect(authError()).toBeNull(); + }); + + it("stays quiet when a launch resume throws", async () => { + invoke.mockRejectedValue(new Error("vault locked")); + expect(await resumeAccount()).toBeNull(); + expect(authError()).toBeNull(); + }); +}); + +describe("signing out", () => { + it("drops the in-memory token even when the vault refuses", async () => { + invoke.mockImplementation((cmd: string) => + cmd === "oauth_begin" ? { authorize_url: "x" } + : { access_token: "tok", expires_at: soon(600) }); + await signIn(); + expect(accessToken()).toBe("tok"); + + invoke.mockRejectedValue(new Error("vault locked")); + await signOut(); + + // The token in this process is the one that could still be used. + expect(signedIn()).toBe(false); + expect(accessToken()).toBeNull(); + }); +}); diff --git a/app/src/state/__tests__/autosync.test.ts b/app/src/state/__tests__/autosync.test.ts new file mode 100644 index 0000000..6da4138 --- /dev/null +++ b/app/src/state/__tests__/autosync.test.ts @@ -0,0 +1,184 @@ +/** + * Refreshing both portals. + * + * The preconditions matter more than the happy path. This runs without anybody + * asking for it, using passwords out of the OS vault, against two colleges' + * servers - so the cases worth pinning are the ones where it must decline to + * run, the guarantee that one portal cannot sink the other, and the guarantee + * that nothing ever throws into an `onMount` that has no catch around it. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const canSync = vi.fn(() => true); +const canSyncKtu = vi.fn(() => true); +const fullSync = vi.fn(); +const syncKtu = vi.fn(); +const canRemember = vi.fn(() => true); +const loadCreds = vi.fn(); +const applySync = vi.fn(); + +vi.mock("../../sync/etlab", () => ({ + canSync: () => canSync(), + fullSync: (...a: unknown[]) => fullSync(...a), + EtlabError: class extends Error {}, +})); +vi.mock("../../sync/ktu", () => ({ canSyncKtu: () => canSyncKtu() })); +vi.mock("../creds", () => ({ + KTU_CRED_KEY: "https://app.ktu.edu.in", + canRemember: () => canRemember(), + loadCreds: (...a: unknown[]) => loadCreds(...a), +})); +vi.mock("../actions", () => ({ + applySync: (...a: unknown[]) => applySync(...a), + syncKtu: (...a: unknown[]) => syncKtu(...a), +})); + +const { + AUTO_SYNC_GAP_MS, autoRefresh, refreshAll, refreshFailures, syncIsStale, +} = await import("../autosync"); +const { edit } = await import("../store"); + +const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); +const of = (r: { results: Array<{ source: string; status: string }> }, source: string) => + r.results.find((x) => x.source === source)!; + +beforeEach(() => { + vi.clearAllMocks(); + canSync.mockReturnValue(true); + canSyncKtu.mockReturnValue(true); + canRemember.mockReturnValue(true); + loadCreds.mockResolvedValue({ username: "24CS211", password: "secret" }); + fullSync.mockResolvedValue({ semesters: {}, history: {} }); + syncKtu.mockResolvedValue({ fetched: ["S4"], courses: 7, semesters: 1, mismatched: [] }); + edit((s) => { + s.student.college = "https://portal.example.edu"; + s.lastSync = ago(AUTO_SYNC_GAP_MS + 60_000); + }); +}); + +describe("preconditions", () => { + it("does nothing in a build that cannot reach either portal", async () => { + canSync.mockReturnValue(false); + canSyncKtu.mockReturnValue(false); + const r = await refreshAll(); + + expect(of(r, "etlab").status).toBe("unavailable"); + expect(of(r, "ktu").status).toBe("unavailable"); + expect(loadCreds).not.toHaveBeenCalled(); + }); + + it("does not touch etlab before a college address is configured", async () => { + edit((s) => { s.student.college = ""; }); + const r = await refreshAll(); + + expect(of(r, "etlab").status).toBe("unavailable"); + expect(fullSync).not.toHaveBeenCalled(); + // KTU has its own fixed address and is unaffected by that gap. + expect(of(r, "ktu").status).toBe("ok"); + }); + + it("never prompts and never syncs a portal with no saved login", async () => { + loadCreds.mockResolvedValue(null); + const r = await refreshAll(); + + expect(of(r, "etlab").status).toBe("no-creds"); + expect(of(r, "ktu").status).toBe("no-creds"); + expect(fullSync).not.toHaveBeenCalled(); + expect(syncKtu).not.toHaveBeenCalled(); + }); + + it("treats a vault that will not answer as simply absent", async () => { + loadCreds.mockRejectedValue(new Error("vault locked")); + const r = await refreshAll(); + + // Not "failed": the student never asked for this, and the answer either way + // is that there is nothing to log in with. + expect(of(r, "etlab").status).toBe("no-creds"); + expect(refreshFailures()).toEqual([]); + }); + + it("reads each portal's login under its own vault key", async () => { + await refreshAll(); + const keys = loadCreds.mock.calls.map((c) => c[0]); + expect(keys).toContain("https://portal.example.edu"); + expect(keys).toContain("https://app.ktu.edu.in"); + }); +}); + +describe("independence", () => { + it("still refreshes attendance when KTU is down", async () => { + syncKtu.mockRejectedValue(new Error("KTU login rejected.")); + const r = await refreshAll(); + + expect(of(r, "etlab").status).toBe("ok"); + expect(applySync).toHaveBeenCalledOnce(); + expect(of(r, "ktu").status).toBe("failed"); + }); + + it("still fetches results when etlab is down", async () => { + fullSync.mockRejectedValue(new Error("etlab is down")); + const r = await refreshAll(); + + expect(of(r, "ktu").status).toBe("ok"); + expect(syncKtu).toHaveBeenCalledOnce(); + expect(of(r, "etlab").status).toBe("failed"); + }); + + it("reports one row per failed portal, never a merged failure", async () => { + fullSync.mockRejectedValue(new Error("etlab is down")); + syncKtu.mockRejectedValue(new Error("KTU login rejected.")); + await refreshAll(); + + expect(refreshFailures().map((f) => f.source).sort()).toEqual(["etlab", "ktu"]); + expect(refreshFailures().map((f) => f.detail)) + .toEqual(expect.arrayContaining(["etlab is down", "KTU login rejected."])); + }); + + it("never throws, because onMount has nothing to catch it", async () => { + fullSync.mockRejectedValue(new Error("etlab is down")); + syncKtu.mockRejectedValue(new Error("KTU login rejected.")); + await expect(refreshAll()).resolves.toBeDefined(); + }); + + it("clears an earlier failure once a later run works", async () => { + fullSync.mockRejectedValue(new Error("etlab is down")); + await refreshAll(); + expect(refreshFailures()).toHaveLength(1); + + fullSync.mockResolvedValue({ semesters: {}, history: {} }); + await refreshAll(); + expect(refreshFailures()).toEqual([]); + }); +}); + +describe("throttling", () => { + it("skips an automatic refresh that just happened", async () => { + edit((s) => { s.lastSync = ago(60_000); }); + expect(await autoRefresh()).toBeNull(); + expect(fullSync).not.toHaveBeenCalled(); + expect(syncKtu).not.toHaveBeenCalled(); + }); + + it("runs automatically once the gap has passed", async () => { + expect(await autoRefresh()).not.toBeNull(); + expect(fullSync).toHaveBeenCalledOnce(); + }); + + it("does not throttle a manual refresh - the student just pressed it", async () => { + edit((s) => { s.lastSync = ago(60_000); }); + const r = await refreshAll(); + + expect(of(r, "etlab").status).toBe("ok"); + expect(fullSync).toHaveBeenCalledOnce(); + }); + + it("treats a record that was never synced as stale", () => { + edit((s) => { s.lastSync = undefined; }); + expect(syncIsStale()).toBe(true); + }); + + it("treats an unparseable timestamp as never synced rather than as fresh", () => { + edit((s) => { s.lastSync = "not a date"; }); + expect(syncIsStale()).toBe(true); + }); +}); diff --git a/app/src/state/__tests__/detectTopic.test.ts b/app/src/state/__tests__/detectTopic.test.ts new file mode 100644 index 0000000..c7fab41 --- /dev/null +++ b/app/src/state/__tests__/detectTopic.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { detectTopic } from "../answers"; + +/** + * Which question shape a phrasing is, decided locally. + * + * This runs before the model and before the subject matcher, and it is allowed + * to say nothing. Saying nothing routes the student to a screen, which is a + * worse answer but never a wrong one - so every case here is judged against the + * standard that a WRONG topic is worse than no topic. + */ + +describe("a question about the rule is not a question about the student", () => { + it.each([ + "how is sgpa calculated", + "explain cgpa", + "what is the pass mark", + "how does attendance affect marks", + "how do they calculate the percentage", + ])("declines %j", (q) => { + // "how is sgpa calculated" contains "sgpa" and was answered with the + // student's own CGPA - a confident reply to a question nobody asked. A + // route at least lands somewhere the real answer might be. + expect(detectTopic(q)).toBeNull(); + }); + + it("still answers the same words when they are about this student", () => { + // The test is possessive, not topical: the regulation question and the + // personal one share almost all their vocabulary. + expect(detectTopic("how badly would it affect my attendance")).not.toBeNull(); + expect(detectTopic("whats my cgpa")).toBe("standing"); + }); +}); + +describe("the forward-looking attendance questions", () => { + it.each([ + ["can i skip tomorrow", "tomorrow"], + ["if i take a leave tommorow", "tomorrow"], + ["what happens if i miss one more", "skip_cost"], + ["how many classes can i miss", "budget"], + ["am i eligible for the exam", "eligibility"], + ["will i get debarred", "eligibility"], + ])("reads %j as %s", (q, topic) => { + expect(detectTopic(q)).toBe(topic); + }); +}); + +describe("the plainest question, which used to fall through", () => { + it.each([ + "whats my attendance in cn", + "how many classes have i attended", + "my attendance", + ])("answers %j rather than routing it", (q) => { + // The detector only fired on forward-looking words, so "can i miss one" + // was answered outright and "what is my attendance" opened a screen. + expect(detectTopic(q)).toBe("attendance_now"); + }); +}); + +describe("marks and standing", () => { + it.each([ + ["what do i need in the final to pass cn", "need_to_pass"], + ["how many marks do i need for an A", "need_to_pass"], + ["am i failing anything", "need_to_pass"], + ["whats my cgpa", "standing"], + ["what sgpa do i need this sem", "standing"], + ["will i get a first class", "standing"], + ])("reads %j as %s", (q, topic) => { + expect(detectTopic(q)).toBe(topic); + }); + + it("prefers the exam question over the standing one when both words appear", () => { + // "what sgpa do i need to pass" is about the target, not about one paper; + // the ordering in `detectTopic` is what decides this and it is asserted + // here so a reorder does not silently change the answer. + expect(detectTopic("what do i need to pass")).toBe("need_to_pass"); + }); +}); + +describe("it says nothing rather than guessing", () => { + it.each([ + "what is the capital of france", + "write my assignment", + "who teaches this", + "", + " ", + ])("declines %j", (q) => { + expect(detectTopic(q)).toBeNull(); + }); +}); diff --git a/app/src/state/__tests__/glossary.test.ts b/app/src/state/__tests__/glossary.test.ts new file mode 100644 index 0000000..ae6d8e3 --- /dev/null +++ b/app/src/state/__tests__/glossary.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { + ATTENDANCE_CONDONE, ATTENDANCE_MARK_BANDS, ATTENDANCE_MARK_MAX, + ATTENDANCE_MIN, DL_CAP_PCT, ESE_PASS_FRACTION, +} from "../../engine"; +import { ALL_FACTS, CAPABILITIES, TERMS, lookupCapability, lookupTerm } from "../glossary"; +import { defineFor, detectTopic } from "../answers"; + +/** + * The definitions, and the misfires they replaced. + * + * "What is condonation" contains no explainer verb, so the topic detector read + * it as an eligibility question and answered with the student's own attendance + * budget - a confident reply to a question nobody asked. These assert both + * halves: the definition is returned, and the wrong figure is not. + */ + +describe("a definition is returned for a question about a word", () => { + it.each([ + ["what is condonation", "Condonation"], + ["what is duty leave", "Duty leave"], + ["what does ese mean", "ESE"], + ["what is cie", "CIE"], + ["whats the difference between sgpa and cgpa", "SGPA and CGPA"], + ["explain debarred", "Debarred"], + ])("answers %j with %s", (q, name) => { + expect(defineFor(q)?.headline).toBe(name); + }); + + it("prefers the longest matching alias", () => { + // "duty leave" must not lose to "dl", and "sgpa and cgpa" must not lose to + // the bare "sgpa" entry. + expect(lookupTerm("what is duty leave")?.name).toBe("Duty leave"); + expect(lookupTerm("what is the difference between sgpa and cgpa")?.name) + .toBe("SGPA and CGPA"); + }); + + it("marks a definition as one, so it is not read as a computed figure", () => { + // "Condonation is available down to 60%" is the regulation. Presented like + // the student's own numbers it would read as something worked out for them. + expect(defineFor("what is condonation")?.isDefinition).toBe(true); + }); +}); + +describe("a question about the student is not a definition", () => { + it.each([ + "what is my attendance", + "whats my cgpa", + "what do i need to pass", + "how many classes can i miss", + ])("declines %j so the engine answers it instead", (q) => { + // One word apart from a definitional question, and a completely different + // kind of answer. + expect(defineFor(q)).toBeNull(); + expect(detectTopic(q)).not.toBeNull(); + }); + + it("returns nothing for a word the glossary does not hold", () => { + expect(defineFor("what is a placement drive")).toBeNull(); + expect(defineFor("what is the capital of france")).toBeNull(); + }); +}); + +describe("the misfires are dead", () => { + it.each([ + "what is duty leave", + "what is condonation", + ])("no longer answers %j with a figure about the student", (q) => { + // Both used to return an attendance budget. The definition path claims + // them first, and the topic detector must not be reached. + expect(defineFor(q)).not.toBeNull(); + }); +}); + +describe("every figure quoted is the constant the engine calculates with", () => { + const body = (name: string) => TERMS.find((t) => t.name === name)!.body; + + it("quotes the eligibility floor rather than a literal", () => { + expect(body("Shortage")).toContain(`${ATTENDANCE_MIN}%`); + expect(body("Condonation")).toContain(`${ATTENDANCE_CONDONE}%`); + }); + + it("quotes the duty leave cap rather than a literal", () => { + expect(body("Duty leave")).toContain(`${DL_CAP_PCT}%`); + }); + + it("quotes the attendance band table rather than a literal", () => { + const text = body("Attendance marks"); + expect(text).toContain(`${ATTENDANCE_MARK_MAX} CIE marks`); + // The top band's percentage and its mark, read off the table itself - the + // drawer used to write "85% earns 5" out by hand in the one panel whose + // job is teaching the rule. + expect(text).toContain(`${ATTENDANCE_MARK_BANDS[0]![0]}% earns ${ATTENDANCE_MARK_BANDS[0]![1]}`); + }); + + it("quotes the separate ESE minimum rather than a literal", () => { + const pct = Math.round(ESE_PASS_FRACTION * 100); + expect(TERMS.some((t) => t.name.includes(`${pct}%`))).toBe(true); + }); + + it("has no term whose aliases are empty, since it could never be found", () => { + for (const term of TERMS) expect(term.aliases.length).toBeGreaterThan(0); + }); +}); + +/** + * Questions about the app, which are the ones nothing could answer. + * + * These shipped once with no test and were dead on arrival: a heredoc turned + * every `\b` in the HOWTO patterns into a literal backspace byte, so the regexes + * read as `/^Hhow (?:do|can|does|often)^H/` and matched nothing at all. The file + * looked correct in every editor, because a terminal renders 0x08 as nothing. + * Six capability entries, reachable by no phrasing, and the only thing that + * would have caught it is an assertion that a real question finds one. + */ +describe("a question about the app is answered by the app", () => { + it.each([ + ["what can this app do", "What TargetX does"], + ["where does this data come from", "Where the data comes from"], + ["how do i import my grade card", "Importing a grade card"], + ["is my password stored", "Whether your password is stored"], + ["how do i back up my data", "Backing up"], + ["how often does it sync", "How often it syncs"], + ])("answers %j with %s", (q, name) => { + expect(defineFor(q)?.headline).toBe(name); + }); + + it("is reached by a possessive phrasing, which the term lookup refuses", () => { + // "How do I import MY grade card" is first-person and is still not a + // question about this student's record. `lookupTerm` rejects it twice over + // - not definitional, and possessive - which is why capabilities need + // their own matcher rather than a looser guard on the shared one. + expect(lookupTerm("how do i import my grade card")).toBeNull(); + expect(lookupCapability("how do i import my grade card")).not.toBeNull(); + }); + + it("every capability is reachable by at least one of its own aliases", () => { + // The assertion the backspace bug needed. A phrasing built from the entry + // itself, so adding a capability with an unmatchable alias fails here. + for (const cap of CAPABILITIES) { + const alias = cap.aliases[0]!; + expect(lookupCapability(`what is ${alias}`), cap.name).not.toBeNull(); + } + }); + + it("declines a question that is not about the app", () => { + expect(lookupCapability("what is the capital of france")).toBeNull(); + expect(lookupCapability("import")).toBeNull(); // No question in it at all. + }); + + it("does not let a capability claim a question about the student", () => { + // "what is my attendance" reads as a HOWTO phrasing and must still reach + // the engine, or a student asking for a figure gets a paragraph. + expect(defineFor("what is my attendance")).toBeNull(); + expect(detectTopic("what is my attendance")).toBe("attendance_now"); + }); + + it("has no fact whose body is shorter than a sentence", () => { + for (const fact of ALL_FACTS) { + expect(fact.body.length, fact.name).toBeGreaterThan(40); + } + }); +}); diff --git a/app/src/state/__tests__/upgrade.test.ts b/app/src/state/__tests__/upgrade.test.ts new file mode 100644 index 0000000..e18975e --- /dev/null +++ b/app/src/state/__tests__/upgrade.test.ts @@ -0,0 +1,151 @@ +// @vitest-environment jsdom +/** + * A record written by an older build still opens. + * + * This is the only defect class in the app with no undo. A student who has + * carried four semesters since v0.1.0 installs an update, and if the newer + * build cannot read what the older one wrote, the honest outcomes are a crash + * on launch or a silently empty record - and the update is automatic, so they + * did not choose the moment. + * + * The payload below is the shape v0.1.0 actually wrote, read off the `AppState` + * interface at that tag rather than assumed: `version`, `scheme`, `student`, + * `activeSemester`, `etlab`, `semesters`, `history`, and optional `goal`, + * `onboarded`, `theme` and `lastSync`. Everything the app has grown since - + * `timetable`, `daywiseAttendance`, `changes` - is absent, because in a real + * v0.1.0 file it is absent. + * + * The assertion is not "it does not throw". It is that the numbers are still + * the same numbers. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const KEY = "targetx.state.v1"; + +/** Exactly what a v0.1.0 build wrote, and nothing a later one added. */ +const V0_1_0 = { + version: 1, + scheme: "KTU 2024", + student: { name: "A Student", reg_no: "MUT24CS130", branch: "CSE", college: "MITS" }, + activeSemester: "S5", + etlab: {}, + semesters: { + S5: { + courses: [ + { + code: "PCCST501", name: "Computer Networks", credits: 4, type: "TH 40/60", + s1: 30, s2: 28, other: 8, attended: 44, held: 50, dl: 0, + }, + { + code: "PCCST503", name: "Compiler Design", credits: 4, type: "TH 40/60", + s1: 22, s2: 20, other: 6, attended: 33, held: 50, dl: 2, + }, + ], + }, + }, + history: { + S3: { sgpa: 8.2, creditsRegistered: 20, creditsEarned: 20, source: "gradecard", conflict: null }, + S4: { sgpa: 7.6, creditsRegistered: 22, creditsEarned: 22, source: "gradecard", conflict: null }, + }, + goal: { cgpa: 8.5 }, + onboarded: true, + theme: "dark", + lastSync: "2026-08-30T09:00:00.000Z", +}; + +async function open(payload: unknown) { + localStorage.clear(); + localStorage.setItem(KEY, JSON.stringify(payload)); + vi.resetModules(); + return await import("../store"); +} + +beforeEach(() => { localStorage.clear(); }); + +describe("a v0.1.0 record opens in this build", () => { + it("keeps every semester, subject and mark", async () => { + const { state } = await open(V0_1_0); + + expect(state.activeSemester).toBe("S5"); + expect(state.semesters["S5"]!.courses).toHaveLength(2); + + const cn = state.semesters["S5"]!.courses[0]!; + // Marks, not just presence. A record that loads with the subjects intact + // and the numbers zeroed is the same loss wearing a better face. + expect(cn.code).toBe("PCCST501"); + expect(cn.s1).toBe(30); + expect(cn.s2).toBe(28); + expect(cn.attended).toBe(44); + expect(cn.held).toBe(50); + }); + + it("keeps published history, which is the part that cannot be re-fetched", async () => { + const { state } = await open(V0_1_0); + expect(Object.keys(state.history).sort()).toEqual(["S3", "S4"]); + expect(state.history["S3"]!.sgpa).toBe(8.2); + expect(state.history["S4"]!.creditsRegistered).toBe(22); + }); + + it("keeps the CGPA target the student set", async () => { + const { state } = await open(V0_1_0); + // `goal` held only `cgpa` at v0.1.0 and holds a whole target set now. + // `normaliseTargets` fills the rest without moving the one that was set. + expect(state.goal?.cgpa).toBe(8.5); + }); + + it("fills in what did not exist yet without inventing data", async () => { + const { state } = await open(V0_1_0); + // Genuinely new since v0.1.0, so absent from the file. Each must arrive as + // "nothing yet" rather than as a value that reads like a fact about this + // student - the timetable especially, since Tex prices tomorrow off it. + expect(state.timetable).toBeUndefined(); + expect(state.changes).toBeUndefined(); + expect(state.daywiseAttendance).toBeUndefined(); + }); + + it("does not drag a returning student back through onboarding", async () => { + const { state } = await open(V0_1_0); + // `onboarded` DID exist at v0.1.0, so somebody upgrading has it set and + // must land in the app rather than in first-run setup. If it were ever + // dropped from a save, `needsSetup` would put onboarding in front of a + // student with two semesters of history already on file. + expect(state.onboarded).toBe(true); + const { needsSetup } = await import("../nav"); + expect(needsSetup()).toBe(false); + }); + + it("keeps the preferences a student had already chosen", async () => { + const { state } = await open(V0_1_0); + expect(state.theme).toBe("dark"); + expect(state.lastSync).toBe("2026-08-30T09:00:00.000Z"); + }); +}); + +describe("a damaged record does not take the app down with it", () => { + it("starts clean rather than throwing when the file is not JSON", async () => { + localStorage.clear(); + localStorage.setItem(KEY, "{ this is not json"); + vi.resetModules(); + const { state } = await import("../store"); + expect(state.semesters).toBeDefined(); + }); + + it("keeps the damaged payload so it can be recovered", async () => { + localStorage.clear(); + localStorage.setItem(KEY, "{ this is not json"); + vi.resetModules(); + await import("../store"); + // The student's response to "TargetX will not open" is to uninstall it, + // which is why the bad payload is set aside rather than overwritten. + expect(localStorage.getItem(`${KEY}.corrupt`)).toBe("{ this is not json"); + }); + + it("starts clean when the shape is wrong rather than half-loading it", async () => { + localStorage.clear(); + localStorage.setItem(KEY, JSON.stringify({ version: 1, scheme: "KTU 2024" })); + vi.resetModules(); + const { state } = await import("../store"); + // No `semesters` key at all: not a record this build can reason about. + expect(state.semesters).toBeDefined(); + }); +}); diff --git a/app/src/state/actions.ts b/app/src/state/actions.ts index cd2155b..74e6706 100644 --- a/app/src/state/actions.ts +++ b/app/src/state/actions.ts @@ -394,8 +394,14 @@ export function reportText(rows: Array<{ course: Course; ev: Record 0 + ? String(summary["sgpaConfirmed"]) : "-"; lines.push("", - `Confirmed SGPA ${summary["sgpaConfirmed"]} Projected ${summary["sgpaProjected"]}`, + `Confirmed SGPA ${confirmed} Projected ${summary["sgpaProjected"]}`, `Credits ${summary["creditsConfirmed"]} of ${summary["credits"]}`); return lines.join("\n"); } diff --git a/app/src/state/answers.ts b/app/src/state/answers.ts new file mode 100644 index 0000000..bec24e6 --- /dev/null +++ b/app/src/state/answers.ts @@ -0,0 +1,403 @@ +import { absenceCost, courseLabel, freeSkips, requiredEseCell } from "../engine"; +import type { Course, Evaluation } from "../engine"; +import { goalPlan, goalRequirement, overall, rows, state, summary, targets } from "./store"; +import type { View } from "./nav"; +import { lookupCapability, lookupTerm } from "./glossary"; + +/** + * What the assistant is called. + * + * It needed a name because it now says things rather than only opening screens, + * and an answer that appears with no attribution reads as the app asserting a + * fact rather than as something having worked it out. + * + * Tex is already inside TargetX, which is why it does not need explaining, and + * it is one syllable so it fits a button label without being shortened again. + * Every user-facing use of the name reads this constant - renaming it is a one + * line change, and no string anywhere spells it out. + */ +export const ASSISTANT = "Tex"; + +/** + * Answers, computed here, said in the box. + * + * The search box used to route: a question got you a screen, and the screen had + * the number on it somewhere. For "where are my marks" that is the right answer. + * For "if I skip tomorrow, how badly does it hurt" it is not an answer at all - + * it is a filing cabinet, and the student still has to do the arithmetic the app + * exists to do for them. + * + * THE MODEL STILL NEVER STATES A FIGURE. It names which question this is and + * which subject it is about - a topic and a code, both from fixed enums - and + * every number in the sentence below is computed by the engine on this machine + * from this student's own records. That is the same guarantee as before; what + * changed is that the answer is now shown where it was asked instead of two + * clicks away. + * + * A topic is only added here when the engine can already answer it exactly. + * There is deliberately no "general" topic and no free-text field: a question + * shape that has no solver behind it must fall through to a route, because the + * alternative is a sentence nobody can show the working for. + */ + +/** The question shapes the engine can answer outright. */ +export const TOPICS = [ + "skip_cost", "budget", "eligibility", "tomorrow", + "attendance_now", "need_to_pass", "standing", +] as const; +export type Topic = (typeof TOPICS)[number]; + +export interface Answer { + /** The sentence. Every figure in it came from the engine. */ + headline: string; + /** + * True when this is a definition rather than a figure about this student. + * + * Rendered differently because it is a different KIND of claim: a definition + * is the regulation, and a figure is this student's record. Presenting them + * identically would let "condonation is available down to 60%" read as + * something computed about them. + */ + isDefinition?: boolean; + /** Supporting lines, one per subject where the question spans several. */ + lines: string[]; + /** Where to go for the full working. The answer is not a substitute for it. */ + view: View; +} + +const pct = (value: number) => `${value.toFixed(0)}%`; +/** "1 mark", "2 marks" - and "half a mark" never appears, marks are integral. */ +const marks = (value: number) => `${value} ${value === 1 ? "mark" : "marks"}`; + +/** + * What one more absence costs a single course, in a sentence. + * + * Both sides of the change, never a bare delta: "86% and 4 marks becomes 84% + * and 3" is a figure a student can check against their own portal. "You lose a + * mark" is one they have to take on trust. + */ +function skipLine(course: Course): string | null { + const label = courseLabel(course); + const cost = absenceCost(course.attended, course.held, course.dl ?? 0, 1); + if (cost === null) return `${label} — attendance not recorded`; + + if (!cost.eligibleBefore) { + return `${label} — already below the line at ${pct(cost.before)}`; + } + if (!cost.eligibleAfter) { + // The one case where a single class is not a matter of marks at all. + return `${label} — one more takes you to ${pct(cost.after)} and you lose exam eligibility`; + } + if (cost.marksLost > 0) { + return `${label} — ${pct(cost.before)} becomes ${pct(cost.after)}, costs ${marks(cost.marksLost)}`; + } + const free = freeSkips(course.attended, course.held, course.dl ?? 0); + return free === null || free <= 1 + ? `${label} — free, but the next one is not` + : `${label} — free (${free} to spare before it costs a mark)`; +} + +/** The classes a student can still miss for nothing, per course. */ +function budgetLine(course: Course): string { + const label = courseLabel(course); + const free = freeSkips(course.attended, course.held, course.dl ?? 0); + const cost = absenceCost(course.attended, course.held, course.dl ?? 0, 1); + if (cost === null || free === null) return `${label} — attendance not recorded`; + if (!cost.eligibleBefore) return `${label} — below 75%, nothing to spare`; + return free === 0 + ? `${label} — none free, the next one costs ${marks(Math.max(1, cost.marksLost))}` + : `${label} — ${free} free before it costs a mark`; +} + +/** + * Where a subject's attendance stands right now. + * + * The plainest question a student can ask, and the one that fell through: the + * detector only fired on forward-looking words - skip, miss, eligible - so + * "what is my attendance in CN" was routed to a screen while "can I miss one" + * was answered outright. The counts are included because a percentage without + * its numerator and denominator is a figure the student cannot check. + */ +function standingLine(course: Course): string { + const label = courseLabel(course); + const cost = absenceCost(course.attended, course.held, course.dl ?? 0, 0); + if (cost === null) return `${label} — attendance not recorded`; + const counts = `${course.attended} of ${course.held}`; + const mark = `${marks(cost.marksBefore)} of the attendance CIE`; + return cost.eligibleBefore + ? `${label} — ${pct(cost.before)} (${counts}), earning ${mark}` + : `${label} — ${pct(cost.before)} (${counts}), below 75% and not eligible`; +} + +/** + * What this subject still needs in the end-semester exam. + * + * `requiredEseCell` is the engine's own answer and is already what the Ledger + * prints; this states it in a sentence instead of a table cell. `bound` means + * the passing mark is not what binds - the student's own target grade is - and + * saying which is the difference between "you need 28" and "you need 28 for the + * grade you asked for, 19 to pass". + */ +function needLine(course: Course, ev: Evaluation): string { + const label = courseLabel(course); + if (ev.grade !== null && ev.ese !== null && ev.ese !== undefined) { + // Already sat and marked: there is nothing left to need. + return `${label} — already graded ${ev.grade}`; + } + const pass = requiredEseCell(ev.needPass, ev.needPassBest); + if (!pass.shown.possible) { + return `${label} — cannot pass on the marks recorded so far`; + } + const target = requiredEseCell(ev.needTarget, ev.needTargetBest); + const base = `${label} — needs ${pass.shown.value} of ${ev.eseMax} in the final to pass`; + // Only mention the target when it asks for MORE than passing does; a target + // already satisfied is not a thing to go and do. + return target.shown.possible && target.shown.value > pass.shown.value + ? `${base}, ${target.shown.value} for your target` + : base; +} + +/** + * Tomorrow's classes, priced individually. + * + * The timetable and the attendance budget have always both been in the app, + * two hundred pixels apart, and nothing multiplied them. This is that join: the + * subjects that actually run tomorrow, each with what missing it would take. + * + * The subject strings come from the portal as printed, so they are matched back + * to the student's own courses by code first and by name second. A period that + * matches nothing is dropped rather than guessed at - naming a subject the + * student does not have would be worse than a shorter list. + */ +const DAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; + +export function tomorrowCourses(now: Date = new Date()): Course[] { + const grid = state.timetable?.grid ?? []; + if (grid.length === 0) return []; + const wanted = DAYS[(now.getDay() + 1) % 7]!; + const day = grid.find((d) => d.day.trim().toLowerCase().startsWith(wanted.slice(0, 3))); + if (!day) return []; + + const out: Course[] = []; + const seen = new Set(); + for (const period of day.periods) { + const printed = period.subject?.trim().toLowerCase(); + if (!printed) continue; + const hit = rows().find((r) => { + const code = (r.course.code ?? "").trim().toLowerCase(); + const name = courseLabel(r.course).trim().toLowerCase(); + return (code !== "" && printed.includes(code)) + || (name !== "" && printed.includes(name)); + }); + if (!hit) continue; + const key = hit.course.code ?? courseLabel(hit.course); + if (seen.has(key)) continue; + seen.add(key); + out.push(hit.course); + } + return out; +} + +/** + * Answer a topic, or decline. + * + * Null when the engine cannot answer it for this student - no courses, no + * attendance recorded, no timetable. Declining is the correct outcome there, + * and the caller falls back to routing: a screen that shows nothing is more + * honest than a sentence built from nothing. + */ +export function answerFor(topic: Topic, code?: string): Answer | null { + const all = rows(); + if (all.length === 0) return null; + + const picked = code + ? all.filter((r) => r.course.code === code) + : all; + if (picked.length === 0) return null; + + if (topic === "tomorrow") { + const courses = tomorrowCourses(); + if (courses.length === 0) return null; + const lines = courses.map(skipLine).filter((l): l is string => l !== null); + if (lines.length === 0) return null; + const free = courses.every((c) => { + const cost = absenceCost(c.attended, c.held, c.dl ?? 0, 1); + return cost !== null && cost.marksLost === 0 && cost.eligibleAfter; + }); + return { + headline: free + ? `Tomorrow is free — missing any of ${courses.length} would cost nothing.` + : `Tomorrow costs you something.`, + lines, view: "attendance", + }; + } + + if (topic === "eligibility") { + const short = picked.filter((r) => { + const cost = absenceCost(r.course.attended, r.course.held, r.course.dl ?? 0, 0); + return cost !== null && !cost.eligibleBefore; + }); + return { + headline: short.length === 0 + ? `Eligible in all ${picked.length} — every subject is above 75%.` + : `${short.length} of ${picked.length} below 75%.`, + lines: (short.length === 0 ? picked : short).map((r) => budgetLine(r.course)), + view: "attendance", + }; + } + + if (topic === "standing") { + // The one topic that is not per-subject: a CGPA is a fact about the whole + // record, and listing it per course would be answering a different question. + const cgpa = overall(); + if (cgpa.credits <= 0) return null; + const need = goalRequirement(); + const plan = goalPlan(); + const lines: string[] = [ + `${cgpa.credits} registered credits counted, ${cgpa.percent.toFixed(1)}%`, + ]; + const target = targets().cgpa; + if (target !== null && target !== undefined && need !== null) { + lines.push(need.possible && need.required !== null + ? `To reach ${target.toFixed(1)}, you need ${need.required.toFixed(2)} SGPA from here on` + : `${target.toFixed(1)} is no longer reachable in the semesters left`); + // Three states, not two. `reachable: false` explicitly does NOT mean + // impossible - goals.ts:395 says it means "this route does not guarantee + // it", and `conditional` is the case where the target is still open on a + // harder route or on marks the plan cannot price yet. Collapsing those + // into "not reachable" would tell a student their target is gone when it + // is not. + if (plan !== null) { + lines.push(plan.reachable + ? "This semester's subjects can carry it" + : plan.conditional + ? "Still open, but today's marks do not guarantee it" + : "This semester's subjects cannot carry it"); + } + } + const sem = summary(); + if (sem.assessed > 0) { + lines.push(`This semester projects to ${sem.sgpaProjected.toFixed(2)} SGPA`); + } + return { headline: `CGPA ${cgpa.cgpa.toFixed(2)}`, lines, view: "home" }; + } + + if (topic === "need_to_pass") { + const lines = picked.map((r) => needLine(r.course, r.ev)); + if (lines.length === 0) return null; + if (picked.length === 1) { + return { headline: lines[0]!, lines: [], view: "ledger" }; + } + return { + headline: "What each subject needs in the final:", + lines, view: "ledger", + }; + } + + const line = topic === "skip_cost" ? skipLine + : topic === "attendance_now" ? standingLine + : budgetLine; + const lines = picked + .map((r) => line(r.course)) + .filter((l): l is string => l !== null); + if (lines.length === 0) return null; + + // With one subject named, the sentence IS the answer and repeating it as a + // heading says the same thing twice. + if (picked.length === 1) { + return { headline: lines[0]!, lines: [], view: "attendance" }; + } + return { + headline: topic === "skip_cost" + ? "What one more absence costs, per subject:" + : topic === "attendance_now" + ? "Where your attendance stands:" + : "Classes you can still miss for free:", + lines, view: "attendance", + }; +} + +/** + * Recognise a question shape without asking anybody. + * + * Cheap keyword detection, run before the model and before the subject matcher. + * It exists because the questions worth answering are asked in a small number + * of ways, and a student typing "can i skip tomorrow" should not wait on a + * network round trip for a sentence this machine can produce immediately. + * + * Only fires on an unambiguous phrase. Anything it is not sure about falls + * through to the router, which is allowed to be slower and is allowed to say no. + */ +/** + * A definition, when the question is asking what a word means. + * + * Tried BEFORE the topic detector and works with an empty record, because "what + * is CIE" has the same answer for a student who has synced nothing. It is also + * what stops the misfires: "what is condonation" used to match the eligibility + * topic and reply with the student's own miss budget. + */ +export function defineFor(query: string): Answer | null { + // A definition first, then a fact about the app. Definitions are the + // narrower test, so trying them first means "what is CIE" is never mistaken + // for a question about the product. + const term = lookupTerm(query) ?? lookupCapability(query); + if (!term) return null; + return { + headline: term.name, lines: [term.body], view: "ledger", isDefinition: true, + }; +} + +export function detectTopic(query: string): Topic | null { + const q = ` ${query.toLowerCase().replace(/[^a-z0-9 ]+/g, " ")} `; + const has = (...words: string[]) => words.some((w) => q.includes(` ${w} `)); + + /** + * A question about the RULE is not a question about this student. + * + * "How is SGPA calculated" contains "sgpa" and was being answered with the + * student's own CGPA - a confident reply to a question nobody asked, which is + * worse than routing, because a route at least lands somewhere the answer + * might be. These fall through so the caller can send them to the glossary. + * + * The test is possessive: "how badly would it affect MY attendance" is about + * this student and is answered; "how does attendance affect marks" is about + * the regulation and is not. + */ + const personal = has("my", "i", "me", "mine", "im") || q.includes(" am i "); + const explains = has("calculated", "calculate", "computed", "compute", + "explain", "explained", "works", "work", "mean", "means", + "definition", "formula", "affect", "affects", "why") + || q.includes(" pass mark ") || q.includes(" pass marks "); + if (explains && !personal) return null; + + if (has("tomorrow", "tmrw", "tommorow", "tommorw")) return "tomorrow"; + + // Ordered before the attendance branches: "what do i need to pass" contains + // no attendance word, but "how many marks do i need" and "am i failing" would + // otherwise be caught by nothing at all. + if (has("pass", "passing", "fail", "failing", "final", "ese", "endsem") + || (has("need", "needed") && has("marks", "mark", "exam", "grade"))) { + return "need_to_pass"; + } + if (has("cgpa", "sgpa", "gpa", "percentage") || q.includes(" first class ") + || q.includes(" where do i stand ") || has("standing", "distinction")) { + return "standing"; + } + if (has("eligible", "eligibility", "debar", "debarred", "condonation")) { + return "eligibility"; + } + // "one more", "another class", "if i miss" - all the ways of asking what the + // NEXT absence costs, as opposed to how many are left. + if ((has("one", "another", "1") && has("miss", "skip", "bunk", "cut", "leave")) + || q.includes(" one more ") || q.includes(" what happens if ")) { + return "skip_cost"; + } + if (has("miss", "skip", "bunk", "cut", "leave", "leaves", "absent")) { + return "budget"; + } + // The plainest question there is, and the one that used to fall through: the + // detector only fired on forward-looking words, so "can i miss one" was + // answered and "what is my attendance" was routed to a screen. + if (has("attendance", "attended", "present")) return "attendance_now"; + return null; +} diff --git a/app/src/state/ask.test.ts b/app/src/state/ask.test.ts new file mode 100644 index 0000000..dac04ef --- /dev/null +++ b/app/src/state/ask.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The ask client. + * + * `ENDPOINT` is read at module scope from `import.meta.env`, so every test that + * cares about it must stub the env BEFORE the dynamic import - a top-level + * import would have already frozen the value. That is why this file imports + * inside each test rather than at the top. + */ + +const ENDPOINT = "https://worker.example.dev"; + +async function load(endpoint: string | undefined, token: string | null) { + vi.resetModules(); + if (endpoint === undefined) vi.stubEnv("VITE_ASK_ENDPOINT", ""); + else vi.stubEnv("VITE_ASK_ENDPOINT", endpoint); + vi.doMock("./auth", () => ({ accessToken: () => token })); + return await import("./ask"); +} + +type FetchMock = (url: string, init: RequestInit) => Promise; + +const reply = (body: unknown, status = 200) => + vi.fn(async () => new Response(JSON.stringify(body), { + status, headers: { "content-type": "application/json" }, + })); + +/** The captured request, with the types the mock was declared with. */ +const sent = (f: ReturnType, i = 0) => { + const call = f.mock.calls[i]; + if (!call) throw new Error("fetch was never called"); + return { url: call[0], init: call[1], body: JSON.parse(String(call[1].body)) }; +}; + +beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); }); +afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.doUnmock("./auth"); }); + +describe("askConfigured", () => { + it("is false with no endpoint, so the palette never offers a route it cannot take", async () => { + const { askConfigured } = await load(undefined, "t"); + expect(askConfigured()).toBe(false); + }); + + it("is true once an endpoint is built in", async () => { + const { askConfigured } = await load(ENDPOINT, "t"); + expect(askConfigured()).toBe(true); + }); +}); + +describe("parseReply", () => { + it("accepts a view route", async () => { + const { parseReply } = await load(ENDPOINT, "t"); + expect(parseReply({ action: { kind: "view", view: "attendance" }, remaining: 7 })) + .toEqual({ ok: true, action: { kind: "view", view: "attendance" }, remaining: 7 }); + }); + + it("rejects a view the app does not have", async () => { + const { parseReply } = await load(ENDPOINT, "t"); + expect(parseReply({ action: { kind: "view", view: "settings" } })).toBeNull(); + }); + + it("defaults a subject's view rather than dropping a correct subject", async () => { + const { parseReply } = await load(ENDPOINT, "t"); + const out = parseReply({ action: { kind: "subject", code: "CST301" }, remaining: 3 }); + expect(out).toEqual({ + ok: true, action: { kind: "subject", code: "CST301", view: "attendance" }, remaining: 3, + }); + }); + + it("narrows an unrecognised refusal reason instead of passing it through", async () => { + const { parseReply } = await load(ENDPOINT, "t"); + const out = parseReply({ action: { kind: "none", reason: "because i said so" } }); + expect(out).toEqual({ ok: true, action: { kind: "none", reason: "unclear" }, remaining: 0 }); + }); + + it("rejects anything that is not an action", async () => { + const { parseReply } = await load(ENDPOINT, "t"); + expect(parseReply(null)).toBeNull(); + expect(parseReply({})).toBeNull(); + expect(parseReply({ action: "ledger" })).toBeNull(); + expect(parseReply({ action: { kind: "navigate", view: "ledger" } })).toBeNull(); + }); + + it("treats a missing remaining as zero rather than NaN", async () => { + const { parseReply } = await load(ENDPOINT, "t"); + const out = parseReply({ action: { kind: "view", view: "home" } }); + expect(out).toMatchObject({ remaining: 0 }); + }); +}); + +describe("askRemote", () => { + it("does not call out at all when no endpoint is built in", async () => { + const { askRemote } = await load(undefined, "t"); + expect(await askRemote("q", [])).toEqual({ ok: false, kind: "unconfigured" }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("does not spend a request when there is no token", async () => { + const { askRemote } = await load(ENDPOINT, null); + expect(await askRemote("q", [])).toEqual({ ok: false, kind: "signin" }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("sends only the question and the course list - never a figure", async () => { + const f = reply({ action: { kind: "view", view: "ledger" }, remaining: 9 }); + vi.stubGlobal("fetch", f); + const { askRemote } = await load(ENDPOINT, "tok"); + await askRemote("where are my marks", [{ code: "CST301", name: "Formal Languages" }]); + + const { url, init, body } = sent(f); + expect(url).toBe(`${ENDPOINT}/ask`); + expect((init.headers as Record).authorization).toBe("Bearer tok"); + // The exact shape matters: anything extra here is an academic record + // leaving the machine. + expect(Object.keys(body).sort()).toEqual(["question", "subjects"]); + expect(body.subjects).toEqual([{ code: "CST301", name: "Formal Languages" }]); + }); + + it("trims a question to the worker's cap instead of being rejected at the edge", async () => { + const f = reply({ action: { kind: "none", reason: "unclear" } }); + vi.stubGlobal("fetch", f); + const { askRemote } = await load(ENDPOINT, "tok"); + await askRemote("x".repeat(900), []); + expect(sent(f).body.question).toHaveLength(400); + }); + + it("refuses an empty question without a round trip", async () => { + const { askRemote } = await load(ENDPOINT, "tok"); + expect(await askRemote(" ", [])).toEqual({ ok: false, kind: "failed" }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("does not double the slash when the endpoint has a trailing one", async () => { + const f = reply({ action: { kind: "view", view: "home" } }); + vi.stubGlobal("fetch", f); + const { askRemote } = await load(`${ENDPOINT}/`, "tok"); + await askRemote("q", []); + expect(sent(f).url).toBe(`${ENDPOINT}/ask`); + }); + + it("maps 401 to a sign-in prompt, not a failure", async () => { + vi.stubGlobal("fetch", reply({ error: "unauthorized" }, 401)); + const { askRemote } = await load(ENDPOINT, "stale"); + expect(await askRemote("q", [])).toEqual({ ok: false, kind: "signin" }); + }); + + it("maps 429 to the quota state so the student is told they are out, not broken", async () => { + vi.stubGlobal("fetch", reply({ error: "rate_limited" }, 429)); + const { askRemote } = await load(ENDPOINT, "tok"); + expect(await askRemote("q", [])).toEqual({ ok: false, kind: "limit" }); + }); + + it("maps a thrown fetch to offline", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("Failed to fetch"); })); + const { askRemote } = await load(ENDPOINT, "tok"); + expect(await askRemote("q", [])).toEqual({ ok: false, kind: "offline" }); + }); + + it("maps a 502 upstream failure to failed", async () => { + vi.stubGlobal("fetch", reply({ error: "upstream" }, 502)); + const { askRemote } = await load(ENDPOINT, "tok"); + expect(await askRemote("q", [])).toEqual({ ok: false, kind: "failed" }); + }); + + it("survives a 200 that is not JSON", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("proxy", { status: 200 }))); + const { askRemote } = await load(ENDPOINT, "tok"); + expect(await askRemote("q", [])).toEqual({ ok: false, kind: "failed" }); + }); + + it("survives a 200 whose action the app does not recognise", async () => { + vi.stubGlobal("fetch", reply({ action: { kind: "delete_everything" } })); + const { askRemote } = await load(ENDPOINT, "tok"); + expect(await askRemote("q", [])).toEqual({ ok: false, kind: "failed" }); + }); + + it("caps the course list at the worker's limit", async () => { + const f = reply({ action: { kind: "view", view: "home" } }); + vi.stubGlobal("fetch", f); + const { askRemote } = await load(ENDPOINT, "tok"); + const many = Array.from({ length: 80 }, (_, i) => ({ code: `C${i}`, name: `Course ${i}` })); + await askRemote("q", many); + expect(sent(f).body.subjects).toHaveLength(60); + }); +}); diff --git a/app/src/state/ask.ts b/app/src/state/ask.ts new file mode 100644 index 0000000..91da5a2 --- /dev/null +++ b/app/src/state/ask.ts @@ -0,0 +1,132 @@ +import { accessToken } from "./auth"; +import type { View } from "./nav"; + +/** + * The remote half of the ask box. + * + * The palette already answers most questions on its own: a stop list, a + * subsequence match, and every figure straight out of the engine. That path is + * free, instant, works offline and cannot invent anything, so it stays first + * and this is only ever reached when it finds nothing. + * + * What crosses the wire is a question and a course list - no marks, no + * attendance, no CGPA, no name, no register number (see the worker's + * `AskRequest`). The reply is a route, never prose and never a number. The + * student's figures are still computed on their own machine by the engine, + * which is the only reason a question box can sit on top of an app whose whole + * position is that it never states a number it cannot show its working for. + */ + +/** Mirrors the worker's `Action`. Kept in sync by the tests, not by import. */ +export type AskAction = + | { kind: "view"; view: View } + | { kind: "subject"; code: string; view: View } + | { kind: "none"; reason: "off_topic" | "unclear" | "no_match" }; + +export type AskOutcome = + | { ok: true; action: AskAction; remaining: number } + /** + * Failures the student can do something about are separated from the ones + * they cannot. `signin` and `limit` are states of their account; `offline` + * and `failed` are ours, and the palette says so rather than blaming them. + */ + | { ok: false; kind: "unconfigured" | "signin" | "limit" | "offline" | "failed" }; + +const ENDPOINT = String(import.meta.env.VITE_ASK_ENDPOINT ?? "").trim(); + +const VIEW_IDS = ["home", "ledger", "attendance", "history", "data"] as const; +const isView = (v: unknown): v is View => + typeof v === "string" && (VIEW_IDS as readonly string[]).includes(v); + +/** Whether this build has somewhere to send a question at all. */ +export const askConfigured = (): boolean => ENDPOINT !== ""; + +/** + * Parse the worker's reply. + * + * Hostile to its input for the same reason the worker's own parser is: this + * runs on a response that passed through a model, and an `action` the app does + * not recognise must become nothing rather than something. A `code` is NOT + * checked here - the worker already rejected any code the client did not send, + * and the caller matches it back to a real row before navigating. + */ +export function parseReply(raw: unknown): AskOutcome | null { + if (typeof raw !== "object" || raw === null) return null; + const o = raw as Record; + const a = o.action; + if (typeof a !== "object" || a === null) return null; + const act = a as Record; + const remaining = typeof o.remaining === "number" ? o.remaining : 0; + + if (act.kind === "view" && isView(act.view)) { + return { ok: true, action: { kind: "view", view: act.view }, remaining }; + } + if (act.kind === "subject" && typeof act.code === "string") { + return { + ok: true, + action: { + kind: "subject", + code: act.code, + view: isView(act.view) ? act.view : "attendance", + }, + remaining, + }; + } + if (act.kind === "none") { + const reason = act.reason; + const known = reason === "off_topic" || reason === "unclear" || reason === "no_match"; + return { + ok: true, + action: { kind: "none", reason: known ? reason : "unclear" }, + remaining, + }; + } + return null; +} + +/** + * Ask the worker to route a question. + * + * Signed out is not an error state to apologise for. Every figure in the app is + * still readable without an account; this one box is what an account buys, and + * the caller says that in a sentence rather than pushing a sign-in wall. + */ +export async function askRemote( + question: string, + subjects: Array<{ code: string; name: string }>, + signal?: AbortSignal, +): Promise { + if (!askConfigured()) return { ok: false, kind: "unconfigured" }; + + const token = accessToken(); + if (!token) return { ok: false, kind: "signin" }; + + // Trimmed to the worker's own caps before sending. A request the edge will + // reject as malformed is a round trip spent to be told what we already knew. + const q = question.trim().slice(0, 400); + if (q === "") return { ok: false, kind: "failed" }; + + let res: Response; + try { + res = await fetch(`${ENDPOINT.replace(/\/+$/, "")}/ask`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ question: q, subjects: subjects.slice(0, 60) }), + signal, + }); + } catch { + // No network, DNS, a campus proxy, a dead worker - indistinguishable from + // here and identical to the student, who is simply not getting an answer. + return { ok: false, kind: "offline" }; + } + + if (res.status === 401) return { ok: false, kind: "signin" }; + if (res.status === 429) return { ok: false, kind: "limit" }; + if (!res.ok) return { ok: false, kind: "failed" }; + + try { + return parseReply(await res.json()) ?? { ok: false, kind: "failed" }; + } catch { + return { ok: false, kind: "failed" }; + } +} diff --git a/app/src/state/auth.ts b/app/src/state/auth.ts new file mode 100644 index 0000000..1a97ae3 --- /dev/null +++ b/app/src/state/auth.ts @@ -0,0 +1,192 @@ +import { invoke } from "@tauri-apps/api/core"; +import { createSignal } from "solid-js"; +import { canSync } from "../sync/etlab"; + +/** + * The account, front-end side. + * + * Thin passes to `src-tauri/src/oauth.rs`, which owns the whole flow: PKCE, the + * loopback listener, the system browser and the refresh token in the OS vault. + * Deliberately so. The webview this code runs in also renders a college + * portal's HTML, and neither the authorization URL nor the refresh token has + * any business passing through it. + * + * What lives here is the short-lived access token, in memory, in a signal that + * is never persisted. It is a bearer credential for our own Worker and nothing + * else, and writing it to disk would be storing a credential to save one + * refresh call on the next launch. + * + * SIGNING IN IS OPTIONAL AND GATES NOTHING BUT THE ASSISTANT. Every figure the + * app shows is computed on this machine from data this machine fetched, and a + * student who never signs in - or whose provider is down, or whose refresh has + * expired - must still open TargetX and read their attendance. There is no + * screen behind this. + */ + +export interface Session { + accessToken: string; + /** Unix seconds, or null when the provider declined to say. */ + expiresAt: number | null; + name: string | null; + email: string | null; + /** + * The avatar, already inlined as a `data:` URI by the Rust side. + * + * Never the provider's https URL. The app's `img-src` is `'self' data:` and + * stays that way: pointing the webview at Google's CDN would mean a request + * to them every time the header drew, from an application that otherwise + * needs no network at all. + */ + avatar: string | null; +} + +/** + * Sign-in configuration. + * + * Both values are public by definition: the issuer is in every token, and a + * PKCE client id is not a secret - that is the entire point of PKCE. They are + * build-time settings rather than constants so a fork can point at its own + * provider without editing source. + */ +const ISSUER = String(import.meta.env.VITE_CLERK_ISSUER ?? "").trim(); +const CLIENT_ID = String(import.meta.env.VITE_CLERK_CLIENT_ID ?? "").trim(); + +/** + * Scopes. Every one of these is here because something uses it. + * + * `openid` is what makes this an identity request at all, and `offline_access` + * is what earns a refresh token - without it the student is signed out the + * moment the access token expires, on every launch, forever. + * + * `profile` and `email` were dropped for a while because nothing displayed + * them, and are back because the account menu now does: the signed-in name, + * address and picture. That is the standard for the scope - ask for a claim + * when something shows it, not in case something might. + * + * `public_metadata` and `private_metadata` are offered by the instance and are + * never appropriate here. + */ +const SCOPES = "openid profile email offline_access"; + +const [session, setSession] = createSignal(null); +const [authBusy, setAuthBusy] = createSignal(false); +const [authError, setAuthError] = createSignal(null); +/** + * True for a moment after a sign-in the student actually performed. + * + * Deliberately NOT derived from `session()` becoming non-null. `resumeAccount` + * does that on every launch, and greeting somebody by name every time they + * open the app is a nag rather than an acknowledgement. + */ +const [justSignedIn, setJustSignedIn] = createSignal(false); +export { authBusy, authError, justSignedIn, session }; + +/** Whether this build was given a provider to talk to at all. */ +export const authConfigured = (): boolean => + canSync() && ISSUER !== "" && CLIENT_ID !== ""; + +export const signedIn = (): boolean => session() !== null; + +/** + * The token to send to the Worker, or null. + * + * Expiry is checked with a minute of headroom, because a token that passes + * here and expires in flight fails at the edge as a 401 that looks like a bug. + * A null answer means "refresh or sign in", never "carry on without one". + */ +export function accessToken(): string | null { + const s = session(); + if (!s) return null; + if (s.expiresAt !== null && s.expiresAt - 60 <= Date.now() / 1000) return null; + return s.accessToken; +} + +interface RawSession { + access_token: string; + expires_at: number | null; + name: string | null; + email: string | null; + avatar: string | null; +} + +const adopt = (raw: RawSession | null): Session | null => { + if (!raw) return null; + const next: Session = { + accessToken: raw.access_token, + expiresAt: raw.expires_at ?? null, + name: raw.name ?? null, + email: raw.email ?? null, + avatar: raw.avatar ?? null, + }; + setSession(next); + return next; +}; + +/** + * Sign in through the system browser. + * + * Two calls, not one: Rust must bind the loopback port and open the browser + * before anything can wait on the callback, and `oauth_finish` then blocks for + * as long as the student takes. Splitting them is what lets the UI show that it + * is waiting rather than appearing to hang. + */ +export async function signIn(): Promise { + if (!authConfigured()) { + setAuthError("Sign-in is not configured in this build."); + return null; + } + setAuthBusy(true); + setAuthError(null); + try { + await invoke<{ authorize_url: string }>("oauth_begin", { + issuer: ISSUER, clientId: CLIENT_ID, scopes: SCOPES, + }); + const next = adopt(await invoke("oauth_finish")); + if (next) { + setJustSignedIn(true); + // Long enough for the acknowledgement to have been shown and gone. The + // flag is a one-shot, not a state anything else should read. + setTimeout(() => setJustSignedIn(false), 6000); + } + return next; + } catch (exc) { + setAuthError(exc instanceof Error ? exc.message : String(exc)); + return null; + } finally { + setAuthBusy(false); + } +} + +/** + * Restore a session at launch from the stored refresh token, without a browser. + * + * Silent about every failure. This runs unasked, and a student who is not + * signed in is in a normal state, not a broken one - telling them about it on + * every launch would be nagging them to sign in to a feature they may not want. + */ +export async function resumeAccount(): Promise { + if (!authConfigured()) return null; + try { + return adopt(await invoke("oauth_resume", { + issuer: ISSUER, clientId: CLIENT_ID, + })); + } catch { + return null; + } +} + +/** + * Forget the account on this machine. + * + * The in-memory session is dropped even if the vault refuses, because the + * student asked to be signed out and the token in this process is the one that + * could still be used. + */ +export async function signOut(): Promise { + setSession(null); + setAuthError(null); + setJustSignedIn(false); + try { + await invoke("oauth_sign_out"); + } catch { /* nothing left to do; the session is already gone from memory */ } +} diff --git a/app/src/state/autosync.ts b/app/src/state/autosync.ts new file mode 100644 index 0000000..5047756 --- /dev/null +++ b/app/src/state/autosync.ts @@ -0,0 +1,183 @@ +import { createSignal } from "solid-js"; +import { canSync, fullSync } from "../sync/etlab"; +import { canSyncKtu } from "../sync/ktu"; +import { KTU_CRED_KEY, canRemember, loadCreds } from "./creds"; +import { applySync, syncKtu } from "./actions"; +import { state } from "./store"; + +/** + * Refreshing the record, from both portals, without being asked twice. + * + * Until this existed nothing in the app ever refreshed itself. `lastSync` was + * written by one button on one screen and by nothing else, so a student who + * opened TargetX to check whether they could skip tomorrow's class was reading + * whatever the portal said the last time they thought to press it. The number + * looked authoritative and was quietly days old, which is worse than no number. + * + * The two sources are refreshed independently and neither can sink the other. + * They are different servers with different logins and different failure modes, + * and KTU being down at result time is not a reason to leave a student without + * today's attendance. A run reports per source; the caller decides what to say. + * + * Automatic runs carry three limits that a manual press does not: + * + * 1. They run ONLY if that portal's password is already in the OS credential + * vault. Nothing here ever prompts, ever stores anything new, or can be the + * reason a password gets saved - that is still a box the student ticks. + * + * 2. They run at launch, never on a timer. etlab is a college server that + * updates attendance once or twice a day when a teacher gets round to it, + * and polling it for data that did not change is load on somebody else's + * infrastructure. Opening the app is also the moment the student is about to + * read the number. + * + * 3. They are throttled, so reopening the app four times is one refresh. + * + * A manual refresh honours none of those: the student pressed the button, they + * get the request, however recently the last one ran. + */ + +/** Do not refresh automatically if the last one was this recent. */ +export const AUTO_SYNC_GAP_MS = 45 * 60 * 1000; + +export type SourceStatus = + | "ok" + /** No saved login for this portal, so there is nothing to sync with. */ + | "no-creds" + /** Browser build, or no college address configured yet. */ + | "unavailable" + | "failed"; + +export interface SourceResult { + source: "etlab" | "ktu"; + status: SourceStatus; + /** Why it failed, verbatim from the portal layer. Only set on "failed". */ + detail?: string; +} + +export interface Refresh { + at: string; + results: SourceResult[]; +} + +const [refreshing, setRefreshing] = createSignal(false); +/** The last refresh's outcome, or null if none has run this session. */ +const [lastRefresh, setLastRefresh] = createSignal(null); +export { lastRefresh, refreshing }; + +/** The sources that failed in the last run. Surfaced in the bell. */ +export function refreshFailures(): SourceResult[] { + return (lastRefresh()?.results ?? []).filter((r) => r.status === "failed"); +} + +/** Milliseconds since the last successful sync, or null if there never was one. */ +export function sinceLastSync(now = Date.now()): number | null { + if (!state.lastSync) return null; + const then = new Date(state.lastSync).getTime(); + if (!Number.isFinite(then)) return null; + return now - then; +} + +/** + * True when an automatic refresh would be worth doing. + * + * A record that has NEVER been synced is stale by definition - that is a + * student who set the app up by hand, or whose first sync failed, and they are + * exactly who benefits from one happening without being asked. + */ +export function syncIsStale(now = Date.now()): boolean { + const age = sinceLastSync(now); + return age === null || age >= AUTO_SYNC_GAP_MS; +} + +/** + * Read a saved login, treating a vault that will not answer as simply absent. + * + * A locked or missing vault is not a sync failure and must not be reported as + * one: telling a student their sync failed when they never asked for a sync is + * noise, and the answer either way is "there is nothing to log in with". + */ +async function vault(key: string) { + if (!canRemember()) return null; + try { + return await loadCreds(key); + } catch { + return null; + } +} + +async function refreshEtlab(): Promise { + if (!canSync()) return { source: "etlab", status: "unavailable" }; + + const base = String(state.student.college || "").trim(); + if (!base) return { source: "etlab", status: "unavailable" }; + + const creds = await vault(base); + if (!creds) return { source: "etlab", status: "no-creds" }; + + try { + // The password is read from the vault into a local, handed to one request + // and dropped when this function returns. It is never put in a signal, + // never in app state and never logged - the same posture the manual panel + // holds, and the reason this lives here rather than in a component. + applySync(await fullSync(base, creds.username, creds.password)); + return { source: "etlab", status: "ok" }; + } catch (exc) { + return { + source: "etlab", status: "failed", + detail: exc instanceof Error ? exc.message : String(exc), + }; + } +} + +async function refreshKtu(): Promise { + if (!canSyncKtu()) return { source: "ktu", status: "unavailable" }; + + const creds = await vault(KTU_CRED_KEY); + if (!creds) return { source: "ktu", status: "no-creds" }; + + try { + await syncKtu(creds.username, creds.password); + return { source: "ktu", status: "ok" }; + } catch (exc) { + return { + source: "ktu", status: "failed", + detail: exc instanceof Error ? exc.message : String(exc), + }; + } +} + +/** + * Refresh both portals. + * + * Never throws. Every path resolves to a report, because one caller is a + * `void`-ed call in `onMount` where an escaping rejection is an unhandled + * promise rejection in a WebView that nobody will ever see. + * + * `Promise.all` rather than sequential: they are different servers, neither + * waits on the other, and both settle because each half catches its own + * failure. Nothing here can reject, so there is no `allSettled` to reach for. + */ +export async function refreshAll(): Promise { + setRefreshing(true); + try { + const results = await Promise.all([refreshEtlab(), refreshKtu()]); + const report: Refresh = { at: new Date().toISOString(), results }; + setLastRefresh(report); + return report; + } finally { + setRefreshing(false); + } +} + +/** + * The launch refresh: the same work, declined when it would buy nothing. + * + * Deliberately silent about being skipped. A student who opened the app four + * minutes ago does not need to be told that a background task they never + * started chose not to run. + */ +export async function autoRefresh(): Promise { + if (!syncIsStale()) return null; + return await refreshAll(); +} diff --git a/app/src/state/creds.ts b/app/src/state/creds.ts index d8c7360..6292dd7 100644 --- a/app/src/state/creds.ts +++ b/app/src/state/creds.ts @@ -21,6 +21,16 @@ export interface StoredCreds { export const canRemember = (): boolean => canSync(); +/** + * The vault key the KTU results portal's login is kept under. + * + * A constant here rather than a literal in the one screen that used it, because + * two callers now read it - the Data screen's form and the background refresh - + * and a second copy of this string that drifted by one character would fail as + * "no saved login", silently, forever. + */ +export const KTU_CRED_KEY = "https://app.ktu.edu.in"; + /** Save a portal login to the OS vault. Rejects if the store is unavailable. */ export const saveCreds = (base: string, username: string, password: string): Promise => invoke("cred_save", { base, username, password }); diff --git a/app/src/state/external.ts b/app/src/state/external.ts new file mode 100644 index 0000000..1215afc --- /dev/null +++ b/app/src/state/external.ts @@ -0,0 +1,44 @@ +/** + * Opening a link in the student's own browser. + * + * `
` does nothing in a Tauri webview, which meant every + * external link in the desktop app was dead: the privacy statement, "Report a + * problem" on the Data screen, and "Report a problem" on a failed sync. The + * last one is the reason this file exists - a student whose sync had just + * broken was being handed a dead link to the issue form, so the one path out of + * a broken app led nowhere. + * + * The plugin is scoped in `capabilities/default.json` to this project's own + * GitHub URLs and nothing else. That scope is the answer to the objection + * written up on `diagnostics_dir`: this webview also renders a college portal's + * HTML, and a hostile page in it can ask for the issue tracker and cannot ask + * for anything beyond it. + */ +import { openUrl } from "@tauri-apps/plugin-opener"; + +/** + * True when running inside the desktop shell. + * + * Mirrors `canDiagnose` in `./diagnostics` and `canSync` in `../sync/etlab` + * rather than inventing a fourth detection. In a browser build the anchor + * works by itself and nothing here should run. + */ +const inShell = (): boolean => + typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; + +/** + * Handle a click on an external link. + * + * Returns without preventing the default in a browser, where the anchor is + * already correct - so the same markup serves both builds and there is no + * second code path to keep in step. + * + * A failure is swallowed on purpose. The href is visible in the markup and the + * student can still copy it; an error toast about a link is noise, and this is + * frequently being called from a screen that is already reporting a fault. + */ +export function openExternal(event: MouseEvent, href: string): void { + if (!inShell()) return; + event.preventDefault(); + void openUrl(href).catch(() => {}); +} diff --git a/app/src/state/glossary.ts b/app/src/state/glossary.ts new file mode 100644 index 0000000..2230f1c --- /dev/null +++ b/app/src/state/glossary.ts @@ -0,0 +1,221 @@ +import { + ATTENDANCE_CONDONE, ATTENDANCE_MARK_BANDS, ATTENDANCE_MARK_MAX, + ATTENDANCE_MIN, DL_CAP_PCT, ESE_PASS_FRACTION, COURSE_TYPES, DEFAULT_TYPE, +} from "../engine"; + +/** + * What the words mean, stated once. + * + * Two problems this fixes, which turned out to be the same problem. + * + * The ask box could not answer "what is condonation" and did something worse + * than failing: "condonation" contains no explainer verb, so the topic detector + * matched it as an eligibility question and replied with the student's own + * attendance budget. A confident answer to a question nobody asked. Definitions + * are now their own kind of answer rather than a question shape that has to be + * guarded against. + * + * And the drawer's glossary hardcoded every figure it quoted - "85% earns 5, + * then 4, 3, 2, 1 down to 60%", "at most 10% of classes held", "at least 40% of + * the ESE paper". Those are the engine's constants written out as English, in + * the one panel whose entire job is teaching the rules, with nothing failing if + * they drift. Every number below is interpolated from the constant the + * calculation itself uses, so a change to the regulation cannot leave this + * screen confidently teaching the old one. + */ + +export interface Term { + /** What it is called on screen. */ + name: string; + /** Everything a student might type to mean it. Lower case, matched whole. */ + aliases: string[]; + body: string; +} + +/** The top band and its mark, read off the table rather than assumed. */ +const TOP_BAND = ATTENDANCE_MARK_BANDS[0]!; +/** The lowest band that still earns anything. */ +const LAST_BAND = ATTENDANCE_MARK_BANDS[ATTENDANCE_MARK_BANDS.length - 1]!; +/** The marks in the table, high to low, as "5, 4, 3, 2, 1". */ +const BAND_MARKS = ATTENDANCE_MARK_BANDS.map(([, m]) => m).join(", "); +const DEFAULT_SPEC = COURSE_TYPES[DEFAULT_TYPE]; +const ESE_PASS_PCT = Math.round(ESE_PASS_FRACTION * 100); + +export const TERMS: Term[] = [ + { + name: "CIE", + aliases: ["cie", "internal", "internals", "continuous internal evaluation"], + body: `Continuous Internal Evaluation — the marks your college gives during the semester: series exams, assignments, and attendance. Out of ${DEFAULT_SPEC.cieMax} for most theory courses.`, + }, + { + name: "ESE", + aliases: ["ese", "end semester", "endsem", "end semester examination", "university exam"], + body: `End Semester Examination — the university exam at the end. Out of ${DEFAULT_SPEC.eseMax} for most theory courses.`, + }, + { + name: `The ${ESE_PASS_PCT}% rule`, + aliases: ["40 rule", "40% rule", "ese minimum", "separate minimum", "ese pass"], + body: `You must score at least ${ESE_PASS_PCT}% of the ESE paper on its own, whatever your CIE is. A strong internal cannot buy a pass.`, + }, + { + name: "Attendance marks", + aliases: ["att mk", "attendance mark", "attendance marks", "r7.5", "r 7.5"], + body: `Attendance is worth up to ${ATTENDANCE_MARK_MAX} CIE marks under Regulations 2024, R 7.5.ii: ${TOP_BAND[0]}% earns ${TOP_BAND[1]}, then ${BAND_MARKS} down to ${LAST_BAND[0]}%. This is the part no other KTU calculator shows — being at ${ATTENDANCE_MIN + 1}% is not "fine", it is marks already gone.`, + }, + { + name: "Shortage", + aliases: ["shortage", "short", "attendance shortage"], + body: `Below ${ATTENDANCE_MIN}% attendance. Condonation may be possible down to ${ATTENDANCE_CONDONE}%, for at most two semesters, against a fee.`, + }, + { + name: "Condonation", + aliases: ["condonation", "condone", "condoned"], + body: `Paying to be allowed to sit an exam you are short of attendance for. Available between ${ATTENDANCE_CONDONE}% and ${ATTENDANCE_MIN}%, for at most two semesters across the programme, against a fee. Below ${ATTENDANCE_CONDONE}% there is no appeal path under R 6.2.`, + }, + { + name: "Debarred", + aliases: ["debarred", "debar", "barred"], + body: `Below ${ATTENDANCE_CONDONE}% attendance. You cannot sit the exam and there is no appeal path under R 6.2.`, + }, + { + name: "Duty leave", + aliases: ["duty leave", "dl", "od", "on duty"], + body: `Approved absence for NSS, sports, fests or placement drives. It counts as present, but only up to ${DL_CAP_PCT}% of classes held (R 6.3.ii) — anything beyond that is wasted, and this app says so.`, + }, + { + name: "Incomplete", + aliases: ["incomplete", "withdrawn", "withdrawal", "grade i", "grade w"], + body: "Published as I or W — withdrawn, or not completed. KTU leaves it out of the SGPA entirely, credits included, until you complete it. It is not a fail and is not scored as one.", + }, + { + name: "Unreachable", + aliases: ["unreachable"], + body: "Even a full ESE paper cannot get this course to a pass. Better to know now.", + }, + { + name: "SGPA and CGPA", + aliases: ["sgpa", "cgpa", "gpa", "sgpa and cgpa", "difference between sgpa and cgpa"], + body: "SGPA is one semester's grade point average, weighted by the credits you registered for that semester. CGPA is the same average across every semester published so far. A failed course still counts in the denominator; one marked I or W does not.", + }, +]; + +/** + * Whether a question is asking what something IS. + * + * Deliberately narrow. A definitional phrasing plus a term the glossary holds + * is a high bar, and anything short of it falls through to the topic detector - + * because "what is my attendance" is a question about the student, not about + * the word "attendance", and the two are one word apart. + */ +const DEFINITIONAL = [ + /\bwhat (?:is|are|does|do)\b/, /\bwhats\b/, /\bexplain\b/, /\bmeaning of\b/, + /\bdefine\b/, /\bmeans?\b/, /\bdifference between\b/, +]; + +/** A question about the student, not about a word. One word decides it. */ +const POSSESSIVE = /\b(my|mine|i|me|im)\b/; + +/** + * Find the term a question is asking about, or null. + * + * Longest alias first, so "duty leave" is not beaten by "dl" and "sgpa and + * cgpa" is not beaten by "sgpa". + */ +const normalise = (query: string) => + ` ${query.toLowerCase().replace(/[^a-z0-9% ]+/g, " ").replace(/\s+/g, " ")} `; + +export function lookupTerm(query: string): Term | null { + const q = normalise(query); + if (!DEFINITIONAL.some((re) => re.test(q))) return null; + // "what is my attendance" is asking for a figure, not a definition. + if (POSSESSIVE.test(q)) return null; + + return longestAlias(q, TERMS); +} + +/** The longest alias any of `pool` matches in an already-normalised query. */ +function longestAlias(q: string, pool: Term[]): Term | null { + let best: { term: Term; length: number } | null = null; + for (const term of pool) { + for (const alias of term.aliases) { + if (!q.includes(` ${alias} `)) continue; + if (best === null || alias.length > best.length) { + best = { term, length: alias.length }; + } + } + } + return best?.term ?? null; +} + +/** + * A question about the APP, which needs its own matcher. + * + * "How do I import my grade card" is possessive and is not definitional, so + * `lookupTerm` refuses it twice over - correctly, since those guards exist to + * stop "what is my attendance" being answered with a dictionary entry. But a + * question about the product is naturally phrased in the first person, and it + * is still not a question about the student's record. Different question, + * different test. + */ +const HOWTO = [ + /\bhow (?:do|can|does|often)\b/, /\bcan i\b/, /\bwhere (?:is|are|do|does)\b/, + /\bwhat can\b/, /\bis my\b/, /\bare my\b/, /\bwhat (?:is|are|does|do)\b/, + /\bwhats\b/, +]; + +export function lookupCapability(query: string): Term | null { + const q = normalise(query); + if (!HOWTO.some((re) => re.test(q))) return null; + return longestAlias(q, CAPABILITIES); +} + +/** + * What the app itself does. + * + * Nothing anywhere described this. Not the app, and not the Worker's system + * prompt - which lists five view names and nothing else - so "how do I import + * my grade card", "where does this data come from" and "is my password stored" + * could not be answered by either half. A student asking what the thing in + * front of them does is asking the most reasonable question there is. + * + * Kept beside the regulation terms deliberately: they are the same kind of + * claim. Neither is computed from the student's record, and both are things + * TargetX can state because they are true of TargetX, not of them. + */ +export const CAPABILITIES: Term[] = [ + { + name: "What TargetX does", + aliases: ["targetx", "this app", "the app", "it do", "you do", "tex"], + body: `Tracks your KTU marks and attendance so you do not have to open etlab or the KTU portal. It prices attendance in CIE marks (R 7.5.ii), says what each subject still needs in the final, and tracks your CGPA against a target you set. Every figure is computed on this machine from your own records.`, + }, + { + name: "Where the data comes from", + aliases: ["data come from", "data comes from", "where the data", "source of the data", + "sync", "syncing", "etlab", "ktu portal"], + body: `Two sources. Your college's etlab portal supplies this semester's marks, attendance and timetable; the KTU portal supplies published grade cards for past semesters. Where the two disagree about a semester, the KTU grade card wins and the disagreement is shown rather than hidden.`, + }, + { + name: "Importing a grade card", + aliases: ["import", "importing", "grade card", "gradecard", "import a grade card", + "paste", "pdf"], + body: `On the Data screen. You can fetch it from the KTU portal directly, paste the table from a grade card, or open the PDF. Any of the three fills in a past semester's SGPA and credits.`, + }, + { + name: "Whether your password is stored", + aliases: ["password", "password stored", "passwords", "credentials", "secure", "safe"], + body: `Your portal password is used to sign in and is not written to disk in readable form, is never included in an export, and is never written to a log. Signing in to TargetX itself is separate and optional - it unlocks the question box and nothing else.`, + }, + { + name: "Backing up", + aliases: ["backup", "back up", "backing up", "export", "restore"], + body: `Export backup on the Data screen writes a single file holding every semester, subject and published result. Restore replaces the whole record with a file, so export first if there is anything you want to keep.`, + }, + { + name: "How often it syncs", + aliases: ["how often", "sync", "refresh", "refreshes", "automatic"], + body: `On its own, at most once every 45 minutes, and only when the record is stale. The refresh button in the header pulls both portals immediately and ignores that gap.`, + }, +]; + +/** Both are facts about TargetX rather than about the student. */ +export const ALL_FACTS: Term[] = [...TERMS, ...CAPABILITIES]; diff --git a/app/src/state/launch.ts b/app/src/state/launch.ts index 5e760cb..403ccce 100644 --- a/app/src/state/launch.ts +++ b/app/src/state/launch.ts @@ -10,13 +10,18 @@ import { saveFault, state } from "./store"; * recomputation run here, once, rather than being buried in a screen the * student may never open. * - * What it deliberately does NOT do is sync. A portal sync needs a password, - * and the password is never stored; running one on launch would mean keeping - * a credential on disk to save a click. Staleness is reported instead. + * What it deliberately does NOT do is sync. This check is arithmetic over data + * already in memory and must finish in milliseconds; a portal sync crosses the + * network to a college server and cannot be on the path to the first paint. + * Staleness is reported here and acted on separately - see `state/autosync`, + * which runs after this, only when the student has already chosen to keep + * their login in the OS credential vault, and never blocks anything. */ export type FindingKind = - "reconcile" | "stale" | "empty" | "attendance" | "corrupt" | "save"; + "reconcile" | "stale" | "empty" | "attendance" | "corrupt" | "save" + /** Raised by the background sync, not by this check. */ + | "sync"; export interface Finding { kind: FindingKind; diff --git a/app/src/state/nav.ts b/app/src/state/nav.ts index c9afdb2..86148bc 100644 --- a/app/src/state/nav.ts +++ b/app/src/state/nav.ts @@ -12,12 +12,35 @@ import { state } from "./store"; export type View = "home" | "ledger" | "attendance" | "history" | "data"; -export const VIEWS: Array<{ id: View; label: string; hint: string }> = [ - { id: "home", label: "Home", hint: "Where you stand and what needs doing" }, - { id: "ledger", label: "Semester", hint: "Marks, attendance and what you still need" }, - { id: "attendance", label: "Attendance", hint: "How many classes you can still miss, per subject" }, - { id: "history", label: "History", hint: "Published results from past semesters" }, - { id: "data", label: "Data", hint: "Sync, import, catalogue and backup" }, +/** + * `keys` are the words a student uses for a screen that are not its name. + * + * The search box strips domain words - "attendance", "marks", "classes" - out + * of a question before matching subjects, because otherwise every phrasing of + * every question matches every subject. But those same words are precisely what + * names a SCREEN, so "am i short on attendance" matched nothing at all: the one + * useful word in it had been removed before anything was compared. + * + * Views are therefore matched on these instead, against the raw question. They + * are an explicit list rather than the hint text because the hints are English + * sentences - matching "what needs doing" would have "what" open Home. + */ +export const VIEWS: Array<{ id: View; label: string; hint: string; keys: string[] }> = [ + { id: "home", label: "Home", hint: "Where you stand and what needs doing", + keys: ["cgpa", "overall", "standing", "summary", "risk", "target", "goal"] }, + { id: "ledger", label: "Semester", hint: "Marks, attendance and what you still need", + keys: ["mark", "marks", "cie", "ese", "internal", "series", "subject", + "subjects", "grade", "grades", "pass", "fail", "sgpa"] }, + { id: "attendance", label: "Attendance", hint: "How many classes you can still miss, per subject", + keys: ["attendance", "absent", "present", "bunk", "leave", "leaves", "skip", + "miss", "class", "classes", "eligible", "eligibility", "debar", + "debarred", "condonation", "timetable", "schedule"] }, + { id: "history", label: "History", hint: "Published results from past semesters", + keys: ["history", "past", "previous", "last", "result", "results", + "gradecard", "transcript", "semester"] }, + { id: "data", label: "Data", hint: "Sync, import, catalogue and backup", + keys: ["sync", "etlab", "ktu", "import", "export", "backup", "restore", + "password", "login", "portal", "catalogue"] }, ]; // Home is the landing screen: the ledger answers "what are my marks", which is diff --git a/app/src/state/store.ts b/app/src/state/store.ts index 627c4aa..e34d53b 100644 --- a/app/src/state/store.ts +++ b/app/src/state/store.ts @@ -5,7 +5,7 @@ import { attendanceTargetGap, cgpaFromSemesters, checkAttendanceTarget, checkGpaTarget, courseFromCode, defaultState, evaluate, historyCredits, horizonToGraduation, normaliseTargets, planForSgpa, reconcileSgpaTarget, requiredSgpaForCgpa, - sgpaTargetFor, statusFor, summarise, toFloat, toOptionalFloat, + sameSgpa, sgpaTargetFor, statusFor, summarise, toFloat, toOptionalFloat, } from "../engine"; import type { Course, HistorySource, MarkInput, SemesterHistory, Targets } from "../engine"; import type { AppState, Semester } from "../engine/course"; @@ -394,11 +394,33 @@ export function setHistory(name: string, sgpa: number, creditsRegistered: number // `mergeHistory` instead would let a stored grade card discard the credits // the student just typed, which is the opposite of the screen's job. const prev = s.history[name]; + // Whether the SGPA itself moved decides the provenance, and only that. + // + // Editing the credits box leaves the figure the card published, so the + // record is still the card's - it keeps `gradecard` and outranks a portal + // scrape on the next sync, which is the screen's job. + // + // Typing over the SGPA is a different act. The number is no longer the one + // the university published, and leaving `source: "gradecard"` on it would + // have the app claim a grade card said something it never said - and hold + // that claim at rank 3, where a later real fetch of the correct figure ties + // with it and can silently lose. A hand-typed figure is `manual`, rank 2: + // still trusted over a scrape, because it was deliberate, and correctly + // beaten by the card it replaced if that card is fetched again. + const changed = prev !== undefined && !sameSgpa(prev.sgpa, sgpa); + // The displaced figure is not discarded. `conflict` exists to hold a value + // from another source that disagrees with the stored one, which is exactly + // what the card's number now is - so the row goes on showing "the KTU grade + // card said 8.42" and the student can put it back. + const displaced = changed && prev.source !== "manual" + ? { source: prev.source, sgpa: prev.sgpa } + : prev?.conflict ?? null; + s.history[name] = { sgpa, creditsRegistered, creditsEarned: prev?.creditsEarned ?? null, - source: prev?.source ?? "manual", - conflict: prev?.conflict ?? null, + source: changed ? "manual" : prev?.source ?? "manual", + conflict: displaced, }; }); } diff --git a/app/src/styles/app.css b/app/src/styles/app.css index 0a53d32..5c156b6 100644 --- a/app/src/styles/app.css +++ b/app/src/styles/app.css @@ -10,10 +10,13 @@ .app { display: grid; grid-template-rows: auto auto auto 1fr; + /* The header carries navigation again. One row of five peer views is not + enough content to earn a 13rem column of its own, and the rail cost the + ledger - the widest thing in the app - that column on every screen. What + made the first attempt cluttered was not the tabs, it was the tabs sharing + a strip with a wordmark, a semester list and a KPI block; the KPIs now live + in the profile block and the strip has room. */ grid-template-columns: 1fr auto; - /* The launch check's findings get their own row directly under the header. - Without a named area they fell to the end of the grid, which put a - warning about attendance below the table it was warning about. */ grid-template-areas: "head head" "notice notice" @@ -22,13 +25,45 @@ height: 100%; } +/* --- header pills ---------------------------------------------------------- */ + +.pill { + align-items: center; + appearance: none; + background: var(--surface-1); + border: 1px solid var(--hairline); + border-radius: 999px; + color: var(--text-dim); + cursor: pointer; + display: inline-flex; + font: inherit; + font-size: var(--text-sm); + gap: var(--s2); + padding: var(--s2) var(--s4); + transition: background var(--dur-fast) var(--ease), + border-color var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} +.pill:hover { background: var(--surface-2); color: var(--text); } + +/* The search pill is wider than its label so it reads as a field to type in + rather than a button that does something. */ +.ask .kbd { + border: 1px solid var(--hairline); + border-radius: var(--radius); + color: var(--text-faint); + font-family: var(--font-mono); + font-size: var(--text-xs); + padding: 0 0.35rem; +} + /* --- header --------------------------------------------------------------- */ .head { grid-area: head; display: flex; align-items: center; - gap: var(--s5); + gap: var(--s4); padding: var(--s3) var(--s5); border-bottom: 1px solid var(--hairline); /* The one genuinely glass surface in the app: the table scrolls underneath @@ -86,14 +121,6 @@ .winbtn-close:hover { background: var(--danger); color: oklch(0.98 0 0); } .winbtn:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; } -.wordmark { - font-size: var(--text-lg); - font-weight: 700; - letter-spacing: var(--tracking-tight); - margin: 0; -} -.wordmark span { color: var(--brand); } - .sems { display: flex; gap: 1px; margin-left: var(--s2); } .sem { @@ -538,3 +565,520 @@ tr.row:hover .del { opacity: 1; } made every sentence a headline. */ .route-panel .chart-note .num { font-size: var(--text-sm); } .route-panel .chart-note strong { color: var(--text); font-weight: 600; } + +/* --- command palette ------------------------------------------------------- + * + * A dialog is the right container here for the reason the craft floor allows + * one: it needs protected focus. It is a search box that takes over the + * keyboard, and anything less than a modal leaves arrow keys fighting the + * table underneath. + */ +.palette-scrim { + position: fixed; + inset: 0; + z-index: 40; + display: flex; + justify-content: center; + align-items: start; + padding-block-start: 12vh; + background: oklch(0 0 0 / 0.42); + backdrop-filter: blur(2px); +} + +.palette { + inline-size: min(38rem, calc(100vw - 2rem)); + max-block-size: 60vh; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--surface-1); + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-card); +} + +.palette-input { + appearance: none; + background: transparent; + border: 0; + border-block-end: 1px solid var(--hairline); + color: var(--text); + font: inherit; + font-size: var(--text-lg); + padding: var(--s4); +} +.palette-input:focus { outline: none; } +.palette-input::placeholder { color: var(--text-faint); } + +.palette-list { + list-style: none; + margin: 0; + padding: var(--s2); + overflow-y: auto; +} + +.palette-hit { + appearance: none; + background: transparent; + border: 0; + border-radius: var(--radius-lg); + color: inherit; + cursor: pointer; + display: grid; + grid-template-columns: 4.5rem 1fr auto; + gap: var(--s3); + align-items: baseline; + inline-size: 100%; + font: inherit; + padding: var(--s2) var(--s3); + text-align: start; +} +.palette-hit[aria-selected="true"] { background: var(--surface-2); } + +.palette-kind { + color: var(--text-faint); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; +} +.palette-label { color: var(--text); font-weight: 500; } +.palette-detail { color: var(--text-faint); font-size: var(--text-xs); } + +.palette-empty { color: var(--text-faint); margin: 0; padding: var(--s5); } + +/* --- header identity, notifications and account ---------------------------- + * + * The header's shape: a home button, the views, then a wide search, then the + * two things that are about you rather than about your marks - what needs your + * attention, and who you are signed in as. + */ + +/* Home is a button, not a tab. It is the app's own mark, it is where every + other view returns to, and giving it the one filled swatch in the header + makes the wordmark and the landing route the same target instead of two + things competing for the top-left corner. */ +.homebtn { + appearance: none; + border: 0; + border-radius: var(--radius-lg); + background: var(--brand); + color: oklch(0.99 0 0); + cursor: pointer; + display: grid; + place-items: center; + inline-size: 2.1rem; + block-size: 2.1rem; + flex: none; + transition: filter var(--dur-fast) var(--ease); +} +.homebtn:hover { filter: brightness(1.12); } +.homebtn[aria-current="true"] { box-shadow: 0 0 0 3px var(--brand-wash); } + +/* The ask box takes ALL the slack, with no cap. + * + * Both earlier attempts left dead air because something in the strip was + * capped: a 30rem maximum, then a 22rem fixed width, and on a 1920px window + * every remaining pixel had nowhere to go but into a gap. Exactly one element + * here is allowed to be elastic and it is this one, so there is no free space + * left to distribute. It is also the largest control in the header on purpose: + * this is the box you type a question into, and a question box the size of a + * button reads as a button. */ + +/* Every square glyph button in the header - refresh, notifications - is one + drawing. They sit side by side and any difference between them would read as + meaning something. */ +.iconbtn { + appearance: none; + background: transparent; + border: 0; + border-radius: var(--radius-lg); + color: var(--text-dim); + cursor: pointer; + display: grid; + place-items: center; + inline-size: 2.1rem; + block-size: 2.1rem; + position: relative; + flex: none; +} +.iconbtn:hover:not(:disabled) { background: var(--surface-2); color: var(--text); } +.iconbtn:disabled { cursor: default; } +/* Not dimmed while it works. A control that fades the moment it is pressed + reads as having failed, and this one is doing exactly what was asked. */ +.refresh:disabled { color: var(--text-dim); } + +/* A count, not a dot. "3 things" and "something" are different messages, and + the student is deciding whether to stop what they are doing. */ +.bell-badge { + position: absolute; + inset-block-start: 1px; + inset-inline-end: 0; + min-inline-size: 1rem; + padding: 0 3px; + border-radius: 999px; + background: var(--danger); + color: oklch(0.99 0 0); + font-family: var(--font-mono); + font-size: 0.62rem; + line-height: 1rem; + text-align: center; +} + +.profile { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: var(--radius-lg); + color: inherit; + cursor: pointer; + display: flex; + font: inherit; + gap: var(--s3); + padding: var(--s1) var(--s2); + flex: none; + text-align: start; +} +.profile:hover { background: var(--surface-2); } + +.avatar { + display: grid; + place-items: center; + inline-size: 2rem; + block-size: 2rem; + border-radius: 999px; + background: var(--surface-3); + border: 1px solid var(--hairline); + color: var(--text-dim); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: var(--tracking-wide); + flex: none; +} + +.profile-lines { display: flex; flex-direction: column; line-height: 1.25; } +.profile-name { font-size: var(--text-sm); font-weight: 600; } +/* The CGPA moved here from its own header KPI block. It is an identity fact - + it is who you are academically - and it was the largest number in a strip + that then had nowhere left to put navigation. */ +.profile-sub { + color: var(--text-faint); + font-family: var(--font-mono); + font-size: var(--text-xs); +} + +/* --- popovers -------------------------------------------------------------- */ + +/* Positioned by the Popover component, in viewport coordinates, because it is + rendered into document.body rather than beside its trigger - see the note in + Popover.tsx for the WebView2 clipping bug that forces this. Fixed rather + than absolute for the same reason: there is no positioned ancestor left. */ +.pop { + position: fixed; + z-index: 30; + inline-size: 22rem; + max-block-size: 60vh; + overflow-y: auto; + padding: var(--s3); + background: var(--surface-1); + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-card); +} +.pop-title { + color: var(--text-faint); + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: var(--tracking-wide); + margin: 0 0 var(--s2); + padding-inline: var(--s2); + text-transform: uppercase; +} +.pop-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--s2); + border-radius: var(--radius-lg); +} +.pop-item + .pop-item { border-block-start: 1px solid var(--hairline); } +.pop-item strong { font-size: var(--text-sm); font-weight: 600; } +.pop-item .dim { color: var(--text-faint); font-size: var(--text-xs); } +.pop-empty { color: var(--text-faint); font-size: var(--text-sm); margin: 0; padding: var(--s2); } +.pop-row { + align-items: center; + display: flex; + gap: var(--s3); + justify-content: space-between; + padding: var(--s2); +} +.pop-row + .pop-row { border-block-start: 1px solid var(--hairline); } + +/* The mark is drawn in brand colours, which is right everywhere except on top + of a brand-filled square, where it disappeared into its own background. On + the home button it is repainted in the button's foreground - keeping the + asymmetry that stops an X reading as a close button, but expressed as two + opacities of white rather than two greens. */ +.homebtn svg line { stroke: currentColor; } +.homebtn svg line:first-of-type { stroke: oklch(1 0 0 / 0.5); } + +/* The label goes hard to the start behind its magnifier and the shortcut hard + to the end. Centring the pair made the widest control in the header read as + a title rather than as a field. */ + +/* --- the ask box ---------------------------------------------------------- + * + * NOT a `.pill`. It was one, and `.pill` is defined twice in this file - once + * for this header and once for the status chips ("3 short of 75%") - so the + * chip rule won on source order and quietly handed a search field a chip's + * centred text. A control you type into and a badge you read have nothing in + * common but a rounded end; sharing a class name was the whole bug. + */ +.ask { + align-items: center; + appearance: none; + background: var(--surface-1); + border: 1px solid var(--hairline); + border-radius: 999px; + color: var(--text-dim); + cursor: text; + display: flex; + font: inherit; + font-size: var(--text-sm); + gap: var(--s3); + justify-content: flex-start; + padding: var(--s2) var(--s4); + text-align: start; + + /* Elastic, capped, and centred by its own auto margins. The cap stops it + becoming a 900px bar on a wide monitor; the auto margins mean the pixels + the cap gives back are split evenly on both sides instead of pooling into + one dead gap beside the bell, which is what every earlier version did. */ + flex: 1 1 auto; + min-inline-size: 12rem; + max-inline-size: 34rem; + margin-inline: auto; + transition: background var(--dur-fast) var(--ease), + border-color var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} +.ask:hover { background: var(--surface-2); color: var(--text); } +.ask:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; } + +/* While the palette is open it IS this control, grown. Two copies on screen + would give the morph something to visibly separate from, which is the exact + impression the morph exists to remove. Opacity, not display: the box has to + keep its size, because the palette measures it to know where to shrink back + to - and a hidden element has no rect. */ +:root[data-palette="open"] .ask { opacity: 0; } + +.ask > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ask svg { flex: none; opacity: 0.7; } +/* Hard to the far end, so the shortcut reads as the field's affordance rather + than as the last two words of the placeholder sentence. */ +.ask .kbd { margin-inline-start: auto; flex: none; } + +/* --- notification rows ----------------------------------------------------- */ + +.pop-head { + align-items: baseline; + display: flex; + gap: var(--s3); + justify-content: space-between; +} +.pop-head .pop-title { margin-block-end: var(--s2); } + +.pop-item-head { + align-items: start; + display: flex; + gap: var(--s2); + justify-content: space-between; +} + +/* Present at all times rather than on hover. A control that only exists once + the pointer finds it is a control a keyboard user is told about by nothing, + and this row is already quiet enough that a small glyph does not shout. */ +.pop-x { + appearance: none; + background: transparent; + border: 0; + border-radius: var(--radius); + color: var(--text-faint); + cursor: pointer; + flex: none; + font: inherit; + font-size: var(--text-base); + line-height: 1; + padding: 2px 5px; +} +.pop-x:hover { background: var(--surface-2); color: var(--text); } +.pop-x:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; } + +/* --- actions inside a popover --------------------------------------------- + * + * `.link primary` was reused here from the launch banner, where `.launch-notice + * .link.primary` scopes it back to a link. Unscoped it matches `button.primary` + * as well, so the sign-in control rendered as a solid swatch with link metrics + * and wrapped "Sign in" onto two lines inside it. + */ +.pop-row > span { flex: 1 1 auto; min-inline-size: 0; } + +.pop-action { + appearance: none; + background: var(--brand); + border: 1px solid transparent; + border-radius: var(--radius-lg); + color: oklch(0.99 0 0); + cursor: pointer; + flex: none; + font: inherit; + font-size: var(--text-sm); + font-weight: 600; + padding: var(--s2) var(--s4); + /* The row is `space-between` and the copy beside it is two lines long. With + nothing stopping it, the action is the thing that gives up its width. */ + white-space: nowrap; +} +.pop-action:hover:not(:disabled) { filter: brightness(1.1); } +.pop-action:disabled { cursor: default; opacity: 0.6; } +.pop-action:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; } + +/* The quiet counterpart: signing out and opening Data are not the thing the + row is recommending, so they stay text. */ +.pop-row > .link { flex: none; white-space: nowrap; } + +/* --- the signed-in face ---------------------------------------------------- */ + +/* Fills the circle it sits in. A non-square source - some providers return + one - would otherwise letterbox inside a round frame, which reads as broken + rather than as a wide photo. */ +.avatar-img { + inline-size: 100%; + block-size: 100%; + border-radius: 999px; + object-fit: cover; + display: block; +} +/* The generic figure needs the padding; a photograph needs the whole circle. */ +.avatar:has(.avatar-img) { border-color: transparent; } + +.who { align-items: center; display: flex; gap: var(--s3); min-inline-size: 0; } +.who-lines { display: flex; flex-direction: column; min-inline-size: 0; } +/* A long Google display name or a long address must not push the sign-out + button off the row. */ +.who-lines > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.who-face { + inline-size: 2.25rem; + block-size: 2.25rem; + border-radius: 999px; + object-fit: cover; + flex: none; +} + +/* An informational count, not an alarm. The empty-record finding fires on a + brand new install, and a red badge there is the app telling a student off for + having just arrived. */ +.bell-badge.info { + background: var(--surface-3); + color: var(--text-dim); + border: 1px solid var(--hairline-strong); + line-height: calc(1rem - 2px); +} + +/* --- the sign-in acknowledgement ------------------------------------------- + * + * Bottom-right, over the content, gone in four seconds. It confirms something + * that already succeeded, so it must not be dismissible - a confirmation that + * has to be closed is a second task handed to someone who just finished one. + */ +.toast { + position: fixed; + inset-block-end: var(--s5); + inset-inline-end: var(--s5); + z-index: 40; + display: flex; + align-items: center; + gap: var(--s4); + max-inline-size: 24rem; + padding: var(--s3) var(--s4); + background: var(--surface-1); + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-card); + /* The one place a shadow is right: this floats over content that is still + there underneath it, and tokens.css's "elevation by lightness" rule has no + lightness step left above surface-1 in light mode. */ + box-shadow: 0 8px 28px oklch(0 0 0 / 0.16); + animation: toast-in var(--dur) var(--ease); +} + +.toast-face { + inline-size: 2.5rem; + block-size: 2.5rem; + border-radius: 999px; + object-fit: cover; + flex: none; +} + +.toast-lines { display: flex; flex-direction: column; gap: 2px; min-inline-size: 0; } +.toast-lines strong { font-size: var(--text-sm); } +.toast-lines .dim { color: var(--text-faint); font-size: var(--text-xs); } + +@keyframes toast-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} + +/* It still appears, it just stops sliding. Removing the animation entirely + would make it pop into existence, which is more startling than the motion. */ +@media (prefers-reduced-motion: reduce) { + .toast { animation: none; } +} + +/* The answer, above the results it is not a substitute for. + Elevation by lightness, per the constraint in tokens.css: this sits on the + palette surface and lifts by one step, never by a shadow. */ +.palette-answer { + padding: var(--s4) var(--s5); + background: var(--brand-wash); + border-bottom: 1px solid var(--hairline); +} +.palette-answer-head { + margin: 0; + font-size: var(--text-base); + line-height: 1.45; + color: var(--text); +} +.palette-answer-lines { + margin: var(--s3) 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: var(--s2); +} +.palette-answer-lines li { + font-size: var(--text-sm); + color: var(--text-dim); +} +.palette-answer > button.link { margin-top: var(--s3); } + +/* The raw attendance counts on a ledger row. Sits after the percentage as its + working, quieter than it, and never wraps - a fraction broken across two + lines reads as two numbers. */ +.att-raw { + margin-left: var(--s2); + font-size: var(--text-xs); + color: var(--text-faint); + white-space: nowrap; +} + +/* Who is answering, and on what authority. A definition is the regulation; a + figure is this student's record. Same block, different ground, so the two + kinds of claim are never mistaken for each other. */ +.palette-answer-who { + margin: 0 0 var(--s2); + font-size: var(--text-xs); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-faint); +} +.palette-answer.definition { background: var(--surface-2); } diff --git a/app/src/styles/calendar.css b/app/src/styles/calendar.css index a29d3f7..8a70376 100644 --- a/app/src/styles/calendar.css +++ b/app/src/styles/calendar.css @@ -112,9 +112,20 @@ font-weight: 600; color: var(--text); /* Fixed layout sizes a column from its width, not its content, so the day - name needs an explicit one or it collapses to an even share. */ - width: 11ch; - min-width: 11ch; + name needs an explicit one or it collapses to an even share. + + 14ch, not 11ch, and the number is measured rather than judged by eye. + `ch` is the width of a ZERO, and no day name is made of zeroes: at 11ch + the column offered 63px of inner width while "Wednesday" sets 75.2px in + Space Grotesk 600, so it spilled 12.2px - and because this cell is + `position: sticky` with its own background, the spill painted straight + over Period 1 instead of being clipped. Thursday cleared by 2.6px, which + is to say the column was under-sized for the whole set and Wednesday was + merely the first to show it. 14ch gives 88px of inner width: 12.8px of + slack on the longest name, enough to survive the fallback font being + wider than Space Grotesk. */ + width: 14ch; + min-width: 14ch; } .grid-table thead th.grid-label { @@ -270,3 +281,15 @@ font-size: var(--text-sm); text-align: center; } + +/* The status glyph inside a calendar block. Centring only - the block already + carries its size and colour; this makes the shape legible without hue. */ +.cal-block { + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + line-height: 1; + font-weight: 700; + color: var(--bg); +} diff --git a/app/src/styles/motion.css b/app/src/styles/motion.css index 0051f1d..76e070d 100644 --- a/app/src/styles/motion.css +++ b/app/src/styles/motion.css @@ -175,9 +175,10 @@ button:active:not(:disabled) { transform: translateY(1px); } } .boot.leaving .splash-note { opacity: 0; transition: opacity var(--fast) var(--ease); } -/* The wordmark's X is empty until the flying one lands on it. */ -.wordmark-x { display: inline-block; line-height: 0; vertical-align: -0.12em; } -.wordmark-x.waiting { opacity: 0; } +/* The home button is an empty swatch until the flying X lands in it. The + square itself stays visible - only the mark inside is held back, so the + header does not reflow when the animation finishes. */ +.homebtn.waiting > svg { opacity: 0; } /* --- inline busy ---------------------------------------------------------- */ @@ -327,3 +328,16 @@ button:active:not(:disabled) { transform: translateY(1px); } animation: crawl 1.1s var(--ease) infinite; transform-origin: center; } + +/* The refresh glyph, turning while two networks are being waited on. There is + no percentage to report here, and a bar that fakes one would be lying about + knowing how far along it is. */ +@keyframes spin { to { transform: rotate(360deg); } } +.spin { animation: spin 900ms linear infinite; transform-origin: 50% 50%; } + +/* Reduced motion gets a pulse rather than a freeze: something must still say + "working", and a stationary icon on a disabled button says "broken". */ +@media (prefers-reduced-motion: reduce) { + .spin { animation: pulse-dim 1.4s var(--ease) infinite; } + @keyframes pulse-dim { 50% { opacity: 0.35; } } +} diff --git a/app/src/styles/screens.css b/app/src/styles/screens.css index 93aa88c..171c1ec 100644 --- a/app/src/styles/screens.css +++ b/app/src/styles/screens.css @@ -264,6 +264,26 @@ label { color: var(--text-dim); display: block; font-size: var(--text-sm); } transition: border-color var(--fast) var(--ease), background var(--fast) var(--ease); } .route:hover { background: var(--surface-2); border-color: var(--brand-deep); } + +/* The recommended route has to LOOK recommended. + * + * All three cards were one drawing with a small coloured eyebrow on the first, + * which asks a student to read three paragraphs to discover that the app + * already has an opinion. It has one - portal sync brings every semester, + * attendance, internals and published results in a single pass, and the other + * two are for students who cannot use it. So the recommendation carries a + * brand edge and a tinted ground, and the alternatives quiet down. */ +.route.recommended { + background: var(--brand-wash); + border-color: var(--brand-deep); +} +.route.recommended:hover { border-color: var(--brand); } +.route.recommended strong { font-size: var(--text-xl); } + +/* Not disabled-looking, just secondary. These are real choices and a student + with no portal login needs them to be obviously available. */ +.route.alt { background: transparent; } +.route.alt strong { font-size: var(--text-base); } .route strong { color: var(--text); font-size: var(--text-lg); font-weight: 600; } .route span { color: var(--text-dim); font-size: var(--text-sm); line-height: 1.55; } .route-tag { @@ -411,19 +431,57 @@ label { color: var(--text-dim); display: block; font-size: var(--text-sm); } align-items: start; } -.card { +/* --- the container surface ------------------------------------------------- + * + * ONE treatment, shared. `.card` and `.tile` used to declare the same six + * properties independently - same gradient border-box, same `--surface-1`, same + * transparent 1px border - and differed only in radius and padding. Two names + * for one drawing meant a change to "the card" silently missed half the app, + * and it made every screen look like every other screen for no authored reason. + * + * The border is a gradient, not a colour: brightest at the top-left and faded + * by the bottom, so a container reads as a raised pane catching light rather + * than a rectangle with an outline. + * + * `isolation` makes each container its own stacking context so the pointer + * light `::before` (motion.css) can sit at z-index -1 - above the surface, + * below the content, clipped to the corners. `relative` anchors it. + */ +.card, +.tile { background: linear-gradient(var(--surface-1), var(--surface-1)) padding-box, linear-gradient(155deg, var(--edge-hi), var(--edge-mid) 42%, var(--edge-lo)) border-box; border: 1px solid transparent; - border-radius: var(--radius-lg); - padding: var(--s4); - /* Stacking context for the pointer-light `::before` (motion.css): `relative` - anchors it, `isolation` lets its z-index -1 sit above the card's surface - and below its content. Same setup the `.tile` carries. */ + border-radius: var(--radius-card); position: relative; isolation: isolate; } + +/* The second ground, and the whole point of having a ramp. + * + * `--surface-2` existed in the tokens from the start while `--surface-1` was + * left carrying every content container in the app, so no screen had a visible + * first thing - hierarchy fell entirely on type size, and Home was spending + * four numeric focal points at one elevation. This is the promotion: at most + * ONE per screen, on the element that is genuinely the subject. + * + * Note what it does NOT do, because the ramp is not monotonic across themes. + * In the dark, `--surface-2` (0.245) sits above `--surface-1` (0.195) and the + * promoted container reads as raised. On paper there is no headroom - the + * ground is 0.955 and `--surface-1` is already 0.99 - so `--surface-2` (0.925) + * sits BELOW the ground and the same container reads as a heavier, weightier + * panel instead. Both separate the subject from its siblings, which is the + * job; only the direction differs, and claiming "raised" in both would be a + * comment that contradicts the pixels in one of them. */ +.card.promoted, +.tile.promoted { + background: + linear-gradient(var(--surface-2), var(--surface-2)) padding-box, + linear-gradient(155deg, var(--edge-hi), var(--edge-mid) 42%, var(--edge-lo)) border-box; +} + +.card { padding: var(--s4); } .card h3 { font-size: var(--text-lg); font-weight: 600; @@ -585,26 +643,12 @@ label { color: var(--text-dim); display: block; font-size: var(--text-sm); } align-items: start; } +/* Layout only. The surface itself is the shared `.card, .tile` rule above. */ .tile { - /* The border is a gradient, not a colour: a hairline that is brightest at - the top-left and fades away by the bottom, so the card reads as a raised - pane catching light rather than as a rectangle with an outline. Two - backgrounds, one clipped to the padding box and one to the border box, - is the only way to put a gradient in a border without a pseudo-element. */ - background: - linear-gradient(var(--surface-1), var(--surface-1)) padding-box, - linear-gradient(155deg, var(--edge-hi), var(--edge-mid) 42%, var(--edge-lo)) border-box; - border: 1px solid transparent; - border-radius: 14px; display: flex; flex-direction: column; gap: var(--s3); padding: var(--s5); - /* `isolation` makes the tile its own stacking context so the pointer-light - `::before` (motion.css) can sit at z-index -1: above the tile's surface, - below its content, clipped to the rounded corners. `relative` anchors it. */ - position: relative; - isolation: isolate; } /* The two cards allowed to occupy two columns: standing is the question the @@ -806,7 +850,14 @@ label { color: var(--text-dim); display: block; font-size: var(--text-sm); } context, not the subject. */ .home .trend svg { margin: 0 auto; max-width: 560px; } -.empty-home { max-width: 60ch; } +/* Centred in the space it has, not pinned to a corner of it. An empty record + leaves the whole screen free, and a single small card in the top-left reads + as the page having failed to load the rest. */ +.empty-home { + max-width: 60ch; + margin-inline: auto; + margin-block-start: 6vh; +} .empty-home h3 { font-size: var(--text-lg); font-weight: 600; margin: 0; } /* The appearance control sits with the tabs but is not one - it changes how @@ -934,24 +985,144 @@ a.link:hover { text-decoration-thickness: 2px; } user-select: all; } -/* --- attendance: the miss budget, drawn ------------------------------------ +/* --- attendance: the threshold meter --------------------------------------- + * + * This replaces the pip strip, which drew one square per class. That strip had + * two faults. It scaled with the WRONG number - a student at 61% got a wall of + * up to thirty amber squares, so the drawing that meant "you are in trouble" + * was simply bigger than the one that meant "you have room", and read as a + * punishment graphic. And it drew the budget while saying nothing about the two + * lines the budget exists because of. + * + * KTU has two attendance thresholds, not one, and the gap between them is the + * whole reason this app exists: 75% is eligibility - miss it and you cannot sit + * the exam - while full marks under R 7.5.ii need 85%. Every point between them + * is internal marks bleeding away silently, and no other calculator shows it. + * So the meter marks BOTH lines and shades the band between them. The student + * sees where they stand, what they are about to lose, and what they are trying + * to clear, in one glyph. * - * Reuses the Data screen's `.cards` grid and `.card` shell so the surface reads - * as the same app - the only new thing is the strip. Each pip is one class: a - * filled square the student can still skip, or one they owe back. The colour is - * a status colour on purpose (room is `--good`, a shortfall is `--warn`), which - * is the one family the tokens reserve for exactly this kind of standing. + * The fill takes the status colour of the band it lands in. The band shading is + * deliberately weak - it is a region, not a value, and must never out-shout the + * fill or the two marks. */ -.att-pips { +.meter { display: flex; - flex-wrap: wrap; - gap: var(--s1); - margin: var(--s2) 0; + flex-direction: column; + gap: var(--s2); + margin: var(--s3) 0 var(--s2); +} + +.meter-track { + position: relative; + block-size: 0.5rem; + border-radius: 999px; + background: var(--surface-2); + overflow: hidden; +} + +.meter-fill { + position: absolute; + inset-block: 0; + inset-inline-start: 0; + border-radius: 999px; + background: var(--good); + /* Grows from the left on mount; the one authored motion on this screen. */ + transition: inline-size 620ms cubic-bezier(0.16, 1, 0.3, 1); } -.att-pip { - block-size: 0.85rem; - border-radius: 2px; - inline-size: 0.85rem; +.meter-fill.warn { background: var(--warn); } +.meter-fill.bad { background: var(--danger); } + +/* The 75-85 band: marks being lost while still eligible. */ +.meter-band { + position: absolute; + inset-block: 0; + background: var(--warn); + opacity: 0.16; +} + +/* A threshold line. `strong` is the 85% full-marks line, the one to aim at. */ +.meter-mark { + position: absolute; + inset-block: -2px; + inline-size: 2px; + background: var(--text-faint); + border-radius: 1px; +} +.meter-mark.strong { background: var(--text-dim); } + +.meter-ends { + display: flex; + justify-content: space-between; + gap: var(--s3); + font-size: var(--text-xs); + color: var(--text-faint); +} +.meter-ends .num { color: var(--text-dim); } + +@media (prefers-reduced-motion: reduce) { + .meter-fill { transition: none; } +} + +/* --- attendance: priced rows ---------------------------------------------- + * + * The reference's neutral label/value list, rewritten in the only unit the + * student actually controls: classes. A row never states a figure the engine + * has not computed - where a plan is unknown the row is simply not rendered, + * rather than printing a zero. + */ +.priced { + display: flex; + flex-direction: column; + border-block-start: 1px solid var(--hairline); + margin-block-start: var(--s3); +} +.priced-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--s3); + padding-block: var(--s2); + border-block-end: 1px solid var(--hairline); + font-size: var(--text-sm); +} +.priced-row dt { color: var(--text-dim); } +.priced-row dd { margin: 0; color: var(--text); font-weight: 500; } + +/* The target invitation, standing where the gauge stands once there is one. + Same footprint, so the card does not resize the moment a target is set - + a layout that jumps on click reads as something having gone wrong. */ +.goal-invite { + display: flex; + flex-direction: column; + gap: var(--s3); + align-items: flex-start; + min-inline-size: 280px; +} +.goal-chips { display: flex; flex-wrap: wrap; gap: var(--s2); } + +/* The per-row remove control on History. + Named `remove-go` rather than `danger`: `button.danger` is a filled + destructive button with its own background, and `class="link danger"` would + match both rules - the collision that once painted a sign-in control as a + bare swatch. Colour alone carries the difference here, so the confirmation + also states what is being discarded in words beside it. */ +.row-remove { text-align: right; white-space: nowrap; } +.confirm-inline { + display: inline-flex; + align-items: center; + gap: var(--s3); + justify-content: flex-end; +} +.confirm-inline .fineprint { color: var(--text-dim); } +button.link.remove-go { color: var(--danger); } +button.link.remove-go:hover { color: var(--danger); font-weight: 600; } + +/* The counts a subject's percentage is computed from. Quiet, because it is + evidence rather than a finding - but present, because a percentage with no + visible numerator and denominator is a number the student cannot check. */ +.att-counts { + margin: 0 0 var(--s3); + font-size: var(--text-sm); + color: var(--text-dim); } -.att-pip.miss { background: var(--good); } -.att-pip.recover { background: var(--warn); } diff --git a/app/src/styles/tokens.css b/app/src/styles/tokens.css index da40bb1..7ed202c 100644 --- a/app/src/styles/tokens.css +++ b/app/src/styles/tokens.css @@ -106,6 +106,11 @@ --radius: 3px; --radius-lg: 6px; + /* The container radius. One value, because `.card` at 6px and `.tile` at a + hard-coded 14px put three different corners on Home at once (tile, card, + notice) for no reason a reader could name. Containers are the largest + shapes on any screen and the most obvious place an inconsistency shows. */ + --radius-card: 12px; /* Motion stays short and eased-out. A status app that animates for a third of a second feels like it is thinking, and this one already knows. */ @@ -165,10 +170,16 @@ --danger-wash: oklch(0.50 0.20 25 / 0.09); /* On paper the lit edge is a shadow, not a highlight: light comes from the - same direction, but a white rim on a white card is invisible. */ - --edge-hi: oklch(0.30 0.01 68 / 0.14); - --edge-mid: oklch(0.30 0.01 68 / 0.06); - --edge-lo: oklch(0.30 0.01 68 / 0.03); + same direction, but a white rim on a white card is invisible. + Strengthened after a screenshot pass: at 0.14/0.06/0.03 the card edge was + effectively invisible against the cream ground, so in light mode the + containers stopped reading as containers at all - dark mode separated them + and light mode did not. The ground/card lightness gap is small here by + design (0.955 vs 0.99), which leaves the edge doing more work on paper + than it does in the dark, not less. */ + --edge-hi: oklch(0.30 0.01 68 / 0.24); + --edge-mid: oklch(0.30 0.01 68 / 0.11); + --edge-lo: oklch(0.30 0.01 68 / 0.05); --glass: oklch(0.955 0.008 68 / 0.74); } diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx index 8da8dc9..4377518 100644 --- a/app/src/ui/App.tsx +++ b/app/src/ui/App.tsx @@ -1,10 +1,11 @@ -import { For, Show, createSignal, onCleanup, onMount } from "solid-js"; +import { For, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"; import { activeCourses, addSemester, attendanceGaps, goalRequirement, hydrate, overall, selectSemester, semesterNames, setAttendanceTarget, setGoal, state, summary, targets, } from "../state/store"; import { VIEWS, needsSetup, setView, view } from "../state/nav"; +import { ASSISTANT } from "../state/answers"; import { appearance, setTheme, startTheme, theme } from "../state/theme"; import { Data } from "./Data"; import { WindowChrome } from "./WindowChrome"; @@ -15,79 +16,19 @@ import { Home } from "./Home"; import { Ledger } from "./Ledger"; import { Setup } from "./Setup"; import { Mark } from "./Mark"; +import { Palette, usePaletteShortcut } from "./Palette"; +import { Popover } from "./Popover"; import { runLaunchCheck, saveFindings } from "../state/launch"; +import { autoRefresh, refreshAll, refreshFailures, refreshing } from "../state/autosync"; +import type { SourceResult } from "../state/autosync"; +import { + authBusy, authConfigured, authError, justSignedIn, resumeAccount, session, + signIn, signOut, signedIn, +} from "../state/auth"; import type { Finding } from "../state/launch"; import { checkForUpdate } from "../sync/update"; import type { Available } from "../sync/update"; -/** - * Header KPIs. - * - * Confirmed and projected sit side by side and are never merged. One blended - * number would be the most flattering thing to show and the least honest — the - * student could not tell which half is real. - */ -function Kpis() { - // With nothing entered every rollup is 0.00, which reads as a student who - // scored zero rather than one who has not started. Dashes say the second - // thing, which is this app's whole discipline applied to its own header. - const started = () => summary().credits > 0 || overall().credits > 0; - const dash = "–"; - - return ( - -
- CGPA - {dash} - nothing recorded yet -
- - }> -
- {/* A live semester makes the header "started", but a CGPA needs a - COMPLETED one. Without that guard a first year saw 0.00 beside - their real projection, which reads as a failed year rather than as - a year that has not finished. */} -
- CGPA - 0} fallback={ - <> - {dash} - no completed semester yet - - }> - {overall().cgpa.toFixed(2)} - {overall().percent.toFixed(1)}% · {overall().credits} cr - -
- -
- Confirmed - {summary().sgpaConfirmed.toFixed(2)} - {summary().creditsConfirmed} of {summary().credits} cr -
-
- Projected - {summary().sgpaProjected.toFixed(2)} - - 0 || summary().unsettled > 0} - fallback={<>all subjects assessed}> - {[ - summary().pending > 0 ? `${summary().pending} not yet assessed` : null, - summary().unsettled > 0 - ? `${summary().unsettled} internal${summary().unsettled === 1 ? "" : "s"} not settled` - : null, - ].filter(Boolean).join(" · ")} - - -
-
-
-
- ); -} - /** * The goal line. * @@ -319,7 +260,8 @@ export function UpdateNotice(props: { update: Available; onDismiss: () => void } /** * A save that is not landing. * - * Sits outside `LaunchNotice` and has no dismiss of its own, on purpose. + * Has no dismiss of its own, and stays a banner rather than moving into the + * notification bell, on purpose. * Everything in that banner reports on something already written down, so * closing it costs nothing; this reports that what is on the screen is NOT * being written down, and the student needs it in front of them until they @@ -345,27 +287,341 @@ export function SaveNotice() { ); } -function LaunchNotice(props: { findings: Finding[]; onDismiss: () => void }) { +/** + * Everything asking for your attention, in one place. + * + * These findings used to be a banner across the top of the app. The banner was + * honest and it was also the first thing between a student and their marks on + * every single launch, saying the same thing every time until it was dismissed. + * A count on a bell states the same fact without spending a row of the screen + * on it, and the two urgent classes stay where they were: a save that is not + * landing keeps its banner, because that one reports the app is losing data, + * and Home keeps its "Needs attention" card, because that is a place a student + * goes to look rather than a thing that interrupts them. + */ +/** + * Refresh both portals, by hand. + * + * The manual sync used to be two separate forms on the Data screen - one per + * portal - and a student who wanted current numbers had to know that there were + * two, find both, and run them in turn. Attendance and published results are + * one question to the person asking it. + * + * This honours none of the automatic run's throttling. The student pressed the + * button; declining to make the request because one ran twenty minutes ago + * would be the app arguing with them about what they just asked for. + * + * It needs both logins in the vault to do anything, and says so rather than + * spinning and reporting success over a portal it never contacted - see the + * per-source rows in the bell, which is where the outcome lands. + */ +function RefreshButton() { + const label = () => (refreshing() ? "Refreshing both portals…" : "Refresh from etlab and KTU"); + + return ( + + ); +} + +export function Bell(props: { findings: Finding[] }) { + const [open, setOpen] = createSignal(false); + /** + * Titles the student has waved away, for this run of the app only. + * + * Not persisted, on purpose. Every finding here is re-derived from the record + * on each launch, so a dismissal saved to disk would be a promise never to + * mention a problem again - and the problems these report do not go away by + * being ignored. Dismissing means "I have read this", not "this is fixed". + */ + const [hidden, setHidden] = createSignal([]); + let anchor: HTMLButtonElement | undefined; + + const live = () => props.findings.filter((f) => !hidden().includes(f.title)); + + /** + * A failed refresh is a notification, not an error dialog. + * + * One row per portal that failed, never a merged "sync failed": etlab and KTU + * are different servers with different logins, and a student whose KTU fetch + * broke while attendance came through fine needs to know that attendance is + * current. Shaped as a launch finding so the list has one kind of row rather + * than two, and routed to Data, which is the screen that can act on it. + */ + const syncFailures = (): Finding[] => + refreshFailures() + .map((r: SourceResult) => ({ + kind: "sync" as const, severity: "warn" as const, + title: `${r.source === "ktu" ? "KTU" : "etlab"} did not refresh`, + detail: r.detail ?? "The portal did not answer.", + goto: "data" as const, action: "Open Data", + })) + .filter((f) => !hidden().includes(f.title)); + + const items = (): Finding[] => [...syncFailures(), ...live()]; + + const dismiss = (title: string) => setHidden((h) => [...h, title]); + const dismissAll = () => { + setHidden((h) => [...h, ...items().map((f) => f.title)]); + setOpen(false); + }; + return ( - 0}> - {/* Appears about half a second after launch, with nobody having done - anything to summon it. Without a live region a screen reader user is - simply not told their data did not reconcile. */} -
- {(f) => ( -
- {f.title} - + <> + + + setOpen(false)} + label="Notifications" anchor={() => anchor}> +
+
+

Needs attention

+ 1}> + +
- )} - -
+ + 0} fallback={ +

+ + Checking both portals… + +

+ }> + {(f) => ( +
+
+ {f.title} + {/* Per-row, because "3 things need attention" is three + different decisions and clearing them together forces the + student to re-read the two they had not dealt with. */} + +
+ {f.detail} + +
+ )}
+
+
+ + + ); +} + +/** + * Account. + * + * The CGPA lives here rather than in its own header block: it is the one number + * that describes the student rather than a semester, and reading it beside + * their name is where the eye already goes. + * + * Sign-in is NOT wired yet. The button says so in plain words instead of + * opening a dialog that cannot finish, because an account control that fails + * silently is worse than one that is honest about not existing. Nothing in the + * app requires an account today - every figure on every screen is computed on + * this machine from data this machine fetched. + */ +/** + * The moment a sign-in lands. + * + * Signing in used to change one line of text inside a menu that the student + * had to still be holding open to see. They pressed a button, a browser window + * flashed past, and the application said nothing - so the only way to find out + * whether it had worked was to go and look. + * + * This is the acknowledgement. It names the person, because being greeted by + * name is the whole proof that the round trip actually carried an identity + * back, and it takes itself away - a confirmation that has to be dismissed is + * a second task handed to someone who just finished one. + */ +function SignedInToast() { + const [showing, setShowing] = createSignal(false); + + // The signal the toast actually watches: an explicit signal set by signIn, + // so a launch resume can never trigger it. + createEffect(() => { + if (!justSignedIn()) return; + setShowing(true); + const t = setTimeout(() => setShowing(false), 4200); + onCleanup(() => clearTimeout(t)); + }); + + return ( + + {(who) => ( +
+ + + + {who().name ? `Signed in as ${who().name}` : "Signed in"} + + {/* Says what it unlocked, because sign-in in this app buys exactly + one thing and a student who does not know that will wonder what + they just handed over. */} + You can ask questions in the search box now. + +
+ )}
); } +function Profile() { + const [open, setOpen] = createSignal(false); + const cgpa = () => (overall().credits > 0 ? overall().cgpa.toFixed(2) : null); + let anchor: HTMLButtonElement | undefined; + + return ( + <> + + + setOpen(false)} + label="Account" anchor={() => anchor}> +
+
+ Appearance + +
+ {/* Sign-in gates the assistant and nothing else, and the copy says + so rather than leaving a student to guess what an account is for + in an app that works entirely offline. */} + + + Accounts are off in this build +
+ Everything here is computed on this machine. +
+
+ }> + + + Not signed in +
+ + + Waiting for your browser… + + +
+ +
+ }> +
+ + + {(src) => } + + + {session()?.name ?? "Signed in"} + {/* The address is the thing that actually answers "which + account is this", which is the only question a signed-in + row has to be able to answer. */} + + {(mail) => {mail()}} + + + + +
+
+ + + {(why) => ( +
+ {why()} +
+ )} +
+ +
+ Data + +
+ + + + ); +} + export function App() { const [setupOpen, setSetupOpen] = createSignal(needsSetup()); startTheme(); @@ -377,10 +633,12 @@ export function App() { // flying: the X is travelling to its place in the wordmark. // done: the overlay is gone. const [phase, setPhase] = createSignal<"boot" | "flying" | "done">("boot"); - let wordmarkX: HTMLSpanElement | undefined; + let homeBtn: HTMLButtonElement | undefined; let flyer: HTMLDivElement | undefined; const [findings, setFindings] = createSignal([]); const [dismissed, setDismissed] = createSignal(false); + const [paletteOpen, setPaletteOpen] = createSignal(false); + usePaletteShortcut(() => setPaletteOpen(true)); const [update, setUpdate] = createSignal(null); const [updateDismissed, setUpdateDismissed] = createSignal(false); @@ -447,7 +705,7 @@ export function App() { setTimeout(() => { setFindings(found); setPhase("flying"); - // The wordmark has to be laid out before it can be measured, and the + // The home button has to be laid out before it can be measured, and the // overlay has to have painted before it can be animated away from. requestAnimationFrame(() => requestAnimationFrame(flyMark)); }, wait); @@ -458,23 +716,35 @@ export function App() { // to null on every failure, so there is nothing to catch and nothing to // report when it finds nothing. setTimeout(() => { void checkForUpdate().then(setUpdate); }, 2000); + + // Refresh from the portal without being asked, but only when the student + // has already put their login in the OS vault - see `autoSync`, which owns + // every precondition. Fire-and-forget and deliberately last: it crosses the + // network to a college server, and nothing on screen may wait on it. + setTimeout(() => { void autoRefresh(); }, 1200); + + // Restore a signed-in account from the refresh token in the OS vault. + // Silent either way: not being signed in is a normal state, and this + // gates nothing but the assistant - every figure on every screen is + // computed here from data this machine already has. + void resumeAccount(); }); /** - * Fly the opening X into the wordmark. + * Fly the opening X into the home button. * * The same drawing in both places, moved rather than swapped: the app does * not cut from a splash to a dashboard, it puts its mark where it lives. * * Measured at the moment it runs rather than hardcoded, because the - * wordmark's position depends on the window width and on whether setup is + * button's position depends on the window width and on whether setup is * showing at all. If there is nothing to fly to - setup is open, or the * header has not rendered - it fades instead, which is also what a student * who has asked for reduced motion gets. */ const flyMark = () => { const node = flyer; - const target = wordmarkX?.getBoundingClientRect(); + const target = homeBtn?.getBoundingClientRect(); const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (!node || !target || target.width < 1 || reduced) { @@ -539,17 +809,44 @@ export function App() { letter: the X is a drawing, and `Mark` hides itself from the accessibility tree unless it is given one - so the only heading on the screen announced as "Target". */} -

Target

- + {/* The wordmark and the home route were two targets in the same + corner. They are one now: the mark is the button, and the app is + named by the window's own title rather than by a heading that had + to be spelled "Target" + a drawing to be announced at all. */} + + + {/* Home is not in this row - it is the button to the left. The rest + are peers with no order, so they stay a flat row. */} + {/* Search sits in the header rather than inside a view because it is + not a view: it crosses all of them. Labelled with its shortcut so + the keyboard route is discoverable without a tour. + + It names Tex, because this control and the palette it grows into + are now visibly one object - and an object that said "Ask + anything" at one size and "Ask Tex" at the other was telling the + student it was two. */} + + - - {activeCourses().length} subjects - - + + + @@ -574,9 +870,6 @@ export function App() { - - setDismissed(true)} /> - {(u) => ( setUpdateDismissed(true)} /> @@ -587,6 +880,9 @@ export function App() { + + setPaletteOpen(false)} /> + diff --git a/app/src/ui/Attendance.tsx b/app/src/ui/Attendance.tsx index 7449b8c..fe5e38b 100644 --- a/app/src/ui/Attendance.tsx +++ b/app/src/ui/Attendance.tsx @@ -1,5 +1,8 @@ import { For, Show, createMemo } from "solid-js"; -import { ATTENDANCE_MIN, courseLabel } from "../engine"; +import { + ATTENDANCE_FULL_MARKS_PCT, ATTENDANCE_MARK_MAX, ATTENDANCE_MIN, + attendanceMarks, courseLabel, daywiseBySubject, toOptionalFloat, +} from "../engine"; import type { AttendancePlan, AttendanceStatus, DaywiseDay, TimetableDay, } from "../engine"; @@ -21,23 +24,23 @@ import { setView } from "../state/nav"; * tell. */ -/** - * Missable / recoverable classes drawn as a strip. - * - * The count in words is the load-bearing fact; the strip is a glance. Capped so - * a subject with forty classes of room does not draw a hundred-pixel ribbon - - * the headline number stays exact and the overflow is stated in words. The - * strip itself is `aria-hidden`, because the same number is announced in the - * heading beside it and a screen reader counting pips would only repeat it. - */ -const MAX_PIPS = 30; - /** A subject's attendance standing, ready to render. */ interface Line { index: number; /** The subject as the student knows it. See `courseLabel`. */ label: string; plan: AttendancePlan | null; + /** + * The counts every other figure on the card is derived from. + * + * They were only ever reachable as editable inputs inside one expanded + * Ledger row, so this screen stated a percentage and a miss budget and never + * showed the two numbers behind them - an app whose whole position is that + * it never states a number it cannot show its working for, not showing the + * working for its loudest number. + */ + attended: number | null; + held: number | null; } export function Attendance() { @@ -46,6 +49,8 @@ export function Attendance() { index: row.index, label: courseLabel(row.course), plan: row.ev.plan, + attended: toOptionalFloat(row.course.attended), + held: toOptionalFloat(row.course.held), }))); return ( @@ -55,8 +60,9 @@ export function Attendance() {

How many classes can you miss?

{state.activeSemester} · the room you have above the {ATTENDANCE_MIN}% - eligibility line, per subject. A full strip is a class you can still - skip; an empty one is a class you owe. + eligibility line, per subject. The meter marks both lines that + matter: {ATTENDANCE_MIN}% to sit the exam, and{" "} + {ATTENDANCE_FULL_MARKS_PCT}% to stop losing internal marks.

@@ -78,6 +84,7 @@ export function Attendance() { + @@ -94,15 +101,25 @@ export function Attendance() { * spelled-out status for the cell's tooltip, so the meaning colour carries is * also available to a pointer and a screen reader, not colour alone. */ -const STATUS_META: Record = { - present: { cls: "present", word: "Present" }, - absent: { cls: "absent", word: "Absent" }, - od: { cls: "credited", word: "On duty" }, - dutyleave: { cls: "credited", word: "Duty leave" }, - duty: { cls: "credited", word: "Duty" }, - leave: { cls: "leave", word: "Leave" }, - holiday: { cls: "holiday", word: "Holiday" }, - none: { cls: "none", word: "No class" }, +/** + * `glyph` is what keeps this grid readable without colour. + * + * The blocks were empty divs distinguished only by hue, and the three that + * matter are close in lightness on both themes - dark --good L0.76, --warn + * L0.80, --danger L0.73 - so under a red-green deficiency they collapse into + * three identical squares. The shape carries the meaning; the colour reinforces + * it. The Ledger already solved this two files away and the pattern was simply + * not applied here. + */ +const STATUS_META: Record = { + present: { cls: "present", word: "Present", glyph: "·" }, + absent: { cls: "absent", word: "Absent", glyph: "×" }, + od: { cls: "credited", word: "On duty", glyph: "~" }, + dutyleave: { cls: "credited", word: "Duty leave", glyph: "~" }, + duty: { cls: "credited", word: "Duty", glyph: "~" }, + leave: { cls: "leave", word: "Leave", glyph: "~" }, + holiday: { cls: "holiday", word: "Holiday", glyph: "" }, + none: { cls: "none", word: "No class", glyph: "" }, }; /** @@ -114,6 +131,110 @@ const STATUS_META: Record = { * the page was never synced the whole thing is one quiet line, never an empty * grid pretending the student has perfect attendance. */ +/** + * The day-by-day record, rolled up per subject and checked against the counts + * the portal published. + * + * Two independent figures for the same fact: what the period log says happened, + * and what the subject page says the totals are. They should agree. When they + * do not, one of them is wrong and the student is the only person who can say + * which - so the app states the disagreement rather than silently preferring + * the number it happens to store. + * + * Subjects are matched from the portal's printed period string back to the + * student's own courses; anything that matches nothing is left out rather than + * shown under a name they would not recognise. + */ +function BySubjectSection() { + const counted = createMemo(() => { + const log = daywiseBySubject(state.daywiseAttendance ?? []); + if (log.size === 0) return []; + return rows().map((row) => { + const code = (row.course.code ?? "").trim().toLowerCase(); + const name = courseLabel(row.course).trim().toLowerCase(); + let found: { attended: number; held: number } | null = null; + for (const [printed, tally] of log) { + const key = printed.toLowerCase(); + if ((code !== "" && key.includes(code)) || (name !== "" && key.includes(name))) { + found = tally; + break; + } + } + return { + label: courseLabel(row.course), + logged: found, + storedAttended: toOptionalFloat(row.course.attended), + storedHeld: toOptionalFloat(row.course.held), + }; + }).filter((r) => r.logged !== null); + }); + + return ( + 0}> +
+
+
+

Counted from the day-by-day record

+

+ What the period log adds up to, per subject, beside the totals the + portal published. They should agree — where they do not, one of + them is wrong. +

+
+
+ +
+ + + + + + + + + + + + {(r) => { + const logged = r.logged!; + const agrees = () => + r.storedAttended !== null && r.storedHeld !== null + && logged.attended === r.storedAttended + && logged.held === r.storedHeld; + const known = () => + r.storedAttended !== null && r.storedHeld !== null; + return ( + + + + + + + ); + }} + + +
SubjectFrom the logPortal totalAgreement
{r.label}{logged.attended}/{logged.held} + {known() ? `${r.storedAttended}/${r.storedHeld}` : "–"} + + no published total to compare + }> + + log says {logged.held - (r.storedHeld ?? 0) > 0 ? "more" : "fewer"} classes + + }> + matches + + +
+
+
+
+ ); +} + function CalendarSection() { const days = createMemo(() => state.daywiseAttendance ?? []); // Every day has the same number of periods after parsing, but a defensive max @@ -138,12 +259,15 @@ function CalendarSection() { 0} fallback={

Sync to see your day-by-day attendance.

}> -