yiekheng c7a6f5f1b0 feat: humanise cron in list summaries; magic-byte detect HEIC; sidebar brand link tests
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>
2026-05-10 12:59:13 +08:00

359 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(" · ");
}