Skip to content

[UX] customer-portal — Partner service orders ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 31 Patterns: data-display/filter-panel

Summary ​

This is the service partner's whole working surface — the list of jobs assigned to their resource and the screen where they confirm, schedule and complete each one — and it is built to the repo's own conventions almost everywhere (PageLayout + DataTable shells, useBackNavigation on desktop, purpose-built mobile detail view, full en/nb key parity for the entire partnerOrders: block). Three things are wrong enough to matter: the list renders a search box on both breakpoints that is wired to nothing at all, a document delete is one un-confirmed tap from permanent destruction, and any booking whose status is outside the four-step new → confirmed → planned → completed lifecycle renders an action card with no action and no explanation.

The two checks from the assignment, answered explicitly:

  • Are the filters scoped to one page while the count claims the whole data set?No — this feature is clean on that count. Traced end to end: statusFilter (PartnerServiceOrdersPage.vue:48-51) → usePartnerOrdersList filters getter (partner-orders.composable.ts:16) → normalizeFilters (create-domain-helpers.ts:101) → fetchPage query.status (partner-orders.collection.ts:11) → controller @Query("status") (service-partners.controller.ts:57) → extra.bookingStatus = { slug: status } (service-partners.service.ts:48-50) → merged into the CASL where. The repository runs findMany and count against the same combined where clause (service-partners.repository.prisma.ts:102-115), so total, totalPages and the rows all describe the filtered set. Pagination is server-side (skip/take, 25/page). The sibling project's defect is not present here.
  • Do all the list controls reach the API? The status filter does; the search box does not. See finding 1 — the backend implements search (repository.prisma.ts:92-100, matching work-order name, order title and machine name), the composable exposes a search ref, and the page never connects the two.

Already filed project-wide, not re-reported here (see PROJECT-LEVEL.md): NAV-03 — neither partner-orders.index nor partner-orders.detail sets a document title; no mechanism exists. List state lost on Back — page and statusFilter are component-local refs with no URL sync and no <KeepAlive>, so returning from an order resets the list to page 1, status "All". Shared DataTable defects — no live region on result changes, isFetching never surfaced, desktop search input unlabelled, hardcoded-English pagination strip with unnamed arrows: see drafts/customer-portal--catalog.md findings 4–7, all of which apply verbatim to this page. MSG-02 (getErrorMessage prefers raw backend strings) is on this feature's path via apiFetch (app/http.ts:22) for document upload/delete, but is helper-level and already filed.

packages/ui and packages/domain-helpers are in-repo workspace packages, so every claim below about DataTable, PageLayout, MobileListHeader, CompactSearch, OptionPickerSheet and createDomainHelpers is read from source, not inferred. Only PrimeVue internals are unverifiable.

Findings ​

1. The list's search box is wired to nothing — typing in it can never change the results — High · proposed FORM-INERT-CONTROL (merge with the existing FORM-12(c) proposal) ​

Where: apps/web/src/pages/PartnerServiceOrdersPage.vue:118-134 (the <DataTable> binding), apps/web/src/domains/partner-orders/partner-orders.composable.ts:24 (search returned but never consumed), packages/ui/src/components/DataTable.vue:115-123 and :199-205What: The page binds v-model:filter-values, :page, :total, :total-pages and @update:page — but not :search and not @update:search. DataTable renders a search field unconditionally on both breakpoints regardless: desktop InputText at DataTable.vue:415-422, and on mobile a CompactSearch magnifier because MobileListHeader is passed :searchable="true" hard-coded at DataTable.vue:335. With props.search undefined, searchValue (DataTable.vue:117-123) falls back to the component-local internalSearch ref and emits update:search into the void. And because :page/:total-pages are present, isServerPaginated is true, so filteredData (:199-205) returns tableDataunfiltered — the client-side fallback filter is deliberately skipped too. The result is that the input has no effect on any code path: not on the request, not on the rendered rows. The API supports search fully (service-partners.repository.prisma.ts:92-100), the debounce machinery exists (create-domain-helpers.ts:87-97), and en/nb both ship partnerOrders.searchPlaceholder (en.yml:1364 / nb.yml:1364) which is also never passed — so the field shows the English literal "Search" (DataTable.vue:420) to a Norwegian partner. Why it matters: A service partner's normal task is "find AO-12345" or "find the job on that Manitou". They tap the magnifier, type the number, and the list sits there showing all 25 rows of page 1 with no error, no empty state and no spinner — the strongest possible signal that the result is the answer. Because there is no result-count feedback either (the count only appears inside the pagination strip, and only when totalPages > 1, DataTable.vue:660-669), nothing contradicts the impression. The data-display/filter-panel anatomy lists "Apply or reset controls" and "Result count feedback" as required parts; this panel has a control that does not apply and no count. Severity call: High, not Blocker — the status filter and pagination work, so every order remains reachable by scrolling, and the feature is not unusable. High rather than Medium because the failure is silent and indistinguishable from a genuine "no such order", which is worse than a control that visibly errors. Fix: One line on the page — destructure search from usePartnerOrdersList() (it is already returned) and bind v-model:search="search" plus :search-placeholder="t('partnerOrders.searchPlaceholder')" and :search-label="t('partnerOrders.searchPlaceholder')". Separately, harden the shared component: DataTable should not render a search affordance when no search prop is bound and serverSearch/isServerPaginated disables its internal filtering — an unbound search box should be impossible to ship.

