feat: put the viewer's own tech column first (and fix the case mismatch that would have emptied it) #6

Merged
mohlec merged 5 commits from feat/own-column-first into main 2026-08-05 17:49:13 +00:00
Owner

Implements the requested behaviour: everyone keeps seeing every tech's column, and the viewer's own column is simply first.

What was actually there

Worth stating, because the README is misleading. It claims "field techs see only their own column" — the web board has never filtered. It renders every column, sorted alphabetically, and auto-scrolls to yours. The mobile board already moved your column to the front. So the web board was the only place that needed the ordering change.

The latent bug this uncovered

The mobile board's myTech-first logic compared two differently-cased strings:

  • myTechName comes from reverseTechMap(), whose keys parsedTechMap() lower-cases
  • a.Tech carries the billing system's own casing — STOUTD, DIAZG, SkinnR, morric

techSet[myTechName] = true therefore inserted a lower-cased duplicate, t != myTechName sorted that duplicate to the front as "mine", and the client filter a.tech === tech matched zero appointments against it.

Net effect: the tech opens the mobile board on an empty column, with their real appointments sitting elsewhere in the swipe order. It affects 4 of the 5 techs — every one whose billing name is not already lower-case. Only morric escapes, by luck.

This has been dormant only because the TechMap has never been populated, so myTechName was always empty. It would have surfaced immediately on populating it.

Also fixed: one tech, two columns

Stored appointments contain both SkinnR (5 appointments) and skinnr (1). Both boards keyed columns on the raw string, so that renders as two columns for the same person. Both now group case-insensitively, keeping the first-seen spelling for display.

Changes

server/mobile.go

  • Group techs by lower-cased key with a canonical display spelling
  • Resolve the viewer's tech to the spelling the appointments actually use, so MY_TECH compares equal to entries in TECHS
  • Order with own-first via strings.EqualFold
  • Client-side appointment filter and isMine now compare case-insensitively
  • MY_TECH is emitted as the canonical spelling (marshal moved after resolution)

webapp/src/components/DispatchBoard.tsx

  • groupByTech groups case-insensitively and takes the viewer's tech, moving that column to the front
  • Comment explicitly warns against turning this into a filter

Verifying after deploy

Requires the TechMap to be populated — it is currently empty, so tech_name resolves to "" for everyone and neither board has anything to order by. Once set, check GET /api/v1/me returns the expected tech_name, then confirm on both boards that the viewer's column is first and that all other columns are still present.

Implements the requested behaviour: **everyone keeps seeing every tech's column, and the viewer's own column is simply first.** ## What was actually there Worth stating, because the README is misleading. It claims *"field techs see only their own column"* — the **web board has never filtered**. It renders every column, sorted alphabetically, and auto-scrolls to yours. The **mobile board** already moved your column to the front. So the web board was the only place that needed the ordering change. ## The latent bug this uncovered The mobile board's `myTech`-first logic compared two differently-cased strings: - `myTechName` comes from `reverseTechMap()`, whose keys `parsedTechMap()` **lower-cases** - `a.Tech` carries the billing system's own casing — `STOUTD`, `DIAZG`, `SkinnR`, `morric` `techSet[myTechName] = true` therefore inserted a *lower-cased duplicate*, `t != myTechName` sorted that duplicate to the front as "mine", and the client filter `a.tech === tech` matched **zero** appointments against it. Net effect: the tech opens the mobile board on an **empty column**, with their real appointments sitting elsewhere in the swipe order. It affects **4 of the 5 techs** — every one whose billing name is not already lower-case. Only `morric` escapes, by luck. This has been dormant only because the TechMap has never been populated, so `myTechName` was always empty. It would have surfaced immediately on populating it. ## Also fixed: one tech, two columns Stored appointments contain both `SkinnR` (5 appointments) and `skinnr` (1). Both boards keyed columns on the raw string, so that renders as two columns for the same person. Both now group case-insensitively, keeping the first-seen spelling for display. ## Changes **`server/mobile.go`** - Group techs by lower-cased key with a canonical display spelling - Resolve the viewer's tech to the spelling the appointments actually use, so `MY_TECH` compares equal to entries in `TECHS` - Order with own-first via `strings.EqualFold` - Client-side appointment filter and `isMine` now compare case-insensitively - `MY_TECH` is emitted as the canonical spelling (marshal moved after resolution) **`webapp/src/components/DispatchBoard.tsx`** - `groupByTech` groups case-insensitively and takes the viewer's tech, moving that column to the front - Comment explicitly warns against turning this into a filter ## Verifying after deploy Requires the TechMap to be populated — it is currently empty, so `tech_name` resolves to `""` for everyone and neither board has anything to order by. Once set, check `GET /api/v1/me` returns the expected `tech_name`, then confirm on both boards that the viewer's column is first **and that all other columns are still present**.
myTechName comes from reverseTechMap(), whose keys parsedTechMap()
lower-cases. a.Tech carries the billing system's own casing (STOUTD,
DIAZG, SkinnR, morric). The two were compared directly, so for every
tech whose billing name is not already lower-case the code inserted a
lower-cased duplicate into the tech set, sorted it to the front as
'mine', and then matched zero appointments against it in the client
filter - the tech landed on an empty column with their real one
elsewhere in the swipe order.

