diff --git a/AGENTS.md b/AGENTS.md index b33677fe..74dcacf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ When Codex substantially authors a Git commit in this repository: ## Project -Gapwise is a privacy-first web application for students at 11 supported Canadian universities, served from `https://gapwise.ca` and dedicated university subdomains. Timetable identity and source-backed building maps cover all supported editions. Pedestrian routing and entrance coverage vary by campus; UTM-specific places and ACORN import remain scoped to the University of Toronto edition. +Gapwise is a privacy-first web application for students at 12 supported Canadian universities, served from `https://gapwise.ca` and dedicated university subdomains. Timetable identity and source-backed building maps cover all supported editions. Pedestrian routing and entrance coverage vary by campus; UTM-specific places and ACORN import remain scoped to the University of Toronto edition. The current product: diff --git a/docs/CAMPUS_DATA.md b/docs/CAMPUS_DATA.md index aab6e4dc..8380c3ce 100644 --- a/docs/CAMPUS_DATA.md +++ b/docs/CAMPUS_DATA.md @@ -1,6 +1,6 @@ # Campus data and documentation boundaries -Canonical public multi-university campus facts, geometry, provenance, and data-maintenance guidance live in [`GapwiseHQ/data`](https://github.com/GapwiseHQ/data). The dataset covers 11 universities (University of Toronto, Carleton University, Toronto Metropolitan University, Queen's University, Wilfrid Laurier University, York University, McMaster University, Western University, University of Guelph, University of Ottawa, and Brock University) across 13 campus models. +Canonical public multi-university campus facts, geometry, provenance, and data-maintenance guidance live in [`GapwiseHQ/data`](https://github.com/GapwiseHQ/data). The dataset covers 12 universities (University of Toronto, Carleton University, Toronto Metropolitan University, Queen's University, Wilfrid Laurier University, York University, McMaster University, Western University, University of Guelph, University of Ottawa, Brock University, and the University of British Columbia) across 14 campus models. Public human-readable data documentation lives at **https://docs.gapwise.ca/data/**. The currently published raw distributions live at **https://data.gapwise.ca/datasets/**. diff --git a/docs/ECOSYSTEM_INTEGRATION.md b/docs/ECOSYSTEM_INTEGRATION.md index 71bf138c..18c91590 100644 --- a/docs/ECOSYSTEM_INTEGRATION.md +++ b/docs/ECOSYSTEM_INTEGRATION.md @@ -6,7 +6,7 @@ Gapwise is one product ecosystem implemented across seven product repositories a | Repository | Owns | Consumes | | ---------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | -| `data` | Canonical multi-university campus facts and geometry across 11 universities; routing graphs and reviewed entrances | Reviewed public/field evidence; core consumer tests | +| `data` | Canonical multi-university campus facts and geometry across 12 universities; routing graphs and reviewed entrances | Reviewed public/field evidence; core consumer tests | | `gapwise` | Web/PWA across 11 university editions, timetable/gap/routing semantics, encrypted private state, API v1, OpenAPI and both SDKs | Validated build-time Data mirror; explicit AI delegation | | `android` | Kotlin/Compose UX, Keystore persistence, native import and encrypted-sync adapter (focuses on U of T) | Core HTTP/crypto contracts and public campus APIs | | `ios` | SwiftUI UX and portable local timetable foundation (focuses on U of T) | Campus-scoped identity; broader native campus/account integrations remain pending | diff --git a/docs/INFRASTRUCTURE_COST_AND_SCALING_MODEL.md b/docs/INFRASTRUCTURE_COST_AND_SCALING_MODEL.md index 925e1d06..275800a3 100644 --- a/docs/INFRASTRUCTURE_COST_AND_SCALING_MODEL.md +++ b/docs/INFRASTRUCTURE_COST_AND_SCALING_MODEL.md @@ -11,7 +11,7 @@ Gapwise operates on an intensely disciplined **local-first, zero-unnecessary-cloud architecture**. Course schedules, `.ics` files, calendar parsing, timetable rendering, walking route graphs, gap detection, and campus building spatial indexes execute 100% client-side in the student's browser (WebAssembly and Web Crypto). -Because of this architectural posture, **Gapwise's current operational infrastructure cost is $0.00/month across all 11 supported universities**. +Because of this architectural posture, **Gapwise's current operational infrastructure cost is $0.00/month across all 12 supported universities**. This document models the resource consumption, provider limits, financial projections, and explicit upgrade triggers across four growth stages: @@ -43,7 +43,7 @@ This document models the resource consumption, provider limits, financial projec ### A. Bandwidth (Vercel & CDN) - **Initial App Route Payload**: 467.7 KiB JS + 37.7 KiB CSS (gzipped) = ~505 KiB per new visitor. -- **Service Worker Precache**: On first load, the PWA precaches 261 entries (~18 MB uncompressed) containing the static offline campus graph for all 11 universities. +- **Service Worker Precache**: The production build precaches static application and campus assets so supported editions remain resilient after their assets have been cached. - **Repeat Visits**: 0 bytes network bandwidth for core app; 304 Not Modified or Cache-Control immutable hits. - **Average monthly bandwidth per active student**: Estimated at 2.5 MB/student/month (accounting for occasional app updates). diff --git a/e2e/ubc-university.e2e.ts b/e2e/ubc-university.e2e.ts new file mode 100644 index 00000000..e89cacb7 --- /dev/null +++ b/e2e/ubc-university.e2e.ts @@ -0,0 +1,83 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { watchForAppFailures } from "./helpers"; + +const workdayFixture = readFileSync( + new URL("../tests/fixtures/ubc-workday-sanitized.tsv", import.meta.url), + "utf8", +); + +test("UBC Workday paste import preserves the Vancouver campus through timetable and routing", async ({ + page, + baseURL, +}) => { + test.skip(test.info().project.name !== "chromium"); + if (!baseURL) throw new Error("Playwright baseURL is required"); + const failures = watchForAppFailures(page, baseURL); + + await page.goto("/?university=ubc&campus=ubc-vancouver"); + await expect(page.getByText("For University of British Columbia", { exact: true })).toBeVisible(); + await expect(page.locator(".brand-scope-pill").first()).toHaveText("UBC"); + + await page.getByRole("button", { name: "Paste Workday View My Courses rows" }).click(); + await page + .getByRole("textbox", { name: "Paste schedule text from Workday View My Courses" }) + .fill(workdayFixture); + await page.getByRole("button", { name: "Import pasted schedule" }).click(); + + await expect(page).toHaveURL(/\/today$/); + await page + .getByRole("group", { name: "View mode" }) + .getByRole("button", { name: "Weekly timetable" }) + .click(); + await expect(page).toHaveURL(/\/timetable/); + await expect(page.getByText("CPSC 110").first()).toBeVisible(); + await expect(page.getByText("ENGL 110").first()).toBeVisible(); + await expect(page.getByText(/6 meetings in Fall/)).toBeVisible(); + await expect(page.locator(".brand-scope-pill").first()).toHaveText("UBC"); + + await page + .getByRole("group", { name: "View mode" }) + .getByRole("button", { name: "Day route" }) + .click(); + await page + .getByRole("group", { name: "Route weekday" }) + .getByRole("button", { name: "Monday" }) + .click(); + await expect(page.getByRole("searchbox", { name: "Search UBC buildings" })).toBeVisible(); + await expect(page.locator(".map-time-marker")).toHaveCount(2); + + const search = page.getByRole("searchbox", { name: "Search UBC buildings" }); + await search.fill("ICCS"); + await expect(page.getByTestId("building-search-result").first()).toContainText( + "Computer Science", + ); + + await page.reload(); + await expect(page.locator(".brand-scope-pill").first()).toHaveText("UBC"); + await expect(page.getByRole("searchbox", { name: "Search UBC buildings" })).toBeVisible(); + + await page.goto("/route?university=ubc&campus=ubc-vancouver"); + await expect(page.locator(".brand-scope-pill").first()).toHaveText("UBC"); + await expect(page.getByRole("searchbox", { name: "Search UBC buildings" })).toBeVisible(); + failures.assertClean(); +}); + +test("UBC has its own demo timetable and usable mobile campus map", async ({ page, baseURL }) => { + test.skip(test.info().project.name !== "mobile-chromium"); + if (!baseURL) throw new Error("Playwright baseURL is required"); + const failures = watchForAppFailures(page, baseURL); + + await page.goto("/?university=ubc&campus=ubc-vancouver"); + await expect(page.getByText("For University of British Columbia", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Try a demo" }).click(); + await expect(page).toHaveURL(/\/timetable/); + await page.getByRole("group", { name: "Weekday" }).getByRole("button", { name: /Mon/ }).click(); + await expect(page.getByText("CPSC 110").first()).toBeVisible(); + await expect(page.getByText("ENGL 110").first()).toBeVisible(); + + await page.getByRole("navigation", { name: "Main" }).getByRole("link", { name: "Map" }).click(); + await expect(page.getByRole("searchbox", { name: "Search UBC buildings" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Options" })).toBeVisible(); + failures.assertClean(); +}); diff --git a/public/institutional-brief.html b/public/institutional-brief.html index e0c30cdb..9abf59a4 100644 --- a/public/institutional-brief.html +++ b/public/institutional-brief.html @@ -281,7 +281,7 @@

