GUESTBOOK.EXEcontact.exe
UNISPACE

</unispace>

UNISPACE

Campus occupancy for students, with no coordinate ever leaving the phone

0

coordinates in any request — a zone id and a rotating session token are all that leave

18

campus buildings mapped, 15 carrying their real OpenStreetMap footprint

438 KB

of map library the home screen never downloads

SPECIFICATIONS

ROLESOLO BUILD
YEAR2026
TYPEPWA
STATUSLIVE · MODELLED DATA
STACKtypescript · react · vite · tailwind · mapbox-gl · turf.js · supabase · postgres +6 more
LINKS[live ↗][github ↗]
AVAILABILITYunispace-tawny.vercel.app

During semester, finding a free desk on campus means guessing, and the cost of a wrong guess is not evenly distributed — a commuter with ninety minutes between classes, or someone for whom each failed attempt is ten minutes of physical effort, pays far more for it than someone who lives on campus.

== WHAT IS THIS ==

UniSpace is a public web app showing how full 18 University of Melbourne buildings are, so students can check before the fifteen-minute walk instead of after it. The location matching runs entirely in the browser — Turf.js tests your GPS point against building polygons on the device, and the only things the app transmits are a zone id and a session token that rotates every thirty minutes — held by a test that fails the build if a latitude ever appears in a request body. Map tiles come from Mapbox, so that request path is theirs rather than the app's. Occupancy on the live site is modelled from committed weekly curves rather than measured, and the interface states which source every reading came from and how confident it is. Live at unispace-tawny.vercel.app, source public.

the README hero: home, the campus map, and a building card side by side, every reading marked as an estimate
home screen: "campus is quiet", 18 buildings 0/5 open, avg occupancy ~14%, quietest alan gilbert ~8%
parkville map with real building footprints, student pav at 12% and erc at 18% shaded green, banner reading "~ estimated from typical campus patterns"
peter hall building card showing NO DATA in a dashed low-confidence panel, "hours not verified", and accessibility rows split between [x] known and [?] unknown
the find-a-spot sheet: open now / quiet zone / food nearby / under 50% / under 30% filters over 18 results ranked by emptiness

== </the problem> ==

During semester, finding a free desk on campus means guessing, and the cost of a wrong guess is not evenly distributed — a commuter with ninety minutes between classes, or someone for whom each failed attempt is ten minutes of physical effort, pays far more for it than someone who lives on campus. The obvious fix is an app that reports occupancy, but the obvious fix has two problems. Anything that tracks where students are all day is a thing students are right to refuse. And a crowdsourced occupancy app is wrong or empty for its entire first week, which is precisely when it is being judged.

== </my approach> ==

A web app over 18 University of Melbourne buildings where the privacy design is the constraint everything else bends around: the browser reads GPS, matches the point against building polygons locally with Turf.js, and sends a zone id — never a coordinate. Session identifiers rotate every thirty minutes, live in memory only, and are never written to a table. The second half of the work is the harder half: showing occupancy honestly when the app is not sure, which for a crowdsourced product is most of the time. Occupancy is assembled from whichever source is best available and the interface always states which, in text as well as colour, with estimates rendered at reduced intensity behind a `~` and only genuinely live data getting the status dot.

role & context

Solo build — product, interface, data sourcing, and the privacy architecture. Also the auditor: the recovery phase began by treating my own plan, which recorded itself as finished, as a set of claims rather than facts.

== </the story> ==

UniSpace answers one question before the walk instead of after it: is there anywhere to sit. It covers 18 buildings at the University of Melbourne's Parkville campus, needs no account, and is a web app rather than a native one because requiring an install would exclude exactly the person who opens it once a week on a train platform.

The interesting problem was never rendering occupancy. It was rendering occupancy honestly while not being sure — which, for a crowdsourced product, is most of the time and all of the first week. So the app never shows a number without showing where it came from. Live counts, crowd reports, model predictions and modelled weekly estimates are distinct states with distinct visual treatments, and confidence is a property of the display rather than a footnote: estimates come through dimmed, dashed and prefixed with `~`, and only genuinely live data earns the status dot. A test enforces that an estimate can never render as a live reading.

