style: format recurrence helpers
This commit is contained in:
@@ -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(
|
||||||
|
([optionValue, label]) => (
|
||||||
<SelectItem key={optionValue} value={optionValue}>
|
<SelectItem key={optionValue} value={optionValue}>
|
||||||
{label}
|
{label}
|
||||||
</SelectItem>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user