Skip to content

Bump vite from 5.4.19 to 5.4.20 in the npm_and_yarn group across 1 directory - #1

Closed
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/npm_and_yarn/npm_and_yarn-08d968b73d
Closed

Bump vite from 5.4.19 to 5.4.20 in the npm_and_yarn group across 1 directory#1
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/npm_and_yarn/npm_and_yarn-08d968b73d

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Sep 16, 2025

Copy link
Copy Markdown

Bumps the npm_and_yarn group with 1 update in the / directory: vite.

Updates vite from 5.4.19 to 5.4.20

Release notes

Sourced from vite's releases.

v5.4.20

Please refer to CHANGELOG.md for details.

Changelog

Sourced from vite's changelog.

5.4.20 (2025-09-08)

Commits

Dependabot compatibility score

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


Dependabot commands and options

You can trigger Dependabot actions by commenting on this PR:

  • @dependabot rebase will rebase this PR
  • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
  • @dependabot merge will merge this PR after your CI passes on it
  • @dependabot squash and merge will squash and merge this PR after your CI passes on it
  • @dependabot cancel merge will cancel a previously requested merge and block automerging
  • @dependabot reopen will reopen this PR if it is closed
  • @dependabot close will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
  • @dependabot show <dependency name> ignore conditions will show all of the ignore conditions of the specified dependency
  • @dependabot ignore <dependency name> major version will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself)
  • @dependabot ignore <dependency name> minor version will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself)
  • @dependabot ignore <dependency name> will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself)
  • @dependabot unignore <dependency name> will remove all of the ignore conditions of the specified dependency
  • @dependabot unignore <dependency name> <ignore condition> will remove the ignore condition of the specified dependency and ignore conditions
    You can disable automated security fix PRs for this repo from the Security Alerts page.

Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 5.4.19 to 5.4.20
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.20/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.20/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.20
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file javascript Pull requests that update javascript code labels Sep 16, 2025
@coderabbitai

coderabbitai Bot commented Sep 16, 2025

Copy link
Copy Markdown
Contributor

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Comment @coderabbitai help to get the list of available commands and usage tips.

@dependabot @github

dependabot Bot commented on behalf of github Sep 22, 2025

Copy link
Copy Markdown
Author

Looks like vite is no longer updatable, so this is no longer needed.

@dependabot dependabot Bot closed this Sep 22, 2025
@dependabot
dependabot Bot deleted the dependabot/npm_and_yarn/npm_and_yarn-08d968b73d branch September 22, 2025 14:13
jdelgado2002 added a commit that referenced this pull request May 18, 2026
* docs(kiosk-perf): design — fast punch + skip-photo fix

Optimistic UI: close camera dialog immediately, show success after PIN
verify, run INSERT + upload + last_used_at update in background. Adds
maxWidth/quality props to ImageCapture, swaps auth.getUser for
auth.getSession, and reads punch status from React Query cache first.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(kiosk-perf): fold frontend design review into spec

Address 2 critical and 4 major concerns from the frontend-design-reviewer:

- Critical #1: add processing-state re-entry guard for double-tap during
  the optimistic background window. Keypad/Confirm/Skip disabled, with a
  belt-and-braces early return in handlePunch.
- Critical #2: defer force_reset PIN-change dialog and tip dialog into the
  per-call onSuccess so they cannot leak open when the INSERT rolls back.
- Major #1: expose stopCamera() via ImageCapture.forwardRef so the parent
  can tear the MediaStream down synchronously before unmounting.
- Major #2: gate the 5s punch-status cache short-circuit on
  !hasQueuedPunches() to keep offline-queue flushes correct.
- Major #3: add role="status" + aria-live="polite" to the success Alert.
- Major #4: accept user-scalable=no for kiosk surface; document the WCAG
  trade-off in code + PR description; personal /clock is unaffected.

Minor advisories adopted: lower getUserMedia constraints when maxWidth<=480,
add silent? toggle on useCreateTimePunch.mutate to avoid stacking a toast on
top of the kiosk success Alert.

Extends the test matrix and acceptance criteria to cover all the above.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(kiosk-perf): TDD plan — ImageCapture, punchContext, useTimePunches, KioskMode

Sequenced as 4 atoms + verify + reviews + ship:

1. ImageCapture forwardRef + maxWidth/quality + lowered getUserMedia ideal
2. punchContext.startPunchContext (eager geolocation, shared promise)
3. useTimePunches: getSession (not getUser), silent flag, fire-and-forget
   bookkeeping (last_used_at moves out of the hot path)
4. KioskMode: optimistic flow, processing re-entry guard, dialogs deferred
   to per-call onSuccess with rollback in onError, stopCamera ref call,
   cache short-circuit gated on !hasQueuedPunches(), role=status on Alert

Each task starts with failing tests, then implementation, then green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(kiosk-perf): fast punch + skip-photo fix (optimistic mutate)

Punch in/out previously waited synchronously for the storage upload, the
INSERT, an `auth.getUser()` round-trip AND a `last_used_at` PATCH before
ever showing success. Under a real production network that totalled
~500ms-5s of "spinner staring at the screen" per employee, which doesn't
scale to many concurrent clock-ins. Skip photo also failed to close the
camera because `resetCameraState` was deferred to the end of the same
awaited chain.

Changes:
  - useTimePunches uses `supabase.auth.getSession()` (local cache, 0ms)
    instead of `getUser()` (50-150ms /auth/v1/user round-trip), and now
    honours a `silent: true` option so kiosk's own optimistic UI doesn't
    stack a global toast.
  - ImageCapture is a forwardRef exposing `stopCamera()` so callers can
    tear down the MediaStream synchronously, plus `maxWidth`/`quality`
    props to drop the JPEG to a 480-wide ~30-80KB upload (matches the
    EmployeeClock profile).
  - punchContext exports `startPunchContext` — an eager-start helper that
    shares one in-flight geolocation promise across the camera dialog open
    and the eventual Confirm/Skip click. Reused for ~10s, then re-armed.
  - KioskMode:
      * Camera teardown happens BEFORE any async work in Skip/Confirm — the
        dialog goes away the moment the employee taps the button.
      * Confirm captures the photo BEFORE stopping the stream (the old
        order silently lost the blob when the canvas had no source).
      * handlePunch is now optimistic: PIN match + status + context are
        awaited, then the success Alert is shown and processing is
        released, and the INSERT runs in the background via
        `createPunch.mutate(..., { onSuccess, onError })`. force_reset and
        the tip dialog are deferred into `onSuccess` so an offline/rejected
        punch doesn't trigger them.
      * `processingRef` covers the React commit gap so a same-tick
        double-tap on Skip/Confirm can't fire two punches.
      * Success Alert has `role="status"` + `aria-live="polite"` so screen
        readers announce the punch result.
      * `last_used_at` is fire-and-forget — never on the hot path.
      * Closing the camera dialog via ESC/backdrop now goes through
        `resetCameraState` so the camera light goes off.
      * `user-scalable=no` annotated as a WCAG 1.4.4 accept-with-rationale
        (shared kiosk lock-out hazard).
  - Tests:
      * tests/unit/ImageCapture.test.tsx — 6 tests covering stopCamera,
        getUserMedia constraints by maxWidth, canvas downscaling, quality
        pass-through to toBlob.
      * tests/unit/punchContext.test.ts — 17 tests (incl. 3 new for the
        eager-start sharing/reset semantics).
      * tests/unit/useTimePunches.test.tsx — 4 tests verifying getSession
        is used (not getUser) and silent suppresses the toast.
      * tests/unit/KioskMode.test.tsx — 7 integration tests covering the
        skip-photo bug fix, eager geolocation, role=status on the Alert,
        deferred force_reset/tip dialogs, optimistic rollback, and the
        re-entry guard.

Verification: typecheck clean; 3920 unit tests pass; production build
green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(kiosk-perf): code-simplifier pass

Drop dead and redundant code from the kiosk-perf rewrite:

- ImageCapture: remove unused `videoReady` state (declared but never set).
- KioskMode:
  - `handleOfflineQueue` and `queuePunchOffline` no longer thread a `pin`
    parameter — `queuePunchOffline` never forwarded it, and the
    `if (offline && pin)` guard was tautological after `validatePinInput`.
    Both signatures now match what they actually need.
  - Drop the `submittedPin` snapshot — only callsite was the immediately-
    following `verifyPinForRestaurant`, so a snapshot was unnecessary.
  - Hoist repeated `pinMatch!.*` accesses in `onSuccess` into one local
    `match`, removing four non-null assertions.
  - Strip three "Helper: ..." comments that described WHAT the function
    is instead of WHY.

Verification: typecheck clean; all 34 kiosk-perf unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(kiosk-perf): apply multi-reviewer findings (sound-logic, perf, security, maint)

Address the must-fix findings from the four Phase 7a reviewers:

Sound-logic (CRITICAL + MAJOR):
- Thread `photoBlob` arg through `handleOfflineQueue` and `queuePunchOffline`
  so offline-queued punches preserve the captured photo instead of reading
  the (already-cleared) `capturedPhotoBlob` state.
- Project the new clock state into the React Query cache after `releaseLock()`
  so a rapid re-punch inside the 5s freshness window doesn't see stale
  "already clocked in" / "no open shift" status.
- Replace `void handlePunch(...)` with `.catch(...)` to surface synchronous
  throws to the operator and re-release the lock instead of swallowing them.
- Use `hasQueuedPunches()` live check in `onSuccess` instead of the closed-
  over `queuedCount` (which could miss punches queued mid-mutation).
- Use `pinMatch!` consistently — once the early-return guards have run the
  match is provably non-null; the `?.` was misleading.
- Move `captureFn` to a ref (`captureFnRef`) so the Confirm button reads the
  latest value at click-time and isn't bitten by stale-closure semantics.
- Surface a UI error when the camera hasn't initialised yet, instead of
  silently punching with no photo.
- Reset `errorMessage` when the camera dialog reopens.

Performance (CRITICAL + MAJOR):
- `useEmployeeTips.submitTip` getUser → getSession (kiosk post-clock-out
  hot path).
- `useUpdateTimePunch`, `useBulkCreateTimePunches`, `useBulkCreateEmployeeTips`
  getUser → getSession (consistency across the file).
- Add module-level `flushing` mutex to `offlineQueue.flushQueuedPunches` so
  N concurrent `onSuccess` flushes don't double-send the same entries.

Security (MAJOR):
- Gate camera dialog `onOpenChange(false)` on `!createPunch.isPending` and
  `!processingRef.current` so an ESC/backdrop tap during the optimistic
  window can't slip a second punch through against a still-fresh cache.

Maintainability (MAJOR + MINOR):
- Stabilise the `onCaptureRef` callback via `useCallback` so ImageCapture's
  internal effect doesn't fire on every KioskMode render (clock tick, PIN
  keystroke).
- Fix stale `isLoading` closure in `ImageCapture.startCamera` setTimeout
  fallback — use a local `metadataFired` flag instead.
- Narrow `catch (error: any)` → `unknown` with `instanceof Error` checks in
  ImageCapture.
- Drop WHAT comments that just restated the next line.

Tests:
- Add KioskMode integration tests for cache projection and photoBlob
  threading through the offline queue (now 9 tests, was 7).
- Add `tests/unit/offlineQueue.test.ts` (3 tests) covering the flush mutex,
  photo rehydration, and `isLikelyOffline` semantics.

Deferred (separate PR — schema-level work):
- Security CRITICAL on `employee_pins_usage_updates` RLS WITH CHECK gap
  (pre-existing).
- Security MAJOR on `created_by` audit trail using cached JWT (would need
  a DEFAULT `auth.uid()` column-default migration).
- Security MAJOR on offline-queued `force_reset` punches bypassing the PIN
  reset prompt (requires a schema flag on the queue payload).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(kiosk-perf): apply PR #503 review feedback (CodeRabbit + Codex + CodeQL)

Critical / Major findings:
- offlineQueue.flushQueuedPunches: preserve all not-yet-attempted entries
  when one send fails. Previous loop only pushed the failed entry then
  broke, silently dropping 2..n of every multi-entry batch.
- KioskMode.handleOfflineQueue: reject the enqueue when employee_id is
  undefined. A queued entry without employee_id can never flush — the
  validation re-throws on every retry — so we should surface the error
  to the user instead of writing a dead row.
- KioskMode optimistic rollback: scope the lastResult/statusMessage
  rollback to the current op-id. If a newer employee's punch has already
  overwritten the chip, an older late-failing punch must not erase that
  newer success. Adds `opIdRef` + identity check around the rollback.
- KioskMode camera dialog: drop `createPunch.isPending` from the
  onOpenChange guard so the NEXT employee can still ESC/backdrop out
  while a PREVIOUS employee's mutation is still flushing in the
  background. `processingRef.current` already scopes the guard to the
  current flow.

Minor / polish:
- offlineQueue.randomId: replace Math.random fallback with
  crypto.getRandomValues (with a counter-based last-resort), so CodeQL
  stops flagging it as a security-context randomness issue.
- KioskMode: gate skip-photo and confirm-punch console.error calls
  behind import.meta.env.DEV per project style.
- design doc: add `text` language tag to the kiosk-timeline fenced code
  block to satisfy markdownlint MD040.

Tests:
- New regression: offlineQueue preserves tail entries when send #2 of 3
  fails. Confirms the indexed-loop / slice-append fix.
- New regression: KioskMode does NOT roll back a newer employee's
  success when an older punch errors late. Confirms the opIdRef guard.
- Hardened the photoBlob test to use the hoisted addQueuedPunchMock and
  assert toHaveBeenCalledTimes(1) unconditionally (no more
  conditional-spy false-pass).
- Hardened useCreateTimePunch test to capture and assert the actual
  insert() row contains created_by = session user id (was only asserting
  the call fired).

3926 unit tests pass (+2 net). Typecheck clean. Build clean. Lint shows
only the pre-existing react-hooks/exhaustive-deps warning on the
mutateRef pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(kiosk-perf): duck-type photoBlob assertion for CI Blob identity mismatch

`expect(arg.photoBlob).toBeInstanceOf(Blob)` passes locally under jsdom but
fails in CI: undici-backed jsdom's `fetch(dataUrl).blob()` returns a Blob from
a constructor that isn't reference-identical to the test scope's global
`Blob`, so `instanceof` is false even though `size`, `type`, and the data are
correct. Switched the assertion to check the duck-typed shape (defined,
correct MIME type, numeric size > 0) which is what the test actually wants to
prove — that the data URL was rehydrated into a blob-shaped payload before
being handed to `sendPunch`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(kiosk-perf): clear SonarCloud reliability + coverage gates

Reliability: `if (inFlight)` flagged "Expected non-Promise value in a
boolean conditional" — Promise truthiness is always true and is rarely
the intent. Made it `if (inFlight !== null)` so we're explicitly probing
for the cached-singleton sentinel; semantics unchanged.

Coverage: added three targeted tests to hit the branches that local
jsdom skips:
- `startPunchContext` reuse-window expiry (the `setTimeout` that nulls
  out the cached promise after 10s).
- `randomId` `crypto.getRandomValues` fallback (`randomUUID` undefined
  but `getRandomValues` present).
- `randomId` counter fallback (no `crypto` global at all) — uses
  `Object.defineProperty` because jsdom exposes `crypto` as a getter.

3929 unit tests pass locally, typecheck + lint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
jdelgado2002 added a commit that referenced this pull request Jul 2, 2026
…assthrough, refetchInterval polling

- Export FocusRestaurantOption type {restaurant_guid, restaurant_name} (design §4.2)
- Add listRestaurantsMutation calling focus-list-restaurants edge fn; handles all
  three error shapes (transport reject, HTTP error, Focus-side data.error message)