2. Deleting a document is irreversible, un-confirmed and one tap away — High · MSG-05 ​

Where: apps/web/src/components/partner/PartnerDocumentsSection.vue:52-59 (handler) and :115-123 (the trash button) What: The trash Button calls remove(doc.id) directly on click. There is no ConfirmDialog, no "are you sure", no undo, and no name of the file in any confirmation. The backend path is a real delete, not a soft one: ServicePartnersService.deleteDocument → documents.deletePublic(doc) (service-partners.service.ts:257-272), which removes the stored object and the row. The button sits inside a DocumentTile whose whole surface is a download link, at size="small" on a mobile list of tiles. Elsewhere in this repo the same class of action is guarded — the skills feature ships skills.detail.confirmDeleteHeader / confirmDeleteSkill copy for exactly this (en.yml:1556-1559). Why it matters: The documents a partner attaches are service reports and photos of completed work — the evidence the customer is invoiced against. A mis-tap on a phone destroys one permanently with no dialog, no undo and no server-side recovery. Only portal-uploaded documents are deletable (:267), which is a good guard against destroying synced records but does nothing for the partner's own uploads. Severity call: High, not Blocker — the feature works as coded; the harm needs the user to tap the wrong thing. But it is unrecoverable data loss with zero friction in front of it, which is the definition of the High band here. Fix: Route the delete through PrimeVue's useConfirm/ConfirmDialog (already a dependency and used elsewhere in the app), naming the file in the message, with the destructive button styled severity="danger". Add partnerOrders.detail.documents.confirmDelete* keys to both locales.

3. A booking in any status outside the four-step lifecycle shows an action card with no action and no explanation — High · MSG-04 ​

Where: apps/web/src/pages/PartnerServiceOrderDetailPage.vue:435-493 (desktop) and apps/web/src/components/mobile/PartnerBookingDetailMobile.vue:170-230 (mobile) What: status is booking.bookingStatus ?? "new" (:46) — the raw Dataverse status slug. The primary-action block is a v-if chain over exactly four values: new → Confirm, confirmed → Plan, planned → Complete, completed → the "this order is completed" banner. There is no v-else. Any other slug falls through the entire chain and renders nothing: the partner sees the "Status & next step" heading, a read-only appointment summary (reached via the v-else-if="status !== 'new'" branch at :401), and then empty space. The schedule fields are hidden too, because showSchedule (:178) is likewise limited to confirmed/planned. The status vocabulary is not limited to four values: the app's own locale files enumerate sixteen booking-status slugs — ongoing, waiting, planned-agreed, denied, cancelled, assigned, travel, … — in both locales (en.yml:971-988, nb.yml:971-988), and bookingStatus.slug is synced straight from Dataverse (booking-status.profile.ts:23), so the frontend does not control which of them arrive. Related: the service's own comment says the notification covers the partner's "confirm/deny" response (service-partners.service.ts:163-164, email subject "Partner {status} a booking", email.service.ts:336-339), yet the UI exposes no decline/deny control at any status — a partner who cannot take the job has no way to say so in the portal. Why it matters: The action card is the only reason the detail page exists for a partner. If a dispatcher parks a booking in waiting, or a previous decline left it denied, the partner opens it, finds a heading and blank space, and has no way to tell whether the app is broken, still loading, or deliberately locked — the read-only badge at :495-501 only renders when canEdit is false, so a partner with edit rights gets no message at all. Severity call: High. It is a genuine dead end with no route out (MSG-04) for the affected bookings. Not Blocker because I cannot confirm from this repo how often partner-assigned bookings carry a status outside the four — that depends on the Dataverse configuration, which is not readable here. The code path itself is certain. Fix: Add the missing v-else to both the desktop and mobile chains: show the current status label plus one sentence naming who moves it next (partnerOrders.detail.flow.noActionForStatus). Separately, decide whether the partner should be able to decline; if yes, add a secondary "Decline order" action on new that transitions to denied (the mutation and the notification already support any slug).