This was latent because the TechMap has never been populated, so
myTechName was always empty. It would have surfaced for 4 of 5 techs
the moment it was.

Also groups tech names case-insensitively: the stored appointments
contain both 'SkinnR' and 'skinnr', which rendered as two columns for
one person.
The board already showed every column to everyone; it just ordered them
alphabetically and relied on an auto-scroll to reach your own. Order the
columns with your own first instead, so it is first in the tab strip too.

Also groups tech names case-insensitively - stored appointments contain
both 'SkinnR' and 'skinnr', which rendered as two columns for one person.
Author
Owner

Added the two staleness fixes discussed, on the same branch.

Mobile: the board never updated itself

It is server-rendered with its data embedded in the page — no websocket, no polling, no refresh-on-focus. The only update path was the manual ↻ Refresh button, so a cancelled or rescheduled job stayed on a tech's screen indefinitely. That is how someone ends up driving to a job that was called off hours ago.

Now reloads on two triggers:

  • Returning to the foreground (visibilitychange) — the common case: phone goes in the pocket, comes back out
  • A slow tick while the board sits open — every 2 minutes, checked twice a minute

Never within 20 seconds of a touch, scroll, click or keypress. A full page reload mid-read is worse than slightly stale data, and this board is read while standing at someone's door.

The open column is saved to sessionStorage and restored after reload, so an auto-refresh does not bounce a dispatcher reviewing another tech's day back to their own column.

Web: refetch on websocket reconnect

The board fetched once on mount and then relied entirely on websocket events, with no polling fallback. Any event fired while the socket was down — laptop asleep, network blip, or one of tonight's server restarts — was lost permanently, and the board went quietly stale with nothing on screen to say so.

registerReconnectHandler is now wired through from the plugin registry (optional-chained, so an older server without it simply keeps the previous behaviour). On reconnect it refetches both /me and /appointments.

Refetching /me too is deliberate: it means a TechMap change lands without a page reload — relevant immediately, since the map is about to be populated for the first time and anyone with the board open would otherwise keep the empty-map ordering until they reloaded.

