# C3_SPEC.md — Living Session Log

Purpose: give a fresh Claude Code session full context on the KHAIZEN C3
migration (Apps Script → Next.js/Supabase) without re-deriving it from
chat history. Update this after each real session.

Stack: Next.js + Supabase (project ref `eelqsipzttcdlqkdfqzy`), deployed to
Vercel (`c3-website-indol.vercel.app`), GitHub `edwin931`. Legacy source
cloned locally via clasp at `~/Desktop/apps-script-1P0T` (`Code.js`,
`JavaScript.html`, `Index.html`, `appsscript.json`) for reference/porting.
New repo at `~/Desktop/c3-website`.

**Key environment fact:** no Supabase CLI link, no DB connection string in
this dev environment. Schema migrations (DDL) go: Claude Code writes the
`.sql` file → prints/shows it → **you manually copy-paste into the Supabase
Dashboard SQL Editor and run it yourself.** Plain data writes (INSERT/UPDATE
under existing RLS) *can* go through a normal authenticated session/route
if policies allow it — but DDL cannot.

**Hard lesson, repeat this to future sessions:** copying via clipboard
tools (`pbcopy`) from Claude Code's shell to your browser's paste buffer
has silently failed at least twice this project (reported "copied," paste
produced no error but nothing changed). **Always manually select-and-copy
the printed file content with your mouse instead of relying on clipboard
piping.** Always clear the SQL Editor completely before pasting. Never
trust a bare "Success. No rows returned" banner alone — it fired
identically for a migration that fully ran and (twice) for one that
silently didn't. Verify with a live `SELECT` after every migration.

