Three threads from the recent UX iteration:
1. Reminder list / detail no longer shows raw "Cron: 32 11 * * *"
----------------------------------------------------------------
`describeRecurrence` for a kind=cron spec used to emit
"Cron: <expr>" verbatim, which is unreadable on the list row's
recurrence line.
New pure helper `describeCronRule(rule)` parses the cron shapes
the recurrence picker produces and renders them as natural
sentences:
"0 9 * * *" → "Every day at 09:00"
"0 9 * * 1-5" → "Every week on Mon, Tue, Wed, Thu, Fri at 09:00"
"0 9 * * 1,3,5" → "Every week on Mon, Wed, Fri at 09:00"
"0 9 1,15 * *" → "Every month on days 1, 15 at 09:00"
"0 9 13 5 *" → "Every year in May on day 13 at 09:00"
"30 17 1,15 1,4,7,10 *" → "Every year in Jan, Apr, Jul, Oct on days 1, 15 at 17:30"
Multi-line rules ("0 9 * * 1\n0 17 * * 5") join the per-line
descriptions with " · " for compactness in the list density.
Long DOM lists (>6 days) collapse with a "+N more" tail to keep
the line short; same convention the picker's per-row preview uses.
Unrecognised shapes (e.g. "*/5 * * * *") fall back to the raw
expression — better than swallowing entirely.
2. HEIC/AVIF magic-byte sniffing at upload
----------------------------------------------------------------
The mime-only check we shipped earlier missed iOS Safari's
habit of uploading HEIC photos with Content-Type: image/jpeg.
The file then made it to the bot, where Sharp's HEIF decoder
plugin is missing, the thumbnail extraction failed, and the
message went out without a working preview — read by the user
as "image still not send".
New helper `sniffUnsupportedImage(bytes)` reads bytes 4..11 of
the upload and looks for the ISOBMFF "ftyp" marker followed by
one of the brands Sharp can't decode (HEIF: heic / heix / hevc
/ heim / heis / mif1 / msf1; AVIF: avif / avis). Brand match is
case-insensitive. Plain JPEG / PNG / unrelated ftyp brands like
mp4 are not flagged.
`uploadMediaAction` now runs the sniff against the buffered
bytes before persisting, returning the same "Images are not
supported, please re-upload images" error as the mime path.
3. Sidebar brand link → dashboard tests
----------------------------------------------------------------
Asserts the desktop <aside> contains an <a href="/" aria-label=
"Go to dashboard"> at the top, scoped via a new extractSidebar
helper so it can't accidentally match the mobile-header brand
link (which uses aria-label="Go home"). A second test confirms
the two aria-labels stay distinct.
22 web test files / 232 passing (was 212):
- +12 cron-description cases in lib/recurrence.test.ts
- +6 magic-byte sniff cases in lib/whatsapp-media.test.ts
- +2 sidebar-brand-link cases in app-shell.test.tsx
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous flat radio list with N-minutes / N-hours / Custom-cron
options is gone. Per the Temenos UUX `date-recurrence-picker` pattern
(developer.temenos.com/uux/docs/components/date-recurrence-picker), the
form now shows a single read-only trigger field summarising the saved
rule:
┌────────────────────────────────────────────────────┐
│ 📅 Don't repeat ▾ │
└────────────────────────────────────────────────────┘
Clicking the trigger opens a modal with the recurrence types as a
tab strip and per-type config swapped in below:
┌──────────────────────────────────────────────────┐
│ Repeat schedule ✕ │
├──────────────────────────────────────────────────┤
│ [Don't repeat] [Daily] [Weekly] [Monthly] [Yearly] │
│ │
│ <per-type config> │
│ │
│ Fires: <plain-text confirmation> │
│ │
│ [Reset] [Cancel] [Save] │
└──────────────────────────────────────────────────┘
Per-tab config:
- Don't repeat — informational text only
- Daily — radio: "Every day" / "Every weekday (Mon–Fri)"
- Weekly — Mon..Sun chip multi-select
- Monthly — day-of-month input (1-31)
- Yearly — month select + day input
The "Fires: …" sentence updates live as the user edits and reflects
the outer time-picker's HH:MM. Save commits, Cancel discards.
Removed:
- Every N minutes
- Every N hours
- Custom cron expression…
- The standalone helpers `flowToCron` / `flowFromCron` /
`freqChoices` / `defaultFlowState` / `FlowState` / `FreqChoice`
in `lib/recurrence.ts`. Their job (compile a UI state to a cron
string and parse one back) now lives privately inside the picker.
Storage / runtime
- Output is still a `CRON:` prefixed rule in `reminders.rrule`. The
bot's `nextOccurrence` already dispatches cron rules through
cron-parser, so no schema or scheduler changes were needed.
Tests (132 web)
- recurrence.test.ts trimmed to keep only what survives: CRON-rule
round-trip via buildRrule + specFromRrule, and the ISO→cron
weekday helper.
- Existing wizard / edit-when-form integration tests are unaffected
because the picker exports the same `<RecurrencePicker>` props.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the user's ask: stop dumping 12 presets in front of the user. Walk
them through "pick a frequency, then configure it." Each choice
expands its config inline below the radio.
Picker (now 8 top-level choices):
○ Don't repeat (one-off)
○ Every N minutes → number input (1-59)
○ Every N hours → number input (1-23) at :MM
○ Every day at HH:MM (uses outer time picker)
○ Every week at HH:MM → weekday chip multi-select
○ Every month at HH:MM → day-of-month input (1-31)
○ Every year at HH:MM → month select + day input
○ Custom cron expression… → free-form textbox
Behaviour:
- Selecting a row reveals only that row's config; the others stay
collapsed so the screen stays calm.
- HH:MM in every "at HH:MM" label tracks the outer time picker — change
the time and every label updates instantly. Same for the cron
expression the picker emits.
- Every config change recompiles to a single cron string and pushes a
`{ kind: "cron", cron: "..." }` spec up to the parent. Empty weekday
list yields null (config not yet valid).
- Editing an existing reminder calls `flowFromCron(rule, firstFire)`
which reverse-engineers a flow state from the stored cron — including
expanding `1-5` ranges into a weekday chip list — so the right radio
is highlighted and config inputs are pre-populated.
- Anything not recognised by `flowFromCron` (legacy RRULE, hand-rolled
cron) lands on "Custom cron expression…" with the literal expression
in the textbox.
Helpers in `lib/recurrence.ts`:
- `FreqChoice` ("none" | "minute" | "hour" | "day" | "week" | "month"
| "year" | "cron") + `FlowState` interface with all config fields.
- `freqChoices(firstFire)` → first-fire-aware label list for the radio.
- `defaultFlowState(firstFire)` → seeds sensible defaults (today's
weekday, day-of-month, month, etc.).
- `flowToCron(flow, firstFire)` → cron string or null. Clamps
out-of-range integers.
- `flowFromCron(rule, firstFire)` → best-effort reverse mapping.
- `isoWeekdayToCron(iso)` → maps ISO 1-7 (Mon..Sun) to cron 0-6
(Sun..Sat).
Removed: the previous `presetToSpec` / `matchPreset` / `presetDescriptors`
+ `presetCron` family. They're superseded by the flow helpers.
Tests (+11 in recurrence.test.ts; total 139 web + 26 bot + 17 shared
= 182):
- freqChoices order and time-bearing labels
- flowToCron for every freq + config combination, including empty
weekday list returning null
- clamp behaviour for out-of-range minute/month-day/month integers
- isoWeekdayToCron for Mon..Sun
- defaultFlowState seeded fields
- flowFromCron round-trips every flow output exactly
- BYDAY range expansion (1-5 → [1,2,3,4,5])
- unrecognised expressions land on the cron textbox
- buildRrule + specFromRrule still handle CRON: prefixed strings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the user's ask: drop the friendly RRULE-based shortcuts (Daily /
Weekly / Custom… etc.) — every selectable preset is now a cron
expression. Schedules are stored in `reminders.rrule` with the
`CRON:` sentinel and dispatched via the existing cron-aware
`nextOccurrence` helper.
Picker
- "Don't repeat" stays at the top (one-off, no cron).
- 11 cron-flavoured presets, each with its underlying cron expression
shown as the hint:
Every minute * * * * *
Every 5 minutes */5 * * * *
Every 15 minutes */15 * * * *
Every 30 minutes */30 * * * *
Every hour at :MM MM * * * *
Every day at HH:MM MM HH * * *
Every weekday at HH:MM MM HH * * 1-5
Every weekend at HH:MM MM HH * * 0,6
Every <DOW> at HH:MM MM HH * * <cron-dow>
Every month on day D at HH:MM MM HH D * *
Every year on Mon D at HH:MM MM HH D M *
- Labels are first-fire-aware: changing the time picker re-derives
every "at HH:MM" label and the preset's canonical cron string.
- "Custom cron expression…" reveals a free-form text input for
anything not covered by the presets.
- Removed: the old "Custom" RRULE detail panel (frequency dropdown,
weekday picker, monthday input, end-condition picker).
Storage
- `presetToSpec("none")` → kind:"none". Every other preset →
kind:"cron" with its canonical cron string.
- `matchPreset` compares the spec's cron expression against each
preset's canonical cron for the current first-fire — falls back to
"cron" (custom textbox) for anything else, including legacy RRULE
reminders that haven't been re-saved yet. Existing RRULE reminders
keep firing on the bot side (nextOccurrence still dispatches both).
- `presetCron(id, firstFire)` is a small pure helper; ISO weekday
(1=Mon..7=Sun) maps to cron weekday (0=Sun..6=Sat).
Tests (+8 in recurrence.test.ts; 137 web + 26 bot + 17 shared = 180)
- presetToSpec emits the right cron for every recurring preset
including Sunday → cron weekday 0.
- matchPreset round-trips through presetToSpec for every preset.
- matchPreset returns "cron" for arbitrary (non-preset) cron strings.
- presetDescriptors lists exactly the cron-only items in order with
first-fire-aware labels ("Every weekday at 09:00", "Every Wed at
09:00", "Every year on May 13 at 09:00", "Custom cron expression…").
- buildRrule produces CRON: prefixed strings for cron presets and
null for "none".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The custom RRULE panel covers most patterns but can't express "every
weekday at 9, 12, and 18" or "every 15 minutes within working hours"
in a single rule. RRULE's BYxxx fields can technically combine, but
the picker UX gets unwieldy fast. Cron expressions cover everything
in one line.
Storage / dispatch
- Cron rules live in the same `reminders.rrule` column with a sentinel
prefix: `CRON:0 9 * * 1-5`. No schema change.
- `@cmbot/shared` now exports:
CRON_PREFIX, isCronRule, stripCronPrefix, validateCronExpression
- `nextOccurrence(rule, tz, after)` and `validateMinInterval(rule, tz)`
detect the prefix and dispatch to `cron-parser`; non-cron rules
continue to flow through rrule unchanged.
- `cron-parser@^5.5.0` added as an explicit dep on @cmbot/shared
(it was already transitively present via pg-boss).
Server actions
- `createReminderAction` / `updateReminderAction`: when rrule has the
CRON: prefix, the user's date+time inputs are ignored — the action
validates the cron, runs the min-interval check (5 min between
fires), and computes scheduledAt as the next match of the cron
expression after now. The bot's existing fire-reminder loop
re-arms via `nextOccurrence` after each fire, which already speaks
cron via the dispatch above.
Picker
- New "Cron expression…" preset at the bottom of the radio list:
"Full sec/min/hour/day/month/dow combinational power"
Selecting it reveals a CronPanel:
* font-mono cron input (5- or 6-field accepted)
* inline examples: 0 9 * * 1-5, */15 * * * *, 0 9,12,18 * * *,
0 0 1 * *
* note that the Date+Time controls above are ignored once a cron
expression is set
- RecurrenceSpec gains an optional `cron` string and a new `kind: "cron"`.
- buildRrule emits `CRON:<expr>` for cron specs.
- specFromRrule round-trips a CRON-prefixed rule back into the spec.
- describeRecurrence renders "Cron: <expr>" so the list view and
review steps show the expression.
Tests (+10; 17 shared + 26 bot + 138 web = 181 total)
- packages/shared rrule.test.ts (+8):
* CRON_PREFIX / isCronRule / stripCronPrefix
* nextOccurrence on a CRON rule returns the right next match in the
operator timezone (e.g. weekday 9 AM KL ↔ exact UTC instant)
* RRULE rules still flow through unchanged
* validateMinInterval on cron: hourly OK, every-minute rejected,
malformed string returns a useful error
* validateCronExpression positive + negative cases
- recurrence.test.ts (+5): cron preset round-trip, label assertions,
`buildRrule`/`specFromRrule` for cron specs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old picker was a row of 5 frequency pills (One-off / Daily / Weekly /
Monthly / Yearly) followed by a separate detail panel — common cases
needed several clicks (interval, weekday list, etc.) and the visual
hierarchy didn't show what was selected at a glance.
New design — a vertical radio list with seven first-fire-aware presets
plus a Custom… expander:
○ Don't repeat (one-off)
○ Every day
○ Every weekday (Mon – Fri)
○ Every weekend (Sat – Sun)
○ Every week on Wed (matches start)
○ Every month on day 13 (matches start)
○ Every year on May 13 (matches start)
○ Custom… ▼ expands
Custom… reveals the existing power-user controls (frequency dropdown,
interval input, weekday picker, day-of-month, end-condition) without
crowding the common path. Toggling between presets and custom is
lossless — the spec is the source of truth.
New helpers in `lib/recurrence.ts`:
- `presetToSpec(id, firstFire)` — canonical RecurrenceSpec for each
preset (round-trippable).
- `matchPreset(spec, firstFire)` — reverse mapping; returns "custom"
for anything that doesn't fit a shortcut, so the picker auto-flips
into expanded mode for non-preset specs.
- `presetDescriptors(firstFire)` — list of preset id/label/hint with
first-fire-aware copy ("Every week on Wed", "May 13", etc).
Wired into both:
- reminder-wizard/when-form-client.tsx (creating)
- reminder-edit/edit-when-form.tsx (editing a section in place)
Tests (+4, 134 web + 26 bot = 160 total green):
- recurrence.test.ts gains a "preset shortcuts" suite covering:
* presetToSpec → canonical spec for each id
* round-trip via matchPreset
* matchPreset returns "custom" for non-shortcut specs
(interval > 1, weekly Mon/Wed/Fri, end=after, monthly on a
different day-of-month than the first fire)
* presetDescriptors labels are first-fire-aware
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reminders
- Reminder list / detail show recurrence summary ("Every Mon, Wed, Fri",
"Every 2 weeks until 2027-01-01", etc).
- Detail page reorganised: each section (Account / Message / When /
Groups) is itself a clickable card that deep-links into the wizard
step in edit mode (editReminderId URL param). No standalone Edit
button. Run history stays read-only.
- New /reminders/[id]/edit shell loads the row, encodes its state into
wizard URL params, and forwards to /reminders/new. The wizard
threads editReminderId through every step.
- updateReminderAction: validates ownership of both the existing
reminder and the (possibly changed) target account, replaces targets
+ messages wholesale, re-arms the pg-boss job (singleton key picks
up the new fire time).
- Wizard submit branches to updateReminderAction when editReminderId
is set; button reads "Save changes" / "Saving…".
- Wizard default first-fire is now the current minute in the operator
zone (not now+1h). Same-minute clicks bump silently to next minute
via a 60 s grace window so the user isn't punished.
- /reminders empty state is filter-aware: "No failed reminders yet."
when ?filter=failed and there are reminders in other states.
Recurrence
- Spec is now a structured object: { kind, interval, weeklyDays,
monthDay, end }. Builder produces RRULEs with INTERVAL, BYDAY,
BYMONTHDAY, COUNT, UNTIL as appropriate. specFromRrule round-trips
for resuming/edit.
- When-step UI: frequency pills, "Every N days/weeks/…" interval,
weekday picker (weekly), day-of-month input (monthly), end picker
(Never / After N occurrences / On date), live human-readable
summary preview.
QR pairing
- Throttle QR refresh to once per 25 s and detach the previous
per-account session listener on Re-pair so listeners can't
accumulate. The UI countdown was flicking every ~5 s because each
Re-pair attached an extra listener — every Baileys QR event then
triggered a fresh DB write + NOTIFY.
Tests (60 green total, +33 in this batch)
- recurrence.test.ts: extended to 25 tests covering interval,
monthday, end conditions (COUNT/UNTIL), and round-trip parsing.
- date-picker.test.ts: 14 tests for splitDateTime / combineDateTime /
validateScheduledAt (incl. the "click-too-fast" same-minute grace)
and defaultFirstFireIso.
- /api/qr/[accountId] route.test.ts: 4 tests — 404 when no QR yet,
404 on missing row, 200 with image/png + no-store + correct PNG
bytes, and verifies the where-clause queries by accountId.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reminders
- Add recurrence to wizard step 3 (None / Daily / Weekly+weekday picker /
Monthly / Yearly). Build the RRULE client-side and thread it through
the wizard URL state.
- Action stores rrule + scheduleKind="recurring" on insert.
- Bot reschedules the next occurrence after firing a recurring reminder
using the existing rrule helpers in @cmbot/shared. One-off behavior
unchanged.
- Add reminders.last_fired_at column to track last fire.
Pairing
- Move QR PNG out of the pg_notify payload (the 8000-byte limit was
silently truncating it; QR never reached the web → "QR hang"). PNG
now lives on whatsapp_accounts.last_qr_png; NOTIFY just signals
{type: session.qr, accountId, ts}. Web fetches the bytes from a new
read-only /api/qr/[accountId] route (allowed via middleware).
- handleStartPairing now stops any in-flight session before starting a
fresh one — fixes Re-pair where session.start was a silent no-op and
Baileys never re-emitted QR.
- Pair-live: countdown moved out from over the QR (it was overlapping
the scan area); shown as a discrete progress bar above the QR.
- Add a "Save QR" download button.
Account detail page
- Pair / Unpair / Delete cards are themselves the trigger (form submit
or DialogTrigger) — no inline buttons, whole card is clickable.
- Sync Groups Now card removed earlier; bot already auto-syncs.
Account list page
- Cards are the link target. A small floating Delete trigger (top-right
trash icon) opens the destructive confirm dialog without blocking
navigation on the rest of the card.
Tests
- recurrence.test.ts: 10 tests for buildRrule / kindFromRrule /
describeRecurrence (incl. weekly day combos and BYDAY ordering).
- reminders.schema.test.ts: regression for the "Invalid datetime" bug —
proves strict Zod .datetime() rejected luxon's offset ISO and the
{ offset: true } option accepts both forms.
Migration: 0004_next_prowler.sql
- whatsapp_accounts.last_qr_png (text)
- reminders.last_fired_at (timestamptz)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>