Rework /employment into a routed master-detail careers page with a globe #867

Closed
opened 2026-07-27 14:01:29 +00:00 by spikerj · 1 comment
Owner

Problem

https://learn.spikersoft.com/employment renders ~33 near-identical cards in flat grids — 19 seeded org roles plus 14 ambassador rows synthesized on the fly from active Locations. Every card inlines its full description and its requirements list, and each of the 7 sections carries a prose blurb, so the page is a wall of words with:

  • no search, no filter, no detail view, and no per-role URL — a role cannot be shared as a link;
  • 14 Ambassador - <Country> cards that differ only by country name (the bulk of the duplication);
  • no geography at all on the 19 org roles — only ambassador rows carry a countryCode. SpikerSoft is going global and the page cannot express that.

Outcome

A routed master–detail careers page:

  • Left: job titles in a listbox (@angular/aria/listbox).
  • Middle: position details, deep-linkable at /employment/:slug so a role can be shared directly.
  • Right (desktop/tablet): a 3D globe — the sponsor-family "About their dream" globe, extracted into a shared lib — showing where SpikerSoft operates and where roles are open.
  • Top: filters by country, work mode, and section.
  • Far less prose: the "most roles are remote, the team is global, everyone is invited to company functions" messaging lives in one subtitle line instead of nine paragraphs.
  • Phones: list → full-screen detail; no globe (three.js and the ~1.3 MB of GeoJSON never download).

Scope