bridges the gap between official course schedules and physical campus navigation. It connects class times to campus geometry—computing true usable break times, verified walking paths, step-free navigation, and study destinations across - 11 Canadian universities and 13 campus models with strict + 12 Canadian universities and 14 campus models with strict privacy-by-design. diff --git a/public/universities/ubc/apple-touch-icon.png b/public/universities/ubc/apple-touch-icon.png new file mode 100644 index 00000000..8c99d357 Binary files /dev/null and b/public/universities/ubc/apple-touch-icon.png differ diff --git a/public/universities/ubc/favicon-16x16.png b/public/universities/ubc/favicon-16x16.png new file mode 100644 index 00000000..b6c8bc88 Binary files /dev/null and b/public/universities/ubc/favicon-16x16.png differ diff --git a/public/universities/ubc/favicon-192x192.png b/public/universities/ubc/favicon-192x192.png new file mode 100644 index 00000000..5c873caf Binary files /dev/null and b/public/universities/ubc/favicon-192x192.png differ diff --git a/public/universities/ubc/favicon-32x32.png b/public/universities/ubc/favicon-32x32.png new file mode 100644 index 00000000..d5dd9110 Binary files /dev/null and b/public/universities/ubc/favicon-32x32.png differ diff --git a/public/universities/ubc/icon-192.png b/public/universities/ubc/icon-192.png new file mode 100644 index 00000000..5c873caf Binary files /dev/null and b/public/universities/ubc/icon-192.png differ diff --git a/public/universities/ubc/icon-512.png b/public/universities/ubc/icon-512.png new file mode 100644 index 00000000..72e01d26 Binary files /dev/null and b/public/universities/ubc/icon-512.png differ diff --git a/public/universities/ubc/logo-mark.svg b/public/universities/ubc/logo-mark.svg new file mode 100644 index 00000000..88849941 --- /dev/null +++ b/public/universities/ubc/logo-mark.svg @@ -0,0 +1,14 @@ + + Gapwise deer mark for UBC + A symmetrical front-facing deer head with antlers in the UBC edition colour. + + + + + + + + + + + diff --git a/public/universities/ubc/og-card.png b/public/universities/ubc/og-card.png new file mode 100644 index 00000000..e751cb87 Binary files /dev/null and b/public/universities/ubc/og-card.png differ diff --git a/public/universities/ubc/site.webmanifest b/public/universities/ubc/site.webmanifest new file mode 100644 index 00000000..a908a69b --- /dev/null +++ b/public/universities/ubc/site.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "Gapwise — University of British Columbia", + "short_name": "Gapwise", + "icons": [ + { + "src": "/universities/ubc/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/universities/ubc/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#0d1117", + "background_color": "#0d1117", + "display": "standalone", + "start_url": "/" +} diff --git a/scripts/audit-residence-coverage.ts b/scripts/audit-residence-coverage.ts index 1701014e..691ad3ac 100644 --- a/scripts/audit-residence-coverage.ts +++ b/scripts/audit-residence-coverage.ts @@ -6,6 +6,7 @@ import { campusResidenceBuildings, campusBuildingConfigurations, CAMPUS_LABELS, + ensureCampusCatalog, type GapwiseCampusId, } from "../src/data/campuses/index.js"; @@ -46,7 +47,12 @@ export interface ResidenceCoverageMatrix { campuses: Record; } -export function generateResidenceCoverageMatrix(): ResidenceCoverageMatrix { +export async function generateResidenceCoverageMatrix(): Promise { + await Promise.all( + manifest.universities.flatMap((university) => + university.campuses.map((campusId) => ensureCampusCatalog(campusId)), + ), + ); const campusCoverages: Record = {}; let totalBuildings = 0; let totalResidences = 0; @@ -112,7 +118,7 @@ export function generateResidenceCoverageMatrix(): ResidenceCoverageMatrix { const args = process.argv.slice(2); const checkOnly = args.includes("--check"); -const matrix = generateResidenceCoverageMatrix(); +const matrix = await generateResidenceCoverageMatrix(); console.log("\n======================================================="); console.log("GAPWISE UNIVERSITY RESIDENCE COVERAGE MATRIX AUDIT"); diff --git a/scripts/build-seo-pages.ts b/scripts/build-seo-pages.ts index 78a4327f..51625177 100644 --- a/scripts/build-seo-pages.ts +++ b/scripts/build-seo-pages.ts @@ -153,7 +153,7 @@ const PAGES: readonly SeoPage[] = [ path: "/developers", title: "Gapwise API & SDKs — Developers", description: - "Build with the Gapwise public campus building, place, routing, and deterministic gap-planning API, OpenAPI contract, and official SDKs across all 11 supported Canadian universities.", + "Build with the Gapwise public campus building, place, routing, and deterministic gap-planning API, OpenAPI contract, and official SDKs across all 12 supported Canadian universities.", heading: "Deterministic campus intelligence for developers.", detail: "Gapwise publishes a bounded public API for campus buildings, places, routing, and gap planning across supported universities, with OpenAPI plus JavaScript/TypeScript and Python SDK documentation.", @@ -338,7 +338,7 @@ function homepageStructuredData(page: SeoPage, uniContext?: UniversityContext) { : [ "Browser-local timetable import (ACORN .ics, Carleton Central, TMU, Queen's, Laurier, York, McMaster, Western, Guelph, uOttawa, Brock schedule formats)", "Mississauga, St. George, Scarborough, Carleton, TMU, Queen's, Laurier, York, McMaster, Western, Guelph, uOttawa, and Brock campus timetable identity", - "Source-backed building maps for all 11 supported universities", + "Source-backed building maps for all 12 supported universities", "Source-backed UTM pedestrian routing", "Optional encrypted private sync", ], diff --git a/scripts/check-seo-output.ts b/scripts/check-seo-output.ts index 309ddfaf..80bc8206 100644 --- a/scripts/check-seo-output.ts +++ b/scripts/check-seo-output.ts @@ -442,46 +442,19 @@ for (const uniId of UNIVERSITY_IDS) { // ── Per-university social preview metadata and OG card regression checks ─────── -const ALL_UNIVERSITY_IDS = [ - "uoft", - "carleton", - "tmu", - "queens", - "laurier", - "york", - "mcmaster", - "western", - "guelph", - "uottawa", - "brock", -] as const; -type AnyUniversityId = (typeof ALL_UNIVERSITY_IDS)[number]; -const ALL_UNIVERSITY_NAMES: Record = { - uoft: "University of Toronto", - carleton: "Carleton University", - tmu: "Toronto Metropolitan University", - queens: "Queen's University", - laurier: "Wilfrid Laurier University", - york: "York University", - mcmaster: "McMaster University", - western: "Western University", - guelph: "University of Guelph", - uottawa: "University of Ottawa", - brock: "Brock University", -}; -const ALL_UNIVERSITY_ORIGINS: Record = { - uoft: "https://gapwise.ca", - carleton: "https://carleton.gapwise.ca", - tmu: "https://tmu.gapwise.ca", - queens: "https://queens.gapwise.ca", - laurier: "https://laurier.gapwise.ca", - york: "https://york.gapwise.ca", - mcmaster: "https://mcmaster.gapwise.ca", - western: "https://western.gapwise.ca", - guelph: "https://guelph.gapwise.ca", - uottawa: "https://uottawa.gapwise.ca", - brock: "https://brock.gapwise.ca", +const universityManifest = JSON.parse(await readFile("universities.json", "utf8")) as { + universities: Array<{ id: string; name: string; hosts: string[]; status: string }>; }; +const supportedUniversityEntries = universityManifest.universities.filter( + (university) => university.status === "supported", +); +const ALL_UNIVERSITY_IDS = supportedUniversityEntries.map((university) => university.id); +const ALL_UNIVERSITY_NAMES = Object.fromEntries( + supportedUniversityEntries.map((university) => [university.id, university.name]), +) as Record; +const ALL_UNIVERSITY_ORIGINS = Object.fromEntries( + supportedUniversityEntries.map((university) => [university.id, `https://${university.hosts[0]}`]), +) as Record; function pngDimensions(bytes: Buffer) { if (bytes.subarray(1, 4).toString() !== "PNG") { @@ -491,7 +464,7 @@ function pngDimensions(bytes: Buffer) { } for (const uniId of ALL_UNIVERSITY_IDS) { - const name = ALL_UNIVERSITY_NAMES[uniId]; + const name = ALL_UNIVERSITY_NAMES[uniId]!; const origin = ALL_UNIVERSITY_ORIGINS[uniId]; const cardPath = `dist/universities/${uniId}/og-card.png`; const cardBytes = await readFile(cardPath); @@ -570,7 +543,7 @@ for (const uniId of ALL_UNIVERSITY_IDS) { // Inspect every public edition page, not only the homepage. One university must never // appear in another university edition's title, description, social tags, or fallback body. for (const uniId of UNIVERSITY_IDS) { - const ownName = ALL_UNIVERSITY_NAMES[uniId]; + const ownName = ALL_UNIVERSITY_NAMES[uniId]!; const pages: Array<[string, string]> = [ ["/", `dist/_universities/${uniId}/index.html`], ...COMMON_SEO_PATHS.map((path): [string, string] => [ diff --git a/scripts/generate-icons.ts b/scripts/generate-icons.ts index 94d32b56..bd798eb1 100644 --- a/scripts/generate-icons.ts +++ b/scripts/generate-icons.ts @@ -244,5 +244,6 @@ await Promise.all([ render("favicon-32x32.png", 32), render("apple-touch-icon.png", 180), render("icon-192.png", 192), + render("favicon-192x192.png", 192), render("icon-512.png", 512), ]); diff --git a/scripts/generate-social-cards.ts b/scripts/generate-social-cards.ts index d8622352..a83acb2e 100644 --- a/scripts/generate-social-cards.ts +++ b/scripts/generate-social-cards.ts @@ -142,6 +142,14 @@ const CARDS: CardConfig[] = [ tagline: "Make the time between classes count.", detail: "Brock University", }, + { + outputPath: "public/universities/ubc/og-card.png", + accentColor: "#002145", + textColor: "#93c5fd", + title: "Gapwise", + tagline: "Make the time between classes count.", + detail: "University of British Columbia", + }, // ── Root fallbacks (U of T default) ────────────────────── { outputPath: "public/og-card.png", diff --git a/src/components/MarketingLandingImpl.tsx b/src/components/MarketingLandingImpl.tsx index 7e32da71..70f919cc 100644 --- a/src/components/MarketingLandingImpl.tsx +++ b/src/components/MarketingLandingImpl.tsx @@ -37,6 +37,7 @@ const INSTITUTION_MARKETING_METRICS: Record< guelph: { count: 15, sampleCodes: ["UC", "LIB", "ROZH", "MCKN"] }, uottawa: { count: 17, sampleCodes: ["UCU", "TBT", "MRT", "DMS"] }, brock: { count: 12, sampleCodes: ["ST", "TH", "MC", "GSB"] }, + ubc: { count: 236, sampleCodes: ["ICCS", "BUCH", "PHRM", "LIFE"] }, }; export function MarketingLandingImpl({ diff --git a/src/components/UploadPanel.tsx b/src/components/UploadPanel.tsx index 4617514f..c42627b5 100644 --- a/src/components/UploadPanel.tsx +++ b/src/components/UploadPanel.tsx @@ -56,6 +56,21 @@ export function UploadPanel({ const hero = variant === "hero"; const university = activeUniversity(); const calendarSource = university?.calendarSource ?? "calendar"; + const preferPaste = university?.preferredImportMethod === "paste"; + const acceptedFileTypes = + university?.acceptedFileTypes ?? + (university?.id !== "uoft" ? ".ics,.txt,.tsv,text/calendar,text/plain" : ".ics,text/calendar"); + const fileTypeLabel = + university?.fileTypeLabel ?? (university?.id === "uoft" ? ".ics" : ".ics or .txt"); + const pastePlaceholder = + university?.preferredImportMethod === "paste" + ? `Paste rows copied from ${calendarSource}...` + : university?.calendarSource + ? `Paste timetable text from ${university.calendarSource} or .ics calendar lines...` + : "Paste your timetable text or .ics calendar lines..."; + const recoveryCopy = preferPaste + ? `Paste another schedule from ${calendarSource} to try again.` + : `Choose another ${calendarSource} ${fileTypeLabel} file to try again.`; useEffect(() => { if (error) clearFirstValuePending(); @@ -78,9 +93,7 @@ export function UploadPanel({ id="ics-file" name="ics-file" type="file" - accept={ - university?.id !== "uoft" ? ".ics,.txt,.tsv,text/calendar,text/plain" : ".ics,text/calendar" - } + accept={acceptedFileTypes} hidden onChange={(event) => { const file = event.target.files?.[0]; @@ -114,7 +127,7 @@ export function UploadPanel({ >

The calendar could not be imported.

{error}

-

Choose another {calendarSource} .ics file to try again.

+

{recoveryCopy}

) : null; @@ -148,11 +161,7 @@ export function UploadPanel({ rows={6} value={pastedText} onChange={(e) => setPastedText(e.target.value)} - placeholder={ - university?.calendarSource - ? `Paste timetable text from ${university.calendarSource} or .ics calendar lines...` - : "Paste your timetable text or .ics calendar lines..." - } + placeholder={pastePlaceholder} className="w-full rounded-md border border-input bg-background p-2.5 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-accent" />
@@ -184,19 +193,27 @@ export function UploadPanel({
); @@ -281,11 +298,7 @@ export function UploadPanel({ rows={6} value={pastedText} onChange={(e) => setPastedText(e.target.value)} - placeholder={ - university?.calendarSource - ? `Paste timetable text from ${university.calendarSource} or .ics calendar lines...` - : "Paste your timetable text or .ics calendar lines..." - } + placeholder={pastePlaceholder} className="w-full rounded-md border border-input bg-background p-2.5 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-accent" />
@@ -318,19 +331,27 @@ export function UploadPanel({