Levandor CRM - Debugging Log
Purpose
Past bugs, their root causes, and resolutions for the Levandor CRM project.
Kanban DnD Cursor Offset (2026-03-09)
Symptom: Dragging kanban cards offset from cursor, invalid drops.
Root cause (3 compounding issues):
CSS.Transform.toString(transform)includesscaleX(1) scaleY(1)causing visual offset- Droppable
refon<ScrollArea>(Radix) - ref goes to Root but content scrolls in nested Viewport - Original card visible during drag (opacity-60) alongside DragOverlay
Fix:
- Use
CSS.Translate.toString(transform)instead - Put ref on plain
<div>wrapper around ScrollArea - Set
opacity: 0on sortable wrapper whenisDragging
Day Planner Uncancel Not Working (2026-03-09)
Symptom: Could cancel blocks but couldn’t restore them. Root cause: Toolbar always showed Play/Check/Cancel regardless of status. No restore action existed. Fix: When CANCELLED or DONE, replace with Restore button (Undo2 icon) → sets status=PLANNED, clears actual_start/actual_end.
CI TypeScript Build Failures (2026-03-09)
Symptom: tsc -b in CI failed with 14 errors, local tsc --noEmit passed.
Root cause: tsc -b (project build mode) stricter about cross-project type resolution:
packages/sharedmissing@types/reactdevDependency- Supabase
.then()destructuring → implicitanyunder noImplicitAny Mapconstructor without generics →Map<unknown, unknown>- Stale code referencing removed columns (
is_closed,description)
Fix: Add @types/react to shared, use await instead of .then(), add Map generics, fix stale refs.
Lesson: Always verify with cd web && npx tsc -b before pushing.
pnpm Lockfile Out of Sync (2026-03-09)
Symptom: CI --frozen-lockfile failed.
Root cause: Lockfile changes from adding workspaces weren’t committed.
Fix: Commit the lockfile.
Billingo Vendor Bill Sync - Null Fields (2026-03-11)
Symptom: Vendor bills synced from Billingo API had null vendor_name, total_gross, spending_date.
Root cause: Wrong assumptions about API response structure:
| Code assumed | API actually returns |
|---|---|
spending.name | spending.partner?.name |
spending.gross_price | spending.total_gross |
spending.spending_date | spending.fulfillment_date |
spending.payment_status | spending.paid_at (date, derive status) |
Fix chain (5 edge function deployments):
- v3: Corrected
BillingoSpendinginterface + field mappings +deriveStatus() - v4: Disabled JWT for testing → 157/163 bills created correctly
- DB:
total_grossINTEGER → NUMERIC (API returns decimals) - DB: Added
VENDOR_BILLtobudget_transaction_source_checkconstraint - DB: Filter out zero-amount bills before budget transaction creation
- v5: Re-enabled JWT, removed debug code → production
Result: 163/163 vendor bills, 100% field population, zero nulls.
Lesson: Always inspect actual API responses with debug logging before writing TypeScript interfaces.
Timezone Bug Dropping Last Day of Month (2026-04-01)
Symptom: Timesheet grid missing entries on the last day of the month (e.g., March 31).
Root cause: getMonthRange used local Date constructor then called .toISOString() which converts to UTC. In CEST (UTC+2), March 31 00:00 local became March 30 22:00 UTC, so the <= filter excluded March 31 entries entirely.
Fix: Use Date.UTC() throughout both useMonthlyProjectTotals and useMonthlyHours to construct date boundaries. Commit 87afabf.
Lesson: Always use Date.UTC() for date range boundaries that feed into database queries. Never mix local Date with .toISOString() for Supabase filters.
Timesheet Export Project Filter Cache Bug (2026-04-01)
Symptom: Selecting/deselecting project checkboxes in the export wizard did not update the preview table. The preview showed stale data.
Root cause: Two compounding issues:
- Project filtering was inside the
queryFn, butselectedProjectswas not in thequeryKey— so TanStack Query served cached unfiltered data when selection changed - Preview on step 3 showed “no data” even when data existed because the exportData memo wasn’t recalculating
Fix: Moved project filtering out of the query and into a client-side useMemo that filters allExportData by selectedProjects. The query now fetches all timesheets for the date range, and filtering happens in the component. Commit 9d5acd3.
Lesson: Never filter data inside queryFn based on state that isn’t in the queryKey. Either add it to the key or filter client-side.
Timesheet Export Project Untick Bug (2026-04-01)
Symptom: Unchecking a project from the “all selected” state (empty array = all) removed all projects instead of keeping the others.
Root cause: The checkbox onCheckedChange handler for unchecking didn’t account for the “empty array means all” convention. When selectedProjects was [] and you unchecked one, it filtered from an empty array, resulting in nothing selected.
Fix: When unchecking from prev.length === 0, expand to all project names first, then filter out the unchecked one. Commit bfabb45.
CF Access Auth Migration (2026-03-28 to 2026-04-01)
Symptom: Multiple auth-related commits over several days as the admin CRM migrated from Clerk to CF Access ZTNA.
Fix chain (4 commits):
b1c09e5: Default to sole person when CF Access cookie absent (dev fallback)02077fa: Open RLS policies for all admin tables behind ZTNA769ba5c: Simplify Supabase client to plain anon key (remove JWT token logic)bcb446f: Open RLS on all 48 tables for anon access behind ZTNA
Result: Admin CRM no longer depends on Clerk. Authentication handled entirely at the edge by Cloudflare Access. Supabase uses anon key with permissive RLS (security enforced at network level by ZTNA).
Day Planner Timezone Corruption (2026-04-27)
Symptom: Time blocks in the day planner drifted to wrong times depending on the user’s local timezone.
Root cause: minutesToIso() in formatting.ts used setHours() (which operates in local time) followed by toISOString() (which converts to UTC). This created a timezone-dependent drift — the stored ISO string would be offset by the user’s UTC offset. Similarly, formatTime() and isoToMinutes() used getHours()/getMinutes() (local time) to parse UTC strings, compounding the mismatch.
Fix: Rewrote minutesToIso() to generate a deterministic UTC ISO string directly without going through Date.setHours(). Switched formatTime() and isoToMinutes() to use getUTCHours()/getUTCMinutes() so all conversions stay in UTC consistently.
Lesson: This is the same class of bug as Timezone Bug Dropping Last Day of Month (2026-04-01). Any time a Date object is constructed from local time and then serialized to ISO/UTC, timezone drift will occur. Always use UTC methods (Date.UTC(), getUTCHours(), etc.) when the stored format is ISO/UTC.
Billingo Sync Null Dereference (2026-04-27)
Symptom: billingo-sync-statuses edge function crashed on invocation.
Root cause: Line 362 of billingo-sync-statuses/index.ts called doc.invoice_number.trim() without checking for null. The invoice_number field can be null in the database when an invoice record hasn’t been assigned a number yet.
Fix: Changed to (doc.invoice_number ?? "").trim() with a nullish coalescing fallback.
Silent Zero Rates in Timesheet Export (2026-04-27)
Symptom: Exported timesheets showed zero hourly rates for some team members, with no error indication.
Root cause: In useTimesheetExport.ts:51, the results of project_member and person queries were not checked for errors. When these queries failed (e.g., due to missing RLS permissions or network issues), pmRes.data and personRes.data were null, and the rate calculation silently fell through to zero.
Fix: Added explicit error checks that throw when either query fails, surfacing the issue to the user instead of producing silently wrong data.
Lesson: Always check .error on Supabase query results before using .data. Silent nulls in financial calculations are worse than crashes.
CORS Origin Whitespace Bug (2026-04-27)
Symptom: CORS preflight requests rejected for legitimate origins when the ALLOWED_ORIGINS env var contained spaces after commas.
Root cause: _shared/cors.ts split ALLOWED_ORIGINS with split(',') but didn’t trim whitespace. An env var like "origin1, origin2" would produce ["origin1", " origin2"], and " origin2" would never match the Origin header value "origin2".
Fix: Added .map(o => o.trim()) after the split to strip whitespace from each origin.
Default CORS Origin Correction (2026-04-27)
Context: The fallback CORS origin (used when ALLOWED_ORIGINS env var is unset) was pointing to the wrong domain.
Fix: Changed default from crm.levandor.com to wrcm.levandor.io — the actual production domain. The wrcm subdomain is intentionally non-obvious to deter automated scraping and attack surface discovery.
Hours-tab garbage hours bug 2026-05-09
Symptom: Tapping a day pill in the mobile Hours tab WeekStrip showed entries from the previous day. Wrong day’s hours displayed.
Root cause: isoDate(d) = d.toISOString().slice(0,10) is timezone-unsafe. When the user tapped a day pill, the midnight-local Date for that day converted to the previous day in UTC under CEST (UTC+2). useTimesheetWeek used the resulting wrong-day key to look up entries.
Same UTC-drift bug found in useDashboardSummary.ts.
Fix: New toLocalDateString() util in web/src/lib/date-utils.ts that uses local-time getFullYear()/getMonth()/getDate() to produce YYYY-MM-DD deterministically in the user’s timezone. Replaced the .toISOString().slice(0,10) pattern in both hooks. 8 unit tests added on the new util. Also dedup’d the startOfWeek helper that was duplicated across MobileHome + MobileHours. Commit 01880a0.
Lesson: This is the third appearance of the same UTC-drift class of bug after the timesheet last-day-of-month bug (2026-04-01) and the day planner timezone corruption (2026-04-27). The pattern: any .toISOString() on a Date constructed in local time will drift by the UTC offset. For date-only keys (YYYY-MM-DD), use local-time getters explicitly. For ISO/UTC roundtrips, use Date.UTC() and getUTC* consistently. Two more hooks still carry this bug — see Same UTC date-drift bug pattern.
Invisible dropdown text on iOS (2026-05-09)
Symptom: <select> elements in mobile drawers rendered with no visible text — appeared blank even when a value was selected. Only happened on iOS Safari / PWA standalone.
Root cause: The selects had appearance-none (to hide the native chevron) plus a Lucide ChevronDown overlay. Without an explicit text color, iOS Safari rendered the option text in a near-invisible color against the white drawer background.
Fix: Explicit text-zinc-900 on the <select> and placeholder:text-zinc-400. Commit 7f4bc84.
Broken Test Mocks (2026-04-27)
Symptom: Two test files failing after recent refactors.
Root causes and fixes:
TaskCard.test.tsx— missinguseToggleTaskStatusmock. The hook was recently extracted from inline logic in TaskCard, but the test’s mock setup wasn’t updated.AppShell.test.tsx— missingQueryClientProviderwrapper. The component started using TanStack Query hooks but the test rendered it without the required provider.
Fix: Added the missing mock for useToggleTaskStatus and wrapped the AppShell test render in a QueryClientProvider.
P0 — Service Worker NavigationRoute locks all users out of CF Access (2026-05-10)
Severity: P0 — total outage, all users (admin web + iOS PWA), persisting across browser refresh.
Symptom: Every user of wrcm.levandor.io saw a full-screen Authentication Error: No CF Access token available. Refresh did not help.
Root cause: Commit ac5f429 build(pwa): switch to injectManifest with custom sw.ts (precache parity) introduced web/src/sw.ts with a Workbox NavigationRoute(createHandlerBoundToURL('/index.html')). This intercepted every navigation (initial loads, refreshes, new-tab opens) and served cached /index.html from the precache instead of going to network. Behind Cloudflare Access ZTNA this is catastrophic: CF Access expires CF_Authorization cookies periodically and refreshes them via a 302 → IdP login → 302 back redirect dance on the next navigation. With the SW intercepting, CF Access never sees the navigation, the cookie never refreshes, cf-access.ts:79 finds no CF_Authorization in document.cookie, and the SPA throws “No CF Access token available” forever. Refresh doesn’t help — service workers persist across refresh by design.
The bug took ~24h to manifest in production because users with fresh CF_Authorization cookies kept working. The lockout only kicked in for each user the first time their cookie naturally expired post-deploy. As cookies expired throughout the day, more users hit the wall — which misled triage into thinking “something’s gradually getting worse” rather than “we shipped a bug.”
The user (correctly) suspected the recent centralized notifications work, but the notifications-system migrations were a red herring. The earlier push-notifications SW commit was the culprit.
Fix (commit cd8394f):
- Remove
registerRoute(new NavigationRoute(...))fromweb/src/sw.tsso navigations go to network and CF Access can run its 302 dance. - Add
self.addEventListener('install', () => self.skipWaiting())andactivate -> self.clients.claim()so the new SW takes over existing clients immediately. - Add a “Reset session and reload” button on the AuthError screen in
web/src/lib/supabase.tsxthat unregisters all SWs + clears all caches + reloads — recovery for users already trapped behind the broken SW.
Lessons:
- ZTNA + custom service worker is a permanent trap. Any navigation interception breaks the cookie-refresh redirect dance. If
NavigationRouteis ever reintroduced, it must whitelist CF Access endpoints AND pass through 30x responses unmodified. Easier rule: don’t useNavigationRouteon this origin, period. - Workbox precaching of static assets is fine — only
NavigationRouteis the trap. - Service workers persist across refresh — design a recovery affordance. The “Reset session and reload” button stays; future SW changes treat it as part of the contract.
- Always include
skipWaiting+clients.claimwhen shipping a fix to a broken SW — otherwise recovery requires every affected user to close all tabs. - Stale doc flagged for separate follow-up:
web/CLAUDE.mdand security both still describe the anon-only Supabase model; JWT exchange viacf-access-authwas reintroduced after769ba5c. - Hard rule for the push-notifications work (push-notifications): the push SW must NOT register
NavigationRoute. Add as an explicit “don’t” in the spec. Test plan addition: every PR touchingweb/src/sw.tsmust manually verify that an expiredCF_Authorizationcookie still triggers the CF Access login redirect — cannot be unit-tested.
See incident-2026-05-10-sw-cf-access-lockout for full incident note (timeline, verification protocol, implications for future SW work).
Toast showing [object Object] for Supabase errors (2026-05-11)
Symptom: Reminder create/update failures surfaced a sonner toast reading literally [object Object] instead of the error message.
Root cause: The error handler did toast.error(String(err)) (or template-interpolated err). Supabase / PostgREST error objects are plain objects ({ message, details, hint, code }), not Error instances — so String(err) / ${err} stringifies to [object Object].
Fix: Pull the fields explicitly — err.message ?? err.details ?? err.hint ?? err.code ?? 'Something went wrong'. Part of the scheduled-reminders P1.5 frontend fixes.
Lesson: Never String() a Supabase error. They aren’t Error instances; read .message / .details / .hint / .code.
actor_person_id() read the wrong JWT-claims GUC (2026-05-11)
Symptom: During the scheduled-reminders work, actor_person_id() (the RLS helper that resolves the calling person) returned NULL — RLS-scoped reads came back empty, RPCs that gated on it misbehaved.
Root cause: actor_person_id() was reading the legacy current_setting('request.jwt.claim.sub', true) GUC — singular claim — which Supabase/PostgREST no longer sets. Modern PostgREST exposes claims under the plural request.jwt.claims (a JSON blob), and auth.uid() is the supported accessor.
Fix: Rewrote actor_person_id() to try auth.uid() first, then fall back to parsing current_setting('request.jwt.claims', true)::jsonb->>'sub'. (The CF Access auth flow issues a Supabase JWT with sub = person.id — see security — so auth.uid() is the person id directly.)
Lesson: Don’t reach for request.jwt.claim.<x> (singular) — it’s a deprecated PostgREST GUC. Use auth.uid() / auth.jwt(), or request.jwt.claims (plural, JSON) if you need a custom claim.
send-push treated missing notification preference as “disabled” (2026-05-11)
Symptom: Push notifications only arrived for notification types the user had explicitly toggled on in /settings/notifications. Brand-new types (e.g. CUSTOM_REMINDER) with default_push = true never pushed, even though the in-app feed showed them.
Root cause: The send-push Edge Function looked up the user’s notification_preference row for the type and, when no row existed, treated it as push-disabled and bailed. But the centralized notification system’s contract is “no preference row → fall back to the type’s default”: emit_notification already did this for in-app delivery (notification_type_def.default_in_app), but send-push never did the equivalent for push.
Fix: send-push EF v6 falls back to notification_type_def.default_push when there’s no explicit preference row. (Same deploy also: return HTTP 500 on real errors instead of 200 — was masking failures that only surfaced in net._http_response; and fall back to a notification:<id> push tag when no better tag is available.) Part of the scheduled-reminders P1.5 fixes — see Phase 1.5 — post-review fixes and push-notifications.
Lesson: When a system has a “default if no preference row” rule, audit every delivery path enforces it — it’s easy for one path (here, push) to lag behind another (in-app). Also: an Edge Function returning 200 on failure is invisible to pg_net callers — return a real error status.
Postgres gotchas surfaced during scheduled-reminders (2026-05-11)
A cluster of PL/pgSQL / Postgres footguns hit while building the scheduled-reminders feature. Cross-project value — recording here:
-
IMMUTABLEon a function that callsnow()is a correctness lie. The planner is entitled to constant-fold anIMMUTABLEfunction at plan time, so a “current-time-dependent” function markedIMMUTABLEcan return a stale value. Behaviorally identical inside a plpgsql body, but bites the moment the function is used in an index expression / generated column / CHECK constraint / materialized view. UseSTABLE. (Thecompute_first_fire_at/compute_next_fire_atrecurrence helpers were originallyIMMUTABLE; fixed toSTABLEin P2.5.) -
PL/pgSQL cursor variables must be
%ROWTYPE, notrecord, if you pass them to a function expecting that exact composite type. Arecordwon’t implicitly cast to a named row type —process_due_remindersfailed withcannot cast type record to reminder(SQLSTATE 42846) until itsFOR ... LOOPcursor was declaredpublic.reminder%ROWTYPE. -
day + timein Postgres yieldstimestamp WITHOUT time zone(a wall-clock value). To get atimestamptzyou must appendAT TIME ZONE '<zone>'. Casting the wall-clocktimestampdirectly totimestamptzuses the server’s timezone (UTC on Supabase) — almost never what you want for user-facing times. (The Phase 3 day-plan-block trigger computes(standup.date + start_time - interval '5 minutes') AT TIME ZONE 'Europe/Budapest'for exactly this reason — there’s no tz column in the schema, so the zone is hardcoded as a single-tenant shortcut.) -
Supabase MCP
apply_migrationassigns its own apply-time timestamp version, not the version in your migration filename — so any project where migrations went through the MCP shows a permanent remote-vs-local mismatch insupabase migration list, andsupabase db pushwill try to re-run all local migration files (failing on non-IF NOT EXISTSCREATE TABLEs). Treat the migration files as the schema source of truth; don’t chase remote-history parity. If the MCP is down and you must apply a migration, usepsql -fagainst the connection string — notsupabase db push.
Related: Gotchas has the full list (also covers the toast [object Object] and verify_jwt: false ones, which are already recorded above as their own entries / in push-notifications).
Evening checklist filed onto the wrong day — post-midnight session (2026-05-11)
Symptom: On the mobile app, opening “today’s” evening checklist showed last night’s answers already pre-filled (the 3 free-text EVENING_CHECKLIST fields + the BOTH-phase Energy datapoint).
Root cause: web/src/mobile/plan/MobilePlan.tsx computed the standup “day” as toLocalDateString(new Date()) — the raw local calendar date — froze it at mount via useMemo(…, []), and passed that one standupId to every sheet, including the evening checklist and evening reflection. The user did their May-10 evening checklist at ~00:20 CEST on May 11 (procrastinated past midnight, tapped the new evening-checklist reminder push). Since it was technically May 11 by then, the app created the May-11 standup row and saved those 4 datapoints onto it. Then on the evening of May 11, opening “today’s” (= May 11’s) evening checklist surfaced last night’s answers. No data was lost — standup has UNIQUE(person, date) and standup_datapoint has UNIQUE(standup, config); the answers were just attached to the wrong day’s row.
Fix (commit 1bf861a): new eveningStandupDate(now = new Date()) in web/src/lib/date-utils.ts — const EVENING_STANDUP_DAY_START_HOUR = 15; now.getHours() < 15 → yesterday’s toLocalDateString, else today’s (so an “evening-standup day D” spans 15:00 on D → 14:59 on D+1; uses getHours() = local time deliberately). MobilePlan.tsx: the evening checklist + evening reflection sheets now target useStandup(person, eveningStandupDate())’s row, with a second on-demand useEnsureStandup(eveningDate) (guarded by eveningDate !== date); the “Evening” nudge button gates on that row’s morning_completed_at/evening_completed_at; morning ritual / lunch checkin / day plan blocks / standup notes are unchanged (still calendar-date); date/eveningDate are no longer mount-frozen — they recompute on document.visibilitychange. Added web/src/lib/__tests__/date-utils.test.ts (4 cases). Desktop /standup (pages/standup/index.tsx) was NOT changed — it computes “today” via toISODateString(new Date()) (UTC), which by accident makes a late-CEST-night session (00:00–02:00 CEST = still the previous day in UTC) land on the previous day’s row anyway. Data migration (one-shot, prod 2026-05-11): UPDATE standup_datapoint SET standup = '<May-10 standup id>' WHERE standup = '<May-11 standup id>' moved the 4 datapoints back to András Léderer’s May-10 row (which had 0 datapoints — no UNIQUE(standup, config) conflict). Still open: the fix is pushed but not deployed — needs pnpm --filter web deploy for the mobile PWA to pick it up.
Lesson: “Today’s date” for daily-ritual features needs care on three axes — (1) compute it from local time, not UTC (toLocalDateString, not toISODateString — the desktop standup page gets this subtly wrong and only gets lucky that the UTC offset cancels the wrongness for late CEST nights); (2) don’t mount-freeze it (useMemo(…, [])) in a PWA — recompute on visibilitychange; (3) an evening ritual specifically should treat post-midnight sessions as the previous day — hence the 15:00 cutoff. A morning ritual does not want (3). This is adjacent to the UTC-drift bug family (Hours-tab garbage hours bug 2026-05-09, Timezone Bug Dropping Last Day of Month (2026-04-01), Day Planner Timezone Corruption (2026-04-27)) but the fix here isn’t “use UTC consistently” — it’s “use local consistently, plus a deliberate evening cutoff.” See evening-checklist-day-boundary-fix-2026-05-11.
Related
- levandor-crm - Project overview
- budget - Budget system (where most bugs surface)
- timesheet-export - Timesheet export feature
- security - CF Access auth architecture
- day-planner - Day planner (timezone bug)
- integrations - External integrations (Billingo, CORS bugs)
- tech-debt - Remaining known issues