That principle runs down into the data rather than stopping at the presentation layer. Researching accessibility against the University's own campus map showed that every flag in the database had been invented, and that the columns could only say yes or no — so the true answer for most buildings, nobody has checked, was not expressible. The columns became nullable, a flag may never be set false from an absence of evidence, and unverified facts render [?] with their provenance shown inline. Opening hours got the same treatment.

Most of this project's engineering time went into repairing itself rather than extending itself. An audit of a plan that recorded itself as complete found that the database had been deleted, that Tailwind was emitting roughly half its utilities because v4 was installed against v3 syntax, and that the build had not compiled in five sprints because the check ran `vite build`, which succeeds while `tsc -b` fails. That audit is committed in the repository, unedited.

== </architecture> ==

React with TypeScript on Vite, built as a PWA, with Mapbox GL rendering real OpenStreetMap building footprints and Recharts drawing the daily curve. Two decisions shaped everything downstream.

Zone matching happens on the client. The Geolocation API hands a raw coordinate to zoneDetection.ts, a pure function that runs a Turf.js point-in-polygon test and returns a zone id, with no logging, no network and no storage — the coordinate is discarded the moment it returns, and a test asserts that file stays side-effect free. Only the zone id crosses the wire. Session ids are module-scoped, regenerate every thirty minutes, are intentionally lost on reload, and are counted in memory by the aggregation function but never written to a table.

Every hook reads through one seam. dataSource.ts exposes fetchRows and subscribeRows, and nothing above it knows whether the rows came from Postgres or from a local fixture. Every external payload is parsed through a Zod schema on the way in. The blending layer then resolves a reading through a fixed chain — live, crowd report, predicted, modelled estimate, none — and confidence.ts holds the single definition of the three confidence tiers that the map, the building card and the finder all render from.

The backend is Postgres with row-level security on every table, seven Deno Edge Functions covering aggregation, predictions, reports, alerts, feedback and an opening-hours sync, and a full migration history. All of it is committed and readable, and none of it is running. The deployed site reads from a fixture layer generated from the same committed seed SQL a real database would be seeded from, with a test that fails if the generated fixtures and the seeds fall out of step.

== </key features> ==

Location matching that never leaves the device

Turf.js point-in-polygon runs in the browser; the one outbound payload carries a zone id and an ephemeral session id and nothing else. Six invariants fail the build if a session id is persisted, a coordinate appears in a request body, an analytics SDK is added, or zone detection gains a side effect.

Every reading states its own source and confidence

Live, crowd report, predicted, modelled estimate, or none — named in text, not just colour. Estimates render dimmed with a dashed border and a `~`; cached readings are downgraded to stale rather than keeping the source they arrived with, so they inherit the low-confidence treatment automatically.

Unknowns that stay unknown

Accessibility flags are nullable and render [?] with their provenance inline when nobody has checked. A building whose hours have no published source reads "Hours not verified" with a hollow dot — and is deliberately not excluded by the Open Now filter, because filtering on invented hours quietly cut the campus to five libraries on weekends.

A campus readout that names its denominator

"5 of 5 buildings with a reading are under 50%" rather than a bare percentage, and NO CAMPUS READING when nothing has reported — because with 13 of 18 buildings silent, any single headline number is a claim about data that does not exist.

Ranked spot finder

Buildings scored on a combination of emptiness, walking distance and amenity match, so the answer to "where should I go" is one screen rather than a map to interpret.

Real building geometry

15 of 18 buildings carry their actual unsimplified OpenStreetMap way, 15 to 58 vertices each, so courtyard buildings read as courtyard buildings. Matched by name and never by proximity, because several seeded coordinates were wrong and proximity matching would have inherited the error instead of exposing it.