Backend (spikersoft-backend)

  • OpenPosition gains nullable countries: List<string>? (ISO-3; empty = worldwide) and workMode: string? (remote/hybrid/onsite), plus a PositionWorkModes constants class.
    • Nullability is mandatory per the #369/#606 schema-evolution rule — the EF-Mongo shaper rejects a missing element for a non-nullable property, and List<T> is not exempt. A required field here would 500 anonymous GET /api/positions for every /employment visitor.
  • PositionSeederService carries per-role values (11 USA-scoped, 8 worldwide, all remote) and gains a reconcile pass that runs before the toInsert.Count == 0 early return — in prod that early return is always taken, so heal logic appended after it would be dead code.
  • PositionDto gains the two fields appended at the end, with named arguments at the 8 call sites (the record is positional and the adjacent types match, so a mid-record insert would silently re-bind with no compiler error).
  • TryNormalizeGeo validation shared across POST / PUT / PUT {id}/requirements, validated against the active locations collection; changelog entries written only on actual change.
  • [AllowAnonymous] on GetOpenPositions and LocationsController.GetActiveLocations (guards the in-flight FallbackPolicy, #415 / #312).
  • Committed mongodb-scripts/2026-07-add-position-geo.js backfill — prod API replicas run with Seeding__RunOnStartup=false and no workflow re-triggers the bootstrap job.
  • New OpenPositionLegacyDocumentTests (BSON-level, FakeMongoClientWrapper) — the existing controller tests use Moq'd DbSets and structurally cannot catch the nullability trap.

Frontend (spikersoft-angular)

  • New libraries/features/geo-globe (@spikersoft/feature-geo-globe) — the sponsor globe, parameterized: tone + palette instead of the hardcoded shared boolean, opt-in stateCountries (careers fetches 1 file / 254 KB instead of 14 / ~1.3 MB), height, and hintText/unavailableText as plain string inputs so the lib carries no Transloco dependency. Must be a feature lib — it injects HttpClient and imports @spikersoft/domain-geo, both illegal for layer:ui.
  • sponsor-family migrated to the lib in the same change; family-globe.component.* deleted.
  • /employment reworked: shell + :slug child route, aria listbox, filter bar mirrored to query params, detail pane, and the 14 ambassador rows collapsed to one "Country Ambassador" entry whose detail shows a country grid (open vs filled) — clicking a country still applies with that country's real positionId, so the existing application dialog is untouched.
  • PositionService.getOpenPositions() replaced by a @Service() + httpResource() catalog per the workspace's read-as-resources rule; the two admin consumers are untouched.
  • Slugs derive from seedKey ?? slugify(backend English title) — never from the translated title, or links shared from the Spanish site would 404 in English.
  • Existing business.hiring analytics event names preserved.

Notes

  • Ships as two PRs (backend, then frontend). The frontend normalizes countries ?? [] / workMode ?? "remote", so it degrades safely if it deploys first.
  • Follow-ups to file separately: admin editing of countries/workMode in hiring-management; per-country ambassador deep links; the duplicate alpha2→alpha3 table in GeoNameService.

Filed by Opus 5 Agent.

## Problem `https://learn.spikersoft.com/employment` renders **~33 near-identical cards** in flat grids — 19 seeded org roles plus 14 ambassador rows synthesized on the fly from active `Locations`. Every card inlines its full description *and* its requirements list, and each of the 7 sections carries a prose blurb, so the page is a wall of words with: - no search, no filter, no detail view, and **no per-role URL** — a role cannot be shared as a link; - 14 `Ambassador - <Country>` cards that differ only by country name (the bulk of the duplication); - **no geography at all on the 19 org roles** — only ambassador rows carry a `countryCode`. SpikerSoft is going global and the page cannot express that. ## Outcome A routed master–detail careers page: - **Left:** job titles in a listbox (`@angular/aria/listbox`). - **Middle:** position details, deep-linkable at `/employment/:slug` so a role can be shared directly. - **Right (desktop/tablet):** a 3D globe — the sponsor-family "About their dream" globe, extracted into a shared lib — showing where SpikerSoft operates and where roles are open. - **Top:** filters by country, work mode, and section. - Far less prose: the "most roles are remote, the team is global, everyone is invited to company functions" messaging lives in one subtitle line instead of nine paragraphs. - **Phones:** list → full-screen detail; no globe (three.js and the ~1.3 MB of GeoJSON never download). ## Scope ### Backend (`spikersoft-backend`) - `OpenPosition` gains **nullable** `countries: List<string>?` (ISO-3; empty = worldwide) and `workMode: string?` (`remote`/`hybrid`/`onsite`), plus a `PositionWorkModes` constants class. - Nullability is mandatory per the #369/#606 schema-evolution rule — the EF-Mongo shaper rejects a missing element for a non-nullable property, and `List<T>` is not exempt. A required field here would 500 anonymous `GET /api/positions` for every `/employment` visitor. - `PositionSeederService` carries per-role values (11 USA-scoped, 8 worldwide, all `remote`) and gains a **reconcile pass that runs before the `toInsert.Count == 0` early return** — in prod that early return is always taken, so heal logic appended after it would be dead code. - `PositionDto` gains the two fields **appended at the end**, with named arguments at the 8 call sites (the record is positional and the adjacent types match, so a mid-record insert would silently re-bind with no compiler error). - `TryNormalizeGeo` validation shared across `POST` / `PUT` / `PUT {id}/requirements`, validated against the active `locations` collection; changelog entries written only on actual change. - `[AllowAnonymous]` on `GetOpenPositions` and `LocationsController.GetActiveLocations` (guards the in-flight `FallbackPolicy`, #415 / #312). - Committed `mongodb-scripts/2026-07-add-position-geo.js` backfill — prod API replicas run with `Seeding__RunOnStartup=false` and no workflow re-triggers the bootstrap job. - New `OpenPositionLegacyDocumentTests` (BSON-level, `FakeMongoClientWrapper`) — the existing controller tests use Moq'd `DbSet`s and structurally cannot catch the nullability trap. ### Frontend (`spikersoft-angular`) - New `libraries/features/geo-globe` (`@spikersoft/feature-geo-globe`) — the sponsor globe, parameterized: `tone` + palette instead of the hardcoded `shared` boolean, opt-in `stateCountries` (careers fetches 1 file / 254 KB instead of 14 / ~1.3 MB), `height`, and `hintText`/`unavailableText` as plain string inputs so the lib carries no Transloco dependency. Must be a `feature` lib — it injects `HttpClient` and imports `@spikersoft/domain-geo`, both illegal for `layer:ui`. - `sponsor-family` migrated to the lib in the same change; `family-globe.component.*` deleted. - `/employment` reworked: shell + `:slug` child route, aria listbox, filter bar mirrored to query params, detail pane, and the 14 ambassador rows collapsed to **one "Country Ambassador" entry** whose detail shows a country grid (open vs filled) — clicking a country still applies with that country's real `positionId`, so the existing application dialog is untouched. - `PositionService.getOpenPositions()` replaced by a `@Service()` + `httpResource()` catalog per the workspace's read-as-resources rule; the two admin consumers are untouched. - Slugs derive from `seedKey ?? slugify(backend English title)` — never from the translated title, or links shared from the Spanish site would 404 in English. - Existing `business.hiring` analytics event names preserved. ## Notes - Ships as two PRs (backend, then frontend). The frontend normalizes `countries ?? []` / `workMode ?? "remote"`, so it degrades safely if it deploys first. - Follow-ups to file separately: admin editing of `countries`/`workMode` in hiring-management; per-country ambassador deep links; the duplicate alpha2→alpha3 table in `GeoNameService`. Filed by Opus 5 Agent.
Author
Owner

Resolved in spikersoft-backend PR #492 (6430e271) + spikersoft-angular PR #588 (72808f7d), both merged to master. Verified against origin/master:

Backend

  • SpikerSoft.Data/Mongos/OpenPosition.cs:58-68 — nullable Countries / WorkMode; PositionWorkModes.cs added.
  • PositionSeederService.cs:165ReconcileGeoFields is called before the :189 toInsert.Count == 0 early return, so existing deployments actually get backfilled rather than skipping.
  • PositionsController.cs:396 — shared NormalizeGeoAsync used by POST/PUT/requirements (:196,232,287), with changelog-on-change at :446-457.
  • Anonymous access: [AllowAnonymous] at PositionsController.cs:21 and LocationsController.cs:40.
  • Migration mongodb-scripts/2026-07-add-position-geo.js; legacy-document test OpenPositionLegacyDocumentTests.cs.

Frontend

  • Globe extracted to libraries/features/geo-globe (@spikersoft/feature-geo-globe); the old family-globe.component.* is deleted and sponsor-family.component.ts:16 now consumes the lib — no duplicate globe left behind.
  • Routed master-detail: projects/spikersoft/src/routes.ts:560-569 shell + :slug child.
  • ARIA listbox via primitives at employment-list.component.ts:2,18 (matches the @angular/aria rule).
  • Filters mirrored to query params employment.component.ts:123-154; ambassador collapse in employment-detail/ambassador-countries.component.ts:8.
  • position-catalog.service.ts:17 uses httpResource per the resource-APIs rule.
  • Globe is @defer (on viewport) inside a desktop-only @if (employment.component.html:65-85), so mobile never pays for it.

Closing.

Resolved in spikersoft-backend PR #492 (`6430e271`) + spikersoft-angular PR #588 (`72808f7d`), both merged to `master`. Verified against `origin/master`: **Backend** - `SpikerSoft.Data/Mongos/OpenPosition.cs:58-68` — nullable `Countries` / `WorkMode`; `PositionWorkModes.cs` added. - `PositionSeederService.cs:165` — `ReconcileGeoFields` is called **before** the `:189` `toInsert.Count == 0` early return, so existing deployments actually get backfilled rather than skipping. - `PositionsController.cs:396` — shared `NormalizeGeoAsync` used by POST/PUT/requirements (`:196,232,287`), with changelog-on-change at `:446-457`. - Anonymous access: `[AllowAnonymous]` at `PositionsController.cs:21` and `LocationsController.cs:40`. - Migration `mongodb-scripts/2026-07-add-position-geo.js`; legacy-document test `OpenPositionLegacyDocumentTests.cs`. **Frontend** - Globe extracted to `libraries/features/geo-globe` (`@spikersoft/feature-geo-globe`); the old `family-globe.component.*` is deleted and `sponsor-family.component.ts:16` now consumes the lib — no duplicate globe left behind. - Routed master-detail: `projects/spikersoft/src/routes.ts:560-569` shell + `:slug` child. - ARIA listbox via primitives at `employment-list.component.ts:2,18` (matches the `@angular/aria` rule). - Filters mirrored to query params `employment.component.ts:123-154`; ambassador collapse in `employment-detail/ambassador-countries.component.ts:8`. - `position-catalog.service.ts:17` uses `httpResource` per the resource-APIs rule. - Globe is `@defer (on viewport)` inside a desktop-only `@if` (`employment.component.html:65-85`), so mobile never pays for it. Closing.
Sign in to join this conversation.