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>
359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
import { DateTime } from "luxon";
|
||
|
||
export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly" | "cron";
|
||
export type EndKind = "never" | "after" | "on";
|
||
|
||
const CRON_PREFIX = "CRON:";
|
||
|
||
const WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const;
|
||
|
||
export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; long: string }> = [
|
||
{ iso: 1, code: "MO", short: "Mon", long: "Monday" },
|
||
{ iso: 2, code: "TU", short: "Tue", long: "Tuesday" },
|
||
{ iso: 3, code: "WE", short: "Wed", long: "Wednesday" },
|
||
{ iso: 4, code: "TH", short: "Thu", long: "Thursday" },
|
||
{ iso: 5, code: "FR", short: "Fri", long: "Friday" },
|
||
{ iso: 6, code: "SA", short: "Sat", long: "Saturday" },
|
||
{ iso: 7, code: "SU", short: "Sun", long: "Sunday" },
|
||
];
|
||
|
||
export interface RecurrenceSpec {
|
||
kind: RecurrenceKind;
|
||
/** Every N units. Defaults to 1. Ignored for `none` and `cron`. */
|
||
interval: number;
|
||
/** ISO weekday numbers (1=Mon..7=Sun). Used for `weekly`. */
|
||
weeklyDays: number[];
|
||
/** Day-of-month for `monthly` (1-31). If omitted, falls back to firstFire.day. */
|
||
monthDay?: number;
|
||
/** Cron expression — only meaningful when kind === "cron". */
|
||
cron?: string;
|
||
/** End condition. */
|
||
end:
|
||
| { kind: "never" }
|
||
| { kind: "after"; count: number }
|
||
| { kind: "on"; until: string /* ISO date YYYY-MM-DD */ };
|
||
}
|
||
|
||
export const DEFAULT_RECURRENCE: RecurrenceSpec = {
|
||
kind: "none",
|
||
interval: 1,
|
||
weeklyDays: [],
|
||
end: { kind: "never" },
|
||
};
|
||
|
||
function clampInterval(n: number): number {
|
||
if (!Number.isFinite(n) || n < 1) return 1;
|
||
return Math.floor(n);
|
||
}
|
||
|
||
/**
|
||
* Build an RRULE string. Supports interval, weekday list, monthday, and the
|
||
* end condition (COUNT or UNTIL). Returns null for one-off reminders.
|
||
*/
|
||
export function buildRrule(spec: RecurrenceSpec, firstFire: DateTime): string | null {
|
||
if (spec.kind === "none") return null;
|
||
if (spec.kind === "cron") {
|
||
return spec.cron ? `${CRON_PREFIX}${spec.cron.trim()}` : null;
|
||
}
|
||
|
||
const parts: string[] = [];
|
||
switch (spec.kind) {
|
||
case "daily":
|
||
parts.push("FREQ=DAILY");
|
||
break;
|
||
case "weekly": {
|
||
parts.push("FREQ=WEEKLY");
|
||
const days =
|
||
spec.weeklyDays.length > 0
|
||
? spec.weeklyDays
|
||
: [firstFire.weekday];
|
||
const codes = days
|
||
.slice()
|
||
.sort((a, b) => a - b)
|
||
.map((d) => WEEKDAY_CODES[d - 1])
|
||
.filter(Boolean);
|
||
parts.push(`BYDAY=${codes.join(",")}`);
|
||
break;
|
||
}
|
||
case "monthly":
|
||
parts.push("FREQ=MONTHLY");
|
||
parts.push(`BYMONTHDAY=${spec.monthDay ?? firstFire.day}`);
|
||
break;
|
||
case "yearly":
|
||
parts.push("FREQ=YEARLY");
|
||
parts.push(`BYMONTH=${firstFire.month}`);
|
||
parts.push(`BYMONTHDAY=${firstFire.day}`);
|
||
break;
|
||
}
|
||
|
||
const interval = clampInterval(spec.interval);
|
||
if (interval !== 1) parts.push(`INTERVAL=${interval}`);
|
||
|
||
if (spec.end.kind === "after" && spec.end.count > 0) {
|
||
parts.push(`COUNT=${Math.floor(spec.end.count)}`);
|
||
} else if (spec.end.kind === "on" && spec.end.until) {
|
||
// RRULE UNTIL is a UTC timestamp. Translate the user's "on this date"
|
||
// into 23:59:59 UTC of that day so the last occurrence is included.
|
||
const dt = DateTime.fromISO(`${spec.end.until}T23:59:59`, { zone: "utc" });
|
||
if (dt.isValid) {
|
||
parts.push(`UNTIL=${dt.toFormat("yyyyMMdd'T'HHmmss'Z'")}`);
|
||
}
|
||
}
|
||
|
||
return parts.join(";");
|
||
}
|
||
|
||
const FREQ_UNIT: Record<string, string> = {
|
||
daily: "day",
|
||
weekly: "week",
|
||
monthly: "month",
|
||
yearly: "year",
|
||
};
|
||
|
||
/**
|
||
* Render the spec as a human sentence, e.g.
|
||
* "Every day"
|
||
* "Every 2 weeks on Mon, Wed, Fri"
|
||
* "Every month on day 14, 12 times"
|
||
* "Every year on May 13, until 2027-05-13"
|
||
*/
|
||
export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string {
|
||
if (spec.kind === "none") return "One-off";
|
||
if (spec.kind === "cron") {
|
||
return spec.cron ? describeCronRule(spec.cron) : "Cron (not configured)";
|
||
}
|
||
|
||
const interval = clampInterval(spec.interval);
|
||
const unit = FREQ_UNIT[spec.kind]!;
|
||
const head =
|
||
interval === 1 ? `Every ${unit}` : `Every ${interval} ${unit}s`;
|
||
|
||
let body = "";
|
||
if (spec.kind === "weekly") {
|
||
const days = spec.weeklyDays.length > 0 ? spec.weeklyDays : [firstFire.weekday];
|
||
const labels = days
|
||
.slice()
|
||
.sort((a, b) => a - b)
|
||
.map((d) => WEEKDAY_LABELS[d - 1]?.short)
|
||
.filter(Boolean)
|
||
.join(", ");
|
||
body = ` on ${labels}`;
|
||
} else if (spec.kind === "monthly") {
|
||
body = ` on day ${spec.monthDay ?? firstFire.day}`;
|
||
} else if (spec.kind === "yearly") {
|
||
body = ` on ${firstFire.toFormat("MMM d")}`;
|
||
}
|
||
|
||
let tail = "";
|
||
if (spec.end.kind === "after" && spec.end.count > 0) {
|
||
tail = `, ${Math.floor(spec.end.count)} time${spec.end.count === 1 ? "" : "s"}`;
|
||
} else if (spec.end.kind === "on" && spec.end.until) {
|
||
tail = `, until ${spec.end.until}`;
|
||
}
|
||
|
||
return head + body + tail;
|
||
}
|
||
|
||
/** Parse a stored RRULE back into a spec for resuming the wizard / editing. */
|
||
export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec {
|
||
if (!rrule) return { ...DEFAULT_RECURRENCE };
|
||
|
||
if (rrule.startsWith(CRON_PREFIX)) {
|
||
return {
|
||
kind: "cron",
|
||
interval: 1,
|
||
weeklyDays: [],
|
||
cron: rrule.slice(CRON_PREFIX.length),
|
||
end: { kind: "never" },
|
||
};
|
||
}
|
||
|
||
const tokens = rrule
|
||
.split(";")
|
||
.map((t) => t.trim())
|
||
.filter(Boolean)
|
||
.reduce<Record<string, string>>((acc, t) => {
|
||
const [k, v] = t.split("=");
|
||
if (k && v !== undefined) acc[k.toUpperCase()] = v;
|
||
return acc;
|
||
}, {});
|
||
|
||
const freq = (tokens.FREQ ?? "").toUpperCase();
|
||
let kind: RecurrenceKind = "none";
|
||
if (freq === "DAILY") kind = "daily";
|
||
else if (freq === "WEEKLY") kind = "weekly";
|
||
else if (freq === "MONTHLY") kind = "monthly";
|
||
else if (freq === "YEARLY") kind = "yearly";
|
||
|
||
const interval = tokens.INTERVAL ? clampInterval(Number(tokens.INTERVAL)) : 1;
|
||
|
||
const weeklyDays: number[] = [];
|
||
if (tokens.BYDAY) {
|
||
for (const code of tokens.BYDAY.split(",")) {
|
||
const idx = WEEKDAY_CODES.indexOf(code.toUpperCase() as (typeof WEEKDAY_CODES)[number]);
|
||
if (idx >= 0) weeklyDays.push(idx + 1);
|
||
}
|
||
}
|
||
|
||
const monthDay = tokens.BYMONTHDAY ? Number(tokens.BYMONTHDAY) || undefined : undefined;
|
||
|
||
let end: RecurrenceSpec["end"] = { kind: "never" };
|
||
if (tokens.COUNT) {
|
||
const n = Number(tokens.COUNT);
|
||
if (Number.isFinite(n) && n > 0) end = { kind: "after", count: Math.floor(n) };
|
||
} else if (tokens.UNTIL) {
|
||
// UNTIL is `YYYYMMDDTHHMMSSZ` per RFC. Pull the date.
|
||
const m = tokens.UNTIL.match(/^(\d{4})(\d{2})(\d{2})/);
|
||
if (m) end = { kind: "on", until: `${m[1]}-${m[2]}-${m[3]}` };
|
||
}
|
||
|
||
return { kind, interval, weeklyDays, monthDay, end };
|
||
}
|
||
|
||
/** Backwards-compatible helper for callers that only need the kind. */
|
||
export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind {
|
||
return specFromRrule(rrule).kind;
|
||
}
|
||
|
||
/** Map ISO weekday (1=Mon..7=Sun) → cron weekday (0=Sun..6=Sat). */
|
||
export function isoWeekdayToCron(iso: number): number {
|
||
return iso === 7 ? 0 : iso;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// describeCronRule
|
||
//
|
||
// Turns the cron expressions the recurrence picker produces back into the
|
||
// kind of human-readable sentence the rest of the app already uses for
|
||
// RRULE-based recurrences. Used by `describeRecurrence` for cron rules
|
||
// and by anywhere else the app surfaces a recurrence summary (reminder
|
||
// list rows, detail page, review step).
|
||
//
|
||
// Shapes the picker emits, all with `MM HH` at the front:
|
||
//
|
||
// "MM HH * * *" → daily, every day
|
||
// "MM HH * * D[,D...]" → weekly, on listed cron weekdays (0=Sun..6=Sat)
|
||
// "MM HH d[,d...] * *" → monthly, on listed days
|
||
// "MM HH d[,d...] m[,m...] *" → yearly, listed days × listed months
|
||
//
|
||
// Multi-line rules ("CRON:line1\nline2\n…") render as the joined
|
||
// per-line descriptions, separated by " · " for compactness in lists.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const MONTH_NAMES_FULL = [
|
||
"January", "February", "March", "April", "May", "June",
|
||
"July", "August", "September", "October", "November", "December",
|
||
];
|
||
|
||
function pad2(n: number): string {
|
||
return n.toString().padStart(2, "0");
|
||
}
|
||
|
||
function formatDaysList(days: number[]): string {
|
||
if (days.length <= 6) return days.join(", ");
|
||
return `${days.slice(0, 6).join(", ")} +${days.length - 6} more`;
|
||
}
|
||
|
||
function describeWeekdayList(cronDays: number[]): string {
|
||
// Cron weekday: 0=Sun..6=Sat. Sort + map to short labels.
|
||
const labels = cronDays
|
||
.slice()
|
||
.sort((a, b) => a - b)
|
||
.map((c) => {
|
||
const iso = c === 0 ? 7 : c;
|
||
return WEEKDAY_LABELS[iso - 1]?.short ?? "";
|
||
})
|
||
.filter(Boolean);
|
||
return labels.join(", ");
|
||
}
|
||
|
||
/** Describe a single cron expression (no `CRON:` prefix). */
|
||
function describeCronExpr(expr: string): string {
|
||
const head = expr.match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
||
if (!head) return expr; // unrecognised — fall back to raw
|
||
const minute = Number(head[1]);
|
||
const hour = Number(head[2]);
|
||
if (
|
||
!Number.isFinite(minute) ||
|
||
!Number.isFinite(hour) ||
|
||
minute < 0 ||
|
||
minute > 59 ||
|
||
hour < 0 ||
|
||
hour > 23
|
||
) {
|
||
return expr;
|
||
}
|
||
const time = `${pad2(hour)}:${pad2(minute)}`;
|
||
const rest = head[3]!.trim();
|
||
|
||
// Daily: "* * *"
|
||
if (rest === "* * *") return `Every day at ${time}`;
|
||
|
||
// Weekly: "* * <DOWs>"
|
||
let m = rest.match(/^\* \* ([0-9,\-]+)$/);
|
||
if (m) {
|
||
const days = m[1]!
|
||
.split(",")
|
||
.flatMap((p) => {
|
||
const r = p.match(/^(\d+)-(\d+)$/);
|
||
if (r) {
|
||
const out: number[] = [];
|
||
for (let i = Number(r[1]); i <= Number(r[2]); i++) out.push(i);
|
||
return out;
|
||
}
|
||
return [Number(p)];
|
||
})
|
||
.filter((n) => n >= 0 && n <= 6);
|
||
const labels = describeWeekdayList(days);
|
||
if (!labels) return expr;
|
||
return `Every week on ${labels} at ${time}`;
|
||
}
|
||
|
||
// Monthly: "<DOMs> * *"
|
||
m = rest.match(/^([0-9,]+) \* \*$/);
|
||
if (m) {
|
||
const days = m[1]!
|
||
.split(",")
|
||
.map((s) => Number(s))
|
||
.filter((n) => n >= 1 && n <= 31);
|
||
if (days.length === 0) return expr;
|
||
return `Every month on day${days.length > 1 ? "s" : ""} ${formatDaysList(days)} at ${time}`;
|
||
}
|
||
|
||
// Yearly: "<DOMs> <Months> *"
|
||
m = rest.match(/^([0-9,]+) ([0-9,]+) \*$/);
|
||
if (m) {
|
||
const days = m[1]!
|
||
.split(",")
|
||
.map((s) => Number(s))
|
||
.filter((n) => n >= 1 && n <= 31);
|
||
const months = m[2]!
|
||
.split(",")
|
||
.map((s) => Number(s))
|
||
.filter((n) => n >= 1 && n <= 12);
|
||
if (days.length === 0 || months.length === 0) return expr;
|
||
const monthLabels = months.map((mo) => MONTH_NAMES_FULL[mo - 1]?.slice(0, 3)).join(", ");
|
||
return `Every year in ${monthLabels} on day${days.length > 1 ? "s" : ""} ${formatDaysList(days)} at ${time}`;
|
||
}
|
||
|
||
// Unrecognised shape — show the raw expression so the user can still
|
||
// make sense of it; better than swallowing entirely.
|
||
return expr;
|
||
}
|
||
|
||
/**
|
||
* Render a cron rule as a sentence. Handles:
|
||
* - the optional "CRON:" prefix our app stores
|
||
* - multi-line rules (joined with " · ")
|
||
*/
|
||
export function describeCronRule(rule: string): string {
|
||
const prefix = "CRON:";
|
||
const stripped = rule.startsWith(prefix) ? rule.slice(prefix.length) : rule;
|
||
const lines = stripped
|
||
.split("\n")
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
if (lines.length === 0) return "Cron (not configured)";
|
||
return lines.map(describeCronExpr).join(" · ");
|
||
}
|