From 42989b14372fdac4db8ea71e72d7f8eda28952a7 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Fri, 10 Apr 2026 15:40:56 -0400 Subject: [PATCH] style: format recurrence helpers --- src/components/recurrence-picker.tsx | 49 ++++++++++++++++--------- src/components/rrule-display.tsx | 20 ++++++++--- src/lib/event-date-format.ts | 21 +++++------ src/lib/event-form.ts | 5 ++- src/lib/recurrence.ts | 54 ++++++++++++++++++++++------ 5 files changed, 106 insertions(+), 43 deletions(-) diff --git a/src/components/recurrence-picker.tsx b/src/components/recurrence-picker.tsx index d89e14b..e9ac8ad 100644 --- a/src/components/recurrence-picker.tsx +++ b/src/components/recurrence-picker.tsx @@ -15,11 +15,11 @@ import { getRecurrencePreview, getWeekdayOptions, parseRecurrenceRule, + type RecurrenceFormValue, recurrenceFrequencyLabels, + type SupportedRecurrenceFrequency, serializeRecurrenceRule, validateRecurrence, - type RecurrenceFormValue, - type SupportedRecurrenceFrequency, type Weekday, } from "@/lib/recurrence"; @@ -39,10 +39,7 @@ const getStartWeekday = (start?: string): Weekday => { return weekdays[jsDay] ?? "MO"; }; -const updateWeekdays = ( - current: Weekday[], - day: Weekday, -): Weekday[] => { +const updateWeekdays = (current: Weekday[], day: Weekday): Weekday[] => { return current.includes(day) ? current.filter((existingDay) => existingDay !== day) : [...current, day]; @@ -95,11 +92,13 @@ export function RecurrencePicker({ value, start, onChange }: Props) { - {Object.entries(recurrenceFrequencyLabels).map(([optionValue, label]) => ( - - {label} - - ))} + {Object.entries(recurrenceFrequencyLabels).map( + ([optionValue, label]) => ( + + {label} + + ), + )} @@ -108,7 +107,12 @@ export function RecurrencePicker({ value, start, onChange }: Props) { <>
- update({ interval: Number.parseInt(event.target.value, 10) || 1 }) + update({ + interval: Number.parseInt(event.target.value, 10) || 1, + }) } className="mt-1.5 w-24" /> @@ -125,7 +131,9 @@ export function RecurrencePicker({ value, start, onChange }: Props) { {recurrence.freq === "WEEKLY" && (
- +
{weekdayOptions.map(({ value: day, label }) => (
@@ -136,7 +144,10 @@ export function RecurrencePicker({ value, start, onChange }: Props) { update({ byDay: updateWeekdays(recurrence.byDay, day) }) } /> -
@@ -186,10 +197,14 @@ export function RecurrencePicker({ value, start, onChange }: Props) {
{validation.errors.count && ( -

{validation.errors.count}

+

+ {validation.errors.count} +

)} {validation.errors.until && ( -

{validation.errors.until}

+

+ {validation.errors.until} +

)} {validation.errors.rule && (

{validation.errors.rule}

diff --git a/src/components/rrule-display.tsx b/src/components/rrule-display.tsx index 64113cc..268d314 100644 --- a/src/components/rrule-display.tsx +++ b/src/components/rrule-display.tsx @@ -1,6 +1,10 @@ import { format, parseISO } from "date-fns"; import { Badge } from "@/components/ui/badge"; -import { formatRecurrenceText, getRecurrencePreview, parseRecurrenceRule } from "@/lib/recurrence"; +import { + formatRecurrenceText, + getRecurrencePreview, + parseRecurrenceRule, +} from "@/lib/recurrence"; interface RRuleDisplayProps { rrule?: string; @@ -12,17 +16,25 @@ export function RRuleDisplay({ rrule, className, start }: RRuleDisplayProps) { if (!rrule) return null; const humanText = formatRecurrenceText(rrule); - const preview = start ? getRecurrencePreview(parseRecurrenceRule(rrule), start, 3) : []; + const preview = start + ? getRecurrencePreview(parseRecurrenceRule(rrule), start, 3) + : []; return (
- + {humanText ?? rrule} {preview.length > 0 && ( - Next: {preview.map((value) => format(parseISO(value), "MMM d")).join(", ")} + Next:{" "} + {preview + .map((value) => format(parseISO(value), "MMM d")) + .join(", ")} )}
diff --git a/src/lib/event-date-format.ts b/src/lib/event-date-format.ts index 98280fc..cc4bec6 100644 --- a/src/lib/event-date-format.ts +++ b/src/lib/event-date-format.ts @@ -1,10 +1,4 @@ -import { - format, - isSameDay, - isToday, - isTomorrow, - parseISO, -} from "date-fns"; +import { format, isSameDay, isToday, isTomorrow, parseISO } from "date-fns"; import type { CalendarEvent } from "@/lib/types"; const getFriendlyDayLabel = (value: Date): string => { @@ -13,7 +7,10 @@ const getFriendlyDayLabel = (value: Date): string => { 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 dayLabel = getFriendlyDayLabel(parsed); @@ -21,12 +18,16 @@ export const formatEventStartLabel = (start: string, allDay?: boolean): string = return `${dayLabel} · ${format(parsed, "HH:mm")}`; }; -export const formatEventRangeLabel = (event: Pick): string => { +export const formatEventRangeLabel = ( + event: Pick, +): string => { const startDate = parseISO(event.start); const startLabel = getFriendlyDayLabel(startDate); 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); diff --git a/src/lib/event-form.ts b/src/lib/event-form.ts index 84ab337..d31e407 100644 --- a/src/lib/event-form.ts +++ b/src/lib/event-form.ts @@ -39,7 +39,10 @@ const eventFormSchema = z if (value.end) { const startDate = parseISO(value.start); 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({ code: z.ZodIssueCode.custom, path: ["end"], diff --git a/src/lib/recurrence.ts b/src/lib/recurrence.ts index 0251315..acd7d6f 100644 --- a/src/lib/recurrence.ts +++ b/src/lib/recurrence.ts @@ -2,7 +2,11 @@ import { format, parseISO } from "date-fns"; import { RRule, rrulestr } from "rrule"; 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 { freq: SupportedRecurrenceFrequency; @@ -23,7 +27,10 @@ const EMPTY_RECURRENCE: RecurrenceFormValue = { byDay: [], }; -const RULE_FREQUENCIES: Record, number> = { +const RULE_FREQUENCIES: Record< + Exclude, + number +> = { DAILY: RRule.DAILY, WEEKLY: RRule.WEEKLY, MONTHLY: RRule.MONTHLY, @@ -58,7 +65,10 @@ const sortWeekdays = (days: string[] | undefined): Weekday[] => { if (!days?.length) return []; return [...days] .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 => { @@ -105,7 +115,10 @@ export const validateRecurrence = ( 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."; } @@ -164,14 +177,22 @@ export const serializeRecurrenceRule = ( const validation = validateRecurrence(value); 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); if (!options) return undefined; 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; const entries = ruleLine.replace(/^RRULE:/, "").split(";"); @@ -181,9 +202,14 @@ export const serializeRecurrenceRule = ( const rightKey = right.split("=")[0] ?? ""; const leftIndex = orderedKeys.indexOf(leftKey); const rightIndex = orderedKeys.indexOf(rightKey); - const normalizedLeftIndex = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex; - const normalizedRightIndex = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex; - return normalizedLeftIndex - normalizedRightIndex || leftKey.localeCompare(rightKey); + const normalizedLeftIndex = + leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex; + const normalizedRightIndex = + rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex; + return ( + normalizedLeftIndex - normalizedRightIndex || + leftKey.localeCompare(rightKey) + ); }); return sorted.join(";"); @@ -213,7 +239,10 @@ export const formatRecurrenceText = (rule?: string): string | null => { 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: "TU", label: "Tue" }, { value: "WE", label: "Wed" }, @@ -228,7 +257,10 @@ export const isWeekdayPreset = (value: RecurrenceFormValue): boolean => value.interval === 1 && value.byDay.join(",") === ["MO", "TU", "WE", "TH", "FR"].join(","); -export const recurrenceFrequencyLabels: Record = { +export const recurrenceFrequencyLabels: Record< + SupportedRecurrenceFrequency, + string +> = { NONE: "Does not repeat", DAILY: "Daily", WEEKLY: "Weekly",