4. Every failure on this feature renders as English boilerplate, a wrong "not found", or an empty state — Medium · MSG-03, MSG-06(b), CONTENT-01 ​

Where: three spots. What:

  • List (PartnerServiceOrdersPage.vue:118-134): :is-error is passed but :error-message, :forbidden-message and :status are not. DataTable therefore falls back to its hardcoded English defaults — "Failed to load data." (DataTable.vue:274) — and because statusValue is null the 403 branch (:271-273) is unreachable and errorStateDescription is undefined. A Norwegian partner whose session or network drops reads an English sentence with no status, no retry button and no explanation. usePartnerOrdersList does not even expose status, though useList returns it (create-domain-helpers.ts:135).
  • Detail (PartnerServiceOrderDetailPage.vue:237-242, :297-302): the branch is v-else-if="isError || !booking" and the body is t("partnerOrders.detail.notFound") — "Fant ikke serviceoppdraget." A 500, an offline fetch, a 403 and a genuine 404 all tell the partner the order does not exist. There is no retry and no link back to the list on the desktop branch (the Back button is inside the v-else success branch at :304-316, so the error state has no navigation at all).
  • Documents (PartnerDocumentsSection.vue:94-134): usePartnerDocumentList returns isError and it is never destructured. The template is isLoading → documents?.length → else empty. A failed document fetch renders "Ingen dokumenter er lastet opp ennå" — a failed load presented as a confirmed fact. Why it matters: The documents case is the sharpest: a partner who uploaded a service report last week opens the order, sees "no documents uploaded yet", and uploads it again — or concludes the portal lost it. This is the customer-portal instance of the cross-project MSG-06 theme. Fix: Pass :error-message="t('partnerOrders.error', { status })" and :status on the list (add the key to both locales, mirroring bookings.error); split the detail branch on 404 vs everything else and give the non-404 case a Retry wired to the query's refetch; add an error branch to the documents section that offers Retry instead of the empty state.

5. The booking status is the one label on this feature that bypasses the app's status translations — Medium · CONTENT-01 ​

Where: PartnerServiceOrdersPage.vue:99 (table Tag), :150 (card pill), PartnerServiceOrderDetailPage.vue:331-334 (detail chip), PartnerBookingDetailMobile.vue:78-83 (mobile chip) What: All four render row.bookingStatusName ?? row.bookingStatus. bookingStatusName is the raw Dataverse BookingStatus.name (service-partners.dto.ts:47, :171) — a single-language value from the system of record — and the fallback is the bare slug ("planned-agreed"). Every other booking-status surface in the app translates it: ServiceOverviewPage.vue:53-54, BookingDetailPage.vue:35, :597, :634, AssetServiceHistorySection.vue:217 all use t("bookings.status.${slug}", slug), and those keys exist complete in both locales (en.yml:971-988 / nb.yml:971-988). This page's own status filter options use the translated keys (PartnerServiceOrdersPage.vue:36-39) — so the filter and the rows it filters are labelled from two different vocabularies. Why it matters: An English-locale partner reads Norwegian status names on every row while the filter dropdown above them is in English; picking "Completed" returns rows tagged "Fullført". Whenever Dataverse has no name the user sees the raw slug. Fix: Replace all four with t(\bookings.status.${row.bookingStatus}`, row.bookingStatusName ?? row.bookingStatus)` — the same call the rest of the app already makes, with the Dataverse name kept as the fallback.

6. The "Agreed appointment" column drops the time of day — Medium · proposed DATA-PRECISION (same family as the currency-precision collision) ​