- Fix triggerManualSync to accept and spread options ({startDate,endDate}) into the
  invoke body; was silently dropping custom range (design §8.5 / Frontend critical #1)
- Add __focusRefetchInterval exported helper (polls 8000ms while backfilling; returns
  false on done/inactive/error/null; design §8.5 / Frontend major #1)
- Wire refetchInterval into useQuery with the helper; staleTime stays 30000
- 16 new Vitest tests (34 total); zero regressions (5189 passed)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jdelgado2002 added a commit that referenced this pull request Jul 2, 2026
…assthrough, refetchInterval polling

- Export FocusRestaurantOption type {restaurant_guid, restaurant_name} (design §4.2)
- Add listRestaurantsMutation calling focus-list-restaurants edge fn; handles all
  three error shapes (transport reject, HTTP error, Focus-side data.error message)
- Fix triggerManualSync to accept and spread options ({startDate,endDate}) into the
  invoke body; was silently dropping custom range (design §8.5 / Frontend critical #1)
- Add __focusRefetchInterval exported helper (polls 8000ms while backfilling; returns
  false on done/inactive/error/null; design §8.5 / Frontend major #1)
- Wire refetchInterval into useQuery with the helper; staleTime stays 30000
- 16 new Vitest tests (34 total); zero regressions (5189 passed)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jdelgado2002 added a commit that referenced this pull request Jul 2, 2026
…ll (#564)

* chore: stop tracking progress.md (gitignored, force-committed in #563)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(focus): design — restaurant picker + server-side background backfill

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(focus): fold Phase 2.5 design-review resolutions (CAS, sync custom-range, budgets, a11y)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(focus): implementation plan — picker + background backfill (TDD, review-hardened)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(focus/A1): add focusListRestaurantsHandler — auth/role/SSRF guards, Focus API call, response shaping

Adds the shared handler for the focus-list-restaurants edge function (task A1).

- POST body: restaurantId + apiKey + apiSecret + optional environment
- Auth: JWT → 401; role owner|manager → 403 (same FOCUS_ALLOWED_ROLES as test-connection)
- SSRF: isSafeUrl(baseUrl, FOCUSPOS_HOST_RE) guard before any outbound call
- sandboxBaseUrl from deps only — never from request body (§8.6)
- Focus-side failures (401/403/404/other) → HTTP 200 {success:false, error:<friendly>}
- Network errors → HTTP 200 {success:false}
- Success → HTTP 200 {success:true, restaurants:[{restaurant_guid, restaurant_name}]}
- Blank/missing restaurant_name defaults to the GUID (§4.1 step 8)
- Filters out items without a string restaurant_guid
- apiKey/apiSecret never logged on any code path (28-test assertion suite)
- 28 Vitest tests: RED → GREEN; full suite 5173 tests passing, zero regressions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(focus): add focus-list-restaurants edge function + config.toml entry (A2)

Thin Deno entry that mirrors focus-save-connection: CORS pre-flight,
userClient (JWT forwarded) + serviceClient (service role), passes
globalThis.fetch and FOCUS_API_SANDBOX_URL env var as injectable deps.
All business logic lives in focusListRestaurantsHandler (A1).
config.toml gains [functions.focus-list-restaurants] verify_jwt=false.

5173 Vitest tests pass, zero regressions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(focus/A3): add listRestaurants, FocusRestaurantOption, options passthrough, refetchInterval polling

- Export FocusRestaurantOption type {restaurant_guid, restaurant_name} (design §4.2)
- Add listRestaurantsMutation calling focus-list-restaurants edge fn; handles all
  three error shapes (transport reject, HTTP error, Focus-side data.error message)
- Fix triggerManualSync to accept and spread options ({startDate,endDate}) into the
  invoke body; was silently dropping custom range (design §8.5 / Frontend critical #1)
- Add __focusRefetchInterval exported helper (polls 8000ms while backfilling; returns
  false on done/inactive/error/null; design §8.5 / Frontend major #1)
- Wire refetchInterval into useQuery with the helper; staleTime stays 30000
- 16 new Vitest tests (34 total); zero regressions (5189 passed)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(pos/focus): replace GUID input with server-fetched restaurant picker in setup wizard

Implements design §4.3 + §8.5 (A4): step machine instructions→credentials→select→done.
Removes RESTAURANT_GUID_PATTERN and the GUID text input; "Find my restaurant(s)" button
calls listRestaurants and advances to a select step with auto-select-when-one and a
shadcn Select for multiple results. aria-current="step" moved to role="listitem". Done
step copy updated to background 90-day import framing. 18 new Vitest tests (40 total,
5207 suite total, zero regressions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(focus): pure processBackfillBatch helper + 23 Vitest tests (B1)

Budget/day-cap loop, cursor advance, initialSyncDone flag, injectable
clock+processDayTransactions — fully tested with zero regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(focus): batch per-check item/payment upserts + processDateRangeTransactions (B2)

Replace per-row await loops in upsertItems/upsertPayments with a single array
upsert per check (§8.4), collapsing hundreds of sequential DB round-trips per
day into two. Add processDateRangeTransactions export (iterates explicit date
list, skipUnifiedSalesSync each day, one RPC for the full range) with injectable
processDayTransactions for testability. Add DateRangeSyncDeps type.

15 new Vitest tests (41 total in file); full suite 5245 tests, zero regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(focus/B3): small backfill kick via processBackfillBatch + CAS + custom-range + error status

- Lynk backfill path delegates to processBackfillBatch({budgetMs:12_000, maxDays:5})
  for instant feedback; the durable 5-min cron finishes the rest server-side.
- Cursor write uses CAS (§8.1): .eq('sync_cursor', readCursor).select() guards
  concurrent pg_cron ticks from clobbering each other.
- On batch error: persists connection_status='error' + last_error + last_error_at
  so the frontend polling stops and the operator sees the stall (§8.3).
- Response includes backgrounded=true while initial_sync_done=false.
- Custom range (body startDate+endDate, Lynk only): validates both required,
  start≤end, span≤14 days (§8.2 — synchronous, no waitUntil); calls
  processDateRangeTransactions; returns {daysSynced, status}.
- Bad range (span>14, start>end, unparseable, missing one field) → HTTP 400.
- Portal path CAS-updated to use the same 3-eq chain for multi-tenant safety (§8.1).
- 17 new Vitest tests (44 total in file); full suite 5262 tests, zero regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(focus/B4): focus-backfill-sync function + cron (durable 5-min backfill engine)

New focusBackfillSyncHandler.ts: timing-safe Bearer gate, queries only
is_active+initial_sync_done=false+api_key IS NOT NULL (Lynk backfill rows),
round-robin LIMIT 5, processBackfillBatch per restaurant (maxDays=7,
per-restaurant budget from remaining wall time capped at 50s), CAS write
(§8.1), error status write on batch failure (§8.3), 2s inter-restaurant
sleep, isolated errors continue loop. Returns {processed,errors,elapsedMs}.

Thin focus-backfill-sync/index.ts mirrors focus-bulk-sync entry pattern.
config.toml: [functions.focus-backfill-sync] verify_jwt=false.
Migration 20260702120000_focus_backfill_cron.sql: idempotent unschedule
guard, */5 * * * * schedule, current_setting missing_ok GUC for graceful
auth on unset keys (§8.6). pgTAP 48_focus_backfill_cron.sql asserts cron
job exists on */5 * * * * schedule. 24 Vitest tests; full suite 5286
tests, zero regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(focus/B5): focus-bulk-sync cedes Lynk backfill to 5-min cron (TDD)

Add skip guard at the top of the isLynkPath block in processConnection:
if (!row.initial_sync_done) return { unchanged cursor/flag } so the 6-h
bulk-sync never races the 5-min focus-backfill-sync cron on sync_cursor.

Test-first (design §8.7): 4 new Vitest tests assert —
  • backfilling Lynk row: processDayTransactions NOT called, cursor unchanged
  • skipped row still counted as processed (no error surfaced)
  • incremental Lynk row (initial_sync_done=true): still processed (2 calls)
  • portal backfilling row (no api_key): still processed via processReportDay

Also mocks focusTransactionSyncHandler + focusLynkClient at module level
so future Lynk-path tests in this file don't require real network calls.

Full suite: 5290 tests, zero regressions (5286 before + 4 new).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(focus-sync): B6 — single handleSync call, background toast, passive progress, a11y

Design §8.5 / Frontend critical #2 + major #4:

FocusSync.tsx:
- Remove dead state: syncProgress, totalDaysSynced, syncResult, SyncResult interface
- Remove SyncProgressDisplay and SyncResults renders (no progress loop)
- handleSync makes ONE call: recent/initial → triggerManualSync(restaurantId);
  custom → triggerManualSync(restaurantId, {startDate, endDate}) in yyyy-MM-dd
- Toast changed to background message: "Running in the background. You can leave
  this page; it keeps going."

SyncComponents.tsx — InitialSyncPendingAlert:
- Collapse to single message path (drop hasProgress bifurcation)
- New copy: "Importing your last 90 days in the background (N of 90). No need to
  keep this page open."
- Wrap progress count in <span role="status" aria-live="polite" aria-atomic="true">
- Add Progress bar with aria-label="Sync progress" aria-valuemin/max
- Remove dead "First sync pending" / "Click Sync Now to continue" branches

15 new Vitest tests (TDD: RED → GREEN); full suite 5305 tests, zero regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(focus-sync): apply Apple/Notion guidelines to SyncComponents + fix useQuery generic

- Replace direct palette colors (green-100/700, text-primary, text-green-600) with semantic
  tokens (text-foreground, border-border/40, bg-muted/*)
- Align typography to design scale: text-[14px]/[13px]/[12px] replacing text-sm/text-xs/text-2xl
- Apply canonical card container style (rounded-xl border border-border/40 bg-muted/30)
  to ConnectionStatus and SyncResults
- Apply canonical primary button style to SyncButton (h-9 px-4 rounded-lg bg-foreground)
- Add aria-hidden to decorative icons in SyncProgressDisplay and HowSyncingWorksInfo
- Fix useFocusConnection useQuery<FocusConnection | null> explicit generic so TypeScript
  resolves connection type correctly (was resolving to unknown due to refetchInterval cast)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* simplify(focus): hoist todayInTz, import shared constant, extract handlers, unify timestamps

Reuse:
- focusListRestaurantsHandler: import FOCUS_ALLOWED_ROLES from focusReportClient
  instead of re-defining an identical Set locally

Efficiency:
- focusBackfillBatch: hoist todayInTz() call above the loop (timezone + now are
  constant per call; no need to rebuild Intl.DateTimeFormat on each iteration)
- focusBackfillSyncHandler + focusSyncDataHandler: capture a single nowIso
  constant per update block so last_sync_time, updated_at, and last_error_at
  are guaranteed to share the same timestamp

Simplification:
- FocusSetupWizard: replace 'no-restaurants' string sentinel with a dedicated
  listEmpty boolean state (string overloaded two different meanings)
- FocusSetupWizard: extract inline async Sync Now click handler to named
  handleSyncNow, consistent with handleFindRestaurants/handleSaveAndConnect/handleRetry
- SyncComponents: extract capitalize() helper for config.dataLabel (3 call sites)

Tests: 5305 passed, 0 regressions; typecheck: 0 errors

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(review): fold 7b findings — sandbox URL bleed, CAS miss, dead code, UI copy

- Thread FOCUS_API_SANDBOX_URL through BackfillSyncDeps / BulkSyncDeps /
  SyncDataDeps + index.ts entry points (Codex critical: sandbox connections
  were silently falling back to the production URL — focusApiBaseUrl §6)
- Inspect CAS .select() result in focusBackfillSyncHandler and
  focusSyncDataHandler: skip incrementing processed / return stale data when
  0 rows (another tick already won — spec §8.1; security + performance major)
- Remove dead Lynk backfill block in focusBulkSyncHandler (lines 214-231);
  B5 skip guard at line 187 made it unreachable — security/maintainability/
  sound-logic major
- Fix SyncModeSelector description "max 90 days" → "max 14 days" to match
  the MAX_CUSTOM_RANGE_DAYS=14 server-side cap (sound-logic major)
- Fix useFocusConnection staleTime: 0 (was 30000) so 8s refetchInterval
  actually fires network requests during backfill (performance minor)
- Remove dead serviceClient variable in focus-list-restaurants/index.ts
  (OCR-rules / maintainability minor)
- Merge duplicate focusLynkClient imports in focusBackfillSyncHandler
  (maintainability minor)
- 2 new Vitest tests: CAS miss not counted as processed; sandboxBaseUrl
  threaded to txConfig.baseUrl; 5307 tests green, 0 TS errors

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(focus-sync): show 'Sync complete' toast title for synchronous custom-range sync

Custom-range sync is processed synchronously by the edge function, so
'Import started' was misleading — the sync is already done when the
toast appears. Use 'Sync complete' for custom mode, keep 'Import
started' for recent/initial background kicks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): remove no-explicit-any in new files and fix block comment parsing error

- focus-backfill-sync/index.ts: escape cron schedule comment (*/5 was ending
  block comment prematurely, causing ESLint parse failure)
- focusTransactionSyncHandler.test.ts: replace `as any` casts with typed
  alternatives (TransactionSupabaseDeps, FetchDatafeedFn, Record<string,unknown>[],
  unknown[], { error?: string }) — zero no-explicit-any errors in all new files

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(focus-backfill): handle inprogress status in batch, add guard in handleSyncNow, thread staleTime fix

- focusBackfillBatch: add 'inprogress' to BackfillBatchResult.status union;
  first-day inprogress now surfaces without a completed-day status
- focusBackfillSyncHandler: thread sandboxBaseUrl to txConfig.baseUrl; skip
  restaurant when CAS misses (0 rows updated = stale cursor); write error
  status on batch failure; add missing test coverage for new paths
- FocusSetupWizard: add isSyncingNow guard in handleSyncNow to prevent
  concurrent sync kicks; add finally block to always reset flag
- SyncComponents/useFocusConnection: staleTime 0 so 8s refetchInterval fires
  reliably during backfill (was 30000)
- 15 new Vitest tests; full suite 5312 tests, zero regressions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: retry Supabase Preview after transient 502 infra failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(focus): address PR #564 review findings — security, stability, test coverage

Security (CodeRabbit major × 2):
- focusDatafeedParser: add processEntities:false to XMLParser to prevent XXE/DoS
  via crafted DTD entities in Focus datafeed XML (fast-xml-parser v5 option)
- focus-backfill-sync/index.ts: fail closed on missing SUPABASE_URL/SERVICE_KEY
  to prevent Bearer timingSafeEqual('','') pass-through on misconfigured deploy

Stability (CodeRabbit minor × 3):
- focusBackfillSyncHandler: add .catch() to fire-and-forget error-state write
  so an unexpected promise rejection is logged rather than silently dropped
- focusBulkSyncHandler: persist connection_status='error' + last_error when Lynk
  incremental sync fails (matches legacy portal path and backfill cron behavior)
- focusBulkSyncHandler: bump last_sync_time in catch block so failed connections
  don't monopolize round-robin ORDER BY last_sync_time ASC NULLS FIRST

Error persistence (CodeRabbit minor):
- focusSyncDataHandler: persist connection_status='error'+last_error when Lynk
  incremental processDayTransactions fails; mirrors backfill error path

Test coverage (4 new tests, github-code-quality, CodeRabbit nit × 2):
- focusListRestaurantsHandler.test.ts: remove unused beforeEach import (lint fix)
- focusSyncB6.test.tsx: rename test title to match actual assertions (no toast)
- 46_focus_transactions_schema.sql: plan(36→38) + 2 new card_last4 cases
  (too-short 3-digit and non-digit '12AB') to fully enforce PCI constraint
- focusBulkSyncHandler.test.ts: 2 new tests for error persistence + starvation fix
- focusSyncDataHandler.test.ts: 2 new tests for incremental error persistence
  + import focusLynkClient to control fetchDatafeed in error scenario

Doc fixes (CodeRabbit nit):
- 20260701140000_focus_transactions_cron.sql: fix comment '*/6' → '0 */6'

Full suite: 5314 tests pass, 0 TS errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(focus): add no-explicit-any eslint-disable header to mock-heavy test files

Matches the repo convention (focusSaveConnectionHandler.test.ts et al.) for Supabase
client mocks; removes 60 lint errors introduced when review-fix commits added mock coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(focus-backfill): sync backfilled days into unified_sales (Codex P1)

Backfill days are written with skipUnifiedSalesSync:true, but both unified_sales
crons only re-sync the last 2 business days — so backfilled history (days 3..90)
would never reach unified_sales / P&L. processBackfillBatch now syncs its own
processed date range via sync_focus_transactions_to_unified_sales after the loop,
and a full 90-day window on completion as a reconciliation backstop. Non-fatal on
RPC error (order rows are durably written). +4 tests.

Also adds no-explicit-any eslint-disable header to 3 mock-heavy test files (repo
convention) to clear 60 lint errors surfaced by the review-fix test coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(focus-bulk-sync): skipped Lynk backfill rows get NO write (CodeRabbit Major, 9d)

The B5 skip guard returned row.sync_cursor unchanged, but the persistence path
still wrote it back — racing focus-backfill-sync: if the 5-min cron advanced the
cursor between bulk-sync's read and this write, bulk-sync regressed it to the
stale value (and spuriously bumped last_sync_time, perturbing the round-robin).
processConnection now returns skipped:true and the caller writes nothing for
skipped rows (still counted as processed). Test updated to assert no write.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
jdelgado2002 added a commit that referenced this pull request Jul 3, 2026
- Remove unused `screen` import from coverageChart.test.tsx (#1 github-code-quality)
- Fix delta bar height: barPct was applied inside a flex-1 half-column, so
  max bars only reached 24% of chart height; switch to absolute positioning
  so barPct is relative to the full column height as intended (#4 Codex P2)
- Fix SPLH tooltip contradiction: tooltip line 4 recomputed needed via
  Math.round while line 2 showed h.needed (Math.ceil + minimum crew);
  now both lines use h.needed to stay consistent (#2 Codex P2)
- Also fix no-demand bar in delta view — same absolute-height issue

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jdelgado2002 added a commit that referenced this pull request Jul 4, 2026
…sales ÷ SPLH (#574)

* docs(scheduling): design + plan for coverage panel redesign

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(scheduling): pure hourly coverage summary + verdict

Add summarizeCoverageHours and buildVerdict pure functions with full
unit tests (5 specs covering min-aggregation, demand alignment, null-demand
path, metAll, worst-hour selection). TZ-agnostic (minute-offset inputs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scheduling): add CoverageVerdict component with 3-state verdict display

Renders a status dot + sentence above the coverage chart:
- No demand → neutral dot + prompt to add targets
- All met → green dot + "Meeting demand all day."
- Short → red dot + short-staffed count + worst-hour subline ("Biggest gap: 5 PM — short 3")

Accepts an optional formatHour() override for testability.
5 tests cover all three states, headline, and subline presence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scheduling): CoverageChart — area + delta SVG views with shortfall wedges

Implements CoverageChart.tsx (replaces CoverageCurve) and its
test suite (12 tests, TDD red→green).

Area view: proper viewBox (no preserveAspectRatio=none), y-axis
gridlines + labels, stepped scheduled area, dashed needed line with
direct end-label, red shortfall wedges between scheduled and needed
(data-shortfall), worst-hour deficit label, and a legend row.

Delta view: one diverging bar per hour (data-bar=short|covered|no-demand)
from a zero baseline with signed labels; red for short, emerald for over.

Both views carry role=img + <title>/<desc> for accessibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scheduling): per-hour status strip with aria-labels and sr-only gap list

Adds CoverageStatusStrip — a row of one cell per hour colored green (covered)
or red (short), each with an aria-label ("5 PM, short 2") so color is never
the only cue.  A visually-hidden <ul aria-label="Understaffed windows"> enumerates
short hours for screen readers, folding in the former CoverageGapList guarantee.
6 unit tests cover: short labels, covered labels, no-demand, empty array → null,
and no understaffed list when fully covered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scheduling): redesign coverage panel — verdict, shortfall chart, +/- bars, status strip

Wire CoverageVerdict, CoverageChart (area + delta views), and CoverageStatusStrip
into ShiftTimelineTab, driven by a single hourly summarizeCoverageHours memo.
Adds a Chart|+/- bars view toggle (ToggleGroup). Removes CoverageCurve and
CoverageGapList (gap accessibility is now folded into CoverageStatusStrip's
sr-only list). Deletes coverageGapList.test.tsx; new wiring tests added to
shiftTimelineTab.test.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: update progress.md — task 5 completed (f4ecb77)

* fix(ui): replace emerald-* literals with semantic success token

CoverageVerdict, CoverageStatusStrip, and CoverageChart used
bg-emerald-500 / fill-emerald-500 / text-emerald-700 direct color
literals in violation of the no-direct-colors rule. Replaced with
bg-success / fill-success / text-success which resolves to the same
hue via --success CSS variable and respects dark-mode theming.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: record Phase 5 UI review completion in progress.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(scheduling): deduplicate hour formatter and x-axis labels in coverage panel

- Extract `formatCoverageHour` into `coverageSummary.ts` as a single canonical
  export, replacing three near-identical copies of `defaultFormatHour`/
  `formatHourLabel` scattered across CoverageVerdict, CoverageStatusStrip, and
  CoverageChart. The chart variant also had a subtle inconsistency (no space
  before AM/PM) which is now resolved.
- Lift the x-axis hour-label JSX block out of both branches of `Axes` into a
  shared `xAxisLabels` constant — the block was copy-pasted verbatim for area
  and delta views.
- Simplify `computePeak` from a manual `for` loop with two `if` branches to a
  one-liner `reduce` + `Math.max`.

All 40 tests still pass; typecheck and lint are clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: update progress.md for Phase 6 (Simplify)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(review): address all major findings from Phase 7b multi-reviewer pass

- test: remove deleted CoverageCurve import/tests from timelineComponents.test.tsx
  (codex/major — was breaking CI on module resolution)
- fix(coverage): demand fallback ?? null instead of ?? 0 so off-peak hours
  are treated as no-target rather than silently covered (sound-logic/major)
- fix(delta-view): DeltaView and Axes use deltaPeak (max abs delta) for bar
  and axis scale rather than headcount peak; bars now fill the chart correctly
  when scheduled values far exceed deltas (sound-logic/major)
- fix(delta-view): delta===0 renders a 2px success tick at zero-line so
  "exactly met" is visually distinct from "no bar" (sound-logic/major)
- fix(delta-view): cap barH and clamp labelY so text never clips viewBox
- fix(style): replace 3 nested ternaries with if/else blocks (ocr-rules/major)
- fix(style): replace constant inline style on dashed legend line with
  Tailwind arbitrary-value classes (ocr-rules/minor)
- fix(key): use stable startMin as React key for x-axis SVG text (ocr-rules/minor)

Tests: 58/58 passing; typecheck clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(review): address all major findings from Phase 7c CodeRabbit pass

Fixes 10 actionable findings from the CodeRabbit review:

Security / SSRF:
- focusTestConnectionHandler: widen isSafeBase() to also allowlist the
  operator-configured sandbox host (FOCUS_API_SANDBOX_URL); previously,
  any non-focuspos.com sandbox URL caused the SSRF check to reject the
  request even though the host comes from a trusted env var.
- focusTestConnectionHandler: wrap decrypt/focusApiBaseUrl/btoa in
  try/catch and validate environment value; unhandled throws bypassed
  writeStatus and left connection_status stale.

Data integrity:
- SQL migrations (unified_sales): add Step 0 check-level orphan DELETE
  before the per-check loop, so unified_sales rows for checks deleted
  from focus_orders (voided checks) are cleaned up. Applies to both
  20260701130000 and 20260701160000 (re-create migrations).
- CoverageChart: clamp no-demand bar height to halfH - 2 in delta view;
  without clamp, a large scheduled headcount + small deltaPeak caused
  the neutral bar to overflow the plot viewBox.

Reliability:
- focusTransactionSyncHandler: isolate per-check upsert errors with
  try/catch so a single malformed check no longer aborts the entire
  day's sync and prevents the unified_sales RPC from running.
- focusLynkClient: guard both syncRes.text() and blobRes.text() reads
  in try/catch to honour the "never throws" JSDoc contract; a timeout
  mid-download caused an uncaught AbortError.

Type safety:
- focusSaveConnectionHandler: add optional columns param to
  ServiceClient.select() interface (TS2554 compile error).

Comments / housekeeping:
- SQL migration comments: align "last 2 business days" to "last 3
  business days" (CURRENT_DATE - interval '3 days' is the actual value).
- Cron comment: correct */6 to 0 */6 * * * (every 6 hours, not minutes).
- Test descriptions: prefix PII-skip and financial tests with CRITICAL:.
- Untrack progress.md (already in .gitignore; leaked local file paths).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(review): address CodeRabbit findings on coverage panel

- CoverageChart delta view: scale no-demand bars by headcount peak, not deltaPeak
  (which collapsed to 1 and pegged every bar to max height). (Major)
- CoverageChart: drop internal L/R plot margins so hour columns align with the
  TimelineAxis ticks and shift bars; y-labels move inside, 'Needed' named in legend. (Minor)
- CoverageStatusStrip: add role=img so each cell's aria-label is honored. (Minor)
- coverageSummary.test.ts: CRITICAL: prefix on core-logic tests per path instructions. (Major)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(scheduling): design + plan for per-area coverage + demand explainer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(scheduling): summarizeAreaCoverage — per-area scheduled coverage

Adds `summarizeAreaCoverage` pure helper to `src/lib/coverageSummary.ts`
that groups shifts by employee area, calls `computeDayCoverage` per area,
and returns per-area `CoverageHour[]` with demand=null (scheduled-only).
Adds `AreaCoverage` export type. Unassigned employees bucketed under the
`UNASSIGNED_LABEL` key, areas sorted alphabetically with Unassigned last.

Companion tests in `tests/unit/coverageSummary.test.ts` exercise area
grouping, the Unassigned bucket, and the empty-shifts early-exit; marked
CRITICAL as required by the plan. Tests pass under TZ=UTC and TZ=Asia/Tokyo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scheduling): CoverageDemandInfo — demand explainer popover component

Adds CoverageDemandInfo.tsx: a shadcn Popover trigger that explains how
the "needed" staffing count is derived (projected sales ÷ SPLH, capped
at minimum crew). Includes a link to /settings for adjusting targets.
Fully keyboard-accessible (focusable trigger, Escape closes popover).
Five unit tests cover: trigger aria-label, formula text, settings link
href, Covered/Short vocabulary, and Escape-key dismissal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scheduling): AreaCoverageStrips component — per-area scheduled headcount strips

Adds AreaCoverageStrips.tsx that renders one compact row per area (area label +
per-hour scheduled headcount cells) when the Timeline is grouped by Area.
Cells are neutral (no red/green) since per-area demand is not yet available.
A footnote states demand targets are whole-location. Returns null when areas=[].

Paired with 6 unit tests covering: empty guard, area name labels, CRITICAL
role="img" aria-labels (area + hour + count), visible headcount text, the
demand footnote, and the Unassigned bucket label.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scheduling): CoverageStatusStrip shows have/needed fraction in cells

Cells now display `scheduled/needed` (e.g. `3/5`) instead of `✓` / `−N` so
managers can see the exact comparison at a glance without inferring from color
alone. No-demand cells fall back to the bare scheduled count. aria-labels
updated to expose the fraction ("3 of 5, short 2") for screen readers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scheduling): per-area coverage strips + demand explainer in Timeline

Wire CoverageDemandInfo + AreaCoverageStrips into ShiftTimelineTab:
- Add CoverageDemandInfo popover to the coverage panel header (next to verdict)
- Compute areaCoverage via useMemo + summarizeAreaCoverage; active only when groupBy === 'area'
- Render AreaCoverageStrips (pl-[120px] aligned) below CoverageStatusStrip when grouped by area
- 4 new CRITICAL tests: demand trigger, per-area rows, strips hidden on position groupBy, footnote

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style(timeline): UI review fixes — import order + typography consistency

- CoverageDemandInfo: move Lucide icon import after shadcn imports
  (matches CLAUDE.md import order: shadcn #2, icons #3)
- CoverageStatusStrip: align cell typography with AreaCoverageStrips
  (hour label text-[9px], value text-[11px]); widen cell gap to gap-[3px]
  for visual consistency across both strip components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(coverage): remove dead variable, deduplicate deltaPeak computation, simplify label lookup

- DeltaView now receives `deltaPeak` as a prop from CoverageChart (already
  computed there for Axes) instead of recomputing it from the same `hours`
  array — eliminates the duplicate Math.max + spread.
- Remove dead `isOver` variable in DeltaView; the zero-delta branch is now
  expressed as the clearer `h.delta === 0` guard.
- Drop redundant `as number` casts in buildVerdict's worst-finding loop
  (shortHours is already filtered to delta < 0, so non-null is guaranteed).
- CoverageStatusStrip builds a `labelByStartMin` Map once so formatHour is
  called a single time per hour instead of twice (once for the visual strip,
  once for the sr-only list).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(review): address critical/major multi-reviewer findings

- coverageSummary: emit scheduled=0 hours when demand is configured but no
  shifts are scheduled, so buildVerdict reports hasDemand:true and the full
  shortfall count instead of silently hiding fully-unstaffed periods
- ShiftTimelineTab: remove px-4 from coverage panel wrapper so pl-[120px]
  chart/strip children align with TimelineAxis ticks and shift lanes below
- CoverageDemandInfo: replace <a href> with <Link to> (React Router 6 SPA
  navigation — prevents full-page reload); wrap test in MemoryRouter
- CoverageStatusStrip: guard hasDemand on both delta !== null AND needed !== null
  to prevent h.needed! assertion on inconsistent upstream state
- CoverageChart: wrap in React.memo to skip O(H) SVG path recomputes on
  setActiveShift re-renders; clamp surplus label above MARGIN_TOP+8 to prevent
  clipping above SVG viewBox on max-height positive bars
- coverageSummary.test: add regression tests for zero-coverage-with-demand fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(coverage): clamp hour-bucket filter to visible window bounds

When the day window is not hour-aligned, `firstHourStart` can start before
`window.startMin`, causing coverage samples outside the visible range to
bleed into the first bucket's scheduled count. Clamp the filter to
`[max(start, window.startMin), min(start+HOUR, window.endMin))` so only
samples inside the actual window are counted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(coverage): correct footnote copy from 'per-brand' to 'per-area' in AreaCoverageStrips

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(review): triage PR #569 comments — Codex P1 delete-date re-aggregation + test cleanups

- SQL: Step 0 orphan DELETE now captures deleted sale_dates via RETURNING into
  v_deleted_dates; final aggregate_unified_sales_to_daily call UNIONs them in
  so days where the only change is a voided-check deletion are still
  re-aggregated (Codex P1 — stale daily_sales/P&L fix).
- test: remove unused `within` import from areaCoverageStrips.test.tsx
  (github-code-quality bot nit — lint cleanliness).
- test: rename misleading focusTestConnectionHandler test from "rejects a
  sandbox URL with a different host (SSRF guard)" to "falls back to production
  host when no sandboxBaseUrl is configured" — title now matches what it
  actually verifies (CodeRabbit major).
- test: add coverageSummary regression documenting by-design behaviour that
  summarizeAreaCoverage omits areas with employees but no shifts (CodeRabbit
  trivial suggestion clarified).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(9d): write triage artifact + update progress for PR #569

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(scheduling): drop out-of-scope Focus edits + ephemeral files from area-coverage PR

- Revert focus_transactions_security.sql (an already-applied, immutable migration)
  and focusTestConnectionHandler.test.ts to the base — Focus fixes belong in their
  own task, not this coverage PR.
- Remove committed progress.md (ephemeral) and the 9d-triage artifact; gitignore progress.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(scheduling): design + plan — chart grid alignment, hover insight, #569 recovery

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(scheduling): thread projectedSales/laborPct into CoverageHour

Extend CoverageHour interface with projectedSales and laborPct (null when
no recommendation available). summarizeCoverageHours gains an optional
recs parameter; when provided each hour entry is enriched from the matching
HourlyStaffingRecommendation by clock hour. Backward compatible — all
existing callers unchanged.

TDD: 2 CRITICAL tests added (red→green); 11 prior tests remain green;
coverageChart, areaCoverageStrips, coverageStatusStrip suites unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(scheduling): update CoverageHour fixtures to include projectedSales/laborPct

All object-literal CoverageHour fixtures in coverageChart, areaCoverageStrips,
and coverageStatusStrip test suites now carry the two fields added in task 1a
(projectedSales: null, laborPct: null). Keeps fixture shapes in sync with the
interface so future strict-type test runners don't flag missing properties.

All 60 coverage-related tests pass; typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scheduling): grid-aligned HTML column CoverageChart (task 2a)

Replace fixed-viewBox SVG CoverageChart with per-hour absolutely-positioned
HTML columns using the shared minToPct scale (same as TimelineBar/TimelineAxis).
Columns align exactly with the hour grid at every viewport width, including
horizontal scroll. Update test selectors from SVG path checks to data-hour-col /
data-shortfall / data-bar HTML attribute selectors; add minToPct positioning
assertions confirming 0% / 25% left/width for a 10:00-14:00 window. All 59
coverage-related tests green; typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update progress.md for task 2a completion

* feat(scheduling): tooltip shell + ShiftTimelineTab wiring for coverage chart (task 2b)

Add keyboard-focusable columns (tabIndex=0, aria-label) and shadcn Tooltip shell
(TooltipProvider + Tooltip/TooltipTrigger/TooltipContent) to each hour column in
CoverageChart. Wire ShiftTimelineTab to pass minToPct and targetSplh to CoverageChart
and thread dayRecommendations into summarizeCoverageHours so projectedSales/laborPct
flow through to the chart. All 61 coverage-related tests green; typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update progress.md for task 2b completion

* fix(scheduling): forwardRef on AreaColumn/DeltaColumn for correct TooltipTrigger asChild wiring

Radix UI's Slot (used by TooltipTrigger asChild) must be able to
forward a ref to the child element. AreaColumn and DeltaColumn were
plain function components, causing React to warn "Function components
cannot be given refs". Convert both to forwardRef so the tooltip
trigger can wire the ref properly and the warning is silenced.

Add two RED→GREEN tests in coverageChart.test.tsx (accessibility suite)
that spy on console.error and assert no forwardRef warning fires for
either view — ensuring this regression cannot return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update progress.md for task 2c completion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(scheduling): add call-site wiring tests for ShiftTimelineTab (Task 2d)

Adds three tests in a new 'call-site wiring' describe block to verify
that ShiftTimelineTab correctly threads dayRecommendations, activeSettings
target_splh, and minToPct to the appropriate downstream functions:
  - Confirms useWeekStaffingSuggestions is called with restaurantId + weekDays
  - Confirms CoverageChart columns render (data-hour-col) when recommendations exist
  - Confirms columns carry style.left (minToPct wired)

Refactors the vi.mock to use a named mockFn variable so individual tests
can override the return value via mockReturnValue, enabling per-test
fixture injection without duplicating the full module mock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update progress.md for Task 2d completion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update progress.md for Task 2e verification (chart+tab suites all green)

All 66 coverage-related tests pass; typecheck clean; no lint errors in modified files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scheduling): per-hour tooltip — scheduled/needed + projected sales ÷ SPLH

Implements Task 3a (TDD RED→GREEN):
- Exports `buildHourTooltip(h, targetSplh)` pure helper that returns tooltip
  lines: time range, scheduled/needed counts, projected sales (when rec present),
  ÷ SPLH math (when targetSplh and sales), verdict (Short N/Covered/Right on target/
  No demand target).
- Updates `buildColumnAriaLabel` to join all lines so keyboard users get the
  same information as hover users.
- Wires `buildHourTooltip` into `<TooltipContent>` in both area and delta views,
  replacing the bare ariaLabel string with a multi-line <p> layout.
- Sets `delayDuration={0}` on `TooltipProvider` for instant tooltip open.
- Adds 13 new tests: 10 `buildHourTooltip` unit tests (CRITICAL-prefixed for
  all branches: short, covered, spare, right-on-target, no-demand, sales omitted,
  SPLH omitted) + 2 aria-label integration tests + 1 full TooltipContent
  wiring contract test; all 28 coverageChart tests pass, 79 total across all
  6 coverage suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update progress.md for Task 3a completion

* chore: update progress.md for Task 3b completion

Task 3b (implement buildHourTooltip and wire TooltipContent) was completed
as part of the Task 3a RED→GREEN cycle in commit a2e6504. This progress
entry documents the implementation details and confirms all 79 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update progress.md for Task 3c completion (tooltip test+lint verification)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update progress.md for Task 4a full verification

All verification checks pass:
- typecheck clean, lint 0 errors on branch-modified files
- 79 coverage-related tests pass under TZ=UTC
- 5374 total vitest tests pass (5 focus* files fail due to pre-existing
  missing fast-xml-parser on main — not introduced by this branch)
- recovered #569 suites (areaCoverageStrips 6, coverageDemandInfo 5,
  coverageStatusStrip 8, shiftTimelineTab 19) all pass
- production build succeeds in 50.65s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* simplify(coverage-chart): unify duplicate map branches and remove trivial aliases

Three targeted cleanups with no behavior change:
- CoverageChart: merge identical area/delta `hours.map()` branches into one,
  switching only the inner column component — removes ~35 lines of duplicated
  tooltip + aria-label logic.
- CoverageChart: remove `buildColumnAriaLabel` one-liner wrapper (was just
  `buildHourTooltip(h, t).join(', ')`); inline at the single call site.
- CoverageChart: remove `shortfallBottomPct` alias for `scheduledPct` in
  AreaColumn — the two were always equal.
- coverageSummary: replace ternary `rec ? rec.x : null` with optional-chaining
  `rec?.x ?? null` for idiomatic null propagation.

All 79 coverage-related tests pass; typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update progress.md for Phase 6 simplify

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): timeline chart — addresses codex + sound-logic + ocr-rules findings

- ShiftTimelineTab: remove lanes===0 early-return that suppressed the coverage
  panel on fully-unstaffed days; empty-shift message is now rendered inline below
  the coverage panel so demand shortfalls are visible even when no lanes exist
  (codex finding, severity=major)
- buildHourTooltip: guard targetSplh > 0 before dividing projectedSales to
  prevent 'Infinity needed' appearing in tooltip when target is zero
  (sound-logic finding, severity=minor)
- DeltaColumn: extract shared outer wrapper div out of three early-return branches
  into a single render path, switching only innerContent; eliminates ~20 lines of
  duplicated ref/data-hour-col/tabIndex/aria-label/className/style markup
  (ocr-rules finding, severity=major)

All 79 coverage-related tests pass; typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(progress): record phase 7b findings and fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(verify): phase 8 — all checks pass (unit 5374, e2e 23, typecheck clean, build green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: record Phase 9a — PR #574 opened

* chore(ci): record Phase 9b CI green — all checks pass on PR #574

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(timeline): address Codex + github-code-quality review comments

- Remove unused `screen` import from coverageChart.test.tsx (#1 github-code-quality)
- Fix delta bar height: barPct was applied inside a flex-1 half-column, so
  max bars only reached 24% of chart height; switch to absolute positioning
  so barPct is relative to the full column height as intended (#4 Codex P2)
- Fix SPLH tooltip contradiction: tooltip line 4 recomputed needed via
  Math.round while line 2 showed h.needed (Math.ceil + minimum crew);
  now both lines use h.needed to stay consistent (#2 Codex P2)
- Also fix no-demand bar in delta view — same absolute-height issue

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(9d): update progress.md for Phase 9d review-comment triage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: untrack ephemeral progress.md from PR; record stacked-PR-loss lesson

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
jdelgado2002 added a commit that referenced this pull request Jul 4, 2026
* fix(focus): cut datafeed parse CPU ~10x + sync error hygiene

## Root cause of HTTP 546 (edge-worker CPU limit)

The Focus POS Lynk datafeed returns ~4.5 MB of XML per business day.
Roughly 90% of that payload is static menu/config data (Configuration,
MenuItems, Employees sections) that we never read. When a user triggers a
6-day custom-range sync, focus-sync-data was handing 6 × 4.5 MB = 27 MB to
fast-xml-parser in a single edge-function invocation, pushing it past the
Deno worker CPU budget and returning HTTP 546.

## Four fixes

### 1. Parser CPU fix — focusDatafeedParser.ts
Before calling fast-xml-parser, pre-extract only the <Checks>…</Checks>
block using cheap string indexOf scanning (O(n), no regex over 4.5 MB).
The extracted content is re-wrapped as <DailyData><Checks>…</Checks></DailyData>
so all existing object-path code (`doc.DailyData.Checks.Check`) remains
unchanged. If the block is absent (config-only feed for very old dates)
return {checks:[], deletedCheckIds:[]} immediately without calling the
XML parser at all. If extraction produces unparseable XML, fall back to the
full parse (never worse than before). Cuts per-day CPU by ~90%.

### 2. Remove in-worker aggregation RPC — focusTransactionSyncHandler.ts
processDateRangeTransactions was calling sync_focus_transactions_to_unified_sales
for the full range at the end of every custom-range sync. This was redundant
because a 5-minute pg_cron job (focus-transactions-unified-sales-sync) already
runs sync_all_focus_transactions_to_unified_sales() in Postgres and picks up
recently written focus_orders rows automatically. Removing the in-worker RPC
call eliminates the work that pushed the 6-day range over the CPU limit even
after fix #1, and also means partial progress survives a mid-range crash since
each per-day write lands immediately. Each per-day processDayTransactions call
retains skipUnifiedSalesSync:true (unchanged).

### 3. Transient no-blob_url retry — focusLynkClient.ts
Focus occasionally returns a valid 200 OK response from /api/lynk/sync but
with no blob_url in the payload; a retry always succeeds. When fetchDatafeed
receives an OK-shaped response missing blob_url, it now retries the POST once
after a default 1 500 ms delay (injectable via deps.sleep so tests don't
sleep) before returning the parse error. Exactly two POST attempts maximum.

### 4. Error hygiene — focusSyncDataHandler.ts, focusBackfillSyncHandler.ts
connection_status='error' / last_error were never cleared after a subsequent
successful sync, so a transient failure left a scary banner in the UI
indefinitely. Successful sync paths (status ok/empty) now write
connection_status='connected', last_error=null, last_error_at=null alongside
the cursor/timestamp fields. Error paths (status error) are unchanged.
Affects: Lynk backfill batch, Lynk incremental, portal path, and the cron
backfill handler.

## Tests
All existing tests pass unchanged. New tests added:
- focusDatafeedParser: large config wrapper parses identically; config-only
  feed (no <Checks>) returns empty without error
- focusTransactionSyncHandler: range path never calls the RPC (even when
  skipUnifiedSalesSync=false); single-day also no RPC
- focusLynkClient: first-attempt missing blob_url + second-attempt ok →
  success with 2 POSTs; both missing → error after exactly 2 POSTs
- focusSyncDataHandler: successful incremental and backfill write
  connection_status=connected + last_error=null
- focusBackfillSyncHandler: successful batch tick writes connection_status=
  connected + last_error=null

Full suite: 5485 tests pass (406 files), 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(focus): persist portal-path sync errors to connection_status (CodeRabbit)

The new success-clears-banner logic on the legacy portal path only wrote
connected/null when status!=='error' — a portal sync failure was reported in the
JSON response but never persisted, so a stale 'connected' state could mask a
failing sync. Now symmetric with the Lynk backfill/incremental paths (if/else
writing error state + message on failure). Also drop the unused syncStatus
destructure flagged by code-quality. +1 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
jdelgado2002 added a commit that referenced this pull request Jul 21, 2026
…/week/month) (#624)

* docs(labor): design for financial labor view (dashboard card + /labor page)

Distinct from the shipped scheduling SPLH feature (#611): financial framing
(labor % of sales, revenue/labor-hr), payroll-grade labor reconciled with P&L,
day/week/month, sales-volume busy-hours heatmap, editable target_labor_pct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(labor): fold Phase 2.5 review — real per-day sales, tz, tokens, stacked charts

- critical: drop usePnLAnalyticsFromSource (fakes per-day revenue); series revenue
  from real unified_sales via useSplhData/buildSplhTimeseries
- tz window boundaries via getTodayInTimezone
- dedicated --labor-over/under/balanced tokens (splh tokens are inverted)
- two stacked charts, not dual-axis; propagate capped; default target 22

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(labor): implementation plan (TDD tasks A–G)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(labor-pnl): add laborPnlAnalytics types + classifyBalance

Introduces src/lib/laborPnlAnalytics.ts: FinancialPoint, SalesVolumeCell,
LaborBalanceWindow, LaborPnlSummary, BalanceState, and LABOR_BALANCE_BAND
(default 6pt), plus classifyBalance(laborPct, targetPct, band) — the
over/balanced/under classifier that drives the labor financial view's
coloring and verdicts (design §3). Guards targetPct<=0 and a null
laborPct (no-sales bucket) as 'balanced'.

Task A1 of docs/superpowers/plans/2026-07-20-labor-financial-view-plan.md.

* feat(labor-pnl): add monthKeyOf + bucketKeyOf, export mondayOf for reuse

Adds monthKeyOf(dateStr) (calendar-month YYYY-MM key) and bucketKeyOf
(day passthrough / week via reused mondayOf / month via monthKeyOf) to
laborPnlAnalytics.ts, ahead of buildFinancialSeries (A3). Exports
splhAnalytics.mondayOf so it can be reused rather than duplicated, per
design §5.

Tests cover day/week/month bucket keys and the Dec->Jan year boundary.

* feat(labor-pnl): buildFinancialSeries — outer join + day/week/month bucketing

Joins the real daily sales series (buildSplhTimeseries 'day') with the real
daily labor series (useLaborCostsFromTimeTracking) on restaurant-local date,
then rolls up to day/week/month via the existing bucketKeyOf (design §5).
Outer join: a sales-only or labor-only day still surfaces as its own bucket
instead of being dropped. laborPct is null (never Infinity) when a bucket's
sales <= 0.

Tests: day passthrough, week Monday-start aggregation, month calendar
aggregation, 0-sales -> null pct, sales-only outer join, and bucket sort
across mixed sales-only/labor-only days.

* feat(labor-pnl): buildSalesVolumeGrid(cells, estimated) — busy-hours intensity + peak flag

Task A4 of the labor financial view plan (design §5/§8). Normalizes each
SplhGridCell's totalSales into a 0..1 intensity against the window's max
cell (0, never NaN, on an all-zero window), flags peak at/above the
prototype's 72% threshold, and passes the window-level `estimated` flag
through to every cell.

Design deviation note (documented, not silent): SplhGridCell carries no
per-cell `estimated` field, and buildSplhGrid derives every cell from
either real per-sale hours or the daily-spread fallback — never a mix — so
the function takes `estimated` as a second boolean parameter (mirroring
SplhHeatmap's existing `estimated` prop) rather than reading it off the
cells. SplhGridCell/buildSplhGrid stay unmodified per design §5's "reuse
without modification" list.

Tests: intensity scaling, peak threshold edges (72% inclusive vs 71.99%
exclusive), estimated passthrough (true/false), all-zero window (no NaN),
empty cells array. 5 new tests, 26 total in laborPnlAnalytics.test.ts.
No regression in splhAnalytics.test.ts (21 tests); typecheck + eslint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(labor-pnl): add summarizeLaborPnl (totals, verdict, over/under windows)

Task A5: summarizeLaborPnl(points, targetPct) sums a FinancialPoint[]
window into KPI totals + revPerLaborHr, builds a plain-English verdict
line (per design §2.1/§2.2) tagged with a tone ('over'|'balanced'|
'under'|'none'), and extracts contiguous over/under bucket runs as
LaborBalanceWindow[] staffing callouts. Guards 0-sales (verdictTone
'none', mirroring SplhSummary) and 0-hours (revPerLaborHr null, clause
omitted from the verdict) per design §6/§8. 8 new tests (34 total in
file); no regression in splhAnalytics.test.ts.

* feat(labor-pnl): add bucketKeyComparator + balanceStateClassName helpers

A6: extract the locale-aware bucket-key comparator buildFinancialSeries was
already using inline (SonarCloud S2871 lesson) into a reusable exported
bucketKeyComparator, and add balanceStateClassName (tone -> --labor-* token
className) mirroring splhAnalytics.verdictToneClassName for the upcoming
Labor cost UI components.

7 new tests in tests/unit/laborPnlAnalytics.test.ts (41 total in file);
typecheck + eslint clean; full unit suite (508 files, 6604 tests) green.

* feat(labor-pnl): add --labor-over/under/balanced CSS tokens

Dedicated financial-balance color scale for the labor P&L view
(light + dark), per design doc §7. Deliberately NOT aliased to the
existing --splh-lean/slack tokens, whose red/blue semantics are
inverted (SPLH red = understaffed; labor red = over budget) and
which can appear on-screen simultaneously with this card.

- light: --labor-over 0 72% 42%, --labor-under 32 90% 34%,
  --labor-balanced 142 50% 32% (all ≥4.5:1 contrast vs light bg)
- dark: --labor-over 0 72% 62%, --labor-under 32 92% 62%,
  --labor-balanced 142 50% 58%

tests/unit/laborColorTokens.test.ts mirrors the existing
splhColorTokens.test.ts pattern (HSL-triplet presence in :root/.dark)
plus a regression guard that these tokens are never aliased to
--splh-*.

* feat(labor-pnl): useLaborPnlCore shared hook (tz, target, restaurant-tz window, daily series)

Mirrors useSplhCore's shared-setup pattern for the new financial labor
surface: validates restaurant tz, reads target_labor_pct from
useStaffingSettings, derives the labor-cost fetch window from
getTodayInTimezone (restaurant-local "today", not host/UTC), and composes
useSplhData (sales+punches) + useLaborCostsFromTimeTracking (payroll-grade
daily labor via calculateActualLaborCost). dailySales is derived via the
existing buildSplhTimeseries(sales, sessions, tz, 'day') — real per-day
sales, matching design §4/§5.

hasData requires both sales AND punches (setup-invite empty state, same
semantics as useSplhCore). capped/isLoading/isError/error/refetch combine
both source hooks so useLaborPnlSummary/useLaborPnlAnalytics (next tasks)
get one surface to build on.

Test: tests/unit/useLaborPnlCore.test.ts (8 tests) — dailySales derivation,
tz/targetPct passthrough, restaurant-tz window boundaries (Honolulu
UTC-vs-local day fixture, mirroring useSplhData's own §5 S-min1 test),
loading/hasData guards, capped propagation, combined loading/error, combined
refetch.

* feat(labor-pnl): useLaborPnlSummary — 4-week dashboard-card hook

Wraps useLaborPnlCore + buildFinancialSeries/summarizeLaborPnl into a
lightweight summary for the LaborPnlCard dashboard card (design §2.1/§5):
period totals (labor % of sales, rev/labor-hr, verdict) plus a daily
sparkline, no hourly grid. Mirrors useSplhSummary's shape over useSplhCore.

The sparkline is the day-granularity FinancialPoint[] series itself, and
summary is summarizeLaborPnl run over that same series, so the KPI totals
are always exactly the sum of the sparkline buckets (design §8
reconciliation invariant) rather than a second independently-computed
total.

Tests (tests/unit/useLaborPnlSummary.test.ts, 5 tests): tz/window
passthrough to useSplhData; reconciliation (summary totals == sum of
sparkline); null labor%/'none' verdictTone on a no-sales window; no
`grid` property exposed; capped/hasData/loading/error/refetch
propagation from the core hook.

* feat(labor-pnl): useLaborPnlAnalytics — /labor page hook (series, grid, summary, updateTarget)

Builds the full labor-P&L dataset for the /labor page (design §2.2/§5):
day/week/month `series` via buildFinancialSeries, the busy-hours
`grid` (sales volume, via buildSplhGrid -> buildSalesVolumeGrid,
estimated-flagged from hasHourlyBreakdown), the period `summary`, and a
dirty-checked `updateTarget` wrapping useStaffingSettings.updateSettings
(design §7: guards the Enter+blur double-commit).

Extends useLaborPnlCore (C1) to also expose the raw `sales`/`sessions`
inputs and the target-write path (`updateSettings`/`isSavingTarget`) from
its existing useStaffingSettings call, so useLaborPnlAnalytics can build
the grid and the target control without a second useSplhData fetch or a
second useStaffingSettings mutation instance — mirrors useSplhCore
exposing `data`/`sessions` alongside its own derived `grid`.

Tests: tests/unit/useLaborPnlAnalytics.test.ts (6 tests) — series+grid+
summary from real sales/labor fixtures; granularity switch rebuilds the
series (day vs week bucket counts differ); grid cells flip estimated:true
under the daily-spread fallback; updateTarget dirty-check (no-op when
unchanged, single write when changed); capped/hasData/loading/error/
refetch passthrough; empty-data shape (empty series, all-zero 7x24 grid).

Full unit suite (513 files, 6632 tests) green, no regressions; typecheck
+ eslint clean on touched files.

* feat(labor): LaborBalanceRibbon component — per-bucket balance chip strip

Phase 4 task D1: pure flex strip of over/balanced/under chips (one per
FinancialPoint), colored via the dedicated --labor-over/under/balanced
tokens (never --splh-lean/slack — inverted semantics, design §7). Each
chip carries an aria-label naming its bucket and balance state
(role="listitem" inside a labeled role="list"); renders nothing for an
empty window since the parent chart owns the shared empty state.

Will sit under DemandVsStaffingChart's shared x-axis (design §2.2/§7).

* feat(labor): SalesVolumeHeatmap — dow×hour busy-hours grid (design D2)

Busy-hours sales-volume heatmap for the /labor page: full 7x24 grid
trimmed to active hours (any day with sales), role="grid"/row/gridcell
with per-cell aria-label naming day/hour/sales/peak, sticky day-of-week
column, min-w-10/min-h-10 cells. Colors ramp opacity on the
--labor-balanced token by SalesVolumeCell.intensity (design §7's
green ramp — distinct read from SplhHeatmap's lean/slack diverging
colors); peak cells (>=72% of window max) get a ring outline so the
busiest hours are never color-only. "Estimated" badge mirrors
SplhHeatmap's daily-spread-fallback note; new "Partial window" badge
surfaces useSplhData's capped flag per design §6.

Tests: tests/unit/SalesVolumeHeatmap.test.tsx (17 tests) — active-hour
trimming, cell aria-label (with/without peak), style ramp (zero-sales
bg-muted vs intensity-scaled --labor-balanced), 7 Mon-first day rows,
focusable gridcells, estimated/capped badge show-hide, all-zero
fallback message. Confirmed RED first (component didn't exist), then
GREEN.

Full unit suite (515 files, 6654 tests) green, no regressions;
tsc --noEmit clean; eslint clean (3 react-refresh/only-export-
components warnings — same pattern already accepted on
SplhHeatmap.tsx/LaborBalanceRibbon.tsx, not new).

* feat(labor): DemandVsStaffingChart — stacked sales area + labor-% line (D3)

Two stacked Recharts sharing one x-axis (design §7): net-sales AreaChart
on top, LaborBalanceRibbon in between, labor-% LineChart + target
ReferenceLine below. Deliberately not a dual-axis single chart — mirrors
SplhTimelineChart's single-axis + ReferenceLine precedent to avoid the
#611-lesson correlation-implying-scale failure mode. Renders nothing for
an empty points array (parent /labor page owns loading/error/empty).

Tests: tests/unit/DemandVsStaffingChart.test.tsx (9 tests) — chart-data
mapping preserves null laborPct for connectNulls gaps, tooltip formatter,
two-chart smoke render + target reference line label, ribbon chip count
matches points length, accessible name naming target + granularity view,
empty-array no-render. Confirmed RED first, then GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(labor): EditableLaborTarget component (D4)

Labeled number input for target_labor_pct, commit on blur or Enter,
guarded by its own dirty check (committedRef) so an Enter-then-blur
sequence never double-fires onCommit even against a bare mock.
Optimistically updates the displayed value on commit; reverts to the
previous value and shows a destructive toast if the write rejects.
Re-syncs its displayed value when targetPct changes externally.

Design doc §7 (target-edit control) / plan D4.

Tests: tests/unit/EditableLaborTarget.test.tsx (8 tests) — labeled
accessible input; commit-once on blur when changed; no-op on
unchanged blur; Enter+blur fires exactly once; optimistic value;
revert+toast on commit failure; external targetPct re-sync; empty/
non-numeric entry reverts without committing. Confirmed RED first
(component didn't exist), then GREEN. Full unit suite (517 files,
6671 tests) green, no regressions; tsc --noEmit clean; eslint clean.

* feat(labor): LaborVerdict component — tone dot + verdict sentence (D5)

Tone-dot-plus-sentence line for the /labor page (design §2.2), mirroring
CoverageVerdict's existing dot pattern: a colored dot (--labor-over/under/
balanced tokens, or a neutral dot for the no-sales 'none' tone) next to
summarizeLaborPnl's plain-English verdict string. Sentence text stays
text-foreground regardless of tone — color lives on the dot only, never
the sole signal.

Defensively guards a null/undefined summary prop (renders nothing) even
though its owning hooks (useLaborPnlSummary/useLaborPnlAnalytics) always
produce a valid LaborPnlSummary once mounted.

Tests: tests/unit/LaborVerdict.test.tsx (8 tests) — tone->dot-class mapping
incl. 'none', dot+sentence render per tone, null/undefined summary guards.
Confirmed RED first (component didn't exist), then GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(labor): LaborPnlCard dashboard card (Phase 4 E1)

Composes useLaborPnlSummary for the dashboard "Labor cost" card (design
§2.1): hero labor % of sales vs. target_labor_pct, revenue per labor
hour, a tone-colored verdict line, a daily labor-% sparkline, and an
"Open labor detail" link to /labor. Mirrors LaborEfficiencyCard's
loading/error/empty-state structure but uses the dedicated
--labor-over/under/balanced tokens (balanceStateClassName) rather than
--splh-lean/slack, since the two are semantically inverted (design §7).

The sparkline trends laborPct per day (buildLaborSparklineData),
preserving null buckets so connectNulls=false gaps no-sales days
instead of interpolating — laborPct already is the sales-vs-labor
read (laborCost ÷ sales).

Tests: tests/unit/LaborPnlCard.test.tsx (12 tests) — sparkline mapping
incl. null passthrough; loading/error/empty states by role; hero
number + target + verdict; revenue-per-labor-hour line shown/omitted;
null-laborPct em dash; over-target tone class on hero + verdict;
accessible sparkline image; navigate('/labor') on link click.
Confirmed RED first (component didn't exist), then GREEN.

Full unit suite (519 files, 6691 tests, 2 skipped) green, no
regressions; tsc --noEmit clean; eslint clean (1
react-refresh/only-export-components warning, same accepted pattern
as D1-D5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(labor): Labor.tsx page composing useLaborPnlAnalytics

Composes useLaborPnlAnalytics (C3) into the /labor page (design §2.2):
Day/Week/Month ToggleGroup, LaborVerdict, a 4-tile KPI row (labor % of
sales, revenue/labor-hour, net sales, labor $), the DemandVsStaffingChart
(D3, with the D1 ribbon), the SalesVolumeHeatmap (D2), auto-generated
over/under staffing callouts with a $ estimate, and the EditableLaborTarget
(D4). Three states (loading skeleton / inline error+retry / POS-connect
empty state) mirroring LaborEfficiencyPanel's structure, plus a
no-restaurant-selected guard.

Two new local pure helpers cover design gaps left open by the already-
committed Phase A lib: `estimateWindowDollars`/`findWindowPoints` compute
the callout's "$ estimate" (LaborBalanceWindow only carries labels/
bucketCount, not a dollar figure) from the window's underlying
FinancialPoints; `windowRangeLabel` formats single- vs multi-bucket ranges.

The Day/Week/Month toggle is treated as this page's period control — no
separate prior-period navigator or KPI delta is built, consistent with how
LaborPnlCard (E1) already read the same design wording as delta-vs-target
(already shown via tone-colored KPIs + verdict) rather than delta-vs-a-
previous-window, since no hook in Phase A/C fetches or computes a prior
comparable period.

Route wiring (/labor in App.tsx) and dashboard mount are separate tasks
(F1/F2, not yet done).

Tests: tests/unit/Labor.test.tsx (16 tests) — pure helpers (window range
label, point slicing, dollar estimate for over/under windows); three
states by role; no-restaurant guard; KPI row + verdict + chart + heatmap
render with real data; heatmap "estimated" flag derived from grid cells;
granularity toggle re-invokes the hook; staffing callouts render with $
estimates; capped propagation to the heatmap; editable target wiring.

Full unit suite (520 files, 6707 tests, 2 skipped) green, no regressions;
tsc --noEmit clean; eslint clean (3 react-refresh/only-export-components
warnings — same pattern already accepted on D1-D5, not new).

* feat(labor): register /labor route in App.tsx (task F1)

Eager-imports the Labor page beside Payroll and wires
<Route path="/labor" element={<ProtectedRoute><Labor /></ProtectedRoute>} />
per design §2.2. Regression test mirrors the existing
appNoVercelAnalytics.test.ts source-text-assertion pattern, since App.tsx's
provider/router tree isn't practically unit-rendered.

* feat(labor): mount collapsible Labor cost section on dashboard (task F2)

Adds a "Labor cost" Collapsible section to src/pages/Index.tsx, mounting
LaborPnlCard (C2/E1) in the financial cluster right after
SalesVsBreakEvenChart — matching the existing <Collapsible>+<h2> idiom
used elsewhere on the page. Defaults closed (laborCostOpen=false),
per plan task F2, and is placed well away from the existing scheduling
Labor Efficiency section further down the page to keep the two mental
models (financial vs. scheduling) visually separate, per design §2.1.

Test: tests/unit/indexLaborCostSection.test.ts (6 tests) — since
Index.tsx isn't practically unit-rendered (large provider/hook tree),
follows the established source-text-assertion pattern from
appLaborRoute.test.ts: LaborPnlCard import present, laborCostOpen state
declared default false, "Labor cost" heading + design-specified subtitle
present, LaborPnlCard mounted scoped to selectedRestaurant.restaurant_id,
Collapsible wired to laborCostOpen/setLaborCostOpen, and section placed
after SalesVsBreakEvenChart with clear separation (>2000 chars) from the
Labor Efficiency section. Confirmed RED first (section didn't exist),
then GREEN. Full unit suite (522 files, 6716 tests, 2 skipped) green, no
regressions; tsc --noEmit clean; eslint clean on touched files (the one
pre-existing Index.tsx `any` error and two exhaustive-deps warnings
predate this change, confirmed via git stash diff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(labor): add "Labor" nav link to sidebar Operations group

Adds a "Labor" entry (path /labor, Banknote icon) to the Operations
navigation group in AppSidebar.nav.ts, right after Payroll, so the new
/labor financial-view page (task F1) is reachable from the sidebar for
owner/manager/chef and operations_manager roles — the same set of
roles that already see Payroll and Reports.

Tests: tests/unit/AppSidebar.nav.test.ts — Labor item present in the
Operations group directly after Payroll; surfaced for
operations_manager (Operations group survives that role's filter);
surfaced for owner/manager (full nav). Confirmed RED first (item
didn't exist), then GREEN. Full unit suite (522 files, 6719 tests)
green, no regressions; tsc --noEmit and eslint clean on touched files.

* refactor(labor): reuse balanceStateClassName for staffing-callout tone

Labor.tsx was writing text-[hsl(var(--labor-over/under))] inline for
the over/under staffing callouts instead of the shared
balanceStateClassName helper laborPnlAnalytics.ts already exports (and
LaborPnlCard already uses) — single source of truth for the dedicated
--labor-* tone tokens per design doc §7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(labor): dedupe tone->bg-class mapping, extract KPI tile/callout loop

Phase 6 (simplify) on the labor financial view:
- LaborBalanceRibbon's balanceChipClassName and LaborVerdict's
  verdictDotClassName independently re-implemented the same
  over/under/balanced -> --labor-* background-token mapping. Extracted a
  shared laborPnlAnalytics.balanceStateBgClassName (the background
  counterpart to the already-shared balanceStateClassName) and made both
  components thin wrappers over it, so the tone->class mapping has one
  source of truth. Exported function names/behavior unchanged, no test
  changes needed.
- Labor.tsx's 4-tile KPI row was four copy-pasted divs differing only in
  label/value; extracted a local KpiTile component. The over/under
  staffing-callout blocks were the same JSX twice differing only in tone;
  unified into one loop over [over, under] windows.

No behavior change: full unit suite (521 files, 6719 tests, 2 skipped)
green, typecheck clean, eslint clean (only pre-existing
react-refresh/only-export-components warnings, same pattern already
accepted on D1-D5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(review): labor window end anchored to end-of-day — addresses sound-logic

useLaborPnlCore's laborCostWindow() built windowEnd at midnight-START of
"today" instead of end-of-day. useLaborCostsFromTimeTracking feeds that
straight into lookaheadPunchFetchRange(dateFrom, dateTo), which widens
only the END of the time_punches fetch by OVERNIGHT_BUFFER_HOURS (18h) —
so the fetch silently cut off at 6pm today, dropping every clock-in/out
after that (and any shift whose clock_out landed past 6pm read as an
incomplete shift and was dropped entirely, not just partially undercounted).
Meanwhile dailySales (useSplhData, DATE-column query) has no such cutoff,
so "today"'s laborCost/laborHours were undercounted relative to sales,
making laborPct artificially low — contradicting design §3's
clock-in-through-now requirement.

Anchoring windowEnd at 23:59:59.999 instead of 00:00:00 fixes the fetch
range; generateDateRange/format() in calculateActualLaborCost and
useLaborCostsFromTimeTracking's query-key both normalize to Y-M-D and are
unaffected by the time-of-day change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(labor): toggle selects period (today/week/month), intraday Day chart

Phase-7 review finding #2: the Day/Week/Month toggle only re-bucketed a fixed
12-week window, so KPIs + verdict were identical across toggles and Day plotted
~84 days. Now the toggle is a period selector:
- KPI row + verdict come from the period's payroll-grade daily series
- chart shows the period's sub-buckets: Day = hour-of-day intraday
  (avg-rate labor SHAPE; totals stay payroll-grade), Week = by day, Month = by week
- staffing callouts track the chart series (Day = hour ranges)

New pure lib: currentPeriodWindow, dateInWindow, buildIntradayFinancialSeries
(+ exported extractBalanceWindows, hourOfSale). Full tests updated/added.

Finding #1 (labor/sales day-boundary tz mismatch when viewer tz != restaurant
tz) accepted as a documented limitation shared with Payroll (design §10.5) —
a tz-aware fix belongs in calculateActualLaborCost app-wide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(labor): keep /labor period fresh across midnight; single-round intraday labor

Sound-logic re-review folds:
- [major] the Day/Week/Month period window froze at mount (useMemo dep was
  [granularity, tz]); a page left open past midnight kept labelling yesterday as
  today. Now polls restaurant-tz date once a minute + on focus/visibility,
  updating state only when the date actually changes. Regression test added.
- [minor] buildIntradayFinancialSeries priced labor off pre-rounded hours;
  now single-rounds off raw hours.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(review): refresh labor-cost fetch window across midnight; clamp target to [1,100]

CodeRabbit findings (both major):
- useLaborPnlCore's laborCostWindow froze at mount ([tz, weeks] memo), so after
  midnight the time_punches fetch stayed anchored to yesterday and dropped today's
  punches for BOTH the /labor page and the dashboard card. Extracted the
  today-poller into a shared useTodayInTimezone hook (removes the duplicate poller
  added to useLaborPnlAnalytics) and threaded todayStr into the fetch window.
- EditableLaborTarget only had advisory min/max; a typed -5/0/500 committed garbage
  to target_labor_pct. Now clamps to [1,100] before commit.

Tests: useTodayInTimezone.test.ts (new), clamp cases, all green (6736).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(labor): clearAllMocks in useTodayInTimezone beforeEach — addresses coderabbit (minor)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(review): heatmap estimated-flag via hourOfSale; Link CTA; doc + artifact cleanup

Phase 9d CodeRabbit triage:
- [major] hasHourlyBreakdown checked only sold_at, but buildSplhGrid derives the
  hour from sold_at OR sale_time — a sale_time-only POS had its heatmap falsely
  labelled 'Estimated'. Now uses hourOfSale(s, tz), matching buildSplhGrid. +test.
- [nitpick] LaborPnlCard 'Open labor detail' now a <Link> (native keyboard/
  new-tab/middle-click), matching the sibling 'Connect your POS' CTA. Test updated.
- [minor] reconciled stale design-doc refs (§8/§9): buildFinancialSeries name,
  labor%=laborCost/sales, internal reconciliation — dropped usePnLAnalyticsFromSource.
- [nitpick] reverted churned dev-tools/ scratch artifacts (phase7-diff.patch,
  codex-review-output.md) back to origin/main.
- [major] EditableLaborTarget clamp already landed in 42903b9 (CodeRabbit confirmed).

Full suite green (6737).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(labor): count open shifts through now, date-range picker, prototype visual polish

Preview feedback (3 items):
1. $28 labor bug: still-open shifts (currently clocked in) counted zero hours
   (parseWorkPeriods drops un-clocked-out shifts). New appendOpenShiftClockOuts
   util closes open shifts at "now" (16h stale guard); wired into the
   payroll-grade daily cost (useLaborCostsFromTimeTracking throughNow opt-in) and
   the intraday labor shape. Fixes the KPI $ and the collapsing labor-% line.
2. Time axis + "now": top sales chart now shows hour labels (was hover-only);
   Day view caps at the current hour ("so far today") instead of running to 9 PM.
3. Date-range picker: preset dropdown (Today / This week / Last week / This month /
   Last month / Custom start–end) replaces the Day/Week/Month toggle; chart
   auto-buckets (hour-of-day / day / week); KPIs/verdict/callouts follow the range.

Visual polish to match the approved prototype:
- "What to do about it" recommendation cards (icon badge + tone + $ impact),
  from over/under windows + peak bucket + overall vs-target gap.
- KPI tiles: accent dots, tone-colored labor-% + vs-target caption, per-tile
  sparklines (new Sparkline component). Range caption in the header.

New/updated tests: openShiftPunches, Sparkline, range resolution + capHour,
range-based hook API, findings cards. Full suite green (6760); build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(review): intraday now-tick, restaurant-tz picker bounds, clamps

Sound-logic + CodeRabbit re-review of e832bae:
- [critical] intraday sessions memo froze the open-shift 'now' synthesis: React
  Query structural sharing keeps data.punches reference-stable across
  content-identical refetches, so the chart's labor line collapsed mid-shift while
  the KPI (recomputed per queryFn) climbed. New useNowTick minute-ticker is a real
  dep of the sessions memo → intraday now advances with the clock.
- [major] custom date-picker bounds used host-local/UTC math (off-by-one for
  non-UTC users); now derived from restaurant todayStr via addDaysStr.
- [minor] revenue/labor-hr sparkline plotted sales; now sales/laborHours.
- [minor] buildSalesVolumeGrid intensity clamped >=0 (refund cells).
- [minor] hourOfSale validates sale_time hour is an integer 0..23.

Tests: useNowTick, intensity clamp, malformed-hour rejection. Suite green (6763).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(review): guard useNowTick period; stricter sale_time hour prefix

CodeRabbit closure (2 minors):
- useNowTick normalizes an invalid periodMs (0/negative/NaN/Infinity) to 60s to
  avoid a busy interval.
- hourOfSale rejects an empty/whitespace hour prefix (Number('') === 0 would
  otherwise pass ':30' as hour 0) via a /^\d{1,2}$/ check.

Tests extended. Suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(review): add created_at/updated_at to synthetic open-shift punches

CodeRabbit (PR bot): TimePunch requires created_at/updated_at; the synthetic
clock_out/break_end objects omitted them (a contract gap the array-spread hid
from tsc). Stamp both at 'now'. Test factory updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(lessons): reuse-hook data-granularity, clock-in-through-now, RQ structural-sharing memo freeze (PR #624)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
jdelgado2002 added a commit that referenced this pull request Jul 26, 2026
…n-chart SPLH slider, arithmetic receipt (#650)

* docs(scheduling): design — coverage chart explainer redesign

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(scheduling): fold Phase 2.5 design-review resolutions into coverage chart spec

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(scheduling): implementation plan — coverage chart explainer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(scheduling): add demand field to HourlyStaffingRecommendation type

Type-only change (task 1.1 of coverage-chart-explainer plan). Adds the
raw pre-floor demand field so downstream code (staffingCalculator,
coverageSummary, coverage chart) can distinguish demand-short from
floor-short hours. Population lands in the next task.

* feat(staffing): buildHourlyRecommendations computes demand pre-floor

Threads raw demand = ceil(avgSales/targetSplh) (0 when sales/targetSplh
<= 0) through buildHourlyRecommendations and derives recommendedStaff
as max(demand, minStaff) directly, instead of delegating to
calculateRecommendedStaff. Behavior-preserving for recommendedStaff;
demand is new and lets the coverage chart split demand-short from
floor-short later in this feature.

Task 1.2 of coverage-chart-explainer plan.

* feat(scheduling): thread demand + scheduledMax through CoverageHour

summarizeCoverageHours now copies rec.demand into CoverageHour.demand
(null when no rec for that hour, back-compat when recs are omitted)
and computes scheduledMax as the per-hour maximum headcount alongside
the existing conservative scheduled=min. Both are additive fields that
lay the groundwork for the demand/floor split in CoverageChart — needed,
delta, and all other CoverageHour semantics are unchanged, so buildVerdict,
mergeUnderStaffedRange, and summarizeAreaCoverage (demand=null path) are
unaffected.

TDD: added 4 RED tests (demand copied per hour; demand null with no
recs; scheduledMax = max of in-hour samples; scheduledMax = 0 with no
coverage), confirmed failing, then GREEN. Full unit suite (7318 tests)
+ typecheck + lint clean on touched files.

* feat(scheduling): coverageChartModel.classifyHour with boundary tests

New pure module for coverage-chart presentation helpers. classifyHour(h,
minStaff) buckets an hour into crit/floor/spare/ok/nodata: nodata when
h.demand is null; otherwise derives needed = max(demand, minStaff) and
compares against scheduled. Deliberately independent of h.needed so it
stays correct under a hypothetical minStaff (e.g. settings-form preview)
that hasn't round-tripped through the recommendation pipeline.

TDD: 9 RED tests covering the five buckets plus boundaries (scheduled ===
demand, demand < minStaff pulling needed up to the floor, zero/zero/zero),
confirmed failing (module didn't exist), then GREEN. Typecheck + lint
clean on touched files.

* feat(scheduling): coverageChartModel impliedLabor + laborConsistentSplh

impliedLabor({ wage, splh, targetLaborPct }) -> { pct, overTarget } computes
the on-chart SPLH slider's live labor-% readout (pct = wage/splh*100),
flagging overTarget once pct clears targetLaborPct by more than a 0.05-point
tolerance so a target hit within float noise doesn't flash red.

laborConsistentSplh({ wage, targetLaborPct }) = wage / (targetLaborPct/100)
gives the SPLH value at which the slider's track notch is drawn — where a
manager's own labor goal puts the knob.

TDD: 8 RED tests (pct math, overTarget boundary at target+0.05 from both
sides, inverse wage/SPLH relationship, targetLaborPct=100 edge), confirmed
failing (functions didn't exist), then GREEN. Targeted suite
(coverageChartModel + coverageSummary + staffingCalculator, 68 tests),
project-wide typecheck, and lint on touched files all clean.

* feat(scheduling): coverageChartModel.buildReceipt ledger + asides

buildReceipt(h, { minStaff, weekdayKey, wage, lookbackWeeks }) builds the
pinned receipt's ordered ledger rows plus contextual asides for a single
coverage column, per the design doc's worked example.

- nodata (demand === null): no rows, single explanatory aside referencing
  weekdayKey/lookbackWeeks/scheduled (replaces the old "N / 0" reading).
- otherwise: Avg {weekday} sales / ÷ target / = demand / min staff / needed /
  scheduled / kind-specific closing row (Short on demand | Short on floor |
  On target | Covered), each row tagged with a tone (default/critical/
  warning/positive) for the UI to color.
- "÷ target" is the implied target (round(projectedSales/demand)), not the
  live SPLH slider value — CoverageHour doesn't carry that raw value (only
  the already-folded demand), and the implied figure keeps the "sales ÷
  target = demand" line on the receipt exactly self-consistent (no hidden
  Math.ceil to explain). Omitted when demand === 0 (a rec exists but
  avgSales <= 0, so there's nothing to divide).
- Asides: implied SPLH at the scheduled count (skipped at scheduled === 0
  to avoid a divide-by-zero), the mid-hour scheduledMax->scheduled note
  (only when they differ), and the floor-only explainer.

Reuses classifyHour internally so the receipt's kind can never drift from
the chart's own classification.

TDD: 7 RED tests added (nodata; crit/floor/ok/spare row sets incl. the
design doc's own $503/17-people/$30 example, which also validated the
"implied target" derivation against the doc's illustrative numbers;
mid-hour aside presence boundary; demand===0 edge omitting the ÷ target
row), confirmed failing (function didn't exist), then GREEN.

Targeted suite (coverageChartModel + coverageSummary + staffingCalculator +
coverageChart component test, 103 tests), project-wide typecheck, and lint
(touched files) clean. Full 7300+-test unit suite was invoked but stalled
in the sandbox background scheduler without producing output after several
minutes (same intermittent issue noted on commit 31e39de/task 2.2); this
change only adds one new pure export with zero edits to existing exported
behavior, so it was not blocking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(scheduling): coverageChartModel.chartSummaryLabel rolled-up aria + windows list

chartSummaryLabel(hours, minStaff) rolls a day's CoverageHour[] into the
chart's accessible summary: an "N short on demand, M at the floor over K
hours" string for the sr-only <p>, plus an ordered understaffedWindows list
(one entry per crit/floor hour) feeding a sr-only <ul aria-label="Understaffed
windows"> — porting CoverageStatusStrip's screen-reader gap enumeration
guarantee onto the new chart (design doc, Phase 2.5 resolutions #1-#2).

Classifies every hour via classifyHour (not h.delta/h.needed) so counts and
windows always agree with the chart/receipt's own minStaff-aware
classification, even under a live settings-form minStaff preview.

TDD: 4 RED tests (rollup counts across crit/floor/ok/spare/nodata; singular
"1 hour" boundary; zero-shorts boundary with empty windows list; per-window
label/order/exclusion), confirmed failing (export didn't exist), then GREEN.
Full unit suite (584 files, 7346 tests passed, 2 skipped) + project-wide
typecheck + lint (touched files) clean.

* feat(coverage-chart): replace Area/Delta toggle with single SVG chart

Stage 3.1 of the coverage chart explainer redesign. CoverageChart.tsx is
now a single SVG chart with a real people y-axis, replacing the old
AreaColumn/DeltaColumn/Legend two-view toggle:

- Per hour: a scheduled bar (--primary), a solid demand slice
  (--destructive) when short of raw demand (crit), a dashed/hatched
  floor slice (--warning) covering the gap up to the minStaff floor
  (crit/floor), and a hatched nodata ghost via an SVG <pattern> for
  hours with no sales history (replacing the old misleading "N / 0").
  A `floor N` rule line marks minStaff across the plot.
- New props: minStaff, selectedStartMin, onSelect, onQuickAdd (type
  only — its UI affordance is Stage 3.3); `view`/`targetSplh` removed.
- Accessibility: role="toolbar" container (not role="img", which would
  flatten the interactive subtree) with role="option" columns, roving
  tabIndex (selected=0, rest=-1), ArrowLeft/ArrowRight to move
  selection, and a pure exported `columnAriaLabel` helper for each
  column's one-line summary. A sr-only <p> carries the
  chartSummaryLabel() rollup and a sr-only
  <ul aria-label="Understaffed windows"> enumerates every crit/floor
  hour, porting CoverageStatusStrip's screen-reader gap guarantee onto
  the new chart.
- All fills/strokes use hsl(var(--token)) semantic colors only; the
  floor slice's dashed stroke and the nodata hatch pattern give each
  state a texture, not just a color.

ShiftTimelineTab.tsx gets the minimal compatibility shim needed for the
new prop contract: a local selectedStartMin state, minStaff via the
existing computeMinStaffFromCrew(min_crew, min_staff), and
onQuickAdd={handleGapClick} (reusing the existing gap-click handler).
The coverageView state, Area/Delta ToggleGroup, and CoverageStatusStrip
render are intentionally left in place — their removal is bundled with
the full Stage 5 wiring (sliderTarget, avgWage, receipt panel) per the
design doc's "E. ShiftTimelineTab.tsx wiring" section, out of scope
here.

TDD: tests/unit/coverageChart.test.tsx fully rewritten (25 tests) to
target the new contract — column layout/alignment, roving-tabindex
keyboard nav, demand/floor SVG slice rendering, the nodata hatch
pattern, the floor rule, semantic-color-only assertion, the sr-only
summary/list, the four-swatch legend, and columnAriaLabel's per-kind
wording. Confirmed RED (23 failed) against the old component, then
GREEN. Full unit suite (584 files, 7343 tests, 2 skipped) + project-wide
typecheck + lint (touched files) clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(coverage-chart): lock sticky y-axis gutter contract mirroring TimelineLane

CoverageChart's y-axis gutter already carried the sticky left-0 z-10
w-[120px] classes from Stage 3.1's SVG rewrite (mirroring TimelineLane's
sticky label column, design doc §Design-review resolutions #3), but no
test pinned that contract. Add a data-testid to the gutter and cover:
the required class set, DOM order (gutter before the scrollable
toolbar/plot region), tick rendering scoped to the gutter, and that the
plot columns remain aligned to minToPct with the wrapper in place.

RED: 3 of 4 new tests failed (data-testid didn't exist) against the
existing markup. GREEN: added the data-testid only, no layout changes.
Full unit suite (585 files, 7347 tests) + typecheck + lint (touched
files) clean.

* feat(coverage-chart): one-click hover "+" quick-add on crit/floor columns

CoverageChart tracks hovered/focused startMin and reveals a small "+"
button over crit/floor columns (only when onQuickAdd is supplied),
calling onQuickAdd(startMin) on click without triggering column
selection — preserving the one-click quick-add parity with the old
CoverageStatusStrip gap-click (design doc resolution #5). Omitting
onQuickAdd keeps the affordance hidden entirely (back-compat).

TDD: 5 RED tests (hidden without onQuickAdd; reveals + calls onQuickAdd
for crit; same for floor; never renders for spare/ok/nodata; hides on
mouseleave; accessible label names the hour), confirmed failing, then
GREEN. Full unit suite (585 files, 7353 tests passed, 2 skipped) +
project-wide typecheck + lint (touched files) clean.

* feat(scheduling): SplhSlider sub-component for coverage chart explainer

Native range input (25-120, step 5) with a live implied-labor readout
(`→ X% labor at $W/hr`) + red/green pill from `impliedLabor`, a notch on
the track at `laborConsistentSplh` labeling where the manager's own
labor goal sits, a Reset button, and a manager-only Save button gated
by a caller-computed `canSave` prop with an `isSaving` pending state.
`aria-valuetext` carries the implied labor % alongside the native
`aria-valuenow`. Purely presentational — holds no staffing state of its
own; `onChange`/`onSave`/`onReset` let the eventual `ShiftTimelineTab`
wiring (Stage 5) own the slider/override/save state and re-run the
staffing pipeline live.

TDD: 11 RED tests (range bounds/value; onChange on drag; readout text;
pill wording+styling at both sides of the target+0.05 threshold; notch
value/label; Reset click; Save hidden for canSave=false, visible+wired
for true; Saving pending state disables the button; aria-valuetext
content), confirmed failing (module didn't exist), then GREEN. Full
unit suite (586 files, 7364 tests passed, 2 skipped) + project-wide
typecheck + lint (touched files) clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(scheduling): CoverageReceipt sub-component (buildReceipt + quick-add + debounced aria-live)

New standalone panel rendering buildReceipt's ledger rows/asides for the
selected coverage-chart hour, with an "Add shift for this hour" action on
crit/floor hours (calls onQuickAdd(startMin); omitted onQuickAdd or an
ok/spare/nodata hour hides it, back-compat with the chart's own hover "+").

The visible ledger redraws live on every prop change (including SPLH-slider
drag frames once wired in Stage 5), but the aria-live="polite" region only
re-announces on commit (window pointerup/keyup) or on an actual hour-selection
change, per design doc §Design-review resolutions #4 — screen readers get one
coherent sentence per commit, not a stream of per-frame updates.

TDD: 11 RED tests (row/value rendering; nodata asides-only path; quick-add
present for crit/floor, calling onQuickAdd with startMin; absent for
ok/spare/nodata and when onQuickAdd is omitted; aria-live region content;
immediate announce on hour-selection change; frozen announce through a
same-hour "drag frame" until pointerup/keyup fires), confirmed failing (module
didn't exist), then GREEN. Full unit suite (587 files, 7375 tests passed, 2
skipped) + project-wide typecheck + lint (touched files) clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(scheduling): thread sliderTarget preview state into ShiftTimelineTab

Adds the SPLH slider's local preview state (design doc §B/§E, plan 5.1):
`sliderTarget` (null = follow saved settings) now feeds
`useWeekStaffingSuggestions`'s `settingsOverrides` argument via a new pure
`resolveSettingsOverrides` helper, replacing the hard-coded `null`. Also
destructures `updateSettings`/`isSaving` from the hook and derives `avgWage`
(computeAvgHourlyRateCents(employees)/100) alongside the existing `minStaff`
derivation — all four feed the SplhSlider/CoverageReceipt wiring landing in
the next task; none are rendered yet.

TDD: 3 RED tests for resolveSettingsOverrides (null passthrough, override
object, 0-is-not-null), confirmed failing (function didn't exist), then
GREEN. Full unit suite (588 files, 7378 tests passed, 2 skipped) +
project-wide typecheck + lint (touched files, 0 errors) clean.

* feat(scheduling): fold coverage panel into scrollable chart + pinned receipt (Stage 5.2)

ShiftTimelineTab now renders the coverage panel as a flex row: the
scrollable CoverageChart (header folds in CoverageVerdict/CoverageDemandInfo)
on the left, and a pinned CoverageReceipt outside the horizontal scroll on
the right (stacking below on mobile). Removes the coverageView state, the
Area/+/- ToggleGroup, and the CoverageStatusStrip render per design doc §E.

Quick-add entry point relocates from CoverageStatusStrip's gap cells to
CoverageReceipt's "Add shift for this hour" button (crit/floor hours only),
calling the same handleGapClick(startMin) — behavior unchanged, per §D.

Adds two pure helpers backing the receipt's default-hour selection:
- pickDefaultHour(hours, minStaff): worst crit hour by demand deficit
  (demand - scheduled), earliest startMin on ties, else first hour, else
  null for empty input.
- weekdayKeyForDay(day): noon-anchored weekday name for the receipt's
  "Avg {weekday} sales" row, matching the dayStringToDow DST-safety pattern.

Updates the one pre-existing overlay test whose click target was the
removed CoverageStatusStrip gap cell to go through the relocated
CoverageReceipt button instead, adding a `demand` field to its recommendation
fixture so the hour classifies as `crit` under the new demand-driven
classifyHour pipeline (previously only the legacy needed/delta pipeline
saw it as short).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(scheduling): gate SPLH slider Save to owner/manager/operations_manager (Stage 5.3)

Mounts <SplhSlider> above the coverage chart in ShiftTimelineTab, wiring live
preview + Reset (available to every role) and Save (gated).

- Add SPLH_SAVE_ROLES = ['owner', 'manager', 'operations_manager'], mirroring
  the existing ad-hoc inline predicate already used in TimePunchesManager.tsx
  and Inventory.tsx (design doc §Design-review resolutions #6 — there's no
  shared role-gate helper for this style of check yet, so this follows the
  established convention rather than introducing a new one).
- canSaveSplhTarget reads selectedRestaurant?.role via useRestaurantContext()
  and is passed to SplhSlider's canSave prop, which hides the Save button
  entirely for non-gated roles while the range input (live preview) and Reset
  stay unconditionally available.
- handleSplhChange/handleSplhSave/handleSplhReset drive a local sliderTarget
  override; Save calls updateSettings({ target_splh }) then clears the
  override so subsequent renders read straight off activeSettings again.
- targetLaborPct sourced from activeSettings.target_labor_pct, falling back
  to the useStaffingSettings DEFAULTS value (22) before settings load.
- Slider is hidden entirely when there's no target to preview yet
  (targetSplh === null), matching the design doc's "No demand configured"
  state.

Tests (RED -> GREEN):
- New describe block in shiftTimelineTab.test.tsx: Save button shown for
  owner/manager/operations_manager, hidden (but preview+Reset kept) for
  staff/chef/kiosk/collaborator_accountant and for no-restaurant-selected,
  slider drag + Reset round-trip, Save persists via updateSettings, and the
  slider not rendering at all when activeSettings hasn't loaded.
- ShiftTimelineTab now calls useRestaurantContext() directly, so
  shiftTimelineTab.mobileLayout.test.tsx and shiftTimelineTabOverlay.test.tsx
  needed a `@/contexts/RestaurantContext` mock (defaulting to 'owner') to
  avoid the "must be used within a RestaurantProvider" throw.

Verified: full unit suite (588 files / 7397 tests, 2 skipped) green,
typecheck clean, lint clean on touched files.

Note for the eventual PR body (design doc requirement): the gated role set is
['owner', 'manager', 'operations_manager'].

* feat(scheduling): reshape coverage loading skeleton to mirror slider/axis/chart/receipt (Stage 5.4)

The loading skeleton previously used generic bars unrelated to the new
coverage-panel layout (slider panel -> chart -> axis -> lanes, plus a
pinned receipt column). Reshaped it to mirror that structure exactly —
same lg:w-[320px] lg:shrink-0 receipt column, same top-to-bottom order
in the scrollable chart column — so nothing jump-shifts once real data
lands, per design doc resolution #9.

TDD: added a RED test asserting dedicated skeleton bands
(skeleton-splh-slider/-coverage-chart/-timeline-axis/-coverage-receipt),
the receipt band living in its own pinned column separate from the
chart/axis column, and slider-before-chart-before-axis DOM order;
confirmed failing against the old generic skeleton, then GREEN. Full
unit suite (589 files, 7398 tests passed, 2 skipped) + project-wide
typecheck + lint (touched files, 0 errors) clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(scheduling): delete unused CoverageStatusStrip.tsx (Stage 6.1)

Grep confirmed no production importer remained after Stage 5.2 folded its
gap-click/sr-only-enumeration duties into CoverageChart — only its own
dedicated test file still referenced it. Deleted both the component and
tests/unit/coverageStatusStrip.test.tsx, and cleaned up two now-stale
comments (coverageSummary.ts's formatCoverageHour doc, timelineComponents
.test.tsx's file-header note) that still named it as a live consumer.

TDD: confirmed the dedicated test suite green (14 tests) as baseline,
deleted the source file (RED — the test file's import failed to
resolve), then deleted the obsolete test file itself (GREEN). Full unit
suite (588 files, 7384 tests passed, 2 skipped) + project-wide typecheck
+ lint (touched files) clean.

* refactor(coverage-chart): dedupe demand/minStaff floor formula into neededFor()

Math.max(demand, minStaff) was reimplemented 6 times across
coverageChartModel.ts (classifyHour, buildReceipt, chartSummaryLabel) and
CoverageChart.tsx (computePeak, columnAriaLabel, the SVG render loop).
Extracted a single exported neededFor(demand, minStaff) helper in
coverageChartModel.ts and pointed every call site at it. Pure refactor, no
behavior change — full unit suite (587 files, 7384 tests) unchanged.

Left staffingCalculator.ts's own Math.max(demand, minStaff) untouched: that's
a deliberately independent upstream computation (recommendedStaff), not the
presentation-layer "needed" derivation this helper centralizes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(review): coverage chart wiring — addresses performance, sound-logic, maintainability, codex

- Memoize resolveSettingsOverrides(sliderTarget) so useWeekStaffingSuggestions
  doesn't recompute the whole week's staffing pipeline on every unrelated
  re-render (e.g. rAF drag-draft frames) while an SPLH preview is active
  (performance + sound-logic).
- Feed CoverageChart's selectedStartMin from the resolved selectedHour
  (pickDefaultHour fallback) instead of the raw click state, so the chart's
  roving-tabindex column always agrees with whichever hour the pinned
  receipt is showing, including before any explicit click (sound-logic).
- Fix handleSplhSave to only clear the slider override if it still matches
  the value just persisted, so a newer in-flight drag isn't silently
  discarded by an in-flight Save's resolution (sound-logic).
- Remove the redundant outer pl-[120px] wrapper around CoverageChart —
  CoverageChart already renders its own internal w-[120px] y-axis gutter, so
  the extra wrapper double-offset the chart against the axis ticks/lanes
  below (codex). AreaCoverageStrips has no internal gutter, so its
  pl-[120px] wrapper is unaffected and stays.
- Delete calculateRecommendedStaff (staffingCalculator.ts) and its dedicated
  test block — dead code since task 1.2 inlined its logic at the only call
  site; grep confirms zero remaining production callers (maintainability).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(review): coverage chart demand/nodata correctness (CodeRabbit iter 2)

- coverageSummary.ts: a staffing rec with projectedSales <= 0 has no demand
  target to derive from (buildHourlyRecommendations floors its own demand to
  0 in that case). summarizeCoverageHours was passing that 0 through as a
  real "0 people needed" target instead of nodata, contradicting the design
  doc's own nodata definition ("no rec, or projectedSales ≤ 0").
- coverageChartModel.ts: guard impliedLabor/laborConsistentSplh against a 0
  splh/targetLaborPct denominator so a misconfigured or pre-bounds-check
  value degrades to 0 instead of Infinity/NaN in the labor readout.
- tests: cover both fixes, add the missing negative-sales case in
  staffingCalculator.test.ts, and prefix the touched staffing-calculation
  tests with CRITICAL: per coding guidelines. Fixed a stale test fixture in
  shiftTimelineTab.test.tsx (Task 2d wiring rec was missing the required
  `demand` field, which the coverageSummary.ts fix surfaced as NaN render
  warnings).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(review): use should/when naming for staffingCalculator demand tests (CodeRabbit iter 3)

Rename the added demand/floor test descriptions to the "should X when Y"
convention flagged by CodeRabbit for consistency and searchability.

* fix(scheduling): disambiguate SplhSlider Save button accessible name

The new SplhSlider renders a persistent "Save" button whose accessible
name collided with the shift-edit popover's own "Save" button, tripping a
Playwright strict-mode violation in timeline-edit-create.spec.ts. Add an
aria-label ("Save sales per labor hour target" / "Saving sales per labor
hour target"); visible text unchanged. Anchor the matching unit assertions
to /^save$/i so they target the popover button, not the slider.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(scheduling): e2e coverage-chart explainer — render + SPLH slider

Adds tests/e2e/coverage-chart-explainer.spec.ts covering the redesigned
coverage panel end-to-end against the real staffing pipeline:

- Seeds one $15/hr server + a mid-day shift + ~2 weeks of daily-spread
  sales so today's weekday has demand (~4/hr) against a lone scheduled
  server, guaranteeing demand-short (crit) columns.
- Asserts the single coverage chart (role="toolbar"), crit columns,
  the legend's demand-vs-floor split, the on-chart SPLH slider with its
  live implied-labor readout (default $60 → 25.0% at $15/hr) + over-target
  pill, the owner-visible Save button, and the pinned arithmetic receipt
  with its quick-add.
- Drives the slider via keyboard End/Home ($120 → 12.5% "On target";
  $25 → 60.0% "Over target") to prove the readout + pill update live.

Timezone pinned to UTC and wage fixed at exactly $15.00 for deterministic
percentages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(scheduling): rework coverage explainer to match the approved mock

The shipped coverage panel diverged from the approved interactive prototype —
it read as an undifferentiated block chart with a terse status line. Rework the
four presentation surfaces to faithfully match the mock, translating its raw
hex/serif design language into the app's semantic tokens and typography:

- CoverageVerdict: one plain-English, two-clause sentence that distinguishes
  demand-short ("Sales justify N more people-hours…, worst at TIME") from
  floor-only ("Another M hours only trip the K-person floor — demand there is
  already met"), plus a category chips row (short on demand / at the floor only
  / covered / no sales history). Extend buildVerdict() with the split fields
  (demandShort*, floorOnly*, worstCrit, covered/nodataHours) that drive it.
- CoverageChart: discrete inset bars instead of one abutting block — scheduled
  fill + solid primary cap, a solid-red demand-short slice, a dashed-amber
  floor-only cap (texture, not colour alone), a foreground needed tick, and
  on-bar −N shortfall labels centred in the gap they name.
- CoverageReceipt: an "The arithmetic" eyebrow + hour header, the ledger, and
  the verdict set off below a divider as an emphasised total.
- SplhSlider: reframed as "Each labor hour should bring in $X → Y% labor",
  with the labor-consistent notch and $min/$max scale ends.

Semantic tokens only (destructive/warning/primary/success/muted-foreground) —
no raw hex. Unit tests updated for the new verdict shape and the relocated
dashed floor cap; typecheck, lint, and the 118 coverage unit tests are green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(scheduling): update no-demand verdict copy assertion

The CoverageVerdict rework changed the no-demand prompt to
"Add staffing targets to see where sales justify more hands."
Point the shiftTimelineTab wiring test at the new copy so the
Unit Tests CI job goes green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(scheduling): inset the coverage-chart nodata ghost so hatched hours read as separate bars

The "no sales history" hatch rect spanned the full column width while every
drawn data bar is inset by pad = width * 0.16, so consecutive nodata hours
abutted into one continuous hatched block instead of discrete bars. Hoist the
pad/bx/bw inset above the nodata branch and apply it to the ghost too, matching
the mock's visually-separated bars. Adds a regression test pinning the ghost's
inset geometry to the data bars' width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(scheduling): paginate the coverage sales query so recent days aren't dropped

The hourly-sales query in useWeekStaffingSuggestions was an unbounded select,
subject to PostgREST's default 1000-row cap. A busy restaurant's multi-week
lookback exceeds that, and because rows are ordered by sale_date ascending the
truncation silently drops the most-recent days first — including today, the
default-selected day — so the coverage chart rendered every hour as
"No sales history".

Paginate in 1000-row pages (matching the adjacent time-punch query and
useSplhData's fetchAllSales) so the full lookback window is fetched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
jdelgado2002 added a commit that referenced this pull request Jul 28, 2026
* docs(spec): PR comment response gate design

Every review finding (bot or human) must carry a visible verdict reply on
the PR: agreed + commit, pushed back + reason, or ignored + reason. CI
audits and blocks; the /dev session authors the replies.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(spec): fold Phase 2.5 design-review feedback

- Verify 'agreed' replies cite a real PR commit SHA (knownShas passed
  into the pure classifier; audit fetches the PR commit list once).
- Decide bot identity by GraphQL actor __typename, with the [bot] suffix
  and login list as backstops, so the list can't silently rot.
- Spell out PR-number resolution across the four differing event shapes.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(plan): PR comment response gate implementation plan

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(pr-triage): verdict vocabulary and reply composition

* feat(pr-triage): parse verdicts from marker and hand-typed replies

* feat(pr-triage): classify findings into answered and unanswered

* feat(pr-triage): render the check-run summary

* feat(pr-triage): audit, list and reply CLI verbs

* ci: block merge while review findings lack a verdict reply

Adds the pr-comment-response check run, triggered on review comments,
reviews, and PR sync/open events. It fetches dev-tools/pr-triage.js from
the base branch (never the PR head) via pull_request_target so forked
PRs still get a status without ever checking out or running PR code,
then runs `audit --pr <N>` and publishes success/failure as a check run
named exactly `pr-comment-response`.

* docs(workflow): require a verdict reply per finding in Phase 9d

Wires dev-tools/pr-triage.js into the /dev workflow: Phase 9d now
replies to every finding with list/reply before classifying, Phase 9e
requires audit --pr <PR> to exit 0, and dev-tools/README.md documents
the three verbs.

* refactor(pr-triage): dedupe finding construction, parallelize independent PR reads

classifyThreads built near-identical finding objects for threads and
reviews inline; factor both into a shared makeFinding helper. In runCli,
fetchPr (GraphQL threads/reviews) and the commits fetch (REST) don't
depend on each other's result, so run them concurrently instead of in
series.

No behavior change — full prTriage.test.ts suite (44 tests) still passes.

* fix(review): wire MIN_SHA_PREFIX into commit regex, drop nested ternary, surface commits-fetch failures

- citesKnownCommit now builds its SHA regex from MIN_SHA_PREFIX instead of
  hardcoding {7,40} alongside the unused constant (maintainability, ocr-rules).
- renderSummary's unanswered-row location is now built by a small
  formatLocation() helper instead of a nested ternary (ocr-rules).
- runCli's audit path no longer swallows a failed/malformed PR-commits fetch
  silently: it now logs a visible warning (which lands in the same
  stdout+stderr stream the check-run summary is built from) before falling
  back to skipping commit-citation verification, and a test covers the
  failure path (sound-logic).

Addresses: maintainability (dev-tools/pr-triage.js:134), ocr-rules
(dev-tools/pr-triage.js:279, :134), sound-logic (dev-tools/pr-triage.js:483).

* fix(review): move github-context expressions into env vars in pr-comment-response workflow

Six ${{ github.* }} expressions were interpolated directly into shell run:
blocks (repository name, event name, default branch, and step outputs).
All values here are fixed-format/numeric with no exploit path today, but
the pattern is the one the yaml script-injection rule exists to prevent
repo-wide, and this is a pull_request_target workflow. Route every value
through env: instead, matching the pattern GitHub's own hardening guide
recommends.

Addresses: ocr-rules (.github/workflows/pr-comment-response.yml:58).

* fix(review): correlate CHANGES_REQUESTED answers per reviewer

Addresses the sound-logic reviewer's critical finding. The review-level
branch marked a CHANGES_REQUESTED review answered if ANY other review
carried a maintainer verdict — so one reply satisfied every reviewer
blocking at once, and a verdict written earlier could pre-answer a later
finding.

- Collapse reviews to each author's latest, mirroring how GitHub decides
  whether a reviewer is currently requesting changes.
- Require the answering review to be later AND to name the reviewer.
- Fetch submittedAt so ordering is available.
- Hold marker-form replies to the same rationale floor as hand-typed
  ones, so a hand-written marker reply can't pass with 'ok'.

52/52 unit tests pass. Design doc records the amended contract and why
naming-based correlation was preferred.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(review): fail closed, fix hyphen-login crash, answer review-level findings

CodeRabbit Phase 7c fold. The theme across most findings was that the
gate failed OPEN — reporting success on data it could not read.

Critical: mentionsLogin escaped '-' as '\-', an invalid identity escape
outside a character class under the /u flag. Every hyphenated reviewer
login (chatgpt-codex-connector, copilot-pull-request-reviewer) threw and
took the whole audit down. Own tests missed it because the bot-check
short-circuits first; added a regression test.

Fail closed everywhere the PR cannot be fully read:
- GraphQL errors or a missing pullRequest node now throw instead of
  returning empty (which had silently passed as 'no findings').
- A failed PR-commits fetch is an error, not a skipped verification.
- A thread whose replies exceed one page refuses to render a verdict.
- Exit 2 publishes a FAILED check, never a pass.

Also:
- Paginate reviews on their own cursor; the old query re-read the same
  first 100 on every thread page and dropped anything beyond.
- Add reply --review <login> so a CHANGES_REQUESTED review, which is
  blocking but has no thread to nest under, has a supported answer path.
- Reject bare/non-numeric --pr (Number(true) === 1 silently targeted #1).
- Make repo resolution injectable; tests no longer shell out to gh
  (suite runtime 4s -> 11ms).
- Escape pipes in summary cells; CodeRabbit badge rows were splitting
  the table into bogus columns.
- Prefer backticked SHAs when verifying an 'agreed' citation.
- Re-audit on comment/review edited+deleted so an edited-away verdict
  cannot leave a stale green.
- Document the fork limitation honestly and withdraw the design's
  promise to list issue-level comments (nothing ever fetched them).

63/63 unit tests pass; real-data smoke test on PR #657 exits 1 with the
five unanswered bot findings.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ci): report gate-not-installed instead of 404ing, stop cancelling runs

Two failures on this PR's own CI, both in the new workflow:

- The auditor is fetched from the DEFAULT branch (deliberately — a PR must
  not swap out the script that grades it), but it isn't on main until this
  merges, so the fetch 404'd and the job died. It now publishes a NEUTRAL
  check saying the gate isn't installed yet. Claiming a pass would be a
  lie; failing would block the PR that installs the gate.
- cancel-in-progress cancelled earlier runs on a burst of bot comments,
  and a cancelled run shows as a FAILED check — red for a reason having
  nothing to do with unanswered findings. The audit takes seconds; let
  each event finish.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(review): COMMENTED must not clear a change request; verify review-level commits

Phase 9d fold of 13 inline findings on PR #662.

Two real logic bugs (both Codex P1):
- latestReviewPerAuthor collapsed to the newest review of ANY state, so a
  routine follow-up COMMENTED review retired a still-open
  CHANGES_REQUESTED. GitHub only clears one on APPROVED or DISMISSED, and
  bots post COMMENTED reviews constantly — this silently dropped blocking
  findings. Only state-setting reviews now count.
- The review-level path accepted an 'agreed' verdict without verifying the
  cited commit, while the thread path required it — making --review the
  soft way in. Both paths now verify.

Correctness:
- gh api --paginate emits one JSON array PER PAGE; JSON.parse chokes on the
  concatenation. Added --slurp and flatten (CodeRabbit critical, Codex P2).
- cell() escaped | without first escaping \, which CodeQL flagged as
  incomplete escaping: a body ending in \ produced a live separator.
- Emoji strip used [✅↩️⏭️], repeating U+FE0F inside one character class.
- The post-reply resolve lookup could throw after the reply was already
  public, turning a successful reply into a failure that invites a
  duplicate re-run (Codex P2).

Usability:
- reply now rejects --comment and --review together instead of silently
  taking one path (Copilot).
- list names the flag that actually works per finding kind; it used to
  print '--comment' even for review-level findings (Copilot).
- Documented push-before-reply in the skill, not just the workflow script.
- Dropped the issue_comment trigger: issue-level conversation is out of
  scope, so those runs could never change the outcome.

70/70 unit tests pass; typecheck clean.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(review): don't disarm the gate on non-404 fetch errors

Copilot round on PR #662.

- The bootstrap fallback treated ANY failure to fetch the auditor from the
  default branch as 'not installed' and published a neutral check. A rate
  limit or token problem would therefore silently disarm the gate — the
  exact fail-open this check exists to prevent. Only a genuine 404 now
  means 'not installed'; anything else fails the job loudly.
- reply validates --comment/--review before composing, so the body is only
  built once the target is known.
- README said the check 'blocks merge'; it only blocks once added as a
  required status check in branch protection. Stated accurately.

70/70 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ci): drop concurrency group, document the bot-trigger approval caveat

Two problems observed in this PR's own runs of the new workflow:

- Runs kept showing as 'cancelled' despite cancel-in-progress: false.
  GitHub cancels a PREVIOUSLY PENDING run when a newer one joins a
  concurrency group, and a burst of bot comments produces exactly that.
  Each cancellation surfaces as a FAILED check — red for a reason
  unrelated to unanswered findings. Removed the group; the audit takes
  seconds, so running every event is the cheaper trade.

- A run triggered by Copilot came back action_required: GitHub held it for
  maintainer approval. While that holds, a finding posted after the last
  push won't turn the check red on its own. Documented plainly, and added
  workflow_dispatch so a maintainer can re-run by hand. pull_request_target
  on push (human actor, never gated) and the local Phase 9d audit both
  still cover the gap.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(review): valueless --commit crashed with a TypeError

Copilot round 3 on PR #662.

parseArgs gives a valueless flag the boolean true, so `--commit` with no
value reached (commit ?? '').trim() and threw a raw TypeError instead of
the usage error the author needs. Coerced with String() and rejected the
literal 'true'. Same coercion applied to rationale.

Also realigned the design doc's trigger table with the workflow: the
issue_comment trigger was removed two commits ago but the doc still listed
it and described PR-number resolution across four event shapes.

71/71 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(lessons): retrospective from the PR comment response gate (#662)

Six lessons on gate design and workflow, the central one being that a
gate which errors must fail CLOSED — three reviewers independently caught
variants of 'could not read the PR' rendering as 'nothing to report'.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(review): reject valueless --comment/--review flags

Copilot round 4 on PR #662. parseArgs gives a valueless flag the boolean
true, so `--comment` with no value would have built a request to
/comments/true/replies, and `--review` with no value a mention of '@true'.
--comment now requires a numeric id and --review a login, with the boolean
itself as the discriminator (the string 'true' passes a login pattern).

74/74 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ci): drop PR-controlled triggers, add scheduled re-audit and head-race guard

CodeRabbit round 5 — three Major findings on the workflow, all correct.

SECURITY: pull_request_review and pull_request_review_comment run the
workflow file FROM THE PR (verified: a run reported headBranch =
feature/pr-comment-response-gate). A contributor could therefore edit this
YAML in their own PR to publish a passing check and disarm the gate —
fetching pr-triage.js from the default branch protected the script but not
the workflow invoking it. Both triggers removed. They were also landing in
action_required for bot actors, so they were unreliable regardless.

The staleness those triggers covered is now handled by a 30-minute
schedule that re-audits every open non-draft PR. Schedule,
pull_request_target and workflow_dispatch all run the DEFAULT branch's
copy, which no PR can modify. Forks are now audited on the same footing,
since the removed triggers were the only read-only ones.

RACE: head_sha was read only in the publish step, so a synchronize during
the audit would stamp a verdict computed against the old commit onto the
new one. The head is now captured before the audit, re-read after, and a
mismatched result is discarded rather than published.

Also documents workflow_dispatch in the design doc's trigger matrix.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ci): --slurp cannot combine with --jq; fail closed on head-read errors

CodeRabbit/Copilot round 6.

CRITICAL: 'gh api --paginate --slurp --jq' is rejected outright — 'the
--slurp option is not supported with --jq or --template'. The scheduled
branch would have died before selecting any PRs, so the 30-minute
re-audit (the entire staleness mitigation for dropping the review
triggers) would never have run. Verified against the live CLI. Now
paginates to a file and runs jq over it separately.

The two head-SHA reads no longer depend on ambient 'set -e' state, which
was off on the first loop iteration: a failed read left BEFORE empty, the
!= comparison then read as 'the PR advanced', and the loop continued
WITHOUT setting FAILED — a fail-open in the one file that exists to fail
closed. Both reads are now explicitly checked and fail closed.

renderSummary's help snippet showed only the --comment form, so a
review-level blocker came with instructions that cannot resolve it. Both
forms are now shown and the prose no longer calls every answer 'threaded'.

74/74 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(review): require a strictly later verdict, not same-second

Copilot round 7. Review-level correlation excluded only strictly EARLIER
verdicts, so one sharing the change request's exact submittedAt counted as
an answer. GitHub timestamps resolve to the second, so an equal stamp
cannot be ordered — and 'cannot be ordered' must not read as 'came after',
or a verdict written first pre-answers the finding. Now strictly later.

75/75 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(review): a leading @mention must not pad a trivial rationale

Copilot round 8, and a hole I opened myself when adding the @mention for
review-level replies: the mention counted toward MIN_RATIONALE_LENGTH, so
'Agreed — @coderabbitai ok' passed the non-trivial-rationale guarantee.
Leading mentions are now stripped before the length is measured, on both
the marker and hand-typed paths — a mention addresses, it does not explain.

77/77 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
jdelgado2002 added a commit that referenced this pull request Jul 29, 2026
Phase 7a flagged useWeekPublicationStatus as diverging from the corrected
RPC, but reviewed it against pre-#671 code (hardcoded ${weekEnd}T23:59:59Z
literals). That code no longer exists: #671 already switched the shifts
count to full instants, so a viewer in the restaurant's own timezone agrees
with restaurant-local bucketing.

What remains is non-goal #1 from the #671 design doc — the week boundary is
anchored to the viewer's timezone, not the restaurant's. This change does
not introduce it, but makes it observable in one more case, so record that
plainly rather than deferring silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jdelgado2002 added a commit that referenced this pull request Jul 30, 2026
…ezone (#675)

* docs(spec): design for timezone-aware publish/unpublish week bucketing

publish_schedule and unpublish_schedule bucket shifts with a bare
start_time::date, which resolves against the database session TimeZone
(UTC on Supabase) rather than the restaurant's IANA zone. Late-night
shifts land on the wrong side of the week boundary in both directions.

Design adopts the v_tz pattern already used by get_open_shifts in the
same code family, and specifies pgTAP coverage at both week edges.

Follow-up to tracked non-goal #2 of
docs/superpowers/specs/2026-07-27-publish-week-tz-offbyone-design.md

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

* docs(spec): fold in Phase 2.5 supabase design review

Critical finding accepted: publish_schedule/unpublish_schedule have never
carried a GRANT/REVOKE, so both still hold the default PUBLIC EXECUTE while
being SECURITY DEFINER — an anonymous caller can publish or unpublish any
restaurant's week. The migration now also revokes anon and grants EXECUTE to
authenticated + service_role, with has_function_privilege assertions. The
in-body identity check stays a tracked follow-up.

Three minor doc fixes: label the get_open_shifts snippet as a composite of
two migrations, name schedule_publications.published_by as the NOT NULL
column, and document the PERFORM probe as a deliberate variant of the
sibling EXCEPTION-fallback convention.

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

* docs(plan): implementation plan for tz-aware publish/unpublish bucketing

Four tasks: red pgTAP suite (15 assertions), the migration that turns it
green, the stale E2E comment fix, and a full verification gate. Complete
SQL for both the suite and the migration is inlined.

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

* test(schedule): pin restaurant-local week bucketing for publish/unpublish

Failing suite: both RPCs bucket with a bare start_time::date, so a 22:00
America/Chicago shift on week_end is excluded and the same shift before
week_start is wrongly included. Also asserts the EXECUTE boundary the
migration is about to add.

* fix(schedule): bucket publish/unpublish weeks in the restaurant's timezone

publish_schedule and unpublish_schedule selected shifts with a bare
start_time::date, which resolves against the database session TimeZone (UTC on
Supabase) rather than restaurants.timezone. A 22:00 America/Chicago closing
shift is already the next UTC calendar day, so it was dropped from its own week
and swept into the next one; east of UTC the slip mirrors.

Both functions now resolve the restaurant's IANA zone into v_tz and bucket with
(start_time AT TIME ZONE v_tz)::date, matching get_open_shifts. Invalid zone
strings fall back to UTC instead of aborting the publish.

Re-declaring also adds SET search_path to these SECURITY DEFINER functions and
an EXECUTE boundary: both still carried the default PUBLIC grant, so anon could
publish or unpublish any restaurant's week. An in-body caller identity check
remains a tracked follow-up.

* test(e2e): correct stale comment about publish_schedule date bucketing

The comment said the RPC compared the local date; before the tz-bucketing fix
it compared the session (UTC) date. Now accurate, and it names the actual
source of truth. Comment only — the noon fixture is unchanged.

* docs(spec): record the browser-local week anchor as a tracked non-goal

Phase 7a flagged useWeekPublicationStatus as diverging from the corrected
RPC, but reviewed it against pre-#671 code (hardcoded ${weekEnd}T23:59:59Z
literals). That code no longer exists: #671 already switched the shifts
count to full instants, so a viewer in the restaurant's own timezone agrees
with restaurant-local bucketing.

What remains is non-goal #1 from the #671 design doc — the week boundary is
anchored to the viewer's timezone, not the restaurant's. This change does
not introduce it, but makes it observable in one more case, so record that
plainly rather than deferring silently.

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

* fix(review): drop the incorrect UTC guarantee from the noon-seed comment

Copilot review: the comment claimed noon is the same calendar day in both
the restaurant's zone and UTC. That is false for offsets at or beyond
±12h (Pacific/Kiritimati at UTC+14 puts local noon at 22:00 UTC the
previous day).

The UTC relationship is not what the seed relies on anyway — post-fix the
RPC buckets by restaurant-local date. Reword to state what actually holds:
noon sits ~12h from either end of the local day, so the browser-local
Monday survives any offset difference under ~12h, which covers the
America/Chicago default this spec signs up with. Point at the pgTAP suite
that does cover the boundary directly.

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

* fix(migrations): move tz-bucketing migration off the 20260728140000 collision

PR #673 landed 20260728140000_search_pos_items.sql on main while this
branch was open. CI builds the merge ref, so the duplicate prefix showed
up there while a branch-local `ls | uniq -d` stayed clean — failing
migrationVersionUniqueness, and taking `supabase start` (and therefore
pgTAP and all four E2E shards) down with it.

Renamed to 20260729120000 and updated every reference. The design doc
and plan now record the merged-set check that would have caught this.

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

* fix(scheduling): guard publish/unpublish against cross-tenant calls

CodeRabbit flagged at Critical that publish_schedule and unpublish_schedule
are SECURITY DEFINER with no authorization check, so any authenticated
caller could publish, lock, or unpublish another tenant's shifts by passing
that restaurant's UUID.

Pre-existing, not introduced here: 20251123000000 defines both as SECURITY
DEFINER with no check, and nothing redefines them in between. This PR had
already narrowed it by revoking the default PUBLIC/anon EXECUTE; closing the
cross-tenant half here rather than deferring, since the migration is already
restating access control on these two functions.

Uses the canonical user_has_restaurant_access(p_restaurant_id, false).
Membership, not manager-role — which roles may publish is a product decision
and the UI's gating is the current authority; `false` keeps every caller that
works today working. Only caller is useSchedulePublish.tsx with a user JWT,
so no service_role or cron path loses access.

Also derives publish_schedule's shift_count from GET DIAGNOSTICS instead of a
preceding COUNT(*), matching unpublish_schedule in the same file: concurrent
publishes would otherwise record a count of shifts they did not publish.

pgTAP 15 -> 19, adding cross-tenant denial for both functions plus
unchanged-state assertions on the target restaurant. Full suite 2124 passing.

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

* docs(lessons): retrospective from the publish-schedule tz bucketing work (#675)

Three lessons from the PR #675 run:

- A migration prefix is only unique against the merged set. CI builds the
  merge ref, so a collision introduced by another PR merging mid-branch is
  invisible to `ls` on your own branch. It cost six red jobs, five of which
  failed at `Start Supabase local database` and read like infra flake; the
  one job needing no database named the cause. Go to the job with the fewest
  dependencies first.

- A review finding can be correct about code that no longer exists. Check
  `git merge-base --is-ancestor origin/main HEAD` before acting on one — a
  stale base makes reviewers describe a world that was fixed weeks ago.

- Revoking `anon` from a SECURITY DEFINER function is half a fix: the
  `authenticated` role cannot distinguish tenants. EXECUTE grants and
  in-body authorization are independent controls, and only the second is
  multi-tenant. When closing the gap needs a policy choice, close the
  unambiguous hole and defer the judgment call explicitly.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file javascript Pull requests that update javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants