# CSV Imports (Clockify/Toggl → time_entries) — current-system spec (parity bar for the rebuild)

> The import pipeline is how time data enters the app: a manager or owner uploads a Clockify (or, historically, Toggl) Detailed Report CSV, the parser normalizes every row into a naive-timestamp entry, the two-step resolver forces an explicit human decision for every operator/client/project name that doesn't already exist as an entity, and commit writes an immutable-ish batch (`clockify_imports`) plus one `time_entries` row per parsed row — with cost and billout dollars **stamped at write time** (ADR #014). It is the most behavior-dense feature in the app and the most battle-tested (44-test CLK-* suite); the rebuild ports it faithfully (judgment call #14) minus the Toggl dialect.

Code of record: `lib/clockify.ts`, `app/(app)/import/page.tsx`, `app/(app)/import/actions.ts`, `app/(app)/import/upload-form.tsx`, `app/(app)/import/resolve/page.tsx`, `app/(app)/import/resolve/actions.ts`, `app/(app)/import/resolve/resolver-form.tsx`, migration `supabase/migrations/20260524000003_pending_imports.sql`, tests `tests/clockify.test.ts`.

## User workflows

1. **Adi's Clockify upload (the primary pipeline).** Adi (manager, Tingang team) exports a weekly/monthly Clockify Detailed Report and uploads it at `/import`. He should not have to know or care about date/time formats — "we both have the same upload screen. how does the app know what format to expect?" (rian, 05-22). Formats are auto-detected; the batch is auto-named from the CSV's date range (e.g. "Jun 2 – Jun 8, 2026") if he doesn't type a name.
2. **Resolving new names before anything is created.** If the CSV contains an operator/client/project name the org doesn't know, the resolver page forces a per-name decision: *Create new* or *Map to existing* (fixing CSV typos like "Vicotria" → mapping to "Victoria" without polluting the entity list — ADR #041 replaced the old silent create-on-the-fly behavior).
3. **Rows with missing fields.** Clockify projects with no client assigned (e.g. "Bowden Works → Brentwood, no client") parse with blank fields. The resolver surfaces them as "buckets" and lets the user assign each bucket to a real project (map or create the full chain) or skip (row imports Blocked).
4. **Rian's Toggl history (retired).** `source=toggl` exists because rian imported his own Toggl logs in May 2026. As of 2026-07-07: "I no longer have any need for toggl."
5. **Batch management.** Every upload is a named batch. Batches list at `/import`, link to their entries (`/entries?batch=<id>`), and can be deleted — cascading their entries — unless any entry has been transferred/invoiced (rian, 05-21: "delete an import batch unless an item in that import has been transferred already").

## Complete behavior

### A. Upload form (`/import`, client component `UploadForm`)

- **B1.** Form fields: `source` radio (`clockify` default | `toggl`), `name` text (optional batch name), `file` (`accept=".csv,text/csv"`, required), `date_format` select (`MM/DD/YYYY (Clockify US default)` | `DD/MM/YYYY (Clockify EU)` | `YYYY-MM-DD (ISO / Toggl default)`), `notes` text (optional). There is **no time-format field** — it was removed when AM/PM auto-detect landed (2026-05-27).
- **B2.** The date-format select is a **fallback only** (label: "auto-detected; this is a fallback for fully-ambiguous CSVs"). It is keyed by source (`key={'date-'+source}`) so flipping the radio remounts it with the per-source default: Clockify → `MM/DD/YYYY`, Toggl → `YYYY-MM-DD`.
- **B3.** Submit button uses `useFormStatus` (`SubmitButton`): label "Upload and import" flips to "Parsing CSV…" in flight (double-submit protection — a real incident, 06-10).
- **B4.** Helper text lists expected columns per source. Clockify: `Project, Client, Description, Task, User, Group, Email, Tags, Billable, Start Date, Start Time, End Date, End Time, Duration (h)`. Toggl: `Project, Client, Description, Email, Member, Start date, Start time, Stop date, Stop time, Duration. Billable is optional.`

### B. Upload action (`uploadClockifyCsv` in `app/(app)/import/actions.ts`)

All failures `redirect('/import?error=<encodeURIComponent(msg)>')`.

- **B5.** Permission: `canManageImports(eu)` required → else "You do not have permission to import."
- **B6.** `source` is lowercased; any value other than `'toggl'` coerces to `'clockify'`.
- **B7.** `date_format` default when absent: `'YYYY-MM-DD'` (toggl) / `'MM/DD/YYYY'` (clockify). `time_format` default `'24h'` (toggl) / `'12h'` (clockify) — stored in `pending_imports` but **ignored by the parser** (dead plumbing).
- **B8.** File required and non-empty → else "Please choose a CSV file to upload." Size cap **25 MB** (`25 * 1024 * 1024`) → else "File too large (25MB max)." Next.js server-action `bodySizeLimit` is raised to `'25mb'` in `next.config.mjs` to match.
- **B9.** If `parseClockifyCsv` yields **zero** entries → fail with `result.errors[0]?.message` or "No valid rows found in the CSV. Check the format selections." If **some** rows parsed, per-row parse errors are **silently discarded** (known wart — see Edge cases).
- **B10.** **Batch auto-naming:** `batchName = typedName ?? (formatDateRangeLabel(dateRange) ?? filename-sans-extension)`. Typed (non-blank, trimmed) name always wins. `formatDateRangeLabel` renders the CSV's start-date range (see B34–B36); when the range is unusable, the filename with its last `.ext` stripped is used.
- **B11.** **Fail-fast team check:** the importer must have a `teams` row in the org (`getTeamForUser(supabase, org.id, eu.effective_user_id)` — teams are keyed by `owner_user_id`) → else "You don't have a team in this organization yet. An org owner needs to create one for you before you can import." (Email→team_member resolution happens at commit; this stops the user reaching the resolver only to fail there.)
- **B12.** **Staging:** insert one `pending_imports` row: `{ org_id, uploaded_by: eu.effective_user_id, filename: file.name, batch_name, notes, source, date_format, time_format, parsed_entries: ParsedEntry[] (jsonb) }`. Attribution uses **effective** user id (ADR #033 — view-as is true impersonation). Then `redirect('/import/resolve?id=<pending.id>')`.

### C. Parsing — headers and dialects (`parseClockifyCsv` in `lib/clockify.ts`)

- **B13.** CSV parsed with papaparse: `Papa.parse<string[]>(csv.trim(), { skipEmptyLines: true })`. Zero rows → single error `{ row: 0, message: 'CSV is empty.' }`.
- **B14.** Header matching: each header cell is lowercased + trimmed and checked for **exact membership** in a per-key candidate list; the **first matching column index wins**. Candidates (this is what makes Clockify and Toggl one parser):

  | key | candidates |
  |---|---|
  | project | `project` |
  | client | `client` |
  | description | `description` |
  | task | `task` (indexed, never read) |
  | user | `user`, `member` |
  | email | `email` |
  | billable | `billable` |
  | startDate | `start date`, `startdate` |
  | startTime | `start time`, `starttime` |
  | endDate | `end date`, `enddate`, `stop date`, `stopdate` |
  | endTime | `end time`, `endtime`, `stop time`, `stoptime` |
  | duration | `duration (h)`, `duration` |
  | rate | `billable rate (usd)`, `billable rate` |
  | amount | `billable amount (usd)`, `billable amount` |

- **B15.** **Required** columns: `project, client, description, email, startDate, startTime, endDate, endTime, duration`. Any missing → abort with row-0 error: `CSV is missing required columns: <keys>. Make sure you exported with Clockify's default Detailed Report layout.` **Optional:** `billable` (Toggl Detailed Report omits it), `user`, `task`, `rate`, `amount`.
- **B16.** **Billable default:** column present → `parseBoolean(cell)` where true iff trimmed-lowercased value ∈ {`yes`, `true`, `1`, `y`}; column absent → **`true`** ("typical for client time tracking").

### D. Parsing — date-format auto-detect (`detectDateFormat`)

Applied to **all non-empty Start Date cells** up front; result overrides the form selector; selector is only used when detection returns `null`: `detectedDateFormat = detectDateFormat(startDates) ?? dateFormat`.

- **B17.** Per string: matches `/^\d{4}-\d{1,2}-\d{1,2}$/` → ISO signal (`sawIso`). Otherwise matches `/^(\d{1,4})[/\-.](\d{1,4})[/\-.](\d{1,4})$/` (separators `/`, `-`, `.`): first slot > 12 → DD/MM signal; **else if** second slot > 12 → MM/DD signal.
- **B18.** Resolution order: ISO-only → `'YYYY-MM-DD'`; DD/MM signal without MM/DD → `'DD/MM/YYYY'`; MM/DD without DD/MM → `'MM/DD/YYYY'`; ISO mixed with both slash signals → `'YYYY-MM-DD'`. (Mixed DD/MM + MM/DD in one CSV is documented as unsupported — "the parser picks whichever unambiguous signal it sees first.")
- **B19.** **Tightest-span tiebreaker** (fully ambiguous input — every slot ≤ 12, e.g. a weekly export of June 1–8): parse the whole date list under BOTH `DD/MM/YYYY` and `MM/DD/YYYY`, count **distinct `YYYY-MM` months** each yields (Set of `parsed.slice(0,7)`), pick the interpretation with **strictly fewer** months. Both counts 0 → `null`. Equal counts → `null` (don't guess; caller falls back to the form selector). Rationale: the correct reading of a weekly export clusters into 1 month; the wrong one scatters across ~8 (real incident: the "26-01-08" batch scattered 132 June entries across Jan–Aug and had to be deleted and re-imported).

### E. Parsing — dates, times, durations, rows

- **B20.** `parseDateOnly(s, fmt)` → zero-padded `YYYY-MM-DD` or `null`. ISO branch is strict (`/^(\d{4})-(\d{1,2})-(\d{1,2})$/`, dashes only). Slash branch accepts separators `/`, `-`, `.`; MM/DD/YYYY maps (m,d,y)=(a,b,c); DD/MM/YYYY maps (d,m,y)=(a,b,c). **2-digit years: `y < 100 → y += 2000`.** Validation: `1 ≤ m ≤ 12`, `1 ≤ d ≤ 31` (no per-month day check — Feb 31 passes), `2000 ≤ y ≤ 2100`; else `null`.
- **B21.** `parseTimeOnly(s, _fmt?)` → 24h-normalized `HH:MM:SS` or `null`. **Format auto-detected PER-STRING** by AM/PM suffix (`/\s*[AaPp][Mm]\s*$/`); the `fmt` parameter is accepted for back-compat and **ignored**. With meridiem: `/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])$/`, requires `1 ≤ h ≤ 12`, `m ≤ 59`, `sec ≤ 59`; `PM && h<12 → h+=12`; `AM && h===12 → h=0`; missing seconds → `00`. Without meridiem: `/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/`, `h ≤ 23`, `m ≤ 59`, `sec ≤ 59`.
- **B22.** `joinDateTime(date, time)`: either null → null; else the naive string `` `${date} ${time}` `` (`YYYY-MM-DD HH:MM:SS`). **No timezone conversion anywhere** — timestamps are source-local wall-clock end-to-end (ADR #003).
- **B23.** `parseDurationSeconds(s)`: `H+:MM:SS` → `h*3600+m*60+s`; `H+:MM` → `h*3600+m*60`; else decimal hours (`Number(t)` finite and `≥ 0`) → `Math.round(dec*3600)`; else/blank → `null`.
- **B24.** Row loop (data rows = index 1..n): rows where **every** cell is null/blank are skipped silently. Invalid start date OR start time → error `Row <n>: invalid start date/time (<rawDate> <rawTime>)` (1-indexed counting the header as row 1) and the row is skipped. **Invalid end date/time is silent**: `end_at` becomes `null`, row still imports.
- **B25.** Field mapping per row: `source_user_email` = trimmed + **lowercased** email cell; `source_user_name` = trimmed user/member cell ('' if column absent); **`operator` = trimmed raw CSV "Client" column** (the operator agency); the CSV **"Project" column is split** by `splitClientProject`.
- **B26.** `splitClientProject(raw)`: splits on the **first occurrence** of the literal delimiter `" : "` (space-colon-space — the legacy `{Client} : {Project}` naming convention). No delimiter → `{ client: null, project: raw.trim() }`. With delimiter → `{ client: left.trim() || null, project: right.trim() }`.
- **B27.** `source_rate_usd` / `source_amount_usd` from the optional rate/amount columns via `parseNumberOrNull` (blank → null; non-finite → null).
- **B28.** `ParsedEntry` shape (this exact shape is persisted as jsonb in `pending_imports.parsed_entries`):
  ```ts
  { source_user_name: string, source_user_email: string,
    operator: string, client: string | null, project: string,
    description: string, billable: boolean,
    start_at: 'YYYY-MM-DD HH:MM:SS', end_at: string | null,
    duration_seconds: number | null,
    source_rate_usd: number | null, source_amount_usd: number | null }
  ```
- **B29.** `ParseResult` also carries: `errors[{row, message}]`, `totalRows` = raw data-row count (`rows.length - 1`, including skipped/errored), `uniqueEmails` (lowercased, sorted), `dateRange` = `{min, max}` of parsed **start** dates (lexicographic on ISO strings) or null.

### F. Batch auto-name label (`formatDateRangeLabel`)

- **B34.** Input endpoints must both match `/^(\d{4})-(\d{2})-(\d{2})$/`, else `null`. Month names: `Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec`. Day numbers unpadded.
- **B35.** Same day → `Jun 2, 2026`. Same year → `Jun 2 – Jun 8, 2026` (also cross-month: `May 28 – Jun 3, 2026`). Cross-year → `Dec 28, 2025 – Jan 3, 2026`.
- **B36.** The range separator is an **en dash with spaces** (`" – "`), not a hyphen.

### G. Staging table (`pending_imports`)

- **B37.** Columns: `id uuid pk`, `org_id fk organizations ON DELETE CASCADE`, `uploaded_by fk auth.users ON DELETE CASCADE`, `uploaded_at default now()`, `expires_at default now() + interval '24 hours'`, `filename`, `batch_name`, `notes`, `source text CHECK in ('clockify','toggl')`, `date_format`, `time_format`, `parsed_entries jsonb not null`. Indexes on `uploaded_by` and `expires_at`.
- **B38.** RLS (both select and all-modify policies): `uploaded_by = auth.uid() OR is_org_owner(org_id) OR is_super_admin_caller()` — uploader sees/manages own; owner/super-admin see all.
- **B39.** **The 24h expiry is nominal only.** `expires_at` is set and indexed, and the UI says pending imports "last 24h", but **no cron/cleanup job exists and no query filters on `expires_at`** — stale rows accumulate harmlessly and a "24h-old" pending import still commits fine. (Migration comment: "cleanup is a follow-up.")

### H. Resolver page (`/import/resolve?id=<pending_id>`, server component)

- **B40.** Route uses a **query param** `id`, not a path segment. Missing `id` → error page "Missing pending-import id." with back-link. Pending row not found (RLS-hidden, cancelled, or deleted) → "Pending import not found. It may have expired (they last 24h) or someone else cancelled it."
- **B41.** Page header shows: batch_name (or "Unnamed batch") · filename (or "(no filename)") · source · entry count, plus "← Cancel and go back" link.
- **B42.** Loads all org `operators(id,name)`, `clients(id,operator_id,name)`, `projects(id,client_id,name)` ordered by name. Known-ness is computed by **case-insensitive name matching scoped by parent**: operator by `lower(name)`; client by `` `${operator_id}|${lower(name)}` ``; project by `` `${client_id}|${lower(name)}` ``. A project can only be "known" if its operator AND client resolve first (chained).
- **B43.** Distinct names are extracted only from entries where the relevant parts are truthy: `distinctOperators` (sorted), `distinctOpClients` keyed `` `${op}|${client}` ``, `distinctTriples` keyed `` `${op}|${client}|${project}` ``. **Composite keys are pipe-delimited everywhere** (known wart: a name containing `|` corrupts key matching).
- **B44.** **Incomplete buckets:** entries where **any** of operator/client/project is blank are grouped by signature `` `${op ?? ''}|${cl ?? ''}|${pr ?? ''}` `` into buckets `{key, opName|null, clName|null, prName|null, count}`, sorted by count **descending**. (Before 2026-06-09 these rows were silently excluded from the resolver and imported with NULLs = Blocked with no warning.)
- **B45.** Summary panel: per-section known/unknown counts ("N known · M unknown"), plus a red "Missing fields: X rows · Y bucket(s)" stat when buckets exist. If everything is known and there are no buckets: "✓ All names already exist. Click **Import** below to commit N entries." Otherwise a warning explains Create-new / Map-to-existing.
- **B46.** **`canSubmit` gate (page-level):** `canManageImports(eu) && (pending.uploaded_by === eu.effective_user_id || canTransfer(eu))`. Compares against **effective** user id (ADR #033). Managers resolve their own pending imports; owners and super-admins (not in view-as) can resolve anyone's. Fails → "You don't have permission to commit this import." instead of the form. (Server-side, cross-user access is additionally blocked by pending_imports RLS at row load.)

### I. Resolver form (client component `ResolverForm`) — wire contract

- **B47.** Decision encoding: `'create'` or `'map:<uuid>'`. Every unknown row and every incomplete bucket **defaults to `'create'`**.
- **B48.** Hidden fields pair a name with a decision; indices are the original (possibly non-contiguous) array indices:
  - `op_name_<i>` = raw operator name; `op_decision_<i>`
  - `cl_name_<i>` = `` `${opName}|${clName}` ``; `cl_decision_<i>`
  - `pr_name_<i>` = `` `${opName}|${clName}|${prName}` ``; `pr_decision_<i>`
  - `incomplete_key_<i>` = bucket signature; `incomplete_decision_<i>` ∈ `map | create | skip`; `incomplete_target_<i>` = existing project id (from `SearchableCombobox`, options labeled `` `${clientName} : ${projectName}` `` sorted by label); `incomplete_create_op_<i>` / `incomplete_create_cl_<i>` / `incomplete_create_pr_<i>` = free-text inputs **prefilled from the bucket's parsed values**, each with a `<datalist>` of existing names.
- **B49.** Known entities across all three sections fold into ONE collapsed `<details>` accordion: "Already in your org: **N operators · M clients · K projects** (click to view)" with ✓-prefixed lists. (Was three accordions; rian: "the 3 accordions is overkill.")
- **B50.** Unknown-row UI: two radios — "Map to existing:" with an inline `<select>` of candidates (the select carries no form name; the chosen id rides in the radio value via state; choosing map auto-selects the first candidate), and "Create new "<name>"". Map is **disabled with "(no candidates at this scope)"** when the parent scope has no candidates.
- **B51.** Candidate scoping is live: operators → all org operators; clients → clients under the row's resolved operator; projects → clients resolved through **`effectiveClientId`**, a memo that merges server-known client ids with **in-flight client mapping decisions** — so mapping an unknown client immediately populates the project pickers beneath it (2026-05-27 bug fix; previously "(no candidates)" forever). Context labels: "under *op*" / "under *op* / *client*", with "(new operator — will be created)" / "(new client — will be created)" hints that disappear when a mapping is chosen.
- **B52.** Incomplete-bucket UI: identity line `**op** / **cl** / **pr**` with `—` for blanks (prominent — rian demanded this not be "small grey font"), secondary "N rows · missing operator, client, project". Options in visual order **Map to existing project** (radio + searchable combobox; focusing it auto-switches the radio) → **Create new** (radio + 3-column prefilled text grid; focusing auto-switches) → **skip — import as-is (Blocked on /entries)** (tiny, grey, last — "I want to allow skip, but I dont want to emphasis that option"). Default = create (prefilled → one-click commit).
- **B53.** Submit row: primary button "Apply decisions and import" when anything is unknown/incomplete, else just "Import" (`pendingLabel="Importing…"`); secondary "Cancel" (`formAction={cancelPendingImport}`, `pendingLabel="Cancelling…"`). Note shown: "New entities will be created with the names as they appear in the CSV. You can rename them later from Manage entities" (`/projects/manage`).

### J. Commit (`commitPendingImport` in `app/(app)/import/resolve/actions.ts`)

All failures redirect to `/import/resolve?error=<msg>&id=<pendingId>` (user lands back on the resolver and can retry).

- **B54.** Guards, in order: `pending_id` present; org found; `eu` non-null AND `canManageImports(eu)` → else "You do not have permission to import."; pending row loads (RLS-gated) → else "Pending import not found (may have expired)."
- **B55.** Decisions parsed by scanning FormData for `^(op|cl|pr)_name_(\d+)$` keys (tolerates index gaps); the paired `<scope>_decision_<idx>` defaults to `'create'` when absent; `map:` prefix → `{kind:'map', toId}`. Decision-map key = the hidden field's VALUE (raw name for ops; pipe composite for cl/pr).
- **B56.** **Incomplete buckets are processed FIRST** (before the main 3 steps), scanning `incomplete_key_(\d+)`:
  - `map`: non-empty `incomplete_target_<idx>` → target project id; **empty target → silently treated as skip**.
  - `create`: all three `incomplete_create_*` values required (trimmed) → else fail `Create-new requires operator + client + project for the bucket "<key>".`; then run `resolveOrCreateOperator` → `resolveOrCreateClient` → `resolveOrCreateProject` inline; result target = the created/found project id.
  - `skip` (or anything else): bucket target = null.
  Because create chains run first, the main resolution steps below find those entities as already existing.
- **B57.** `resolveOrCreate*` helpers: case-insensitive existence check with `.ilike('name', name)` scoped by parent (org / operator_id / client_id — "so same-named children under different parents resolve correctly"); miss → insert `{org_id, [parent fk], name}`. Failure → `Could not create <kind> "<name>": <db message>`. (Wart: `ilike` with raw CSV names means `%`/`_` act as wildcards; the main steps below use exact lowercase-map matching and don't have this issue. Also: duplicated from `entries/actions.ts` with a different failure redirect.)
- **B58.** **Step 1 — operators.** For each distinct non-empty `entry.operator`: an existing case-insensitive org match **wins over any decision**; otherwise the decision applies — no decision → fail `No decision provided for unknown operator "<name>". Please re-submit the resolver.`; `map` → target id must exist in the loaded operators → else "Mapped operator id not found."; `create` → insert `{org_id, name}` with the raw CSV name. Builds `opResolved` (raw name → id) and `opCanonical` (raw name → **canonical DB name**, post-mapping).
- **B59.** **Step 2 — clients.** For each distinct `` `${op}|${client}` `` pair: resolve `opId` from step 1 (missing → fail `Operator "<op>" wasn't resolved (internal bug). Re-submit the resolver.`); existing match under that operator wins; else decision: `map` target must exist AND satisfy **`target.operator_id === opId`** → else `Mapped client "<name>" is under a different operator than expected.`; `create` → insert `{org_id, operator_id: opId, name}`. Builds `clResolved`/`clCanonical` keyed by the pair.
- **B60.** **Step 3 — projects.** Same shape per distinct triple, with parent check **`target.client_id === clId`** → else `Mapped project "<name>" is under a different client than expected.`; `create` → insert `{org_id, client_id: clId, name}`. Builds `prResolved`/`prCanonical`.
- **B61.** **Team lookup.** `getTeamForUser(org, eu.effective_user_id)` → else fail "You don't have a team — can't compute conversions." Load that team's `team_members` (`id, email, display_name, rate_proportion, consolidate_as, cost_rate_usd, billout_rate_usd`); build `TeamLookup` keyed by `email.toLowerCase()`.
- **B62.** **Batch row insert** into `clockify_imports`: `{ org_id, imported_by: eu.effective_user_id, filename, name: batch_name, row_count: parsedEntries.length, notes, source }`. Note `row_count` = **valid parsed entries**, not raw CSV rows (skipped/errored rows are not persisted anywhere).
- **B63.** **Per-row conversion** (`applyConversion(entry, lookup)` on `source_user_email`):
  - No team-member match → `team_member_id`, `converted_user`, `converted_duration_seconds`, `cost_rate_usd`, `billout_rate_usd` all null (entry imports "unmatched"; Blocked downstream until re-resolved).
  - Match → `converted_user = consolidate_as ?? display_name`; `converted_duration_seconds = Math.round(duration_seconds × rate_proportion)` (**legacy — feeds only the Toggl CSV export**; cost/billout no longer read it, ADR #034).
- **B64.** **Cost stamp (locked at write, ADR #014):** `billout_cost_usd = Math.round((duration_seconds / 3600) × member.cost_rate_usd × 100) / 100`; null if duration or cost rate is null. **SOURCE hours × per-member per-source-hour rate** — `team.base_rate_usd` and proportion are vestigial for money math.
- **B65.** **Billout stamp (locked at write):** `effectiveRate = effective_billout_rate(team_member_id, project_id)` via RPC (project-specific override precedence), memoized per `(team_member_id, project_id)` pair in `effRateCache`; fallback `member.billout_rate_usd` when the RPC returns null; `billout_amount_usd = Math.round((duration_seconds / 3600) × rate × 100) / 100` (`computeBilloutAmount` — despite its parameter name `convertedDurationSeconds`, the action passes SOURCE duration; correct post-ADR #034 behavior). Null if duration or both rates null.
- **B66.** **Canonical renaming + project_id stamping:**
  - All three parts present → `project_id = prResolved[triple]`; the denormalized text columns `operator`/`client`/`project` are **replaced with the canonical DB names** (`opCanonical`/`clCanonical`/`prCanonical`, raw as fallback) — a CSV typo mapped to an existing entity is rewritten ("Vicotria" → "Victoria").
  - Partial row → look up the bucket target by signature `` `${op??''}|${cl??''}|${pr??''}` ``; if a target project was chosen (and its chain prefetched via one `projects → clients → operators` join query), stamp `project_id = target` and **all three text columns from the target's chain**. Otherwise (skip / missing chain): best-effort canonical rewrite of whatever parts exist; blanks stay blank; `project_id` stays null → row renders **Blocked** on `/entries`.
- **B67.** Row shape inserted into `time_entries`: `{ org_id, import_id, project_id, source_user_name, source_user_email, operator, client, project, description, billable, start_at, end_at, duration_seconds, source_rate_usd, source_amount_usd, team_member_id, converted_user, converted_duration_seconds, billout_cost_usd, billout_amount_usd }`. `invoice_id`/`transferred_at` are never set at import.
- **B68.** **Chunked insert:** sequential chunks of **500**. On any chunk error: **rollback = delete `time_entries` where `import_id = batch.id`, then delete the `clockify_imports` row**; the `pending_imports` row is intentionally **kept** so the user can retry from the resolver; fail `Insert failed at rows <i+1>–<i+500>: <msg>`. **Entities created in B56–B60 are NOT rolled back** (no DB transaction — known debris wart).
- **B69.** On success: delete the `pending_imports` row; `revalidatePath` `/import`, `/projects`, `/projects/manage`, `/transfer`, `/entries`; `redirect('/import?import_id=<batch.id>')`.
- **B70.** `cancelPendingImport`: reads `pending_id` (missing → just redirect `/import`), deletes the `pending_imports` row, redirects `/import`. **No app-layer permission/org check — relies entirely on pending_imports RLS.**

### K. Import list + post-import summary (`/import` page)

- **B71.** Batch list: all `clockify_imports` for the org ordered `imported_at desc`; in view-as (as a non-owner) filtered to `getViewAsImportScope` = the batch ids `imported_by` the effective user (sentinel no-match UUID when they have none). Per batch, two count queries on `time_entries`: total (`import_id = b.id`) and transferred (`+ transferred_at not null`) — an accepted N+1 ("small dataset").
- **B72.** Table columns: Name (links to `/entries?batch=<id>`; "(unnamed)" if null) · File · Imported (`toLocaleString()`) · Entries · Transferred · Notes · action. Action: if `transferred_entries > 0` → text "Locked" with tooltip `Can't delete — <n> entries already transferred.`; else (and if `canManageImports`) → `DeleteForm` with confirm text `Delete batch "<name|filename|(unnamed)>" and all <total> entries in it? This cannot be undone.`
- **B73.** Post-import summary (when `?import_id=` matches a batch): green "Import complete" panel with matched (entries with `team_member_id`) vs unmatched counts, and — when unmatched exist — the distinct sorted **unknown emails** with guidance: entries are imported but can't transfer until the team members are added (`/team`), then re-resolved per-entry on `/entries`. Link: "View entries in this batch →" (`/entries?batch=<id>`). Note: unknown-email feedback happens **after** commit, on this summary — the resolver page itself does not surface emails.
- **B74.** Upload panel renders only for `canManageImports`; the batch list renders for anyone who can view the page.

### L. Batch delete (`deleteImportBatch`)

- **B75.** Guards, in order: id present; org; `canManageImports` → else "You do not have permission to delete batches."; view-as scope `guardImport(supabase, eu, org.id, id)` → non-null message fails (e.g. "That row isn't visible to the user you're viewing as. Exit view-as to make this change.").
- **B76.** **Transfer guard (server-side, authoritative):** count batch entries with `transferred_at` not null; `> 0` → fail `Can't delete: <n> entries in this batch have already been transferred. Undo those transfers first.` (The "Locked" label in the UI is cosmetic; this is the real guard.)
- **B77.** Delete order: **entries first** (`time_entries` where org+import_id), then the batch row — because the FK `time_entries.import_id → clockify_imports` is **ON DELETE SET NULL** and deleting the batch first would orphan entries instead of removing them. Then revalidate `/import`, `/transfer`, `/entries` and redirect `/import`.

## Data model

| Table | Role | Columns touched by this feature |
|---|---|---|
| `pending_imports` | Two-step staging (see B37–B39) | `id, org_id, uploaded_by, uploaded_at, expires_at, filename, batch_name, notes, source ('clockify'\|'toggl' CHECK), date_format, time_format, parsed_entries jsonb` |
| `clockify_imports` | Batch (name predates Toggl support; holds `backfill` batches too — **no CHECK on `source`**, unlike pending_imports) | writes `org_id, imported_by (= effective_user_id), filename, name, row_count, notes, source`; reads `imported_at` |
| `time_entries` | One row per parsed entry | writes the B67 shape; reads `transferred_at` (delete guard) and `team_member_id` (summary). FK `import_id → clockify_imports` **ON DELETE SET NULL** |
| `operators` / `clients` / `projects` | 3-level entity hierarchy: operator (CSV "Client" col) → client (left of `" : "`) → project (right of `" : "`). Names unique-ish per parent by convention; resolver creates rows here | `id, org_id, name` (+ `operator_id` on clients, `client_id` on projects) |
| `teams` / `team_members` | Conversion + rate stamping | `teams.owner_user_id` (importer must own a team); `team_members.email` (lookup key, lowercased), `display_name`, `consolidate_as`, `rate_proportion` (legacy), `cost_rate_usd`, `billout_rate_usd` |

Semantics that matter: the `operator/client/project` **text columns on time_entries are a denormalized cache** of the entity chain (kept in sync by rename actions); `project_id` is the source of truth and `NULL project_id ⇒ Blocked` downstream. `billout_cost_usd` / `billout_amount_usd` are **stamped, not derived** — rate changes never retroactively re-cost stored rows (ADR #014). Timestamps are naive (ADR #003). `effective_billout_rate` is a Postgres RPC (`p_team_member_id`, `p_project_id`) implementing project-override > member-default precedence.

## Permissions

- **Upload, commit, delete batches:** `canManageImports(eu)` — org `owner` or `manager`, or super admin **not** in view-as (`lib/permissions.ts`).
- **Resolve someone else's pending import:** page gate `canManageImports(eu) && (pending.uploaded_by === eu.effective_user_id || canTransfer(eu))`; `canTransfer` = org owner, or super admin not in view-as. Backed server-side by pending_imports RLS (`uploaded_by = auth.uid() OR is_org_owner(org_id) OR is_super_admin_caller()`).
- **View-as (ADR #033):** attribution (`uploaded_by`, `imported_by`) always uses `effective_user_id`. In view-as as a non-owner, the batch list is scoped to `getViewAsImportScope` (batches `imported_by` the effective user) and `guardImport` blocks deleting out-of-scope batches.
- **Entry-level knock-on:** `canEditEntry` lets manage-imports users edit imported entries until `invoice_id`/`transferred_at` locks them (then `canTransfer` only).
- **RLS:** entries are scoped per uploader (ADR #027 — managers see only their own batches' entries); `operators`/`clients`/`projects` are `is_org_member(org_id)`-gated (opened 2026-06-09 after Adi's commit failed with an RLS violation inserting a project).
- **`cancelPendingImport` has no app-layer check** — RLS only.

## Edge cases & error behavior

Every guard/failure message is enumerated in B5–B77. Additional warts a rebuild must knowingly fix or reproduce:

1. **Partial parse errors are swallowed** (B9): if 90 of 100 rows parse, the 10 invalid-start rows vanish with no user-visible warning; invalid END date/time is silently nulled with no error at all. `ParseResult.errors`/`totalRows` exist to report this; the UI never shows them.
2. **No DB transaction on commit** (B68): entity creates (including incomplete-bucket chains, which run first) survive a failed/rolled-back commit and a user cancel.
3. **Pipe-delimited composite keys** (B43, B48, B56): a name containing `|` corrupts decision matching end-to-end.
4. **`ilike` matching in resolveOrCreate helpers** (B57): `%`/`_` in names act as wildcards.
5. **24h expiry is a lie in the UI** (B39): no cleanup exists; nothing checks `expires_at`.
6. **`detectDateFormat` edge cases:** `YYYY/MM/DD` (slashed ISO) is misdetected as DD/MM and then every row fails year validation; a CSV mixing ISO with unambiguous DD/MM resolves to DD/MM and the ISO rows silently fail start-date parsing (dash is an accepted separator in the slash regex); mixed DD/MM + MM/DD unsupported by design.
7. **Feb 31 passes date validation** (`d ≤ 31`, no per-month check).
8. **No dedup:** uploading the same CSV twice creates two batches with duplicate entries; pending uploads don't reserve names or hold transactions.
9. **No re-processing:** adding a team member later doesn't re-resolve old unmatched entries — manual per-entry Re-resolve (🔄 on /entries) or delete-and-reimport.
10. **Double-submit** was a real incident ("pending import" error, 06-10); current mitigation is only the `useFormStatus` pending label.
11. **Dead plumbing:** `time_format` (form default → pending_imports column → ignored), `task` header (indexed, never read), `_fmt` param on `parseTimeOnly`.
12. **In-memory parse** — whole file parsed in one server action; fine to ~10 MB, streaming needed beyond.
13. **The "Locked" UI state is client-side courtesy**; only the B76 count query actually protects transferred batches.
14. **Sequential awaited RPC calls** for effective rates (cached per pair, but serial) and N+1 batch counts (B71) — performance, not correctness.

## UI notes

Current UI (see B1–B4, B40–B53, B71–B74) and the taste profile behind it (transcript §17, §4):

- **Progressive disclosure is the organizing principle** rian pushed through three rounds of resolver iteration: primary action ("mapping or creating new projects") front and center; known entities in ONE collapsed accordion ("the 3 accordions is overkill"); Skip allowed but visually buried ("I want to allow skip, but I dont want to emphasis that option").
- **The parsed identity must be prominent**, not "in small grey font. EG: operator: Bowden Works · client: (blank) · project: Brentwood" — buckets lead with a bold op/cl/pr heading, `—` for blanks.
- **Searchable comboboxes** "similar to a google sheet with data validation" — the bucket project picker is the `SearchableCombobox`, same component as /entries edit.
- **Radios inline, order Map → Create → Skip** (his explicit spec), with focus-capture auto-switching the radio when the user touches a control belonging to another mode.
- **Progress feedback on every submit** ("some sort of importing indication would be good") — `useFormStatus` pending labels ("Parsing CSV…", "Importing…", "Cancelling…").
- **Auto-detection over configuration:** he rejected format pickers ("how does the app know what format to expect... ?"); date/time detection + auto-named batches ("checks the min and max date and names it something like Jun 2- Jun 8, 2026") are the direct product. Keep the fallback date selector only for truly ambiguous CSVs.
- **Confirmation counts must match reality** — delete confirm embeds the actual entry count; the post-import summary reports matched/unmatched truthfully.
- Disliked: redundant/nested info, headers "out of whack", actions with no feedback, important data de-emphasized. He gives feedback via screenshots and expects visual mess to be recognized unprompted.

## Rebuild deltas

Per `docs/rebuild/00-vision-and-scope.md`, `02-domain-model.md`, `04-architecture.md`, `07-judgment-calls.md`:

- **Toggl import path retired** (00 §drop-list; 07 #8): the new importer speaks **Clockify + manual entry only**; historical Toggl rows migrate as plain data. Clockify remains the ingest path — no native tracker in scope (07 #14); the pipeline is ported **faithfully, with its CLK-* suite** as fixtures.
- **`clockify_imports` + `pending_imports` → `import_batches`** (+ staged payloads), keeping the two-step flow, resolver (map/create/skip per unknown and per incomplete bucket), and date-range auto-naming (02 §import_batches). `time_entries.source` gets a CHECK of `clockify | toggl | manual | backfill` — the historical `toggl`/`backfill` values are admitted so migrated rows keep their provenance; the importer only ever *writes* `clockify`/`manual` (02 §3 time_entries).
- **Text triple carried as `raw_operator`/`raw_client`/`raw_project`** (02 §3 time_entries): the as-imported triple becomes an **immutable import payload**, not a display cache — provenance on resolved rows (which display/group/join via FKs exclusively) and the ONLY identity /entries and the resolver have for unassigned/Blocked rows. Only the display-cache *role* is dropped: canonical-renaming's second half goes away (B66 text rewriting becomes moot — raw_* keeps the CSV spelling; project_id stamping stays).
- **`rate_proportion`, `converted_user`, `converted_duration_seconds`, `transferred_at` dropped** (02, ADR #034 + Toggl phase-out): B63's conversion math disappears; the **delete guard (B75–B76) re-keys from `transferred_at` to invoice-line attachment** ("applied to invoice" replaces "transferred" everywhere).
- **Architecture** (04): parser ports to Python `services/imports/` (clockify.py); resolve-or-create chains live in ONE implementation (`services/entities/`), killing the B57 duplication; commit becomes transactional server-side logic (fixes edge cases 1–4); Playwright smoke covers import→resolve→commit end-to-end; every mutation audited.
- Carry-over expectations: auto-detection over configuration, stamped-at-write money, two-step human resolution (02 §invariants #4), per-uploader scoping superseded by the engagement-based permission engine (`can_manage_imports` capability).

## Test specs

Existing automated suite (vitest, `tests/clockify.test.ts`) — carry over as-is; note the file **reuses IDs CLK-021/CLK-022 across describes** (dedupe in the rebuild catalog):

- T-CLK-001: given `2026-06-02` + fmt YYYY-MM-DD / when parseDateOnly / then `2026-06-02`.
- T-CLK-002: given `6/2/2026` + MM/DD/YYYY / then `2026-06-02`.
- T-CLK-003: given `6/2/2026` + DD/MM/YYYY / then `2026-02-06` (same input differs by selector).
- T-CLK-004: given garbage / then null.
- T-CLK-005: given month>12 or day>31 / then null.
- T-CLK-006: given 2-digit year `6/2/26` / then year 2026.
- T-CLK-007: given `13:45:30` / when parseTimeOnly / then `13:45:30`.
- T-CLK-008: given `1:45:30 PM` / then `13:45:30`.
- T-CLK-009: given `12:00 AM` → `00:00:00`; `12:00 PM` → `12:00:00`.
- T-CLK-010: given malformed time / then null.
- T-CLK-011: given `2:30:15` duration / then 9015 s.
- T-CLK-012: given `2:30` duration / then 9000 s.
- T-CLK-013: given `2.5` duration / then 9000 s.
- T-CLK-014: given CSV with blank lines / when parseClockifyCsv / then one entry per non-empty data row.
- T-CLK-015: given CSV missing a required header / then row-0 error naming the missing keys, zero entries.
- T-CLK-016: given multi-row CSV / then uniqueEmails collected + dateRange = min/max start dates.
- T-CLK-017: given `Adi@Example.COM` / then email lowercased.
- T-CLK-018: given email not in TeamLookup / when applyConversion / then all five outputs null.
- T-CLK-019: given member with consolidate_as / then converted_user = consolidate_as; without → display_name.
- T-CLK-020: given duration + rate_proportion / then converted_duration_seconds = round(duration × proportion).
- T-CLK-021a (file: "CLK-021 splitClientProject"): given `A : B : C` / then split on FIRST `" : "` → client `A`, project `B : C`; no separator → client null.
- T-CLK-022a (file: "CLK-022 parseClockifyCsv splits Project field"): given Clockify row / then CSV Client col → `operator`, Project col splits into `client` + `project`.
- T-CLK-021b (file: "CLK-021 surfaces billout_rate_usd"): applyConversion passes member billout_rate_usd through.
- T-CLK-022b (file: "CLK-022"): computeBilloutAmount null if either input null.
- T-CLK-023: computeBilloutAmount = seconds/3600 × rate, rounded to 2 dp.
- T-CLK-026: `parseTimeOnly('13:45:30', '12h')` still parses 24h (fmt arg ignored).
- T-CLK-027: `parseTimeOnly('1:45 PM', '24h')` still parses 12h (fmt arg ignored).
- T-CLK-028: given plain `1:45:30` (no suffix) / then treated as 24h `01:45:30`.
- T-CLK-029: detectDateFormat picks ISO on 4-digit-year prefix.
- T-CLK-030: picks DD/MM when ANY first slot > 12.
- T-CLK-031: picks MM/DD when ANY second slot > 12 and no DD signal.
- T-CLK-032: returns null when nothing parses.
- T-CLK-032b: ambiguous week June 1–8 written DD/MM / then DD/MM (1 month beats scattered).
- T-CLK-032c: US weekly export June 1–8 in MM/DD / then MM/DD.
- T-CLK-032d: both interpretations span equal months / then null (form fallback).
- T-CLK-033: blank/unparseable entries ignored by detector.
- T-CLK-034: mixed ISO + numeric rows — first unambiguous signal wins.
- T-CLK-040: range within one month → `Jun 2 – Jun 8, 2026`.
- T-CLK-041: cross-month same-year → `May 28 – Jun 3, 2026`.
- T-CLK-042: cross-year → `Dec 28, 2025 – Jan 3, 2026`.
- T-CLK-043: single-day → `Jun 2, 2026`.
- T-CLK-044: null range → null.

Catalog-only (never automated) — implement in the rebuild:

- T-IMP-001: given a valid Clockify CSV / when upload→resolve→commit / then one batch + N time_entries rows exist.
- T-IMP-002: given rows with unknown emails / when committed / then those rows have team_member_id NULL (and null converted/cost/billout fields) and the post-import summary lists the distinct emails.
- T-IMP-003: given a batch with ≥1 transferred/invoiced entry / when delete attempted / then server refuses with the count in the message (UI "Locked" is not the guard).

New specs for uncovered behaviors:

- T-IMP-004: given no file or empty file / when upload / then "Please choose a CSV file to upload."
- T-IMP-005: given a 26 MB file / then "File too large (25MB max)."
- T-IMP-006: given user without manage-imports / then upload, commit, and delete all refuse.
- T-IMP-007: given importer with no team / when upload / then fail-fast "You don't have a team…" before staging.
- T-IMP-008: given blank batch name + parseable date range / then batch auto-named via formatDateRangeLabel; given no usable range / then filename sans extension; given typed name / then typed name wins.
- T-IMP-009: given Toggl headers (Member/Stop date/Stop time, no Billable) / when parsed / then columns map via aliases and billable defaults true. *(Rebuild: replace with a Clockify-only header test; Toggl dialect retired.)*
- T-IMP-010: given billable column with `yes`/`TRUE`/`1`/`y`/`no` / then true×4, false.
- T-IMP-011: given a row with invalid start date/time / then row skipped and error recorded `Row <n>: invalid start date/time (…)`; given invalid END only / then end_at null, row kept, no error. *(Rebuild: surface both to the user — current swallowing is a documented wart.)*
- T-IMP-012: given upload succeeds / then pending_imports row holds `parsed_entries` jsonb, `uploaded_by = effective_user_id`, and browser lands on `/import/resolve?id=<uuid>`.
- T-IMP-013: given resolver where all names known and no buckets / then "✓ All names already exist" and button reads "Import"; with unknowns → "Apply decisions and import".
- T-IMP-014: given an unknown client mapped to an existing client / then project pickers under it immediately show that client's projects (effectiveClientId reactivity).
- T-IMP-015: given a mapped decision whose target belongs to a different parent / when commit / then "Mapped client/project … is under a different operator/client than expected." and nothing inserted.
- T-IMP-016: given a stale form missing a decision for an unknown name / then "No decision provided for unknown operator…" (op) / "No decision for unknown client…" / "No decision for unknown project…".
- T-IMP-017: given create decisions / then entities inserted with raw CSV names, chained (org→operator→client→project), and entries stamped with the new project_id.
- T-IMP-018: given a CSV typo mapped to an existing entity / then the time_entries text columns carry the CANONICAL entity name, not the CSV spelling. *(Rebuild note: becomes moot under 02 §3 — `raw_*` keeps the CSV spelling as immutable provenance and display resolves via the mapped FK; the rebuild's equivalent assertion is that the FK points at the canonical entity while raw_* preserves the original text.)*
- T-IMP-019: given an incomplete bucket resolved by map / then all rows in the bucket get the target project_id AND the target chain's three text names; by create → chain created then same; by skip → NULLs kept, rows Blocked on /entries.
- T-IMP-020: given incomplete bucket "map" with no combobox selection / then treated as skip (no error).
- T-IMP-021: given incomplete bucket "create" with any of the three fields blank / then fail `Create-new requires operator + client + project for the bucket "<key>".`
- T-IMP-022: given matched member / then billout_cost_usd = round2(source hours × cost_rate_usd) and billout_amount_usd = round2(source hours × (effective_billout_rate ?? member billout_rate_usd)); project override wins over member default.
- T-IMP-023: given >500 parsed rows / then insert happens in 500-row chunks; given a failing chunk / then all inserted entries + the batch row are deleted, the pending row survives, and the resolver shows `Insert failed at rows <a>–<b>: …`.
- T-IMP-024: given successful commit / then pending row deleted and redirect to `/import?import_id=<batch>` shows the matched/unmatched summary card.
- T-IMP-025: given Cancel on the resolver / then pending row deleted, redirect `/import`, no batch/entries created.
- T-IMP-026: given a manager viewing another user's pending import / then RLS hides it ("Pending import not found…"); given an owner / then they can resolve anyone's (canSubmit).
- T-IMP-027: given view-as (super admin as manager) / then upload/commit attribute `uploaded_by`/`imported_by` to the EFFECTIVE user; batch list filters to the effective user's batches; deleting an out-of-scope batch is refused by guardImport.
- T-IMP-028: given batch delete allowed / then entries deleted BEFORE the batch row (FK is ON DELETE SET NULL) and no orphans remain.
- T-IMP-029: given batch commit / then clockify_imports.row_count equals parsed-entry count (not raw CSV row count).
- T-IMP-030 (regression, the "26-01-08" incident): given a fully-ambiguous weekly CSV whose true format is DD/MM / when uploaded with the form default MM/DD / then all entries land in one month (tightest-span detector overrides the selector).