Where: PartnerServiceOrdersPage.vue:88 (table cell) and :181 (card footer) What: Both call formatDate(row.startTime) from useDateFormatter() invoked with no options (:19), which resolves to dateStyle: "medium" and no timeStyle (packages/ui useDateFormatter.ts:30-31, :52). The rendered value is "15. jul. 2026" for a column headed partnerOrders.columns.appointment — "Avtalt oppmøte" / "Agreed appointment". The detail page, for the same field, formats weekday + date and a separate hour:minute (PartnerServiceOrderDetailPage.vue:71-81), and formatDateTime exists unused on the same composable (useDateFormatter.ts:55). Why it matters: The time is the operative fact of a service appointment. A partner planning their day sees four jobs all reading "15. jul. 2026" and must open each one to find out which is at 08:00 and which at 15:00 — on the list view whose entire purpose is that scan. Same shape as the members currency defect: a correct shared formatter exists and this surface uses a variant that discards what the user needs. Fix: useDateFormatter({ timeStyle: "short" }) on this page, or call formatDateTime. Both card and table cell.

7. Unsaved edits on the detail page are discarded without warning — Medium · proposed FORM-12(d) (already logged as a collision in PROJECT-LEVEL.md) ​

Where: PartnerServiceOrderDetailPage.vue:128-138 (watch(booking, hydrateForm)) and :40 / MobileDetailHeader.vue:28-34 (back handlers) What: Two vectors onto the same gap. (a) hydrateForm is a watcher on the query result, so any change to the server object overwrites the in-progress form — including partnerSummary, the long free-text field a partner types on site. The query client sets no defaultOptions (app/query-client.ts, packages/ui createAppQueryClient.ts:18-25), so TanStack's defaults apply: refetchOnWindowFocus: true, staleTime: 0. Structural sharing means an identical payload keeps the same object reference and does not trigger the watcher, so this only bites when the booking genuinely changes server-side — but that is precisely what the Dataverse sync pipeline does to these rows. (b) Nothing guards navigation: no onBeforeRouteLeave, no beforeunload. Tapping Back with a half-written summary discards it silently. The save affordance itself is dirty-gated (summaryDirty, :190), so the app knows the state is unsaved and still says nothing. Why it matters: The summary is written in the field, often on a phone, often between other apps (camera, maps — the page links out to Google Maps at :648-657, which backgrounds the app and re-triggers focus refetch on return). Losing it means retyping the whole visit report. Severity call: Medium rather than High because vector (a) needs a concurrent server-side change to fire, and (b) is recoverable by retyping. Fix: Hydrate once (watch(booking, hydrateForm, { immediate: true, once: true }) or hydrate only when the form is pristine), and add an onBeforeRouteLeave guard that confirms when summaryDirty || scheduleDirty.

8. The mobile detail page hand-rolls its header: no <h1>, and "Back" pushes a new history entry instead of going back — Medium · A11Y-04, repo convention (apps/web/CLAUDE.md) ​

Where: apps/web/src/components/mobile/MobileDetailHeader.vue:53-66 and :28-34, used at PartnerBookingDetailMobile.vue:68-73What: Two defects in the shell this page chose. (a) The title renders as <p class="truncate text-[15px] font-bold"> — there is no <h1> anywhere on the mobile detail page, and the first heading in the document is the <h2> "Status & next step" at PartnerBookingDetailMobile.vue:121. The repo's two sanctioned shells both provide one (PageLayout.vue:103, MobileListHeader.vue:50), and apps/web/CLAUDE.md says so explicitly: "These are the ONLY two page shells — never hand-roll a page header". The desktop branch of the same page uses PageLayout and does get its <h1>, so the heading outline changes with viewport width. (b) goBack() does router.push({ name: backTo }) — an ordinary forward navigation. The desktop branch of the same page uses the history-aware useBackNavigation (PartnerServiceOrderDetailPage.vue:40), which apps/web/CLAUDE.md mandates: "Never hand-roll back buttons or hardcode router.push(<parent>) as 'back'". Why it matters: (a) A screen-reader user navigating the mobile page by headings finds no page title and a document that starts at level 2. (b) Every list → detail → "back" round trip adds two entries to history, so the Android hardware Back button and the Capacitor wrapper's back gesture retrace the entire session instead of leaving the section — and a partner who opened the order from a notification link is pushed to the list rather than returned to where they were. Fix: Render the title as <h1> in MobileDetailHeader (keeping the current type scale), and replace goBack with useBackNavigation({ name: backTo }). Both are fixes in shared components used by other mobile detail pages, so they pay off beyond this feature.

9. The filter and search chrome is untranslated even though the keys exist — Medium · CONTENT-01, FORM-01 ​

Where: PartnerServiceOrdersPage.vue:118-134 (props not passed), with the resulting defaults at DataTable.vue:420, CompactSearch.vue:16 and OptionPickerSheet.vue:23-24What: The page passes none of :search-placeholder, :search-label, :apply-label, :clear-label, so the shared components fall back to English string literals baked into packages/ui: the desktop search placeholder is "Search", the mobile magnifier's aria-label is "Search", and the mobile filter sheet's two buttons read "Apply" and "Clear". partnerOrders.searchPlaceholder exists and is translated in both locales (en.yml:1364 / nb.yml:1364) and is simply never used. The desktop search input additionally has no label of any kind — no <label>, no aria-label, not type="search" (DataTable.vue:417-421); the desktop filter Select is likewise placeholder-only (:401-411). Why it matters: The partner-facing app is Norwegian-first (nb), and this is a Norwegian page with three English controls sitting in the middle of its filter bar. A screen-reader user reaches an unnamed text box and an unnamed combobox. Note the distinction from the shared-component defect already filed against the catalog: the component should default better, but here the page also declines to pass values it already has. Fix: Pass the four label props from this page now; separately (shared fix) make DataTable bind :aria-label="searchLabel ?? searchPlaceholder" and type="search" on the desktop input and :aria-label="filter.label" on each Select, and stop defaulting user-visible strings to English inside packages/ui.

10. Editable fields on the detail page have no programmatically associated label, and the note field has no label at all — Medium · FORM-01 ​

Where: apps/web/src/components/fields/ResponsiveNoteField.vue:64-74, ResponsiveDateTimeField.vue:60-70, ResponsiveDurationField.vue:58-64; call sites at PartnerServiceOrderDetailPage.vue:387-396, :533-537 and PartnerBookingDetailMobile.vue:131-141, :270-275What: All three components render <label class="…">{{ label }}</label> with no for attribute and without wrapping the control, so nothing associates the text with the PrimeVue DatePicker / InputNumber / Textarea beside it. Worse, both call sites of ResponsiveNoteField — the partner's work summary, the one field they actually write prose into — omit label entirely, and the prop defaults to "" (:26), so the v-if suppresses the element: the desktop Textarea has only a placeholder. On mobile the equivalent row is MobileFieldRow, whose label is also empty for the note, leaving a button whose accessible name is the truncated preview of the value. These are in-repo components, so this is asserted from source, not inferred from PrimeVue. Why it matters: A screen-reader user tabbing the action card hears "edit text, Legg til et kort sammendrag…" — the placeholder — which disappears the moment they type, and hears the appointment and duration fields as unnamed controls. FORM-01 is explicit that a placeholder is never the only label. Fix: Give each component a generated id (useId()) bound to both the control and <label for>, or wrap the control in the <label>. Pass :label="t('partnerOrders.detail.booking.partnerSummary')" at both ResponsiveNoteField call sites (visually hidden if the card heading is meant to carry it), and pass a label to MobileFieldRow for the note row.

11. Smaller defects — Low · A11Y-04, A11Y-03, dead code ​

Where: four spots. What:

  • PartnerServiceOrdersPage.vue:62-103 — the seven columnHelper.display column definitions are dead. The page passes card-only, so DataTable renders the card grid at every breakpoint (DataTable.vue:565) and the table branch is never reached; the columns survive only to shape the loading skeleton. Their partnerOrders.columns.* headers are never displayed, and they are computed once at setup so they would not react to a locale switch if they were. Either delete them or drop card-only.
  • PartnerServiceOrdersPage.vue:155 — each card's title is an <h3> directly under the page <h1>, skipping level 2, on every card in the list.
  • MobileFieldRow.vue:27-32 — when disabled the element is still a <button> in the tab order with a click handler that no-ops; only the chevron is hidden. A read-only partner tabs onto controls that appear actionable and do nothing. Use :disabled="disabled" on the button (or render a non-interactive row).
  • PartnerServiceOrdersPage.vue:36-39 — the filter option labels reuse the singular status keys ("New", "Confirmed") while the fallbacks written next to them ("Nye", "Bekreftede") show the intent was the plural section names from the legacy view. Cosmetic, but the fallbacks are dead and misleading — the keys all resolve.

Unverified ​

  • A11Y-01 (contrast). Not assessable from code. Highest-risk spots for whoever runs a tool: the text-[11px] uppercase text-text-3 metadata labels used ~12 times across the detail page (e.g. PartnerServiceOrderDetailPage.vue:346, :406, :571), the text-xs text-text-3 AO-number overline on each card (PartnerServiceOrdersPage.vue:143), and the bg-status-info-bg / text-status-info-fg status pill.
  • A11Y-06 (short viewport / responsive). The mobile detail page stacks a sticky header, six or more cards, and a sticky action bar with pb-[calc(150px+env(safe-area-inset-bottom))] clearance (PartnerBookingDetailMobile.vue:66); whether the save bar and the bottom tab bar both stay clear of the note editor sheet at ~700px height needs a rendered check at 320/360/390px, as apps/web/CLAUDE.md requires.
  • PrimeVue internals (node_modules not installed). Three claims I could not settle: whether Button :loading sets the native disabled attribute — if it does, every primary action here (Confirm / Plan / Complete / Save / Upload) drops focus to <body> mid-submission, which would be a FORM-06 failure on this feature; whether Tag and Select expose accessible names without explicit ARIA; and whether Textarea/InputNumber/DatePicker generate an id that a for attribute could target (bears on finding 10's fix, not its existence).
  • Which booking-status slugs actually reach partner bookings (finding 3). The slug set comes from Dataverse configuration, which is outside this repo. The fall-through code path is certain; its frequency is not.
  • Whether the status filter's four slugs match the synced BookingStatus.slug values. "new" is confirmed in use (booking.handler.ts:29); confirmed, planned and completed appear in the locale files and in the update flow, but the authoritative list lives in the aa_slug column in Dataverse (booking-status.profile.ts:23). If any differ, the corresponding filter option silently returns zero rows.

Baseline additions ​

  • FORM-INERT-CONTROL — A control the user can operate must reach the code that acts on it; a filter, search or sort affordance that no handler consumes is never rendered. (Finding 1. Merge with the existing FORM-12(c) proposal — "inert controls (filters that never reach the API)" — same rule, and this is a second independent instance in a second project.)
  • DATA-PRECISION — A shared value formatter is called with the precision the labelled field promises; a surface may not silently discard a component of the value (time-of-day, decimal places, unit) that another surface shows. (Finding 6. Same family as the CONTENT-05(a) currency-precision collision and the catalog's DATA-RAW-CODE proposal — recommend the orchestrator folds all three into one rule about shared value formatters, with three worked examples.)

Findings 2, 3, 4, 5, 7, 8, 9, 10 and 11 need no new rule: MSG-03, MSG-04, MSG-05, CONTENT-01, FORM-01, A11Y-03, A11Y-04 and the already-proposed FORM-12(d) and MSG-06 cover them as written.

Cross-project note ​

  • Finding 1 (inert control) is the second confirmed instance in this programme — the assignment flagged it in a sibling project, and here it is again in a different shape (bound-to-nothing rather than filtered-client-side). Both projects use TanStack + a shared table wrapper. Worth a coordinated sweep of every list view in all four projects for search/filter inputs whose emitted event has no listener; it is mechanically greppable (<DataTable / <Table without v-model:search).
  • Finding 2 (unconfirmed destructive action) — playout, members and tt-time-tracker all have delete affordances (custom songs/overlays, décharges, time entries, invoices). MSG-05 has been raised per-feature so far; a four-project inventory of "delete without confirm" would probably be one table.
  • Finding 4 (failed load rendered as an empty state) is the customer-portal instance of the MSG-06 theme already confirmed in members (DonsProgress.vue) and tt-time-tracker (worker screens). All four projects now have at least one instance; it is ready to be promoted from "proposed" to a baseline rule.
  • Finding 5 (a shared translation helper bypassed on one surface) rhymes exactly with the catalog's DATA-RAW-CODE finding in the same project — same page family, same root cause, different field. In this repo the pattern now has three instances (capacity codes, category labels, booking-status names), which suggests the fix belongs at the DTO/response boundary rather than in each view.
  • Finding 8(b) (Back implemented as a forward push) is worth checking in playout and tt-time-tracker, both of which have detail pages reached from lists; customer-portal has the correct helper (useBackNavigation) and one mobile component that bypasses it, which is the easiest kind of drift to accumulate.