diff --git a/README.md b/README.md index 9e22c825..79c68eb5 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ Gapwise is one application serving dedicated university editions via host-based | **University of Guelph** | Guelph Campus | WebAdvisor (text paste or `.ics`) | [guelph.gapwise.ca](https://guelph.gapwise.ca) | | **University of Ottawa** | Downtown Campus | uoCampus (text paste or `.ics`) | [uottawa.gapwise.ca](https://uottawa.gapwise.ca) | | **Brock University** | St. Catharines Campus | BrockDB / Student Portal (text paste or `.ics`) | [brock.gapwise.ca](https://brock.gapwise.ca) | +| **University of British Columbia** | Vancouver / Point Grey Campus | Workday View My Courses (table copy/paste) | [ubc.gapwise.ca](https://ubc.gapwise.ca) | +| **University of Waterloo** | Main Campus | Quest Class Schedule (list-view copy/paste) | [waterloo.gapwise.ca](https://waterloo.gapwise.ca) | --- diff --git a/docs/ECOSYSTEM_INTEGRATION.md b/docs/ECOSYSTEM_INTEGRATION.md index b9c3654b..b795ea24 100644 --- a/docs/ECOSYSTEM_INTEGRATION.md +++ b/docs/ECOSYSTEM_INTEGRATION.md @@ -4,16 +4,16 @@ Gapwise is one product ecosystem implemented across seven product repositories a ## Repository graph -| Repository | Owns | Consumes | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | -| `data` | Canonical multi-university campus facts and geometry across 13 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 | -| `ai` | OAuth/MCP authorization, minimized snapshots and bounded preference actions | Core public API and browser-authoritative delegated snapshots | -| `docs` | Public developer documentation | Released producer contracts, including AI's generated MCP manifest | -| `status` | Independent probes and incident presentation | Public service endpoints and operator-confirmed facts | -| `.github` | Organization contributor, support and security guidance | Ownership boundaries above | +| Repository | Owns | Consumes | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `data` | Canonical multi-university campus facts and geometry across 13 universities; routing graphs and reviewed entrances | Reviewed public/field evidence; core consumer tests | +| `gapwise` | Web/PWA across 13 university editions and 15 campus models, 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 | +| `ai` | OAuth/MCP authorization, minimized snapshots and bounded preference actions | Core public API and browser-authoritative delegated snapshots | +| `docs` | Public developer documentation | Released producer contracts, including AI's generated MCP manifest | +| `status` | Independent probes and incident presentation | Public service endpoints and operator-confirmed facts | +| `.github` | Organization contributor, support and security guidance | Ownership boundaries above | ## Public developer platform diff --git a/public/sitemap.xml b/public/sitemap.xml index 1e2d82fb..ad9a8e75 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,6 +2,8 @@ https://gapwise.ca/ https://gapwise.ca/about + https://gapwise.ca/universities + https://gapwise.ca/open-source https://gapwise.ca/utm-timetable https://gapwise.ca/campus-map https://gapwise.ca/gap-planner diff --git a/scripts/build-seo-pages.ts b/scripts/build-seo-pages.ts index 51625177..200b031d 100644 --- a/scripts/build-seo-pages.ts +++ b/scripts/build-seo-pages.ts @@ -6,6 +6,23 @@ const SITE_ORIGIN = "https://gapwise.ca"; const GITHUB_ORGANIZATION = "https://github.com/GapwiseHQ"; const GITHUB_CORE = `${GITHUB_ORGANIZATION}/gapwise`; +const universitiesManifest = JSON.parse(await readFile("universities.json", "utf8")) as { + universities: Array<{ + id: string; + name: string; + shortName: string; + campusScope: string; + hosts: string[]; + campuses: string[]; + enabledFeatures?: { routing?: boolean }; + }>; +}; +const supportedUniversityCount = universitiesManifest.universities.length; +const supportedCampusCount = universitiesManifest.universities.reduce( + (count, university) => count + university.campuses.length, + 0, +); + export type UniversityContext = { id: string; name: string; @@ -90,8 +107,7 @@ const PAGES: readonly SeoPage[] = [ description: "Gapwise is a free and open-source timetable, campus navigation, and student planning platform for students across multiple Canadian universities.", heading: "Make the time between classes count.", - detail: - "Import your timetable in your browser, explore source-backed campus maps, understand the usable time between classes, and plan your campus day across Canadian universities. The default edition supports University of Toronto (UTM, UTSG, UTSC) with ACORN import and pedestrian routing for UTM. Dedicated editions are available for Carleton, TMU, Queen's, Laurier, York, McMaster, Western, Guelph, uOttawa, and Brock.", + detail: `Import your timetable in your browser, explore source-backed campus maps, understand the usable time between classes, and plan your campus day across ${supportedUniversityCount} Canadian universities and ${supportedCampusCount} campus models.`, sections: [ { title: "Your timetable, connected to campus context", @@ -152,8 +168,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 12 supported Canadian universities.", + description: `Build with the Gapwise public campus building, place, routing, and deterministic gap-planning API, OpenAPI contract, and official SDKs across all ${supportedUniversityCount} 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.", @@ -251,10 +266,10 @@ function outputPath(path: string) { function homepageStructuredData(page: SeoPage, uniContext?: UniversityContext) { const origin = uniContext?.origin ?? SITE_ORIGIN; - const organizationId = `${origin}/#organization`; + const organizationId = `${SITE_ORIGIN}/#organization`; const websiteId = `${origin}/#website`; const appId = `${origin}/#app`; - const founderId = `${origin}/#andrew-muratov`; + const founderId = `${SITE_ORIGIN}/#andrew-muratov`; const isDedicatedTenant = Boolean( uniContext && (uniContext.id !== "uoft" || uniContext.campusName), ); @@ -266,13 +281,12 @@ function homepageStructuredData(page: SeoPage, uniContext?: UniversityContext) { "@type": "Organization", "@id": organizationId, name: "Gapwise", - url: `${origin}/`, - description: uniName - ? `Privacy-first timetable intelligence, campus maps, and day planning for ${uniName} students.` - : "Privacy-first timetable intelligence, campus maps, and day planning for university students across Canada.", + url: `${SITE_ORIGIN}/`, + description: + "Gapwise is the free and open-source university timetable, campus navigation, routing, and student-planning platform for Canadian universities.", logo: { "@type": "ImageObject", - url: `${origin}/icon-512.png`, + url: `${SITE_ORIGIN}/icon-512.png`, width: 512, height: 512, }, @@ -298,16 +312,17 @@ function homepageStructuredData(page: SeoPage, uniContext?: UniversityContext) { { "@type": "WebSite", "@id": websiteId, - name: uniName ? `Gapwise — ${uniName}` : "Gapwise", + name: uniName ? `Gapwise for ${uniName}` : "Gapwise", url: `${origin}/`, description: page.description, inLanguage: "en-CA", publisher: { "@id": organizationId }, + ...(origin !== SITE_ORIGIN ? { isPartOf: { "@id": `${SITE_ORIGIN}/#website` } } : {}), }, { - "@type": "WebApplication", + "@type": ["WebApplication", "SoftwareApplication"], "@id": appId, - name: uniName ? `Gapwise — ${uniName}` : "Gapwise", + name: uniName ? `Gapwise for ${uniName}` : "Gapwise", url: `${origin}/`, description: page.description, applicationCategory: "EducationalApplication", @@ -316,6 +331,7 @@ function homepageStructuredData(page: SeoPage, uniContext?: UniversityContext) { inLanguage: "en-CA", creator: { "@id": founderId }, publisher: { "@id": organizationId }, + isPartOf: { "@id": websiteId }, offers: { "@type": "Offer", price: "0", @@ -336,10 +352,10 @@ function homepageStructuredData(page: SeoPage, uniContext?: UniversityContext) { "Optional encrypted private sync", ] : [ - "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 12 supported universities", - "Source-backed UTM pedestrian routing", + `Browser-local timetable import across ${supportedUniversityCount} supported Canadian universities`, + `University-specific timetable identity across ${supportedCampusCount} campus models`, + `Source-backed building maps for all ${supportedUniversityCount} supported universities`, + "Campus pedestrian routing with source-classified entrances and explicit uncertainty", "Optional encrypted private sync", ], sameAs: [GITHUB_CORE], @@ -393,6 +409,8 @@ function fallback(page: SeoPage, uniContext?: UniversityContext) { ? ([ ["/", "Gapwise home"], ["/about", "About Gapwise"], + ["/universities", "Supported universities"], + ["/open-source", "Open source"], ["/campus-map", "Campus map"], ["/gap-planner", "Gap planner"], ["/campus-routing", "Campus routing"], @@ -407,6 +425,8 @@ function fallback(page: SeoPage, uniContext?: UniversityContext) { : ([ ["/", "Gapwise home"], ["/about", "About Gapwise"], + ["/universities", "Supported universities"], + ["/open-source", "Open source"], ["/utm-timetable", "UTM timetable"], ["/gap-planner", "Gap planner"], ["/campus-map", "Campus map"], @@ -447,20 +467,13 @@ function fallback(page: SeoPage, uniContext?: UniversityContext) { page.path === "/" && !isDedicatedTenant ? `

Supported Canadian Universities

-

Gapwise provides dedicated editions with university-specific timetable import, campus data, and verified destinations:

- +

Gapwise provides dedicated editions with university-specific timetable import and source-backed campus data:

+
` : ""; @@ -518,6 +531,8 @@ function renderDocument(baseHtml: string, page: SeoPage, uniContext?: University const UNIVERSITY_COMMON_PATHS = new Set([ "/", "/about", + "/universities", + "/open-source", "/campus-map", "/gap-planner", "/campus-routing", @@ -534,7 +549,7 @@ function getUniversityPage(page: SeoPage, uni: UniversityContext): SeoPage { if (page.path === "/") { return { path: "/", - title: `Gapwise — ${uni.name}`, + title: `Gapwise for ${uni.name} — Timetable & Campus Navigation`, description: `Gapwise is a free and open-source timetable, campus navigation, and student planning platform for ${uni.name} students.`, heading: "Make the time between classes count.", detail: @@ -651,7 +666,6 @@ for (const { campusId, context } of UOFT_CAMPUS_EDITIONS) { await writeFile(join(campusDir, "robots.txt"), renderRobotsTxt(`${context.origin}/sitemap.xml`)); } -const universitiesManifest = JSON.parse(await readFile("universities.json", "utf8")); let universityCount = 0; const universityPageCounts: Record = {}; diff --git a/scripts/check-seo-output.ts b/scripts/check-seo-output.ts index 80bc8206..7a1db4ee 100644 --- a/scripts/check-seo-output.ts +++ b/scripts/check-seo-output.ts @@ -14,7 +14,11 @@ function requireEntity( type: string, ): Record { const entity = graph.find((item) => item["@id"] === id); - if (!entity || entity["@type"] !== type) { + const entityTypes = entity?.["@type"]; + if ( + !entity || + (entityTypes !== type && (!Array.isArray(entityTypes) || !entityTypes.includes(type))) + ) { throw new Error(`homepage structured data is missing ${type} ${id}`); } return entity; @@ -36,6 +40,22 @@ function requireReference( } } +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", +); + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + // Ensure root dist/index.html, dist/sitemap.xml, dist/robots.txt, dist/og-gapwise.png, dist/og-card.png // do not exist so they cannot shadow host-specific rewrites on Vercel for (const rootFile of [ @@ -125,20 +145,17 @@ for (const needle of [ 'Gapwise is open source on GitHub', "Gapwise — University Timetable & Campus Navigation", 'content="Gapwise is a free and open-source timetable, campus navigation, and student planning platform for students across multiple Canadian 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', ]) requireText(home, needle, "homepage crawlable content and discovery"); +for (const university of supportedUniversityEntries) { + requireText( + home, + `${escapeHtml(university.name)}`, + `homepage discovery for ${university.id}`, + ); +} + for (const path of [ "/about", "/utm-timetable", @@ -180,6 +197,8 @@ const UOFT_CAMPUS_EDITIONS = [ const CAMPUS_EDITION_PATHS = [ "/about", + "/universities", + "/open-source", "/campus-map", "/gap-planner", "/campus-routing", @@ -200,7 +219,11 @@ for (const campus of UOFT_CAMPUS_EDITIONS) { readFile(`${campusDir}/robots.txt`, "utf8"), ]); - requireText(campusHome, `Gapwise — ${campus.name}`, `${campus.id} homepage title`); + requireText( + campusHome, + `Gapwise for ${campus.name} — Timetable & Campus Navigation`, + `${campus.id} homepage title`, + ); requireText( campusHome, `rel="canonical" href="${campus.origin}/"`, @@ -213,7 +236,7 @@ for (const campus of UOFT_CAMPUS_EDITIONS) { ); requireText( campusHome, - `"@id":"${campus.origin}/#organization"`, + `"@id":"https://gapwise.ca/#organization"`, `${campus.id} homepage JSON-LD`, ); requireText(campusRobots, `Sitemap: ${campus.origin}/sitemap.xml`, `${campus.id} robots.txt`); @@ -227,7 +250,7 @@ for (const campus of UOFT_CAMPUS_EDITIONS) { ); requireText( campusHome, - `"name":"Gapwise — ${campus.name}"`, + `"name":"Gapwise for ${campus.name}"`, `${campus.id} homepage structured-data name`, ); requireText(campusHome, `"url":"${campus.origin}/"`, `${campus.id} homepage structured-data URL`); @@ -272,33 +295,14 @@ const UOFT_ONLY_PATHS = [ "/places/rawc", ]; -const UNIVERSITY_IDS = [ - "carleton", - "tmu", - "queens", - "laurier", - "york", - "mcmaster", - "western", - "guelph", - "uottawa", - "brock", -] as const; -type UniversityId = (typeof UNIVERSITY_IDS)[number]; -const UNIVERSITY_ORIGINS: Record = { - 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 UNIVERSITY_IDS = supportedUniversityEntries + .filter((university) => university.id !== "uoft") + .map((university) => university.id); +const UNIVERSITY_ORIGINS = Object.fromEntries( + supportedUniversityEntries.map((university) => [university.id, `https://${university.hosts[0]}`]), +) as Record; -function getUniversityOrigin(uniId: UniversityId): string { +function getUniversityOrigin(uniId: string): string { const origin = UNIVERSITY_ORIGINS[uniId]; if (!origin) throw new Error(`Missing origin configuration for university: ${uniId}`); return origin; @@ -306,6 +310,8 @@ function getUniversityOrigin(uniId: UniversityId): string { const COMMON_SEO_PATHS = [ "/about", + "/universities", + "/open-source", "/campus-map", "/gap-planner", "/campus-routing", @@ -391,12 +397,14 @@ for (const uniId of UNIVERSITY_IDS) { } requireText(uniHtml, `property="og:url" content="${uniOrigin}/"`, `${uniId} index og:url`); - // University HTML must have JSON-LD with the university origin (not gapwise.ca/#organization etc.) - if (uniHtml.includes(`"https://gapwise.ca/#`)) { - throw new Error( - `${uniId} index.html has JSON-LD entity IDs still pointing to gapwise.ca — must use ${uniOrigin}`, - ); - } + // Every edition links its host-specific Website/Application to the one canonical Gapwise entity. + requireText( + uniHtml, + `"@id":"https://gapwise.ca/#organization"`, + `${uniId} canonical organization entity`, + ); + requireText(uniHtml, `"@id":"${uniOrigin}/#website"`, `${uniId} website entity`); + requireText(uniHtml, `"@id":"${uniOrigin}/#app"`, `${uniId} application entity`); // University HTML must not leak U of T fallback navigation links for (const path of UOFT_ONLY_PATHS) { @@ -442,12 +450,6 @@ for (const uniId of UNIVERSITY_IDS) { // ── Per-university social preview metadata and OG card regression checks ─────── -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]), @@ -513,7 +515,7 @@ for (const uniId of ALL_UNIVERSITY_IDS) { const expectedTitle = uniId === "uoft" ? "Gapwise — University Timetable & Campus Navigation" - : `Gapwise — ${escapedName}`; + : `Gapwise for ${escapedName} — Timetable & Campus Navigation`; requireText(html, ``, `${uniId} og:title`); requireText(html, `${expectedTitle}`, `${uniId} title`); @@ -556,14 +558,16 @@ for (const uniId of UNIVERSITY_IDS) { /]*application\/ld\+json)[^>]*>[\s\S]*?<\/script>/g, "", ); - for (const [otherId, otherName] of Object.entries(ALL_UNIVERSITY_NAMES)) { - if (otherId === uniId) continue; - if (html.includes(otherName)) { - throw new Error(`${uniId} ${path} leaks ${otherName}`); + if (path !== "/universities") { + for (const [otherId, otherName] of Object.entries(ALL_UNIVERSITY_NAMES)) { + if (otherId === uniId) continue; + if (html.includes(otherName)) { + throw new Error(`${uniId} ${path} leaks ${otherName}`); + } + } + if (/\b(?:UTM|UTSG|UTSC|U of T|ACORN)\b/i.test(html)) { + throw new Error(`${uniId} ${path} leaks U of T campus or timetable identity`); } - } - if (/\b(?:UTM|UTSG|UTSC|U of T|ACORN)\b/i.test(html)) { - throw new Error(`${uniId} ${path} leaks U of T campus or timetable identity`); } if ( path === "/" || diff --git a/src/components/PublicFeaturePage.tsx b/src/components/PublicFeaturePage.tsx index fc00d781..442ec7ee 100644 --- a/src/components/PublicFeaturePage.tsx +++ b/src/components/PublicFeaturePage.tsx @@ -9,6 +9,8 @@ const RESOURCE_LINKS = [ ["/campus-routing", "Campus routing"], ["/acorn-import", "ACORN import"], ["/about", "About"], + ["/universities", "Universities"], + ["/open-source", "Open source"], ] as const; export function PublicFeaturePage({ page }: { page: PublicFeaturePageData }) { @@ -35,6 +37,9 @@ export function PublicFeaturePage({ page }: { page: PublicFeaturePageData }) { About + + Universities + Developers diff --git a/src/content/public-feature-pages.ts b/src/content/public-feature-pages.ts index b5f3ad64..12fc6868 100644 --- a/src/content/public-feature-pages.ts +++ b/src/content/public-feature-pages.ts @@ -1,3 +1,11 @@ +import universities from "../../universities.json" with { type: "json" }; + +const supportedUniversityCount = universities.universities.length; +const supportedCampusCount = universities.universities.reduce( + (count, university) => count + university.campuses.length, + 0, +); + export type PublicFeatureSection = { title: string; body: string; @@ -44,6 +52,51 @@ export const PUBLIC_FEATURE_PAGES = { }, ], }, + universities: { + path: "/universities", + eyebrow: "University editions", + title: "One Gapwise, built around each supported campus.", + seoTitle: "Supported Canadian Universities — Gapwise", + description: `Explore all ${supportedUniversityCount} Canadian university editions and ${supportedCampusCount} campus models supported by Gapwise for timetable planning, campus search, and navigation.`, + lead: "Each edition combines a university-specific timetable workflow with its own source-backed campus model while sharing one privacy-first Gapwise application.", + sections: [ + { + title: `${supportedUniversityCount} university editions`, + body: `Gapwise currently supports ${universities.universities.map((university) => university.name).join(", ")}.`, + }, + { + title: `${supportedCampusCount} campus models`, + body: "U of T has distinct Mississauga, St. George, and Scarborough models. Every other supported edition selects its own campus data, search index, timetable adapter, and pedestrian graph.", + }, + { + title: "Honest campus evidence", + body: "Building, entrance, route, and accessibility records retain source provenance and uncertainty. Unknown facts remain unknown instead of being presented as verified.", + }, + ], + }, + openSource: { + path: "/open-source", + eyebrow: "Free and open source", + title: "Open campus software, with clear ownership of facts.", + seoTitle: "Open Source University Planning — Gapwise", + description: + "Learn how Gapwise publishes its university timetable, campus navigation, data, API, SDK, mobile, AI, documentation, and status projects as open source.", + lead: "Gapwise is free to use and developed in public. Its repositories separate product behavior, canonical campus evidence, developer contracts, mobile clients, AI permissions, and operational status.", + sections: [ + { + title: "Code developed in public", + body: "The GapwiseHQ organization publishes the web application, canonical campus data, developer documentation, CLI, native client foundations, AI integration, and status service.", + }, + { + title: "Data with provenance", + body: "Campus facts retain source identity, licensing, inference state, and uncertainty so downstream tools can distinguish evidence from a routing connector or approximation.", + }, + { + title: "Privacy-first architecture", + body: "Timetable imports are parsed locally in the browser, guest mode remains first-class, and private sync is optional and browser-encrypted before storage.", + }, + ], + }, timetable: { path: "/utm-timetable", eyebrow: "UTM timetable", diff --git a/src/data/campuses/generated/residence-coverage-matrix.json b/src/data/campuses/generated/residence-coverage-matrix.json index 0245bde6..e7b4040f 100644 --- a/src/data/campuses/generated/residence-coverage-matrix.json +++ b/src/data/campuses/generated/residence-coverage-matrix.json @@ -1,13 +1,13 @@ { "schemaVersion": "1.0", - "generatedAt": "2026-09-30T13:48:39.600Z", + "generatedAt": "2026-09-30T21:58:37.038Z", "summary": { - "totalUniversities": 12, - "totalCampuses": 14, - "totalBuildings": 797, - "totalResidences": 192, - "universitiesWithResidenceCoverage": 12, - "campusesWithResidenceCoverage": 14, + "totalUniversities": 13, + "totalCampuses": 15, + "totalBuildings": 865, + "totalResidences": 199, + "universitiesWithResidenceCoverage": 13, + "campusesWithResidenceCoverage": 15, "coveragePercentage": 100 }, "campuses": { @@ -1128,6 +1128,51 @@ "aliases": ["walter-h-gage-residence-south-tower", "South"] } ] + }, + "waterloo-main": { + "universityId": "waterloo", + "universityName": "University of Waterloo", + "campusId": "waterloo-main", + "campusName": "University of Waterloo", + "totalBuildings": 68, + "residenceCount": 7, + "residences": [ + { + "code": "CMH", + "name": "Claudette Millar Hall", + "aliases": ["claudette-millar-hall"] + }, + { + "code": "MHR", + "name": "Minota Hagey Residence", + "aliases": ["minota-hagey-residence"] + }, + { + "code": "MKV", + "name": "William Lyon Mackenzie King Village", + "aliases": ["william-lyon-mackenzie-king-village", "Mackenzie King Village"] + }, + { + "code": "REV", + "name": "Ron Eydt Village", + "aliases": ["ron-eydt-village"] + }, + { + "code": "TH", + "name": "Tutors' Houses", + "aliases": ["tutors-houses", "Tutors’ Houses"] + }, + { + "code": "UWP", + "name": "University of Waterloo Place", + "aliases": ["university-of-waterloo-place", "UW Place", "Grand Commons"] + }, + { + "code": "V1", + "name": "Student Village 1", + "aliases": ["student-village-1", "Village 1"] + } + ] } } } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index bd599d96..d33990de 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as CampusRoutingRouteImport } from './routes/campus-routing' import { Route as DevelopersRouteImport } from './routes/developers' import { Route as GapPlannerRouteImport } from './routes/gap-planner' import { Route as MailRouteImport } from './routes/mail' +import { Route as OpenSourceRouteImport } from './routes/open-source' import { Route as OpsRouteImport } from './routes/ops' import { Route as PlacesRouteImport } from './routes/places' import { Route as PrivacyRouteImport } from './routes/privacy' @@ -29,6 +30,7 @@ import { Route as SupportRouteImport } from './routes/support' import { Route as SurveyRouteImport } from './routes/survey' import { Route as TermsRouteImport } from './routes/terms' import { Route as TrustRouteImport } from './routes/trust' +import { Route as UniversitiesRouteImport } from './routes/universities' import { Route as UtmTimetableRouteImport } from './routes/utm-timetable' import { Route as AppIndexRouteImport } from './routes/_app.index' import { Route as AppGapsRouteImport } from './routes/_app.gaps' @@ -87,6 +89,11 @@ const MailRoute = MailRouteImport.update({ path: '/mail', getParentRoute: () => rootRouteImport, } as any) +const OpenSourceRoute = OpenSourceRouteImport.update({ + id: '/open-source', + path: '/open-source', + getParentRoute: () => rootRouteImport, +} as any) const OpsRoute = OpsRouteImport.update({ id: '/ops', path: '/ops', @@ -137,6 +144,11 @@ const TrustRoute = TrustRouteImport.update({ path: '/trust', getParentRoute: () => rootRouteImport, } as any) +const UniversitiesRoute = UniversitiesRouteImport.update({ + id: '/universities', + path: '/universities', + getParentRoute: () => rootRouteImport, +} as any) const UtmTimetableRoute = UtmTimetableRouteImport.update({ id: '/utm-timetable', path: '/utm-timetable', @@ -189,6 +201,7 @@ export interface FileRoutesByFullPath { '/developers': typeof DevelopersRoute '/gap-planner': typeof GapPlannerRoute '/mail': typeof MailRoute + '/open-source': typeof OpenSourceRoute '/ops': typeof OpsRoute '/places': typeof PlacesRouteWithChildren '/privacy': typeof PrivacyRoute @@ -199,6 +212,7 @@ export interface FileRoutesByFullPath { '/survey': typeof SurveyRoute '/terms': typeof TermsRoute '/trust': typeof TrustRoute + '/universities': typeof UniversitiesRoute '/utm-timetable': typeof UtmTimetableRoute '/gaps': typeof AppGapsRoute '/timetable': typeof AppTimetableRoute @@ -217,6 +231,7 @@ export interface FileRoutesByTo { '/developers': typeof DevelopersRoute '/gap-planner': typeof GapPlannerRoute '/mail': typeof MailRoute + '/open-source': typeof OpenSourceRoute '/ops': typeof OpsRoute '/places': typeof PlacesRouteWithChildren '/privacy': typeof PrivacyRoute @@ -227,6 +242,7 @@ export interface FileRoutesByTo { '/survey': typeof SurveyRoute '/terms': typeof TermsRoute '/trust': typeof TrustRoute + '/universities': typeof UniversitiesRoute '/utm-timetable': typeof UtmTimetableRoute '/gaps': typeof AppGapsRoute '/timetable': typeof AppTimetableRoute @@ -248,6 +264,7 @@ export interface FileRoutesById { '/developers': typeof DevelopersRoute '/gap-planner': typeof GapPlannerRoute '/mail': typeof MailRoute + '/open-source': typeof OpenSourceRoute '/ops': typeof OpsRoute '/places': typeof PlacesRouteWithChildren '/privacy': typeof PrivacyRoute @@ -258,6 +275,7 @@ export interface FileRoutesById { '/survey': typeof SurveyRoute '/terms': typeof TermsRoute '/trust': typeof TrustRoute + '/universities': typeof UniversitiesRoute '/utm-timetable': typeof UtmTimetableRoute '/_app/gaps': typeof AppGapsRoute '/_app/timetable': typeof AppTimetableRoute @@ -280,6 +298,7 @@ export interface FileRouteTypes { | '/developers' | '/gap-planner' | '/mail' + | '/open-source' | '/ops' | '/places' | '/privacy' @@ -290,6 +309,7 @@ export interface FileRouteTypes { | '/survey' | '/terms' | '/trust' + | '/universities' | '/utm-timetable' | '/gaps' | '/timetable' @@ -308,6 +328,7 @@ export interface FileRouteTypes { | '/developers' | '/gap-planner' | '/mail' + | '/open-source' | '/ops' | '/places' | '/privacy' @@ -318,6 +339,7 @@ export interface FileRouteTypes { | '/survey' | '/terms' | '/trust' + | '/universities' | '/utm-timetable' | '/gaps' | '/timetable' @@ -338,6 +360,7 @@ export interface FileRouteTypes { | '/developers' | '/gap-planner' | '/mail' + | '/open-source' | '/ops' | '/places' | '/privacy' @@ -348,6 +371,7 @@ export interface FileRouteTypes { | '/survey' | '/terms' | '/trust' + | '/universities' | '/utm-timetable' | '/_app/gaps' | '/_app/timetable' @@ -369,6 +393,7 @@ export interface RootRouteChildren { DevelopersRoute: typeof DevelopersRoute GapPlannerRoute: typeof GapPlannerRoute MailRoute: typeof MailRoute + OpenSourceRoute: typeof OpenSourceRoute OpsRoute: typeof OpsRoute PlacesRoute: typeof PlacesRouteWithChildren PrivacyRoute: typeof PrivacyRoute @@ -379,6 +404,7 @@ export interface RootRouteChildren { SurveyRoute: typeof SurveyRoute TermsRoute: typeof TermsRoute TrustRoute: typeof TrustRoute + UniversitiesRoute: typeof UniversitiesRoute UtmTimetableRoute: typeof UtmTimetableRoute OauthConsentRoute: typeof OauthConsentRoute } @@ -455,6 +481,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MailRouteImport parentRoute: typeof rootRouteImport } + '/open-source': { + id: '/open-source' + path: '/open-source' + fullPath: '/open-source' + preLoaderRoute: typeof OpenSourceRouteImport + parentRoute: typeof rootRouteImport + } '/ops': { id: '/ops' path: '/ops' @@ -525,6 +558,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof TrustRouteImport parentRoute: typeof rootRouteImport } + '/universities': { + id: '/universities' + path: '/universities' + fullPath: '/universities' + preLoaderRoute: typeof UniversitiesRouteImport + parentRoute: typeof rootRouteImport + } '/utm-timetable': { id: '/utm-timetable' path: '/utm-timetable' @@ -624,6 +664,7 @@ const rootRouteChildren: RootRouteChildren = { DevelopersRoute: DevelopersRoute, GapPlannerRoute: GapPlannerRoute, MailRoute: MailRoute, + OpenSourceRoute: OpenSourceRoute, OpsRoute: OpsRoute, PlacesRoute: PlacesRouteWithChildren, PrivacyRoute: PrivacyRoute, @@ -634,6 +675,7 @@ const rootRouteChildren: RootRouteChildren = { SurveyRoute: SurveyRoute, TermsRoute: TermsRoute, TrustRoute: TrustRoute, + UniversitiesRoute: UniversitiesRoute, UtmTimetableRoute: UtmTimetableRoute, OauthConsentRoute: OauthConsentRoute, } diff --git a/src/routes/_app.index.tsx b/src/routes/_app.index.tsx index 022a6dd3..1d3778ed 100644 --- a/src/routes/_app.index.tsx +++ b/src/routes/_app.index.tsx @@ -7,7 +7,7 @@ export const Route = createFileRoute("/_app/")({ const isRootHost = !university || university.id === "uoft"; const title = isRootHost ? "Gapwise — University Timetable & Campus Navigation" - : `Gapwise — ${university?.name ?? "Campus timetable"}`; + : `Gapwise for ${university?.name ?? "Campus timetable"} — Timetable & Campus Navigation`; const description = isRootHost ? "Gapwise is a free and open-source timetable, campus navigation, and student planning platform for students across multiple Canadian universities." : `Gapwise is a free and open-source timetable, campus navigation, and student planning platform for ${university.name} students.`; diff --git a/src/routes/developers.tsx b/src/routes/developers.tsx index bfad4d3c..1e7b6448 100644 --- a/src/routes/developers.tsx +++ b/src/routes/developers.tsx @@ -49,7 +49,7 @@ const { data, meta } = await response.json();`; const ENDPOINTS = [ ["GET", "/v1", "Discovery and version metadata"], ["GET", "/v1/universities", "13 supported universities and editions"], - ["GET", "/v1/campuses", "13 supported campus models and metadata"], + ["GET", "/v1/campuses", "15 supported campus models and metadata"], ["GET", "/v1/buildings", "Buildings, coverage, and provenance"], ["GET", "/v1/places", "Campus places and availability"], ["POST", "/v1/routes", "Deterministic campus routing"], @@ -131,7 +131,7 @@ function DevelopersPage() { Build with the campus layer behind Gapwise.

- Use the stable API and SDKs for deterministic campus intelligence across 11 + Use the stable API and SDKs for deterministic campus intelligence across 13 supported universities, Gapwise Data for canonical campus models and raw artifacts, or the separate AI/MCP surface for explicitly delegated student context.

diff --git a/src/routes/open-source.tsx b/src/routes/open-source.tsx new file mode 100644 index 00000000..ef3fa78e --- /dev/null +++ b/src/routes/open-source.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PublicFeaturePage } from "@/components/PublicFeaturePage"; +import { PUBLIC_FEATURE_PAGES } from "@/content/public-feature-pages"; + +export const Route = createFileRoute("/open-source")({ + head: () => ({ + meta: [ + { title: PUBLIC_FEATURE_PAGES.openSource.seoTitle }, + { name: "description", content: PUBLIC_FEATURE_PAGES.openSource.description }, + ], + }), + component: () => , +}); diff --git a/src/routes/universities.tsx b/src/routes/universities.tsx new file mode 100644 index 00000000..629a11bb --- /dev/null +++ b/src/routes/universities.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PublicFeaturePage } from "@/components/PublicFeaturePage"; +import { PUBLIC_FEATURE_PAGES } from "@/content/public-feature-pages"; + +export const Route = createFileRoute("/universities")({ + head: () => ({ + meta: [ + { title: PUBLIC_FEATURE_PAGES.universities.seoTitle }, + { name: "description", content: PUBLIC_FEATURE_PAGES.universities.description }, + ], + }), + component: () => , +}); diff --git a/tests/auth-branding.test.ts b/tests/auth-branding.test.ts index 5a2d3e29..a0608134 100644 --- a/tests/auth-branding.test.ts +++ b/tests/auth-branding.test.ts @@ -173,7 +173,7 @@ describe("branding metadata", () => { expect(indexRoute).toContain("const university = activeUniversity()"); expect(indexRoute).toContain("Gapwise — University Timetable & Campus Navigation"); - expect(indexRoute).toContain("`Gapwise — ${university?.name"); + expect(indexRoute).toContain("`Gapwise for ${university?.name"); expect(indexRoute).toContain('{ name: "description", content: description }'); }); }); diff --git a/tests/residence-multi-university.test.ts b/tests/residence-multi-university.test.ts index 19a9225b..7938a913 100644 --- a/tests/residence-multi-university.test.ts +++ b/tests/residence-multi-university.test.ts @@ -57,9 +57,9 @@ describe("First-class multi-university residence platform", () => { it("provides verified residence coverage across every supported campus model", () => { expect(residenceMatrix.summary.coveragePercentage).toBe(100); - expect(residenceMatrix.summary.campusesWithResidenceCoverage).toBe(14); - expect(residenceMatrix.summary.universitiesWithResidenceCoverage).toBe(12); - expect(residenceMatrix.summary.totalResidences).toBeGreaterThanOrEqual(190); + expect(residenceMatrix.summary.campusesWithResidenceCoverage).toBe(15); + expect(residenceMatrix.summary.universitiesWithResidenceCoverage).toBe(13); + expect(residenceMatrix.summary.totalResidences).toBeGreaterThanOrEqual(199); for (const university of manifest.universities) { for (const campus of university.campuses) { diff --git a/tests/seo.test.ts b/tests/seo.test.ts index 01389ffb..96ec29ee 100644 --- a/tests/seo.test.ts +++ b/tests/seo.test.ts @@ -7,6 +7,8 @@ import universities from "../universities.json" with { type: "json" }; const SITE_ORIGIN = "https://gapwise.ca"; const FEATURE_PATHS = [ "/about", + "/universities", + "/open-source", "/utm-timetable", "/campus-map", "/gap-planner", @@ -172,7 +174,7 @@ describe("Gapwise searchability and entity metadata", () => { expect(destinationFor("/robots.txt")).toBe(`/_campuses/${campusId}/robots.txt`); expect( destinationFor( - "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", ), ).toBe(`/_campuses/${campusId}/_seo/$1.html`); expect(destinationFor("/(.*)")).toBe(`/_campuses/${campusId}/index.html`); diff --git a/vercel.json b/vercel.json index 213e415a..eb91f6ff 100644 --- a/vercel.json +++ b/vercel.json @@ -331,7 +331,7 @@ "destination": "/_universities/carleton/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -401,7 +401,7 @@ "destination": "/_universities/tmu/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -471,7 +471,7 @@ "destination": "/_universities/queens/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -541,7 +541,7 @@ "destination": "/_universities/laurier/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -611,7 +611,7 @@ "destination": "/_universities/york/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -681,7 +681,7 @@ "destination": "/_universities/mcmaster/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -751,7 +751,7 @@ "destination": "/_universities/western/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -821,7 +821,7 @@ "destination": "/_universities/guelph/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -891,7 +891,7 @@ "destination": "/_universities/uottawa/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -961,7 +961,7 @@ "destination": "/_universities/brock/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -1031,7 +1031,7 @@ "destination": "/_universities/ubc/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -1101,7 +1101,7 @@ "destination": "/_universities/waterloo/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -1171,7 +1171,7 @@ "destination": "/_campuses/utm/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -1241,7 +1241,7 @@ "destination": "/_campuses/utsg/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -1311,7 +1311,7 @@ "destination": "/_campuses/utsc/robots.txt" }, { - "source": "/(about|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", + "source": "/(about|universities|open-source|campus-map|gap-planner|campus-routing|developers|ai|support|trust|privacy|security|accessibility)", "has": [ { "type": "host", @@ -1360,6 +1360,14 @@ "source": "/about", "destination": "/_seo/about.html" }, + { + "source": "/universities", + "destination": "/_seo/universities.html" + }, + { + "source": "/open-source", + "destination": "/_seo/open-source.html" + }, { "source": "/utm-timetable", "destination": "/_seo/utm-timetable.html"