Skip to content

feat: 2.0 shell, routes, and per-user shell selection - #1247

Merged
TurtIeSocks merged 17 commits into
v2from
feat/shell-and-ia
Aug 24, 2026
Merged

feat: 2.0 shell, routes, and per-user shell selection#1247
TurtIeSocks merged 17 commits into
v2from
feat/shell-and-ia

Conversation

@TurtIeSocks

Copy link
Copy Markdown
Collaborator

Plan 2 of 5. The 2.0 client gets a shell you can actually navigate: every route from the spec's information architecture, a mobile bottom nav, a hub at the root, a profile page, and a per-user flag on the server deciding which built shell a request is served. 1.0 is untouched apart from the routing change that has to know about both.

What is here

Eight routes in app/, each its own lazy chunk. The map route is the only one that will ever carry MapLibre and deck.gl, and that only holds if nothing pulls a page in at module scope, so app.html is checked to load no page chunk eagerly.

A bottom nav with Map, Filters, Alerts and Me. Search stays in the map's top bar and the hub sits behind the logo, per the spec.

A hub at / that renders without a session at all, because it is on for every operator including anonymous ones. It is navigation and nothing else. The news feed is a later phase.

A profile page that bootstraps from GET /api/settings, the endpoint 1.0 already uses. That was deliberate: it means none of this waits on the transport work, which is a separate design session.

A useAppShell column on the users table, defaulting to off, and a router that reads it. Nothing writes it yet, so flagging an account is a manual database update today. That is the intended state for this plan.

/map, /filters, /alerts and /profile render placeholders. Filling them is what plans 3 through 5 are for.

What the review caught

Three findings on the routing, all of them design mistakes in the plan rather than the implementation, and all three worth describing because they share a shape.

The shell decision was made per user and applied to the union of both route tables, so the path never entered into it. A flagged user was served the 2.0 shell for the thirteen paths only 1.0 implements, including the /@/lat/lon/zoom and /id/category/id deep links people share, plus /reset and /blocked/:info. Every one of them fell through to the 2.0 catch-all and rendered a not-found page. Flag an account, open a map link a friend sent, get nothing. A path only 1.0 owns now serves the 1.0 shell whatever the flag says.

The hub was unreachable by URL. express.static mounts ahead of the router and serve-static defaults to serving index.html for a bare directory request, so / was answered off disk before the router ever saw it. Navigating to / from inside the app worked, which is why nothing noticed: only a cold load or a bookmark showed it. Static now passes index: false and lets / fall through.

Flipping the column did nothing until the user logged out, because passport serializes the whole user into the session and hands it back without re-reading. Worse in reverse, since a user hurt by the first finding could not be rescued by setting the flag back to zero. The fix re-reads the column on session init and writes it to both the request user and the stored session object, because patching only the former is discarded at the end of the request.

Worth naming how these were found. Three review passes ran. The two that read the diff and ran the build cleared the branch. The one that mounted the real router behind the real middleware and issued real HTTP requests found all three. The existing tests could not have caught any of it: they called the decision function and checked list membership, which verifies the decision and never the consequence.

Known limitations

An unflagged user hitting a 2.0-only path lands on 1.0's not-found page, the mirror image of the first finding above. Two shells with different route vocabularies cannot both be complete, and this only bites while accounts are being flagged by hand.

The flag takes effect on the next page load rather than instantly, since the shell is chosen when the HTML is served and the settings call happens after it.

res.sendFile inherits send's dotfiles: 'ignore', so an install path containing a dot segment returns 404 for every client route while static assets keep working. This predates the branch and is left alone.

Verification

bun test        104 pass, 0 fail, 107 expect() calls, 20 files
bun run typecheck   clean
bun run lint        clean, 654 files
bun run build       both shells, eight separate page chunks, separate stylesheets

The route table was exercised as a full matrix, every path against both flag states, plus a user with no session, an empty user, the column absent, false, and a mysql tinyint zero. All thirteen 1.0-only paths return the 1.0 shell in both states, the seven 2.0 paths honour the flag, and every degenerate user shape falls back to 1.0. Each of the three fixes was reverted in turn to confirm its test actually goes red.

🤖 Generated with Claude Code

TurtIeSocks and others added 17 commits August 23, 2026 21:47
Six tasks covering the route table, the mobile bottom nav, session bootstrap,
the hub, the profile page, and the server-side flag that decides which shell a
request is served.

Four judgement calls are recorded in the plan rather than left implicit: the
router is react-router since it is already a dependency, component tests run on
bun's own runner instead of adding Vitest, the profile reads the existing
settings endpoint so nothing here waits on the transport work, and the spec's
list of things this IA deletes stays out of scope because those files still
serve the shell most users are on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wrap the route table in a layout route rendering Shell, which owns
the bottom nav and an Outlet for the page content. ROUTES stays flat
for Task 1's test; the nested table createBrowserRouter consumes is
exported separately as ROUTER_ROUTES.

