Drain the long-tail audit queue captured in alpha-uat-master.md.
- next-intl ripped out (zero useTranslations callers ever existed):
package.json, next.config.ts plugin wrap, src/i18n/, messages/, and
the layout NextIntlClientProvider all gone; <html lang="en"> hardcoded.
- RTL lint nudge added: warn-only no-restricted-syntax on physical
Tailwind utilities (ml-/mr-/pl-/pr-/text-left/text-right/border-l/
border-r/rounded-l-/rounded-r-) inside JSX className literals.
Existing ~1,000 sites grandfathered; new code trends toward logical.
- Icon-only button accessibility lint: jsx-a11y/control-has-associated-
label enabled at warn; 4 empty <th>/<td> action placeholders gain
sr-only labels.
- Currency: SUPPORTED_CURRENCIES drops the hardcoded English labels;
new currencyLabel(code, locale?) helper resolves via Intl.DisplayNames.
CurrencySelect + settings-manager migrated.
- Date locale sweep: 7 surfaces flip from toLocaleString('en-GB'|'en-US')
to toLocaleString(undefined, ...) so dates honour runtime locale.
- Dialog/Sheet width: 10 document/EOI/entity-form dialogs gain a
lg:max-w-4xl or lg:max-w-5xl step so wide desktops get breathing room.
- PaymentsSection collapsed-bar: slim one-line bar showing
"Payments - Not received yet" or "Payments - \$X received - N payments
- Expand"; per-interest collapse state persists in localStorage; the
RecordPayment flow auto-expands.
- muted-foreground opacity sweep: 10 text-bearing
text-muted-foreground/{60,70,80} hits dropped to plain
text-muted-foreground for AA contrast on muted bg. Icon-only
(aria-hidden) opacity hits left as-is.
- Micro-type bump: text-[10px] and text-[11px] -> text-xs (12px)
across 87 files in src/components + src/app. Pure mechanical sweep.
- Audit-doc cleanup: alpha-uat-master.md stale 2026-05-25 summary
rewritten with cumulative state through today. Items genuinely still
open are now a short long-tail list.
- New docs/marketing-site-followups.md: Umami Phase 4a/3/5, email
pixel E2E verification, and website-cutover work parked here so
they don't get lost in the CRM audit doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Webhook auto-create on signed Reservation Agreement was gating itself on
isTenanciesModuleEnabled, but autoCreatePendingTenancies never enabled
the module — so the very first tenancy on a fresh port was unreachable
even though the row-exists fallback in isTenanciesModuleEnabled was
designed exactly for this lazy auto-surface case. Drop the gate; the
inserted row now flips the module on automatically via the fallback.
docs/tenancies-design.md §"When disabled" and the P3 PR-table row
updated to reflect the new contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Interest Documents tab on the berth detail page listed deal docs
read-only with only an "Open" link to the interest detail page —
forced reps to navigate away just to see the PDF. Now every row whose
backing PDF exists opens the existing FilePreviewDialog inline.
- Service: listDealDocumentsForBerth now joins files and returns
fileId (COALESCE(signedFileId, fileId) so completed envelopes
prefer the signed PDF), fileName, mimeType. Drafts without a blob
yet still appear, just non-clickable.
- UI: row title area is a button that triggers FilePreviewDialog;
Eye affordance on hover. Falls back to a "no file yet" hint when
the document has no backing blob. "Open" link stays as the
secondary "go to interest" action.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
useBerthPatch invalidated ['berths', berthId] (plural+id), but the
berth detail page reads ['berth', berthId] (singular+id). Cache key
never matched, so the PATCH landed in the DB but the visible field
reverted to its pre-edit value on re-render. Realtime invalidation
covered for it via 'berth:updated', but Socket.IO is unavailable
in some dev environments.
Switch to the correct singular key + keep the plural-list invalidation
so list views (BerthList, bulk-edit sheet) also refresh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the UI half of the bulk-price feature (backend already shipped).
Reps with berths.update_prices can retune pricing without unlocking the
rest of the berth schema, both one-at-a-time and in bulk.
- berth-columns: PriceCell wraps InlineEditableField, gated by
can('berths', 'update_prices'). Click → input → save through
PATCH /api/v1/berths/[id]/price. stopPropagation so row click
doesn't navigate while editing.
- bulk-price-edit-sheet: right-side Sheet listing selected berths from
the React Query cache. Per-row price + currency inputs with dirty-
highlight. "Set all to" + "Adjust by %" shortcuts. Diff-only POST to
/bulk-update-prices reports updated/unchanged/missing. Body is keyed
on the selection so useState initializes fresh per open.
- berth-list: new "Update prices" bulk action gated by the same
permission, sits between Remove tag and Archive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per docs/superpowers/audits/alpha-uat-master.md Bucket 3 #1. When a
yacht is linked to the interest the rep can flip a per-interest toggle
so the berth recommender reads dimensions off the yacht record instead
of the rep-entered desired_* columns.
- Migration 0087 + interests.useYachtDimensions boolean (default false).
- Validator (createInterestSchema) accepts the new field; service insert
+ update paths spread it through automatically.
- berth-recommender.service.loadInterestInput dual-source resolution:
when toggle=true AND yachtId is set AND the yacht has at least one
measurement on file, the recommender uses the yacht's length / width /
draft instead of the desired_* values. Falls back to the desired
columns whenever any precondition fails (no yacht link, toggle off,
or the yacht carries no measurements). Returned InterestInput gains
a `dimensionsSource: 'interest' | 'yacht'` trace field.
- Interest form: under the "Berth size desired" section, when a yacht
is linked, a checkbox surfaces — "Use the linked yacht's dimensions
for the recommender". When checked, the three dimension inputs grey
out (DimensionInput gains a `disabled` prop) so the rep can't
accidentally edit the now-overridden values. Hint text spells out
the fallback behaviour.
Verified: tsc clean, 1493/1493 vitest, migration applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- DashboardReportBuilder grows an optional Cover-page brand picker
surfaced only when can('admin', 'manage_settings') AND the user has
access to >1 port. Pulls ports from PortContext; default option is
"Use active port brand", remaining options are the other ports the
user can reach. Choice persists in config.coverBrandPortId; threaded
through preview, download (/reports/generate), and queue
(/reports/runs) payloads.
- render-report.service.ts: when run.config.coverBrandPortId resolves
to an accessible port, the cover-page logo + portName come from THAT
port's brand kit. Falls back to the source port silently when the
override port is missing or stale. Source-port DATA stays — only the
cover branding swaps. Useful for cross-port leadership decks.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- renewTenancy service:
- permanent / fee_simple / strata_lot → mutate-in-place (startDate
moves forward, endDate may extend or null out)
- fixed_term / seasonal → end the current row at its existing endDate
+ mint a successor with previousTenancyId chain. newEndDate required.
- transferTenancy service: end-and-spawn — end current row at
transferDate, mint fresh active row with transferredFromTenancyId
pointing back. New client + yacht cross-validated against port +
ownership constraint (assertClientOwnsOrRepresentsYacht).
- POST /api/v1/tenancies/[id]/renew + /transfer routes gated on
tenancies.manage + module-enabled.
- TenancyRenewDialog (tenure-aware copy explains in-place vs successor),
TenancyTransferDialog (ClientPicker + YachtPicker with owner-scoped
filter). Both mounted on tenancy-detail.tsx alongside Edit + End.
- Validators: renewTenancySchema + transferTenancySchema in
src/lib/validators/tenancies.ts.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Migration 0086: berth_tenancies.previous_tenancy_id +
transferred_from_tenancy_id self-FKs + partial indexes. Per
docs/tenancies-design.md these chain renewal / transfer successors
to predecessors for fixed-term and seasonal lineage. Schema mirrored
in tenancies.ts with AnyPgColumn typed-import.
- POST /api/v1/tenancies (generic create): accepts berthId in the
body so client + yacht tab entry points don't have to bounce through
/api/v1/berths/[id]/tenancies. Same createPending service helper.
- TenancyCreateDialog: <TenancyCreateDialog open clientId? yachtId?
berthId? /> with all three pickers; pre-fills the carrier from the
parent entity. POSTs to /api/v1/tenancies; "Create" and
"Create and activate" CTAs both wire to the new endpoint.
- Mounted on ClientTenanciesTab + YachtTenanciesTab behind
<PermissionGate resource="tenancies" action="manage"> so reps can
mint tenancies directly from those tabs without bouncing through
the berth page.
- TenancyEditDialog: edit metadata only (start/end dates, tenure type,
notes) via the new action='update' branch on the [id] PATCH route.
Status transitions stay on activate/end/cancel. Wired into the
tenancy detail page header. Outer wrapper unmounts on close so the
form re-initialises from current row data without setState-in-effect.
- updateTenancy service helper + PATCH action='update' branch added.
Audit-logged + emits berth_tenancy:activated to invalidate detail
query caches.
Renew + Transfer dialogs deferred — both need lineage UX decisions
(tenure-aware mutate-in-place vs new-row spawn; client/yacht swap
semantics) and the self-FK columns this commit lands are the
underpinning. Next sub-task.
Verified: tsc clean, 1493/1493 vitest, migration applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- PlacedField gains optional defaultValue + fieldMeta carriers. The
field-placement submit threads fieldMeta verbatim through the FormData
payload (only when populated), where the API route + service +
Documenso client already accepted it (v2 field/create-many honours
fieldMeta per row).
- FieldSidePanel grows a FieldMetaSubPanel that renders per-type
controls in the right rail:
- TEXT — default text, label, required toggle
- NUMBER — format string, min, max, required
- CHECKBOX — multi-select option editor with per-option `checked`
- RADIO — single-select option editor (mutually-exclusive default)
- DROPDOWN — single-select option editor
Each writes shallowly into field.fieldMeta so Documenso v2's
create-many endpoint receives the shape it expects. SIGNATURE /
INITIALS / DATE / EMAIL / NAME render nothing (no per-instance
config today).
- ChoiceMetaEditor extracted as a top-level component so the option
list doesn't recreate its DOM subtree on every keystroke
(react-hooks/static-components rule).
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- DashboardReportBuilder gains an optional Subtitle input alongside
Title. Persisted in the config payload sent to /api/v1/reports/runs
+ /api/v1/reports/generate + threaded through the preview payload's
useMemo dep list so live preview reflects the override.
- Cover-page brand picker (admin-only) — deferred. Today the renderer
uses the active port's brand kit; cross-port branding swap needs a
permission gate, port-pick UI, and a renderer override and is queued
for a follow-up. Subtitle alone covers the most common ad-hoc need
(custom cover-page subtext like "Board pack — March 2026").
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- report-render.service.ts: KindRenderer now carries a per-kind toCsv
serializer alongside the PDF renderer. renderReportRun branches on
run.outputFormat — 'pdf' (existing path), 'csv' (new), 'png' (throws
with a clear "deferred" message so the run lands as 'failed' without
a partial blob). Storage path, mime type, filename + extension all
pick up the output-format suffix; the file row mirror records the
matching mime so the standard download surface serves it correctly.
- csvCell / rowsToCsv helpers: RFC-4180 escaping (always double-quoted,
doubles internal quotes, CRLF newlines).
- 4 per-kind serializers:
- dashboard: stage-count + top-interests + meta as 3-col CSV
- clients: activity log rows (id/createdAt/action/entityType/entityId/userId)
- berths: occupancy metrics (totalBerths + occupancyRate + status counts)
- interests: revenue metrics (completed + forecast + per-stage breakdown)
- DashboardReportBuilder + SimpleReportBuilder gain an Output-format
toggle (PDF | CSV). DashboardReportBuilder threads it into the queued-
run POST; SimpleReportBuilder threads it directly. Synchronous PDF
download path (Dashboard "Download PDF" button) stays PDF-only since
/api/v1/reports/generate returns a blob, not a run row.
PNG remains deferred — flagged with a follow-up TODO inside the render
branch + the builder selector deliberately omits PNG so reps don't pick
it and watch a run fail.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P4 — landing + builder:
- /[portSlug]/reports — new landing page with 4 build-kind cards
(dashboard / clients / berths / interests), 3 library cards
(Templates / Runs / Schedules), and the pre-P4 reports list
preserved under "Legacy library" so historical PDFs stay accessible.
- /[portSlug]/reports/[kind] — kind-aware builder route.
- dashboard: refactored the existing export dialog body into
DashboardReportBuilder (page-mounted; same widget grouping +
date-range + SavedTemplatesPicker + preview). New "Queue + go to
Runs" CTA enqueues a report_runs row via /api/v1/reports/runs
(Reports P3 path); "Download PDF" keeps the synchronous /generate
fallback for ad-hoc one-shots.
- clients / berths / interests: SimpleReportBuilder — date-range +
enqueue to /api/v1/reports/runs. Kind-specific filters land
alongside dedicated renderers in P6+.
- Dashboard "Export as PDF" button rewired: no longer opens an
in-dashboard Dialog. Becomes a Link → /reports/dashboard?from=...&to=...
carrying the currently-active range through search params so the
builder pre-fills it. Removes the dialog body (~290 lines) from the
button file; the same UI lives in DashboardReportBuilder.
- ?from=YYYY-MM-DD&to=YYYY-MM-DD search params pass the range into the
builder page.
P5 — sub-pages (functional, backed by P2 CRUD endpoints):
- /reports/runs — paginated table of report_runs with status badges,
auto-polls every 5s while any row is pending/rendering, per-row
Download (file by storageKey) + Re-run actions.
- /reports/templates — saved template grid. Clicking the name links to
the builder with ?templateId=… so it pre-applies.
- /reports/schedules — schedule table with cadence labels (weekly /
monthly / quarterly), next-run timestamps, recipient counts, and a
per-row enable Switch (PATCH /api/v1/reports/schedules/[id]).
Verified: tsc clean, 1493/1493 vitest, dev-server compile clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MUST-FIX:
- src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:70 — the
PUT allowlist still gated `reservations: {view,create,activate,cancel}`.
Stale: would reject valid `tenancies.{view,manage,cancel}` writes and
silently accept ghost `reservations.*` writes that never land. Replaced.
- src/lib/services/alert-rules.ts:68 — `reservation.no_agreement` alert
emitted `entityType: 'reservation'`. Every other tenancy-related
audit/socket/dashboard label is `'berth_tenancy'`. Inconsistent dedupe
+ activity-feed label miss.
- tests/e2e/exhaustive/08-portal.spec.ts:6 — hardcoded /portal/my-reservations
navigates to a 404 every run.
- tests/e2e/exhaustive/03-reservations.spec.ts — entire spec renamed to
03-tenancies.spec.ts; tab + button locators updated to match renamed UI.
SHOULD-FIX (consistency):
- src/components/clients/client-detail.tsx — useRealtimeInvalidation only
caught 3 of the 4 berth_tenancy:* events; added the `:created` listener.
- src/lib/services/client-merge.service.ts — MergeResult.movedRows.reservations
+ snapshot.reservations + local loserReservations / movedReservations
renamed to tenancies / loserTenancies / movedTenancies. No external
consumers grep-confirmed.
- src/lib/services/gdpr-bundle-builder.ts — GdprBundle.reservations field
renamed to .tenancies; user-facing HTML section "Reservations" → "Tenancies";
local reservationRows → tenancyRows.
- 6 UI copy strings: gdpr-export-button, bulk-archive-wizard,
bulk-hard-delete-dialog, hard-delete-dialog, admin-sections-browser ×2,
admin/import/page, won-status-panel — all "reservations" prose updated
to "tenancies" (occupancy-record sense).
- tests/integration/api/tenancies.test.ts — handler import aliases
`createReservationHandler` etc renamed to `createTenancyHandler` etc.
- tests/unit/services/berth-tenancies.test.ts — local helper makeReservation
→ makeTenancyLocal (avoids shadow of the renamed factory).
- scripts/audit-permissions.ts — stale allowlist entry for
/berth-reservations/[id]/route.ts removed (path no longer exists).
- docs/runbooks/permission-audit.md — stale row for same path removed.
- docs/tenancies-design.md — fixed factual error
("tenancies.service.ts" → "berth-tenancies.service.ts").
Verified: tsc clean, 1493/1493 vitest.
Dev-server note: the running `next dev` process started before P2 and
shows Turbopack cached compile errors against the renamed schema files.
Source is correct (./tenancies); restart `next dev` to clear the cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- new report-render.service.ts: renderReportRun(reportRunId) +
emailReportRun(reportRunId). Render path fetches the run row,
advances status to 'rendering', resolves the kind→fetcher+template
pair from REPORT_RENDER_MAP (dashboard→pipeline, clients→activity,
berths→occupancy, interests→revenue), generates the PDF, uploads to
storage, mirrors onto `files` so the standard download/attachment
surfaces serve it, and stamps storageKey + sizeBytes + status='complete'.
Failure path stamps 'failed' + errorMessage + compensating
storage.delete to keep blobs from orphaning. Email path resolves the
schedule's recipients + the rendered file via the standard
resolveAttachments port-isolation check, sends one message per
recipient via the existing sendEmail helper, and stamps emailedAt.
- reports worker (src/lib/queue/workers/reports.ts) gains 3 jobs:
- 'report-schedules-poll': scans report_schedules where enabled=true
AND nextRunAt <= now, mints a report_runs row per due schedule via
createReportRun (triggeredBy='schedule'), advances next_run_at via
nextRunFor() BEFORE enqueue so a downstream failure doesn't pin the
schedule on the same tick, then enqueues report-run-render.
- 'report-run-render': calls renderReportRun + auto-cascades into
report-run-email when the run was schedule-triggered.
- 'report-run-email': calls emailReportRun.
These coexist with the legacy 'report-scheduler' + 'generate-report'
jobs operating on scheduled_reports/generated_reports.
- scheduler.ts registers 'report-schedules-poll' on a 1-minute cron so
the system catches due schedules even when no API event nudges them.
- POST /api/v1/reports/runs now enqueues 'report-run-render' after
createReportRun. Enqueue failures are logged + swallowed so the API
still returns 201; the schedule poll picks pending rows up as a
safety net.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- tenancy-reports.service.ts: 4 read-only query functions backing the
widgets. Heatmap uses a months×areas SQL grid with date-range overlap;
renewals-at-risk filters active tenancies whose end_date is inside a
90d window with NO successor pending/active row already minted on the
same berth; revenue forecast buckets active tenancies by their
end-date quarter; tenure breakdown is a simple GROUP BY status='active'.
- 4 new API routes under /api/v1/dashboard/tenancy-*:
- tenancy-occupancy (heatmap)
- tenancy-renewals (at-risk list)
- tenancy-revenue (forecast)
- tenancy-tenure (breakdown)
Each prepended with assertTenanciesModuleEnabled so a port without
the module gets 404 instead of an empty payload.
- 4 widget components:
- TenancyOccupancyHeatmapWidget — areas × months table with shaded
cells (5-tier emerald ramp by occupancy %)
- TenancyRenewalsAtRiskWidget — top-10 list, 30-day urgency badge
- TenancyRevenueForecastWidget — horizontal bar list by quarter,
currency-formatted totals
- TenancyByTenureTypeWidget — proportional bars, color-coded per
tenure type
- WidgetIntegration union extended with 'tenancies_module'; the
useDashboardIntegrations hook reads it off PortProvider (no extra
fetch). All four widgets register with selfGates=true +
requires='tenancies_module' so the picker AND render path filter
them out when the module is off.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- PortProvider exposes tenanciesModuleByPort + a useTenanciesModuleEnabled()
hook that returns the flag for the currently-active port. Synchronous
read off context (server-resolved in the dashboard layout), so no
fetch latency / hydration flicker when the rep flips ports.
- buildBerthTabs / getClientTabs / getYachtTabs gain a
tenanciesModuleEnabled option. When false, the Tenancies tab is
filtered out entirely. When true, it slots into the entity-specific
position (after Interests on berth + yacht; after Companies on client).
- BerthDetail / ClientDetail / YachtDetail pass the hook value through.
Hook call ordered above the early-return so React's rules-of-hooks
stays satisfied. Existing read-only tab content (Active tenancy card
+ History + the berth-side BerthReserveDialog "Create tenancy" CTA
from P2) stays untouched — it just becomes visible when the module
is on.
Deferred (separate ship): generic TenancyCreateDialog that pre-fills
clientId / yachtId from the parent entity context, so client / yacht
tabs can mint a tenancy without bouncing through the berth detail page.
Today client/yacht Tenancies tabs are read-only (the create entry-point
is the berth tab); the generic dialog will land alongside the Edit /
Renew / Transfer / End dialogs (design § P6 sub-tasks).
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dashboard layout resolves tenanciesModuleByPort server-side (one
isTenanciesModuleEnabled call per port the user has access to) and
passes the map through AppShell → Sidebar. Atomic SSR — no
flicker of the nav entry in/out after hydration.
- Sidebar gains NavItemGated.requiresTenanciesModule. The Tenancies
entry (KeyRound icon, immediately below Berths) only renders when
the currently-active port has the flag flipped on. Per-port live
switch fires when the rep toggles ports without reload.
- /[portSlug]/tenancies + /[portSlug]/tenancies/[id] both call
isTenanciesModuleEnabled and notFound() when disabled — guards
against direct URL access even when the sidebar is hidden.
- API routes (/api/v1/tenancies, /[id], /berths/[id]/tenancies)
prepended with assertTenanciesModuleEnabled — matches design §
"All routes ... return 404 when off". NotFoundError maps to 404.
- Existing tenancy API tests get a makePortWithTenancies() helper
(calls enableTenanciesModule after makePort) so the gate is
satisfied. Affects 2 test files (16 tests retargeted).
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- derivePublicStatus gains optional hasActivePermanentTenancy flag;
precedence updated to "sold > under_offer > available" where
Sold can come from EITHER berths.status='sold' (admin set) OR an
active permanent-class tenancy (only when module enabled).
- Permanent-class tenure types defined in one place
(isPermanentTenureType): permanent | fee_simple | strata_lot.
Seasonal / fixed_term tenancies do NOT flip — they fall through to
the existing under_offer / available precedence.
- /api/public/berths (list) + /api/public/berths/[mooringNumber]
(single) both gate the lookup on isTenanciesModuleEnabled(portId).
Disabled module = lookup skipped entirely, preserving pre-module
behaviour for ports that haven't opted in.
- 8 new unit tests covering: flip from available, flip from under_offer,
explicit sold idempotency, false-flag fallthrough, default-omit pre-
module behaviour, permanent-class membership for each tenure type,
and null/undefined/unknown rejection.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- berth-tenancies.service.ts: autoCreatePendingTenancies(portId, interestId, opts)
loops over interest_berths WHERE is_in_eoi_bundle=true and mints ONE
pending tenancy per in-bundle berth. Wrapped in pg_advisory_xact_lock
per port + idempotent skip when a (pending|active) tenancy already
exists for the berth (webhook retry-safe). Each insert audit-logged
+ emits berth_tenancy:created socket event.
- createPending: same advisory-lock + tx pattern, additionally calls
enableTenanciesModule(portId) so the FIRST manual tenancy in a port
lazily flips tenancies_module_enabled=true (idempotent UPSERT, no-op
on subsequent inserts).
- handleDocumentCompleted: branch on reservation_agreement completion
gates on isTenanciesModuleEnabled, then calls autoCreatePendingTenancies
with the just-committed signedFileId. Per design §"When disabled":
stage advance + reservationDocStatus flip still fire when the module
is off; only the tenancy mint is skipped.
- 5-case integration test covering bundle expansion, idempotent retry,
empty-bundle no-op, missing-interest no-op, and the first-insert
module-enable side effect.
Verified: tsc clean, 1485/1485 vitest (5 new cases).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the second execution pass:
- Reports P2 CRUD landed on report_runs + report_schedules.
- Form-error sweep complete platform-wide (16 remaining callsites adopted).
- Audit-doc cleanup: dock-letters / email-test / cancelMode were already
shipped earlier and should not have been listed as queued.
Total ~25 commits across this date; ~110 h still queued for follow-up
(Reports P3-P7, Tenancies P2-P7, UploadForSigning field metadata, B3 wave).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds the API + service layer the P1 schema migration 0084 set up:
- src/lib/validators/reports.ts: new schemas for list/create on runs +
full CRUD on schedules. Locked enums for kind / output / cadence /
status so the route layer can reject invalid combinations early.
- src/lib/services/report-runs.service.ts: list with kind/status/template
filters, create with cross-port template guard + config.kind
discriminator check, updateReportRunStatus for the future P3 worker to
flip status through pending/rendering/complete/failed.
- src/lib/services/report-schedules.service.ts: full CRUD plus
nextRunFor() deterministic cadence math. nextRunAt is recomputed on
cadence change or on re-enable (off->on) but left untouched on no-op
edits so a mid-cycle recipient swap doesn't slip the fire-time.
- /api/v1/reports/runs (GET + POST) + /api/v1/reports/runs/[id] (GET)
- /api/v1/reports/schedules (GET + POST) +
/api/v1/reports/schedules/[id] (GET + PATCH + DELETE)
- tests/integration/report-runs-schedules.test.ts: 9 cases covering the
cross-port FK guard, the config.kind cross-check, listing filters,
cadence math for all three v1 cadences, the no-op-doesn't-slip rule,
and the ON DELETE SET NULL contract on schedule deletion.
Permission gating: list/get on reports.view_dashboard (read), all mutations
on reports.export (write). Matches the existing /reports/templates routes.
P3 (the BullMQ render+email queue) is the next slice; it'll consume the
pending rows produced here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the form-error rollout the prior session shipped on the 6
highest-impact forms (client/interest/yacht/company/berth/expense). Adds
the scroll-to-first-error wrapper + the top-of-form summary banner to:
- src/app/(auth)/login/page.tsx
- src/app/(auth)/reset-password/page.tsx
- src/app/(auth)/set-password/page.tsx
- src/app/(auth)/setup/page.tsx
- src/app/(dashboard)/[portSlug]/invoices/new/page.tsx
- src/components/berths/berth-detail-header.tsx (status-change dialog)
- src/components/companies/add-membership-dialog.tsx
- src/components/invoices/invoice-detail.tsx (record-payment form)
- src/components/reservations/berth-reserve-dialog.tsx
- src/components/yachts/yacht-transfer-dialog.tsx
Each call site: hook wraps handleSubmit, FormErrorSummary renders only
when 2+ errors fire (no visual change otherwise), and per-form `labels`
prop translates field names to human-readable strings. invoice-line-items
is a sub-form via useFormContext, so it inherits from the parent.
1471/1471 vitest, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When ExternalEoiUploadDialog mounts on an interest with a non-terminal
generated EOI (status sent / partially_signed / draft), it now surfaces
an amber banner naming the active envelope and offering two paths via
radio:
- "Cancel the generated envelope and replace it" (default + recommended):
upload posts cancelActiveDocumentId; the service voids the upstream
Documenso envelope + flips the local doc row to cancelled BEFORE the
new external-EOI doc lands. Audit-log on the new doc carries
metadata.replacedDocumentId so reps can trace cause + effect.
- "Keep both records (advanced)": legacy behaviour - leaves two EOIs on
the deal. Useful only for backfilling intentionally-parallel records.
Cancel runs outside the upload transaction so a Documenso void error
doesn't block the upload the rep has already photographed. The dialog
already shares cache + envelope shape with InterestDetail, so the recent
B4 #4 fix means opening the dialog no longer blanks the page.
cancelMode='delete' is hardwired in the replace path (kill the upstream
envelope on void). Pairs with the existing keep_remote affordance on the
manual Cancel-document flow shipped earlier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EoiGenerateDialog gains an inline "Include on EOI" checkbox in the
Section 3 header (renders only when ctx.yacht is set; defaults ON so
existing behaviour is unchanged). When OFF, the generate-and-sign POST
flips includeYachtDetails=false on the body; service blanks
eoiContext.yacht before either pathway runs:
- Documenso template payload: buildDocumensoPayload reads no yacht so
yacht.* and owner.* merge fields ship empty. Existing template tolerates
blanks per the "left blank if absent" copy.
- In-app PDF fill (pdf-lib): generateEoiPdfFromTemplate sees no yacht so
AcroForm field writes for the yacht block are skipped.
Persists the rep's choice in the document-create audit log
(metadata.includeYachtDetails) so an audit trail records explicit opt-outs
even though documents has no JSONB metadata column today.
ft/m unit toggle in the Section 3 header now hides when Include is OFF
(unit choice is meaningless without yacht details).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 'radio' SettingType the registry-driven admin form can render. Same
shape as 'select' (options list, enum validation, resolved/source badges),
but renders inline radio cards instead of a dropdown so each option's
consequences sit side-by-side for the admin.
Adopted on the two highest-stakes Documenso behaviour toggles:
- `eoi_send_mode` — Manual vs Auto signing-invitation dispatch
- `documenso_signing_order` — Parallel vs Sequential recipient flow
Both choices are binary and materially different (one auto-sends mail, the
other doesn't; one routes signing serially, the other in parallel), so the
upfront comparison beats a hidden dropdown.
`documenso_redirect_url` keeps its url-input — it's already a single
free-text field with no enum.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to prior commit — the untracked file-icon.tsx that both
EntityFolderView and FileGrid now import.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extract FileIcon mapping to `src/components/files/file-icon.tsx` (single
source of truth for mime→icon+colour palette; was previously inline in
FileGrid only).
- EntityFolderView file rows now render the type-specific icon (PDF/red,
Image/blue, Sheet/green, Video/purple) instead of a generic FileText —
multi-deal clients become scannable at a glance.
- Add an inline "Signed" pill on rows where signedFromDocumentId is set so
reps can distinguish a signed-from-workflow copy from a vanilla upload
without hovering for "View signing details".
- Tighter hover treatment (row picks up a subtle bg on hover) for affordance.
- FileGrid refactored to consume the shared FileIcon so both surfaces stay
in lockstep on future mime additions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three B4 bug fixes shipped together:
- **#4 Upload-signed-copy blank body** — ExternalEoiUploadDialog used
queryKey=['interests', interestId] but didn't unwrap the {data} envelope
while the parent InterestDetail (same key) does, so opening the dialog
clobbered the cache with a wrapped shape and blanked the detail page
("Unknown Client" + empty tab body). Dialog now unwraps to match.
- **#2 Legacy-stage canonicalization regression test** — new integration
test locks in the external-EOI advance gate: canonical pre-EOI stages
(enquiry/qualified/nurturing) advance to 'eoi' on upload; at-or-past-EOI
stages stay put while metadata still writes. 7/7 passing. Backfill
script intentionally not shipped — dev DB is test data, prod cutover
is manual.
- **#3 Global-search dropdown translucent rows** — defensive opaque
background on the popover wrapper (bg-white dark:bg-popover) guards
against the subtle transparency UAT captured on the Berths page.
Live-browser repro still needed to identify the exact bleeding row;
this defense makes the surface unambiguously solid in light mode
regardless of which class wins tailwind-merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Top-of-doc status block summarising what landed during the autonomous
execution pass (~12 commits across Bucket 1/2/3/4) + what remains
queued for follow-up sessions. Lets future sessions skip directly to
deferred items without re-triaging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Locked decision from the audit: bump every Sheet width uniformly so
content-dense drawers (EoiGenerateDialog, InterestForm, ClientForm,
…) get more horizontal room without per-site overrides. Adds a
lg:max-w-xl tier so wide viewports get extra breathing room while
the sm tier stays tight on tablets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of the locked Reports page design (docs/reports-page-design.md).
This PR is the data foundation — API routes, UI builder, scheduler,
and rendering pipeline land in subsequent PRs.
What ships:
- Migration 0084: extends report_templates with description + visibility
+ archived_at, softens the unique-name index to skip archived rows,
adds report_runs (append-only audit log) and report_schedules
(BullMQ recurring scheduler) tables with full indexes.
- Schema TypeScript additions in src/lib/db/schema/reports.ts:
reportSchedules + reportRuns table definitions with strongly-typed
recipients / config / status enums.
Behaviour today: no UI changes; existing /api/v1/reports/generate
keeps working unchanged. Saved templates can be archived via
report_templates.archived_at once the templates CRUD API lands in P2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of the locked Tenancies module design (docs/tenancies-design.md).
This PR is the gating infrastructure — the actual table rename
(berth_reservations -> tenancies) + self-FKs + perm-rename + sidebar
entry land in subsequent PRs.
What ships:
- `system_settings.tenancies_module_enabled` registry entry (port-scoped
boolean, default false). Surfaces in the registry-driven admin form
+ the resolveForAdminAPI chain.
- `src/lib/services/tenancies-module.service.ts` with:
* isTenanciesModuleEnabled(portId) — checks the admin setting AND
the lazy "any berth_reservations row exists" sentinel
* enableTenanciesModule / disableTenanciesModule — idempotent
upserts on the system_settings row
* assertTenanciesModuleEnabled — throw-on-disabled helper for
route handlers (NotFoundError -> 404)
- Three admin endpoints under /api/v1/admin/tenancies-module/
(status / enable / disable), all gated on admin.manage_settings.
Behaviour today: with the module off (default), nothing changes.
Sidebar, entity tabs, top-level page, webhook auto-create branch,
and dashboard widgets all continue to read the same flag and stay
hidden until either an admin toggles it ON or the first auto-create
flips it via the lazy "row exists" sentinel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B2 Wave A (visual breakpoints):
- Documents Hub folder rail: widen ResizablePanel default 20→22%,
min 14→18%, add min-w-[180px] CSS floor so names don't truncate
at tablet 768
- Website analytics KPI tiles: switch lg grid 6→3 cols, restore 6
at xl so "Visit duration" stops truncating in the 1024+sidebar
layout
- Pipeline Value tile per-stage rows: compact $3.5M format on
sm- breakpoint (responsive sm:hidden / hidden sm:inline pair)
B2 Wave D (form-error UX rollout):
- useFormScrollToError + FormErrorSummary wired into 5 high-impact
forms: client-form, interest-form, yacht-form, company-form,
berth-form. Validation failures now scroll the first errored
field into view + render a top-of-form summary banner when ≥2
errors exist. Remaining ~23 form surfaces queued for follow-up.
B2 Wave B (Umami follow-ups):
- TopList primitive: add onExpandRange + expandRangeLabel props
for the empty-state nudge ("Try last 30 days" button). Callers
can opt in to drive the page-level DateRange.
B2 Wave C (FieldLabel + admin tooltip audit):
- Verified FieldLabel primitive already exists + is adopted in
custom-field-form. Registry-driven-form renders entry.description
inline below labels for every entry — the broad sweep across
15-20 admin pages is deferred to a focused polish session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Critical data-correctness fixes
- external-eoi.service: stage-advance list rewritten against canonical
7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to
legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so
EOI uploads from 'qualified' silently skipped the stage flip. Now also
writes eoiDocStatus='signed' alongside eoiStatus='signed'.
- public-interest.service + api/public/interests/route: pipelineStage
'open' → 'enquiry' for new public interests.
- interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker
comments updated.
- Display fallbacks canonicalized: dashboard.service, dashboard-report-data,
pdf/templates/{interest,client}-summary, interest-picker, timeline route
all route through canonicalizeStage / stageLabelFor.
Multi-berth interest label sweep
- New helper src/lib/templates/interest-berth-label.ts with 9 unit tests
(deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments,
falls back to 'first + N more').
- New batched aggregator getAllBerthMooringsForInterests on the
interest-berths service.
- BoardInterestRow + listInterests + getInterest extended with
berthMoorings: string[].
- Swept render sites: interest-detail-header, pipeline-card +
pipeline-column (kanban), interest-columns (list), interest-card,
interest-detail (breadcrumb), client-pipeline-summary +
client-interests-tab, yacht-tabs, shared interest-picker.
- PDF report "New interests (in period)" Source column → Berth column.
Dashboard PDF report fixes
- Hardcoded EUR → reads ports.default_currency once at the top of
resolveDashboardReportData. Falls back to USD.
- 'maintenance' berth-status bucket removed everywhere (wasn't in
canonical BERTH_STATUSES); cleaned from dashboard.service,
dashboard-report-data, occupancy-report, berth-status-chart, fixture.
- Berth demand ranking: dropped placeholder Tier column (resolver
hardcoded 'A' — heat-tier never plumbed through).
- Deal pulse distribution: tier values capitalized (hot → Hot etc.).
- Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing
"Validation failed" when all sections checked).
- Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no
more 2-line wraps on "needs date range"); accepts initialRange?:
DateRange so the dashboard's active range pre-fills dateFrom/dateTo via
rangeToBounds.
Interest banner overcounts fix
- interest-berth-status-banner: filters out self-caused under-offer
berths (where the only active deal touching the berth IS this same
interest). Waits for all competing-queries before committing the
count. Was showing "3 berths unavailable" when only 1 actually had a
competitor.
Sessions list ordering
- sessions-list: client-side sort by lastAt desc + displays lastAt
instead of firstAt so visible timestamp matches the sort key.
Audit log polish
- Details button: side Sheet → Popover anchored to the button (in-place
inline dropdown). Works with the virtualized table.
- From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3.
EntityFolderView (Documents Hub entity view)
- Per-row Download button (hover-reveal icon).
- File-type icon prefix + tighter row layout.
- Per-row interest-berth badge: files.ts attaches interestBerthLabel via
one batched getAllBerthMooringsForInterests call across all groups.
AggregatedFile type + EntityFolderView render the badge linking back
to the parent interest.
External EOI upload dialog
- Title input pre-fills from the derived default via controlled
displayTitle = title || defaultTitle (no setState-in-effect).
EOI Generate dialog
- Success toast on mutation success.
- Primary berth's "Include in EOI" checkbox is now forced-on + disabled
with tooltip: the primary IS the canonical "berth for this deal",
excluding it is semantically nonsense.
Primary berth must always be in EOI bundle (service + backfill)
- interest-berths.service: insert path forces is_in_eoi_bundle=true
whenever is_primary=true; update path coerces back to true when the
caller tries to set false on a primary. Backfilled 7 existing rows.
Documenso redirect URL fallback
- port-config getPortDocumensoConfig: resolution chain extended to
documenso_redirect_url → public_site_url → null. Operators with
public_site_url configured (most ports) now get sensible signer
landing without setting two settings.
World-map click → navigate
- website-analytics-shell: country click navigates to the nationality-
filtered Clients page via router.push instead of copying a URL to
clipboard.
Documents Hub: subfolder grid in main panel
- Subfolder cards rendered above the documents list when the current
folder has children. Lets reps drill into subfolders from the main
content area, not only via the sidebar tree.
Interest list initial sort
- usePaginatedQuery gains initialSort option (used when URL has no sort
param). Interest list passes updatedAt desc so the table header
surfaces the active sort visibly + most-recently-added/edited bubble
to the top.
Interest auto-assign on create
- interests.service createInterest: three-tier owner resolution chain
— explicit input → port's default_new_interest_owner setting →
creator (when not super-admin). Super-admins skipped since they often
create on behalf of other reps.
Backfills
- 12 interests with eoi_status='signed' + missing eoi_doc_status='signed'
aligned.
- 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false
flipped to true.
Verified
- pnpm tsc --noEmit: clean
- pnpm exec vitest run: 1463 / 1463 passed
Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md
across all 4 buckets, including two OPEN QUESTIONS (Reservations module
re-imagine, Reports dedicated page promotion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Finishes B4 #8 by completing the UI half of the per-interest filing
model. Backend foundations (files.interest_id column, ensureEntityFolder
for 'interest', upload-zone scope radio, outcome rename hook, backfill)
shipped earlier in this audit cycle.
- listFiles validator + service: optional interestId filter
- listFilesAggregatedByEntity: routes entityType='interest' to a new
helper that returns "THIS DEAL" + "FROM CLIENT" + symmetric-reach
company/yacht groups
- InterestDocumentsTab: Attachments section now renders two cohorts
via two paginated queries, with client-side de-duplication so files
filed under this deal don't double-count under "From client"
- FileRow type exposes the optional interestId so the de-dupe filter
doesn't need a re-fetch
Backend foundations were already in place ('generic' CustomDocumentType,
storage-path routing). This wires the UI surface across Documents Hub +
entity file tabs.
- UploadForSigningDialog: interestId now string | null; new entity?,
folderId?, onCreated? props. Generic path POSTs to new endpoint
/api/v1/upload-for-signing; interest-scoped paths unchanged.
- uploadDocumentForSigning service: interestId nullable; skips interest
lookup, pipeline-stage advance, doc-status flip on the generic path.
Routes file FK + auto-filed folder via either interest.clientId or the
caller-supplied entity. Validation enforces the matching invariant
(generic must be interestId=null, type-specific must carry one).
- New menu item in NewDocumentMenu ("Upload & send for signature") on
Documents Hub root + folder views.
- Upload & send-for-signature button on ClientFilesTab + CompanyFilesTab,
gated by documents.send_for_signing.
Existing unit tests for the service still pass (validation paths unchanged).
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
Two days, two modals, both touching widget layout - collapsed into
one. The separate "Rearrange" button + RearrangeWidgetsDialog from
54c5d0f are gone; the Customize modal now does both jobs:
- Two sections in the body: "On dashboard (N)" and "Hidden (N)"
- Visible rows are sortable (drag handle on the left, position number,
switch on the right). Single SortableContext, vertical strategy.
- Hidden rows are toggle-only (no drag handle - order doesn't matter
for off-dashboard widgets). Flipping the switch on appends to the
bottom of the visible section.
- Both visibility toggles and reorder commits optimistically via
useDashboardWidgets so the dashboard reflows in the background.
dashboard-shell: removes the Rearrange button + RearrangeWidgetsDialog
import + setOrder destructure. rearrange-widgets-dialog.tsx deleted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The in-place drag (N46 / a147cbc) had two failure modes:
- Bucket constraints: each layout group (charts / rails / feed) was
its own SortableContext; drops outside the active group silently
no-op'd, so any cross-region drag did nothing.
- Long drags lost their drop target: dnd-kit's closestCenter
collision detection on a sparse grid would intermittently null
out `over` mid-drag, which presented as the dragged tile snapping
back to its original slot.
Switched to a single-flat-list modal:
- New <RearrangeWidgetsDialog>: opens from the "Rearrange" button,
shows every visible widget as a row with a drag handle and a
position number, single vertical SortableContext, Save commits.
- Dashboard shell strips the DndContext + per-bucket SortableContext
wrappers + the SortableWidget cell + all dnd-kit imports related
to the canvas drag. Each widget renders as a plain <WidgetCell>.
- Rearrange button now opens the dialog instead of toggling a drag
mode. Disabled when there's fewer than 2 visible widgets.
The drag persistence fix from ee4d5c8 still applies — the dialog's
Save calls the same setOrder() that PATCHes preferences.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported: "when I refresh the page with this size viewport it
switches between tablet and desktop view." The root cause was the
two-step tier resolution:
1. Server renders shell based on User-Agent (mobile vs desktop only).
2. Client mounts with that hint, useEffect runs matchMedia, may flip.
When the UA says "desktop" but the viewport is actually 900px (so
matchMedia says "tablet"), the chrome visibly switches mid-render.
Most painful on macOS Safari dragged below 1024.
Fix: AppShell writes a `pn-crm.viewport-tier` cookie (1-year, Lax) on
every matchMedia evaluation. The dashboard layout reads the cookie
and prefers it over the UA classifier for `initialFormFactor`. First
visit can still flicker (no cookie yet); every subsequent reload uses
the resolved tier and renders the correct chrome on first paint.
The cookie values are 'mobile' / 'tablet' / 'desktop' but the server's
initialFormFactor prop only accepts 'mobile' | 'desktop' (binary by
design — AppShell's useEffect resolves the actual tier client-side
from matchMedia). 'tablet' from the cookie collapses to 'desktop' on
SSR; AppShell's useEffect re-resolves to tablet immediately. The
fluent path on cookie hit is desktop -> tablet (no flicker because
both shells render the desktop tree; only the sidebar Sheet wrapper
differs, and that's invisible until opened).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
N46 (a147cbc) shipped the drag-drop UI + optimistic mutation, but the
PATCH body was being silently stripped by the user-preferences Zod
validator — `dashboardWidgetOrder` wasn't in the schema, so Zod's
default strip-unknown-keys behaviour dropped it before the DB write.
Symptom: drop the widget in a new position → UI reflects the order
optimistically → onSettled invalidates + refetches → GET returns the
unchanged-on-disk order → dashboard snaps back to the original
layout.
Added the field to updateUserPreferencesSchema with the same loose
shape (array-of-string) the schema declared 100+ lines earlier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported the search bar dropping to a second row + the top-right
buttons (+ New / Inbox / Avatar) going missing as they resized the
browser. Playwright probe confirmed: at every width 780-1280 the
search bar's intrinsic `max-w-2xl` (672px) forced the topbar's
center grid column to expand to that width, leaving the right
column too narrow to hold "+ New + Inbox + Avatar" without
overlapping the search OR going off-screen.
Two coordinated fixes:
1. Grid template `auto_1fr_auto` instead of `1fr_minmax(280,800)_1fr`.
Side columns now size to their actual content (logo + breadcrumbs
on the left; New + Inbox + Avatar on the right); the center
column takes whatever's left. No more "intrinsic content forces
the column to grow" behaviour.
2. Search wrapper max-width scales by tier: max-w-md (448px) at
base, lg:max-w-xl (576px), xl:max-w-2xl (672px). Generous enough
on wide screens, restrained enough on narrow ones so the side
columns always get the space they need.
Verified via Playwright probe at 780/900/1023/1024/1100/1280 —
"+ New" button now lands inside the header at every width.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final Bucket 1 visual-audit follow-up. Audit of all 4 useIsMobile
callers:
- pipeline-chart.tsx + pipeline-funnel-chart.tsx → keep useIsMobile
(short x-axis stage labels apply on tablet too — bar charts can't
fit full "Reservation" / "Deposit Paid" text at narrow widths).
- date-picker.tsx + date-time-picker.tsx → migrate to useViewportTier.
Tablet (768-1023) has plenty of room for the desktop Popover
Calendar; only the smallest phone widths now fall back to the
native datepicker input.
1454/1454 vitest, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PageHeader stack point + tablet topbar trigger fixes verified via
Playwright re-screenshot at 768 + 1024.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two Bucket 1 quick-fixes from the 2026-05-22 visual audit, both
1-2-line CSS changes with outsized visual impact.
PageHeader stack point: lg → xl
The earlier sm → lg revision (commit 6d665d0) fixed the 768 tablet
crush but introduced a SECOND crush at exactly 1024: that's where
the desktop shell mounts (sidebar = 256px) AND lg:flex-row kicks
in, leaving the title cell to compete with a 4-button action row
in only ~720px of content. Title degraded to "(" and "Last 30
days" wrapped three-deep ("Last / 30 / days"). Moving to xl
(1280) keeps the strip stacked through tablet AND the narrowest
desktop width. Verified via Playwright at 1024 — title now reads
cleanly with the action row stacked below.
Topbar tablet logo trigger:
AppShell mounts a logo button in Topbar's leadingSlot prop on
tablet (the design intent: click logo → sidebar Sheet slides in).
Live screenshot at 768 showed zero affordance — search bar started
at the very left edge of the visible viewport. Two root causes,
both fixed:
- center grid column was minmax(420px, 800px) which starved the
left column to ~100px at 768 width (no sidebar present).
Changed to minmax(280px, 800px) at base, minmax(420px, 800px)
only at lg+.
- search container had unconditional sm:-translate-x-...
shifting it 128px LEFT to compensate for a sidebar that isn't
present at tablet, pulling the search input over the leading-
slot. Gated the translate to lg: so it only kicks in when the
sidebar is actually inline.
Verified via Playwright at 768 — hamburger icon now appears in
the top-left corner; search bar sits to its right without overlap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>