Added the two staleness fixes discussed, on the same branch. ## Mobile: the board never updated itself It is server-rendered with its data embedded in the page — **no websocket, no polling, no refresh-on-focus**. The only update path was the manual `↻ Refresh` button, so a cancelled or rescheduled job stayed on a tech's screen indefinitely. That is how someone ends up driving to a job that was called off hours ago. Now reloads on two triggers: - **Returning to the foreground** (`visibilitychange`) — the common case: phone goes in the pocket, comes back out - **A slow tick** while the board sits open — every 2 minutes, checked twice a minute Never within **20 seconds of a touch, scroll, click or keypress**. A full page reload mid-read is worse than slightly stale data, and this board is read while standing at someone's door. The open column is saved to `sessionStorage` and restored after reload, so an auto-refresh does not bounce a dispatcher reviewing another tech's day back to their own column. ## Web: refetch on websocket reconnect The board fetched once on mount and then relied entirely on websocket events, with no polling fallback. Any event fired while the socket was down — laptop asleep, network blip, or one of tonight's server restarts — was **lost permanently**, and the board went quietly stale with nothing on screen to say so. `registerReconnectHandler` is now wired through from the plugin registry (optional-chained, so an older server without it simply keeps the previous behaviour). On reconnect it refetches both `/me` and `/appointments`. Refetching `/me` too is deliberate: it means a **TechMap change lands without a page reload** — relevant immediately, since the map is about to be populated for the first time and anyone with the board open would otherwise keep the empty-map ordering until they reloaded.
The mobile board is server-rendered with embedded data - no websocket, no
polling - so a cancelled or rescheduled job stayed on screen until the tech
pressed Refresh. Reload on returning to the foreground, and on a slow tick
while the board sits open, but never within 20s of a touch so a reload never
yanks the page mid-read. The open column is remembered in sessionStorage so
an auto-reload does not bounce the reader back to their own column.
The board fetched once on mount and then relied entirely on websocket
events, with no polling fallback. Events fired while the socket was down
were lost permanently and the board went silently stale. Resync on
reconnect, which also picks up plugin config changes such as a TechMap edit.
Author
Owner

Pre-merge verification

This repo builds only on push to main, so nothing verifies a PR. Built the branch by hand on the CI host instead:

go build            exit 0      25,990,324 bytes
tsc --noEmit        exit 0      no type errors
npm run build       exit 0      main.js 23,519 bytes

Confirmed the new logic is actually in the compiled artifacts, not just the source:

Marker Artifact Result
dispatch:selectedTech Go binary PRESENT
visibilitychange Go binary PRESENT
REFRESH_MAX_AGE_MS Go binary PRESENT
30-minute reminder Go binary PRESENT
registerReconnectHandler webapp bundle PRESENT
unregisterReconnectHandler webapp bundle PRESENT

The Go markers matter because the mobile JS lives inside a Go raw string literal — a stray backtick would have silently truncated the template rather than failing the build.

Note: gofmt -l flags api.go, main.go, mobile.go, plugin.go, store.go. That is pre-existing — identical output on main before this branch (several files use spaces rather than tabs). Not introduced here, and not fixed here to keep the diff readable, but worth a separate cleanup plus a CI gofmt check.

## Pre-merge verification This repo builds only on push to `main`, so nothing verifies a PR. Built the branch by hand on the CI host instead: ``` go build exit 0 25,990,324 bytes tsc --noEmit exit 0 no type errors npm run build exit 0 main.js 23,519 bytes ``` Confirmed the new logic is actually in the compiled artifacts, not just the source: | Marker | Artifact | Result | |---|---|---| | `dispatch:selectedTech` | Go binary | PRESENT | | `visibilitychange` | Go binary | PRESENT | | `REFRESH_MAX_AGE_MS` | Go binary | PRESENT | | `30-minute reminder` | Go binary | PRESENT | | `registerReconnectHandler` | webapp bundle | PRESENT | | `unregisterReconnectHandler` | webapp bundle | PRESENT | The Go markers matter because the mobile JS lives inside a Go raw string literal — a stray backtick would have silently truncated the template rather than failing the build. **Note:** `gofmt -l` flags `api.go`, `main.go`, `mobile.go`, `plugin.go`, `store.go`. That is **pre-existing** — identical output on `main` before this branch (several files use spaces rather than tabs). Not introduced here, and not fixed here to keep the diff readable, but worth a separate cleanup plus a CI `gofmt` check.
mohlec merged commit 618be8e8fa into main 2026-08-05 17:49:13 +00:00
mohlec deleted branch feat/own-column-first 2026-08-05 17:49:13 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
slash/slash-dispatch-plugin!6
No description provided.