Runs with no backend at all

One read seam means the whole app works against fixtures generated from the committed seed SQL, with no network. That is also what the deployed site runs on, and it says so on screen.

== </key decisions> ==

DECISION 01

Match the GPS point to a zone on the device and send only a zone id. The moment a raw coordinate reaches a server, "we never see where you are" becomes a claim about server-side behaviour that nobody outside the project can check. Reducing the wire format to a zone identifier and a rotating session token makes the promise checkable by someone who does not trust me — by reading the egress points in a clean clone, or by the test that fails the build if a latitude ever appears in a request body. The public deploy has no backend attached, so there is nothing on the other end of that broadcast to watch. The cost is accepted and permanent: aggregation can only ever count sessions per zone, so finer-grained occupancy is off the table forever.

DECISION 02

Write the privacy rules as tests rather than as documentation. Six invariants fail the build if a session id is persisted, if a session id appears in an Edge Function insert, if a coordinate turns up in a request body, if an analytics SDK is imported, if the Google key gets a VITE_ prefix, or if zone detection acquires a side effect. The test's own header is candid about the limit of this: it cannot prove the app is private, only that nobody has casually reintroduced the specific mistakes that would make it not.

DECISION 03

Put every read behind one seam. It was introduced to make the app runnable with no backend, and the larger payoff was that fixtures generated from the committed seed SQL became the integration test data too, so production and test data structurally cannot drift. Pointing at a live database is an environment-variable change, not a rewrite.

DECISION 04

Commit the whole backend and host none of it. Hosted Postgres, Realtime and Edge Functions are a monthly bill on a portfolio project. Parking it as a decision — and saying so in the README, on this page, and on screen in the app — is more defensible than leaving it pending indefinitely. What a reader can evaluate is the client, the data model, and the interface's behaviour under missing data; what they cannot evaluate is a production backend under load.

DECISION 05

Make unverified data unrepresentable as a default. Wrong accessibility data is harmful rather than merely inaccurate, because someone plans a route around it. Nullable columns, a rule that a flag may never be set false from an absence of evidence, and a [?] state with inline provenance. The same rule then propagated to opening hours, where filtering on invented values had been silently cutting the campus to five libraries on weekends.

DECISION 06

Measure the build output, never the source. The CSS assertion compiles the real stylesheet and checks what comes out, contrast ratios are computed from index.css rather than inspected, and the bundle budget reads the actual dist/ output. All three exist because a source-level check would have passed happily through the outage that made this project not work.

== </what the audit found> ==

The plan said the project was finished. An audit treated every checkmark in it as a claim rather than a fact, and all three load-bearing ones were false. The Supabase project the app pointed at had been deleted — three independent resolvers returned NXDOMAIN for the project ref while supabase.co itself resolved fine, and a merely paused project still answers DNS — taking with it a migration that had been applied to the cloud and never committed. Tailwind was installed at v4 while index.css was still written in v3 syntax, so the theme scale never loaded and every utility drawing a value from it silently emitted nothing; the shipped stylesheet carried roughly half the utilities it should have (97, against 194 after the fix). And the build had not compiled in five sprints, because the audit step ran `vite build`, which succeeds while `tsc -b` fails. None of the three was feature work, which is exactly why a plan that tracked features could not see them. The audit is committed as WIRING-AUDIT.md and preserved unedited, including the findings later closed as won't-do.

== </what was repaired> ==

The recovery fixed roots rather than symptoms, and each fix left behind a test whose job is to make that failure mode impossible rather than merely absent. The CSS assertion compiles the real stylesheet and checks the build output, because a source-level check would have passed happily throughout the outage.

Rebuilding the data layer as fixtures immediately surfaced a P0 that would otherwise have shipped: blendOccupancy checked whether an occupancy row was fresh but not whether it was real, and since the aggregation function rewrites every row on a timer, a campus with zero users would have reported every building as "Live · 0%" — confidently telling a student that a full library was empty. Every existing blending test passed throughout, because the test helper defaulted data_quality to 'live' and no test ever varied it.