happy-dom needs registering before any test file imports
@testing-library/dom, which only bunfig's preload can guarantee, but
that preload runs once for the entire bun test process across every
workspace in this monorepo, not just app/. Registering happy-dom
there unconditionally broke packages/masterfile (CORS), server/test
(Response.json resolving to happy-dom's class), and app/build.test.ts
(Vite's bundled code branching on typeof document). test-setup.ts
instead exports setupDom/teardownDom for a test file's own
beforeAll/afterAll, which Bun scopes per file, and BottomNav.test.tsx
reads its queries off render()'s return value rather than the
testing-library screen singleton, since screen snapshots document at
import time and would need the same process-wide registration.
app/test-setup.ts deliberately does not register happy-dom at import time,
because a preload applies to the whole `bun test` run and there is no
per-directory scoping, which broke three unrelated suites when tried. The
file only exports setup and teardown functions now, and the one test that
needs a DOM imports them and calls them from its own beforeAll.

That leaves the preload entry doing nothing. Removing it keeps the suite at
80 passing, and it stops the config implying a global registration that is
not happening, which would mislead anyone who later added a side effect to
that file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a boolean useAppShell column to the users table, defaulting to false so
every existing row keeps the 1.0 client, and teaches the client router to pick
between dist/app.html and dist/index.html per request. The route list is now
the union of both clients' paths, so 1.0 deep links keep working and the flag,
not the path, decides which shell a visitor receives.
The pre-2.0 migrations resolve this table through
database.settings.userTableName so an install could rename it. 2.0 drops
that, and the change was announced as unsupported long enough ago that
designing the new migration around it would be carrying weight nobody is
standing on.

An install that did rename the table now fails at migrate time with a
missing-table error rather than quietly upgrading half way, which is the
better of the two failures.

The existing migrations keep reading the config value. They describe work
already applied, and rewriting them would change the account of what ran.
Removing the option from the config and the models is its own job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Discord and Telegram spread the whole user row onto the session user, so
they picked up the new useAppShell column for free. Local login assigns
fields one at a time and did not copy it, so flagging a local account onto
the 2.0 shell silently did nothing and the account stayed on 1.0.

That is the failure mode worth avoiding here: nothing errors, the column
holds the value asked for, and only the one auth method quietly ignores it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…path

Guards against the local-login gap fixed in f52518b, where Discord and
Telegram spread the whole row and picked up the new column for free while
LocalClient copied fields one at a time and silently dropped it. Verified
against the pre-fix LocalClient to confirm the local-login test actually
fails without the copy line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Guards the gap that let a local account be flagged onto the 2.0 shell and
silently stay on 1.0. Discord and Telegram spread the users-table row onto
the session user, so a new column reaches them for free; local login copies
fields one at a time and had to be told about this one.

Driving the login end to end would mean standing up app config that only
exists after a real server boot, which is a lot of scaffolding for one field
assignment, so the invariant is pinned against the source instead.

It is written as "spreads the row or copies the flag", not "keeps using a
spread". Rewriting a client to assign fields explicitly is fine as long as
the flag comes along, and a test that failed on correct code would get
deleted the first time it was in the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The handler was registered over the union of both route tables while the shell
decision looked only at the user, so a flagged account asking for one of the 13
paths the 2.0 client has no route for got app.html and fell through to its
catch-all NotFound. Shared map deep links, the blocked page and password reset
were all among them.

Register the 1.0 only paths, derived from the two tables so a third list cannot
drift, with a handler that always sends the 1.0 shell, and let the flag decide
only on paths both clients implement.

The existing tests checked list membership, which is why this survived review.
The new ones drive the router over HTTP and assert the shell each path returns
under both flag states.
serve-static answers a directory request with index.html by default, and it is
mounted ahead of the router, so a cold load of / was served the 1.0 shell off
disk and never reached the code that picks a shell per user. Navigating to /
inside the 2.0 client worked, since that is client side routing, which is why
this went unnoticed.

Passing index: false hands / to the router that already owns it. Hashed assets
still come off disk, which the new test checks alongside the fall through.
Passport serializes the whole users row into the session and deserializes it
without re-reading the database, so flipping the column did nothing until the
person logged out. Sessions live in the database, so a restart did not help
either, and someone hurt by a bad flag could not be rescued by setting it back.

The settings endpoint already re-reads the row, so the refresh happens there.
It patches the object stored in the session as well as req.user, because the
deserializer hands back a copy and a req.user patch is discarded when the
request ends. The flag then applies on the next page load, since the shell is
chosen when the HTML is served and the settings call happens after it.

The new test drives the real serializer pair over an express session, and fails
if only req.user is patched.
CI failed on "marks the active destination for assistive tech" with
TestingLibraryElementError: Found multiple elements with the role "link" and
name "Filters". It passed locally every time.

Renders are appended to the same document and stay there, and the queries
render returns are bound to the whole body rather than to the container that
render created. The hub renders a link labelled Filters as well, so once both
files had rendered there were two matches and getByRole threw for ambiguity.
Whether that happened came down to which files had already run, which is why
one machine saw it and the other did not.

Both files now clean up after each test, and queries are scoped with within()
to the container the render owns, so a stale render elsewhere cannot make them
ambiguous. Verified against a deliberately polluted document holding two
competing Filters links: the scoped query still resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TurtIeSocks
TurtIeSocks merged commit b32a7ee into v2 Aug 24, 2026
2 checks passed
@TurtIeSocks
TurtIeSocks deleted the feat/shell-and-ia branch August 24, 2026 03:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant