From 5a087317a509bdf42780b5dcbd402731f443396d Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:06:32 +0530 Subject: [PATCH 01/32] Attendance: replace the pip strip with a threshold meter The pip strip drew one square per class, and it scaled with the wrong number: a student at 45% got a wall of thirty amber squares, so the drawing that meant "you are in trouble" was simply larger than the one that meant "you have room". It read as a punishment graphic. It also said nothing about the two lines the budget exists because of. KTU has two attendance thresholds, and the gap between them is the reason this app exists: 75% is eligibility, while full marks under R 7.5.ii need 85%. Every point between is internal marks bleeding away while the student is told they are "fine". The meter marks both lines, shades the band between them, and tints the fill with the status of the band it lands in. The right-hand end prices the position in marks rather than repeating the 85% constant on every card - seven identical labels on one screen said nothing, while "3 of 5 marks" differs per subject and is the figure no other KTU calculator shows. It comes from the engine's own attendanceMarks(); nothing here computes or guesses a figure, and a subject with no percentage on record draws no meter at all rather than an empty track. The track carries a written aria-label with both thresholds and the marks earned, so the fact reaches a screen reader as a fact - the old strip was aria-hidden and conveyed nothing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AkUwXEMQaeVRe8DE8aT5XX --- .../2026-09-01T06-22-44Z__app-src-ui.md | 101 +++++++++++++ app/src/styles/screens.css | 114 +++++++++++++-- app/src/ui/Attendance.tsx | 138 +++++++++++------- app/src/ui/__tests__/Attendance.test.tsx | 31 +++- 4 files changed, 305 insertions(+), 79 deletions(-) create mode 100644 .impeccable/critique/2026-09-01T06-22-44Z__app-src-ui.md 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/app/src/styles/screens.css b/app/src/styles/screens.css index 93aa88c..5ea0a78 100644 --- a/app/src/styles/screens.css +++ b/app/src/styles/screens.css @@ -934,24 +934,106 @@ a.link:hover { text-decoration-thickness: 2px; } user-select: all; } -/* --- attendance: the miss budget, drawn ------------------------------------ +/* --- attendance: the threshold meter --------------------------------------- * - * 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. + * 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. + * + * 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); +} +.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; } -.att-pip { - block-size: 0.85rem; - border-radius: 2px; - inline-size: 0.85rem; +.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); } -.att-pip.miss { background: var(--good); } -.att-pip.recover { background: var(--warn); } +.priced-row dt { color: var(--text-dim); } +.priced-row dd { margin: 0; color: var(--text); font-weight: 500; } diff --git a/app/src/ui/Attendance.tsx b/app/src/ui/Attendance.tsx index 7449b8c..6516e29 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, +} from "../engine"; import type { AttendancePlan, AttendanceStatus, DaywiseDay, TimetableDay, } from "../engine"; @@ -21,17 +24,6 @@ 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; @@ -55,8 +47,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.

@@ -288,18 +281,64 @@ function TimetableSection() { } /** One subject: its standing, the headline number, and the strip. */ +/** + * Where a percentage stands against KTU's two attendance lines, drawn once. + * + * The two lines are the point. `ATTENDANCE_MIN` (75) is eligibility - below it + * the exam cannot be sat - and `ATTENDANCE_FULL_MARKS_PCT` (85) is where R + * 7.5.ii finally pays all five internal marks. The band between them is the + * figure this whole app exists to surface: a student sitting at 78% is + * "fine" by the only number their college quotes them, and is losing internal + * marks every week for it. + * + * Rendered as one `role="img"` with a written label rather than as bare divs, + * because the meter carries a fact and a screen reader must get the fact, not + * a decorative strip. Nothing here computes a figure: `current` arrives from + * the engine, and where it is unknown the caller does not render a meter. + */ +function ThresholdMeter(props: { current: number }) { + const pct = () => Math.max(0, Math.min(100, props.current)); + const tone = () => ( + pct() >= ATTENDANCE_FULL_MARKS_PCT ? "" : + pct() >= ATTENDANCE_MIN ? " warn" : " bad" + ); + /** What R 7.5.ii pays at this percentage. Engine-computed, never guessed. */ + const earned = () => attendanceMarks(pct()) ?? 0; + const label = () => + `Attendance ${pct().toFixed(0)}%. Eligibility line ${ATTENDANCE_MIN}%. ` + + `Full internal marks from ${ATTENDANCE_FULL_MARKS_PCT}%. ` + + `Currently earning ${earned()} of ${ATTENDANCE_MARK_MAX} attendance marks.`; + + return ( +
+ + {/* The right end prices the position in the unit that actually moves the + student's grade. Repeating the 85% constant on every card said the + same thing seven times on one screen; what differs per subject - and + what no other calculator shows - is how many of the five R 7.5.ii + marks this attendance is currently earning. */} +
+ {pct().toFixed(0)}% + + {earned()} of {ATTENDANCE_MARK_MAX} marks + +
+
+ ); +} + function SubjectCard(props: { line: Line }) { const plan = () => props.line.plan; - // How many pips the strip draws, and whether the true count overran the cap. - const budget = () => { - const p = plan(); - if (p === null) return 0; - return p.state === "surplus" ? p.skip : (p.attend ?? 0); - }; - const shown = () => Math.min(budget(), MAX_PIPS); - const overflow = () => Math.max(0, budget() - MAX_PIPS); - return (
@@ -316,8 +355,8 @@ function SubjectCard(props: { line: Line }) {

}> {(p) => ( - + <> + – @@ -328,13 +367,20 @@ function SubjectCard(props: { line: Line }) {
- 0}> - - + }> +
+ {p().skip} + + more class{p().skip === 1 ? "" : "es"} you can miss + +
+ + + {/* One glyph, both lines, every state - replacing the two pip + strips that differed only by hue. */} + + + At {p().current.toFixed(0)}% there is no way back above{" "} @@ -345,31 +391,13 @@ function SubjectCard(props: { line: Line }) { {p().attend} without missing one to get back.

- - }> -
- {p().skip} - - more class{p().skip === 1 ? "" : "es"} you can miss - -
- 0} fallback={ -

No room to spare — the next miss drops you under {ATTENDANCE_MIN}%.

}> - - 0}> -

+{overflow()} more not shown

-
+

+ At {p().current.toFixed(0)}%. Miss more than{" "} + {p().skip} and you drop below {ATTENDANCE_MIN}%. +

-

- At {p().current.toFixed(0)}%. Miss more than{" "} - {p().skip} and you drop below {ATTENDANCE_MIN}%. -