The fixture generator turned out to be the most productive place to look for bugs, because everything downstream inherits whatever it gets wrong. It was dropping every database-default timestamp behind an `as unknown as Building[]` cast, so last_updated was undefined campus-wide and every freshness stamp in the deployed app failed its truthiness guard and rendered nothing. It was also skipping two migrations whose `WHERE id = '...'` form the seed parser had no helper for — the two migrations that replace the original oversized rectangles with real geometry — so no version of this database had ever held a true footprint, and a courtyard building like Old Arts rendered as a flat four-cornered slab.

The last bug found is the one the whole project is organised against. The first line on the home screen announced CAMPUS IS BUSY while average occupancy read 14% and every building in the list said EMPTY. The cause was a single fallback: `occupancy?.pct ?? 100` scored a building with no reading as completely full, and 13 of 18 have no reading, so absent data was being rounded to bad news in the one line a user reads first. It now judges only against buildings that reported and names its own denominator, and falls all the way through to NO CAMPUS READING rather than guessing.

Two accessibility repairs were structural rather than cosmetic. A keyboard trap: two role=dialog sheets were dismissible only by pointer and they cover the tab bar (WCAG 2.1.2). There was already a test that pressed Escape and it passed on the bug, because it never asserted the sheet had actually gone — replacing it with an invariant over every dialog in the tree surfaced a third nobody had noticed. And the schema itself was wrong: accessibility flags could only say yes or no, so the true answer for most buildings — nobody has checked — was unrepresentable. The columns are now nullable, a flag may never be set false from an absence of evidence, and unverified facts render [?] with their provenance inline. Where data could not be recovered it was not invented: the deleted migration was rewritten without the Google Place IDs it originally carried, and 11 of 18 remain NULL.

== </what was measured> ==

Measurement changed decisions repeatedly, and not always in the expected direction. Splitting Mapbox out of the entry took the landing route from 637 KB gzip — the starting figure recorded in the budget test's own header — down to 169 KB in a local build. The next optimisation made things worse: naming a `charts` chunk for Recharts added about 108 KB to the route, because Recharts was already correctly split behind the lazily-loaded building card and naming it caused the bundler to hoist it into a static import. Shrinking it was the goal and growing it was the result, and only measuring the build output caught that.

The honest caveat on that headline number, since this page trades on honesty: the repository states this same measurement three incompatible ways, and the budget test asserts a ceiling of 210 KB rather than any of them. Re-measuring the deployed assets directly, the live landing route serves about 174 KB of JavaScript. That is why the figure on the card is the map library that never arrives at all — 438 KB gzip, verifiable by opening the network tab on the home screen — rather than a byte count that drifts between builds.

Computing WCAG contrast from index.css rather than eyeballing it caught a text token measuring 2.56:1 that was carrying 49 pieces of real content, and the same test later blocked a design revert: restoring the university palette's literal gold (2.02:1) and green (2.42:1) failed 13 assertions, so the shipped values are hue- and saturation-preserved and darkened until they pass. Three separate bugs in this project have now had the same shape — a stylesheet cannot be verified by reading it, only by measuring what a browser computes.

== </decisions> ==

DECISION 01

Match the GPS point to a zone on the device, and send only a zone id

because The moment a raw coordinate reaches a server, "we never see where you are" becomes a claim about server-side behaviour that nobody outside the project can check. Pushing the point-in-polygon test into a pure function on the client and reducing the wire format to a zone identifier turns the promise into something a stranger verifies in the network tab. The cost is real: aggregation can only ever count sessions per zone, so finer-grained occupancy is permanently off the table.

DECISION 02

Every hook reads through one seam (dataSource.ts), not from Supabase directly

