style: format recurrence helpers

This commit is contained in:
2026-04-10 15:40:56 -04:00
parent f3350e0124
commit 42989b1437
5 changed files with 106 additions and 43 deletions

View File

@@ -15,11 +15,11 @@ import {
getRecurrencePreview, getRecurrencePreview,
getWeekdayOptions, getWeekdayOptions,
parseRecurrenceRule, parseRecurrenceRule,
type RecurrenceFormValue,
recurrenceFrequencyLabels, recurrenceFrequencyLabels,
type SupportedRecurrenceFrequency,
serializeRecurrenceRule, serializeRecurrenceRule,
validateRecurrence, validateRecurrence,
type RecurrenceFormValue,
type SupportedRecurrenceFrequency,
type Weekday, type Weekday,
} from "@/lib/recurrence"; } from "@/lib/recurrence";
@@ -39,10 +39,7 @@ const getStartWeekday = (start?: string): Weekday => {
return weekdays[jsDay] ?? "MO"; return weekdays[jsDay] ?? "MO";
}; };
const updateWeekdays = ( const updateWeekdays = (current: Weekday[], day: Weekday): Weekday[] => {
current: Weekday[],
day: Weekday,
): Weekday[] => {
return current.includes(day) return current.includes(day)
? current.filter((existingDay) => existingDay !== day) ? current.filter((existingDay) => existingDay !== day)
: [...current, day]; : [...current, day];
@@ -95,11 +92,13 @@ export function RecurrencePicker({ value, start, onChange }: Props) {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(recurrenceFrequencyLabels).map(([optionValue, label]) => ( {Object.entries(recurrenceFrequencyLabels).map(
<SelectItem key={optionValue} value={optionValue}> ([optionValue, label]) => (
{label} <SelectItem key={optionValue} value={optionValue}>
</SelectItem> {label}
))} </SelectItem>
),
)}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -108,7 +107,12 @@ export function RecurrencePicker({ value, start, onChange }: Props) {
<> <>
<div> <div>
<Label htmlFor="interval" className="text-xs text-muted-foreground"> <Label htmlFor="interval" className="text-xs text-muted-foreground">
Interval (every {recurrence.interval} {recurrence.freq === "DAILY" ? "day" : recurrence.freq === "WEEKLY" ? "week" : "month"} Interval (every {recurrence.interval}{" "}
{recurrence.freq === "DAILY"
? "day"
: recurrence.freq === "WEEKLY"
? "week"
: "month"}
{recurrence.interval > 1 ? "s" : ""}) {recurrence.interval > 1 ? "s" : ""})
</Label> </Label>
<Input <Input
@@ -117,7 +121,9 @@ export function RecurrencePicker({ value, start, onChange }: Props) {
min={1} min={1}
value={recurrence.interval} value={recurrence.interval}
onChange={(event) => onChange={(event) =>
update({ interval: Number.parseInt(event.target.value, 10) || 1 }) update({
interval: Number.parseInt(event.target.value, 10) || 1,
})
} }
className="mt-1.5 w-24" className="mt-1.5 w-24"
/> />
@@ -125,7 +131,9 @@ export function RecurrencePicker({ value, start, onChange }: Props) {
{recurrence.freq === "WEEKLY" && ( {recurrence.freq === "WEEKLY" && (
<div> <div>
<Label className="text-xs text-muted-foreground">Days of the week</Label> <Label className="text-xs text-muted-foreground">
Days of the week
</Label>
<div className="mt-1.5 flex flex-wrap gap-3"> <div className="mt-1.5 flex flex-wrap gap-3">
{weekdayOptions.map(({ value: day, label }) => ( {weekdayOptions.map(({ value: day, label }) => (
<div key={day} className="flex items-center gap-1.5"> <div key={day} className="flex items-center gap-1.5">
@@ -136,7 +144,10 @@ export function RecurrencePicker({ value, start, onChange }: Props) {
update({ byDay: updateWeekdays(recurrence.byDay, day) }) update({ byDay: updateWeekdays(recurrence.byDay, day) })
} }
/> />
<Label htmlFor={day} className="cursor-pointer text-xs font-normal"> <Label
htmlFor={day}
className="cursor-pointer text-xs font-normal"
>
{label} {label}
</Label> </Label>
</div> </div>
@@ -186,10 +197,14 @@ export function RecurrencePicker({ value, start, onChange }: Props) {
</div> </div>
{validation.errors.count && ( {validation.errors.count && (
<p className="text-xs text-destructive">{validation.errors.count}</p> <p className="text-xs text-destructive">
{validation.errors.count}
</p>
)} )}
{validation.errors.until && ( {validation.errors.until && (
<p className="text-xs text-destructive">{validation.errors.until}</p> <p className="text-xs text-destructive">
{validation.errors.until}
</p>
)} )}
{validation.errors.rule && ( {validation.errors.rule && (
<p className="text-xs text-destructive">{validation.errors.rule}</p> <p className="text-xs text-destructive">{validation.errors.rule}</p>

View File

@@ -1,6 +1,10 @@
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { formatRecurrenceText, getRecurrencePreview, parseRecurrenceRule } from "@/lib/recurrence"; import {
formatRecurrenceText,
getRecurrencePreview,
parseRecurrenceRule,
} from "@/lib/recurrence";
interface RRuleDisplayProps { interface RRuleDisplayProps {
rrule?: string; rrule?: string;
@@ -12,17 +16,25 @@ export function RRuleDisplay({ rrule, className, start }: RRuleDisplayProps) {
if (!rrule) return null; if (!rrule) return null;
const humanText = formatRecurrenceText(rrule); const humanText = formatRecurrenceText(rrule);
const preview = start ? getRecurrencePreview(parseRecurrenceRule(rrule), start, 3) : []; const preview = start
? getRecurrencePreview(parseRecurrenceRule(rrule), start, 3)
: [];
return ( return (
<div className={className}> <div className={className}>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<Badge variant="secondary" className="h-5 text-[10px] font-normal capitalize"> <Badge
variant="secondary"
className="h-5 text-[10px] font-normal capitalize"
>
{humanText ?? rrule} {humanText ?? rrule}
</Badge> </Badge>
{preview.length > 0 && ( {preview.length > 0 && (
<Badge variant="outline" className="h-5 text-[10px] font-normal"> <Badge variant="outline" className="h-5 text-[10px] font-normal">
Next: {preview.map((value) => format(parseISO(value), "MMM d")).join(", ")} Next:{" "}
{preview
.map((value) => format(parseISO(value), "MMM d"))
.join(", ")}
</Badge> </Badge>
)} )}
</div> </div>

View File

@@ -1,10 +1,4 @@
import { import { format, isSameDay, isToday, isTomorrow, parseISO } from "date-fns";
format,
isSameDay,
isToday,
isTomorrow,
parseISO,
} from "date-fns";
import type { CalendarEvent } from "@/lib/types"; import type { CalendarEvent } from "@/lib/types";
const getFriendlyDayLabel = (value: Date): string => { const getFriendlyDayLabel = (value: Date): string => {
@@ -13,7 +7,10 @@ const getFriendlyDayLabel = (value: Date): string => {
return format(value, "MMM d, yyyy"); return format(value, "MMM d, yyyy");
}; };
export const formatEventStartLabel = (start: string, allDay?: boolean): string => { export const formatEventStartLabel = (
start: string,
allDay?: boolean,
): string => {
const parsed = parseISO(start); const parsed = parseISO(start);
const dayLabel = getFriendlyDayLabel(parsed); const dayLabel = getFriendlyDayLabel(parsed);
@@ -21,12 +18,16 @@ export const formatEventStartLabel = (start: string, allDay?: boolean): string =
return `${dayLabel} · ${format(parsed, "HH:mm")}`; return `${dayLabel} · ${format(parsed, "HH:mm")}`;
}; };
export const formatEventRangeLabel = (event: Pick<CalendarEvent, "start" | "end" | "allDay">): string => { export const formatEventRangeLabel = (
event: Pick<CalendarEvent, "start" | "end" | "allDay">,
): string => {
const startDate = parseISO(event.start); const startDate = parseISO(event.start);
const startLabel = getFriendlyDayLabel(startDate); const startLabel = getFriendlyDayLabel(startDate);
if (event.allDay || !event.end) { if (event.allDay || !event.end) {
return event.allDay ? startLabel : `${startLabel} · ${format(startDate, "HH:mm")}`; return event.allDay
? startLabel
: `${startLabel} · ${format(startDate, "HH:mm")}`;
} }
const endDate = parseISO(event.end); const endDate = parseISO(event.end);

View File

@@ -39,7 +39,10 @@ const eventFormSchema = z
if (value.end) { if (value.end) {
const startDate = parseISO(value.start); const startDate = parseISO(value.start);
const endDate = parseISO(value.end); const endDate = parseISO(value.end);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { if (
Number.isNaN(startDate.getTime()) ||
Number.isNaN(endDate.getTime())
) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ["end"], path: ["end"],

View File

@@ -2,7 +2,11 @@ import { format, parseISO } from "date-fns";
import { RRule, rrulestr } from "rrule"; import { RRule, rrulestr } from "rrule";
import type { Frequency, Weekday } from "@/lib/rfc5545-types"; import type { Frequency, Weekday } from "@/lib/rfc5545-types";
export type SupportedRecurrenceFrequency = "NONE" | "DAILY" | "WEEKLY" | "MONTHLY"; export type SupportedRecurrenceFrequency =
| "NONE"
| "DAILY"
| "WEEKLY"
| "MONTHLY";
export interface RecurrenceFormValue { export interface RecurrenceFormValue {
freq: SupportedRecurrenceFrequency; freq: SupportedRecurrenceFrequency;
@@ -23,7 +27,10 @@ const EMPTY_RECURRENCE: RecurrenceFormValue = {
byDay: [], byDay: [],
}; };
const RULE_FREQUENCIES: Record<Exclude<SupportedRecurrenceFrequency, "NONE">, number> = { const RULE_FREQUENCIES: Record<
Exclude<SupportedRecurrenceFrequency, "NONE">,
number
> = {
DAILY: RRule.DAILY, DAILY: RRule.DAILY,
WEEKLY: RRule.WEEKLY, WEEKLY: RRule.WEEKLY,
MONTHLY: RRule.MONTHLY, MONTHLY: RRule.MONTHLY,
@@ -58,7 +65,10 @@ const sortWeekdays = (days: string[] | undefined): Weekday[] => {
if (!days?.length) return []; if (!days?.length) return [];
return [...days] return [...days]
.filter((day): day is Weekday => WEEKDAY_ORDER.includes(day as Weekday)) .filter((day): day is Weekday => WEEKDAY_ORDER.includes(day as Weekday))
.sort((left, right) => WEEKDAY_ORDER.indexOf(left) - WEEKDAY_ORDER.indexOf(right)); .sort(
(left, right) =>
WEEKDAY_ORDER.indexOf(left) - WEEKDAY_ORDER.indexOf(right),
);
}; };
const toUntilDate = (until: string): Date => { const toUntilDate = (until: string): Date => {
@@ -105,7 +115,10 @@ export const validateRecurrence = (
errors.rule = "Interval must be at least 1."; errors.rule = "Interval must be at least 1.";
} }
if (value.count !== undefined && (!Number.isInteger(value.count) || value.count < 1)) { if (
value.count !== undefined &&
(!Number.isInteger(value.count) || value.count < 1)
) {
errors.count = "Count must be a whole number greater than 0."; errors.count = "Count must be a whole number greater than 0.";
} }
@@ -164,14 +177,22 @@ export const serializeRecurrenceRule = (
const validation = validateRecurrence(value); const validation = validateRecurrence(value);
if (!validation.isValid) { if (!validation.isValid) {
throw new Error(validation.errors.rule || validation.errors.count || validation.errors.until || "Invalid recurrence."); throw new Error(
validation.errors.rule ||
validation.errors.count ||
validation.errors.until ||
"Invalid recurrence.",
);
} }
const options = toRRuleOptions(value, dtstart); const options = toRRuleOptions(value, dtstart);
if (!options) return undefined; if (!options) return undefined;
const rule = new RRule(options); const rule = new RRule(options);
const ruleLine = rule.toString().split("\n").find((line) => line.startsWith("RRULE:")); const ruleLine = rule
.toString()
.split("\n")
.find((line) => line.startsWith("RRULE:"));
if (!ruleLine) return undefined; if (!ruleLine) return undefined;
const entries = ruleLine.replace(/^RRULE:/, "").split(";"); const entries = ruleLine.replace(/^RRULE:/, "").split(";");
@@ -181,9 +202,14 @@ export const serializeRecurrenceRule = (
const rightKey = right.split("=")[0] ?? ""; const rightKey = right.split("=")[0] ?? "";
const leftIndex = orderedKeys.indexOf(leftKey); const leftIndex = orderedKeys.indexOf(leftKey);
const rightIndex = orderedKeys.indexOf(rightKey); const rightIndex = orderedKeys.indexOf(rightKey);
const normalizedLeftIndex = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex; const normalizedLeftIndex =
const normalizedRightIndex = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex; leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
return normalizedLeftIndex - normalizedRightIndex || leftKey.localeCompare(rightKey); const normalizedRightIndex =
rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
return (
normalizedLeftIndex - normalizedRightIndex ||
leftKey.localeCompare(rightKey)
);
}); });
return sorted.join(";"); return sorted.join(";");
@@ -213,7 +239,10 @@ export const formatRecurrenceText = (rule?: string): string | null => {
return rrulestr(`RRULE:${rule}`).toText(); return rrulestr(`RRULE:${rule}`).toText();
}; };
export const getWeekdayOptions = (): Array<{ value: Weekday; label: string }> => [ export const getWeekdayOptions = (): Array<{
value: Weekday;
label: string;
}> => [
{ value: "MO", label: "Mon" }, { value: "MO", label: "Mon" },
{ value: "TU", label: "Tue" }, { value: "TU", label: "Tue" },
{ value: "WE", label: "Wed" }, { value: "WE", label: "Wed" },
@@ -228,7 +257,10 @@ export const isWeekdayPreset = (value: RecurrenceFormValue): boolean =>
value.interval === 1 && value.interval === 1 &&
value.byDay.join(",") === ["MO", "TU", "WE", "TH", "FR"].join(","); value.byDay.join(",") === ["MO", "TU", "WE", "TH", "FR"].join(",");
export const recurrenceFrequencyLabels: Record<SupportedRecurrenceFrequency, string> = { export const recurrenceFrequencyLabels: Record<
SupportedRecurrenceFrequency,
string
> = {
NONE: "Does not repeat", NONE: "Does not repeat",
DAILY: "Daily", DAILY: "Daily",
WEEKLY: "Weekly", WEEKLY: "Weekly",