**Addendum (0036a/0036c saga, this session):** a verification query that
only checks `prosrc like '%some_identifier%'` (or similarly greps a
policy's text) is not sufficient — it can return a false positive by
matching an inline SQL *comment* rather than the actual logic, and in
this case returned true when the underlying function/policy hadn't
changed at all. It took four full paste-and-run attempts (function) and
two more (policy) before the change genuinely landed, cause never fully
confirmed (most likely a stale/leftover-paste tab, never definitively
isolated). **Always verify by pulling the FULL body/definition** —
`select prosrc from pg_proc where proname = '...'` for functions,
`select pg_get_expr(polqual, polrelid), pg_get_expr(polwithcheck,
polrelid) from pg_policy where polname = '...'` for policies — and
actually read it, not just check a substring matched.

---

## Migrations applied so far (0001–0033), in order

- 0001–0007: earlier base schema (invoices, etc.) — pre-dates this log.
- 0008: `profiles` table (`id`, `agent_id`, `is_invoice_reviewer`,
  `is_invoice_approver`, `created_at` — **the two boolean flags are gone
  now, see 0019**), `is_invoice_reviewer()` / `is_invoice_approver()` SQL
  functions (**also gone, see 0019**), invoices RLS originally referencing
  them (**migrated onto `has_permission()` in 0018**).
- 0009/0010: `invoices_update_approver` policy (and similar) originally
  called `is_invoice_approver()` directly — **migrated to
  `has_permission()` in 0018, see below.**
- 0011: `schedule` table.
- 0012: `invoice_profiles` table (per-agent rate override).
- 0013: `invoice_settings` (global row, `holiday_pay`, `bonuses` jsonb) +
  `rate_history` table.
  - All three (`0011`/`0013`) originally shipped with `*_admin_TODO`
    write policies on `using (true)` — a deliberate open placeholder
    pending a real admin flag. **This was later closed by 0015.**
- 0014: `permissions` system.
  - `profiles.is_admin` column added.
  - `feature_registry` table (`feature_key` PK, currently only one row:
    `'invoicing'`).
  - `permissions` table (`user_id`, `feature_key`, `can_view`,
    `can_write`, `granted_by`, `granted_at`, composite PK).
  - RLS added on both new tables (self-or-admin select, admin-only write,
    inline `is_admin` check — `has_permission()` didn't exist yet at this
    point).
  - Existing `is_invoice_reviewer`/`is_invoice_approver` flags **folded
    into `permissions` rows** for `feature_key='invoicing'` (reviewer →
    `can_view=true`, approver → `can_view=true, can_write=true`). Old
    columns deprecated at this point, not yet dropped — **now fully
    dropped, see 0019.**
- 0015 (`0015_has_permission_and_feature_writes.sql`): `has_permission(feature
  text, need_write boolean default false)` function (admin bypass +
  permissions-row check). Rewrote **all six** `*_admin_TODO` write
  policies across `schedule`/`invoice_settings`/`rate_history` to use it
  (not just the 3 the original spec snippet illustrated — extended to
  all 6 deliberately). **This closed the open-write-access hole.**
  Verified behaviorally: unprivileged test account blocked on all three
  tables; Quinty/Edwin unaffected.
- 0016 (`0016_broad_reviewer_role.sql`): `profiles.is_broad_reviewer`
  column. `has_permission()` extended with a third clause: if
  `is_broad_reviewer=true`, `feature != 'invoicing'`, and
  `need_write=false` → grant. Gives Berry (see roster) broad view access
  to all *future* non-invoicing features, without settings/admin
  authority. Verified via temp account. **Exclusion list extended to
  also exclude `'rate_and_schedule'` in 0020** — Berry should have zero
  access to anything invoicing-adjacent, not just invoicing itself.
- 0017 (`0017_profiles_auto_provision_trigger.sql`): trigger
  `on_auth_user_created` on `auth.users` insert → auto-creates a
  `profiles` row (`is_admin=false`, `is_broad_reviewer=false`,
  `agent_id=null`). Plus a one-time backfill block for the 10 people
  invited before the trigger existed. Verified live (new signup got an
  auto-created profile row; the 10 backfilled rows exist).
- 0018 (`0018_migrate_invoice_approver_to_has_permission.sql`): moved the
  last five live call sites off `is_invoice_reviewer()`/`is_invoice_approver()`
  onto `has_permission()` — `invoices_select_reviewer_or_approver`,
  `invoices_update_approver` (self-approval exclusion from 0009 preserved
  verbatim), the `invoices_restrict_content_edit_when_submitted()` trigger
  function, and all four `qpi_qualifications` policies (select was
  approver-only in the legacy source too, not reviewer-level). Grep of
  every migration found five sites, not the two informally named
  "0009/0010" in conversation — `0010`'s own trigger doesn't call either
  function at all.
  - **Also fixed a real bug in `has_permission()` itself**, folded into
    this same migration per explicit go-ahead: it never checked `can_view`
    for the `need_write=false` path, only whether a `permissions` row
    existed at all. A user explicitly set to `can_view=false` (e.g. via
    `/settings/users` unchecking View, which upserts `false/false` rather
    than deleting the row) would still have passed. Fixed by requiring
    `can_view` unconditionally in the permissions-row branch — a no-op for
    the `need_write=true` path since `write_implies_view` (0014) already
    guarantees `can_write=true` rows have `can_view=true`.
  - Verified live: two DB-side re-checks (zero remaining policy/function
    references) both returned 0 rows. Behaviorally verified the `can_view`
    fix itself with a temp account — `can_view=false, can_write=false` on
    `invoicing` correctly blocked `SELECT` on both `invoices` and
    `qpi_qualifications` (would have leaked through before 0018); temp
    account, its `permissions` row, and its `profiles` row all confirmed
    removed afterward.
  - One near-miss worth remembering: the first draft of the "any other
    function still references these?" verification query called
    `pg_get_functiondef(oid)` inside a `WHERE` clause ANDed with an
    `nspname='public'` filter. Postgres doesn't guarantee predicate
    evaluation order, so it hit the built-in `array_agg` aggregate's own
    `pg_proc` row before the schema filter excluded it —
    `pg_get_functiondef` throws `"X" is an aggregate function` (42809) on
    any aggregate's oid. Fixed by filtering `prokind = 'f'` inside a CTE
    first, then projecting `pg_get_functiondef` only over already-filtered
    rows. **Lesson for future verification queries**: never call
    `pg_get_functiondef`/similarly-strict catalog functions inside a
    multi-predicate `WHERE` clause — isolate the narrowing filter in its
    own CTE/subquery first.
- 0019 (`0019_drop_invoice_approver_columns_and_functions.sql`): dropped
  `profiles.is_invoice_reviewer`, `profiles.is_invoice_approver`,
  `public.is_invoice_reviewer()`, `public.is_invoice_approver()`.
  Self-guarding — re-ran both DB-side verification checks (corrected form)
  inside the same transaction immediately before dropping anything, so a
  stale reference would have aborted the whole migration rather than
  silently breaking something. Preceded by a full `grep -rn` of `src/`
  (not just `supabase/migrations/`) for both names, which turned up one
  real live reference that the DB-side checks alone couldn't see (app code
  isn't in `pg_policies`): `src/app/invoices/[id]/page.js` was still
  reading `profile.is_invoice_approver` to gate the approve/reject/reverse/
  pay buttons. **Confirmed live** — `profiles` no longer has either column.
- 0020 (`0020_split_rate_and_schedule_from_invoicing.sql`): fixed a real
  access-control bug — Dominic/Andrew's single `invoicing` permissions
  row (below) also granted them invoice view/approval and full
  `qpi_qualifications` access, since `'invoicing'` was the one
  `feature_key` gating schedule/invoice_settings/rate_history/
  qpi_qualifications/invoices all together. Full write-up in its own
  section below (**"Rate & Schedule access split"**) — see there for the
  complete before/after, the Quinty consequence, and the read-visibility
  clarification.

**Post-0017 manual grants (done via hand-run SQL, not yet a UI):**
- Berry (`e5f06a25-8e3e-4839-94be-c1ed852fc458`): `is_broad_reviewer=true`
  — confirmed live.
- Dominic (`c04f7a4a-ca51-4d6e-b146-f35e51b57e01`) and Andrew
  (`fd91642c-71ec-4716-b275-efc08564a625`): originally granted
  `permissions` row `feature_key='invoicing'`, `can_view=true,
  can_write=true` — **superseded by 0020, see "Rate & Schedule access
  split" below.** They now hold `feature_key='rate_and_schedule'` only;
  the `invoicing` row was deleted entirely.
- 0021 (`0021_performance_tables.sql`): new `feature_key='performance'`.
  Four tables — `commslayer_reports`, `penalties`, `qa_scores`,
  `trustpilot_scores` — ported from legacy `state.reports`/`penalties`/
  `qa`/`trustpilot`. View gated on `has_permission('performance')`
  (unlike schedule/rate_history's deliberate open read), agents always
  see their own rows regardless of the grant, no write path for agents
  on any of the four. No permissions rows granted yet (admin-only until
  specific people are granted via `/settings/users`).
- 0022 (`0022_assign_agent_ids.sql`) + 0023 (`0023_commslayer_agent_map.sql`):
  see "Performance feature" section below — discovered 11 of 12 people
  had no `agent_id` at all.
- 0024/0025: `commslayer_reports` schema corrections found by testing the
  real Commslayer API directly — see "Performance feature" section.
- 0026 (`0026_performance_settings.sql`): `performance_settings` singleton
  row table (`weights`, `shift_multipliers`, `shift_resolution_offsets`
  jsonb; `admin_bonus_rate`/`admin_bonus_cap` numeric), same pattern as
  `invoice_settings`; plus `commslayer_reports.admin_bonus_points` column.
  Gated on `has_permission('performance', ...)`. Confirmed live.
- 0027 (`0027_handovers.sql`): `handovers` table for `/handover`. See
  "History & Insights, Shift Handover, Coaching Notes" section below.
- 0028 (`0028_agent_notes.sql`): `agent_notes` + `agent_note_replies`
  tables for `/coaching`. Same section below.
- 0029 (`0029_profiles_is_active.sql`): `profiles.is_active` boolean,
  default `true` (existing profiles unaffected). Legacy's
  `state.agents[].active` equivalent — "on/off the roster right now."
  The column alone does nothing; **`/api/agent-roster` and
  `/api/team-standings` both filter on `is_active=true`** (updated in the
  same commit), and every page listing agents sources its roster from one
  of those two routes. `/settings/users` got a real "Active" toggle
  checkbox per user (tooltip: "Inactive agents are removed from
  schedule/ranking/performance lists"), wired through a `toggleActive()`
  call to `/api/admin-users`. **This closes the `renderAgents`
  active/inactive gap** noted in the gap-check table further below —
  that row is now stale, see the correction note under "Still open."
- 0030 (`0030_invoice_profiles_rate_gating.sql`): fixed a real live gap —
  `invoice_profiles` (0012) had only self policies, and
  `invoice_profiles_update_own`'s blanket row-level check let an agent
  write to EVERY column on their own row, including `hourly_rate_usd`/
  `invoice_prefix` — a direct pay-rate override
  (`computeMonthlyBase`, `invoiceLogic.js:377-379`) an agent could set on
  themselves via a crafted direct request. Confirmed live the table had 0
  rows at the time, so unexploited, but the door had been open since 0012.
  Fix: added `rate_and_schedule`-gated admin select/insert/update
  policies (6 policies total, 3 self + 3 admin), PLUS a
  `BEFORE INSERT OR UPDATE` trigger,
  **`invoice_profiles_restrict_rate_fields`**, blocking
  `hourly_rate_usd`/`invoice_prefix` changes by anyone without
  `has_permission('rate_and_schedule', need_write => true)` — independent
  of which RLS policy let the statement through (same trigger-based
  column-guard pattern as `invoices_restrict_content_edit_when_submitted`,
  0008/0018). SELECT gated the same as UPDATE (not left open like
  `rate_history`'s own read policy), deliberately more conservative since
  this table also holds PII (address, bank, SSS/Pag-IBIG).
- 0031 (`0031_is_agent_and_full_name_seed.sql`): two related changes.
  (1) `profiles.is_agent` boolean, default `false`, flipped `true` for
  exactly the 5 real CS agents (`mayvel`/`mon`/`jurina`/`kate`/`rubyrose`)
  by `agent_id` — a DIFFERENT axis from `is_active` (0029): `is_agent` is
  "is this person one of the 5 CS agents at all" (never changes day to
  day), `is_active` is "on/off the roster right now" (anyone). Feeds
  `/api/agent-roster`, which now filters on both. (2) Seeded
  `invoice_profiles.full_name = 'Edwin'` for `agent_042` (first name
  only) — purely additive, the other 11 stay `''` (existing default,
  falls back to `agent_id`/"—" in the UI) until their real names are
  confirmed. Migration guard asserts exactly 5 `is_agent=true` rows and
  Edwin's `full_name` — both confirmed live.
- 0032 (`0032_holidays.sql`): new `holidays` table (`date` unique,
  `label`, `added_by`) — a DIFFERENT axis from
  `invoice_settings.holiday_pay` (jsonb `{enabled, rate, standardHours,
  dates}`, unchanged), which still owns the top-up rate/percentage
  config; this table only owns WHICH DATES are holidays.
  `buildLineItems()`'s existing `holidayPay.dates`-array logic is
  unchanged by this migration. New `is_current_user_agent()` helper
  function (same small-named-boolean-function convention as
  `is_invoice_reviewer()`/`is_invoice_approver()` used to be). RLS: view
  = real agent (`is_agent=true`) OR `rate_and_schedule` write (narrower,
  deliberately NOT `schedule_select_all`'s wide-open `using (true)`);
  write (insert/update/delete) = `is_admin` only. **Schema/RLS only — no
  UI was ever built on top of this.** Confirmed by a full `src/` grep for
  `holidays`: zero references anywhere. The migration's own comment
  explicitly scoped this: "wiring holidays -> that array (or replacing
  it) is Stage 3 UI work, not a schema concern" — that UI work has not
  happened. **Still genuinely open**, see "Still open" below.
- 0033 (`0033_invoices_is_manual.sql`): `invoices.is_manual` boolean,
  default `false` — flags an invoice created directly via the blank
  line-item editor (`/invoices/new-manual`) vs. the schedule-derived
  generation flow. No RLS/trigger change — migration's own comment states
  explicitly why none was needed: `weeks` already defaults to `'[]'`
  (0001), the relevant snapshot columns are already nullable, and
  `invoices_insert_own` (0008) only requires
  `agent_id = current_agent_id()` and `status = 'draft'`, nothing
  schedule/id-shape specific. Manual invoices get their own id scheme
  (`inv_manual_<uuid>`, client-generated), fully decoupled from
  `generateInvoiceId()`'s deterministic `inv_<agentId>_<month>_c<cut>`
  format, so the two can never collide. Confirms (see "Retract +
  draft-only content edit" section below) that the owner-transition and
  content-edit-lock triggers apply identically to manual and
  schedule-derived invoices — nothing in 0033 carves out an exception.

---

## Team roster (12 people — confirmed final count)

CIao = Edwin (same account/person, not a separate teammate — resolved a
false "12th person" confusion earlier in this project).

| Name | Email | Account status | Role/notes |
|---|---|---|---|
| Edwin (=CIao) | edwin@khaizenunderwear.com | has account, `is_admin=true` | builder/admin |
| Quinty | quinty@khaizen.eu | has account | invoice approver (`can_write` on invoicing) — schedule/rate `write` access she incorrectly had via the old shared gate was removed by 0020 (correct, she was never a legacy billing admin) |
| Dominic | dominic@khaizenunderwear.com | invited | billing admin → `rate_and_schedule` `can_write` only (0020) — zero invoicing access |
| Andrew | andrew@khaizenunderwear.com | invited | billing admin → `rate_and_schedule` `can_write` only (0020) — zero invoicing access |
| Kenn | kenn@khaizen.eu | invited | invoice reviewer (view only) |
| Bjorn | info@khaizen.eu | invited | invoice reviewer — **⚠️ email looks like a shared/generic inbox, never confirmed with him whether it's personal. Resolve before trusting this account is really his.** |
| Berry | berry@khaizenunderwear.com | invited | `is_broad_reviewer=true` (granted) — was named in a legacy code comment as a reviewer but missing from the actual `INVOICE_REVIEWERS` array; real gap, now fixed going forward |
| Mayvel | mayvel@khaizenunderwear.com | invited | agent |
| Mon | mon@khaizenunderwear.com | invited | agent |
| Jurina | jurina@khaizenunderwear.com | invited | agent |
| Kate | kate@khaizenunderwear.com — **wrong**, actual is ks@khaizenunderwear.com | invited (correct address) | agent |
| Rubyrose | rubyrose@khaizenunderwear.com | invited | agent |

**`profiles.agent_id` assignment (0022) + `commslayer_agent_map` seed
(0023) — CONFIRMED LIVE.** Discovered while building the Commslayer
agent mapping (see below): 11 of these 12 people had `agent_id = NULL` —
nothing in `invoice_profiles` (0 rows), `invoices`, or `schedule` ever
referenced a real agent_id for any of them except Edwin's `agent_042`.
`0022_assign_agent_ids.sql` assigns name-based slugs (`jurina`, `kenn`,
`dominic`, `berry`, `rubyrose`, `quinty`, `kate`, `mayvel`, `andrew`,
`bjorn`, `mon` — legacy's `idOf(name)` scheme), matched by
`auth.users.email` to avoid hardcoded UUIDs. `0023_commslayer_agent_map.sql`
seeded all 12 real people's Commslayer numeric ids against those
agent_ids (`ai_agent_1877` excluded — Commslayer's own AI agent, not a
person). Both confirmed live: all 12 `agent_id`/`commslayer_agent_id`
rows present and correct.

**Real near-miss worth remembering: `0023` ran successfully once before
`0022` had actually been applied**, silently seeding `commslayer_agent_map`
rows referencing agent_ids (`jurina`, `dominic`, etc.) that didn't exist
in `profiles` yet — no error, no rejected insert, nothing. That's because
`commslayer_agent_map.agent_id` has no foreign key back to `profiles`, by
this project's own established convention (`schedule.agent_id`,
`invoices.agent_id` are the same free-text-no-FK shape, since there's no
real `agents` table to reference). **Lesson: a migration that depends on
another migration having run first, where the dependency is only a
naming/data convention and not an enforced FK, can succeed completely
and silently even when that dependency was skipped** — the only way it
surfaced here was a live join check (`profiles` × `commslayer_agent_map`)
turning up just one match (Edwin) instead of twelve. Worth a live
cross-check like that after any migration with this kind of soft,
unenforced dependency on another one.

**Known, intentional inconsistency: Edwin's `agent_id` stays `agent_042`,
not renamed to `edwin`.** Confirmed deliberately, not an oversight — his
existing value is already referenced throughout tonight's verified
invoices/schedule/rate_history data, and renaming it for cosmetic
consistency with the other 11's name-based slugs risked breaking
something already confirmed working, for no functional benefit. If this
ever needs fixing for real (e.g. onboarding docs/scripts assuming a
name-based scheme for everyone), it's a separate, larger migration that
updates every table referencing `agent_042`, not a quick rename.

All 10 non-Edwin/Quinty invites were sent. They still need to accept and
set passwords — outside anything automatable, just a follow-up.

---

## Business logic confirmed this session

**QPI attaches at Cut 2, not Cut 1**, of eligible months (March, June,
September, December) — confirmed in legacy `JavaScript.html` at three
independent sites (`buildInvoice()`, the admin override-modal handler,
and the display/rendering check), all gated on `cutNumber === 2` /
`cut === 2`. Rationale: by Cut 2 close, the full quarter's working hours
are known (the quarter-ending month is complete). **Any earlier note
describing "Cut 1" was wrong/stale — Cut 2 is correct and has no open
ambiguity.**

**QPI line-item attachment + qpi_notes — PORTED, committed (`a63f707`).**
`buildLineItems()` (`src/lib/invoiceLogic.js`) now takes `qpiQualification`
and `agentInvoices` and does the attachment itself — matches both legacy
sites (`buildInvoice` JavaScript.html:7025-7052, admin override-modal
handler :7567-7591) on qualification check and line-item shape
(`_qpiKey`/`_qpiBase`/`_qpiPct`/`weekKey: null`, which the prior page.js-side
version was missing). `invoices/new/page.js` now just fetches the
qualification row + agent's invoices and passes them in, staying pure
(no Supabase calls inside `invoiceLogic.js`).

Also ported the `qpiNotes` achieved/missed/base text summary (legacy
JavaScript.html:7086-7105 and :7592-7605 — the two sites differ slightly
in wording; picked the override-handler's fuller format as the one
canonical version) and wired it into the previously-unpopulated
`invoices.qpi_notes` column, which `invoices/[id]/page.js` was already
rendering unconditionally but nothing wrote to. Deliberately fixed a
legacy inconsistency in the process: `buildInvoice`'s notes were gated
only on `achieved.length > 0`, not on `qpiBase > 0` like the line items
were — so notes could appear describing ₱0.00 incentives. This port
gates notes on both, same as the items.

Cross-checked with a hand-built fixture (not just calling
`qpiBillableBase()` again): Dec 2026 Cut 2, qualified on 2 of 3
incentives, base built from one real approved invoice plus 3 decoys
(rejected / paid / out-of-quarter) that must NOT count — 16 assertions,
all passing, including an exact-text match on the notes string and a
check that the notes never leak into the `items` jsonb payload itself.

Not ported (out of scope, not asked for): the third QPI call site
(invoice-detail QPI Breakdown display block, JavaScript.html:8946+) —
unrelated to line-item attachment, a separate rendering concern.

**3-week-cut hours investigation — RESOLVED, no bug found.** Some cuts do
span 3 ISO weeks instead of 2 (confirmed: Dec 2026 Cut 2 = W51+W52+W53,
via `splitWeeksIntoCuts()`). All three open questions checked out clean:
1. `splitWeeksIntoCuts()` (`invoiceLogic.js:54-64`, matches legacy
   `JavaScript.html:6948-6958` line-for-line) is pure calendar/ISO-week
   derivation — `weeksTouchingMonth()` + a midDay-based filter into
   `cut1`/`cut2`. No hardcoded week count anywhere in it.
2. `buildLineItems()`'s `weeks.forEach(...)` (`invoiceLogic.js:411`) and
   the per-week `regularDates.reduce(...)` (`invoiceLogic.js:445`, also
   matches legacy `JavaScript.html:9934-10135` line-for-line) iterate over
   however many weeks are in the passed-in `weeks` array — no length
   check, cap, or bound anywhere. Same in the `src/app/invoices/new`
   caller: `weeks` flows through generically (`flatMap`, `.join`, passed
   straight to `buildLineItems`), nothing indexes it as if it were always
   length-2.
3. Empirically verified with a synthetic full-schedule (`REGULAR` every
   day) for Dec 2026 Cut 2 (W51+W52+W53): produced 3 separate weekly line
   items (56h + 56h + 32h, the last partial since W53 spills into
   January) summing to the correct full 144h / ₱40,320 total — not
   truncated to 2 weeks. Script: ad hoc, not committed (used a scratch
   copy of `invoiceLogic.js` as `.mjs` to run standalone under plain
   `node`, since the package has no `"type": "module"`).

**Conclusion: no code changes needed.** The "no artificial limit" intent
was already satisfied by the pure calendar-driven design — there was
never a 2-week assumption to begin with, in either the legacy source or
the port.

---

## Invoicing math verification (completed, closed out)

Full independent cross-check done for `agent_042` (Edwin's own test
account), Dec 2026 Cut 1: two independently-written implementations of
the holiday-pay logic matched byte-for-byte against a deliberately busy
fixture (working holiday, HOL_OFF, plain OFF holiday-listed day, real
Saturday, 6th-consecutive-day streak). Live `/invoices/new` UI output
matched an independently-computed total exactly (₱34,521.60) once a
wrong "3-week" assumption (mine, not a code bug) was corrected to the
real 2-week span for that specific cut. **This item is done — no open
questions on invoicing math itself**, only the 3-week-cut generalization
above.

---

## Access control (completed, closed out)

The `*_admin_TODO` policies on `schedule`/`invoice_settings`/`rate_history`
(originally `using (true)` — open to any authenticated user) are closed,
verified behaviorally (unprivileged account blocked on all three tables,
Quinty/Edwin unaffected). **This item is done.**

---

## Retract + draft-only content edit — confirmed DB-enforced, completed, closed out

Re-verified directly against migration source (not re-derived from memory)
after a question about whether these were real fixes or just legacy-style
cosmetic UI gates. **Both are real, DB-enforced, and not just client-side
buttons** — recording the exact mechanism here so this doesn't need
re-litigating later.

**Retract (`submitted` → `draft`) — two layers:**
1. `invoices_update_owner` (0008 RLS policy): coarse gate — owner can
   `UPDATE` only while `status IN ('draft', 'submitted', 'rejected')`
   (both `USING` and `WITH CHECK`).
2. **`trg_invoices_restrict_owner_status_transitions`**
   (`invoices_restrict_owner_status_transitions()`, migration 0010,
   `BEFORE UPDATE` trigger) — the precise fix. RLS's `USING`/`WITH CHECK`
   can't express "old status X, new status Y" as a pair (neither clause
   sees both row versions at once), so this trigger does it: for the
   owner's own invoice, allows *exactly* three transitions —
   `draft→submitted`, `submitted→draft` (retract), `rejected→submitted`
   — and raises an exception on anything else. This is what closed a real
   bug 0010 found: under the coarser set-membership check alone,
   `submitted→rejected` was also valid, meaning any owner could
   self-reject their own invoice, bypassing `invoices_update_approver`
   entirely (0009's self-approval fix didn't touch this, since it only
   patched the approver policy). Confirmed this also correctly blocks
   Edwin specifically (owner AND approver) from self-rejecting via the
   owner path, per the trigger's own `agent_id = current_agent_id()` key.

Client-side `canRetract`/`handleRetract` (`invoices/[id]/page.js`) is UI
convenience on top of this — the trigger is the actual gate.

**Draft-only content edit (line items, both schedule-derived AND manual
invoices):**
- **`trg_invoices_restrict_content_edit_when_submitted`**
  (`invoices_restrict_content_edit_when_submitted()`, 0008, function body
  updated in 0018 to call `has_permission('invoicing', need_write =>
  true)` instead of the now-dropped `is_invoice_approver()`) — blocks
  `items`/`subtotal`/`tax`/`total`/`monthly_base`/`rate_snapshot`/`weeks`
  changes while `old.status = 'submitted'`, for anyone without invoicing
  write access.
- **Confirmed uniform across invoice types, not schedule-derived-only**:
  the trigger's column list has no `is_manual` branching, and 0033 (the
  migration that added `is_manual`) explicitly states "no RLS change is
  needed either" — manual invoices need no exception because none of
  0008/0010/0018's owner/content-edit logic ever keyed off anything
  schedule-specific to begin with. Client-side `canEditContent = isOwner
  && (status === 'draft' || status === 'rejected')` (same file) gates the
  same edit-mode UI for both invoice types identically.

**This item is done** — both mechanisms confirmed live in migration
source, both apply uniformly regardless of `is_manual`. No further work
needed here unless a new transition/edit rule is explicitly requested.

---

## Rate & Schedule access split (0020, completed, closed out)

**The bug:** Dominic and Andrew each held a single `permissions` row
(`feature_key='invoicing'`, `can_view=true, can_write=true`). Because
`'invoicing'` was the one `feature_key` gating `schedule`,
`invoice_settings`, `rate_history`, `qpi_qualifications`, AND the
`invoices` table/approve-reject-reverse-pay UI all together, that one row
also granted them full invoice view+approval access, `invoice_settings`
write, and full `qpi_qualifications` access (view/insert/update/delete
qualifications) — none of which they should have had. Legacy kept these
roles strictly separate: `INVOICE_BILLING_ADMINS`
(Edwin/Dominic/Andrew — rate + schedule only) vs. `INVOICE_APPROVERS`
(Quinty/Edwin only — actual invoice access). This directly contradicted
this session's *earlier* confirmed decision (see below) that the shared
`invoicing` gate "already covers exactly the right people" — it didn't;
that assumption is now corrected.

**The fix (0020):** `rate_and_schedule` is now a separate `feature_key`
from `invoicing`. `schedule`'s and `rate_history`'s write policies
(`schedule_insert`/`update`, `rate_history_insert`/`update`/`delete`)
check `has_permission('rate_and_schedule', need_write => true)`.
`invoice_settings`, `qpi_qualifications`, and `invoices` are untouched —
still gated on `'invoicing'` exactly as before. `has_permission()`'s
`is_broad_reviewer` clause (0016) now excludes both `'invoicing'` and
`'rate_and_schedule'`. Dominic and Andrew hold a `rate_and_schedule` row
(`can_view=true, can_write=true`) only; their `invoicing` row was deleted
entirely — zero invoicing access, matching legacy exactly. Confirmed side
effect, not a surprise: this also correctly revoked their
`invoice_settings` write and `qpi_qualifications` access.

**Quinty consequence, surfaced before running, confirmed intended:**
Quinty's only permissions row is `invoicing` `can_write=true` (no
`is_admin`, no `rate_and_schedule` grant). Under the old shared gate this
also gave her schedule/rate write, which she loses now that those tables
check `rate_and_schedule` instead. This is correct, not a bug — Quinty
was only ever a legacy `INVOICE_APPROVER`, never an `INVOICE_BILLING_ADMIN`.

**Schedule/rate read-visibility staying open — confirmed intentional, not
a gap.** `schedule_select_all` and `rate_history_select_all` are
`using (true)` for *any* authenticated user, and always have been
(0011/0013) — completely unrelated to `has_permission()`. The
`is_broad_reviewer` exclusion added in 0020 only changes what
`has_permission()` itself returns (relevant to future features whose
*write* or explicitly-gated *view* policies check it) — it does not, and
was never going to, retroactively lock down schedule/rate_history reads,
since those SELECT policies don't call `has_permission()` at all. Verified
directly: a temp account with `is_broad_reviewer=true` and zero
permissions rows could still `SELECT` from both tables. If genuinely
locking down schedule/rate *reads* to specific grantees is ever wanted,
that's a separate, new RLS decision — not something 0020 did or was
meant to do.

**Verified live:** 13/13 behavioral assertions across three temp accounts
(impersonating Dominic/Andrew's new state and Berry's), covering
schedule/rate_history write success, zero invoice/qpi_qualifications
access, and the `is_broad_reviewer` exclusion (including a positive check
that a genuinely future, unrelated feature key still gets the broad-view
bypass — the mechanism itself wasn't broken, just too broad for these two
specific keys). All temp accounts, permissions rows, and test data rows
confirmed cleaned up afterward.

---

## `/settings/users` + `/schedule` (built this session, closed out)

**Scheduling write access — ~~confirmed, no new `feature_key`~~
SUPERSEDED, see "Rate & Schedule access split" above.** This session
originally decided scheduling write should stay tied to the existing
`invoicing` `can_write` gate rather than a separate feature, reasoning
that gate "already covers exactly the right people." That turned out to
be wrong — it also gave Dominic/Andrew invoice access they should never
have had. 0020 introduced `rate_and_schedule` specifically to fix this.
Left here rather than deleted so the reasoning trail (and the correction)
stays visible.

**`/settings/users`** (spec Section 4 of
`docs/0014_permissions_system_spec.md`) — table of every account (email,
`agent_id`, `is_admin` toggle, per-feature view/write checkboxes rendered
from `feature_registry`). Blocked by a real gap found while building it:
`profiles` has zero admin-facing RLS (only "read your own row," from
0008) — no admin-select-all, no admin-update. Rather than write a new
migration for this, listing users + toggling `is_admin` goes through a
new admin-verified server route, `src/app/api/admin-users/route.js`,
matching the exact pattern already established by `/api/invite-user` and
`/api/check-user-status` (service-role key, caller's own bearer token
checked against their own `is_admin` first). **No schema/RLS change was
needed for this feature at all.** Permission checkboxes upsert into
`permissions` directly from the client — already correctly admin-gated by
0014's RLS, no new route needed there.

**`/schedule`** — month grid, dates × agent-id columns (mirrors the
legacy grid's actual dates-as-rows/agents-as-columns layout). Read access
for everyone (existing `schedule_select_all`); edit controls only render
for `is_admin` or invoicing `can_write` — RLS is still the real gate, this
is UI convenience only, same philosophy as the invoice detail page's
action buttons. No delete policy exists on `schedule` at all (deliberate,
per 0011) — clearing a day sets it to `OFF`, never a row delete.
Known limitation: agent columns are derived from distinct `agent_id`s
already present in `schedule` (there's no `agents` table to join
against) — a brand-new agent needs their first shift added via the page's
own "+ Add column" box before they'd appear at all.

**Bug found and fixed while checking `src/` before 0019**:
`src/app/invoices/[id]/page.js` was still reading
`profile.is_invoice_approver` to compute `isApprover` (gating
approve/reject/reverse/pay). This was two problems, not one: it would
have broken outright once 0019 dropped the column, but it was **already
stale** before that — since 0018 moved the real DB-level check to
`has_permission('invoicing', need_write => true)`, and Dominic/Andrew
were granted `can_write` only via a `permissions` row (never had
`is_invoice_approver=true` set), they'd have seen zero approve/reject/etc.
buttons despite the database actually allowing it. Fixed by swapping the
source to the same own-`is_admin`-or-`permissions`-row check `/schedule`
uses. Lint clean, build clean, re-grepped `src/` afterward — zero
remaining live references (two comment-only mentions of the old name
narrating migration history, not code).

---

## Performance feature — Commslayer / QA / Trustpilot / Penalties (in progress)

Legacy investigation (full findings in chat log): `parseCommslayerText()`
doesn't exist — the real legacy fetch is `fetchCommslayerMetrics()`
(`Code.js`), a genuine live API integration, but its merge into
`state.reports` was never reconciled to the internal roster anywhere
(stored under `'cs_'+commslayerId` keys that no other code ever reads).
QA/Trustpilot/Penalties are 100% manual admin entry in legacy
(`openQaEditor`/`openTpEditor`/`openPenaltyEditor`) — "Trustpilot" there
is an internal negatives/severe counter, not real Trustpilot API data.
`computeMonthlyTotals()` is the one combined-score formula, reading all
three plus the weekly ranking pipeline.

**Schema (0021, confirmed live)** — `commslayer_reports`, `penalties`,
`qa_scores`, `trustpilot_scores`. Full access-design write-up in the
migrations list above.

**Real agent_id gap (0022/0023, confirmed live).** Discovered while
building the Commslayer agent mapping: 11 of 12 people had
`profiles.agent_id = NULL` — nothing anywhere in the live DB
(`invoice_profiles`, `invoices`, `schedule`) ever referenced a real
agent_id for them except Edwin's `agent_042`. `0022` assigned name-based
slugs (legacy's `idOf(name)` scheme); `0023` created `commslayer_agent_map`
and seeded the real Commslayer-numeric-id ↔ agent_id crosswalk
(`ai_agent_1877` excluded). **Real near-miss**: `0023` first ran
successfully *before* `0022` had actually been applied, silently seeding
rows against agent_ids that didn't exist yet — `commslayer_agent_map`
has no FK back to `profiles` (matching this project's established
free-text-agent_id convention), so nothing rejected the insert. Only a
live join check caught it. **Lesson for future migrations with this kind
of soft, unenforced dependency: verify with a live join, don't just
trust each migration's own guard in isolation.**

**`/api/commslayer-sync` route (confirmed working end-to-end against
real data).** Two real bugs found only by testing against the live API,
not by reasoning about it:
1. The correct base path is `/api/integration/v1/reports/agents`, not
   `/api/v1/reports/agents` (legacy's own endpoint — either Commslayer's
   API changed since, or it was always wrong and silently failing in
   legacy too; unknown which). Found via Commslayer's own published docs
   at `/api/integration/v1/docs`.
2. The real response shape is `{ data: { data: [...], meta: {...} } }`,
   not `{ agents: [...] }` legacy expected. Several fields are
   genuinely fractional in real data (`avg_response_time: 427290.7`,
   `one_touch_tickets: 27.6`) — `commslayer_reports` had them typed
   `integer` (0021/0024), which rejects fractional inserts; fixed in
   0025. This also resolves the `one_touch_tickets` ambiguity noted
   during the original investigation: real values are clearly rate/
   percentage-like, not a raw ticket count — legacy's own
   `a.one_touch_tickets || 0` treatment (as a plain count) looks wrong,
   its trend-chart treatment (20%-target/100-max) looks right.

End-to-end verified live: real 3-day sync wrote 22 real
`commslayer_reports` rows, independently re-counted via a separate
service-role query (not just trusting the route's own report).

**`/performance` admin UI (committed) + `computeMonthlyTotals()` (ported,
committed) + `/trends/volume` (committed).** See the three commits from
this session for full detail.

**Weekly ranking pipeline — `buildWeekRows`/`computeScores`/`rankRows`
(ported, fixture-verified, committed `9f51e12`).** Source
`JavaScript.html:975-1167`. Chain: `buildWeekRows()` aggregates one ISO
week's daily `commslayer_reports` + `schedule` rows into one row per
agent (closedTickets/ticketsReplied summed unconditionally every day —
a faithful legacy quirk, even an OFF day's report value counts; oneTouch/
avgResponse/resolution averaged with a >0 gate; scheduledHours via shift
lookup; a days-worked-weighted `weightedShiftMultiplier` + dominant
`shiftKey`). `computeScores()` normalizes each metric within the
scheduled-that-week group (prorating closedTickets to an 8h-equivalent,
inverting avgResponse/resolution so lower is better, resolution offset-
adjusted per shift type), weights them per `performance_settings.weights`,
applies the shift multiplier, adds `adminBonus` flat, returns a `score`
(`null` if `scheduledHours<=0`). `rankRows()` sorts by score descending
and assigns rank + points.

**Rank-to-points scale: 10/6/3/0, not the old trend page's 5/3/2/1 —
deliberate fix, not a reproduced inconsistency.** Legacy actually has two
disconnected scales: `rankRows()` itself (the one that really drives
spins/disqualification via `computeMonthlyTotals`) uses 10/6/3/0 for
1st/2nd/3rd/4th+, while the old standalone trend-page rendering used a
separate, cosmetic 5/3/2/1 that never fed into scoring at all — it only
ever affected what the trend page *displayed*, not what an agent's spins
actually were. Confirmed decision: the ported `/trends/ranking` page
shows the real 10/6/3/0 scale everywhere, so what's displayed is always
what actually drove the result, closing a real legacy display/logic
mismatch on purpose.

**CUSTOM_ shift scoring gap — found during porting, fixed on explicit
instruction, not preserved.** Legacy's `buildWeekRows()` resolves
scheduled hours via a bare `SHIFT_TYPES[shiftKey]?.hours ?? 0` — since
`SHIFT_TYPES` has no literal entry for dynamic `CUSTOM_HHMM-HHMM`-style
keys, any agent scheduled on a CUSTOM_ shift scored 0 hours for that day
in the ranking pipeline (excluded from `daysActive`/`scheduledHours`
entirely, same as an unscheduled OFF day) — a real legacy bug, not a
design choice. Fixed via `scoringHoursFor()`, which resolves `CUSTOM_`
shifts through `parseCustomShift()` (the same function already verified
for invoicing's night-differential calculation earlier this session)
instead of falling through to 0, while named shift types still resolve
via the same direct `SCORING_SHIFT_HOURS` lookup legacy uses. Fixture
test updated to cover it directly: two different CUSTOM_ time ranges in
the same week resolve to their real durations (8h and 4h) instead of 0,
and the agent scores normally instead of getting `score: null`.

**Fixture-verified (same standard as QPI/holiday-pay/`computeMonthlyTotals`)
before any page was built**, per explicit instruction — hand-computed
expected values (prorated output, normalized 0-100 scores, weighted
composite, rank/points), not re-derived via the function's own
sub-formulas: 45/45 assertions passing, including the OFF-day ticket-sum
quirk (preserved, matches legacy) and the CUSTOM_ shift fix above.

**Admin Bonus editor (`/performance`, committed `d85205c`).** Hours-to-
points conversion `pts = min(round(hrs*rate*10)/10, cap)`
(`JavaScript.html:2647`, verbatim), rate/cap read from
`performance_settings`, written into `commslayer_reports.admin_bonus_points`
keyed by **date**, not month (matches legacy's actual per-day-report
storage location, not a new per-month bucket). Live-verified via temp
account: 3.5 hours → 17.5 pts computed and stored correctly, round-
tripped back through the same read path the editor uses.

**`performance_settings` (0026, confirmed live).** Singleton row —
`weights` (closedTickets/oneTouch/avgResponse/resolution percentages),
`shift_multipliers` (per-shift-type % bonus), `shift_resolution_offsets`
(per-shift-type minutes, absorbs shift-length-driven resolution-time
differences before scoring it), `admin_bonus_rate`/`admin_bonus_cap`.
Same pattern as `invoice_settings`; RLS gated on
`has_permission('performance', ...)`.

**`/trends/ranking` (committed `9b4494e`) — computeMonthlyTotals is now
genuinely wired end-to-end for the first time.** For a selected month:
fetches the agent roster, computes every ISO week touching that month
(`weeksTouchingMonth`), fetches `commslayer_reports`/`schedule` across
the full date range plus `qa_scores`/`trustpilot_scores`/`penalties` for
the month, converts `commslayer_reports`' raw-seconds fields to minutes
at this fetch boundary (the scoring pipeline itself, like legacy,
operates in minutes throughout), runs each week through
`buildWeekRows`→`computeScores`→`rankRows`, accumulates
`weeklyPointsByAgent`, then calls `computeMonthlyTotals()` — the first
real caller that can supply it. Renders a per-week rank/points table plus
totals (QA/TP/penalty adjustments, spins, DQ badge) and a rank-count
(R1/R2/R3) summary table.

**End-to-end live-verified against real seeded Supabase data** (not just
the fixture test above): 6 real ISO weeks, two synthetic agents with
known daily metrics run through the actual fetch-and-transform code path
`/trends/ranking` uses (real RLS-scoped reads via a temp account, real
`buildWeekRows`/`computeScores`/`rankRows`/`computeMonthlyTotals` calls) —
weekly points, QA/Trustpilot/penalty adjustments, and final monthly
totals all matched hand-derived expectations exactly (8/8 assertions).
Confirms the whole chain is correctly wired, not just each function in
isolation.

**All 7 trend categories now complete: volume, speed, onetouch, agent,
shift, heatmap, ranking (committed `2b4035d`/`f205300`).** Source:
`buildTrendData`/`trendInsightText` (`JavaScript.html:4177-4346`) for
speed/onetouch/agent/shift, `renderPerformanceHeatmap`
(`JavaScript.html:4505-4614`) for heatmap — structurally separate from
the chart dispatch in legacy too, so it got its own `PerformanceHeatmap`
component rather than being forced into `TrendLineChart`. `shift` (an
EARLY vs LATE 2-bar comparison, no date axis) got its own
`ShiftCompareChart` component for the same reason. speed/onetouch/agent
all reuse `TrendLineChart`.

Fixture-verified (53 assertions, hand-computed) and live cross-checked
against real Supabase data (20 assertions) — the 22 already-synced
Commslayer rows (2026-07-20..22) for speed/onetouch, plus a synthetic
`schedule` overlay seeded on those same real report rows/agent_ids for
agent/shift/heatmap (no real schedule data existed yet for those dates),
cleaned up and confirmed gone afterward.

**Real deviation caught during design, before the fixture test was
written:** `buildHeatmapBuckets` initially let every report row (even
for an agent outside the roster) count toward the "overall" bucket —
legacy's own iteration (`agents.forEach(a => {...rpt.metrics[a.id]...})`)
only ever aggregates active/roster agents into either bucket. Fixed
before testing; the fixture explicitly asserts a non-roster agent's
inflated values are excluded from both `overall` and `byAgent`.

**Real fix, on explicit instruction, to `buildAgentTrendData`:**
legacy's own `value || null` (`JavaScript.html:4246-4269`) collapses a
genuine 0 on a working day (0 tickets closed, 0% one-touch, etc.) to the
same null a non-working day gets — a working day's real zero and a
day off looked identical on the chart. Fixed to distinguish three cases
now: not working → null; working but no report row at all that day →
null (no data was ever reported); working with a report row present →
the real value, including a genuine 0, rendered as an actual zero point.
Re-verified after the fix: 53 fixture assertions (6 new, specifically
targeting this distinction) and 20 live-data assertions, all passing.

`shiftDurationHours`'s CUSTOM_ fix (already applied to the ranking
pipeline) is reused directly inside `buildAgentTrendData` via the same
`scoringHoursFor()` helper — a CUSTOM_ shift here is also correctly
treated as "working," rather than a second place silently reintroducing
the CUSTOM_-scores-0 bug.

One small naming cleanup alongside this work: the nav's `'ranking'`
label was "Ranking Heatmap" (a leftover from before the actual
`'heatmap'` category existed) — renamed to plain "Ranking" since having
both side by side was confusing.

---

## Full gap-check: legacy views vs. c3-website (this session)

Full inventory of every top-level legacy view (grepped `render*`
functions dispatched from `render()`'s `state.currentView` switch,
`JavaScript.html:1523-1600` + `NAV` array `:1296-1324`) against what
exists in c3-website, done before building the four features below.

| Legacy view | Status at time of gap-check |
|---|---|
| `renderDaily` (Daily Report) | Missing — **the top-priority gap**, since Trends/Performance/Ranking are all downstream of it |
| `renderMonthly` (Monthly Tracker) | Covered — split across `/trends/ranking` (view) + `/performance` (QA/TP/penalty edit) |
| `renderRankings` (Weekly Rankings) | Covered by `/trends/ranking`'s per-week columns |
| `renderTrends` (Trends Overview) | Covered — all 7 categories |
| `renderHistory` (History & Insights) | Still missing — per-agent historical averages + coaching flags + AI manager-summary text |
| `renderAdminInvoices` + `renderMyInvoices` | Covered — unified into one `/invoices` page (RLS decides visibility) |
| `renderAdminQPI` (Quarterly Bonuses) | Was missing, **now built** (see below) |
| `renderQuarterlyInvoices` (agent-facing QPI) | Partial — QPI Breakdown display block exists on invoice detail; no standalone page |
| `renderInvoiceProfile` | Still missing — `invoice_profiles` table exists, no UI |
| `renderUsdRate` | Was missing, **now built** (see below) |
| `renderSchedule` / `renderMySchedule` | Covered — one `/schedule` page, RLS decides edit vs. view-only |
| `renderAgents` | ~~Partial — `/settings/users` covers permissions/roles; no active/inactive roster management~~ **CORRECTED, no longer accurate**: 0029 (`profiles.is_active`) + a real "Active" toggle in `/settings/users`, filtered in `/api/agent-roster`/`/api/team-standings`, closes this. Confirmed built. |
| `renderSettings` | Partial — permissions section + **now** performance/invoicing/USD-rate editors; 2FA email still missing |
| `renderHandover` | Still missing entirely |
| `renderCoaching` (Coaching / My Notes) | Still missing entirely |
| `renderKB` (Knowledge & FAQ) | Still missing entirely |
| `renderMyStats` (My Performance) | Was missing, **now built** (see below) |
| `renderLeaderboard` (Team Standings) | Was missing, **now built** (see below) |

---

## Daily Report, QPI admin, agent dashboard, settings pages (this session)

Four features built in parallel per explicit instruction — none needed
a schema/RLS change, confirmed by reading the actual current policies
before writing any code (not assumed).

**`/daily` — Daily Report (ported from `renderDaily`,
`JavaScript.html:2345+`).** Sync-trigger (`/api/commslayer-sync`) +
review that day's numbers + a shareable text draft. Reuses
`buildWeekRows`/`computeScores`/`rankRows` on a single-date array —
`buildWeekRows` is generic over any date array, so no new aggregation
function was needed to make a "one day" version of the weekly pipeline.

**Deterministic draft, not a live LLM call — confirmed decision.**
Legacy's `generateReportDraft()` (`JavaScript.html:1232-1287`) actually
calls a live LLM (Gemini, via `callAI`) with a long prompt to write real
manager prose. This project has no AI API key wired up. Built
`buildDailyReportDraft()` as a deterministic stand-in instead — same
structure (agent performance table / rule-based "Focus" bullets /
templated summary paragraph), rule-based thresholds instead of
generated text. Flagged explicitly to the user rather than silently
substituted; **confirmed: keep deterministic, no LLM integration
wanted.** Fixture-tested (structural checks: OFF-row rendering, each
threshold bullet, top-performer callout, day-over-day trend line).

**`/qpi` — QPI admin (ported from `renderAdminQPI`,
`JavaScript.html:9402+`).** Form setting `qpi_qualifications`
(`trust_score`/`sla`/`continuity`) per agent per quarter — the same
`QPI_INCENTIVES` list (`invoiceLogic.js`) `buildLineItems()` already
reads when attaching the QPI line item; this page is simply where those
three flags get set, closing a real gap (someone was presumably hand-
writing SQL to set these until now). Admin-write only, gated on
`has_permission('invoicing', need_write => true)`, matching
`qpi_qualifications`' existing RLS exactly (0008, migrated onto
`has_permission` in 0018) — no schema change. **Live-verified:** write
blocked with zero permissions, succeeds once granted `invoicing`
`can_write`.

**`/my-performance` + `/team-standings` — agent-facing dashboard (ported
in spirit from `renderMyStats`/`renderLeaderboard`).** Two pages with a
deliberately different privacy model, not a straight port of legacy
(which shows everyone's full metrics to everyone):

- **`/my-performance`** shows an agent's own report metrics/QA/
  Trustpilot/penalties/admin bonus — everything already covered by
  existing self-view RLS (0021: "agents always see their own rows
  regardless of the grant"), no schema change. Deliberately does NOT
  show weekly rank/score: computing that needs `buildWeekRows`/
  `computeScores` to normalize each metric against every OTHER active
  agent that week (max closed tickets, min/max response time, etc.),
  and a regular agent's own bearer token can only read their own
  `commslayer_reports`/`schedule` rows under current RLS — that
  normalization genuinely can't happen client-side here.
- **`/team-standings`** is the answer to that gap: a new
  `/api/team-standings` server route using the service-role key (same
  pattern as `/api/admin-users`/`/api/invite-user`) to compute the whole
  team's rank via `computeMonthlyTotals()`, then returns ONLY
  `{agentId, name, rank, totalPoints, spins, disqualified}` per agent —
  never raw `qa_scores`/`penalties`/`trustpilot_scores` rows. This is
  the mechanism for "own detail only, everyone's rank" without opening
  up those tables themselves. Requires just a valid session, not any
  specific permission — matches legacy's agent-facing, ungated
  leaderboard. `name` resolves from `invoice_profiles.full_name`
  (self-view-only table, readable here only because the route runs with
  the service-role key), falling back to `agent_id` — a deliberate,
  one-off exception to this app's usual agent_id-verbatim convention,
  since a shared leaderboard reads much better with real names than
  agent_id slugs. **Live-verified:** a zero-permission temp account gets
  a 200 with only the sanitized fields from the route, while the exact
  same account is confirmed blocked from reading another agent's raw
  `qa_scores`/`penalties` rows directly (proving the route does real
  work RLS wouldn't allow the client to do itself).

**Settings pages — `/settings/performance`, `/settings/invoicing`,
`/settings/usd-rate`.** Close the hand-SQL gaps left since 0013/0020/
0026 seeded `invoice_settings`/`rate_history`/`performance_settings`
with defaults but no UI ever wrote to them.
- `/settings/performance`: scoring weights, shift multipliers/
  resolution offsets, Admin Bonus rate/cap. Gated on
  `has_permission('performance', need_write => true)`.
- `/settings/invoicing`: rate/prefix, bill-to, perks, night
  differential, holiday pay, bonuses. `shift_hours` (an open-ended
  per-shift-code map, not a fixed field set) is edited as raw JSON
  rather than inventing a full shift-editor UI. Gated on
  `has_permission('invoicing', need_write => true)`.
- `/settings/usd-rate`: add/delete CRUD over `rate_history` (period, cut
  label, rate, hourly USD, note) — built as a plain list, not legacy's
  full calendar UI (`renderUsdRate`), same underlying data, simpler
  surface, consistent with this app's other admin editors. Gated on
  `has_permission('rate_and_schedule', need_write => true)`, matching
  `rate_history`'s RLS (0020) and legacy's `canSetCurrencyRate()`
  (billing admins) exactly.

All three settings pages' write gates were **not** individually
live-verified with temp accounts — those RLS policies were already
live-verified when they were created earlier this session (0015/0020/
0026); this work only builds UI on top of them, unchanged.

Committed as 7 commits (Daily Report `54d5a39`, QPI admin `96d4674`, My
Performance `ff03a05`, Team Standings + route `82b6b5a`, performance
settings `f33f214`, invoicing settings `01a6a6d`, USD rate `86f6232`) —
one per feature, since unlike the trend categories these are separate,
non-interleaved files.

**Still open after this batch** (unchanged from the gap-check table
above, not attempted this round): `renderHistory`, `renderHandover`,
`renderCoaching`, `renderKB`, `renderInvoiceProfile`, the QPI Breakdown
missing-months warning, `renderAgents`' active/inactive roster
management, Settings' 2FA email section.

---

## History & Insights, Shift Handover, Coaching Notes (this session)

Three more gap-check items closed. Knowledge Base explicitly **skipped**
per its own stated conditional ("only if quick and low-effort") — its
core feature is `renderKB`'s "Ask the Assistant" chat, entirely
LLM-dependent (same category as the Daily Report/History-summary
decisions below), plus a separate glossary/search UI on top. Not quick.

**Before building anything, consulted the frontend-design skill per
explicit instruction — it doesn't exist in this environment.** Built
against the user's own concrete performance requirements instead
(skeletons, pagination/virtualization, parallel fetches, lean bundle,
debounce, Server Components where interactivity isn't needed), applied
consistently across all three pages below.

**`/history` — History & Insights (ported from `renderHistory`/
`buildHistoricalStats`, `JavaScript.html:3436-3671`, committed
`b254ab4`).** `buildHistoricalStats()` reuses the already-tested
`buildWeekRows`/`computeScores`/`rankRows` chain twice — once per DAY
(same single-date trick the Daily Report uses) for per-day flags, once
per ISO WEEK for `weeklyPoints` — no new scoring logic, only a new
aggregation/flagging layer. Auto-flags: consistently-low-rank,
response/resolution/one-touch target misses, declining trend,
multiple penalties. **Manager summary is deterministic**
(`buildHistorySummary()`), not legacy's live LLM call
(`generateManagementSummary()` calls Gemini) — same reasoning and same
user confirmation as the Daily Report's draft generator.

Fixture-verified (38 assertions, including a hand-computed
declining-vs-improving trend case isolated to a single scoring
dimension for tractability) and live-verified against real Supabase
data (8 assertions) — the live test **surfaced a genuine, non-obvious
legacy behavior, not a bug**: `daysWorked` is gated on the date's report
bucket existing *at all* (Commslayer sync ran and *someone's* data
landed that day), not on this specific agent's own row being present.
Two real agents (kenn, kate) are each individually missing one of the
three synced days, yet both correctly show `daysWorked=3` because other
agents' data existed those days — confirmed by inspecting the raw
per-date rows directly, then documented in code (matches legacy's
`buildDayRows`/`if (!report) return` gate exactly, which is day-level,
never per-agent).

**`/handover` — Shift Handover (ported from `renderHandover`,
`JavaScript.html:5295-5573`, new `handovers` table 0027, committed
`c096713`).** Legacy hard-forks the whole page on `state.session.role`
(strictly 'agent' xor admin); this app has no such strict role (a
single account can be `is_admin` AND have an `agent_id`, e.g. Edwin), so
this is one page with additive sections instead — "needs ownership /
post / your entries" shown to everyone, an extra admin management block
(browse any date, delete, last-7-days table) shown only to `is_admin`.
RLS is deliberately open (read/insert/accept for any authenticated
user, matching `schedule`'s own precedent), delete restricted to
`is_admin`, matching legacy's admin-only delete button exactly. No
pagination needed — legacy itself never offers more than a 7-day
lookback, so this doesn't introduce an unbounded list to paginate
either.

**`/coaching` — Coaching Notes (ported from `renderCoaching`,
`JavaScript.html:5153-5293`, new `agent_notes` + `agent_note_replies`
tables 0028, committed `b737887`).** Private manager-to-agent feedback.
**Design decision flagged and confirmed before running 0028**:
admin-side access is gated on `is_admin` directly, not a new
`has_permission()`-delegatable `feature_key` — the `is_broad_reviewer`
clause (0016) auto-grants view access to any feature except
invoicing/rate_and_schedule, appropriate for aggregate performance
numbers but not private 1:1 notes; legacy's own gate is bare
`role === 'admin'` with no broader exposure mechanism, so `is_admin`-only
matches it exactly. `reply.category` dropped entirely (not just
hidden) — legacy hardcodes it to the same literal string on every
reply with no UI to vary it, so it carried zero information.

Both the agent's own notes list and the admin's "all sent notes" list
grow unboundedly (unlike every other list built this batch) — real
server-side paginated via Supabase `.range()`, 10/page with "Load
more", not fetched in full and sliced client-side.

**Live-verified: 17 RLS assertions in one combined test covering both
`handovers` and `agent_notes`/`agent_note_replies`** — open insert/
accept + admin-only delete on handovers; self-view-or-admin select with
admin-only insert/delete on notes; owner-only insert on replies; reply
cascade-delete confirmed when a note is removed. Two initial "failures"
on first run were bugs in the test script itself (mixed up two
different `check()` call conventions, and a `0`-rows-remaining count
read as falsy) — caught by inspecting the raw data before treating
either as real, not blindly re-running until green.

**Performance requirements applied across all three pages**: skeleton
loading states (new shared `Skeleton.js`) instead of spinners/blank
screens; all independent fetches parallelized via `Promise.all`
(History's 5-way fetch, Handover's admin day+week fetch, Coaching's
notes+roster fetch); no new dependencies (`Sparkline.js` is plain SVG);
each page split into a Server Component shell (`page.js`, zero client
JS) + a `*Client.js` — full server-side data fetching isn't available
since this app has no cookie/SSR auth (session is browser-persisted
only), so this only moves the static chrome out of the client bundle,
not the actual data fetching; no debounce added anywhere, since none of
the three pages have a text-search input in legacy to attach one to.

---

## Still open / not yet built

1. **Bjorn's email** — confirm `info@khaizen.eu` isn't a shared inbox
   before trusting that account.
2. **QPI Breakdown: only the "Generate Cut 1 + Cut 2" buttons are still
   missing — the missing-months WARNING TEXT is built.** ~~Missing-months
   warning not yet ported~~ — CORRECTED, this was stale. The core display
   block (per-incentive calculations + total QPI bar, built from this
   invoice's own `qpi`-type items) was built and committed (`a32eaad`).
   The missing-months warning itself (source JavaScript.html:8962-8972 —
   cross-references every *other* invoice this agent has in the quarter
   via `QPI_BASE_STATUSES`, flags which months have no invoice at all) is
   ALSO built (`invoices/[id]/page.js`'s `missingMonths`/`missingLoading`
   effect) — confirmed live-rendering real data ("⚠ Missing: April 2026 &
   May 2026 invoices not yet generated") during this pass. Only the
   legacy "Generate Cut 1 + Cut 2" buttons (source
   JavaScript.html:8988-9002) remain genuinely unbuilt — deliberately, per
   the original design note: triggering invoice generation for another
   month from this page is a materially bigger feature than rendering the
   warning, unrelated to the warning text itself.
3. **Holidays UI (on top of 0032) — genuinely not built.** The `holidays`
   table + RLS exist (0032), but zero UI anywhere consumes it — confirmed
   via a full `src/` grep for `holidays` (no hits). No tab/view inside
   `/schedule` or anywhere else. The migration's own comment already
   scoped this as separate: "wiring holidays -> that array (or replacing
   it) is Stage 3 UI work, not a schema concern."
4. **`renderQuarterlyInvoices` (standalone agent-facing QPI page) — still
   just partial.** No standalone route exists (confirmed against the
   current route list) — QPI Breakdown remains embedded in invoice detail
   only, as originally noted in the gap-check table further below.
5. **`renderInvoiceProfile` (agent self-service profile edit) — still
   missing entirely.** `/settings/invoice-profiles` is the admin-only
   rate-override page (gated on `rate_and_schedule` write) — its own code
   comment states explicitly that personal-field self-service editing
   "is not duplicated here." No page exists anywhere for an agent to edit
   their own address/bank/Pag-IBIG/SSS/status.
6. **Settings 2FA email — still missing.** Confirmed via a `src/` grep for
   2fa/mfa/totp: zero hits.

**Resolved this session, no longer open:**
- ~~Invite flow~~ — `/invite` page built (`b66d886`), wraps the existing
  `/api/invite-user` + `/api/check-user-status` routes, linked from
  `/settings/users`.
- ~~`/schedule`'s agent-column limitation~~ — roster now derives from
  `profiles` (`e20bb6a`) via a new non-admin-gated `/api/agent-roster`
  route (returns only `agent_id`, nothing else — profiles still has no
  broad-read RLS policy), not from whichever `agent_id`s happen to
  already have schedule rows. A new hire's column appears as soon as
  their profile has an `agent_id`; the "+ Add column" box is now just a
  manual escape hatch for an agent_id not yet set on any profile.
3. **Environment note:** filesystem access to `~/Desktop/*` from Claude
   Code's shell broke mid-session tonight (macOS Full Disk Access grant
   issue, unrelated to any of the above) — fixed by restarting the
   terminal app after re-checking System Settings → Privacy & Security →
   Full Disk Access. If it recurs, same fix.

---

## Deployment hygiene gap — discovered and closed this session

**The discovery:** asked to check three things before continuing with
new feature work (Vercel production env vars, deployment freshness,
whether a real nav menu links to everything built) — the honest answer
turned up something much bigger than any of those three individually.
`git fetch origin` + `git status -sb` showed **local `main` was 40
commits ahead of `origin/main`** — every commit from this entire
multi-session build (all the way back through the Performance feature,
trend categories, Daily Report/QPI/dashboard/settings pages, History/
Handover/Coaching) had never been pushed to GitHub at all. Since Vercel
deploys from that repo, production had been running whatever was live
*before* any of this work existed, regardless of what env vars were
configured or how recently "deployment" ran.

**Why it went unnoticed this long:** nothing in this session's workflow
ever checked push status — commits were made locally and verified via
`git log`/`git status` (which only reflect the local repo), never
against `origin/main`. **Lesson for future sessions: periodically
`git fetch origin` and diff against `origin/main`, don't assume local
commits are automatically pushed or deployed.**

**The auth problem, found while trying to fix it:** Claude Code's own
shell in this environment has no working GitHub credential and no way
to prompt for one interactively. `git fetch` failed with
`Invalid username or token. Password authentication is not supported
for Git operations.`; a direct `git push origin main` attempt failed
differently, with `fatal: could not read Username for
'https://github.com': Device not configured` (no TTY available to
prompt). **This means Claude Code cannot push to GitHub from this
project's environment at all** — pushes need to happen from the user's
own terminal, where an interactive credential flow (in this case,
macOS Keychain) can actually complete.

**Resolution:** user ran `git push origin main` directly from their own
terminal; Keychain supplied and saved a working credential. Confirmed
via `git fetch origin` + `git rev-parse main origin/main` afterward —
both resolved to the identical commit hash (`3a90b32` at the time),
`origin/main..HEAD` empty. Vercel deployment then confirmed successful,
with `SUPABASE_SERVICE_ROLE_KEY` and `COMMSLAYER_API_KEY` both
confirmed present in Production environment variables (both had been
sitting in `.env.local` only — see the still-open key-rotation note
below for `SUPABASE_SERVICE_ROLE_KEY` specifically).

**Also discovered in the same check, at the time: no shared nav
component existed anywhere in the app.** `src/app/page.js` was still the
untouched Create Next App default scaffold (links only to nextjs.org/
vercel.com). Every other page's only outbound link was a bare "← Back"
to `/invoices` or `/performance`. Every one of the ~25 routes built this
project was reachable only by typing its exact URL — nothing was
discoverable by clicking around the app at all.

**CORRECTED, no longer open: this is built.** `AppShell.js` is now a
complete persistent left-sidebar + sticky top-bar nav, wrapping every
route (`src/app/layout.js`), with grouped/permission-gated links to all
real pages, a submitted-invoices badge, and a synced-status indicator —
confirmed present across the whole app as of the dark-theme restyle work.
The dangling forward-reference this note originally had ("the nav-menu
build immediately following this section") pointed at a section that was
never actually written here — the build happened, just was never logged
in this file. Recorded now so it doesn't get re-flagged as open again.

**Standing lesson for future sessions:** Claude Code verifying "it's
committed" is not the same as "it's pushed," and "it's pushed" is not
the same as "it's deployed with the right env vars." All three need
independent, periodic verification — this session went a long way
before any of them were checked even once.

---

## Daily Report timezone bug — investigated and partially fixed this session

**User report:** Daily Report showed "synced 6 agents for yesterday" but a
specific other date showed no synced data, even though that date's
activity already existed in Commslayer.

**Issue 1 — CONFIRMED and FIXED.** Every "today"/"yesterday" calculation
across the app used `new Date().toISOString().slice(0, 10)` (or
`new Date().getFullYear()`/`getMonth()`), which reads the UTC calendar
date — not Philippine time (UTC+8). For roughly 1/3 of every day (UTC
00:00–08:00, i.e. PH 08:00–16:00 is fine, but UTC 16:00–24:00 = PH
00:00–08:00 is the dangerous window... concretely: from UTC 16:00 to
23:59, `new Date()` is already on the PH *next* day but
`toISOString()` still reports the *old* UTC date), "today"/"yesterday"
resolved to the wrong calendar date relative to PH, causing exactly this
symptom — the wrong date gets requested/displayed as "yesterday."

Fixed with a timezone-independent helper (fixed +8h offset applied to
`Date.now()`, then reading back UTC components — deliberately not
dependent on the runtime's local clock setting) applied consistently
across all 7 files that had this pattern: `daily/page.js`,
`my-performance/page.js`, `handover/HandoverClient.js`,
`trends/[category]/page.js`, `history/HistoryClient.js`,
`performance/page.js` (including a stray inline default-date literal),
`qpi/page.js` (including `quarterOptions()`'s quarter-window math).
Deliberately did NOT touch calendar-math helpers like `monthBounds()`
(`new Date(y, m, 0).getDate()`) or absolute-instant timestamps like
handover's `accepted_at: new Date().toISOString()` — those aren't "what
is today" computations and aren't part of this bug class.

**Issue 2 — investigated, found NOT fixable at the code level, documented
as an accepted limitation (user decision, this session).** Live-tested
against the real Commslayer API (not just reasoning about it):

- *Finding A:* Commslayer's account timezone is UTC+2 (confirmed via the
  `meta` field Commslayer echoes back on every response:
  `from_date`/`to_date` always resolve to `...T00:00:00.000+02:00` /
  `...T23:59:59.999+02:00`), not UTC and not PH time. A "Commslayer day"
  for date D actually spans PH time D 06:00 → D+1 06:00, a genuine
  ~6-hour skew from PH midnight-to-midnight.
- *Attempted fix, disproven live:* sending a full ISO timestamp with an
  explicit offset (e.g. `from_date=2026-07-22T18:00:00+02:00`, chosen to
  exactly span one PH calendar day) does NOT work — the API silently
  discards the time component and re-snaps to a whole UTC+2 calendar day
  regardless of what's sent. Proven decisively: requesting the *exact*
  sub-day window `2026-07-23T00:00:00+02:00`..`2026-07-23T23:59:59+02:00`
  (which should be a no-op if parsed correctly) returned data identical
  to the bare date `2026-07-23` — i.e. anything after the date part is
  ignored.
- *Why there's no workaround either:* this endpoint only returns
  pre-aggregated per-agent-per-day totals (closed_tickets,
  tickets_replied, etc.), never per-ticket timestamps — so even
  client-side, there's no data granularity to reallocate the ~6-hour
  overlap between two PH calendar days.
- *Finding B (confirmed alongside, also documented in
  `/api/commslayer-sync/route.js`'s code comments):* a genuine multi-day
  `from_date`/`to_date` range returns ONE aggregated total across the
  whole range, not per-day rows. This confirms the route's existing
  design (one API call per single day, never a batched wide-range call)
  is correct and must never be "optimized" into a batch call — doing so
  would silently collapse every date in the range onto one row.

**Decision (user, this session):** accept the ~6-hour skew as a known
Commslayer data-source limitation. No further code fix — attempting a
`from_date`/`to_date` offset shift would compile and look correct while
silently doing nothing, since the API discards it. If exact PH-day
alignment is ever needed, it would require a different Commslayer
endpoint with per-ticket timestamps (not yet investigated, and not
confirmed to exist).

---

## Dark-theme consistency audit — findings deferred as known tech debt

A full audit across all 20 routes + shared components (grep for
hardcoded hex/inline colors/one-off font sizes, plus a check for
divergent copies of shared layout/card/table markup) turned up **8**
findings (~~7~~ — corrected miscount; the list below has always had 5
deferred items, not 4, they just weren't added up correctly). 3 were
fixed immediately (see below); the remaining **5** are **deferred — not
urgent, no visible impact today**, tracked here so they don't need
rediscovering from scratch later.

**Fixed this session:**
- Chart components (`TrendLineChart.js`, `ShiftCompareChart.js`,
  `PerformanceHeatmap.js`) were still 100% light-theme with their own
  internal `@media(prefers-color-scheme)`/`[data-theme]` branching —
  leftover from before the app-wide light/dark toggle was deliberately
  removed. Every chart on Trends and every Sparkline on History was
  rendering as a light-white box inside an otherwise fully dark page.
  Converted to fixed dark-only tokens, no branching. `Sparkline.js`'s
  stray light-mode default color (`#57534e`) fixed too.
- `StatusPill.js` maintained its own separate copy of the 5-status
  color palette that `tailwind.config.js` already defines
  (`theme.extend.colors.status`) — two sources of truth for the same
  values, already drifted in shape (`StatusPill`'s copy was missing
  `dot`). Now imports and reads directly from `tailwind.config.js`.
- `AppShell.js`'s sidebar active-nav-item gradient used a hand-derived
  `from-[rgba(139,124,255,.95)] to-[rgba(99,102,241,.95)]` instead of
  the `from-accent-from to-accent-to` token pair every other "active"
  state in the app uses (status filters, trend tabs, interval presets,
  schedule view toggle). Now matches.

**Deferred — known tech debt, revisit only if it becomes visible:**
1. **A few genuinely new one-off hex values with no token**:
   `team-standings/page.js` (~line 78-80) introduces a fuchsia/magenta
   (`#e879f9`, `#d946ef`, `rgba(217,70,239,...)`) for the "1 spin"
   wheel-of-fortune tier — not present anywhere in
   `tailwind.config.js`'s token set. Low priority: it's one isolated
   spot, not a spreading pattern.
2. **Existing tokens re-typed as raw hex instead of referenced**:
   `StatCard`'s `accent`/`deltaColor` props and `Sparkline`'s `color`
   prop only accept literal hex, so every call site
   (`invoices/page.js`, `handover/HandoverClient.js`,
   `history/HistoryClient.js`) hand-copies a hex value that happens to
   match `ink.faint`/`accent.from`/`status.*` instead of referencing
   the token programmatically. Would need `StatCard`/`Sparkline` to
   accept a token name instead of a hex string — a small API change,
   not urgent since the values are currently correct, just duplicated.
3. **`#111117` (an elevated/"raised panel" surface color) has no
   token**, hand-typed identically in `schedule/page.js` (sticky
   column bg), `DateRangePicker.js` (popover bg), and now also
   `TrendLineChart.js`/`ShiftCompareChart.js` (tooltip/--surface-1,
   added during this session's chart fix — reused the same existing
   hex deliberately rather than inventing a fourth value, but that
   makes 4 hand-typed copies now instead of 1). Should eventually
   become a `raised`/`panel` entry in `tailwind.config.js`.
4. **No named font-size scale**: `text-[Npx]` arbitrary values (10,
   11, 13, 15, 23, 26, 28px) repeat ~110 times across every restyled
   page. Mostly self-consistent (28px=h1, 11px=table-label, 13px=body,
   everywhere), so not visibly broken, but never promoted into a
   `fontSize` scale in `tailwind.config.js` (only `fontFamily` was
   added there) — every page repeats raw pixel values instead of e.g.
   `text-label`/`text-h1`. One confirmed outlier that was deliberately
   **left alone** per explicit instruction: `AppShell.js`'s sidebar
   nav-item text is `text-[13.5px]`, while the rest of the app's
   equivalent body text uses plain `13px` — a half-step outlier from
   the otherwise-consistent scale.
5. **No shared table-shell/`<Th>` component**: the exact strings
   `rounded-[18px] border border-glass-border bg-glass-bg
   shadow-glass-lg backdrop-blur-md` (table container) and
   `text-[11px] font-bold uppercase tracking-[.06em] text-ink-label`
   (header cell) are hand-typed verbatim in 8-10 files each (unlike
   cards/buttons/inputs, which do have shared helpers). Values are
   consistent — copy-pasted correctly every time — but it's 8-10
   independent copies with no single source, so a future design tweak
   to "every table" means editing every file by hand. Candidate for a
   `tableShellClass()` + `<Th>` extraction, same pattern as
   `cardClass()`/`buttonClass()`.

None of the deferred items are visible bugs today (dark theme renders
correctly everywhere) — they're code-organization/duplication risks
that make future edits more error-prone, not present-day defects.

---

## is_admin self-approval exception + Kenn/Bjorn invoicing grant (this session)

**Confirmed live, tested, closed out:**

- **0036a** (`0036a_admin_self_approval_function.sql`) — `invoices_
  restrict_owner_status_transitions()` (0010's owner-transition trigger)
  now exempts `is_admin` actors entirely: `and not exists (select 1 from
  public.profiles where id = auth.uid() and is_admin = true)` added to
  the blocking condition.
- **0036c** (`0036c_admin_self_approval_policy.sql`) — `invoices_
  update_approver` (0009/0018) now has an `is_admin` OR-exception on its
  self-invoice exclusion, in both `using` and `with check`. Confirmed
  live via a live test that BOTH were needed: with only 0036a live,
  Edwin's self-approve failed with a *different* error ("new row
  violates row-level security policy"), not the trigger's old exception
  — proving the trigger passed but `invoices_update_approver` alone was
  still the only policy that could ever permit an approve transition
  (`invoices_update_owner`'s own `with check` restricts new status to
  draft/submitted/rejected, excluding `approved` outright).
- No `0036b` guard/verification migration file exists or was needed —
  verification moved to live full-body `pg_proc`/`pg_policy` queries
  instead (see the hard-lesson addendum above).
- **UI fix, same session, also confirmed**: `invoices/[id]/page.js`'s
  `canApproveOrReject`/`canReverseOrPay` were hard-gated on a bare
  `!isOwner`, with no `is_admin` awareness at all — so even with 0036a/
  0036c live, Edwin would never see the Approve button on his own
  invoice, since the button itself was gated off before the backend fix
  ever mattered. Changed to `(!isOwner || isAdmin)` on both flags,
  `isAdmin` sourced from the profile row already being fetched
  (`profile?.is_admin`), no new query added.
- Live-tested end-to-end (disposable invoices/users, fully cleaned up
  after each run, real invoices/agent_042 confirmed untouched
  throughout): Edwin self-approving his own `submitted` invoice now
  succeeds (`submitted → approved`, `approved_by`/`approved_at` set); a
  simulated non-admin approver (granted `invoicing` `can_write`, not
  `is_admin` — same shape as Quinty) is still blocked from self-
  approving their own invoice, with a positive control confirming that
  same approver can still approve a *different* agent's invoice
  normally.
- This was a **deliberate, explicitly confirmed decision**, not a
  security regression — self-approval was originally blocked by 0009/
  0010 specifically because it defeats the point of approval; that
  reasoning still holds for everyone except `is_admin` (today, only
  Edwin), where it was explicitly re-scoped. Do not "fix" this later by
  removing the carve-out without confirming that's an intentional
  policy reversal.
- Also this session: confirmed via a live query that exactly Edwin
  (`is_admin`) and Quinty (`invoicing` `can_write`) hold approval
  power — plus one unrelated finding, a leftover `fixtest-...` test
  fixture account with `is_admin=true` and no `agent_id`, which
  **was** confirmed to have zero references anywhere (permissions,
  every invoice `*_by` column, rate_history, performance tables,
  agent_notes, holidays) and has been fully deleted (`profiles` row +
  `auth.users` row, verified gone).
- **0037** (`0037_grant_kenn_bjorn_invoicing_view.sql`) — grants Kenn
  and Bjorn `invoicing` `can_view=true, can_write=false` (they had no
  `invoicing` permissions row at all, confirmed via the live query
  above). **Confirmed live**: the full unfiltered query (`select
  p.agent_id, perm.can_view, perm.can_write from profiles p left join
  permissions perm on perm.user_id = p.id and perm.feature_key =
  'invoicing'`) returns exactly the target state, 4 rows — Edwin
  `true/true`, Quinty `true/true`, Kenn `true/false`, Bjorn
  `true/false`.

---

## Coaching delegation to Andrew + Performance access for Andrew/Dominic (this session)

**0038 (`0038_coaching_permission_feature.sql`) — written a prior session, never applied until now, and had a real bug caught before this run.** The migration's `insert into permissions` grant was commented `-- Andrew` but used UUID `c04f7a4a-ca51-4d6e-b146-f35e51b57e01` — which is actually **Dominic's** UUID (confirmed via `grep` against 0017's own seed comment `<dominic@khaizenunderwear.com>` and 0020, where the same UUID is labeled "Dominic" five times total). Had it run as originally written, it would have silently granted `coaching` access to Dominic instead of Andrew. Caught and fixed (swapped to Andrew's real UUID, `fd91642c-71ec-4716-b275-efc08564a625`, confirmed the same way) before ever pasting it into the SQL Editor — nothing live was ever wrong. Adds `feature_key='coaching'` to `feature_registry`, excludes `'coaching'` from `has_permission()`'s `is_broad_reviewer` bypass (alongside invoicing/rate_and_schedule, closing the exact concern flagged in 0028), moves `agent_notes`'/`agent_note_replies`' admin-facing policies off bare `is_admin` onto `has_permission('coaching', ...)` OR `is_admin`, and grants Andrew `coaching` `can_view=true, can_write=true`.

**0039 (`0039_grant_andrew_dominic_performance.sql`) — new, written this session.** Grants Andrew and Dominic `feature_key='performance'` (`can_view=true, can_write=true`) — QA/Trustpilot/Penalties/Admin Bonus editing access on `/performance` and `/settings/performance`. Both UUIDs cross-checked against 0017/0020 before writing (not reused blindly from 0038's mistake). No schema/`has_permission()` change needed — `'performance'` has existed as a `feature_key` since 0021. Edwin needs no row — `is_admin` already bypasses `has_permission()` entirely.

**Both confirmed live** via direct `SELECT` against `permissions` joined to `profiles`:
- `feature_key='coaching'`: exactly 1 row — `andrew`, `true/true`.
- `feature_key='performance'`: exactly 2 rows — `andrew` and `dominic`, both `true/true`.

**Real gap found and fixed the same session: the DB grant alone wasn't enough for coaching.** Before assuming either grant would be visible on the actual site, checked how each page gates its admin UI:
- `/performance` and `/settings/performance` already check `permissions.can_write` for `feature_key='performance'` OR `is_admin` (pre-existing code, unchanged) — Andrew/Dominic's grant took effect immediately with zero code changes, live as soon as they log in.
- `src/app/coaching/CoachingClient.js` checked **only** `profile.is_admin`, with no reference to `permissions`/`feature_key` anywhere in the file — Andrew's `coaching` grant had **zero visible effect** despite being correctly live in the database. Fixed (commit `182bf4c`): `CoachingClient.js` now checks `permissions.can_write` for `feature_key='coaching'` OR `is_admin` (`canManage`, replacing the old bare `isAdmin` state/prop throughout — compose form, "All sent notes" list, delete button), mirroring the exact pattern already used by the two performance pages. Lint clean, `npm run build` clean.
- **Lesson: a `permissions` row existing and being correct is not the same as a feature actually respecting it** — always check the specific frontend gate (`is_admin` bare vs. `has_permission`-equivalent client check) before telling the user a grant is "done," not just the DB state.

**Second real gap found the same session, unrelated to permissions logic: `/performance` had no sidebar link at all, for anyone, ever.** User (Edwin, `is_admin=true`) reported not seeing a "Performance" tab anywhere. Investigation confirmed `src/components/AppShell.js`'s `GROUPS` array (the sole source of nav items — no separate nav-config file) never had a `/performance` entry — only `/my-performance` and `/settings/performance` existed, both different pages. Traced via git history: the `/performance` admin page was added in `665e94f` and never followed up with a nav-wiring commit. Confirmed this wasn't a systemic permissions-fetch bug (other admin-gated items like `/settings/users` worked fine for Edwin, and the amber "Limited nav" fallback badge wasn't showing) — genuinely just a missing array entry. Fixed (commit `723dfeb`): added `{ href: '/performance', label: 'QA & Bonus Entry', need: 'performanceWrite' }` to the Administration group, gated identically to the page's own internal check. Lint clean, `npm run build` clean.

**Lesson reinforced, not new**: an inline SQL comment naming a person (`-- Andrew`) is not proof of whose UUID it actually is — this project's own convention has no FK from `permissions.user_id` to a human-readable name, so a copy-paste/mixup error like this is silent and only catchable by cross-referencing the UUID against other migrations or a live join. Worth doing this cross-check on every future migration that hardcodes a specific person's UUID, not just when something looks suspicious.

**Third gap, same root cause, found once actually looking at the page: `/performance` and `/my-performance` were both still on the pre-dark-theme light styling (`bg-stone-50`, white cards, stone borders) — the exact "Dark-theme consistency audit" (further above) never caught these two because neither was reachable from the nav at the time of that audit, so nobody clicked into them. Both had prior-session code comments already flagging the gap (`"this page was apparently missed from the dark-theme rollout"`) that never got acted on.** Fixed both (commits `0cc72c7`, `7273b8d`): rewrote using the same shared primitives already proven on other pages — `cardClass()`, `inputClass()`, `tableShellClass()`/`thClass()`, `buttonClass()`, `fieldLabelClass()`/`sectionLabelClass()`, `Select` (custom dropdown, avoids native `<select>`'s white-popup-in-dark-mode issue from commit `8a5efe4`), and `StatCardCentered` (`ui/StatCard.js` — already built specifically for `/my-performance`'s 4-tile stat grid per its own doc comment, never actually wired in until now). Styling only, no behavior/data changes. Lint clean, `npm run build` clean on both. Live-checked via a headless-Chromium screenshot of each page's unauthenticated `/login` redirect (no test credentials available in this environment) — confirmed dark canvas background (`rgb(8,8,13)`), zero leftover `bg-stone-50`, zero console errors; the actual authenticated QA/Trustpilot/Penalties and My Performance content itself should still get a real human look once deployed.

---

## Daily Report: explicit schedule Off vs. residual synced activity (this session)

**User report, from a real screenshot comparison**: on `/schedule`, Sat 2026-08-01 shows Kate and Rubyrose explicitly `Off` (only jurina/Monmark scheduled that day). But `/daily` for the same date showed Kate and Rubyrose with real numeric rows (`Replied 0, Closed 1`, etc.) instead of "OFF" — only Mayvel (genuinely `0/0`) rendered as OFF. Looked like a schedule/report mismatch bug.

**Root cause: not a defect — two already-deliberate decisions colliding.** (1) `buildWeekRows` (`src/lib/performanceLogic.js`) sums `closedTickets`/`ticketsReplied` unconditionally every day, even an OFF day's report value — a documented, intentional legacy-matching quirk (see the Weekly ranking pipeline section above). (2) `daily/page.js`'s own render logic was deliberately changed in an earlier session to show real numbers whenever ANY ticket activity exists, specifically so a date with no schedule row wouldn't hide real synced data behind a bogus "OFF" (see that fix's own inline comment, still in the file). Combined, an agent explicitly scheduled Off who still has a stray synced ticket (most likely explained by the already-investigated Commslayer UTC+2 account-timezone skew, `~6h` off from PH time — see "Daily Report timezone bug" above) rendered identically to someone who actually worked, with no visual distinction.

**Real underlying limitation surfaced while fixing this**: `buildWeekRows`' own `shiftKey` resolution (`daySchedule[agentId] || 'OFF'`) defaults a **missing schedule row** to `'OFF'` the same way it represents an **explicit** Off shift — its output alone cannot tell the two apart. This matters because the original "don't hide real activity" fix needs to keep applying when there's genuinely no schedule data at all, but should NOT apply the same way when the schedule explicitly says Off.

**User's decision, given three options (keep as-is / always trust schedule / show OFF with a flag): show OFF, but flag residual activity.** Fixed (commit `01fc2e1`): `loadDay()` in `src/app/daily/page.js` now also returns the raw per-agent schedule map for that specific date (`scheduleByAgent`), threaded into a new `todayScheduleByAgent` state separately from the `buildWeekRows`-derived `ranked` rows. Render logic now has three cases instead of two: (a) no schedule signal and no activity → plain `OFF` (unchanged); (b) **explicitly** scheduled Off (`todayScheduleByAgent[agentId] === 'OFF'`) but with real `closedTickets`/`ticketsReplied` → `OFF (schedule)` plus a small amber flag badge (e.g. "1 closed synced") with a tooltip explaining the likely Commslayer timezone-skew cause; (c) everything else (scheduled to actually work, OR no schedule row at all with real activity) → the original numeric row, completely unchanged — preserving the earlier fix's intent exactly for the "no schedule data" case. Lint clean, `npm run build` clean, verified no console errors via headless-Chromium against the unauthenticated `/login` redirect (no test credentials available in this environment — the actual flagged row should get a real look once deployed with Aug 1 data).

---

## Process notes for future sessions

- `SUPABASE_SERVICE_ROLE_KEY` is in `.env.local` (added this session).
  **This key was pasted into a chat session at one point and should be
  rotated** (Project Settings → API → regenerate secret key, update
  `.env.local`) — not urgent, but don't forget indefinitely.
- Confirm `.env.local` is in `.gitignore` before any further work — never
  verified explicitly this session.
- When testing with temp/unprivileged accounts, always clean up both the
  `profiles` row and the `auth.users` row after — `schedule` has no
  delete policy for anyone (deliberate, from 0011), so leftover test rows
  there need a manual admin-run `DELETE`.
- **Known transient issue, not a code bug:** `admin.auth.admin.createUser()`
  (and once, an invite send — Kate's invite, a prior session) has now
  intermittently failed twice with `AuthApiError: invalid JWT: unable to
  parse or verify signature... unrecognized JWT kid <nil> for algorithm
  ES256` (`status 403, code 'bad_jwt'`). Both times it self-resolved on
  an immediate retry with no code change — looks like a brief Supabase
  auth-signing-key hiccup, not something wrong with the service-role key,
  the request shape, or this project's code. If it recurs during
  temp-account test setup: retry once before assuming a real problem.

---

## Mobile: PWA install + real mobile card layouts (this session)

**PWA manifest + icons (commit `ede1efc`).** `src/app/manifest.js` (Next.js's special-file convention, auto-linked in `<head>`, no manual wiring) + `apple-touch-icon`/`appleWebApp` metadata in `layout.js` for iOS (which doesn't read the web manifest's icons for "Add to Home Screen" the way Android/Chrome does). Icons resized from the existing `public/khaizen-logo.png` via macOS `sips` — no new dependency. Confirmed live: manifest serves correct JSON, all three icon files return 200, correct `<meta>`/`<link>` tags present in the rendered page head.

**Before treating the PWA install as "done," user asked the right question: is the app itself actually mobile-friendly, or would installing it just open a desktop layout that needs horizontal scrolling?** Checked every data-heavy page — answer was no, only `/invoices` and `/settings/users` had a genuine mobile card fallback (table hidden below `sm:`, a real card/list shown instead). Every other page (`/schedule`, `/daily`, `/my-performance`, `/team-standings`, `/performance`, `/qpi`, `/handover`, `/history`) relied on `overflow-x-auto` — exactly the bad experience being asked about.

**Fixed all 8 (commit `9aaa615`), extending the exact pattern already proven on `/invoices`/`/settings/users`:**
- **`/schedule`** — the hard case: a real matrix (dates × agents), not a list, so "card per row" doesn't reduce columns the way it does elsewhere. Restructured as card-per-**DATE**, with every agent's shift stacked vertically inside each date's card — turns sideways scrolling into plain vertical scrolling instead. Also fixed `BulkSetModal`'s Days/Agents `grid-cols-2`, which would have squeezed both lists to ~150px wide on a phone (`grid-cols-1 sm:grid-cols-2` now).
- **`/daily`** — card per agent, metrics as a 2-column grid. Extracted `rowOffState()` (new pure function, top of file) so the desktop table and mobile cards share the *exact* same OFF/OFF-flagged/active branching from the fix above, rather than duplicating that logic and risking the two views drifting apart.
- **`/my-performance`, `/team-standings`, `/qpi`, `/history`** — card-per-record layouts for every wide table (daily reports, penalties, standings, QPI incentive checkboxes as labeled checkbox rows, the 11-column per-agent history table, target adherence badges).
- **`/performance`** — QA/Trustpilot/Admin Bonus per-agent rows weren't literal tables (already flex rows), but would still squeeze a long name against inputs on a narrow screen — changed to stack (name above, inputs below) via `flex-col sm:flex-row`. Penalties table got the same card treatment as everywhere else.
- **`/handover`** — card list for the admin-only "recent handovers" table, lower priority since it's already behind a collapsed `<details>`.

Lint and full `npm run build` clean across all 8. Verified zero console errors at a real 375px mobile viewport (headless Chromium) for every touched page. **Caveat, same as the earlier dark-theme restyle work**: these are all auth-gated pages and no test credentials exist in this environment, so the actual authenticated card rendering has not been seen by human eyes yet — needs a real phone check once deployed, not just "build succeeded."

**Known gap in the above sweep, discovered later the same session: it only checked one-level-deep routes.** The `grep -rl "overflow-x-auto" src/app/*/page.js` used to find candidate pages doesn't match nested routes like `src/app/settings/usd-rate/page.js` — every `/settings/*` subpage was silently skipped. `/settings/usd-rate` got its mobile card treatment as a side effect of the USD Rate bug fix below (already deep in that file), but `/settings/invoicing`, `/settings/invoice-profile`, and `/settings/users` (partially — it already had the pattern from before this session) have not been re-checked. **Still open, not yet swept.**

---

## USD Rate: silent no-op bug + duplicate/added-date visibility (this session)

**User report**: Dominic's July invoice showed real schedule-derived hours (Week 27: 24h, Week 28: 40h) but ₱0.00 total — the invoice's own notes said `Period: Global fallback — log a cutoff rate in USD Rate page`. Separately, Dominic's own `/settings/usd-rate` view showed "No rate history yet," even though the user believed Andrew had already logged July's Cut 1 and Cut 2 rates.

**Confirmed not an isolation bug**: `/settings/usd-rate` loads every `rate_history` row with no per-user filter (`select('*')`), and `rate_history_select_all` (0013) is `using (true)` for any authenticated user — the list is already fully shared across Edwin/Andrew/Dominic by construction. An empty list means the table is genuinely empty for everyone, not hidden from anyone specifically.

**Real bug found**: `addEntry()` was `if (!period || !rate) return` with **zero error message** on the early return. The "Period" field requires actually interacting with a custom date-picker widget (`DateRangePicker`), unlike the simpler Cut Label dropdown and Rate number input next to it — very plausible that Andrew filled in Cut Label + Rate, clicked "+ Add," and nothing happened, with no error telling him why. This is the leading explanation for how he could believe the rates were logged when the table was empty.

**Fixed (commit `f64e4bf`)**, per explicit follow-up request (among-billing-admins visibility, duplicate detection, added-date):
- Explicit validation errors ("Pick a period date first." / "Enter a rate.") instead of the silent no-op.
- **Live duplicate notice** — reads the already-loaded `rows` list client-side, shown the instant a period with an existing row is picked, *before* Add is ever clicked: existing rate, who added it (`saved_by`), and when (`recorded_at`, new `fmtDate()` helper). Submit button disabled while it's showing.
- Submit-time duplicate check as a second layer (race-condition backstop — two admins adding the same period near-simultaneously), plus translation of the real DB-level `unique` constraint on `rate_history.period` (0013 — this already existed, just surfaced as a raw Postgres error) into the same friendly message.
- New **Added** column (`recorded_at`) on the list table itself, not just the add-form warning — the user explicitly asked for this.
- Mobile card layout added to this page too (see the gap noted above).

Lint and `npm run build` clean. Verified zero console errors at a real 375px mobile viewport. **Not yet confirmed**: whether Andrew's belief that he'd already added the rates matches this exact failure mode — asked the user to have him retry on this page and confirm live.

**Second real UX bug found in the same page, same session (commit `8d57950`): the Period picker was a full day-by-day calendar, and a separate "Cut Label" dropdown existed alongside it that looked like it set the cut but didn't.** The actual period was derived entirely from which calendar DAY got clicked (`day<=15` → Cut 1, `>15` → Cut 2, via the now-deleted `periodForDate()`) — "Cut Label" was pure decoration, stored as typed text with zero connection to the real computed `period`. Picking "Cut 2" in that dropdown while clicking a day in the first half would silently save a Cut 1 row *labeled* "Cut 2" — another plausible contributor to the same "I thought I already added this" confusion, independent of the silent-no-op bug above. Fixed: replaced with two explicit inputs, a plain `<input type="month">` and a real Cut 1/Cut 2 `Select`, which are now the *only* source `period` is computed from (`${month}-C${cutNumber}`) — no hidden day-number mechanic, `cut_label` always exactly matches what was picked. `DateRangePicker` import removed (no longer used on this page). Lint/build clean, bundle size dropped (4.47kB → 2.96kB) as a side effect of dropping that component.

---

## Doubled "-INV" invoice numbers + persistent Back button (this session, commit `bd419bb`)

**User report, from a real invoice**: `EC-INV-INV-2026-31-32-33` — a doubled `INV`. Root cause: `generateInvoiceNumber()` (`invoiceLogic.js`) always appends `-INV` to `invoice_profiles.invoice_prefix`, but that field's own settings UI (`settings/invoice-profile/page.js`) never explained that — labeled plainly "Invoice Prefix," placeholder "default." Typing `EC-INV` there (a reasonable guess at what a full prefix should look like) produces exactly this bug on a real, live invoice. Fixed universally (every agent's profile, not special-cased to whoever reported it): strips any redundant trailing `-INV`/`INV` already typed (case-insensitive) before appending its own, so `EC`, `EC-INV`, and `ec-inv` all normalize to the same correct `EC-INV`. Field itself also got a clarifying placeholder (`"e.g. EC"`) and hint text so this can't recur. Note: the one specific already-created invoice with the bad number still has it baked into its stored `invoice_number` — the code fix only prevents new ones; that existing draft needs deleting/regenerating once deployed if it matters.

**Back button, same session, separate ask.** No page anywhere had a way to navigate back except the sidebar (forward-only) — became a real problem with this session's earlier PWA "Add to Home Screen" work: launched in standalone display mode, there's no browser chrome at all, so there was no way back on mobile once installed. Added one button to `AppShell.js`'s shared header (next to the hamburger, always visible — not `lg:hidden` like the hamburger, since this matters on desktop too, not just mobile) using real `router.back()` — appears on every page automatically since AppShell wraps the whole app, no per-page changes needed.

Both lint/build clean. Live-checked via headless Chromium (zero console errors) — couldn't visually confirm the header itself renders correctly with a real session (no test credentials in this environment, same limitation as every other UI change this session) — needs a real look once deployed.

---

## Leave/credit shift codes wrongly scored as real work hours (this session, commit `140448c`)

**User report, from a real Daily Report**: Rubyrose showed a real `0.0` score and last-place rank (`R5`) instead of `OFF`, despite being on Paid Leave that day with zero synced ticket activity — flagged as confusing, should behave "the same as off" since her day was blocked (using leave credits, not actually working).

**Root cause**: `performanceLogic.js`'s `SCORING_SHIFT_HOURS` gave `HOL_OFF`/`NSD_CREDIT`/`LEAVE` a nonzero value (8h) — inherited verbatim from legacy's `SHIFT_TYPES.hours`, which doubles as the PAY-hours table (you're paid for these days without working them). `invoiceLogic.js`'s own `OFF_SHIFTS` list already correctly groups all three alongside `NSD_NOPAY`/`LWOP`/`NSD`/`OFF` as "not actually working" — but the scoring table never matched that list, a real drift bug between two files' notion of the same shift codes that's existed since the ranking pipeline was first ported (not something introduced by any of today's other changes).

**Fixed**: exported `OFF_SHIFTS` from `invoiceLogic.js`, derived the three scoring-hours entries from it directly instead of a second hardcoded (and driftable) copy. Affects every consumer of `buildWeekRows`/`scoringHoursFor` — Daily Report, Weekly Ranking, History, Trends, Team Standings — uniformly, not just the Daily Report page where it was noticed. An agent on any of these leave/credit codes with zero real activity now correctly gets `scheduledHours=0` → excluded from ranking (shows as `OFF`), everywhere this pipeline is used, instead of being scored against a phantom 8-hour schedule they were never actually working.

**Verified with an actual fixture test**, matching this project's established standard for scoring-pipeline changes (not just build success) — ran `buildWeekRows`/`computeScores`/`rankRows` directly via a standalone script: a LEAVE-scheduled agent with zero activity → `scheduledHours=0, score=null, rank=null` (previously `0.0`/last place); a normal REGULAR-scheduled agent's real score/rank unaffected. Lint and build clean.

---

## Time Off (NSD/PTO) feature — port from legacy KHAIZEN Team Portal (this session, commit `5c9b16a`)

**Context.** The team runs leave/non-service-day requests through a separate Google Apps Script tool (Sheet-as-database, shared static password `KhaizenCore!` for the manager panel). User wants it ported into c3-website with real Supabase auth/permissions instead. Sent real screenshots of the legacy tool's actual per-person balances (entitlement/carryover/used/remaining) plus a detailed written spec of the workflow (request → pending → approve/deny → auto-populate the planner, credit validation, two-week-notice soft guideline, weekend-shifter Monday/NSD restriction, ClickUp notification gap).

**Went through a full plan-mode cycle before writing any code** (3 parallel Explore agents — schedule/shift-code system, permissions system, self-service/calendar UI patterns — then a written plan, reviewed and approved). Key decisions confirmed with the user before design:
- Nav label: **"Time Off"**.
- Architecture: approved requests write into the **existing `schedule` table** (same one `/schedule` already reads/writes) — deliberately NOT a second, competing "who's off when" system.
- Credit mapping: all four request types (Non-service day, Personal day, Preferred leave, Other) draw from **one shared per-agent-per-year balance pool**, confirmed against the legacy screenshots themselves (one number per person, not four). `NSD` request type writes shift_code `NSD_CREDIT`; `PERSONAL`/`PREFERRED`/`OTHER` write `LEAVE` — both already-existing shift codes that already burn a credit in `invoiceLogic.js` (`usesCredit`/`creditDays`), just reused rather than inventing a parallel vocabulary.
- Explicitly deferred, not in this build: weekend-shifter Monday/NSD restriction (needs a "who is a weekend shifter" concept this app doesn't have), ClickUp submission notifications (needs a real API token + destination decision).
- Proration formula derived from the legacy screenshot itself, not guessed: Mon's card explicitly showed "1.25 days/month" and a 2.5-day entitlement for a partial year starting Oct 28 — `15/12 x 2 full months (Nov, Dec) = 2.5`, matched exactly. Implemented as "1.25 days per full calendar month remaining after the start month" — flagged in code as unverified for the edge case of starting exactly on the 1st of a month (only one real reference point exists).

**Schema (migration `0040_time_off_feature.sql`)**:
- `feature_key='time_off'`, added to the `is_broad_reviewer` bypass exclusion list (same reasoning as `coaching` — real HR data shouldn't leak to broad reviewers by default).
- `time_off_balances` — one row per agent per year. `entitlement`/`carryover_in`/`adjustment` (the user's explicit "must be editable, e.g. bonus days added") are stored; **`used` is never stored** — always computed from approved `time_off_requests` plus a `pre_migration_used` bridge column that carries the legacy tool's "already used" figures forward (since there are no real per-date request records behind that historical number). Single source of truth, same philosophy as `has_permission()`/`OFF_SHIFTS`.
- `time_off_requests` — `pending`/`approved`/`denied`/`revoked`, no delete policy (transition status instead, matching `schedule`'s own convention). Self-insert-own-pending-only (mirrors `invoices_insert_own`'s exact shape); decisions gated on `has_permission('time_off', need_write=>true)` only — an agent can never approve/revoke their own request by editing the row directly, even though they can read it.
- Berry and Kenn granted `time_off` view+write (the user's stated approver list, Edwin already covered via `is_admin`).
- Real 2026 starting balances seeded directly from the user's screenshots (Mon, Jurina, Mayvel, Kate, Rubyrose, Andrew, Dominic, Edwin, Berry — Berry's entitlement is 25/year, not 15, per her own legacy card) — guarded with a `do $migration_guard$` block re-asserting row count and Mon's exact carryover value, same convention as every prior seed migration (0022/0023/0031/0039).

**Approval write path — new service-role route `/api/time-off/decide`**, not a widened `schedule` RLS grant. Reasoning: Berry/Kenn approving leave shouldn't need `rate_and_schedule` write access (a billing-admin concept, unrelated). The route checks `is_admin` OR a `time_off` `can_write` permissions row directly (can't call `has_permission()` as a Postgres RPC from a service-role client — that function relies on `auth.uid()` from a real request JWT, which a service-role client doesn't have), then upserts `schedule` rows on approve (mapped via `REQUEST_TYPE_SHIFT_CODE`) or resets them to `OFF` on revoke — same `{shift_date, agent_id, shift_code}` / `onConflict: 'shift_date,agent_id'` shape every existing write path on `/schedule` already uses.

**`src/lib/timeOffLogic.js`** — pure functions (`prorateEntitlement`, `computeBalance`, `requestDayCount`, `datesInRange`, `isShortNotice`), same no-Supabase-calls convention as `invoiceLogic.js`/`performanceLogic.js`. **Verified with a standalone fixture test (14 assertions) before writing any UI** — confirmed Mon's real balance numbers reproduce exactly (`17.5` available, `9.5` used, `8` remaining with zero new requests), a cross-New-Year request only counts the portion actually inside the year being computed, and the two-week notice check is correctly a non-blocking signal only (per explicit "submit it anyway, managers decide case by case" instruction), never a hard block.

**UI**: one page `/time-off`, additive sections (own balance/calendar/request form/history always shown to anyone with an `agent_id`; an approval queue + team-wide calendar added on top for approvers) — same philosophy as `/handover`, not a hard agent/admin fork like the legacy tool. New `src/components/ui/LeaveCalendar.js` reuses `DateRangePicker.js`'s exact day-grid math (`firstWeekday`/`daysInMonth`/padded `cells`) for a read-only month view, without modifying `DateRangePicker` itself. Balance admin editor at `/settings/time-off` (entitlement/carryover/adjustment/preferred-date per agent, `used`/`remaining` shown read-only, computed — editing a number here can never touch `used`, only real approved requests can). Credit-validation blocks submission client-side if the requested day count exceeds `remaining`. Mobile card layouts built in from the start (per this session's earlier mobile-friendliness sweep — no page shipped that would immediately need the same retrofit). `StatusPill` (built for invoice statuses) intentionally NOT reused for request statuses — it silently falls back to gray for any unrecognized status, which would have made `denied`/`revoked` indistinguishable from `pending`; a small local pill was added instead rather than risking a change to the shared invoice status palette.

Lint and `npm run build` clean across all 7 files. Verified zero console errors at both desktop and 375px mobile viewports (headless Chromium, unauthenticated `/login` redirect — same limitation as every other UI change this session, no test credentials available). Confirmed the new API route returns a real `401` (not a crash) when called unauthenticated.

**Migration `0040` confirmed live** — all 5 verification queries checked directly against the database (not a bare "Success" banner): `feature_registry` has exactly 1 `time_off` row; `has_permission()`'s full body (read via `pg_proc.prosrc`, not a substring match) correctly excludes `time_off` from the broad-reviewer bypass; exactly 3 policies each on `time_off_balances`/`time_off_requests`; Berry and Kenn both hold `time_off` `true/true`; all 9 seeded 2026 balances match the legacy screenshots exactly (including Mon's `2.5` carryover and `agent_042`'s `13.75`).

**Still not yet done**: the code commits (`5c9b16a`, `e028e00`) haven't been pushed/deployed yet — confirm with `git push origin main` before expecting any of this to appear on the live site. Once deployed, the full request → approve → schedule-write loop needs a real end-to-end test with an actual account before anyone trusts it with real leave requests — this is the first time anything other than `/schedule` itself writes into the `schedule` table.

**Three follow-ups from user feedback, same session (commit `594438a`):**

1. **No-carryover policy, confirmed 2026-08-05**: starting the year after 2026, unused balance no longer carries into the next year — every agent resets to their flat standard entitlement. Added `LAST_CARRYOVER_YEAR` (`2026`) and `nextYearBalanceDefaults()` to `timeOffLogic.js` as an explicit rule, since there's no automated year-end rollover in this app at all — an admin manually creates each new year's balance row on `/settings/time-off` — so this exists to give that manual process a documented rule to follow rather than silently relying on `carryover_in`'s schema default (0) and hoping nobody adds carryover math back in later. **2026's already-seeded carryover values are untouched** — this only affects future year transitions (2027 onward). Verified with a 3-assertion fixture test.

2. **Consolidated the approval queue onto `/settings/time-off` (renamed "Time Off Admin"), removed entirely from `/time-off`.** User reported genuine confusion — approvals lived on the main page, balance editing on a separate settings page, and it wasn't obvious where to go to approve a request. Both now live together on one page, pending requests shown *above* the balances list (the thing to check first). `/time-off` stays the self-service surface (own balance/calendar/request form/history) plus a read-only team calendar with a link pointing to the new admin page. Added a real pending-count nav badge to the "Time Off Admin" nav item — same pattern as the existing Invoices submitted-count badge (`AppShell.js`) — so an approver sees at a glance whether anything needs checking without opening the page at all.

3. **Preferred Date moved out of the cramped entitlement/carryover/adjustment grid into its own clearly-labeled row per agent** on `/settings/time-off`, per explicit "this looks messy" feedback. Stays free text, not a real date input — real values include holiday names like "Easter," not just calendar dates.

Lint and build clean. Zero console errors at desktop + 375px mobile viewports. Not yet pushed/deployed — same standing next step.

---

## Time Off — tabbed redesign from a user-supplied mockup (this session, 2026-08-05)

**Context.** User designed a full replacement look for both Time Off pages in an external design tool and pasted the resulting self-contained HTML/JS prototype (`KHAIZEN C3.dc.html`, not part of the repo), asking for it to be applied to the real app for both the agent-facing page and the admin page, keeping mobile access. The mockup's underlying calc logic (`usedDays()`/`dayCount()`/`fmtRange()`) matched `computeBalance()`/`requestDayCount()` exactly — confirmed no business-logic changes were needed, only layout/UI.

**Preferred Date, real dates vs. free text**: previous follow-up (above) had deliberately kept it free text because real legacy values included holiday names like "Easter." The mockup showed it as two real date fields instead. Confirmed with the user (AskUserQuestion) to switch to real dates. **Migration `0041_time_off_preferred_date_range.sql`** adds `preferred_date_start`/`preferred_date_end` (date columns) to `time_off_balances`, backfills 7 of 8 agents' real 2026 dates parsed from the old free-text values, and converts Rubyrose's "Easter" to its real 2026 date (`2026-04-05`). The old `preferred_date` text column is kept, not dropped (same "stop using, don't destroy" pattern as `rate_history.hourly_usd` in migration `0034`) — no longer written from either page's UI.

**New shared component `src/components/ui/MonthlyBarChart.js`** — a 12-bar monthly-totals chart, used on both pages ("Days taken by month" personal, "Team leave by month" admin) rather than two near-identical hand-rolled copies.

**`/time-off` rewritten from one long scrolling page into four tabs** (Calendar / Request / My Requests / Team — Team only shown to approvers): 4 `StatCard` tiles up top (Available/Used/Remaining/Carried over) replace the old plain divs; Calendar tab adds an "Allowance used" progress bar and the new bar chart above the existing `LeaveCalendar`; Request tab is unchanged functionally (`DateRangePicker` + type `Select` + reason); My Requests is now a real table with a mobile card fallback (previously a plain card list) showing Type/Dates/Days/Reason/Status; Team tab is the existing read-only team calendar plus a link to the admin page, gated the same way it always was (`time_off` write access or admin).

**`/settings/time-off` rewritten from "Pending requests + one-card-per-agent Balances" into three tabs** (Requests / Balances / Insights) with 4 `StatCard` tiles up top (Pending / Approved YTD / On leave in [current month] / Team utilization %, all derived from already-loaded data — no new queries). Requests tab is the existing approval queue, unchanged. Balances tab converted from per-agent cards into a real table (Agent+mini util bar / Entitlement / Carryover / Adjustment+Note / Preferred day / Save), mobile card fallback added for the first time on this page; "Preferred day" is now two real `<input type="date">` fields with a formatted label above them, replacing the free-text row. New Insights tab: "Team leave by month" (shared `MonthlyBarChart`, with a total-days-this-year label) and "Allowance used per agent" (name + progress bar + % per row) — both computed client-side from data the page already loads, no new endpoints.

Lint and `npm run build` both clean. Verified zero console errors at desktop (1280px) and mobile (375px) via headless Chromium, dark theme confirmed (`background-color: rgb(8, 8, 13)`) — both pages correctly redirect to `/login` in this environment (no test credentials available, same standing limitation as every prior UI change this session), so the actual tabbed content still needs a real look with a live session before calling this fully verified. **Migration `0041` confirmed live** — verification query against `time_off_balances` for 2026 shows all 7 backfilled dates exactly as specified (including Rubyrose's Easter conversion to `2026-04-05`), and `agent_042`/`berry` correctly left null.

---

## Time Off — schedule/invoice link confirmed, revoke/cancel workflow closed the loop (this session, 2026-08-05)

**User question, plain language**: once a request is approved and lands on the schedule, does it actually flow into invoicing — and can it ever be "undone," either by an admin or by the agent themselves?

**Confirmed already-working**: approving a request writes `NSD_CREDIT` (Non-service day) or `LEAVE` (Personal/Preferred/Other) into the real `schedule` table for every date in the range — the same table `/schedule` reads. `invoiceLogic.js` already treats both codes as paid-but-not-worked days that consume a leave credit and bill 8h (`usesCredit: true`, `creditDays++`), so an approved request shows up on that agent's invoice automatically, no separate step.

**Gap found while confirming this**: `/api/time-off/decide` already supported a `revoked` decision (resets the schedule dates back to `OFF`, reverses exactly what approving wrote) — but neither page had ever wired a button to it. There was no way, anywhere in the UI, for anyone to actually revoke an approved request. Also, `/schedule` itself has no concept of "this cell came from an approved time-off request," so a billing admin could still directly overwrite it from the plain schedule grid without touching `time_off_requests.status` at all — a real desync risk between the two that revoke buttons alone don't fully close, flagged to the user but out of scope for this fix (would need a bigger `/schedule` change to lock or flag those cells).

**Confirmed with the user (AskUserQuestion) before building**: (1) an agent cancelling their own already-approved time off needs admin approval first — same review step as a new request, not an instant self-undo, since staffing may already be planned around that absence; (2) admins should also get a way to browse approved requests directly and revoke one on an agent's behalf, not just respond to a cancellation ask.

**Migration `0042_time_off_cancel_request.sql`** adds `cancel_requested boolean` / `cancel_requested_at timestamptz` to `time_off_requests` — a flag layered on top of the existing row, not a 5th `status` value. Reasoning: `status` has a check constraint locked to `('pending','approved','denied','revoked')` (0040), and the row needs to stay `status='approved'` (still billing correctly) right up until an admin actually acts on the cancellation — a new status value would mean every consumer of that status elsewhere (invoicing, balance calc) would need to treat it as equivalent to 'approved', whereas a flag needs no such change anywhere.

**New route `/api/time-off/request-cancel`** — service-role, same shape as `/api/time-off/decide` but inverted authorization: caller must own the request (not be an approver), since `time_off_requests` has no self-update RLS policy at all. Only sets the flag; never touches `schedule` or `status` itself. Confirmed returns a real `401` unauthenticated.

**`/api/time-off/decide` extended**: new `cancel_denied` decision (admin keeps the leave approved, just clears the flag — no schedule/status change); any `revoked` decision now also clears `cancel_requested`/`cancel_requested_at`, whether that revoke came from fulfilling a cancellation ask or a direct admin-initiated one.

**UI**: `/time-off`'s My Requests tab gets a "Cancel" button on any of the agent's own approved rows (hidden once already requested, replaced with an amber "Cancellation requested" note), behind the existing `useConfirm()` dialog (`ConfirmDialog.js` — the app's shared dark-themed confirm, not `window.confirm()`) explaining an admin still has to approve it. `/settings/time-off`'s Requests tab gains two new sections below the existing pending queue: "Cancellation requests" (Approve cancellation → revoke, or Keep approved → `cancel_denied`) and a year-scoped "Approved" browse table with a `Revoke` button on every row, confirmed via the same dialog with a full explanation of what it undoes. Both new sections follow the existing table+mobile-card fallback convention.

Lint and `npm run build` clean. Confirmed both new/changed API routes return real `401`s unauthenticated (not crashes). Zero console errors at desktop + 375px mobile viewports. **Migration `0042` confirmed live** — verification query against `information_schema.columns` shows both `cancel_requested` (boolean) and `cancel_requested_at` (timestamp with time zone) present on `time_off_requests`.

---

## PAG-IBIG MP2 silently understated on Cut 2 when Cut 1 was a manual invoice (this session, 2026-08-05)

**User report**: night differential missing for some agents (Rubyrose, Jurina) in July, plus "PAG-IBIG MP2 has wrong calculation for the others" — both cuts. The night-diff report is still pending the user checking `Settings → Invoicing`'s Night Differential toggle/rate/window (it's one global setting shared by every agent — no per-agent override — so if it got disabled or misconfigured, only agents who actually work qualifying night hours would ever notice, which fits "some agents" exactly). The PAG-IBIG report led to a real, confirmed bug in the code, found and fixed this session.

**Root cause**: PAG-IBIG MP2 (Cut 2 only) is meant to be computed on the FULL MONTH's billable hours — Cut 1's hours plus Cut 2's own (`buildLineItems`'s own doc comment: "uses raw hours-only totals from both cuts of the month"). Both places that assemble a Cut 2 invoice (`/invoices/new/page.js`, `/invoices/[id]/page.js`'s recompute path) fetched Cut 1's hours by looking up `invoices` where `id = generateInvoiceId(agentId, month, 1)` — the deterministic `inv_<agent>_<month>_c1` id format that only schedule-auto-generated invoices actually get. **Manual invoice creation** (`/invoices/new-manual`, added earlier this session) lets an admin pick Cut 1 or Cut 2 but always assigns a random uuid as the row's `id` (`generateManualInvoiceId()`) — so whenever an agent's Cut 1 invoice for a month was created manually rather than auto-generated, that lookup silently found nothing, `cut1HoursBase` defaulted to `0`, and PAG-IBIG MP2 got computed on Cut 2's hours alone instead of the full month — understating it, with no error or warning anywhere. This is exactly the kind of bug the "no calculation changes" framing around manual invoices at the time didn't anticipate: manual invoices weren't wired into every downstream lookup that assumed the deterministic id.

**Fixed** in both call sites: look up Cut 1 by `agent_id` + `month` + `cut_number = 1` (summing hours across however many rows match, in case more than one ever exists) instead of guessing an id. This is agnostic to how Cut 1 was created.

**Found and fixed a second bug from the same root cause while in this code**: the invoice detail page's "Cut {n} already exists →" link (shown when trying to generate a month+cut that already has a row, any status) also recomputed `generateInvoiceId(...)` for its `href` instead of using the actual row's id — same silent failure mode, manifesting as a dead/wrong link instead of a wrong number, for a manually-created invoice. Fixed by changing `existingMonthCuts` from a `Set` of `"{month}-c{cut}"` keys to a `Map` keyed the same way but valued with the row's real `id` (now also selected in that query), and pointing the link at `existingMonthCuts.get(cutKey)`.

**Important limitation, told to the user directly**: this fix only prevents the bug going forward. Any invoice already generated with the wrong (too-low) PAG-IBIG MP2 amount has that wrong number permanently baked into its stored `items` snapshot — the code fix does not retroactively correct it. Gave the user a read-only diagnostic query (`cross join lateral jsonb_array_elements(items)` filtering `perk_pagibig` items with `cut1HoursBase = 0` where a real Cut 1 invoice does exist for that agent+month) to find which already-created invoices need manual correction or regeneration.

Lint and `npm run build` clean. This is pure application-code logic living in page components, not the pure functions in `invoiceLogic.js` — no fixture test possible in isolation; verifying it for real requires an actual manually-created Cut 1 invoice in the live database, which the user needs to check post-deploy. No migration involved (no schema change).

**Standing/unresolved**: the night-differential report is still open pending the user checking the Night Differential toggle/rate/window in `Settings → Invoicing` and reporting back what it shows.

---

## Night differential root cause: the toggle was simply off (this session, 2026-08-05)

**Resolved**: the user checked `Settings → Invoicing` and found Night Differential's "Enabled" checkbox unchecked (Rate 10%, Window 22–6, otherwise correctly configured) — confirmed the single most likely hypothesis from the earlier report. They turned it on themselves. Confirmed via `git diff` against every commit made this session that no night-differential calculation code (`nightDiffHoursForShift`, `computeWeekHours`) was touched at all this session — the only `invoiceLogic.js` changes were the earlier invoice-prefix fix and exporting `OFF_SHIFTS` for the unrelated scoring/ranking pipeline. Nothing to revert.

**Important caveat given to the user**: flipping the toggle only affects invoices generated from now on — there is no "recompute an existing invoice" feature anywhere in the app (`generateMissingInvoice` in `/invoices/[id]/page.js` only fires for a month+cut that doesn't have a row at all yet). Rubyrose's and Jurina's already-created July invoices still have the wrong ($0) ND baked into their stored `items` permanently. Fixing those depends on status: `draft` → delete and regenerate; `submitted`/`approved` → needs rejecting first to reopen it; `paid` → flagged as sensitive, won't touch without explicit instruction.

**Follow-up decision, confirmed via AskUserQuestion**: should the toggle be removed entirely (ND always-on, since it's a legally mandated pay component) or just have its *default* value changed to on while keeping the toggle for a genuine future override? User chose **keep the toggle, default to on**. Changed:
- `settings/invoicing/page.js`'s `newDefaultNightDiff()`: `enabled: false` → `enabled: true` (only affects the pre-load placeholder state and the merge-base for a brand-new/never-configured settings row — an explicitly saved `false` still loads and displays as off, the toggle still fully works).
- `invoiceLogic.js`'s `buildLineItems()` defensive fallback (`settings.nightDiff || {...}`, for the case `invoice_settings.night_diff` is somehow null/missing): was `{ enabled: false }` — silently produced ZERO night differential with no error if that column were ever null. Now `{ enabled: true, rate: 10, phWindowStart: 22, phWindowEnd: 6 }`, matching the settings page's own default exactly, so an absent config can never again silently mean "don't pay it."

**Verified with a real fixture test** (not just build success): `buildLineItems()` called with `invoiceSettings: {}` (no `nightDiff` key at all, simulating a missing config) against a single LATE shift (19:00–27:00 PH) now produces a real `nsd` line item worth 5 hours (the correct 22:00–03:00 overlap) — previously this exact input would have silently produced zero ND items. A second assertion confirms an explicit `{ enabled: false }` still fully suppresses it, so the toggle itself is unaffected by this change. Lint and `npm run build` clean.

No migration needed — this only changes code-level defaults and fallbacks, not the already-corrected live `invoice_settings` row (the user changed that value directly in the UI).

---

## Brand refresh: new KHAIZEN C3 logo lockup (this session, 2026-08-06)

**User request**: replace the app's logo with a new brand lockup, supplied as a complete spec — `README.md` (color/type/geometry spec), `KhaizenLogo.jsx` (a standalone plain-React/inline-style reference implementation), and `khaizen-mark.svg` (the badge alone). Circular blue-gradient badge with an ascending-bars "K" monogram (reads as *kaizen*, continuous improvement) + a silver-gradient "KHAIZEN" wordmark + a blue-gradient "C3" product mark + a "CUSTOMER SERVICE / COMMAND CENTER" descriptor.

**Previous logo** was a single flat raster, `public/khaizen-logo.png` (4376×4376), referenced via `next/image` in 8 places: 3 spots in `AppShell.js` (loading-skeleton sidebar header, session-error fallback header, real sidebar header) and 5 auth pages (login, forgot-password, reset-password, mfa-enroll, mfa-challenge) where the badge sits next to a page-specific heading, not a full lockup.

**Ported to this app's own conventions rather than pasted verbatim**: the supplied `KhaizenLogo.jsx` uses inline styles and a manual Google Fonts `<link>` for Space Grotesk. This app already loads Space Grotesk via `next/font/google` in `layout.js` (exposed as Tailwind's `font-display`) and uses Tailwind utilities everywhere else — so the new `src/components/KhaizenLogo.js` uses `bg-clip-text`/`bg-gradient-to-*` for the gradient text and an arbitrary-value `drop-shadow-[...]` for the badge glow instead of inline styles, keeping only the actual SVG `<defs>`/gradient markup (which really does need to stay literal SVG) and the `idPrefix` prop (needed because `AppShell` mounts a mobile-drawer sidebar AND a desktop sidebar in the DOM simultaneously — CSS hides one per breakpoint, but both exist, so reusing one gradient id would make the second instance's gradients silently do nothing).

**Two exports**: `KhaizenMark` (badge only) and default `KhaizenLogo` (full lockup, `variant: 'default'|'compact'`, `theme: 'dark'|'light'`, matching the spec's sizing table via a `size` prop that scales every measurement proportionally).

**Applied**:
- `AppShell.js`'s 3 spots → full `<KhaizenLogo />` for the two real sidebar headers, `variant="compact"` for the slim session-error fallback bar. Removed the now-unused `next/image` import.
- The 5 auth pages → swapped only the mark (`<KhaizenMark size={32} />` in place of the old `<Image>`), left each page's own heading/subtitle untouched — these aren't "the sidebar," just a badge next to a page title.
- **Regenerated the actual icon files from the new mark** (no image-processing library installed, so rasterized via headless Chromium/Playwright — already available in this session's scratchpad from earlier work — rendering the SVG at each exact target size and screenshotting with a transparent background): `src/app/icon.png` (favicon, 4376×4376), `public/icons/apple-touch-icon.png` (180×180), `public/icons/icon-192.png`/`icon-512.png` (PWA manifest icons), and `public/khaizen-logo.png` itself (same filename/dimensions, new pixels) — kept that file and its filename specifically so `supabase/email-templates/invite-user.html`'s `<img src="/khaizen-logo.png">` (email clients can't render inline SVG/React) picks up the new design with zero template changes.
- Added `public/khaizen-mark.svg` as a standalone static asset (verbatim from the supplied file), per the spec's own note that it's meant for favicon/avatar/email-signature use outside the app.

**Verified, not just built**: rendered the actual login page and confirmed the new badge shows correctly with zero console errors (desktop + mobile viewports). To verify the full sidebar lockup itself — unreachable via screenshot in this environment since there's no test login session — created a throwaway route (`src/app/logo-preview-scratch/`) rendering every variant/size/theme side by side, screenshotted it, confirmed against the spec (full lockup, compact, four badge sizes with the sub-24px simplification kicking in correctly, light theme), then deleted the route entirely — confirmed via `git status` afterward that it left no trace. Lint and `npm run build` clean.

---

## New role-aware Home Dashboard (this session, 2026-08-06)

**User request, in the user's own words**: an overview page for everyone — agents see their own status in one look; each admin (Quinty/Andrew/Dominic/Berry/Edwin) sees a dashboard scoped to exactly what they have access to, "so that nothing is being left [unnoticed]." Also, mid-build: "make it more interactive and not boring... worth 10,000 dollars" — addressed via richer per-card visuals (progress bars/charts/sparklines), not a different color scheme.

**Went through a full plan-mode cycle** (one Explore agent — confirmed which "pending" signals already exist per feature before designing any card) plus two rounds of `AskUserQuestion` before writing code. Confirmed:
- This becomes the new post-login landing page (`/` now redirects to `/dashboard` instead of `/daily`), not just an extra nav link.
- Agent section: time off balance + pending requests, current invoice status, latest rank, upcoming schedule.
- Admin section is **systematic, not hand-picked per person**: one card per permission the signed-in user actually holds, mirroring `AppShell.js`'s own `isAdmin || byFeature.X` gating pattern — so a newly-granted permission automatically gets its card later, with zero per-person code to maintain.

**New page `src/app/dashboard/page.js`**. Two independently-shown, stacked sections (not a role fork — Edwin/`agent_042` is both an admin and an agent, and sees both):
- **"Your Overview"** (shown to anyone with an `agent_id`): Time Off card (`computeBalance()` from `timeOffLogic.js`, same fetch `/time-off` itself uses, plus the "Allowance used" progress bar and `MonthlyBarChart` built for that page earlier this session); Current Invoice card (looked up by `agent_id`+`month`+`cut_number` — **not** `generateInvoiceId()`, learning directly from the manual-invoice/PAG-IBIG bug fixed earlier this session — plus a `Sparkline` of the last 5 cuts' totals); Latest Rank card (calls the existing `/api/team-standings` route, which already computes this month's whole-team ranking server-side since a regular agent's own RLS-scoped session can't do that cross-agent normalization itself — confirmed via Explore rather than assumed, no new endpoint needed); Upcoming Schedule card (next 5 `schedule` rows, `schedule_select_all` already permits reading your own).
- **"Needs Your Attention"** (shown to anyone holding `admin` or any write permission): one `StatCard` tile per permission, each a real live count, not a static label — `invoicingWrite`→submitted-invoice count (identical query to `AppShell`'s own `submittedCount` badge), `timeOffWrite`→pending + cancellation-request counts combined, `performanceWrite`→agents not yet QA-scored this month (roster from `/api/agent-roster` minus this month's `qa_scores`, a derived count that didn't exist as a query anywhere before), `rateScheduleWrite`→whether the current cut's USD rate has been logged yet (`rate_history` lookup, same shape `usd-rate/page.js` already uses). `coaching` write intentionally gets a plain link card with no live number — confirmed via Explore that no cheap indexed "needs follow-up" signal exists on `agent_notes` today (would require pulling every note plus a per-note reply-exists check), and a fake/expensive number wasn't worth adding just for a dashboard tile. `admin` bypasses every specific flag, same as everywhere else in this app.
- "What cut is today" has no existing shared helper anywhere in the app (confirmed via Explore — every other page either hardcodes cut `1` or lets a human pick it; legacy's auto-day-based derivation was deliberately removed elsewhere as a "hidden mechanic"). Written as a small **local, read-only, display-only** helper inside this page only — a status card showing today's cut is a different concern from a form silently auto-selecting what gets billed, so this doesn't reopen that earlier decision.
- Access is fetched independently in this page (own `profiles`+`permissions` query), not shared from `AppShell`'s state — same convention every other gated page in this app already follows.

**Visual polish**, addressing the "not boring" feedback with existing primitives only (no new chart library, no new dependency): a time-of-day personalized greeting header; the Time Off card's progress bar + bar chart; a `Sparkline` (`src/components/Sparkline.js`, already built for other pages) on the invoice card; hover-lift/glow transitions on every card (all are real links to their full feature page, not decorative).

**`src/app/page.js`**: redirect target changed from `/daily` to `/dashboard` (one line). **`AppShell.js`**: added `{ href: '/dashboard', label: 'Dashboard' }` as the first item in the Operations group, ungated, so it's reachable again after navigating away — not just a one-time redirect destination.

**Verified beyond just build success**: rendered `/dashboard` for real (zero console errors, dark theme, desktop + 375px) — though with no session it only shows the empty/login-redirect state, same standing limitation as every other page this session. To actually verify the new visual treatment (progress bars, sparkline, bar chart, hover states, mobile stacking) looked right together, built a throwaway preview route with realistic mock data matching the real card markup, screenshotted both viewports, confirmed it reads as intended, then deleted the route — confirmed via `git status` it left no trace, same technique used for the logo work. Lint and `npm run build` clean. Real per-account data (do the counts/balances/rank actually come out correct against this account's live data) still needs a real look once deployed — no way to verify actual Supabase content without a live login session in this environment.

---

## Home dashboard v2 — rename to /home, view-as, richer layout (this session, 2026-08-06)

**User sent a reference screenshot** of a dashboard design they liked better, plus several concrete asks, confirmed via two rounds of `AskUserQuestion` before touching code: rename "Dashboard" to "Home" (**the URL too**, not just the label) with the sidebar logo linking there; keep the time-of-day greeting but make it feel less flat with a small animated icon (confirmed: a hand-built SVG, not a real generated GIF — no external asset/dependency, matches how every other visual in this app is already plain SVG); restructure "Needs Your Attention" from a tile grid into a single list card matching the screenshot's density (dot + count + title + real subtitle + "Open →" per row); add a secondary admin-only stat row (Payroll this cut / On shift now / Open approvals); and a **"View as {specific person}"** feature so the super-admin can preview any real person's exact Home dashboard.

**Went through a second full plan-mode cycle** (one Explore agent) specifically to verify the view-as feature's security story before writing a line of it — didn't want to guess on this one. Read `has_permission()`'s actual SQL body (migration 0040) and the real RLS `select` policies on `time_off_balances`/`time_off_requests`/`invoices`: **confirmed a real `is_admin=true` session already has an unconditional read bypass on all of them** — the `is_broad_reviewer` exclusion list (`invoicing`/`rate_and_schedule`/`coaching`/`time_off`, confirmed earlier this session) only restricts that separate broad-reviewer clause, not `is_admin`. So view-as needed **no new RLS policy and no service-role route** — it's a display-only convenience for interpreting data the admin can already legally see, not a new access grant. Also confirmed "Team CSAT" from the screenshot has no real data source anywhere in this app (only ticket-volume/response-time metrics and negative-review counts exist, no satisfaction score) — user confirmed to drop it rather than fabricate a number.

**Route rename**: `src/app/dashboard/page.js` → `src/app/home/page.js` (`git mv`), `src/app/page.js`'s redirect target updated, `AppShell.js`'s nav entry relabeled, and the sidebar's `<KhaizenLogo />` (the one real shared desktop+mobile sidebar instance — confirmed via reading the file there's only one, not two, despite responsive classes making it look like separate desktop/mobile chrome) wrapped in a `<Link href="/home">`.

**Greeting**: new `src/components/GreetingIcon.js` — sun / sun-behind-cloud / moon-with-twinkling-stars depending on time of day, plain SVG + Tailwind `animate-spin`/`animate-pulse` + staggered `animationDelay`, no library, no downloaded asset. The greeting's own hour-of-day now uses the viewer's plain browser-local `new Date()` — deliberately different from `todayISO()`/`curMonth`/`curCut`, which stay PH-anchored for the actual business-data queries (invoices, rate lookups) exactly as before; only the cosmetic greeting text changed clocks.

**"Needs Your Attention" → one list card**, each row backed by a real computed subtitle, nothing fabricated: Invoices to approve (₱ sum of submitted invoices' `total`, not just a count); Time-off requests ("across N agents", a distinct-`agent_id` count over the same pending+cancellation rows already fetched); Not yet QA-scored (up to 2 real missing agents' names via `/api/agent-names`, "+N more" beyond that); USD rate (only shown when NOT set for the current cut, subtitle = real days-since-last-`rate_history`-entry, not a bare "Not set"). Coaching notes stays a link-only row, unchanged reasoning from v1 (no cheap real "needs follow-up" signal exists). Rows share one card via `divide-y`, not separate bordered tiles — a new `attentionListClass()` reuses `cardClass()`'s exact visual tokens minus its baked-in padding (deliberately NOT done by appending a `p-0` override string, since Tailwind's generated CSS order isn't guaranteed to match className string order — that's a real footgun, avoided by just not fighting the base class).

**New secondary stat row**, gated on the real signed-in `access.admin` (matches the screenshot's explicit "Super Admin" framing) — all three genuinely computed, confirmed buildable from existing data before promising them: Payroll this cut (sum of `invoices.total` for the current month+cut, `submitted`/`approved`/`paid` only, drafts excluded as not-yet-finalized); On shift now (count of agents whose *today's* shift's PH time range, via `getShiftHoursPH`/`shiftDurationHours` already in `invoiceLogic.js`, currently contains the current PH time — handles cross-midnight shifts like `LATE` 19:00–03:00 correctly via the same `+24` wraparound check `nightDiffHoursForShift` already uses); Open approvals (a rollup of the row-list's own counts, not a separately-fabricated number).

**View as**: admin-only dropdown (`@/components/ui/Select`), sourced from `/api/admin-users` (confirmed admin-gated itself) + `/api/agent-names` for real display names — same combo `/settings/time-off` already uses. Selecting a person computes `effectiveAgentId`/`effectiveAccess` from *their* real profile+permissions shape (via a new shared `accessFromProfileAndPermissions()` helper so the real-self path and the view-as path can't silently drift into two different derivations of the same access object) and feeds those into the exact same personal/attention fetch effects — no forked logic. The greeting's name (`fullName`) is fetched independently, keyed to the REAL signed-in user only, specifically so it can never flip to the previewed person's name. A visible banner ("Previewing as {name} — read-only" + Exit button) makes the mode unmissable whenever active.

**Verified beyond build success**: rendered a throwaway preview route with realistic mock data covering every new piece together (animated icon, view-as dropdown + active preview banner, the row-list, the secondary stat row) at desktop and 375px, confirmed it reads well and matches the reference screenshot's density, zero console errors both times, then deleted the route — confirmed via `git status` it left no trace, same technique used twice already this session. Lint and `npm run build` clean. Real per-account numbers for all the new derived signals (payroll sum, on-shift count, QA-missing names, rate staleness) still need a real look post-deploy — no live session available in this environment to confirm against actual data.

**Same session, separate small cleanup**: user flagged that `/performance`'s "Admin Bonus" section (a per-date bulk hours→points editor) was a genuine duplicate of `/daily`'s inline per-agent-row editor — both wrote the exact same `commslayer_reports.admin_bonus_points` column via the shared `src/lib/adminBonus.js`, confirmed by reading that file's own doc comment before touching anything ("Shared between /performance's per-date bulk editor and /daily's inline per-agent-row editor"). Removed the section and all its now-dead state/effects/handlers from `performance/page.js`; also removed the now-fully-unused single-entry `saveAdminBonusHours()` from `adminBonus.js` itself (confirmed zero remaining call sites first) — `hoursToPoints()` and the batch version `/daily` actually uses stay untouched. Lint, build, and a live-rendered console-error check on `/performance` all clean.

---

## View As goes app-wide, and becomes strictly read-only everywhere (this session, 2026-08-06)

**User interrupted mid-push with a new reference screenshot**: the View As switcher living in the persistent top bar app-wide, with the **sidebar nav itself collapsing** to match whoever's selected — "as I change the view I wanted to know what are the menu bar they only have access too same as on the design." Confirmed via two rounds of `AskUserQuestion`: (1) app-wide, not Home-only — filters sidebar nav plus every page; (2) "full preview everywhere" — every page renders as that person would actually see it, not just sidebar+Home.

**A third, more consequential confirmation came from a risk I found mid-implementation, not from the user's original ask**: reading `invoices/new/page.js` and `settings/invoice-profile/page.js` while patching them surfaced a real bug shape — "reads render as the previewed person" is safe (RLS already has a real `is_admin` bypass, confirmed per-table below), but a **write** fired while previewing would still execute for real, under the *admin's own* identity, potentially against the previewed person's `agent_id`. Surfaced this explicitly before writing any more page patches; user's answer — **disable every write action (save/submit/approve/deny/edit) on every page while previewing, so it's truly read-only everywhere** — became the binding safety rule applied to literally every one of the ~20 remaining files.

**RLS confirmation, done properly before writing the rollout plan, not assumed**: Postgres RLS is keyed to the real authenticated `auth.uid()` — the client can't spoof that by changing a query filter, so "view as" only works safely on tables where the real admin's session *already* has a genuine RLS read bypass. Re-read `has_permission()`'s actual SQL body and confirmed its `is_admin` branch is an unconditional OR, exempt from the `is_broad_reviewer` exclusion list — individually reconfirmed for every table this rollout touches (`invoices`, `time_off_balances`, `time_off_requests`, `schedule`, `invoice_profiles`, `qpi_qualifications`, `agent_notes`, plus `qa_scores`/`trustpilot_scores`/`penalties`/`commslayer_reports` following the identical pattern from this session's own prior work). **Nothing here needed a new policy or a service-role route** — pure display-layer convenience over data the admin can already legally read. One real, separately-confirmed exception found during the audit: `invoices_insert_own` (migration 0008) is **strictly self-only**, `agent_id = current_agent_id()`, with **no admin bypass** — meaning `invoices/new`, `invoices/new-manual`, and the `generateMissingInvoice` helper inside `invoices/[id]/page.js` would genuinely RLS-fail (not just "look wrong") if a write fired with a swapped `agentId`. This is exactly the kind of risk the write-disable rule above was built to close, and is called out inline in `generateMissingInvoice`'s own code comment.

**Architecture**: `AppShell.js` was split into an outer data-owning `AppShell` and an inner `ShellChrome` that consumes the new context — a component can't provide and consume the same React Context in one render scope. New `src/lib/access.js` exports one canonical `accessFromProfileAndPermissions(profile, permsByFeature)`, used by both the real-session derivation and every previewed-target derivation, so the two paths can never independently drift (modeled directly on this project's own earlier `OFF_SHIFTS` drift-bug lesson). New `src/lib/ViewAsContext.js` (`ViewAsProvider` + `useViewAs()`) fetches `/api/admin-users` + `/api/agent-names` once, owns the picker's `targetId` state, and exposes `{ effectiveAgentId, effectiveAccess, target }` to the whole tree. The admin-only picker and the "Previewing as {name} — read-only · Exit" banner now live in `ShellChrome`, between the header and every page's content — not duplicated per-page.

**Rollout pattern, applied to all ~20 remaining pages** (self-scoped: `my-performance`, `my-qpi`, `settings/invoice-profile`; roster-wide: `invoices` list, `history`, `trends/[category]`, `daily`, `qpi`, `performance`, `settings/performance`, `settings/invoicing`, `settings/usd-rate`, `settings/time-off`, `schedule`, `settings/users`, `invite`; mixed: `invoices/[id]`, `handover`, `coaching`, `time-off`): derive `viewedX = effectiveAccess ? effectiveAccess.someFlag : localFlag` and `viewedAgentId = effectiveAgentId ?? localAgentId`, substitute every downstream gate/query/effect-dependency usage, then guard every write handler with `if (isPreviewing) return` (placed *after* any `e.preventDefault()` — got this backwards once on `performance/page.js`'s `addPenalty`, caught it in the diff before moving on) and add `|| isPreviewing` to the relevant `disabled=` props. `invoices/page.js` had no local access flag to swap (it relies purely on RLS), so it got a new explicit client-side `visibleInvoices` filter instead — the one page in this rollout doing row-filtering rather than gate-swapping. `team-standings` was explicitly skipped (self-agentId only drives the "YOU" highlight — low value). `settings/security` stays permanently excluded — its `supabase.auth.mfa.*` calls are bound to the real session with no "on behalf of" concept.

**Two components needed a `disabled` prop threaded in from their parent** rather than calling `useViewAs()` themselves: `CoachingClient.js`'s `ReplyForm`/`NoteCard`, `HandoverClient.js`'s `EntryCard`. **`settings/users/page.js`** (the most sensitive page in the rollout) got a code comment noting its 6 write handlers route through service-role endpoints that check the *real* caller's `is_admin` and have no concept of View As — so the client-side `isPreviewing` guard is the only actual protection there, not a redundant belt-and-suspenders check.

**Caught and fixed one regex-rename regression before it shipped**: a bulk rename of `canManage` → `viewedCanManage` in `CoachingClient.js` accidentally also renamed the JSX shorthand boolean prop `canManage` (i.e. `canManage={true}`) at the admin `<NoteCard>` call site, while `NoteCard`'s own same-named destructured parameter correctly stayed untouched (different scope) — this would have silently passed `canManage={undefined}` at runtime, breaking the admin Delete button with no error. Caught by inspecting the diff immediately after the edit, fixed by rewriting that JSX line explicitly.

**Accepted, not fixed, as a deliberate time-tradeoff**: `schedule/page.js`'s full month grid of per-cell `<select>`s and `invoices/[id]/page.js`'s 930+ line action-button set don't have `disabled` on literally every individual control — the handler-level `isPreviewing` guard is fully safe (a click just silently no-ops), but a control can still *look* clickable. Noted inline where relevant; not a functional gap, purely cosmetic.

**Verified**: `npm run lint` and `npm run build` clean across the full ~20-file batch. Headless-Chromium check (one page per shape — `my-performance` self-scoped, `daily` roster-wide, `invoices/[id]` mixed — plus `settings/security` as the must-be-unaffected control) at 1280px and 375px: all four return zero console errors and correctly redirect to the dark-themed login gate (same standing limitation as every prior check this session — no live session available here, so confirming previewing actually surfaces another real person's real data needs a look from the user post-deploy). Committed in two groups per the approved plan: `6ef766c` (AppShell + Context + Home migration) and `adf8105` (the remaining ~20 pages).

---

## Home's Upcoming Schedule redesign + in-app notification feed (this session, 2026-08-06)

**Two requests, same visit**: the plain "date + shift label" list on Home's Upcoming Schedule card looked bland; separately, the user wants important events — payday, a new QA score, a Trustpilot update, a schedule change — to actually surface on Home instead of requiring a visit to each page to notice them, described as "pops up there and when [I've] seen it, it will be gone," plus real phone push notifications for the installed home-screen PWA with a custom sound.

**Set expectations before building the bigger ask**: flagged two real platform constraints up front rather than discovering them mid-build — (1) Web Push doesn't support app-picked custom notification sounds on iOS Safari at all, and modern Android/Chrome mostly ignore it too (OS plays its own default), so "specific sound" isn't realistically deliverable; (2) real device push is genuinely separate infrastructure (service worker, VAPID keys, a subscriptions table, a send trigger) from an in-app feed. Confirmed via `AskUserQuestion` to sequence it: **in-app notification feed now, real push as a later follow-up.**

**Schedule card redesign**: replaced the flat `weekday, date — shift label` rows with a colored date chip (3-letter weekday + day number), a **Today**/**Tomorrow** relative label alongside the full date, the actual shift time range (new `shiftTimeRangeLabel()`, sourced from `invoice_settings.shift_hours` — same column `/schedule` and the existing on-shift-now admin stat already read), and a category-tinted pill instead of plain text — four families (working/holiday/leave/off), colored consistently (`SHIFT_CATEGORY_STYLE`), matching `schedule/page.js`'s own `SHIFT_GROUPS` grouping. `CUSTOM_HH:MM-HH:MM` shifts now render as a friendly formatted range (e.g. "2 PM – 10 PM") in the pill itself too, via the same new `formatHourPH()` helper, instead of the raw `14:00-22:00` string. Since this reads off the page's existing `effectiveAgentId`-driven fetch, it improves automatically for both a real self-view and any View As preview target — no separate code path.

**Notification feed, built as derived data rather than a new events log**: rather than hooking into every write path that could produce a notification (QA save, schedule save, invoice-paid action, Commslayer sync) — a much bigger, riskier surface — items are computed on Home's load by comparing each source table's own timestamp against a per-user "last seen" bookmark: `invoices.paid_at` (payday), `qa_scores.updated_at`/`trustpilot_scores.updated_at` for the current month (new score/review), and `schedule.updated_at` on any future-dated row (schedule change). No new hooks needed anywhere else in the app. Trade-off accepted: QA/Trustpilot checks are current-month-only, matching every other current-month-only check already on this page (`rateSetForCut`, `perfMissingCount`) — a same-day correction to last month's score wouldn't surface, judged not worth the extra query for a first pass.

**New table, migration `0043_notification_reads.sql`**: `notification_reads(user_id uuid primary key, last_seen_at timestamptz)`. Deliberately its own brand-new table rather than a column on `profiles` — `profiles` has had **no self-update policy at all** since migration 0008, by explicit design (it holds `is_admin`/`is_broad_reviewer`/`agent_id`; a self-update path there would be a real privilege-escalation surface). This table holds nothing sensitive, so a narrow self-only policy (`user_id = auth.uid()` for select/insert/update) is safe to add fresh. Added one extra `select`-only bypass for `is_admin` (mirroring the exact admin-check expression from `has_permission()` in migration 0040) so View As previewing — read-only everywhere, this session's governing rule — can show what the previewed person would actually see, rather than substituting the real admin's own bookmark; the previewed person's row is otherwise unreadable to anyone else under the self-only policies. No update/insert bypass for admin — marking-as-seen while previewing is blocked client-side (`isPreviewing` guard in the fetch effect, consistent with every other write in the View As rollout) before it would even hit that RLS wall.

`target.id` (from `ViewAsContext`, sourced from `/api/admin-users`) is the previewed person's real `profiles.id`/`auth.users.id` — exactly the key `notification_reads` needs — so `effectiveUserId = viewAsTarget?.id || session?.user?.id` swaps correctly for a previewed target with no extra plumbing.

**UI**: a fixed top-right toast stack (`NotificationToast`), one card per event — a colored icon bubble per category (payday=emerald, QA=sky, Trustpilot=violet, schedule=indigo; four small hand-built inline SVG icons, no emoji, no external asset, matching this app's existing plain-SVG convention from `GreetingIcon`), a slide-in-from-the-right entrance transition, a manual × dismiss, and an automatic dismiss after 9 seconds — matching the "pops up, then gone" request literally. Clicking a toast navigates to the relevant page. The DB bookmark update happens once, right after computing the list (using the *old* bookmark value captured before the write), so there's no race between "what just got shown" and "what's now marked seen."

**Verified**: `npm run lint` and `npm run build` clean. No live session available to trigger the real fetch effect, so — same technique used repeatedly this session — built a throwaway preview route rendering the actual toast markup/classes against mock items, screenshotted desktop (confirmed icon colors, spacing, entrance animation) and mobile (confirmed stacking width), confirmed the × dismiss actually removes a card (4 → 3), then deleted the route — confirmed via `git status` it left no trace. Real per-account behavior (does a real payday/QA-score/schedule change actually produce the right toast, does the bookmark correctly suppress it next visit) still needs a live look post-deploy, once the migration below has actually been run.

**Migration printed for manual paste per the standing workflow — not yet confirmed run.** Real push notifications (service worker, VAPID keys, a subscriptions table, an actual send trigger) remain a deliberately separate, later phase.

---

## Legacy portal data import + invoice navigation/state fixes (this session, 2026-08-06)

**Legacy KHAIZEN Team Portal (Google Apps Script, still live) turned out to have no password on its main Schedule tab** — a plain fetch had returned an empty shell earlier because it's a client-rendered SPA; a real headless browser (Playwright) renders it fully. Extracted the actual per-agent shift roster (`data-orig` attribute on each cell's `<select>` — not the visible label text, which concatenates every `<option>` and reads as garbled "Origineel (LATE)" Dutch/legacy UI text) for Mon/Jurina/Mayvel/Kate/Ruby Rose, contiguous from Apr 20 through Dec 31 2026, 1,100 cells, zero unparsed.

**The "PTO Overview" tab was manager-password-gated** (user supplied the password, confirmed correct — "Toegang verleend"). Before writing anything, cross-checked its 9 agents' "Used" days against `time_off_balances.pre_migration_used` (seeded in migration 0040 from this same portal) — **every number matched exactly, zero drift**, confirming balances are already correctly migrated and don't need re-importing. The PTO Requests tab's 40 individual entries (5 pending, 16 approved, 19 denied/revoked) were a separate decision: importing the approved/denied ones as real rows on top of the already-baked-in `pre_migration_used` snapshot would double-count those days. Confirmed via `AskUserQuestion`: import **only the 5 pending** (so they become actionable in `/settings/time-off`), leave the rest inside `pre_migration_used` untouched. One of the 5 ("Mon — Sep 2–Aug 1, 2026 (0 day)") has an end date before its start date — a genuine data-entry error in the legacy tool itself, which never validated the range — flagged and excluded rather than guessed at; only 4 imported.

**Migration `0044_import_legacy_schedule_and_pending_time_off.sql`**: idempotent `schedule` upsert (on conflict do update, safe to re-run) + 4 `time_off_requests` inserts, with a `do $$ ... $$` guard verifying exactly 1100 schedule rows and 4 pending requests landed. Printed for manual paste per the standing workflow; not yet confirmed run. Known accepted side effect once it is: the next Home visit for those 5 agents will show a "Your schedule was updated" toast (capped at 14 upcoming shifts by that feed's own query) — an accurate confirmation, not a bug.

**Separately, user flagged two real UX bugs while testing**: clicking Home's "Invoices to approve" row landed on the plain `/invoices` list defaulted to the current month, not the actual month with something waiting — and navigating into an invoice and back reset the filter to defaults every time ("does not bring me back to zero but where I left off"). Root cause: `/invoices`' `month`/`statusFilter` were pure component state, never reflected in the URL, so neither a deep link nor browser-back could restore them. Fixed by making the URL the source of truth (`?month=&status=`, read on mount via `useSearchParams` — wrapped in `Suspense` per this app's existing `mfa-enroll` convention — written back via `router.replace` on every change) and switching the detail page's "← All invoices" links and post-delete redirect from a hardcoded `router.push('/invoices')`/`<Link>` to `router.back()`, which now returns to the exact filtered URL instead of a reset default. Home's "Invoices to approve" row now links to `/invoices?month=<earliest submitted month>&status=submitted` instead of a bare `/invoices`.

**Third bug, same testing pass**: adding a new invoice line item defaulted its Rate input to a literal `0`, so the box showed "0" until manually cleared instead of starting blank. `addEditRow()`'s `rate: 0` → `rate: ''` — the amount recompute already used `parseFloat(next.rate) || 0`, so an empty string was already handled safely; only the initial value needed to change.

**Verified**: `npm run lint` and `npm run build` clean. Confirmed `/invoices?month=...&status=...` loads with zero console errors (redirects to login, no live session — same standing limitation). Did not find or fix every other page that might show a stray "0" in a blank numeric field — only the one instance identified in this pass.

**0044's guard was wrong on the first paste, fixed live**: the first run aborted with "expected 1100 schedule rows... found 1111" — 11 rows for these exact agents already existed in that date range before this import (old seed/test data on dates the scrape didn't produce, e.g. a Sunday), and the guard's strict `<>` comparison rolled back the ENTIRE transaction over it, including the otherwise-correct 1100-row insert and the 4 pending requests. Changed both guards from `<>`/exact-match to `>=`/at-least, since their job is to catch a real failure, not demand the tables were empty beforehand. Re-ran clean; user-confirmed live counts: 1111 schedule rows, 4 pending requests.

**Same USD-rate deep-link friction found on `/settings/usd-rate`** as the invoices fix above: `periodMonth` always started at `''` regardless of how you arrived, so Home's "USD rate — Cut X not set" row landed on a blank add-form requiring manual re-entry of the exact same month/cut the row already named. Fixed identically — `periodMonth`/`cutNumber` now read from `?month=&cut=` (`useSearchParams`, wrapped in `Suspense`), and Home's href became `/settings/usd-rate?month=<curMonth>&cut=<curCut>` instead of a bare link. Lint, build, and a zero-console-error live check all clean.

---

## Notification cards replace the floating toast; PWA zoom locked (this session, 2026-08-06)

**Direct feedback after seeing the toast-based notification feed live** (a real screenshot of the deployed Home page, first time this session a feature was reviewed against actual production data rather than mock data): the fixed-position, auto-dismissing toast stack from earlier this session doesn't have a natural resting place while just browsing the page, and a floating overlay is awkward on a small phone screen specifically. User's own framing: a card, in the page itself, that visibly signals "new" without being an alarming warning, and only disappears once explicitly acted on ("if it's selected, that means it's already seen, so it can vanish afterwards").

**`NotificationToast` → `NotificationCard`**: same underlying data (payday/QA/Trustpilot/schedule, derived from existing tables vs. the `notification_reads` bookmark — completely unchanged, this was a presentation-only swap) now renders as a real card in a new "What's New" section between the greeting and "Your Overview," using the exact same `attentionCardClass()` visual language as every other Home card instead of a `position: fixed` overlay. Each card carries a small soft "New" pill (category-colored, a plain dot + text, not a red/alarming style) instead of a border-glow or a badge count. Removed the toast's 9-second auto-dismiss entirely — a persistent page card disappearing on its own mid-read would be a bug, not a feature; a card now only vanishes when clicked through or explicitly dismissed via ×, both of which fade+scale it out before removing it from the grid (matching the "vanish once seen" framing word-for-word). The `notification_reads` bookmark write (marking things seen server-side) is unchanged — it still happens once, at load, using the *old* bookmark value, exactly as before; only the visual disappearance is now tied to explicit interaction rather than automatic.

**PWA pinch-zoom lock**: `layout.js`'s `viewport` export was previously just `{ themeColor }`, leaving Next.js's bare default meta (no explicit zoom control) — added `width: 'device-width', initialScale: 1, maximumScale: 1, userScalable: false`, which is the standard mechanism for disabling pinch/double-tap zoom, explicitly requested so the installed home-screen PWA renders "intact" on a phone rather than drifting in and out of zoom from incidental taps. This applies globally (browser tab or installed PWA alike) since there's no separate meta tag for standalone-only — accepted tradeoff, explicitly requested ("as long as it's clear and readable").

**Verified**: `npm run lint` and `npm run build` clean. Built a throwaway preview route with the real card markup + mock items (same technique used repeatedly this session), confirmed the grid layout, entrance animation, and manual dismiss (4 cards → 3, remaining cards reflow cleanly) at both a desktop width and a 390px mobile width, zero console errors, then deleted the route — confirmed via `git status` it left no trace. Did not attempt to verify the zoom-lock's actual on-device feel (pinch gestures aren't something a headless browser check can exercise) — that needs a real phone, ideally with the app actually added to the home screen as the user described.

---

## Payday reminder notification + collapsible sidebar (this session, 2026-08-06)

**New 5th notification type: "Payday is tomorrow"**, added after the user pointed out the Contractor Service Agreement's Article 4 (per a separate AI chat's answer, screenshotted) states the Service Fee is payable on the 2nd and 4th Friday of each calendar month, and that invoices are actually processed the day before (Thursday) — one day ahead of that contract date. Unlike the other four notification types (all derived from a real row's timestamp changing), this is a pure calendar fact with no table behind it, computed directly via `upcomingPaydayFridayISO()`. Every date computation in it goes through `T00:00:00Z` + UTC methods (`addDaysISO`, `fridaysInMonth`) — never a bare `new Date(y,m,d)`/`.getDay()`/local `.toISOString()` round-trip — matching `invoiceLogic.js`/`timeOffLogic.js`'s own established convention for exactly this reason: local-timezone round-tripping can silently shift a calendar date by a day depending on where the code executes. Slots into the exact same "new since last visit" comparison as the other four types by giving it an `at` of *today's* start (not "right now") — so it shows once on the qualifying Thursday, then correctly stays suppressed for the rest of that same day once the bookmark advances, instead of firing on every page load.

**Collapsible sidebar**, requested after the user reviewed a live screenshot of the mobile drawer and asked for "the main page" (the sidebar) to collapse "in a clean way, not awkward." Desktop-only affordance (`collapsed` state in `AppShell.js`, persisted to `localStorage` under `c3-sidebar-collapsed`, read via a lazy `useState` initializer so a returning user's preference applies on first paint) — the mobile slide-in drawer is a separate concern and is never collapsed, since something that's only ever fully open or fully hidden has nothing meaningful to collapse to.

Collapsing to an icon-only rail required something to actually SHOW per row once labels disappear — added one hand-drawn `NavIcon` per nav item (22 total, same plain-SVG/no-library convention as `GreetingIcon`/`NotifIcon`), switched on a new `icon` key added to every `GROUPS` entry. Group header labels (OPERATIONS/INSIGHTS/etc.) collapse to a thin divider line instead of vanishing outright, so the grouping itself doesn't disappear, only its label. A small round chevron button straddles the sidebar's right edge (the same "floating rail handle" pattern VS Code/Linear/Notion use) — flips 180° on toggle via a `ChevronIcon` component. Nav-badge counts (Invoices/Time Off Admin) become a small glowing dot in collapsed mode rather than a number, since there's no room for the digit itself. The logo swaps from the full lockup to `KhaizenMark` alone (icon-only) when collapsed, reusing the export already built for this exact purpose during the earlier logo-refresh work.

Width and content-padding both animate via a shared CSS variable (`--rail-w`, set independently on both the `<aside>` and the content wrapper, since they're siblings, not ancestor/descendant, so the variable has to be declared on each) rather than two independent Tailwind breakpoint classes drifting out of sync — `transition-[width]`/`transition-[padding]` at `duration-300 ease-out` keep the sidebar and the content shifting in lockstep. One real bug caught before shipping: the collapsed active-item pill first rendered as an edge-to-edge bar with no visible rounding (a `w-full` row inside tight `px-2` nav padding at only 76px total width left too little box for a 10px radius to read as a pill) — fixed by bumping the row's own vertical padding (`py-2` → `py-2.5`), which gave the `rounded-[10px]` box enough proportional size to clearly read as a floating rounded highlight instead of a flush bar, confirmed via a side-by-side before/after screenshot.

**Verified**: `npm run lint` and `npm run build` clean. Since `ShellChrome` isn't exported and needs a live session's `access`/`agentId` to render at all, built a throwaway preview route reproducing the real sidebar markup/classes verbatim with mock nav items (same technique used repeatedly this session) — screenshotted expanded, collapsed, and the post-fix collapsed pill close-up, confirmed the icon set renders correctly and the toggle actually flips state, then deleted the route. Did not verify the width/padding transition's actual smoothness by eye in a real browser interaction (only via static before/after screenshots) — the CSS itself is straightforward (`transition` + a CSS variable), but motion quality is something only a live look can fully confirm.

---

## Real device push notifications, phase 2 (this session, 2026-08-06)

**The user asked for the deferred phase 2**: the in-app "What's New" cards should also reach the phone as a real OS notification, with an actual permission prompt, and confirmed they want it to make sound. Re-flagged the same platform reality as before starting: Web Push doesn't support a custom sound on iOS Safari (or on most Android/Chrome either) — this gets a real notification with the OS's own default sound, not a custom jingle.

**Refactored first, built second**: extracted the notification-derivation logic (payday reminder / payday-just-paid / new QA score / Trustpilot update / schedule change) out of `home/page.js` into a new pure-function module, `src/lib/notificationLogic.js` — `computeNotificationItems()` plus the payday-Friday calendar math. This exists specifically so the client (in-app cards) and the new server-side push sender can never independently drift into disagreeing about what counts as "new," the same lesson this project already learned once with `accessFromProfileAndPermissions()`. `home/page.js` now imports from it instead of defining its own copy.

**New table, migration `0045_push_subscriptions.sql`**: `push_subscriptions(id, user_id, endpoint unique, p256dh, auth, created_at)`, self-only RLS (no admin bypass this time — unlike `notification_reads`, nothing about View As previewing should ever touch someone else's push subscriptions; that stays strictly real-session-only). Also adds `notification_reads.last_pushed_at`, a bookmark deliberately SEPARATE from `last_seen_at` (0043): `last_seen_at` only advances when someone opens Home, but push has to fire for people who haven't opened the app at all — reusing the same bookmark would mean either never pushing (gated on "already seen in-app") or re-pushing the same item forever (gated on nothing). A distinct bookmark, only ever advanced by the sender below, keeps the two concerns independent.

**Client side**: `src/components/PushNotificationPrompt.js`, rendered on Home right under the greeting. Checks browser support, current `Notification.permission`, and whether a subscription already exists, and only renders when there's actually something to offer (never nags after a real grant/deny/dismiss — dismissal persists to `localStorage`). On "Enable": requests permission, registers `public/sw.js` (a minimal service worker whose only job is showing a push notification and focusing/opening the right page on tap — deliberately no offline asset caching, a separate concern nobody asked for), subscribes via `pushManager.subscribe()`, and upserts the subscription directly via `supabase.from('push_subscriptions').upsert(...)` — no new API route needed for this half, since self-only RLS already allows it, same as `notification_reads`.

**Server side**: `src/app/api/cron/send-push/route.js`, triggered on a schedule by Vercel Cron (`vercel.json`, every 15 minutes — Hobby-plan cron frequency limits may apply; adjust the schedule string if Vercel rejects it). Same service-role `adminClient()` pattern as every other privileged route in this app (`/api/admin-users`, `/api/time-off/request-cancel`), just triggered by a schedule instead of a user action. For every subscribed user, re-derives the exact same items via `computeNotificationItems()` (server-side data, fetched with the admin client since this has to read across every user, not just one), sends via `web-push` (new dependency) to each of that user's devices, deletes any subscription `web-push` reports as gone (404/410 — browser data cleared, device unenrolled), then advances `last_pushed_at`. Guarded by a `CRON_SECRET` bearer-token check (the standard Vercel Cron pattern) so the endpoint can't be triggered by an arbitrary internet request.

**Real VAPID keypair generated** (via `web-push generateVAPIDKeys()`) for this deployment — not a placeholder. **Three env vars still need to be added in the Vercel project settings before any of this actually sends anything**: `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` (server-side, keep secret), `NEXT_PUBLIC_VAPID_PUBLIC_KEY` (same value as the public key, client-exposed on purpose — the browser needs it to call `pushManager.subscribe()`), and `CRON_SECRET` (any random string, shared between Vercel's cron config and the route's own check). None of these exist yet on the live deployment; the prompt and the cron route both fail gracefully with a clear error until they're set, rather than silently doing nothing.

**Verified**: `npm run lint` and `npm run build` clean, `/api/cron/send-push` registers as a dynamic route. `npm install web-push` surfaced 6 pre-existing high-severity `npm audit` findings — all in Next.js/eslint-config-next/postcss's own transitive deps, unrelated to `web-push` itself and nothing to force-upgrade mid-feature (that would mean bumping Next.js to a breaking major version as an unannounced side effect). Confirmed `PushNotificationPrompt` renders without console errors via a throwaway preview route (deleted after); headless Chromium reports `Notification.permission` as `denied` unconditionally regardless of granted context permissions (a known headless-mode limitation, not a bug in the component's own logic), so the "eligible" card state was confirmed by overriding that one property via `page.addInitScript` rather than a real permission grant. Did not and could not verify actual push delivery, the permission-prompt real browser dialog, or on-device sound — none of that is exercisable without a real deployment with the env vars set and a real phone.

**Deployment postmortem — the first push actually deploying went wrong twice, both root-caused and fixed live:**

1. **`git push` alone never deploys** — the code sat committed locally (`93eea24`) for several exchanges while walking the user through adding env vars in the Vercel dashboard, mistakenly assuming a prior `git push origin main` had already gone out. Once actually pushed, no new deployment appeared in the dashboard at all — not building, not queued, nothing.
2. **Root cause, found via `vercel --prod --yes` from the CLI (already linked to the project via `.vercel/project.json`, already authenticated)**: Vercel outright rejected the deploy — `"Hobby accounts are limited to daily cron jobs. This cron expression (*/15 * * * *) would run more than once per day."` This explains why the dashboard showed nothing: the deploy failed validation before a build ever started, so it never got a row in the Deployments list at all — a silent-looking failure that would have been very hard to spot from the dashboard alone without the CLI's actual error output.
3. **Fixed**: `vercel.json`'s schedule changed from `*/15 * * * *` to `0 0 * * *` (once daily, midnight UTC = 8am PH) — still fully functional, just checks for new events once a day instead of every 15 minutes; revisit if the Vercel plan ever changes. Deployed directly via `vercel --prod --yes` rather than waiting on the GitHub webhook again, confirmed `Ready` and aliased to `c3-website-indol.vercel.app`; separately curl-confirmed `/sw.js` serves with a real 200.

**Real end-to-end confirmation, not just a build/lint pass**: user enabled push on their actual phone (real permission prompt, real browser dialog — none of which a headless check could ever exercise), then manually triggered `/api/cron/send-push` via the dashboard's own "Run" button, and **received a real push notification on-device**. This is the first feature this session verified against a genuine live signal from outside the development environment entirely, not a screenshot of mock data or a lint/build pass — the full chain (permission → subscribe → store → cron → web-push → service worker → OS notification) is confirmed actually working in production, not just theoretically wired up correctly.

---

## Sidebar section labels clarified (this session, 2026-08-06)

**User reviewed a mobile screenshot of the sidebar drawer** and asked for the four group labels (Operations/Insights/My Dashboard/Administration) to read clearly enough to reference in an instruction ("go check under Administration") — explicitly not louder/more dramatic, just more legible than the previous `text-10 text-ink-dim` treatment, which was small and dim enough to functionally disappear. Brought the label text closer to this app's own established section-header convention (`sectionLabelClass()`, already used for Home's "Your Overview"/"Needs Your Attention") — bumped to `text-11`/`text-ink-label` from `text-10`/`text-ink-dim` — and added a small accent-gradient tick beside each label so every group reads as a distinct anchor point at a glance, not just a dividing line of text. Applies uniformly to every signed-in user's sidebar (nothing here is role-gated; only *which* groups have any visible items already was, unchanged).

**Verified**: `npm run lint` and `npm run build` clean. Built a throwaway preview route at a 375px mobile width (matching the screenshot being reviewed) reproducing the real label markup, screenshotted it, confirmed all four labels read clearly without looking heavy, then deleted the route.

---

## Profile popover replaces the "My Dashboard" sidebar group (this session, 2026-08-06)

**User asked, in their own words**, for personal/account pages to live behind a click on the profile avatar instead of permanently occupying sidebar space — the common "click your avatar for an account menu" pattern (Slack/Notion/Linear/Vercel itself all do this) — explicitly anchored to the icon (not a dimming full-screen modal like the mobile drawer's own overlay) and explicitly "not dramatic."

**The entire "My Dashboard" group** (My Performance, My Invoice Profile, My QPI Status, Team Standings, Security) moved out of `GROUPS` into a new `ACCOUNT_ITEMS` array, rendered inside a popover anchored to the sidebar's bottom avatar row instead. Judgment call, flagged here rather than silently decided: all five were folded in together rather than cherry-picking which count as "real settings" (e.g. Team Standings is a leaderboard, not a configuration page) — the user's own framing treated the whole cluster as "my stuff," and moving all of it reads as a much bigger decluttering win than moving two or three. `getBreadcrumb()` gained a fallback check against `ACCOUNT_ITEMS` (section label "Account") so visiting one of these 5 pages directly still resolves a real breadcrumb instead of falling through to the empty default.

**Interaction**: clicking the avatar/name row toggles a popover (`profileMenuOpen` state) positioned `absolute bottom-full left-3` — opens upward since the trigger sits at the very bottom of the sidebar, with a fixed `w-60` regardless of collapsed/expanded rail width (so it stays fully readable even when the trigger itself is just a 76px-wide icon). Closes on: clicking outside (a plain `mousedown` document listener checking a ref, not a visible dimming backdrop — deliberately lighter-weight than the mobile drawer's own click-away overlay, matching "connected to the icon, not a heavier modal"), or on any navigation (`pathname` change). Same `visible(item.need)` filter as every other nav item — which of the 5 account pages actually show still depends on the signed-in person's own access, unchanged from before. "Sign out" moved from its own always-visible button into the bottom of this same popover.

**Verified**: `npm run lint` and `npm run build` clean. Built a throwaway preview route reproducing the real popover markup at both collapsed and expanded rail widths side by side, with real interactive state (not just a static screenshot) — clicked to open each, screenshotted both, then simulated an outside click via Playwright and confirmed via a text-content check that the menu actually closed, before deleting the route.

**Real bug, caught only once the user actually saw it live**: the popover used `bg-glass-bg-strong` (`rgba(255,255,255,.04)` — only 4% white, per `tailwind.config.js`) relying entirely on `backdrop-blur-md` to obscure whatever sat behind it. That works fine for a card sitting against a plain background, but on a real (shorter) sidebar the popover genuinely overlapped the nav list behind it, and a light blur over dense text isn't enough to hide it — the result was a garbled "double text" mess (screenshotted by the user), which also made "Sign out" — already present, just visually lost in the noise — look like it was missing entirely. The scratch-preview check that supposedly verified this earlier never caught it because its mock sidebar was tall enough that the popover never actually overlapped anything behind it — a real gap in that verification, not just bad luck.

**Fix**: switched to `bg-raised` (`#111117`, a genuinely solid color) + `shadow-glass-lg` + `backdrop-blur-xl` + an explicit `z-50` — the exact same combination `DateRangePicker.js`'s own popup already uses, per that file's own header comment explicitly describing this as the established pattern for "a solid (non-translucent) backing... needed behind a sticky/overlapping element." Should have reached for it the first time instead of defaulting to the same `bg-glass-bg-strong` every plain card on this page uses. Re-verified with a scratch preview specifically constructed to reproduce the overlap this time (a deliberately short sidebar so the popover opens over real nav text) — confirmed clean, no ghosting, "Sign out" fully legible — before deleting the route.

---

## Mobile horizontal-overflow audit (this session, 2026-08-06)

**User reported having to scroll right on mobile to see content**, and asked to check the app broadly rather than pinpoint one page (the screenshots attached with the report were actually from an unrelated third-party video, not the app itself — flagged and set aside rather than guessed at). Did a real audit instead of guessing: grepped every page with a `<table>` and every page with a pill-style tab bar, checking each against the two correct containment patterns this app already establishes elsewhere.

**Tables — found the exact mechanism, not just a guess**: `tableShellClass()` (`src/components/ui/Table.js`) sets `overflow-hidden` on the outer shell purely so its rounded corners clip cleanly; every correctly-built table in this app then adds a SECOND, inner `<div className="overflow-x-auto">` around the actual `<table>` for the scrolling itself — two layers, two jobs. Four tables were missing that inner layer entirely (`invoices/new/page.js`, `invoices/new-manual/page.js`, and two tables inside `invoices/[id]/page.js` — the line-items table and the "Billable Hours Breakdown" table, the latter using `whitespace-nowrap` on every one of its 5 columns, making it the single most overflow-prone table in the app). Without the inner wrapper, an overflowing table doesn't scroll — `overflow-hidden` on the outer shell just silently CLIPS it, hiding data with no way to reach it at all, arguably worse than the scrolling the user described. Added the missing inner `overflow-x-auto` div to all four. (`invoices/page.js`'s own list table was a false positive — it's already `hidden sm:block` with a separate `sm:hidden` mobile card list, an even better mobile treatment than a scrollable table.)

**Tab bars — the more likely actual culprit**: `invoices/page.js`'s 6-item status filter (all/draft/submitted/approved/rejected/paid, each with its own count badge), plus the tab bars on `time-off/page.js` and `settings/time-off/page.js`, were all a bare `inline-flex` with no `overflow-x-auto` and no `flex-wrap` — and critically, sitting inside a `flex`/`flex-wrap` parent row, a flex item's default `min-width: auto` refuses to shrink below its own content width, so an overflowing pill group doesn't clip or scroll on its own — it forces the WHOLE PARENT ROW, and with it the page, to become horizontally scrollable. This is the exact mechanism behind "have to scroll all the way to the right" and "cannot see the other tabs." Fixed by wrapping each pill group in an intermediate `<div className="min-w-0 max-w-full overflow-x-auto">` — the `min-w-0` is the specific part that lets the wrapper actually shrink inside its flex parent instead of refusing to, so ITS OWN `overflow-x-auto` gets the chance to activate instead of the overflow propagating outward. Schedule's two small 2-item toggles (Schedule/Holidays, Month/Week) were checked and left alone — short enough labels that they're not a realistic risk, and touching them would've been unjustified churn.

**Verified with a real reproduction, not just a lint/build pass**: built a throwaway scratch route mirroring `invoices/page.js`'s exact 6-item status-filter markup at a real 375px viewport, and specifically measured `document.body.scrollWidth` against `window.innerWidth` via Playwright — confirmed they're now exactly equal (no page-level overflow at all), where before the fix the pill group's true width would have pushed `body.scrollWidth` well past the viewport. Screenshotted the result too: the pill group visibly truncates at the viewport edge ready to scroll internally, while "Showing 24 of 24" and the rest of the page stay in place. Same technique used for the table fix — a scratch page deliberately narrow enough to reproduce the overlap/overflow condition, not one that happened to be too roomy to ever trigger it (the mistake made once already this session with the profile popover).

---

## Follow-up: Schedule's mobile card view still overflowed, plus a full sweep of every mobile card layout (this session, 2026-08-06)

**The user sent a real screenshot** (this time genuinely of the app, not another unrelated video) showing Schedule's mobile view scrolled sideways — agent names visibly truncated on their LEFT edge ("...tillero", "...alvacion", cut-off tails of real names), the "Holidays" tab clipped too, while the sticky header stayed in place. That last detail matters: the header staying put while content below shifted meant this wasn't `document.body` overflowing (the bug already fixed) — it was `<main>` itself (AppShell's scrollable content area, `overflow-y-auto` with no explicit `overflow-x`, which per CSS spec computes to `auto` on the other axis too once either axis is set) scrolling horizontally because something inside it refused to shrink.

**Root cause, different from the tab-bar bug but the same underlying CSS lesson**: Schedule's desktop grid (`hidden sm:block`) already has proper `overflow-x-auto` wrapping — that's not what rendered. The MOBILE-only card view (`sm:hidden`, a card per date with one row per agent: name + shift dropdown) was the actual culprit. Its row was `flex items-center justify-between gap-3` with a bare `<span>` for the agent name and a `w-36` `Select` for the dropdown — neither flex child had `min-w-0`, so the name refused to shrink below its own text width for a long name (Jonalyn Castillero, Christian Salvacion, etc. — real names visible in the screenshot), pushing the row, the card, and `main` itself wider than the viewport.

**Given this was the SECOND time the same class of bug ("a flex row's text side has no `min-w-0`/`truncate`, so a long name blows out the layout") showed up in different components, did a full sweep instead of just patching Schedule** — grepped every `sm:hidden` mobile-card block across the app (13 files) and read each one individually rather than trusting a shallow grep count (which had already proven unreliable once this same session — a naive `awk` scan of "the first 40 lines after the first match" completely missed 3 of `settings/time-off/page.js`'s 4 separate `sm:hidden` blocks, since they're spread hundreds of lines apart). Found and fixed the identical pattern in: `schedule/page.js` (the reported bug), `settings/users/page.js` (email/agentId block), `settings/usd-rate/page.js` (rate entry, including a genuinely free-text `note` field), `my-performance/page.js` (penalty reason), `team-standings/page.js` (agent name + rank badges, nested two flex levels deep so `min-w-0` had to be applied at both levels for `truncate` to actually take effect), `qpi/page.js` (incentive label), `invoices/page.js` (invoice number/agent line), `time-off/page.js` (request reason), `daily/page.js` (agent name + OFF-flag badge), `history/HistoryClient.js` (agent name + flag badge), `performance/page.js` (penalty reason). Each fix follows the same shape: `min-w-0 flex-1` (or just `min-w-0` where nested) on the text-bearing side, `truncate` on single-line identifiers, `break-words` on genuinely free-text fields (notes/reasons — worth reading in full, not worth losing to an ellipsis), and `flex-none` on the fixed-size control side so it never gets squeezed instead.

**Explicitly checked and left alone, not missed**: `handover/HandoverClient.js`'s mobile row (both sides already short/fixed-width — a date and shift-code pair, genuinely low risk); `history/HistoryClient.js`'s second `sm:hidden` block and `settings/time-off/page.js`'s first three of four blocks (all already block-stacked layouts with no `justify-between` row at all, or already using `flex-wrap` — both are the other correct way to avoid this bug, and were already doing so); Schedule's two small 2-item toggles (Schedule/Holidays, Month/Week — short labels, not a realistic risk).

**Verified with the same reproduction technique as the tab-bar fix**: built a throwaway scratch route with the exact real Schedule mobile-card markup and genuinely long agent names (matching ones visible in the reported screenshot), measured `document.body.scrollWidth` against `window.innerWidth` via Playwright at 375px — confirmed exactly equal, no overflow. `npm run lint` and `npm run build` clean across all 11 touched files.

---

## Schedule still overflowed after the card fix — a second, unrelated cause in the same header (this session, 2026-08-06)

**User sent another real screenshot** showing the same horizontal-scroll symptom on Schedule, even after the name-truncation fix above shipped. This time the root cause was genuinely different, not a regression of the same bug: the page HEADER — Holidays/Schedule toggle, Month/Week toggle, and the "Month" date input — sat in one `flex items-end gap-2.5` row with no `flex-wrap`. The outer `<header>` itself IS `flex-wrap` (so this whole cluster correctly drops below the title on a narrow screen), but the cluster's own three sub-groups still had to fit side by side within it, and three toggle-pairs-plus-a-date-input never fits a 375px phone no matter how compact each piece is — there's no text here to truncate; the fix has to be structural.

**User's own proposed fix, implemented directly**: stack the Month/Year picker ABOVE the two toggle groups on mobile instead of beside them. Done via `flex flex-col-reverse gap-2.5 sm:flex-row sm:items-end` on the outer cluster, with the two toggle `<div>`s grouped into one sub-container as the "first" child and the month/week-picker as the "second" — `flex-col-reverse` flips the VISUAL order (picker on top) while leaving DOM/tab order unchanged (toggles-then-picker, so keyboard navigation isn't disturbed), and reverts to the original side-by-side row at `sm:` and up where it already fit fine. Also added `flex-wrap` to the week-view's Prev/date-range/Next/"Copy Previous Week" button row as a proactive fix for the same class of issue, spotted while already in this section.

**Verified**: `npm run lint` and `npm run build` clean. Built a throwaway scratch route with the real header markup at both 375px and a 1000px desktop width — confirmed via `document.body.scrollWidth` that mobile no longer overflows at all, screenshotted both to confirm the picker visually stacks above the toggles on mobile exactly as requested, and that the desktop layout renders byte-for-byte the same side-by-side arrangement as before (no unintended change above the `sm:` breakpoint).

---

## QPI base was silently excluding paid invoices (this session, 2026-08-06)

**User asked, on My QPI Status**: they had an approved July invoice, July is genuinely part of Q3, so why did the Q3-2026 Billable Base show ₱0.00? Checked the actual calculation instead of guessing. `qpiQuarterKey()`'s quarter math is correct (Jul–Sep = Q3, confirmed by reading it directly) — that wasn't the bug.

**The real bug**: `QPI_BASE_STATUSES` (the allow-list `qpiBillableBase()` filters invoices against) was `['approved']` only — it silently excluded `'paid'` invoices. This gap traces back to legacy itself, which also excluded paid (confirmed by this project's own earlier comment on this exact constant, from when draft/submitted were removed to close a self-invoicing exploit) — nobody had independently re-examined the paid-exclusion on its own terms when that fix went in. In practice this meant the base was almost always computed too low: QPI is computed at the LAST month of its own quarter (September, for Q3, per the existing cut_number=2 + Mar/Jun/Sep/Dec eligibility rule elsewhere in this file), by which point the quarter's EARLIER invoices (July, August) have almost always already been paid out by normal biweekly payroll timing — only whichever invoice hadn't been paid yet would ever count toward the base.

**Fix, confirmed with the user before changing a real compensation calculation**: `QPI_BASE_STATUSES` → `['approved', 'paid']`. No gaming risk in adding `paid` — unlike `draft`/`submitted` (which an agent can trigger themselves by just creating and sitting on a self-authored invoice), reaching `paid` is strictly MORE final and entirely outside the agent's own control. One canonical constant, two consumers (`qpiBillableBase()` itself, and `invoices/[id]/page.js`'s "which months are missing a qualifying invoice" warning) — fixing it in one place correctly fixes both; the missing-months warning had the identical symptom (a paid invoice's month would get wrongly flagged as "missing" the moment it left "approved").

**Verified with a real fixture test, not just a build pass**: called `qpiBillableBase()` directly with a paid ₱15,000 invoice, an approved ₱8,000 invoice, and draft/submitted invoices carrying a deliberately huge ₱99,999 each — confirmed the result is exactly ₱23,000 (paid + approved counted, draft/submitted correctly still excluded, so the anti-gaming fix from before remains fully intact). `npm run lint` and `npm run build` clean.

---

## Bjorn granted time-off approval + an "Approved by ___" stamp surfaced in the UI (this session, 2026-08-06)

**User asked**: Kenn, Bjorn, and Berry should all be able to approve time off, and whoever decides a request should leave a visible stamp. Checked current state before writing anything, rather than assuming: Berry and Kenn already had `time_off` `can_write=true` (migration 0040), Edwin already had it via `is_admin` — only Bjorn was missing. Granted via migration `0046`, reusing his already-confirmed `user_id` from migration 0037's invoicing grant.

**The stamp needed no schema change at all** — `time_off_requests.decided_by`/`decided_at` already exist (migration 0040) and `/api/time-off/decide` already writes both on every approve/deny/revoke. The actual gap was purely that no page ever displayed them — confirmed by grepping for `decided_by` across the UI and finding zero matches before this fix.

**New `approverDisplayName()` in `timeOffLogic.js`**, shared by both the admin queue (`settings/time-off/page.js`, added an "Approved by" column to the desktop table + a line in the mobile card) and an agent's own request history (`time-off/page.js`, added under the status pill in both the desktop table and mobile card). Maps `decided_by`'s raw stored email to a real name for the 4 known approvers, falling back to the email's local-part for anyone not yet listed. Bjorn is the specific reason this map exists rather than deriving a name from the email directly: his account is genuinely `info@khaizen.eu` (confirmed in migration 0037's own comment), and without the map that would display as "Approved by info."

**Verified**: `npm run lint` and `npm run build` clean. Called `approverDisplayName()` directly with all 4 known approver emails plus an unmapped one plus `null` — confirmed each resolves correctly, including the Bjorn case specifically (`info@khaizen.eu` → `"Bjorn"`, not `"info"`).

---

## Notification "seen" bookmark write hardened against silent failure (this session, 2026-08-06)

**User, looking at a real screenshot of two live "Payday!" cards**, asked for reassurance/confirmation that once a "What's New" card is dismissed, it genuinely stays gone rather than clogging Home again on a future visit. Verified the underlying suppression logic directly with a real fixture test rather than just re-reading the code: called `computeNotificationItems()` twice with the same two paid invoices — once with an old (epoch-zero) bookmark, once with a bookmark advanced to "now" (exactly what `home/page.js` sets right after computing a visit's items) — first call returns both items, second call returns zero. The comparison logic itself is correct.

**The one real gap found**: the bookmark write itself (`notification_reads.upsert(...)`) was fire-and-forget — never awaited, never checked for an error. If that write ever silently failed for any reason (an RLS hiccup, a network blip, anything), the bookmark would never actually advance in the database, and the exact same "New" cards would keep reappearing on every single visit forever, with literally no way to notice why — the failure would be invisible. Fixed to `await` the call and `console.error` on failure — not surfaced in the UI (this is a background bookmark update, not a user action with anywhere sensible to show an error toast), but at least now diagnosable via browser devtools instead of being a silent black box.

**Verified**: `npm run lint` and `npm run build` clean. Gave the user a direct query against their own `notification_reads` row so they can independently confirm `last_seen_at` is actually advancing on their real account — the one thing genuinely outside what a fixture test or a build pass can check from here.

---

## "On Shift Now" widened to time-off approvers, now shows real names (this session, 2026-08-06)

**User, looking at the live Home dashboard**, asked for two things: (1) Berry, Kenn, and Bjorn (not just full admins) should see the "On Shift Now" card, since they already need this same operational picture to do their own approval work; (2) the card should show the actual names of whoever's genuinely on shift right now, deliberately excluding anyone off or not yet clocked in.

**Widened precisely, not broadly**: `effectiveAccess.admin || effectiveAccess.timeOffWrite` — matching exactly the same 4 people who can approve time off (Berry/Kenn/Bjorn/Edwin), not a wider net. "Payroll this cut" (raw payroll totals) and "Open approvals" stayed strictly admin-only — the user only asked about on-shift visibility specifically, and widening actual payroll figures is a different sensitivity tier that wasn't part of this request. The stat row's outer gate is now `admin || timeOffWrite` so non-admin approvers still see the row at all, with the other two admin-only cards conditionally omitted inside it.

**Names, not just a count**: the existing on-shift query already filtered to agents whose shift window genuinely contains the current moment (this was already the correct "who's actually on shift" logic, only ever exposed as a bare number before) — added a fetch to `/api/agent-names` (same endpoint already used elsewhere on this page) to resolve each on-shift `agent_id` to a display name. Anyone off or not yet on shift was already excluded by construction (they never match the shift-window check to begin with), so no separate filtering step was needed to satisfy "don't include their name yet."

**`StatCard` (`src/components/ui/StatCard.js`) gained an optional `names` prop** rather than building a one-off duplicate component — renders as small pills tinted with the card's own existing accent color (not a new color), so it reads as one cohesive card rather than two different widgets stacked together. Fully backward-compatible: every other `StatCard` usage across the app (Invoices, Daily Report) is unaffected since the prop is optional and simply doesn't render anything when omitted.

**Verified**: `npm run lint` and `npm run build` clean. Built a throwaway preview route with the real component (no duplication needed, direct import) covering three real states — the full admin row, a lone "On Shift Now" card as Berry/Kenn/Bjorn would see it, and the empty state (nobody on shift, zero chips) — at both desktop and mobile widths. Confirmed the lone-card scenario reads clean in the grid rather than looking sparse or broken, and that the two untouched admin-only cards render pixel-identical to before.

---

## "On Shift Now" made genuinely live: Early/Late labels, names drop off when a shift ends (this session, 2026-08-07)

**User asked for two more things on top of the previous fix**: (1) identify whether each on-shift person is working Early or Late, and (2) when someone's shift ends, actually remove their name from the card instead of leaving it stuck until the next full page reload.

**Root cause of (2)**: the on-shift computation lived inside Home's big one-shot "attention" `useEffect` — correct at the instant it ran, but it never re-ran again for the rest of the page visit. A shift ending 20 minutes into someone's session would leave their name showing indefinitely.

**Fix**: pulled the on-shift computation out into its own dedicated `useEffect`, still gated on the same `admin || timeOffWrite` access check as before, but now wrapped in a `setInterval` (re-runs every 5 minutes) plus an immediate call on mount, with proper `cancelled`-flag + `clearInterval` cleanup. This makes "who's on shift" a periodically-re-checked live fact, unlike everything else on Home which is intentionally compute-once-per-load. The underlying cross-midnight-aware shift-window filter itself was already correct and unchanged — only the fact that it now re-runs is new.

**Early/Late labels**: reused the existing `shiftLabel(code)` helper (already used elsewhere on this page to turn a raw `shift_code` into "Early"/"Late"/"Regular"/etc., including `CUSTOM_`/`HOL_WORK_` prefixes) rather than inventing new labeling logic. Each name pill now reads e.g. `"Edwin · Late"`.

**Verified**: `npm run lint` and `npm run build` clean.