because It was introduced to make the app runnable with no backend, and the payoff turned out to be larger. The fixtures are generated from the same committed seed SQL a real database would be seeded from, and a test fails if the two fall out of step — so production data and test data structurally cannot drift. It also means pointing at a live project is an environment-variable change rather than a rewrite.

DECISION 03

The backend is committed in full and hosted nowhere

because Hosted Postgres, Realtime and Edge Functions are a recurring monthly bill on a portfolio project. Parking it deliberately, and saying so in the README and on screen, is more defensible than leaving it pending forever. The honest consequence is stated once and not softened: the live-crowdsourced path has never run against a real user, and what can be evaluated here is the client, the data model, and how the interface behaves when it does not know something.

DECISION 04

Unverified data renders [?] rather than a sensible default

because Wrong accessibility data is harmful, not merely inaccurate — someone plans a route around it. A boolean column cannot express "nobody has checked", which was the true answer for most buildings, so the schema was the bug. The same rule then propagated to opening hours, where filtering on invented values had been quietly cutting the campus to five libraries on weekends.

== </where it doesn’t work> ==

Occupancy is modelled, not measured. No Supabase project is provisioned and none will be, so every reading on the live site is generated from committed weekly curves and the live-crowdsourced path has never run against a real user; crowd reporting works only within your own browser session. The room directory is emptier still — 890 rooms are committed in the seed SQL, but they were deliberately never written into the fixture layer the demo runs on, so the cross-building room search renders nothing at all on the deployed site. Real-world data is partial and labelled as such: opening hours are sourced for 5 of 18 buildings and the other 13 carry invented seed values marked unverified, exactly one building has an entrance the University's own map labels accessible, and no published source exists for accessible parking anywhere on campus, so all 18 read [?] — do not use this for access planning. Three of the 18 footprints are still rectangles, because those buildings could not be identified in OpenStreetMap with confidence and matching them by proximity would have inherited a seeding error rather than exposed it. The privacy guard is a source-level check by its own admission: it proves nobody has casually reintroduced the specific mistakes it knows about, not that the application is private. And none of it has been through Lighthouse, a throttled-3G run, VoiceOver, or a PWA install on real iOS or Android hardware.

== </what i learned> ==

>

A checkmark is a claim, not a fact. Three independent failures — a deleted database, a framework emitting no CSS, and a build that had not compiled in five sprints — all survived inside a plan that recorded the project as finished, because none of them was feature work and the plan only tracked features.

>

Assert the property, not the instance. There was already a test pressing Escape on the dialog that trapped keyboard users, and it passed on the bug because it never asserted the sheet had gone. Replacing it with an invariant over every dialog in the tree found a third one nobody knew about.

>

A stylesheet cannot be verified by reading it, only by measuring what a browser computes. Three separate bugs here had that exact shape, which is why the CSS and contrast assertions run against build output rather than source.

>

The schema is where honesty is won or lost. A boolean accessibility column cannot say "nobody has checked", so every building was asserting something false before a single line of UI was involved.

>

Absent data has a default, whether or not you chose one. A lone `?? 100` fallback rounded 13 buildings with no reading up to completely full and announced a busy campus at 14% occupancy — in the first line a user reads.

next:

What is left is not code. The genuinely outstanding items are real-world data the repository cannot produce: accessible parking, for which no published source exists for any building; step-free entry for 17 of the 18; opening hours for the 13 buildings with no published source, and a re-check of the sourced five against the semester calendar, since they come from a current-week table that will be wrong over exams; and CC-licensed photographs of real buildings. The nearest code-shaped job is wiring the committed 890-room directory into the fixture layer, which would make the cross-building room search work on the deployed demo without needing a backend at all. The hardware passes — Lighthouse, throttled 3G, VoiceOver, PWA install on real iOS and Android — remain open. The backend stays committed and unhosted by decision, not by omission.

== </full stack> ==

typescript · react · vite · tailwind · mapbox-gl · turf.js · supabase · postgres · deno edge functions · zod · recharts · framer-motion · vitest · pwa