From f6ddc88ab6206a75e2672e2e0ea23d1618885734 Mon Sep 17 00:00:00 2001 From: andrewmuratov Date: Wed, 30 Sep 2026 09:58:42 -0400 Subject: [PATCH 1/2] feat: launch the UBC Vancouver edition Co-authored-by: Codex --- AGENTS.md | 2 +- docs/CAMPUS_DATA.md | 2 +- docs/ECOSYSTEM_INTEGRATION.md | 2 +- docs/INFRASTRUCTURE_COST_AND_SCALING_MODEL.md | 4 +- e2e/ubc-university.e2e.ts | 78 + public/institutional-brief.html | 2 +- public/universities/ubc/apple-touch-icon.png | Bin 0 -> 1086 bytes public/universities/ubc/favicon-16x16.png | Bin 0 -> 160 bytes public/universities/ubc/favicon-192x192.png | Bin 0 -> 1203 bytes public/universities/ubc/favicon-32x32.png | Bin 0 -> 324 bytes public/universities/ubc/icon-192.png | Bin 0 -> 1203 bytes public/universities/ubc/icon-512.png | Bin 0 -> 3617 bytes public/universities/ubc/logo-mark.svg | 14 + public/universities/ubc/og-card.png | Bin 0 -> 44742 bytes public/universities/ubc/site.webmanifest | 20 + scripts/audit-residence-coverage.ts | 10 +- scripts/build-seo-pages.ts | 4 +- scripts/check-seo-output.ts | 55 +- scripts/generate-icons.ts | 1 + scripts/generate-social-cards.ts | 8 + src/components/MarketingLandingImpl.tsx | 1 + src/components/UploadPanel.tsx | 75 +- src/content/public-feature-pages.ts | 2 +- .../generated/residence-coverage-matrix.json | 376 +- .../generated/routing-entrance-audit.json | 177 +- src/data/campuses/index.ts | 59 +- src/data/campuses/ubc/campus.json | 349725 +++++++++++++++ src/data/campuses/ubc/catalog.json | 60769 +++ .../utm/generated/campus-access-audit.json | 2 +- src/features/routing/campus-transition.ts | 5 +- src/features/search/search-engine.ts | 1 + src/main.tsx | 22 +- src/routes/developers.tsx | 4 +- src/server/public-campus/campus-snapshots.ts | 2 + src/universities/registry.ts | 2 +- src/universities/timetable-adapters.ts | 11 + src/universities/ubc/adapter.ts | 86 + src/universities/ubc/config.ts | 20 + src/universities/ubc/demo-timetable.ts | 68 + src/universities/ubc/text-parser.ts | 147 + tests/campus-data-sync.test.ts | 2 + ...pus-routing-multi-university-audit.test.ts | 8 +- tests/ecosystem-consistency.test.ts | 15 +- tests/fixtures/ubc-workday-sanitized.tsv | 4 + tests/marketing-home.test.ts | 10 +- tests/residence-multi-university.test.ts | 19 +- .../timetable-multi-university-golden.test.ts | 32 +- tests/ubc-adapter.test.ts | 89 + tests/universities.test.ts | 22 +- universities.json | 21 + vercel.json | 70 + 51 files changed, 411911 insertions(+), 137 deletions(-) create mode 100644 e2e/ubc-university.e2e.ts create mode 100644 public/universities/ubc/apple-touch-icon.png create mode 100644 public/universities/ubc/favicon-16x16.png create mode 100644 public/universities/ubc/favicon-192x192.png create mode 100644 public/universities/ubc/favicon-32x32.png create mode 100644 public/universities/ubc/icon-192.png create mode 100644 public/universities/ubc/icon-512.png create mode 100644 public/universities/ubc/logo-mark.svg create mode 100644 public/universities/ubc/og-card.png create mode 100644 public/universities/ubc/site.webmanifest create mode 100644 src/data/campuses/ubc/campus.json create mode 100644 src/data/campuses/ubc/catalog.json create mode 100644 src/universities/ubc/adapter.ts create mode 100644 src/universities/ubc/config.ts create mode 100644 src/universities/ubc/demo-timetable.ts create mode 100644 src/universities/ubc/text-parser.ts create mode 100644 tests/fixtures/ubc-workday-sanitized.tsv create mode 100644 tests/ubc-adapter.test.ts 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..9ff3e0dd --- /dev/null +++ b/e2e/ubc-university.e2e.ts @@ -0,0 +1,78 @@ +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(/\/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 0000000000000000000000000000000000000000..8c99d3573b6d621c0932ceb763561d498bbb7c9f GIT binary patch literal 1086 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD4M^IaWiw)6U}5!iaSW-r_4ck~*Hr@v_76HN ztm4diA})gFda+!3F;y)5k6dj!*gq=Cxo}3z3lLmtg(tgHH7S*W!0%dF^*=UYq;cCQzFad5ZHTH(Xn z3Dw4O7Y55j^wQyF4=#G(I`^BqZ?hj^Yi@OCzs9MIQ?@v`%e3! z%qw2rQbX-j9hv+XuJu~#SaX-lfd*(D!0GOpg+_-LnwzQq!rnN4?v zcKoO8v9yfyIM^D&dw;i*fl%2uQ`7gc%^$d$9Bu6Zp?I>xdEZZu-cPI3zfdCjVkf)Q<*FAO9x?Vi z6mNFe&Q|dHd-GZ5*xmkaZ|xMA&Yor7`(>%!PJLCOj9q+RZh0Hg;N5Ji# z!(FzgErQXNQ6)TAPX(JB``E6{Q|q~O>hhxG8DaCpWD}Q!R)$>^bjy62dC?FgnKt8n hc3>KTJPDKLd9R;qk;zli8+#Qb^X;@;3kg literal 0 HcmV?d00001 diff --git a/public/universities/ubc/favicon-16x16.png b/public/universities/ubc/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..b6c8bc883d03558e2c9d1b74e657c8f43f6c4e8b GIT binary patch literal 160 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`>7Fi*Ar-fh6C_w~Fk0_d4NS6N zoS!4cyuQR}4u4Yfkq$PQCO#ggtXdwXF3|)Yu*jT4?|3Gu{g!mu@~pwIgI7qIv!J`( z$V1{SL$bi^lmo}Ik1TlcOTNc3XH&xhFKdZ@rdv!5QLaM!a~*wsfmSkjy85}Sb4q9e E01}5Xw*UYD literal 0 HcmV?d00001 diff --git a/public/universities/ubc/favicon-192x192.png b/public/universities/ubc/favicon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..5c873caf7327b11bc95c5024c33319312b468b26 GIT binary patch literal 1203 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zf+;V5#zSaSW-r_4cm!sbE79h6g+> ztn5w9tbs>5o!gt3+w~5%?hq7z(43|JSzm$G7kMudnvszic=E&sqBKo%fmB>{zb9kE6kh;Ysyj7QO!;^6mc5 zdnflKd*2>@A1Maoe;@BQckccDEB)2O{ZrVURUbO?^L_Vsd#C&2KX+B1e6MRX|73i+ zoXN)N^7Uosna@?9y%XIRVtDfX)0&XoX0xV#`d{Ne!-93kwF0lj$0n!ezpvB%Y5V2N zGw*eCJ=Gf5#uYp{73lA}+~n6x2Jhxsd#hePyy;Qg7~~i4blb&a64P1@)&m}z z&M%|97z4Z>FR>TaUdp%hSg@3?oBvC_>aRfp=YI)I&|d7YZpEDCPD|$%e_^nYKly3v zlzFu;{;pic`rNVL2}6cy-PZb9yiY_}+~+d8tm9p^^1{l6E~|Ai7pUI*X?tC2|CROt z<*dDKUh-L)F>h1v+RtV=xWd25w9YI#{Bl)qfYX|(Mcymt9P{51-fOm|dYRM3E9&vx zocfvFs%%>ocBC?&-miV@hkM+UL?-9gFRG zHOm!xO#b|ln`*JMn!n~%>vfZ3fA&ti_R4^* zZ&Q4f_m-T0A*7r8?0~eeRACH zZS(eRWvkD9eE-+?n*z?o`Uft#?R=L0ZO#37=^QPGCG$k5+V0JGf9aK~@Kw`dzS$2W zO(pw$dZzn$<$vAmTX%EI@q3G>-^(ru{FO4@=-ITX3&W14uQ$?a`Ofgg?AAS1zUnEF zy3>-UAI+F3w{5vm$m(-CvI})4rd>_ic`t7Jwaacr-=pg8SsOk5p0iPUHoHsSg6HdB z{9H8gmG9a|m#waU{`5EF15f#uq$kx`D0W)NM!vD z$o5Ws&*jf}KvDd{RJEhtVHI2qe3A^)KQ}J52I;yNJSS}32N4FDD*l5nOU$x=&ny$} zJ*&&Xz}Ubbz<{6*2<8<2xO%NU&C%-6%|+F7#JPf(I9`%(+w}Z7lMDZYCe0wlmHaO+ z_Jx-0QOx{yUaDZ3{Yyc+><822C$hY}XqSCp=06qw6E+`|G<&rwjozmB|J2dHbY^Lw zs{f@m-l{sK3AZk-n53Th3`|~wkV}Q8SsA>oakzIr#BqZ6oaK`re%fzZM@$(2Q$gOcN}$=Jn|*X&|u?-#1qX&9Euc76}E=3uZcO8F#lx4 z{ev$VCdhd3dg%HxoJjDT{Fg__&069{#g>PU6wEw$RNReETejYnT%vo<=)=i0!w;v$ zjWl-sOBXP|!Ep?%fIWyg$h-8wA;rTA?|%s9%v|7Q-MpZPc?Rz@=9>zJ0@6#^mP|_v zWZ1+baz18J-2)f)LlAQ}YdEkecstsqJ~m}a7LZnPGY6@;^B{)th=N(HEW@ei8-BDW z&U#WC!!YHo$ej8Ey6hV#ZdQ$D&^Ucih9QZcqv2^&paGZLK@AC35e9~npM1V=(SGCs P3>^kfS3j3^P6 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e751cb87b0869983029eb61e1f13d21d1e162546 GIT binary patch literal 44742 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 E5AZg&{Qv*} literal 0 HcmV?d00001 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({