-
+ )}
diff --git a/app/src/ui/__tests__/Attendance.test.tsx b/app/src/ui/__tests__/Attendance.test.tsx index d830317..225208b 100644 --- a/app/src/ui/__tests__/Attendance.test.tsx +++ b/app/src/ui/__tests__/Attendance.test.tsx @@ -14,7 +14,7 @@ */ import { cleanup, render } from "@solidjs/testing-library"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { ATTENDANCE_MIN, attendancePlan } from "../../engine"; +import { ATTENDANCE_FULL_MARKS_PCT, ATTENDANCE_MIN, attendancePlan } from "../../engine"; import type { Course } from "../../engine"; import { addCourse, edit, updateCourse } from "../../state/store"; import { Attendance } from "../Attendance"; @@ -68,9 +68,17 @@ describe("the attendance screen", () => { expect(text).toContain("Compiler Design"); expect(text).toContain("more class"); expect(text).toContain(String(plan.skip)); - // One filled pip per missable class - the strip is the whole point. - expect(c.querySelectorAll(".att-pip.miss")).toHaveLength(plan.skip); - expect(c.querySelectorAll(".att-pip.recover")).toHaveLength(0); + // The meter replaced the pip strip. What matters is that it is drawn from + // the engine's percentage and carries BOTH KTU thresholds in its label - + // the 75/85 gap is the fact this screen exists to show. + const meter = c.querySelector(".meter-track"); + expect(meter).not.toBeNull(); + const label = meter!.getAttribute("aria-label") ?? ""; + expect(label).toContain(`${plan.current.toFixed(0)}%`); + expect(label).toContain(`${ATTENDANCE_MIN}%`); + expect(label).toContain(`${ATTENDANCE_FULL_MARKS_PCT}%`); + // A subject above the full-marks line is not tinted as a warning. + expect(c.querySelectorAll(".meter-fill.bad")).toHaveLength(0); }); it("shows a deficit subject the run of classes needed to recover", () => { @@ -83,8 +91,13 @@ describe("the attendance screen", () => { expect(text).toContain("Computer Networks"); expect(text).toContain("to recover"); expect(text).toContain(String(plan.attend)); - expect(c.querySelectorAll(".att-pip.recover")).toHaveLength(plan.attend!); - expect(c.querySelectorAll(".att-pip.miss")).toHaveLength(0); + // Below the eligibility line the fill carries the danger tone, and the + // label still names both thresholds. + const meter = c.querySelector(".meter-track"); + expect(meter).not.toBeNull(); + expect(meter!.getAttribute("aria-label") ?? "") + .toContain(`${plan.current.toFixed(0)}%`); + expect(c.querySelectorAll(".meter-fill.bad")).toHaveLength(1); }); it("shows an unrecorded subject as 'not recorded', never a percentage", () => { @@ -95,9 +108,11 @@ describe("the attendance screen", () => { const text = c.textContent ?? ""; expect(text).toContain("Data Mining"); expect(text).toContain("not recorded"); - // Never invented as a full or empty house, and no strip drawn. + // Never invented as a full or empty house. No meter either: a meter needs + // a percentage, and there is none - drawing an empty track would be the + // same lie as printing 0%. expect(text).not.toMatch(/\b0%|100%/); - expect(c.querySelectorAll(".att-pip")).toHaveLength(0); + expect(c.querySelectorAll(".meter-track")).toHaveLength(0); }); it("renders every state together without throwing", () => { From 50eadf71eeed7d18f770dc2be886e57b0f78a629 Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:16:40 +0530 Subject: [PATCH 02/32] One container treatment, and a second ground that is actually used .card and .tile declared the same six properties independently - the same gradient border-box, the same --surface-1, the 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 is why every screen looked like every other screen. They now share one rule and carry only their own layout. The ramp existed and went unused. --surface-1 carried every content container while --surface-2 and --surface-3 sat in the tokens, so no screen had a visible first thing and hierarchy fell entirely on type size - Home was spending four numeric focal points at one elevation. A `promoted` modifier moves the one element that is genuinely the subject onto the second ground; Home's Standing tile takes it. At most one per screen. Radius is one value. .card at 6px and .tile at a hard-coded 14px put three different corners on Home at once, counting the notice. The light-mode edge tokens were strengthened (0.14/0.06/0.03 to 0.24/0.11/0.05). A screenshot pass showed the card edge was effectively invisible against the cream ground, so containers stopped reading as containers on paper while dark mode separated them fine. The ground/card lightness gap is deliberately small in light, which leaves the edge doing more work there, not less. 552 tests pass, including the contrast test that reads the real stylesheet. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AkUwXEMQaeVRe8DE8aT5XX --- app/src/styles/screens.css | 66 ++++++++++++++++++++++++++------------ app/src/styles/tokens.css | 19 ++++++++--- app/src/ui/Home.tsx | 4 ++- 3 files changed, 63 insertions(+), 26 deletions(-) diff --git a/app/src/styles/screens.css b/app/src/styles/screens.css index 5ea0a78..636b19b 100644 --- a/app/src/styles/screens.css +++ b/app/src/styles/screens.css @@ -411,19 +411,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 +623,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 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/Home.tsx b/app/src/ui/Home.tsx index 370305e..090710a 100644 --- a/app/src/ui/Home.tsx +++ b/app/src/ui/Home.tsx @@ -260,7 +260,9 @@ export function Home() { }>
-
+ {/* The one promoted surface on Home. Standing is what the screen is + for; everything else on it explains or qualifies this number. */} +
diff --git a/app/src/ui/Palette.tsx b/app/src/ui/Palette.tsx new file mode 100644 index 0000000..c05e0ad --- /dev/null +++ b/app/src/ui/Palette.tsx @@ -0,0 +1,204 @@ +import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"; +import { courseLabel } from "../engine"; +import { rows } from "../state/store"; +import { VIEWS, setView } from "../state/nav"; +import type { View } from "../state/nav"; + +/** + * The command palette. + * + * The app had no keyboard route to anything and no way to find a subject: the + * semester table is fourteen columns with no sort, no filter and no search, so + * a student who knew exactly which subject they were worried about still had to + * read down the grid for it. This is that route. + * + * It ROUTES, it does not answer. Every row here resolves to a view or a subject + * that already exists, and the figures it prints beside a subject come from the + * engine's own evaluation. Nothing is generated, summarised or inferred, which + * is what makes it safe to put a question box at the top of an app whose whole + * trust position is that it never states a number it cannot show its working + * for. When a model is eventually put behind this box, it selects among these + * same rows - it does not get to invent one. + */ + +interface Hit { + kind: "view" | "subject"; + label: string; + /** The line under the label. Engine-computed for subjects. */ + detail: string; + go: () => void; +} + +/** + * Words that carry no target. + * + * A student types a question, not a keyword: "how many classes can I miss in + * ML". Matched literally that finds nothing, because no subject is called + * "how". The question words are dropped and what is left - "classes", "miss", + * "ml" - is matched term by term, so the sentence resolves to the same subject + * the bare code would have. This is not language understanding and does not + * pretend to be: it is a stop list, and every figure it leads to is one the + * engine already computed. + */ +const STOP = new Set([ + "a", "am", "an", "and", "any", "are", "be", "can", "could", "do", "does", + "for", "get", "have", "how", "i", "if", "in", "is", "it", "many", "me", + "more", "much", "my", "of", "on", "or", "should", "show", "still", "take", + "tell", "the", "this", "to", "what", "whats", "when", "where", "which", + "will", "with", "you", + // Domain words that describe the QUESTION rather than name a subject. They + // are in every phrasing of it, so keeping them would match every subject. + "attendance", "bunk", "class", "classes", "cut", "day", "days", "leave", + "leaves", "mark", "marks", "miss", "percent", "percentage", "skip", +]); + +/** The words in a query that could name something. */ +function terms(q: string): string[] { + return q.toLowerCase().split(/[^a-z0-9]+/) + .filter((w) => w.length > 0 && !STOP.has(w)); +} + +/** Subsequence match, so "cn" finds "Computer Networks". */ +function matches(haystack: string, needle: string): boolean { + if (!needle) return true; + const h = haystack.toLowerCase(); + const n = needle.toLowerCase(); + if (h.includes(n)) return true; + let i = 0; + for (const ch of h) { + if (ch === n[i]) i += 1; + if (i === n.length) return true; + } + return false; +} + +export function Palette(props: { open: boolean; onClose: () => void }) { + const [query, setQuery] = createSignal(""); + const [cursor, setCursor] = createSignal(0); + let input: HTMLInputElement | undefined; + + const hits = createMemo(() => { + const q = query().trim(); + // A question with nothing nameable left in it - "how many can I miss" - + // is not a failed search. It is a question about every subject, so every + // subject answers it. + const words = terms(q); + const anyTerm = (hay: string) => + words.length === 0 || words.some((w) => matches(hay, w)); + const out: Hit[] = []; + + for (const v of VIEWS) { + if (!matches(v.label, q)) continue; + out.push({ + kind: "view", label: v.label, detail: v.hint ?? "", + go: () => setView(v.id as View), + }); + } + + for (const row of rows()) { + const label = courseLabel(row.course); + if (!anyTerm(label) && !anyTerm(row.course.code ?? "")) continue; + // Only facts the engine already settled. A subject with no attendance on + // record says so rather than being given a percentage. + const att = row.ev.attendance; + const plan = row.ev.plan; + // "How many classes can I miss" is the question this app was built to + // answer, so it is the FIRST thing a subject row says - not a percentage + // the student then has to do arithmetic on. Both halves come straight + // from the engine's solver: `skip` is how many more classes can be + // missed while staying eligible, `attend` is the run needed to climb + // back. Neither is computed here. + const budget = plan === null + ? null + : plan.state === "surplus" + ? `can miss ${plan.skip} more` + : plan.attend === null + ? "cannot reach 75% this semester" + : `must attend ${plan.attend} in a row`; + const detail = att === null + ? "attendance not recorded" + : `${budget === null ? "" : budget + " · "}${att.toFixed(0)}% · CIE ${row.ev.cie} of ${row.ev.cieMax}`; + out.push({ + kind: "subject", label, detail, + go: () => setView("ledger"), + }); + } + + return out; + }); + + // A filtered list whose selection stayed put would run the wrong row on + // Enter as soon as the results moved under it. + createEffect(() => { query(); setCursor(0); }); + + createEffect(() => { + if (props.open) { setQuery(""); setCursor(0); queueMicrotask(() => input?.focus()); } + }); + + const run = (hit: Hit | undefined) => { + if (!hit) return; + hit.go(); + props.onClose(); + }; + + const onKey = (e: KeyboardEvent) => { + const list = hits(); + if (e.key === "Escape") { e.preventDefault(); props.onClose(); return; } + if (e.key === "ArrowDown") { + e.preventDefault(); + setCursor((c) => Math.min(c + 1, Math.max(0, list.length - 1))); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setCursor((c) => Math.max(c - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + run(list[cursor()]); + } + }; + + return ( + +
+ +
+
+ ); +} + +/** Ctrl/Cmd+K from anywhere. Returns nothing; it only wires the shortcut. */ +export function usePaletteShortcut(open: () => void) { + const onKey = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + open(); + } + }; + onMount(() => window.addEventListener("keydown", onKey)); + onCleanup(() => window.removeEventListener("keydown", onKey)); +} diff --git a/app/src/ui/__tests__/HeaderAccess.test.tsx b/app/src/ui/__tests__/HeaderAccess.test.tsx index 0250a84..a3ec907 100644 --- a/app/src/ui/__tests__/HeaderAccess.test.tsx +++ b/app/src/ui/__tests__/HeaderAccess.test.tsx @@ -2,10 +2,10 @@ /** * The header, which is also the title bar. * - * Two things in it were unreachable by name. The wordmark's X is a drawing, - * and `Mark` hides itself from the accessibility tree unless it is given a - * title - so the only `h1` on the screen announced as "Target", losing the - * last letter of the product's name. The add-semester button is drawn as a + * Two things in it were unreachable by name. Home is drawn as the app's mark + * with no text at all, and `Mark` hides itself from the accessibility tree by + * default - so without an explicit label the button that returns you to the + * landing screen announces as nothing. The add-semester button is drawn as a * plus sign, and a glyph is the accessible name unless one is supplied. */ import { cleanup, render } from "@solidjs/testing-library"; @@ -52,14 +52,14 @@ beforeEach(() => { setView("ledger"); }); -describe("the wordmark", () => { - it("is announced with its X, not as 'Target'", () => { +describe("the home button", () => { + it("is announced by name even though it is drawn as a glyph", () => { const { container } = render(() => ); - const h1 = container.querySelector("h1.wordmark")!; - const mark = h1.querySelector("svg")!; - expect(mark.getAttribute("role")).toBe("img"); - expect(mark.getAttribute("aria-label")).toBe("X"); - expect(mark.getAttribute("aria-hidden")).toBeNull(); + const home = container.querySelector("button.homebtn")!; + expect(home.getAttribute("aria-label")).toMatch(/^Home\./); + // Its own label is the accessible name; the drawing inside must not add a + // second one, or the button announces twice. + expect(home.textContent).toBe(""); }); it("still hides the mark wherever it is decoration", () => { From 98b3fac888ee5f88c2e785b2696726e9f6bd4583 Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:56:26 +0530 Subject: [PATCH 04/32] feat(worker): add the Cloudflare Worker behind the ask box Three of the assistant's requirements cannot be met inside a Tauri binary, and they are all the same problem: trust cannot live on the machine being trusted. A shipped Gemini key is extractable from files on the user's disk, a rate limit enforced in JS is a limit the user deletes, and a Clerk session token proves nothing until something verifies its signature. This is the smallest service that solves all three. What it deliberately does not see: marks, attendance, CGPA, name or register number. The request carries a question and the semester's course list; the response carries a route. Every figure a student reads is still computed by the engine on their own machine, which is what lets the app keep saying it never states a number it cannot show its working for. "Never answer anything outside the app" is enforced twice, and neither is the prompt. Gemini decodes against a JSON schema whose subject field is an enum of the codes the client actually sent, so prose is not a permitted value and an invented course code is not generatable; then parseAction revalidates whatever came back against the same closed set before the client sees it. The prompt is for accuracy, the schema is for safety, and the first is never relied on for the second. Ordering in the request path is deliberate. Identity is checked before the body is read, so an unauthenticated caller cannot make us parse what they sent. The quota is claimed before the model is called, because counting on success lets anyone who can reliably provoke an upstream error spend our budget without spending their own. The counter is a Durable Object rather than KV: KV would be cheaper and would also let twenty parallel requests race a read-modify-write through a five-unit quota. Failures never explain themselves. Expired and forged must look identical from outside, and an upstream error string can carry a URL or a key fragment. Not wired to the app. The ask box answers attendance and leave questions today with no network, no key and no account. Turning this on means requiring sign-in and sending questions to Google, which is a deliberate change to the app's privacy position rather than a config flag. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF --- worker/.gitignore | 3 + worker/README.md | 100 +++ worker/package-lock.json | 1616 ++++++++++++++++++++++++++++++++++++++ worker/package.json | 18 + worker/src/clerk.ts | 67 ++ worker/src/gemini.ts | 106 +++ worker/src/index.ts | 105 +++ worker/src/limit.ts | 78 ++ worker/src/schema.ts | 110 +++ worker/tsconfig.json | 16 + worker/wrangler.toml | 19 + 11 files changed, 2238 insertions(+) create mode 100644 worker/.gitignore create mode 100644 worker/README.md create mode 100644 worker/package-lock.json create mode 100644 worker/package.json create mode 100644 worker/src/clerk.ts create mode 100644 worker/src/gemini.ts create mode 100644 worker/src/index.ts create mode 100644 worker/src/limit.ts create mode 100644 worker/src/schema.ts create mode 100644 worker/tsconfig.json create mode 100644 worker/wrangler.toml diff --git a/worker/.gitignore b/worker/.gitignore new file mode 100644 index 0000000..a933f10 --- /dev/null +++ b/worker/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.wrangler/ +.dev.vars diff --git a/worker/README.md b/worker/README.md new file mode 100644 index 0000000..049ede2 --- /dev/null +++ b/worker/README.md @@ -0,0 +1,100 @@ +# targetx-ask + +The Worker behind the app's ask box. + +## Why it exists + +Three requirements cannot be met inside a Tauri binary, because all three +depend on trusting a machine we do not control: + +| Requirement | Why the client cannot do it | +|---|---| +| Keep the Gemini key secret | A Tauri bundle is files on the user's disk. Any shipped key is extractable, and the bill is ours. | +| Rate limit per user | A limit enforced in JS is a limit the user deletes. | +| Only signed-in users | A Clerk session token proves nothing until something verifies its signature. | + +## What it does and does not see + +The request carries **a question and the semester's course list**. It does not +carry marks, attendance, CGPA, the student's name or their register number. + +The model chooses a route — a view, or a subject plus the view that answers +questions about it. The app then computes the actual figure locally, the same +way it does with no network at all. Nothing that reaches a student's screen was +authored by a model. + +## Why the topic rule holds + +Not because the prompt asks nicely. Two independent mechanisms: + +1. **Structured output.** Gemini decodes against a JSON schema whose `code` + field is an enum of the codes the client actually sent. Prose is not a value + the schema permits, and an invented course code is not generatable. +2. **`parseAction` on the way out.** Whatever comes back is validated against + the same closed set before the client sees it. Anything else becomes + `{ kind: "none" }`. + +The prompt is for accuracy. The schema is for safety. Never rely on the first +for the second. + +## Deploy + +You need a Cloudflare account, a Clerk instance and a Gemini API key. + +```sh +npm install + +# 1. Clerk issuer. Public, not a secret - Clerk Dashboard -> API Keys -> the +# "Frontend API" / issuer URL, e.g. https://cheerful-cat-42.clerk.accounts.dev +# Put it in wrangler.toml under [vars]. + +# 2. The Gemini key. A secret. This command prompts and never writes to disk. +npx wrangler secret put GEMINI_KEY + +# 3. Ship it. +npx wrangler deploy +``` + +For local development put the key in `.dev.vars` (gitignored, never committed): + +``` +GEMINI_KEY=... +``` + +## Quota + +40 questions per user per UTC day, counted in a Durable Object keyed by the +Clerk user id. Counted *before* the model is called: counting on success would +let anyone who can reliably provoke an upstream error spend our budget without +spending their own quota. + +Change `DAILY` in `src/limit.ts`. + +## Endpoint + +``` +POST /ask +Authorization: Bearer + +{ "question": "how many classes can I miss in ML", + "subjects": [{ "code": "CST414", "name": "Machine Learning" }] } +``` + +``` +200 { "action": { "kind": "subject", "code": "CST414", "view": "attendance" }, + "remaining": 39 } +401 { "error": "unauthorized" } +429 { "error": "rate_limited", "resetAt": 1757030400 } +502 { "error": "upstream" } +``` + +Failures never explain themselves. "Expired" and "forged" must look identical +from outside, and an upstream error string can carry a URL or a key fragment. + +## Not wired to the app yet + +The app does not call this. The ask box answers attendance and leave questions +today with no network, no key and no account, straight from the engine. This +service is for the questions the engine genuinely cannot answer, and turning it +on means requiring sign-in and sending questions to Google — a deliberate +change to the app's privacy position, not a config flag. diff --git a/worker/package-lock.json b/worker/package-lock.json new file mode 100644 index 0000000..b51e000 --- /dev/null +++ b/worker/package-lock.json @@ -0,0 +1,1616 @@ +{ + "name": "targetx-worker", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "targetx-worker", + "dependencies": { + "jose": "^5.9.6" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250109.0", + "typescript": "^5.7.2", + "wrangler": "^3.99.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/workerd": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.17", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/worker/package.json b/worker/package.json new file mode 100644 index 0000000..24668a5 --- /dev/null +++ b/worker/package.json @@ -0,0 +1,18 @@ +{ + "name": "targetx-worker", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "jose": "^5.9.6" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250109.0", + "typescript": "^5.7.2", + "wrangler": "^3.99.0" + } +} diff --git a/worker/src/clerk.ts b/worker/src/clerk.ts new file mode 100644 index 0000000..07c0a9b --- /dev/null +++ b/worker/src/clerk.ts @@ -0,0 +1,67 @@ +/** + * Who is asking. + * + * Clerk issues a short-lived JWT signed with a key whose public half is + * published at the instance's JWKS endpoint. Verification is therefore a pure + * signature check against a public key - no call to Clerk on the request path, + * no shared secret, and nothing that breaks when Clerk is having a bad day + * beyond new sign-ins. + * + * The token is treated as hostile input throughout. It arrived from a desktop + * binary on a machine we do not control, and the only thing making it + * trustworthy is the signature. + */ +import { createRemoteJWKSet, jwtVerify } from "jose"; + +export interface Caller { + /** Clerk's stable user id. The rate limiter counts against this. */ + sub: string; +} + +/** + * One JWKS fetcher per isolate. + * + * `createRemoteJWKSet` caches the keys and re-fetches only when it sees a + * signing key it does not know, which is what makes per-request verification + * cheap. Building a new one per request would put an HTTPS round trip to Clerk + * in front of every question and hand an attacker a way to make us hammer + * Clerk by sending unknown key ids. + */ +let jwks: ReturnType | null = null; + +function keys(issuer: string) { + if (!jwks) jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`)); + return jwks; +} + +/** + * Verify a Clerk session token. + * + * Returns null on every failure rather than throwing, and never explains which + * check failed. "Expired" and "forged" are different facts to us and must look + * identical from outside: telling a caller their signature was valid but their + * audience was wrong is telling them how to get closer. + */ +export async function verify( + authorization: string | null, + env: { CLERK_ISSUER: string }, +): Promise { + if (!authorization?.startsWith("Bearer ")) return null; + const token = authorization.slice("Bearer ".length).trim(); + if (!token) return null; + + try { + const { payload } = await jwtVerify(token, keys(env.CLERK_ISSUER), { + issuer: env.CLERK_ISSUER, + // Clock skew between a student's laptop and Cloudflare is real and is not + // an attack. Thirty seconds is the usual allowance; it is far short of + // the token's own lifetime, so it does not meaningfully extend one. + clockTolerance: 30, + }); + + if (typeof payload.sub !== "string" || !payload.sub) return null; + return { sub: payload.sub }; + } catch { + return null; + } +} diff --git a/worker/src/gemini.ts b/worker/src/gemini.ts new file mode 100644 index 0000000..e06d3a9 --- /dev/null +++ b/worker/src/gemini.ts @@ -0,0 +1,106 @@ +/** + * The model call. + * + * Gemini is asked for a value from a JSON schema, not for a reply. Structured + * output is a constraint the API enforces during decoding: the response cannot + * be prose because prose does not satisfy the schema. That is the difference + * between asking a model to stay on topic and making it unable to leave. + * + * The prompt still says what the app is, because a well-briefed model picks the + * right route more often. It is not what makes the rule hold - `parseAction` on + * the way out is. Prompt for quality; validate for safety. + */ +import type { AskRequest } from "./schema"; +import { VIEWS } from "./schema"; + +const ENDPOINT = + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"; + +const SYSTEM = `You route questions inside TargetX, a KTU academic tracker. + +You do not answer questions and you do not state figures. The application +computes every number itself from the student's own records; your only job is +to say WHERE the answer already lives. + +Views: +- home: overall standing, CGPA, what needs attention +- ledger: this semester's marks, per subject, per component +- attendance: attendance percentage, classes that can still be missed, CIE + marks earned from attendance, the weekly timetable +- history: published results from past semesters +- data: sync with etlab and KTU, import, backup + +Choose "subject" when the question names one of the given courses, and set +"view" to where that question is answered - attendance questions to attendance, +mark questions to ledger. Choose "view" when the question is about the student +overall. Choose "none" with reason "off_topic" for anything not about this +student's academic record, "unclear" when the question cannot be understood, +and "no_match" when it is on topic but nothing here answers it.`; + +/** Mirrors `Action`. Gemini enforces this during decoding. */ +function responseSchema(codes: string[]) { + return { + type: "OBJECT", + properties: { + kind: { type: "STRING", enum: ["view", "subject", "none"] }, + view: { type: "STRING", enum: [...VIEWS] }, + // An enum of the codes the client actually sent, so an invented course + // code is not merely rejected later - it cannot be generated. Empty + // enums are not valid, so a student with no courses gets the field left + // open and `parseAction` catches anything that comes back in it. + ...(codes.length > 0 ? { code: { type: "STRING", enum: codes } } : {}), + reason: { type: "STRING", enum: ["off_topic", "unclear", "no_match"] }, + }, + required: ["kind"], + }; +} + +/** Raw parsed JSON from the model, still untrusted. Validate before use. */ +export async function route( + req: AskRequest, apiKey: string, signal: AbortSignal, +): Promise { + const codes = req.subjects.map((s) => s.code); + const catalogue = req.subjects.map((s) => `${s.code} ${s.name}`).join("\n"); + + const body = { + systemInstruction: { parts: [{ text: SYSTEM }] }, + contents: [{ + role: "user", + parts: [{ text: `Courses this semester:\n${catalogue}\n\nQuestion: ${req.question}` }], + }], + generationConfig: { + responseMimeType: "application/json", + responseSchema: responseSchema(codes), + // Routing is a classification, not a composition. Sampling buys nothing + // here except a different answer to the same question on a retry. + temperature: 0, + maxOutputTokens: 128, + }, + }; + + const res = await fetch(ENDPOINT, { + method: "POST", + // The key travels in a header, never in the URL. Query strings end up in + // proxy logs and error reports; headers are far less likely to. + headers: { "content-type": "application/json", "x-goog-api-key": apiKey }, + body: JSON.stringify(body), + signal, + }); + + if (!res.ok) throw new Error(`gemini ${res.status}`); + + const json = await res.json<{ + candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; + }>(); + + const text = json.candidates?.[0]?.content?.parts?.[0]?.text; + if (typeof text !== "string") throw new Error("gemini: no content"); + + // Structured output means this should always parse. "Should" is not a + // guarantee we get to rely on when the input is a remote service. + try { + return JSON.parse(text) as unknown; + } catch { + throw new Error("gemini: unparseable"); + } +} diff --git a/worker/src/index.ts b/worker/src/index.ts new file mode 100644 index 0000000..a0ee2e0 --- /dev/null +++ b/worker/src/index.ts @@ -0,0 +1,105 @@ +/** + * TargetX ask endpoint. + * + * The only reason this service exists is that three of the app's requirements + * cannot be met inside a desktop binary: + * + * - the Gemini key must stay secret, and a Tauri bundle is files on the + * student's disk; + * - a rate limit enforced by the client is a rate limit the client deletes; + * - a Clerk session proves nothing until something verifies the signature. + * + * All three are the same problem - trust cannot live on the machine being + * trusted - and this is the smallest thing that solves it. + * + * What it deliberately does NOT do: compute anything about a student. Marks, + * attendance and CGPA never reach here. The request carries a question and a + * course list; the response carries a route. Every figure the student reads is + * still produced by the engine on their own machine, which is what lets the app + * keep saying it never states a number it cannot show its working for. + */ +import { verify } from "./clerk"; +import { claim } from "./limit"; +import { route } from "./gemini"; +import { parseAction, parseAskRequest } from "./schema"; + +export { Quota } from "./limit"; + +interface Env { + GEMINI_KEY: string; + CLERK_ISSUER: string; + QUOTA: DurableObjectNamespace; +} + +/** No credentials, no cookies - the client sends a bearer token by hand. */ +const CORS = { + "access-control-allow-origin": "*", + "access-control-allow-headers": "authorization,content-type", + "access-control-allow-methods": "POST,OPTIONS", +}; + +function json(body: unknown, status = 200, extra: Record = {}) { + return Response.json(body, { status, headers: { ...CORS, ...extra } }); +} + +export default { + async fetch(request: Request, env: Env): Promise { + if (request.method === "OPTIONS") return new Response(null, { headers: CORS }); + if (request.method !== "POST") return json({ error: "method" }, 405); + + const url = new URL(request.url); + if (url.pathname !== "/ask") return json({ error: "not_found" }, 404); + + // Identity first, before the body is even read. An unauthenticated caller + // should not be able to make us parse anything they sent. + const caller = await verify(request.headers.get("authorization"), env); + if (!caller) return json({ error: "unauthorized" }, 401); + + // Quota second, before the model is called. See `claim`: counting after a + // success would let a caller who can reliably provoke an error spend our + // budget without spending their own. + const quota = await claim(env.QUOTA, caller.sub); + if (!quota.ok) { + return json({ error: "rate_limited", resetAt: quota.resetAt }, 429, { + "retry-after": String(Math.max(1, quota.resetAt - Math.floor(Date.now() / 1000))), + }); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return json({ error: "bad_request" }, 400); + } + + const ask = parseAskRequest(payload); + if (!ask) return json({ error: "bad_request" }, 400); + + // A hung upstream must not hold a Worker invocation open indefinitely. The + // app has a local answer for most questions anyway, so a fast failure is + // worth more here than a slow success. + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), 8000); + + try { + const raw = await route(ask, env.GEMINI_KEY, abort.signal); + const action = parseAction(raw, new Set(ask.subjects.map((s) => s.code))); + + // The model returned something outside its own schema. That is not an + // action the app is allowed to act on, and it is not an error the student + // needs to see either - it is a shrug. + if (!action) { + return json({ action: { kind: "none", reason: "unclear" }, remaining: quota.remaining }); + } + + return json({ action, remaining: quota.remaining }); + } catch { + // Nothing about the upstream failure is echoed back. The error text could + // carry a URL, a key fragment or a project id, and none of that is the + // student's business or safe to leak. + return json({ error: "upstream" }, 502); + } finally { + clearTimeout(timer); + } + }, +}; diff --git a/worker/src/limit.ts b/worker/src/limit.ts new file mode 100644 index 0000000..e3a47f4 --- /dev/null +++ b/worker/src/limit.ts @@ -0,0 +1,78 @@ +/** + * The daily quota, counted where it cannot be lied to. + * + * A limit enforced in the app is not a limit: the app is a binary on the + * student's own machine and they can edit it. This counter lives in a Durable + * Object, which is the only thing in the Workers platform that gives a single + * authoritative copy of a value with serialised access to it - KV would be + * cheaper and would also let a client fire twenty parallel requests through a + * read-modify-write race and spend twenty units of a five-unit quota. + * + * One object per user id, so two students never contend with each other, and a + * heavy user's traffic is serialised only against themselves. + */ + +/** Requests one user may make per UTC day. */ +const DAILY = 40; + +export interface Decision { + ok: boolean; + /** Requests left AFTER this one, so the client can show a real number. */ + remaining: number; + /** Unix seconds at which the quota resets. */ + resetAt: number; +} + +function dayKey(now: number): string { + return new Date(now).toISOString().slice(0, 10); +} + +function nextMidnight(now: number): number { + const d = new Date(now); + d.setUTCHours(24, 0, 0, 0); + return Math.floor(d.getTime() / 1000); +} + +export class Quota { + private state: DurableObjectState; + + constructor(state: DurableObjectState) { + this.state = state; + } + + async fetch(): Promise { + const now = Date.now(); + const today = dayKey(now); + + // `blockConcurrencyWhile` is not needed here: a Durable Object already + // processes one fetch at a time per object, which is the entire reason + // this is a DO and not a KV key. + const stored = await this.state.storage.get<{ day: string; used: number }>("c"); + const used = stored?.day === today ? stored.used : 0; + + if (used >= DAILY) { + return Response.json({ + ok: false, remaining: 0, resetAt: nextMidnight(now), + } satisfies Decision); + } + + await this.state.storage.put("c", { day: today, used: used + 1 }); + return Response.json({ + ok: true, remaining: DAILY - (used + 1), resetAt: nextMidnight(now), + } satisfies Decision); + } +} + +/** + * Claim one request against a user's quota. + * + * Counted BEFORE the model is called, not after. Counting on success would let + * a caller who reliably triggers a Gemini error spend our money without ever + * spending their own quota, which is the cheapest denial-of-wallet there is. + */ +export async function claim( + ns: DurableObjectNamespace, userId: string, +): Promise { + const stub = ns.get(ns.idFromName(userId)); + return await (await stub.fetch("https://quota/claim")).json(); +} diff --git a/worker/src/schema.ts b/worker/src/schema.ts new file mode 100644 index 0000000..88633fa --- /dev/null +++ b/worker/src/schema.ts @@ -0,0 +1,110 @@ +/** + * The contract between the app and the model. + * + * This file is the enforcement point for "the assistant may not answer anything + * outside the app". That rule is NOT implemented as a sentence in a prompt. + * A prompt is a request, and a request can be talked out of; every published + * jailbreak is a demonstration of exactly that. It is implemented as a type the + * response has to parse into before it is allowed back to the client. + * + * The model's entire vocabulary is below. It can name a view, it can name a + * subject, or it can decline. There is no shape here that carries prose, and + * none that carries a number - so the model structurally cannot state a figure + * about a student's marks, correct or otherwise. Every number the student sees + * is still computed on their own machine by the engine, exactly as it is today. + * + * The model routes. The engine answers. That separation is the whole design. + */ + +/** The views the app actually has. Anything else is a hallucinated route. */ +export const VIEWS = ["home", "ledger", "attendance", "history", "data"] as const; +export type ViewId = (typeof VIEWS)[number]; + +/** + * What the app asks a question about. + * + * Note what is NOT in here: no marks, no attendance percentages, no CGPA, no + * name, no register number. The model is routing a question to a subject, and + * it needs the subject's code and title to do that - it does not need to know + * how the student is doing, and sending that would be handing an academic + * record to a third party for no gain. What leaves the machine is a question + * and a course list. The answer is computed at home. + */ +export interface AskRequest { + question: string; + subjects: Array<{ code: string; name: string }>; +} + +export type Action = + | { kind: "view"; view: ViewId } + | { kind: "subject"; code: string; view: ViewId } + /** + * The refusal is a first-class action rather than an error, because the + * honest answer to "what is the capital of France" from an academic tracker + * is a shrug, not a 400. `reason` is one of a fixed set - it is not free text + * the model wrote, for the same reason nothing else here is. + */ + | { kind: "none"; reason: "off_topic" | "unclear" | "no_match" }; + +const REASONS = ["off_topic", "unclear", "no_match"] as const; + +function isView(v: unknown): v is ViewId { + return typeof v === "string" && (VIEWS as readonly string[]).includes(v); +} + +/** + * Parse a model response into an Action, or reject it. + * + * Deliberately hostile to its input. This runs on whatever the model returned, + * which is untrusted text no matter how well the prompt was written, and the + * only thing standing between it and the client is this function. Unknown + * fields are dropped rather than passed through; a subject code the client + * never sent is rejected rather than trusted, because a model that invents a + * course code would otherwise have the app navigate to a subject that does not + * exist. + */ +export function parseAction(raw: unknown, knownCodes: Set): Action | null { + if (typeof raw !== "object" || raw === null) return null; + const o = raw as Record; + + if (o.kind === "view" && isView(o.view)) { + return { kind: "view", view: o.view }; + } + + if (o.kind === "subject" && typeof o.code === "string" && knownCodes.has(o.code)) { + // The view defaults rather than failing: a model that names the right + // subject and forgets where to show it has still done the useful half. + return { kind: "subject", code: o.code, view: isView(o.view) ? o.view : "attendance" }; + } + + if (o.kind === "none" && typeof o.reason === "string" + && (REASONS as readonly string[]).includes(o.reason)) { + return { kind: "none", reason: o.reason as (typeof REASONS)[number] }; + } + + return null; +} + +/** Shape check on what the CLIENT sent, before any of it reaches the model. */ +export function parseAskRequest(raw: unknown): AskRequest | null { + if (typeof raw !== "object" || raw === null) return null; + const o = raw as Record; + + if (typeof o.question !== "string") return null; + const question = o.question.trim(); + // A cap, because the question is forwarded to a metered API and an unbounded + // string is an unbounded bill. Long enough for any real question. + if (question.length === 0 || question.length > 400) return null; + + if (!Array.isArray(o.subjects) || o.subjects.length > 60) return null; + const subjects: AskRequest["subjects"] = []; + for (const s of o.subjects) { + if (typeof s !== "object" || s === null) return null; + const { code, name } = s as Record; + if (typeof code !== "string" || typeof name !== "string") return null; + if (code.length > 24 || name.length > 120) return null; + subjects.push({ code, name }); + } + + return { question, subjects }; +} diff --git a/worker/tsconfig.json b/worker/tsconfig.json new file mode 100644 index 0000000..9ec0009 --- /dev/null +++ b/worker/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "lib": ["es2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["src"] +} diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 0000000..3c62ff3 --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,19 @@ +name = "targetx-ask" +main = "src/index.ts" +compatibility_date = "2025-01-09" +compatibility_flags = ["nodejs_compat"] + +# The Clerk instance's issuer URL. NOT a secret - it is public, it is in every +# token the app already holds, and keeping it in the file means the deployment +# is reproducible from the repo. The Gemini key is NOT here; it is set with +# `wrangler secret put GEMINI_KEY` and never touches version control. +[vars] +CLERK_ISSUER = "https://REPLACE-ME.clerk.accounts.dev" + +[[durable_objects.bindings]] +name = "QUOTA" +class_name = "Quota" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["Quota"] From 902569111e9b593f6d9496f5679fa1f546aca99e Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:01:45 +0530 Subject: [PATCH 05/32] fix(ui): the bell and account menu closed on the click that opened them Both popovers shipped dead. Solid delegates every `onClick` to `document`, and the close-on-outside-click listener was on `document` as well - and `stopPropagation` does not stop other listeners bound to the SAME node, only `stopImmediatePropagation` does, and only for handlers registered after it. The wrapper's guard therefore did nothing: the trigger's toggle opened the popover and the dismissal closed it again within one click. Closing is now decided by geometry - was the click inside the wrapper - which is independent of listener order and of the framework's delegation strategy. The regression tests dispatch real bubbling MouseEvents rather than calling the handlers, because the whole bug lived in how two listeners on one node interleave; a test that invoked the click handler directly would have passed against the broken build. Confirmed by reverting the fix: all six fail. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF --- app/src/ui/App.tsx | 34 +++++++-- app/src/ui/__tests__/HeaderPopovers.test.tsx | 80 ++++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 app/src/ui/__tests__/HeaderPopovers.test.tsx diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx index f0269d8..064a49e 100644 --- a/app/src/ui/App.tsx +++ b/app/src/ui/App.tsx @@ -294,15 +294,31 @@ export function SaveNotice() { function Bell(props: { findings: Finding[]; onGo: () => void }) { const [open, setOpen] = createSignal(false); const count = () => props.findings.length; + let wrap: HTMLDivElement | undefined; - // A popover that outlives a click elsewhere is a popover the student has to - // dismiss twice. - const onDocClick = () => setOpen(false); + /* + * Close when the click lands outside, decided by geometry rather than by + * propagation. + * + * The propagation version did not work and could not: Solid delegates every + * `onClick` to `document`, and this listener is on `document` too. + * `stopPropagation` does not stop other listeners already bound to the SAME + * node - only `stopImmediatePropagation` does, and only for handlers + * registered after it. So the button's own toggle opened the popover and + * this handler closed it again in the same click, and the bell looked dead. + * + * Asking whether the click was inside the wrapper is independent of both + * listener order and the framework's delegation strategy. + */ + const onDocClick = (e: MouseEvent) => { + if (wrap && e.target instanceof Node && wrap.contains(e.target)) return; + setOpen(false); + }; onMount(() => document.addEventListener("click", onDocClick)); onCleanup(() => document.removeEventListener("click", onDocClick)); return ( -
e.stopPropagation()}> +
+ }> +
+ + Signed in +
+ You can ask questions in the search box. +
+ +
+
+ + + {(why) => ( +
+ {why()} +
+ )} +
+
Data - - diff --git a/app/src/ui/Home.tsx b/app/src/ui/Home.tsx index 090710a..6e931b1 100644 --- a/app/src/ui/Home.tsx +++ b/app/src/ui/Home.tsx @@ -59,7 +59,14 @@ export function Home() { * four published results sitting in history. */ const onRecord = () => new Set([ - ...Object.keys(state.semesters), + // A semester with no subjects in it is not a semester on record. Setup + // creates the current one before anything is in it, so a student who had + // just finished onboarding was told "1 semester on record" while looking + // at an empty screen - the app opening with a claim it could not support, + // which is the one thing it is supposed never to do. + ...Object.entries(state.semesters) + .filter(([, sem]) => (sem?.courses.length ?? 0) > 0) + .map(([name]) => name), ...Object.keys(state.history), ]).size; @@ -239,11 +246,26 @@ export function Home() {

Where you stand

-

- {state.activeSemester} · {onRecord()} semester - {onRecord() === 1 ? "" : "s"} on record -

+ {/* With nothing on record the count is not "0 semesters", it is + absent. A zero here is a fact about the record; the student has + not made one yet, and the empty state below already says so in + words that lead somewhere. */} + {/* No lede at all when the record is empty. It said "nothing + recorded yet" directly above a card whose heading is "Nothing + recorded yet", which is the same sentence twice in one eyeful and + makes the screen read as an error rather than as a start. */} + 0}> +

+ {state.activeSemester} · {onRecord()} semester + {onRecord() === 1 ? "" : "s"} on record +

+
+ {/* The header action is for a student who HAS data and wants more of + it. With an empty record the card below carries the same call, and + two identical primary buttons on one screen is a choice the student + has to make between two things that do the same thing. */} + 0}> setView("data")}>Get your marks in }> @@ -251,6 +273,7 @@ export function Home() { Synced {new Date(state.lastSync!).toLocaleDateString()} +
@@ -586,7 +609,10 @@ function ChangesPanel(props: { at: string; items: Change[] }) { function EmptyHome() { return (
-

Nothing recorded yet

+ {/* "Nothing recorded yet" as a heading describes the app's problem. This + describes the student's next move, which is the only thing an empty + state is for. */} +

Get your marks in

TargetX has no marks to work from. The fastest route is your college portal — it brings attendance, internals and every past semester in one diff --git a/app/src/ui/Setup.tsx b/app/src/ui/Setup.tsx index cab0aec..13c27e0 100644 --- a/app/src/ui/Setup.tsx +++ b/app/src/ui/Setup.tsx @@ -22,7 +22,7 @@ type Stage = "welcome" | "data" | "goal"; const STAGES: Array<{ id: Stage; label: string }> = [ { id: "welcome", label: "Start" }, - { id: "data", label: "Your subjects" }, + { id: "data", label: "Your marks" }, { id: "goal", label: "Your goal" }, ]; @@ -133,7 +133,7 @@ function DataStep(props: { onBack: () => void; onNext: () => void }) {

- - - From bd372f8690bba25b8244df1d86954bed12b565d8 Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:57:28 +0530 Subject: [PATCH 16/32] feat(home): offer the target from the card that depends on it The Standing tile is grid-column: span 2 and the gauge that fills its right half only renders once a target exists, so a student with no target got a double-width card carrying one number and a sentence. The empty half now carries the action that fills it - five values rather than a number pad, because a student arrives wanting 'above eight', not 8.37. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF --- app/src/styles/screens.css | 12 ++++++++++++ app/src/ui/Home.tsx | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/app/src/styles/screens.css b/app/src/styles/screens.css index 6a02dd7..43e08ea 100644 --- a/app/src/styles/screens.css +++ b/app/src/styles/screens.css @@ -1088,3 +1088,15 @@ a.link:hover { text-decoration-thickness: 2px; } } .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); } diff --git a/app/src/ui/Home.tsx b/app/src/ui/Home.tsx index 6e931b1..114f7ea 100644 --- a/app/src/ui/Home.tsx +++ b/app/src/ui/Home.tsx @@ -6,7 +6,7 @@ import { unconfirmedNames, } from "../engine"; import { - dismissChanges, goalRequirement, overall, rows, state, summary, trend, + dismissChanges, goalRequirement, overall, rows, setGoal, state, summary, trend, } from "../state/store"; import { setView } from "../state/nav"; import { GoalGauge, TrendChart } from "./charts"; @@ -328,7 +328,16 @@ export function Home() { CGPA · {overall().percent.toFixed(1)}%
- + {/* The gauge occupies the right half of a double-width tile. With + no target set it rendered nothing, so the card became one + number and a sentence spread across the widest surface on the + screen - the dead half that made Home feel unfinished. + + What goes there instead is the action the sentence beside it + is already asking for. Setting a target is the single thing + that changes what every other figure on this screen means, and + it was previously only reachable from a row on another view. */} + }> + Set a target +
+ {(value) => ( + + )} +
+
+ ); +} + function EmptyHome() { return (
From 61f6ed28d7c9d8933c1915b10725c542dffe9577 Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:05:28 +0530 Subject: [PATCH 17/32] feat(ask): route unmatched questions through the deployed worker The ask box only ever answered what local matching could find. This wires the second half: when the palette has nothing to press, Enter hands the question to the Cloudflare worker, which returns a route - a view or a subject - and never prose and never a number. Local stays first, and Enter only leaves the machine when there is no local hit. The engine's answer is free, offline, instant and cannot hallucinate; calling a metered API per keystroke would be an unbounded bill for answers already held. What crosses the wire is the question and a list of {code, name}. No marks, no attendance, no CGPA, no name, no register number. PaletteAsk.test.tsx asserts the payload by equality rather than by a no-digits regex, because a course code has digits in it and that regex would have proved nothing. Also corrects the header comment in HeaderPopovers.test.tsx, which still blamed a WebView2 backdrop-filter clipping bug for blank panels. The cause was a stale dev server that had stopped applying HMR updates. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF --- app/src/state/ask.test.ts | 186 +++++++++++++++++++ app/src/state/ask.ts | 132 +++++++++++++ app/src/ui/Palette.tsx | 105 ++++++++++- app/src/ui/__tests__/HeaderPopovers.test.tsx | 13 +- app/src/ui/__tests__/PaletteAsk.test.tsx | 151 +++++++++++++++ 5 files changed, 578 insertions(+), 9 deletions(-) create mode 100644 app/src/state/ask.test.ts create mode 100644 app/src/state/ask.ts create mode 100644 app/src/ui/__tests__/PaletteAsk.test.tsx 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/ui/Palette.tsx b/app/src/ui/Palette.tsx index c05e0ad..3d61fb4 100644 --- a/app/src/ui/Palette.tsx +++ b/app/src/ui/Palette.tsx @@ -3,6 +3,8 @@ import { courseLabel } from "../engine"; import { rows } from "../state/store"; import { VIEWS, setView } from "../state/nav"; import type { View } from "../state/nav"; +import { askConfigured, askRemote } from "../state/ask"; +import { signedIn } from "../state/auth"; /** * The command palette. @@ -17,8 +19,13 @@ import type { View } from "../state/nav"; * engine's own evaluation. Nothing is generated, summarised or inferred, which * is what makes it safe to put a question box at the top of an app whose whole * trust position is that it never states a number it cannot show its working - * for. When a model is eventually put behind this box, it selects among these - * same rows - it does not get to invent one. + * for. The model behind this box selects among these same rows - it does not + * get to invent one. + * + * The remote route is deliberately the SECOND thing tried, and only on Enter. + * Local matching is free, offline, instant and cannot hallucinate, so it answers + * every question it can; calling a metered API per keystroke would be an + * unbounded bill for answers the machine already had. */ interface Hit { @@ -75,7 +82,11 @@ function matches(haystack: string, needle: string): boolean { export function Palette(props: { open: boolean; onClose: () => void }) { const [query, setQuery] = createSignal(""); const [cursor, setCursor] = createSignal(0); + const [asking, setAsking] = createSignal(false); + /** What the remote route had to say, when it had to say anything. */ + const [remote, setRemote] = createSignal(null); let input: HTMLInputElement | undefined; + let inflight: AbortController | undefined; const hits = createMemo(() => { const q = query().trim(); @@ -129,18 +140,82 @@ export function Palette(props: { open: boolean; onClose: () => void }) { // A filtered list whose selection stayed put would run the wrong row on // Enter as soon as the results moved under it. - createEffect(() => { query(); setCursor(0); }); + createEffect(() => { + query(); + setCursor(0); + // A verdict about the previous question is worse than no verdict at all + // once the question has changed under it. + setRemote(null); + }); createEffect(() => { if (props.open) { setQuery(""); setCursor(0); queueMicrotask(() => input?.focus()); } }); + // A request whose palette has closed has nobody left to answer. + onCleanup(() => inflight?.abort()); + const run = (hit: Hit | undefined) => { if (!hit) return; hit.go(); props.onClose(); }; + /** + * Hand the question to the router. + * + * Only reached when local matching found nothing, so there is no risk of the + * model overriding an answer the engine could already give. A returned route + * is followed; a returned subject is matched back to a real row before the + * app moves, because "the worker validated the code" and "this student has + * that subject" are not the same claim. + */ + const ask = async () => { + const q = query().trim(); + if (q === "" || asking()) return; + inflight?.abort(); + const ctl = new AbortController(); + inflight = ctl; + setAsking(true); + setRemote(null); + try { + const out = await askRemote(q, rows().map((r) => ({ + code: r.course.code ?? "", + name: courseLabel(r.course), + })), ctl.signal); + + if (!out.ok) { + setRemote( + out.kind === "signin" ? "Sign in from the profile menu to ask questions." + : out.kind === "limit" ? "That is all the questions for today. The rest of the app is unchanged." + : out.kind === "offline" ? "No connection. Everything below still works offline." + : out.kind === "unconfigured" ? "Question routing is not set up in this build." + : "That did not go through. Try rephrasing it.", + ); + return; + } + + const a = out.action; + if (a.kind === "view") { setView(a.view); props.onClose(); return; } + if (a.kind === "subject") { + const hit = rows().find((r) => r.course.code === a.code); + if (hit) { setView(a.view); props.onClose(); return; } + setRemote("That subject is not in this semester."); + return; + } + setRemote( + a.reason === "off_topic" + ? "That one is outside what TargetX knows about." + : a.reason === "no_match" + ? "Nothing in your record matches that." + : "Not sure what that is asking. Try naming the subject.", + ); + } finally { + if (inflight === ctl) inflight = undefined; + setAsking(false); + } + }; + const onKey = (e: KeyboardEvent) => { const list = hits(); if (e.key === "Escape") { e.preventDefault(); props.onClose(); return; } @@ -152,7 +227,11 @@ export function Palette(props: { open: boolean; onClose: () => void }) { setCursor((c) => Math.max(c - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); - run(list[cursor()]); + const hit = list[cursor()]; + // The engine's answer wins whenever it has one. Enter only leaves the + // machine when there is nothing here to press. + if (hit) { run(hit); return; } + void ask(); } }; @@ -168,7 +247,23 @@ export function Palette(props: { open: boolean; onClose: () => void }) { onKeyDown={onKey} /> 0} fallback={ -

Nothing matches “{query()}”.

+
+ +

Nothing here matches “{query()}”.

+ {/* The offer is only made when it can be honoured. Telling a + signed-out student to press Enter and then refusing them + is worse than not offering. */} + +

+ {asking() ? "Working it out…" : "Press Enter to ask."} +

+
+ + }> + {(said) =>

{said()}

} +
+
}>
    {(hit, i) => ( diff --git a/app/src/ui/__tests__/HeaderPopovers.test.tsx b/app/src/ui/__tests__/HeaderPopovers.test.tsx index 22b5060..415af84 100644 --- a/app/src/ui/__tests__/HeaderPopovers.test.tsx +++ b/app/src/ui/__tests__/HeaderPopovers.test.tsx @@ -14,10 +14,15 @@ * broken build. * * The panels are queried on `document` rather than on the render container: - * they are rendered through a Portal into `document.body`, out of the header, - * because the header carries a backdrop-filter and WebView2 clips a filtered - * element's descendants to its own box - which painted them and then clipped - * them away in the shipped app while every test passed. + * they are rendered through a Portal into `document.body`. The Portal is there + * so a panel anchored to a header button is not clipped or stacked by the + * header's own box, which is the ordinary reason a popover is portalled. + * + * An earlier version of this comment blamed a WebView2 backdrop-filter clipping + * bug for blank panels in the running app. That was wrong and is recorded here + * so it is not re-derived: the panels were blank because a dev server that had + * been up for hours had silently stopped applying HMR updates. Restarting it + * fixed it. Nothing about backdrop-filter was involved. */ import { cleanup, render } from "@solidjs/testing-library"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; diff --git a/app/src/ui/__tests__/PaletteAsk.test.tsx b/app/src/ui/__tests__/PaletteAsk.test.tsx new file mode 100644 index 0000000..15a809f --- /dev/null +++ b/app/src/ui/__tests__/PaletteAsk.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment jsdom +/** + * When the ask box is allowed to leave the machine. + * + * The palette answers most questions locally: a stop list, a subsequence match, + * and figures straight from the engine. That path is free, offline, instant and + * cannot invent anything. The remote router is strictly a fallback, and the two + * rules below are what keep it one: + * + * - Enter runs the local hit when there is one, and only reaches the network + * when there is nothing to press. A palette that asked on every Enter would + * bill a metered API for answers the machine already had. + * - Nothing but the question and the course list is ever sent. Marks, + * attendance and CGPA stay here, which is what lets an academic tracker put + * a question box on top of a third-party model at all. + * + * `askRemote` is mocked rather than `fetch`, because the assertion is about + * WHETHER the palette calls out and with what - not about how the client + * serialises it. That is `ask.test.ts`'s job and it is tested there. + */ +import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Course } from "../../engine"; +import { addCourse, edit, updateCourse } from "../../state/store"; + +const askRemote = vi.fn(); +vi.mock("../../state/ask", () => ({ + askConfigured: () => true, + askRemote: (...args: unknown[]) => askRemote(...args), +})); +vi.mock("../../state/auth", () => ({ signedIn: () => true })); + +const { Palette } = await import("../Palette"); + +const ML: Partial = { + code: "CST305", name: "Machine Learning", credits: 4, type: "TH 40/60", + s1: 38, s2: 34, other: 9, attended: 39, held: 50, dl: 0, +}; + +function open() { + edit((d) => { + d.semesters = { S5: { courses: [] } }; + d.activeSemester = "S5"; + d.history = {}; + }); + addCourse(); + updateCourse(0, ML); + return render(() => {}} />); +} + +const type = (value: string) => { + const input = screen.getByLabelText("Search subjects and views") as HTMLInputElement; + fireEvent.input(input, { target: { value } }); + return input; +}; + +afterEach(() => { cleanup(); askRemote.mockReset(); }); +beforeEach(() => { + askRemote.mockResolvedValue({ + ok: true, action: { kind: "view", view: "attendance" }, remaining: 39, + }); +}); + +describe("the engine answers first", () => { + it("does not call out when a subject matched locally", async () => { + open(); + type("machine learning"); + // There is a row to press, so Enter presses it. + fireEvent.keyDown(screen.getByLabelText("Search subjects and views"), { key: "Enter" }); + await Promise.resolve(); + expect(askRemote).not.toHaveBeenCalled(); + }); + + it("does not call out for a question with no nameable term, which every subject answers", async () => { + open(); + // "how many classes can i miss" is all stop words, so the palette treats it + // as a question about every subject and lists them. Nothing to ask. + type("how many classes can i miss"); + fireEvent.keyDown(screen.getByLabelText("Search subjects and views"), { key: "Enter" }); + await Promise.resolve(); + expect(askRemote).not.toHaveBeenCalled(); + }); + + it("does not call out on an empty box", async () => { + open(); + fireEvent.keyDown(screen.getByLabelText("Search subjects and views"), { key: "Enter" }); + await Promise.resolve(); + expect(askRemote).not.toHaveBeenCalled(); + }); +}); + +describe("the router is the fallback", () => { + it("asks only once local matching found nothing", async () => { + open(); + const input = type("zzzqqq"); + expect(screen.getByText(/Nothing here matches/)).toBeTruthy(); + fireEvent.keyDown(input, { key: "Enter" }); + await vi.waitFor(() => expect(askRemote).toHaveBeenCalledTimes(1)); + }); + + it("sends the question and the course list, and no figure of any kind", async () => { + open(); + fireEvent.keyDown(type("zzzqqq"), { key: "Enter" }); + await vi.waitFor(() => expect(askRemote).toHaveBeenCalled()); + + const [question, subjects] = askRemote.mock.calls[0] as [string, unknown[]]; + expect(question).toBe("zzzqqq"); + // Asserted as an exact payload rather than as "no digits appear": a course + // code has digits in it, so a regex over the JSON would fail on every real + // subject while proving nothing. What matters is that the seeded course's + // 39 of 50 classes and its 38/34/9 marks are not in here, and an equality + // check on the whole array says that and nothing weaker. + expect(subjects).toEqual([{ code: "CST305", name: "Machine Learning" }]); + }); + + it("tells the student what happened when the router declines", async () => { + askRemote.mockResolvedValue({ + ok: true, action: { kind: "none", reason: "off_topic" }, remaining: 39, + }); + open(); + fireEvent.keyDown(type("zzzqqq"), { key: "Enter" }); + await vi.waitFor(() => + expect(screen.getByText(/outside what TargetX knows about/)).toBeTruthy()); + }); + + it("says the app still works when there is no connection, rather than only reporting failure", async () => { + askRemote.mockResolvedValue({ ok: false, kind: "offline" }); + open(); + fireEvent.keyDown(type("zzzqqq"), { key: "Enter" }); + await vi.waitFor(() => expect(screen.getByText(/still works offline/)).toBeTruthy()); + }); + + it("refuses to navigate to a subject this student does not have", async () => { + askRemote.mockResolvedValue({ + ok: true, action: { kind: "subject", code: "NOPE999", view: "attendance" }, remaining: 39, + }); + open(); + fireEvent.keyDown(type("zzzqqq"), { key: "Enter" }); + await vi.waitFor(() => + expect(screen.getByText(/not in this semester/)).toBeTruthy()); + }); + + it("clears a stale verdict as soon as the question changes under it", async () => { + askRemote.mockResolvedValue({ ok: false, kind: "offline" }); + open(); + fireEvent.keyDown(type("zzzqqq"), { key: "Enter" }); + await vi.waitFor(() => expect(screen.getByText(/still works offline/)).toBeTruthy()); + type("zzzqqqw"); + expect(screen.queryByText(/still works offline/)).toBeNull(); + }); +}); From 2aff0dd9e7913d984a4a99ad771f8b85f5e44e04 Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:10:54 +0530 Subject: [PATCH 18/32] fix(worker): route on gemini-flash-lite-latest, and stop the palette listing everything gemini-2.0-flash was retired and the endpoint returns 404, so every question in the app was failing at the model call. Verified against the live API with the worker's own schema: flash-lite routes 'how many classes can i miss in ML' to {kind:subject, code:CST305, view:attendance} at temperature 0. Moved to the floating alias rather than another pinned version. That is the opposite of the usual advice and is the point: a pinned model's failure mode is a 404 that arrives without warning and takes the whole feature down, which is what just happened. The alias can only change the model behind a fixed JSON schema that the API enforces during decoding and parseAction rejects anything outside - very little surface for a change to break. The palette also opened onto every view and every subject before a key was pressed. That is not a result set, it is the whole app enumerated in the one place a student came to narrow it down. The list now starts when the typing does, and an untouched box renders no verdict at all. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF --- app/src/ui/Palette.tsx | 45 ++++++++++++++++++++++++++---------------- worker/src/gemini.ts | 18 ++++++++++++++++- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/app/src/ui/Palette.tsx b/app/src/ui/Palette.tsx index 3d61fb4..59f6ffd 100644 --- a/app/src/ui/Palette.tsx +++ b/app/src/ui/Palette.tsx @@ -90,6 +90,12 @@ export function Palette(props: { open: boolean; onClose: () => void }) { const hits = createMemo(() => { const q = query().trim(); + // An empty box lists nothing. It used to open onto every view and every + // subject, which is not a set of results - it is the whole app enumerated + // in the one place a student came to narrow it down. There is nothing to + // read there and nothing to choose between, so the list starts when the + // typing does. + if (q === "") return []; // A question with nothing nameable left in it - "how many can I miss" - // is not a failed search. It is a question about every subject, so every // subject answers it. @@ -247,23 +253,28 @@ export function Palette(props: { open: boolean; onClose: () => void }) { onKeyDown={onKey} /> 0} fallback={ -
    - -

    Nothing here matches “{query()}”.

    - {/* The offer is only made when it can be honoured. Telling a - signed-out student to press Enter and then refusing them - is worse than not offering. */} - -

    - {asking() ? "Working it out…" : "Press Enter to ask."} -

    -
    - - }> - {(said) =>

    {said()}

    } -
    -
    + // An untouched box says nothing at all. "Nothing matches" is a + // verdict on a search, and no search has happened yet - the + // placeholder in the field is already the whole instruction. + +
    + +

    Nothing here matches “{query().trim()}”.

    + {/* The offer is only made when it can be honoured. Telling + a signed-out student to press Enter and then refusing + them is worse than not offering. */} + +

    + {asking() ? "Working it out…" : "Press Enter to ask."} +

    +
    + + }> + {(said) =>

    {said()}

    } +
    +
    +
    }>
      {(hit, i) => ( diff --git a/worker/src/gemini.ts b/worker/src/gemini.ts index e06d3a9..5fe0b07 100644 --- a/worker/src/gemini.ts +++ b/worker/src/gemini.ts @@ -13,8 +13,24 @@ import type { AskRequest } from "./schema"; import { VIEWS } from "./schema"; +/** + * The routing model. + * + * `flash-lite` because this is a classification with a fixed output schema and + * five possible destinations - the cheapest model that can read a course list + * is the right one, and a larger model buys nothing a JSON enum has already + * decided. + * + * The floating alias rather than a pinned version, which is the opposite of the + * usual advice and is deliberate. `gemini-2.0-flash` was pinned here and Google + * retired it; the endpoint began returning 404 and every question in the app + * failed. A pinned version's failure mode is total and arrives without warning. + * The alias's failure mode is that the model behind it changes - and with + * temperature 0, a schema the API enforces during decoding, and `parseAction` + * rejecting anything outside it, there is very little for a change to break. + */ const ENDPOINT = - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"; + "https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent"; const SYSTEM = `You route questions inside TargetX, a KTU academic tracker. From 9b56219a926eef7cb7efeae6a135a3752192c5db Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:24:38 +0530 Subject: [PATCH 19/32] fix(palette): match a sentence's words literally, not as subsequences The loose subsequence match exists so "cn" finds Computer Networks. Applied to words pulled out of a sentence it matches far too much: "what happens if i miss one more class" leaves the terms [happens, one], and "one" subsequence-matches Computer Networks - o, n, e in order. Four of seven subjects matched that query. The damage is not the wrong rows. A local hit stops Enter from reaching the router, so a phantom match silently disables the one path that could answer a question the engine cannot. Loose matching is now allowed only when the query is a single term, which is the case it was built for. The test seeds Computer Networks specifically so it can fail: a suite holding only Machine Learning passes against the broken build, because that name has no o-n-e in it. Verified by reverting the fix in place - the test goes red - and restoring it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF --- app/src/ui/Palette.tsx | 25 ++++++++++-- app/src/ui/__tests__/PaletteAsk.test.tsx | 49 +++++++++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/app/src/ui/Palette.tsx b/app/src/ui/Palette.tsx index 59f6ffd..0cfca1b 100644 --- a/app/src/ui/Palette.tsx +++ b/app/src/ui/Palette.tsx @@ -65,12 +65,28 @@ function terms(q: string): string[] { .filter((w) => w.length > 0 && !STOP.has(w)); } -/** Subsequence match, so "cn" finds "Computer Networks". */ -function matches(haystack: string, needle: string): boolean { +/** + * Match a subject label against one term. + * + * `loose` allows a subsequence, so "cn" finds "Computer Networks". That is the + * behaviour a student typing an abbreviation wants, and it is ONLY safe when + * the abbreviation is the whole query. + * + * Applied to words pulled out of a sentence it matches almost anything, and + * measurably did: "if i took a leave tomorrow how badly would it affect my + * attendance" left the terms [took, tommorw, badly, would, affect], and "took" + * subsequence-matched "Computer Networks" - t, o, o, k in order, scattered + * across three words. The student got one arbitrary subject and no answer. + * + * Worse than a bad row: a local hit stops Enter from reaching the router, so a + * phantom match silently disables the one path that could have answered. + */ +function matches(haystack: string, needle: string, loose = true): boolean { if (!needle) return true; const h = haystack.toLowerCase(); const n = needle.toLowerCase(); if (h.includes(n)) return true; + if (!loose) return false; let i = 0; for (const ch of h) { if (ch === n[i]) i += 1; @@ -100,8 +116,11 @@ export function Palette(props: { open: boolean; onClose: () => void }) { // is not a failed search. It is a question about every subject, so every // subject answers it. const words = terms(q); + // One term is an abbreviation and gets the loose match. Several terms are a + // sentence, and a sentence's words have to appear literally - see `matches`. + const loose = words.length === 1; const anyTerm = (hay: string) => - words.length === 0 || words.some((w) => matches(hay, w)); + words.length === 0 || words.some((w) => matches(hay, w, loose)); const out: Hit[] = []; for (const v of VIEWS) { diff --git a/app/src/ui/__tests__/PaletteAsk.test.tsx b/app/src/ui/__tests__/PaletteAsk.test.tsx index 15a809f..8862e38 100644 --- a/app/src/ui/__tests__/PaletteAsk.test.tsx +++ b/app/src/ui/__tests__/PaletteAsk.test.tsx @@ -37,6 +37,21 @@ const ML: Partial = { s1: 38, s2: 34, other: 9, attended: 39, held: 50, dl: 0, }; +/** + * The subject that makes the phantom-match bug visible. + * + * "Computer Networks" contains o, n, e in order, so the word "one" - left + * behind by the stop list from "what happens if i miss one more class" - + * subsequence-matched it. "Machine Learning" does not, so a test seeded only + * with ML would pass against the broken build and prove nothing. This course + * is here to make the test able to fail; the revert check below confirms it + * does. + */ +const CN: Partial = { + code: "CST303", name: "Computer Networks", credits: 4, type: "TH 40/60", + s1: 30, s2: 28, other: 8, attended: 44, held: 50, dl: 0, +}; + function open() { edit((d) => { d.semesters = { S5: { courses: [] } }; @@ -45,6 +60,8 @@ function open() { }); addCourse(); updateCourse(0, ML); + addCourse(); + updateCourse(1, CN); return render(() => {}} />); } @@ -81,6 +98,33 @@ describe("the engine answers first", () => { expect(askRemote).not.toHaveBeenCalled(); }); + it("does not let a sentence phantom-match a subject and swallow the question", async () => { + open(); + // Measured against the real haystacks, not supposed: the palette matches + // the course NAME and the CODE as two separate strings (courseLabel is the + // name alone, engine/course.ts:44). The stop list leaves [happens, one], + // and "one" subsequence-matches "Computer Networks" - o, n, e in order. + // + // The damage is not the bad row. A local hit stops Enter from reaching the + // router, so a phantom match silently disables the one path that could + // have answered a question the engine cannot. + const input = type("what happens if i miss one more class"); + expect(screen.getByText(/Nothing here matches/)).toBeTruthy(); + fireEvent.keyDown(input, { key: "Enter" }); + await vi.waitFor(() => expect(askRemote).toHaveBeenCalledTimes(1)); + }); + + it("still resolves an abbreviation typed as the whole query", async () => { + open(); + // The loose match is why it exists, and it survives: "ml" is one term, so + // it may match a subsequence, and it finds Machine Learning. + type("ml"); + expect(screen.getByText("Machine Learning")).toBeTruthy(); + fireEvent.keyDown(screen.getByLabelText("Search subjects and views"), { key: "Enter" }); + await Promise.resolve(); + expect(askRemote).not.toHaveBeenCalled(); + }); + it("does not call out on an empty box", async () => { open(); fireEvent.keyDown(screen.getByLabelText("Search subjects and views"), { key: "Enter" }); @@ -110,7 +154,10 @@ describe("the router is the fallback", () => { // subject while proving nothing. What matters is that the seeded course's // 39 of 50 classes and its 38/34/9 marks are not in here, and an equality // check on the whole array says that and nothing weaker. - expect(subjects).toEqual([{ code: "CST305", name: "Machine Learning" }]); + expect(subjects).toEqual([ + { code: "CST305", name: "Machine Learning" }, + { code: "CST303", name: "Computer Networks" }, + ]); }); it("tells the student what happened when the router declines", async () => { From c0ef7d0b91e3de69ca9c555c72f8ba272f68d0df Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:34:59 +0530 Subject: [PATCH 20/32] fix(history): a blank box is not a delete, and a typed figure is not the university's Two data-integrity bugs on the one screen where the student edits by hand. Clearing the SGPA box and blurring deleted the entire SemesterHistory - the SGPA, both credit totals, the source and any recorded conflict - with no confirmation and no undo. Select-all, Backspace, Tab was enough, and the CGPA in the header dropped with no explanation. Meanwhile erasing everything two screens away has a two-step confirmation, so the guard was on the wrong action. Blank now means "I do not know this": the stored figure goes back in the box, and removing a semester is a deliberate two-press control that names the SGPA it will discard. Typing over an SGPA kept the record's existing source, so a hand-typed number on a row that came from a KTU grade card still claimed `source: gradecard` - the app asserting the university published something it never did, and holding that claim at rank 3, where a later real fetch of the correct figure ties and can silently lose. An edited SGPA is now `manual` (rank 2): still trusted over a portal scrape because it was deliberate, and correctly beaten by the card it replaced. The displaced figure goes into `conflict`, which already exists to hold exactly this, so the row keeps showing "KTU grade card said 8.42" and the student can put it back. Editing only the credits still keeps the card's provenance - the SGPA is unchanged, so the record is still the card's. `sameSgpa` is exported rather than duplicated: two definitions of "the student changed it" would drift. Each test was verified against a reverted fix - both pairs go red - because a test that cannot fail is worse here than no test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF --- app/src/engine/history.ts | 11 +- app/src/state/store.ts | 28 ++- app/src/styles/screens.css | 17 ++ app/src/ui/History.tsx | 53 +++++- app/src/ui/__tests__/HistoryEdits.test.tsx | 189 +++++++++++++++++++++ 5 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 app/src/ui/__tests__/HistoryEdits.test.tsx 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/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/screens.css b/app/src/styles/screens.css index 43e08ea..26112a8 100644 --- a/app/src/styles/screens.css +++ b/app/src/styles/screens.css @@ -1100,3 +1100,20 @@ a.link:hover { text-decoration-thickness: 2px; } 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; } diff --git a/app/src/ui/History.tsx b/app/src/ui/History.tsx index 7a07f8b..24b09d2 100644 --- a/app/src/ui/History.tsx +++ b/app/src/ui/History.tsx @@ -135,6 +135,10 @@ export function History() { Earned Recomputed Cross-check + {/* No visible label: the column holds one control per row and + each already names its own semester. A header word here + would be read out before every cell in the table. */} + Remove semester @@ -229,6 +233,14 @@ function HistoryRow(props: { row: Row }) { props.row.published ? String(props.row.published.sgpa) : ""); const [creditDraft, setCreditDraft] = createSignal( props.row.registered === null ? "" : String(props.row.registered)); + /** + * Removal is two presses, and deliberately not a modal. + * + * The first press turns the control into the confirmation, in the row being + * removed, so what is about to be discarded is on screen beside the question + * rather than described in a dialog covering it. + */ + const [confirming, setConfirming] = createSignal(false); const commit = () => { const sgpaValue = Number(sgpaDraft().trim()); @@ -238,7 +250,19 @@ function HistoryRow(props: { row: Row }) { // store is still a store write, and every write re-runs the memo this // table is built from. Tabbing across a row must cost nothing. if (!props.row.published) return; - edit((s) => { delete s.history[props.row.name]; }); + // An empty box means "I do not know this", never "destroy the record". + // + // Clearing it used to delete the entire SemesterHistory - the SGPA, both + // credit totals, the source and the recorded conflict - with no + // confirmation and no undo, on blur. Select-all, Backspace, Tab was + // enough, and the CGPA in the header silently dropped. That is the most + // destructive action in the app, and it was the only one with no guard, + // while erasing everything two screens away has a two-step confirmation. + // + // Removing a semester is now a deliberate act with its own control. A + // blur just puts back what is stored, so the box cannot be used to + // discard a figure the university published. + setSgpaDraft(String(props.row.published.sgpa)); return; } if (!Number.isFinite(sgpaValue)) return; @@ -314,6 +338,33 @@ function HistoryRow(props: { row: Row }) { )} + + {/* Only offered where there is something to remove. An empty row has + nothing to discard and the control would be a no-op wearing a + destructive label. */} + + setConfirming(true)}>Remove + }> + + {/* Names the figure being discarded, not just the semester. The + SGPA is the thing that took a semester to earn and the thing + the CGPA above will move without. */} + + Discard {props.row.name} ( + {props.row.published!.sgpa.toFixed(2)})? + + + + + + + ); } diff --git a/app/src/ui/__tests__/HistoryEdits.test.tsx b/app/src/ui/__tests__/HistoryEdits.test.tsx new file mode 100644 index 0000000..4324067 --- /dev/null +++ b/app/src/ui/__tests__/HistoryEdits.test.tsx @@ -0,0 +1,189 @@ +// @vitest-environment jsdom +/** + * What a hand edit on the History table is allowed to do. + * + * Two rules, both of which the screen previously broke, and both of which are + * the same rule seen twice: the app must never hold a number it cannot account + * for, and must never silently discard one the university published. + * + * - An empty box means "I do not know this". It used to mean "delete the + * entire semester record", on blur, with no confirmation and no undo - + * select-all, Backspace, Tab was enough to drop an SGPA, both credit + * totals, the source and the recorded conflict, and to move the CGPA in + * the header with no explanation. + * - Typing over a figure changes whose figure it is. Keeping `gradecard` on + * a hand-typed number would have the app claim the university published + * something it never did, and hold that claim at the rank where a later + * real fetch of the correct figure ties with it and can lose. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; +import { defaultTargets } from "../../engine"; +import { edit, state } from "../../state/store"; +import { History } from "../History"; + +const reset = () => { + edit((s) => { + s.activeSemester = "S5"; + s.semesters = { S5: { courses: [] } }; + s.history = {}; + s.goal = defaultTargets(); + }); +}; + +/** A semester the university published, which is the case with something to lose. */ +const seedCard = () => edit((s) => { + s.history["S3"] = { + sgpa: 8.42, creditsRegistered: 20, creditsEarned: 20, + source: "gradecard", conflict: null, + }; +}); + +const sgpaBox = () => + screen.getByLabelText("Published SGPA for S3") as HTMLInputElement; +const creditBox = () => + screen.getByLabelText("Registered credits for S3") as HTMLInputElement; + +/** Type a value and blur, which is what commits on this screen. */ +const put = (box: HTMLInputElement, value: string) => { + fireEvent.input(box, { target: { value } }); + fireEvent.blur(box); +}; + +beforeEach(reset); +afterEach(cleanup); + +describe("an empty box is not a delete", () => { + it("keeps the semester when the SGPA is cleared and blurred", () => { + seedCard(); + render(() => ); + put(sgpaBox(), ""); + + // The record survives, whole - not just the row. + expect(state.history["S3"]).toBeDefined(); + expect(state.history["S3"]!.sgpa).toBe(8.42); + expect(state.history["S3"]!.source).toBe("gradecard"); + expect(state.history["S3"]!.creditsRegistered).toBe(20); + }); + + it("puts the stored figure back in the box, so the screen and the store agree", () => { + seedCard(); + render(() => ); + put(sgpaBox(), ""); + // A box left blank over a stored 8.42 would be the screen showing one + // thing and the CGPA above being computed from another. + expect(sgpaBox().value).toBe("8.42"); + }); + + it("still writes nothing at all for a row that has no stored record", () => { + edit((s) => { s.semesters = { S3: { courses: [] }, S5: { courses: [] } }; }); + render(() => ); + put(sgpaBox(), ""); + expect(state.history["S3"]).toBeUndefined(); + }); +}); + +describe("removing a semester is deliberate", () => { + it("does not remove on the first press", () => { + seedCard(); + render(() => ); + fireEvent.click(screen.getByLabelText("Remove S3 from history")); + expect(state.history["S3"]).toBeDefined(); + }); + + it("names the figure being discarded before discarding it", () => { + seedCard(); + render(() => ); + fireEvent.click(screen.getByLabelText("Remove S3 from history")); + // The SGPA, not just the semester name: it is the thing that took a + // semester to earn and the thing the CGPA will move without. + // + // Scoped to the confirmation group rather than the whole screen - 8.42 is + // also sitting in the input box, so a document-wide query would pass + // without the confirmation naming anything at all. + const group = screen.getByRole("group", { name: "Remove S3?" }); + expect(group.textContent).toMatch(/Discard S3/); + expect(group.textContent).toMatch(/8\.42/); + }); + + it("removes only on the confirming press", () => { + seedCard(); + render(() => ); + fireEvent.click(screen.getByLabelText("Remove S3 from history")); + fireEvent.click(screen.getByText("Remove")); + expect(state.history["S3"]).toBeUndefined(); + }); + + it("keeps the record when the student backs out", () => { + seedCard(); + render(() => ); + fireEvent.click(screen.getByLabelText("Remove S3 from history")); + fireEvent.click(screen.getByText("Keep")); + expect(state.history["S3"]).toBeDefined(); + expect(state.history["S3"]!.sgpa).toBe(8.42); + }); + + it("offers nothing to remove on a row with no record", () => { + edit((s) => { s.semesters = { S3: { courses: [] }, S5: { courses: [] } }; }); + render(() => ); + expect(screen.queryByLabelText("Remove S3 from history")).toBeNull(); + }); +}); + +describe("editing a figure changes whose figure it is", () => { + it("marks an overwritten grade-card SGPA as the student's own entry", () => { + seedCard(); + render(() => ); + put(sgpaBox(), "8.6"); + + expect(state.history["S3"]!.sgpa).toBe(8.6); + // Not `gradecard`. No grade card ever carried 8.6, and claiming one did + // would hold the figure at the rank that beats a real KTU fetch. + expect(state.history["S3"]!.source).toBe("manual"); + }); + + it("keeps the displaced university figure so it can be seen and put back", () => { + seedCard(); + render(() => ); + put(sgpaBox(), "8.6"); + + expect(state.history["S3"]!.conflict) + .toEqual({ source: "gradecard", sgpa: 8.42 }); + // And it is on screen, not merely in the store. + expect(screen.getByText(/KTU grade card said/i)).toBeTruthy(); + }); + + it("leaves the source alone when only the credits box moved", () => { + seedCard(); + render(() => ); + put(creditBox(), "22"); + + // The SGPA is still exactly what the card published, so the record is + // still the card's - which is what lets it outrank a portal scrape. + expect(state.history["S3"]!.creditsRegistered).toBe(22); + expect(state.history["S3"]!.source).toBe("gradecard"); + expect(state.history["S3"]!.conflict).toBeNull(); + }); + + it("does not re-tag the record when the SGPA is retyped identically", () => { + seedCard(); + render(() => ); + put(sgpaBox(), "8.42"); + expect(state.history["S3"]!.source).toBe("gradecard"); + }); + + it("does not stack a conflict when a hand entry is edited again", () => { + edit((s) => { + s.history["S3"] = { + sgpa: 7.5, creditsRegistered: 20, creditsEarned: 20, + source: "manual", conflict: null, + }; + }); + render(() => ); + put(sgpaBox(), "7.9"); + // Replacing one of the student's own guesses with another displaces no + // published figure, so there is no disagreement to record. + expect(state.history["S3"]!.source).toBe("manual"); + expect(state.history["S3"]!.conflict).toBeNull(); + }); +}); From 368f98a51daac838a12f8ed28cacdf894bb1292e Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:39:49 +0530 Subject: [PATCH 21/32] feat(palette): match screens on the words the stop list throws away Traced 24 real student phrasings through the matcher. Three questions the engine answers instantly and offline were reaching the network instead, and one had regressed. "am i short on attendance" found nothing at all. The stop list strips domain words - attendance, marks, classes - because otherwise every phrasing of every question matches every subject; but those same words are exactly what names a SCREEN, and stripping them left the question with only "short", which named nothing. Views are now matched on the raw question against an explicit `keys` list, so the words that identify a screen are used to find it. The keys are a list rather than the hint text because the hints are English sentences and matching "what needs doing" would have "what" open Home. "what do i need in the final to pass cn" stopped resolving when in-sentence subsequence matching was closed - the fix for phantom matches took this with it. Initials give it back: "Computer Networks" -> "cn" exactly, which cannot over-match the way a subsequence does. "one" is not the initials of anything. Consequence worth recording: the router is now called for almost nothing. Local matching answers the engine questions, routes the navigational ones, and sends attendance-shaped questions to Attendance. What remains for the model is the long tail of phrasings the keyword list misses - which is the right division, since the local path is free, offline and cannot invent anything. The phantom-match test was rewritten rather than deleted: "what happens if i miss one more class" now legitimately matches the Attendance view, so the assertion moved to the property that actually matters - no SUBJECT is claimed to match. Verified it still goes red against the reverted fix. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF --- app/src/state/nav.ts | 35 ++++++++++++++--- app/src/ui/Palette.tsx | 22 ++++++++++- app/src/ui/__tests__/PaletteAsk.test.tsx | 49 +++++++++++++++++++----- 3 files changed, 89 insertions(+), 17 deletions(-) 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/ui/Palette.tsx b/app/src/ui/Palette.tsx index 0cfca1b..5fee3ad 100644 --- a/app/src/ui/Palette.tsx +++ b/app/src/ui/Palette.tsx @@ -86,6 +86,12 @@ function matches(haystack: string, needle: string, loose = true): boolean { const h = haystack.toLowerCase(); const n = needle.toLowerCase(); if (h.includes(n)) return true; + // Initials, which work in a sentence because they cannot over-match: the + // initials of "Computer Networks" are exactly "cn" and nothing else. Losing + // this was the cost of the fix above - "what do i need to pass cn" is four + // terms, so the loose path is closed and "cn" stopped resolving. This gives + // it back without giving back "one" matching Computer Networks. + if (n.length >= 2 && initials(h) === n) return true; if (!loose) return false; let i = 0; for (const ch of h) { @@ -95,6 +101,15 @@ function matches(haystack: string, needle: string, loose = true): boolean { return false; } +/** "Computer Networks" -> "cn". Words only; "and"/"of" are not initials. */ +const SKIP_WORD = new Set(["and", "of", "the", "for", "in", "to", "a"]); +function initials(label: string): string { + return label.toLowerCase().split(/[^a-z0-9]+/) + .filter((w) => w.length > 0 && !SKIP_WORD.has(w)) + .map((w) => w[0]) + .join(""); +} + export function Palette(props: { open: boolean; onClose: () => void }) { const [query, setQuery] = createSignal(""); const [cursor, setCursor] = createSignal(0); @@ -123,8 +138,13 @@ export function Palette(props: { open: boolean; onClose: () => void }) { words.length === 0 || words.some((w) => matches(hay, w, loose)); const out: Hit[] = []; + // Views are matched on the RAW question, not on `terms`. The stop list + // exists to stop domain words matching every subject, and those same words + // - attendance, marks, results, sync - are the ones that name a screen. A + // question stripped of them had nothing left to match a view with. + const raw = new Set(q.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)); for (const v of VIEWS) { - if (!matches(v.label, q)) continue; + if (!matches(v.label, q) && !v.keys.some((k) => raw.has(k))) continue; out.push({ kind: "view", label: v.label, detail: v.hint ?? "", go: () => setView(v.id as View), diff --git a/app/src/ui/__tests__/PaletteAsk.test.tsx b/app/src/ui/__tests__/PaletteAsk.test.tsx index 8862e38..49ee9ca 100644 --- a/app/src/ui/__tests__/PaletteAsk.test.tsx +++ b/app/src/ui/__tests__/PaletteAsk.test.tsx @@ -98,20 +98,22 @@ describe("the engine answers first", () => { expect(askRemote).not.toHaveBeenCalled(); }); - it("does not let a sentence phantom-match a subject and swallow the question", async () => { + it("does not let a sentence phantom-match a subject", () => { open(); // Measured against the real haystacks, not supposed: the palette matches // the course NAME and the CODE as two separate strings (courseLabel is the // name alone, engine/course.ts:44). The stop list leaves [happens, one], - // and "one" subsequence-matches "Computer Networks" - o, n, e in order. - // - // The damage is not the bad row. A local hit stops Enter from reaching the - // router, so a phantom match silently disables the one path that could - // have answered a question the engine cannot. - const input = type("what happens if i miss one more class"); - expect(screen.getByText(/Nothing here matches/)).toBeTruthy(); - fireEvent.keyDown(input, { key: "Enter" }); - await vi.waitFor(() => expect(askRemote).toHaveBeenCalledTimes(1)); + // and "one" subsequence-matches "Computer Networks" - o, n, e in order, so + // an unrelated subject was offered as the answer to a question about + // missing a class. + type("what happens if i miss one more class"); + + // The question is about attendance, so the Attendance view is the right + // row and it is there - "miss" and "class" name the screen. + expect(screen.getByText("Attendance")).toBeTruthy(); + // But no subject is claimed to match, because none does. + expect(screen.queryByText("Computer Networks")).toBeNull(); + expect(screen.queryByText("Machine Learning")).toBeNull(); }); it("still resolves an abbreviation typed as the whole query", async () => { @@ -125,6 +127,33 @@ describe("the engine answers first", () => { expect(askRemote).not.toHaveBeenCalled(); }); + it("finds the screen a question names, even though the word was stripped", () => { + open(); + // "attendance" is a stop word - it appears in every phrasing of every + // attendance question, so matching subjects on it would match all of them. + // Stripping it left "am i short on attendance" with only [short], which + // named nothing, and the question that the Attendance screen answers + // outright found no results at all. Views are matched on the raw question + // for exactly this reason. + type("am i short on attendance"); + expect(screen.getByText("Attendance")).toBeTruthy(); + }); + + it("routes a CGPA question without a screen called CGPA", () => { + open(); + type("whats my cgpa"); + expect(screen.getByText("Home")).toBeTruthy(); + }); + + it("resolves an abbreviation used inside a sentence, by initials", () => { + open(); + // Four terms, so the loose subsequence path is closed. Initials are what + // carries this: "Computer Networks" -> "cn" exactly, which cannot + // over-match the way a subsequence does. + type("what do i need in the final to pass cn"); + expect(screen.getByText("Computer Networks")).toBeTruthy(); + }); + it("does not call out on an empty box", async () => { open(); fireEvent.keyDown(screen.getByLabelText("Search subjects and views"), { key: "Enter" }); From 510c6cb1853007db55e1bd03b355998122d62754 Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:29:43 +0530 Subject: [PATCH 22/32] feat(engine): price the next absence, not just the eligibility slack Every attendance figure in the app answered "am I above 75%". A student at 86% is told by every portal that they are fine; two absences put them at 84% and cost a CIE mark under R 7.5.ii, and nothing anywhere tells them until the mark is gone. That gap is the product. `absenceCost` reports both sides - "86% and 4 marks becomes 84% and 3" - rather than a bare delta, because a difference on its own is a figure that cannot show its working. `freeSkips` is the budget that actually binds: the last absence that costs no mark, which for most students is far smaller than the number that keeps them eligible. Composition, not new arithmetic. The percentage after n absences is the same effective figure the rest of the engine uses, recomputed with the classes added to `held`; the marks on both sides come from `attendanceMarks`. Nothing here carries a second opinion about what a percentage is worth, and the tests assert against the band table rather than against copied output. Duty leave is deliberately held at today's credited value. `creditDutyLeave` caps DL at a fraction of held, so a larger denominator could mathematically admit more DL - but a student does not acquire duty leave by missing a class, and letting the cap rise would report a skip as cheaper than it is. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF --- app/src/engine/__tests__/absenceCost.test.ts | 129 +++++++++++++++++++ app/src/engine/attendance.ts | 93 ++++++++++++- app/src/engine/types.ts | 23 ++++ 3 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 app/src/engine/__tests__/absenceCost.test.ts 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/attendance.ts b/app/src/engine/attendance.ts index 5168d04..3f60387 100644 --- a/app/src/engine/attendance.ts +++ b/app/src/engine/attendance.ts @@ -1,7 +1,9 @@ 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, 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 +210,92 @@ 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; +} 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; From 782ca5a4b5765a4cf87b00db00512dbe9b73ec4a Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:34:55 +0530 Subject: [PATCH 23/32] feat(worker): log every question the router is asked, content apart from identity Local matching in the app now answers almost everything - the keyword list routes attendance, marks, results and sync questions without calling out - so the requests that reach this Worker are, by construction, the phrasings the keyword list MISSED. That set is the only evidence there is about what to add to it, and it was being discarded the instant a response was returned. Refusals are recorded as carefully as successes and are the more valuable half: a declined question is a student who wanted something the app would not give them, and nothing else in the system records that. Upstream failures are recorded too, because a run of them is the difference between "nobody asked" and "everybody asked and got nothing". Identity is deliberately not in the log. No `sub`, no token, no name, no email - the caller is verified on every request and counted for quota, that value just never reaches this file, and the quota counter needs no content to work. Holding both halves would make this a named student's question history over time, which carries DPDP obligations that a routing corpus does not. Course codes are out for a weaker version of the same reason: a distinctive question plus one semester's course list re-identifies a student in a small cohort. The count is kept, since that is all the router's behaviour depends on. The write is in ctx.waitUntil and logAsk swallows every error, so a logging failure cannot turn an answered question into a 502. The binding is optional in the Env type: removing the dataset degrades to no logging rather than to a broken endpoint. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF --- worker/.dev.vars.example | 11 +++++ worker/src/index.ts | 25 +++++++++- worker/src/log.ts | 103 +++++++++++++++++++++++++++++++++++++++ worker/wrangler.toml | 8 +++ 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 worker/.dev.vars.example create mode 100644 worker/src/log.ts diff --git a/worker/.dev.vars.example b/worker/.dev.vars.example new file mode 100644 index 0000000..d418cc8 --- /dev/null +++ b/worker/.dev.vars.example @@ -0,0 +1,11 @@ +# Local development only. Wrangler reads this when you run `npm run dev`. +# +# NEVER commit this file - it is gitignored, and the committed copy is this +# .example. For the DEPLOYED worker the key is not a file at all: +# +# npx wrangler secret put GEMINI_KEY +# +# which prompts, stores the value encrypted at Cloudflare, and never writes it +# to disk. The key must not go anywhere near app/.env: Vite inlines every +# VITE_* value into the shipped bundle, so it would ship inside the installer. +GEMINI_KEY= diff --git a/worker/src/index.ts b/worker/src/index.ts index a0ee2e0..77f8eb4 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -22,6 +22,7 @@ import { verify } from "./clerk"; import { claim } from "./limit"; import { route } from "./gemini"; import { parseAction, parseAskRequest } from "./schema"; +import { logAsk, outcomeOf } from "./log"; export { Quota } from "./limit"; @@ -29,6 +30,12 @@ interface Env { GEMINI_KEY: string; CLERK_ISSUER: string; QUOTA: DurableObjectNamespace; + /** + * Optional. A deployment without the dataset bound still answers questions; + * see `logAsk`. Analytics Engine is not on every plan, and losing the log is + * a worse outcome than losing the feature only if the feature still works. + */ + ASK_LOG?: AnalyticsEngineDataset; } /** No credentials, no cookies - the client sends a bearer token by hand. */ @@ -43,7 +50,7 @@ function json(body: unknown, status = 200, extra: Record = {}) { } export default { - async fetch(request: Request, env: Env): Promise { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { if (request.method === "OPTIONS") return new Response(null, { headers: CORS }); if (request.method !== "POST") return json({ error: "method" }, 405); @@ -80,10 +87,21 @@ export default { // worth more here than a slow success. const abort = new AbortController(); const timer = setTimeout(() => abort.abort(), 8000); + const started = Date.now(); + // Logged on every path below, including the failures. The set of questions + // that reach this Worker at all is the set the app could not answer by + // itself, which is the only evidence there is about what to teach it next. + const record = (outcome: Parameters[1]["outcome"]) => + ctx.waitUntil(logAsk(env.ASK_LOG, { + outcome, question: ask.question, + subjectCount: ask.subjects.length, + latencyMs: Date.now() - started, + })); try { const raw = await route(ask, env.GEMINI_KEY, abort.signal); const action = parseAction(raw, new Set(ask.subjects.map((s) => s.code))); + record(outcomeOf(action)); // The model returned something outside its own schema. That is not an // action the app is allowed to act on, and it is not an error the student @@ -96,7 +114,10 @@ export default { } catch { // Nothing about the upstream failure is echoed back. The error text could // carry a URL, a key fragment or a project id, and none of that is the - // student's business or safe to leak. + // student's business or safe to leak. It IS recorded, because a run of + // upstream errors is the difference between "nobody asked" and "everybody + // asked and got nothing". + record("upstream_error"); return json({ error: "upstream" }, 502); } finally { clearTimeout(timer); diff --git a/worker/src/log.ts b/worker/src/log.ts new file mode 100644 index 0000000..977cdab --- /dev/null +++ b/worker/src/log.ts @@ -0,0 +1,103 @@ +/** + * What the router was asked, and what it did about it. + * + * The point of this log is one specific blind spot. Local matching in the app + * now answers almost every question - the keyword list routes attendance, + * marks, results and sync questions without ever calling out - so the requests + * that reach this Worker are, by construction, the phrasings the keyword list + * MISSED. That set is the only evidence there is about what to add to it, and + * without a log it is discarded the instant a response is returned. + * + * Refusals are logged as carefully as successes, and are the more valuable + * half: a question the model declined is a student who wanted something the app + * would not give them, and nothing else in the system records that. + * + * WHAT IS DELIBERATELY NOT LOGGED + * + * No `sub`, no token, no name, no email. The caller's identity is verified on + * every request and used to count quota - it just never reaches this file, and + * the quota counter needs no content to do its job. Keeping the two apart is + * the whole design: with both, this is a profile of a named student's questions + * over time, and holding that carries obligations under the DPDP Act that a + * routing corpus does not. + * + * No course codes or names either. A course list is not sensitive on its own, + * but a distinctive question plus a specific semester's course list re-identifies + * a student inside a small cohort. The COUNT is kept, because the only thing the + * router's behaviour depends on is whether it had a course list to work with. + * + * The question text itself IS kept, because it is the thing being studied and + * there is no useful anonymised form of it. It is free text a student typed, so + * it is treated as the sensitive field it is: nothing is joined to it. + */ + +/** Every way a request can end. The refusals are the interesting rows. */ +export type Outcome = + | "routed_view" + | "routed_subject" + | "declined_off_topic" + | "declined_unclear" + | "declined_no_match" + /** The model returned something outside its own schema. */ + | "unparseable" + /** Gemini failed, timed out, or was aborted. */ + | "upstream_error"; + +export interface AskLog { + outcome: Outcome; + question: string; + /** How many courses the client sent. Never which ones. */ + subjectCount: number; + /** Milliseconds spent in the model call. */ + latencyMs: number; +} + +/** + * A stable id for a question, so repeats can be counted without grouping by the + * raw string in every query. Truncated: this identifies a phrasing, and a + * full-width digest would invite treating it as a key for something else. + */ +async function digest(text: string): Promise { + const bytes = new TextEncoder().encode(text.toLowerCase().trim()); + const hash = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(hash).slice(0, 8)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Write one point, or quietly do nothing. + * + * Never throws and never awaits into the response path. A logging failure - + * an unbound dataset, a quota, a malformed point - must not turn a working + * answer into an error for the student. The binding is optional for the same + * reason: a deployment without it should serve questions, not refuse them. + */ +export async function logAsk( + dataset: AnalyticsEngineDataset | undefined, entry: AskLog, +): Promise { + if (!dataset) return; + try { + dataset.writeDataPoint({ + // `indexes` is what the dataset is sampled and grouped by, so it carries + // the outcome - the dimension every question about this log starts from. + indexes: [entry.outcome], + blobs: [entry.outcome, entry.question, await digest(entry.question)], + doubles: [entry.subjectCount, entry.latencyMs], + }); + } catch { + // Nothing to do and nobody to tell. The request has already been answered. + } +} + +/** The outcome an Action maps to, so the caller does not spell it out twice. */ +export function outcomeOf( + action: { kind: string; reason?: string } | null, +): Outcome { + if (!action) return "unparseable"; + if (action.kind === "view") return "routed_view"; + if (action.kind === "subject") return "routed_subject"; + if (action.reason === "off_topic") return "declined_off_topic"; + if (action.reason === "no_match") return "declined_no_match"; + return "declined_unclear"; +} diff --git a/worker/wrangler.toml b/worker/wrangler.toml index e381719..aabaa07 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -17,3 +17,11 @@ class_name = "Quota" [[migrations]] tag = "v1" new_sqlite_classes = ["Quota"] + +# The router's question log. Content only - no `sub`, no token, no course codes; +# see src/log.ts for why identity and content are kept apart. The binding is +# optional in the Worker, so removing this block degrades to no logging rather +# than to a broken endpoint. +[[analytics_engine_datasets]] +binding = "ASK_LOG" +dataset = "targetx_ask_log" From 5b16526a49955c417c4491e1f8f06d0ee227b5de Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:43:12 +0530 Subject: [PATCH 24/32] feat(ask): answer the question in the box instead of pointing at a screen "If I take a leave tomorrow, how badly does it affect my attendance" opened the Attendance screen, which shows a static budget - classes left before 75% - and never what the NEXT absence costs. Those are different facts and the second one was the question. The box now says it. The model still never states a figure. It names which question this is and which subject it is about, both from fixed enums, and every number in the sentence is computed here by the engine from this student's own records. What changed is where the answer appears, not who produces it. Both sides of a change are always given - "86% becomes 84%, costs 1 mark" - never a bare delta, because a difference on its own is a figure the student cannot check against their own portal. `tomorrowCourses` is the join that was missing: the timetable and the attendance budget have both been in the app all along, two hundred pixels apart, with nothing multiplying them. Portal subject strings are matched back to the student's own courses by code then by name, and a period matching nothing is dropped rather than guessed at - naming a subject they do not have is worse than a shorter list. Topics are only added where the engine can answer exactly. There is no "general" topic and no free-text field: a question shape with no solver behind it falls through to a route, because the alternative is a sentence nobody can show the working for. `answerFor` returns null on no courses, no attendance or no timetable, and the caller routes instead - a screen showing nothing is more honest than a sentence built from nothing. Detection is local keywords, so "can i skip tomorrow" is answered with no network round trip at all. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF --- app/src/state/answers.ts | 224 ++++++++++++++++++++ app/src/styles/app.css | 28 +++ app/src/ui/Palette.tsx | 43 ++++ app/src/ui/__tests__/PaletteAnswer.test.tsx | 193 +++++++++++++++++ 4 files changed, 488 insertions(+) create mode 100644 app/src/state/answers.ts create mode 100644 app/src/ui/__tests__/PaletteAnswer.test.tsx diff --git a/app/src/state/answers.ts b/app/src/state/answers.ts new file mode 100644 index 0000000..92e27c7 --- /dev/null +++ b/app/src/state/answers.ts @@ -0,0 +1,224 @@ +import { absenceCost, courseLabel, freeSkips } from "../engine"; +import type { Course } from "../engine"; +import { rows, state } from "./store"; +import type { View } from "./nav"; + +/** + * 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", +] as const; +export type Topic = (typeof TOPICS)[number]; + +export interface Answer { + /** The sentence. Every figure in it came from the engine. */ + headline: string; + /** 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`; +} + +/** + * 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", + }; + } + + const line = topic === "skip_cost" ? skipLine : 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:" + : "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. + */ +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} `)); + + if (has("tomorrow", "tmrw", "tommorow", "tommorw")) return "tomorrow"; + 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"; + } + return null; +} diff --git a/app/src/styles/app.css b/app/src/styles/app.css index 1ed0123..9adc50a 100644 --- a/app/src/styles/app.css +++ b/app/src/styles/app.css @@ -1025,3 +1025,31 @@ tr.row:hover .del { opacity: 1; } @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); } diff --git a/app/src/ui/Palette.tsx b/app/src/ui/Palette.tsx index 5fee3ad..bdc8c5d 100644 --- a/app/src/ui/Palette.tsx +++ b/app/src/ui/Palette.tsx @@ -4,6 +4,8 @@ import { rows } from "../state/store"; import { VIEWS, setView } from "../state/nav"; import type { View } from "../state/nav"; import { askConfigured, askRemote } from "../state/ask"; +import { answerFor, detectTopic } from "../state/answers"; +import type { Topic } from "../state/answers"; import { signedIn } from "../state/auth"; /** @@ -119,6 +121,29 @@ export function Palette(props: { open: boolean; onClose: () => void }) { let input: HTMLInputElement | undefined; let inflight: AbortController | undefined; + /** + * The answer, when the question is one the engine can answer outright. + * + * Computed before routing and shown above the results, because it IS the + * answer - the rows below it are where to go for the working. Detection is + * local keywords, so "can i skip tomorrow" is answered without a network + * round trip for a sentence this machine can produce immediately. + */ + const answer = createMemo(() => { + const q = query().trim(); + if (q === "") return null; + const topic = detectTopic(q); + if (topic === null) return null; + // A subject named in the question narrows the answer to it; otherwise the + // question is about every subject and every subject answers. + const words = terms(q); + const loose = words.length === 1; + const named = words.length === 0 ? undefined : rows().find((r) => + words.some((w) => matches(courseLabel(r.course), w, loose) + || matches(r.course.code ?? "", w, loose))); + return answerFor(topic, named?.course.code); + }); + const hits = createMemo(() => { const q = query().trim(); // An empty box lists nothing. It used to open onto every view and every @@ -291,6 +316,24 @@ export function Palette(props: { open: boolean; onClose: () => void }) { onInput={(e) => setQuery(e.currentTarget.value)} onKeyDown={onKey} /> + + {(said) => ( +
      +

      {said().headline}

      + 0}> +
        + {(line) =>
      • {line}
      • }
        +
      +
      + {/* The answer is not a substitute for the working. */} + +
      + )} +
      + 0} fallback={ // An untouched box says nothing at all. "Nothing matches" is a // verdict on a search, and no search has happened yet - the diff --git a/app/src/ui/__tests__/PaletteAnswer.test.tsx b/app/src/ui/__tests__/PaletteAnswer.test.tsx new file mode 100644 index 0000000..29536f7 --- /dev/null +++ b/app/src/ui/__tests__/PaletteAnswer.test.tsx @@ -0,0 +1,193 @@ +// @vitest-environment jsdom +/** + * The box answers, instead of pointing at a screen that answers. + * + * "If I take a leave tomorrow, how badly does it affect my attendance" used to + * open the Attendance screen, which shows a static budget - how many classes + * are left before 75% - and never what the NEXT absence costs. Those are + * different facts, and the second one is the question. + * + * Every figure asserted here is re-derived from the engine in the test, never + * copied from a run: if `absenceCost` or the band table changes, this file + * fails rather than going on quoting a number the app no longer computes. + */ +import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ATTENDANCE_MARK_BANDS, absenceCost, freeSkips } from "../../engine"; +import type { Course } from "../../engine"; +import { addCourse, edit, updateCourse } from "../../state/store"; + +vi.mock("../../state/ask", () => ({ + askConfigured: () => false, + askRemote: vi.fn(), +})); +vi.mock("../../state/auth", () => ({ signedIn: () => false })); + +const { Palette } = await import("../Palette"); + +const FULL_AT = Math.max(...ATTENDANCE_MARK_BANDS.map(([p]) => p)); + +/** Sits exactly on the full-marks line, so one absence crosses a band. */ +const EDGE: Partial = { + code: "CST303", name: "Computer Networks", credits: 4, type: "TH 40/60", + s1: 30, s2: 28, other: 8, dl: 0, + held: 100, attended: Math.ceil((FULL_AT / 100) * 100), +}; + +/** Comfortably clear, so an absence costs nothing. */ +const CLEAR: Partial = { + code: "CST305", name: "Machine Learning", credits: 4, type: "TH 40/60", + s1: 38, s2: 34, other: 9, dl: 0, held: 100, attended: 99, +}; + +function open(...courses: Partial[]) { + edit((d) => { + d.semesters = { S5: { courses: [] } }; + d.activeSemester = "S5"; + d.history = {}; + d.timetable = undefined; + }); + courses.forEach((c, i) => { addCourse(); updateCourse(i, c); }); + return render(() => {}} />); +} + +const type = (value: string) => { + const input = screen.getByLabelText("Search subjects and views") as HTMLInputElement; + fireEvent.input(input, { target: { value } }); + return input; +}; + +afterEach(cleanup); +beforeEach(() => { edit((d) => { d.timetable = undefined; }); }); + +describe("the cost of the next absence is stated, not routed to", () => { + it("names both sides of the change, so the student can check it", () => { + open(EDGE); + type("what happens if i miss one more class in computer networks"); + + const cost = absenceCost(EDGE.attended!, EDGE.held!, 0, 1)!; + // Derived, not copied: the sentence must carry the engine's own figures. + expect(cost.marksLost).toBeGreaterThan(0); + const said = screen.getByText(new RegExp( + `${cost.before.toFixed(0)}%.*${cost.after.toFixed(0)}%`)); + expect(said).toBeTruthy(); + expect(said.textContent).toMatch(new RegExp(`costs ${cost.marksLost} mark`)); + }); + + it("says free when it is free, and how much room is left", () => { + open(CLEAR); + type("what happens if i miss one more class in machine learning"); + + const free = freeSkips(CLEAR.attended!, CLEAR.held!, 0)!; + expect(absenceCost(CLEAR.attended!, CLEAR.held!, 0, 1)!.marksLost).toBe(0); + expect(screen.getByText(new RegExp(`free \\(${free} to spare`))).toBeTruthy(); + }); + + it("answers about every subject when none is named", () => { + open(EDGE, CLEAR); + type("what happens if i miss one more class"); + expect(screen.getByText(/What one more absence costs, per subject/)).toBeTruthy(); + expect(screen.getByText(/Computer Networks/)).toBeTruthy(); + expect(screen.getByText(/Machine Learning/)).toBeTruthy(); + }); + + it("still offers the working, because the sentence is not a substitute for it", () => { + open(EDGE); + type("what happens if i miss one more class"); + expect(screen.getByText("See the full breakdown")).toBeTruthy(); + }); + + it("says nothing at all when attendance was never recorded", () => { + open({ ...EDGE, attended: null, held: null, attendance: null }); + type("what happens if i miss one more class"); + // A sentence built from nothing is worse than no sentence. + expect(screen.queryByText(/costs/)).toBeNull(); + }); +}); + +describe("tomorrow joins the timetable to the budget", () => { + const withTimetable = () => edit((d) => { + // Every weekday carries the same two subjects, so the test does not depend + // on which day it is run. + d.timetable = { + grid: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + .map((day) => ({ + day, + periods: [ + { subject: "CST303 Computer Networks", teacher: null }, + { subject: "CST305 Machine Learning", teacher: null }, + ], + })), + substitutions: [], + }; + }); + + it("prices each subject that actually runs tomorrow", () => { + open(EDGE, CLEAR); + withTimetable(); + type("can i skip tomorrow"); + + expect(screen.getByText(/Tomorrow costs you something/)).toBeTruthy(); + // The one that crosses a band is priced; the one that does not is free. + const cost = absenceCost(EDGE.attended!, EDGE.held!, 0, 1)!; + expect(screen.getByText(new RegExp(`costs ${cost.marksLost} mark`))).toBeTruthy(); + expect(screen.getByText(/Machine Learning.*free/)).toBeTruthy(); + }); + + it("says tomorrow is free when nothing on it costs a mark", () => { + open(CLEAR); + edit((d) => { + d.timetable = { + grid: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + .map((day) => ({ + day, periods: [{ subject: "CST305 Machine Learning", teacher: null }], + })), + substitutions: [], + }; + }); + type("can i skip tomorrow"); + expect(screen.getByText(/Tomorrow is free/)).toBeTruthy(); + }); + + it("declines rather than guessing when there is no timetable", () => { + open(EDGE, CLEAR); + type("can i skip tomorrow"); + // No timetable means the app does not know what runs tomorrow. Listing + // every subject would be answering a question it was not asked. + expect(screen.queryByText(/Tomorrow/)).toBeNull(); + }); + + it("drops a timetable subject the student does not have, rather than naming it", () => { + open(CLEAR); + edit((d) => { + d.timetable = { + grid: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + .map((day) => ({ + day, + periods: [ + { subject: "CST305 Machine Learning", teacher: null }, + { subject: "MAT101 Some Elective Nobody Registered", teacher: null }, + ], + })), + substitutions: [], + }; + }); + type("can i skip tomorrow"); + expect(screen.queryByText(/Some Elective/)).toBeNull(); + expect(screen.getByText(/Machine Learning/)).toBeTruthy(); + }); +}); + +describe("eligibility is answered from the record", () => { + it("does not claim every subject is fine when one is not", () => { + open(EDGE, { ...CLEAR, attended: 60, held: 100 }); + type("am i eligible"); + expect(screen.getByText(/1 of 2 below 75%/)).toBeTruthy(); + }); + + it("says so plainly when all of them are", () => { + open(EDGE, CLEAR); + type("am i eligible"); + expect(screen.getByText(/Eligible in all 2/)).toBeTruthy(); + }); +}); From 8c8448c7a6a865aa47ee0bbd576687cf162850ac Mon Sep 17 00:00:00 2001 From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:53:02 +0530 Subject: [PATCH 25/32] feat(attendance): show the counts every percentage is computed from `attended` and `held` appeared in exactly one place in the whole app - as editable inputs inside an expanded Ledger row, one subject at a time, behind a click. So the app printed "78%" and "you can miss 4 more" on its most prominent screen and never showed the two numbers those come from, which is the one thing it says it will never do. A student wanting to check their own figure had to open seven rows one at a time. Three places, one addition each: The Attendance card now carries "39 of 50 attended" under the subject name, plus the credited duty leave when there is any - without it the working would not add up, since DL is exactly why the shown percentage differs from attended/held. It deliberately does not repeat the percentage sitting one line above: saying it twice would make the counts read as a second opinion rather than as the source. The Ledger row now shows 39/50 beside the percentage, in the column that was already there. The calendar was hue-only - empty divs whose sole carrier was a hover `title`, above a legend marked aria-hidden. Under a red-green deficiency the three statuses that matter collapse into identical squares, and to a screen reader the grid was a table of empty cells with its key hidden. Each block now carries a glyph and an sr-only status, and the legend is exposed. Also adds a roll-up the period log could always have supported and never did: attendance counted per subject from the day-by-day record, beside the totals the portal published. Two independent figures for one fact, which should agree - and where they do not, the app states the disagreement rather than silently preferring the number it happens to store. And `/5` on the Att mk column is now `specFor(type).attMax`. CourseSpec.attMax exists so it can vary and constants.ts instructs the next maintainer to spell out per-type values; on that day the literal would have printed "4/5" for a course the engine scores out of 8. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF --- app/src/engine/__tests__/daywise.test.ts | 88 +++++++++ app/src/engine/attendance.ts | 44 ++++- app/src/styles/app.css | 10 + app/src/styles/calendar.css | 12 ++ app/src/styles/screens.css | 9 + app/src/ui/Attendance.tsx | 187 ++++++++++++++++-- app/src/ui/Ledger.tsx | 22 ++- .../ui/__tests__/AttendanceCounts.test.tsx | 120 +++++++++++ 8 files changed, 472 insertions(+), 20 deletions(-) create mode 100644 app/src/engine/__tests__/daywise.test.ts create mode 100644 app/src/ui/__tests__/AttendanceCounts.test.tsx 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 3f60387..12253b6 100644 --- a/app/src/engine/attendance.ts +++ b/app/src/engine/attendance.ts @@ -2,7 +2,8 @@ import { ATTENDANCE_MARK_BANDS, ATTENDANCE_MARK_MAX, ATTENDANCE_MIN, DL_CAP_PCT, } from "./constants"; import type { - AbsenceCost, AttendanceBand, AttendancePlan, Course, MarkInput, + AbsenceCost, AttendanceBand, AttendancePlan, Course, DaywiseAttendance, + MarkInput, } from "./types"; import { ceil, clamp, floor, round, toFloat, toOptionalFloat } from "./util"; @@ -299,3 +300,44 @@ export function freeSkips( } 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/styles/app.css b/app/src/styles/app.css index 9adc50a..950b3a6 100644 --- a/app/src/styles/app.css +++ b/app/src/styles/app.css @@ -1053,3 +1053,13 @@ tr.row:hover .del { opacity: 1; } 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; +} diff --git a/app/src/styles/calendar.css b/app/src/styles/calendar.css index a29d3f7..66fd5a0 100644 --- a/app/src/styles/calendar.css +++ b/app/src/styles/calendar.css @@ -270,3 +270,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/screens.css b/app/src/styles/screens.css index 26112a8..171c1ec 100644 --- a/app/src/styles/screens.css +++ b/app/src/styles/screens.css @@ -1117,3 +1117,12 @@ a.link:hover { text-decoration-thickness: 2px; } .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); +} diff --git a/app/src/ui/Attendance.tsx b/app/src/ui/Attendance.tsx index 6516e29..fe5e38b 100644 --- a/app/src/ui/Attendance.tsx +++ b/app/src/ui/Attendance.tsx @@ -1,7 +1,7 @@ import { For, Show, createMemo } from "solid-js"; import { ATTENDANCE_FULL_MARKS_PCT, ATTENDANCE_MARK_MAX, ATTENDANCE_MIN, - attendanceMarks, courseLabel, + attendanceMarks, courseLabel, daywiseBySubject, toOptionalFloat, } from "../engine"; import type { AttendancePlan, AttendanceStatus, DaywiseDay, TimetableDay, @@ -30,6 +30,17 @@ interface Line { /** 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() { @@ -38,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 ( @@ -71,6 +84,7 @@ export function Attendance() {
+
@@ -87,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: "" }, }; /** @@ -107,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 @@ -131,12 +259,15 @@ function CalendarSection() { 0} fallback={

Sync to see your day-by-day attendance.

}> -