@super-calendar/core 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@super-calendar/core",
3
+ "version": "2.0.0",
4
+ "description": "Render-agnostic core for super-calendar: date math, selection model, event layout, the month-grid builder, headless hooks, and neutral theme tokens. No renderer.",
5
+ "keywords": [
6
+ "calendar",
7
+ "date-fns",
8
+ "date-picker",
9
+ "headless",
10
+ "react"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/afonsojramos/react-native-super-calendar.git",
16
+ "directory": "packages/core"
17
+ },
18
+ "source": "./src/index.ts",
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "!**/__tests__"
23
+ ],
24
+ "sideEffects": false,
25
+ "main": "./dist/index.js",
26
+ "module": "./dist/index.mjs",
27
+ "types": "./dist/index.d.ts",
28
+ "react-native": "./src/index.ts",
29
+ "exports": {
30
+ ".": {
31
+ "react-native": "./src/index.ts",
32
+ "import": {
33
+ "types": "./dist/index.d.mts",
34
+ "default": "./dist/index.mjs"
35
+ },
36
+ "require": {
37
+ "types": "./dist/index.d.ts",
38
+ "default": "./dist/index.js"
39
+ }
40
+ },
41
+ "./package.json": "./package.json"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "peerDependencies": {
47
+ "date-fns": ">=3",
48
+ "react": ">=18"
49
+ },
50
+ "scripts": {
51
+ "build": "tsdown",
52
+ "bench": "bun scripts/bench.ts"
53
+ }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ // @super-calendar/core: the render-agnostic core. Date math, the selection
2
+ // model, event layout, the month-grid builder, the headless hooks, and the
3
+ // neutral theme tokens. Imports nothing from React Native, react-dom, Reanimated,
4
+ // Gesture Handler, or Legend List, so it bundles into any renderer.
5
+ export * from "./types";
6
+ export * from "./tokens";
7
+ export * from "./presentation";
8
+ export * from "./utils/dateRange";
9
+ export * from "./utils/dates";
10
+ export * from "./utils/drag";
11
+ export * from "./utils/eventDisplay";
12
+ export * from "./utils/layout";
13
+ export * from "./utils/monthGrid";
14
+ export * from "./utils/recurrence";
15
+ export * from "./utils/timezone";
@@ -0,0 +1,39 @@
1
+ // Shared presentation decisions: turn a day's selection state into a render
2
+ // "intent" both renderers map to their own primitives (CSS for dom, StyleSheet
3
+ // for native). Keeping the decision here means the pill/fill/badge rules can't
4
+ // drift between the two renderers — they were duplicated once and that's exactly
5
+ // the bug this prevents.
6
+
7
+ /** Which range-band shape a day shows. */
8
+ export type RangeBandKind = "none" | "fill" | "pill-start" | "pill-mid" | "pill-end";
9
+
10
+ /** The band shape for a day, given the fill-cell option. */
11
+ export function rangeBandKind(
12
+ day: { isInRange: boolean; isRangeStart: boolean; isRangeEnd: boolean },
13
+ fillCell: boolean,
14
+ ): RangeBandKind {
15
+ // A single-day range (start === end) is the badge alone, no band.
16
+ if (!day.isInRange || (day.isRangeStart && day.isRangeEnd)) return "none";
17
+ if (fillCell) return "fill";
18
+ if (day.isRangeStart) return "pill-start";
19
+ if (day.isRangeEnd) return "pill-end";
20
+ return "pill-mid";
21
+ }
22
+
23
+ /** Whether a band shape rounds its leading / trailing edge (pill ends). */
24
+ export function bandRounding(kind: RangeBandKind): { start: boolean; end: boolean } {
25
+ return { start: kind === "pill-start", end: kind === "pill-end" };
26
+ }
27
+
28
+ /** Which filled badge a day shows (today wins over a selection). */
29
+ export type DayBadgeKind = "none" | "today" | "selected";
30
+
31
+ /**
32
+ * The filled-badge kind for a day. `isSelected` is true for both range endpoints
33
+ * and discrete selected days; today always wins when it coincides.
34
+ */
35
+ export function dayBadgeKind(day: { isSelected: boolean }, isToday: boolean): DayBadgeKind {
36
+ if (isToday) return "today";
37
+ if (day.isSelected) return "selected";
38
+ return "none";
39
+ }
package/src/tokens.ts ADDED
@@ -0,0 +1,73 @@
1
+ // Neutral, renderer-agnostic theme tokens: the shared colour palette both the
2
+ // React Native and react-dom renderers derive their themes from. Plain data, no
3
+ // platform types. Renderer-specific metrics (cell / badge / band sizes) stay in
4
+ // each renderer's theme, since they legitimately differ between the two.
5
+
6
+ export interface CalendarColors {
7
+ /** Hour lines, day separators and month-cell borders. */
8
+ gridLine: string;
9
+ /** Background tint behind weekend columns/cells. */
10
+ weekendBackground: string;
11
+ /** Background tint over hours outside business hours (time grid). */
12
+ outsideHoursBackground: string;
13
+ /** Today badge fill. */
14
+ todayBackground: string;
15
+ /** Today badge text. */
16
+ todayText: string;
17
+ /** Selected day / range-endpoint badge fill. */
18
+ selectedBackground: string;
19
+ /** Selected day / range-endpoint badge text. */
20
+ selectedText: string;
21
+ /** Band behind a selected range. */
22
+ rangeBackground: string;
23
+ /** Hover highlight behind a day (DOM, mouse only). */
24
+ hoverBackground: string;
25
+ /** Current-time indicator line (time grid). */
26
+ nowIndicator: string;
27
+ /** Primary text (day numbers, weekday labels). */
28
+ text: string;
29
+ /** Muted text (hour labels, "+N more"). */
30
+ textMuted: string;
31
+ /** Dimmed text for disabled / adjacent-month days. */
32
+ textDisabled: string;
33
+ /** Default event chip fill. */
34
+ eventBackground: string;
35
+ /** Default event chip text. */
36
+ eventText: string;
37
+ }
38
+
39
+ export const lightColors: CalendarColors = {
40
+ gridLine: "#E2E4E9",
41
+ weekendBackground: "#F6F7F9",
42
+ outsideHoursBackground: "#F1F2F4",
43
+ todayBackground: "#1F6FEB",
44
+ todayText: "#FFFFFF",
45
+ selectedBackground: "#1F6FEB",
46
+ selectedText: "#FFFFFF",
47
+ rangeBackground: "#DCE7FF",
48
+ hoverBackground: "#E6ECF5",
49
+ nowIndicator: "#E5484D",
50
+ text: "#1A1B1E",
51
+ textMuted: "#6B7280",
52
+ textDisabled: "#B5B9C0",
53
+ eventBackground: "#DCE7FF",
54
+ eventText: "#1A1B1E",
55
+ };
56
+
57
+ export const darkColors: CalendarColors = {
58
+ gridLine: "#2A2E37",
59
+ weekendBackground: "#15171C",
60
+ outsideHoursBackground: "#101216",
61
+ todayBackground: "#1F6FEB",
62
+ todayText: "#FFFFFF",
63
+ selectedBackground: "#1F6FEB",
64
+ selectedText: "#FFFFFF",
65
+ rangeBackground: "#243B53",
66
+ hoverBackground: "#2E3138",
67
+ nowIndicator: "#E5484D",
68
+ text: "#ECEDEE",
69
+ textMuted: "#9BA1A6",
70
+ textDisabled: "#4B4F58",
71
+ eventBackground: "#243B53",
72
+ eventText: "#EAF2FF",
73
+ };
package/src/types.ts ADDED
@@ -0,0 +1,70 @@
1
+ export type CalendarMode = "day" | "3days" | "week" | "custom" | "month" | "schedule";
2
+
3
+ /** The time-grid modes (day-column views, excluding month and schedule). */
4
+ export type TimeGridMode = Exclude<CalendarMode, "month" | "schedule">;
5
+
6
+ /**
7
+ * The minimal shape every calendar event must have. Layout (positioning,
8
+ * overlap resolution, paging) only ever reads `start`/`end`; `title` is used by
9
+ * the built-in default renderer. Anything else lives in your own type and is
10
+ * threaded through untouched via the `T` generic.
11
+ */
12
+ export interface ICalendarEvent {
13
+ start: Date;
14
+ end: Date;
15
+ title?: string;
16
+ /**
17
+ * Force this event into the all-day lane (above the time grid) instead of the
18
+ * timed columns. When omitted, an event is treated as all-day only if it spans
19
+ * whole days (both `start` and `end` land on midnight).
20
+ */
21
+ allDay?: boolean;
22
+ /** Ignore taps/long-presses on this event (the built-in renderer also dims it). */
23
+ disabled?: boolean;
24
+ /**
25
+ * Repeat rule. Pass the event to `expandRecurringEvents(events, start, end)` to
26
+ * materialise its occurrences within a range; the calendar itself doesn't
27
+ * expand recurrences.
28
+ */
29
+ recurrence?: RecurrenceRule;
30
+ }
31
+
32
+ /** How often a recurring event repeats. */
33
+ export type RecurrenceFrequency = "daily" | "weekly" | "monthly" | "yearly";
34
+
35
+ /** A simple, RRULE-inspired repeat rule expanded by `expandRecurringEvents`. */
36
+ export interface RecurrenceRule {
37
+ freq: RecurrenceFrequency;
38
+ /** Repeat every N periods. Default 1. */
39
+ interval?: number;
40
+ /** Stop after this many occurrences (including the first). */
41
+ count?: number;
42
+ /** Stop on/after this date (inclusive). */
43
+ until?: Date;
44
+ /**
45
+ * For `weekly`: the weekdays to repeat on (0 = Sunday … 6 = Saturday), keeping
46
+ * the event's time of day. Omit to repeat on the start date's own weekday.
47
+ */
48
+ weekdays?: WeekStartsOn[];
49
+ }
50
+
51
+ /**
52
+ * An event carrying arbitrary extra fields `T` alongside the required shape.
53
+ * `ICalendarEvent` is authoritative: keys it reserves (`start`/`end`/`title`)
54
+ * cannot be re-typed by `T`.
55
+ */
56
+ export type CalendarEvent<T = unknown> = ICalendarEvent & Omit<T, keyof ICalendarEvent>;
57
+
58
+ /** Build a stable key for an event. Defaults to start-time + index. */
59
+ export type EventKeyExtractor<T = unknown> = (event: CalendarEvent<T>, index: number) => string;
60
+
61
+ /** Sunday = 0 … Saturday = 6, matching `Date.prototype.getDay()`. */
62
+ export type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6;
63
+
64
+ /**
65
+ * A day's open hours for `businessHours` shading on the time grid: `{ start, end }`
66
+ * in hours (fractions allowed, e.g. 9.5), or `null` when the day is closed (fully
67
+ * shaded). `undefined` from the callback means "no business-hours shading". Shared
68
+ * by both renderers; pair with `closedHourBands` to get the spans to shade.
69
+ */
70
+ export type BusinessHours = (date: Date) => { start: number; end: number } | null;
@@ -0,0 +1,163 @@
1
+ import { isAfter, isBefore, startOfDay } from "date-fns";
2
+ import { createContext, useCallback, useContext, useMemo, useState } from "react";
3
+ import { isSameCalendarDay } from "./dates";
4
+
5
+ /** A selected span. `end` is `null` while only the first endpoint has been picked. */
6
+ export interface DateRange {
7
+ start: Date;
8
+ end: Date | null;
9
+ }
10
+
11
+ /** Limits applied before a date can be selected. */
12
+ export interface DateSelectionConstraints {
13
+ /** Earliest selectable day (inclusive). */
14
+ minDate?: Date;
15
+ /** Latest selectable day (inclusive). */
16
+ maxDate?: Date;
17
+ /** Return true to forbid selecting a specific day. */
18
+ isDateDisabled?: (date: Date) => boolean;
19
+ }
20
+
21
+ /** Whether `date` passes the min/max/disabled constraints (compared by calendar day). */
22
+ export function isDateSelectable(date: Date, constraints: DateSelectionConstraints = {}): boolean {
23
+ const day = startOfDay(date);
24
+ if (constraints.minDate && isBefore(day, startOfDay(constraints.minDate))) return false;
25
+ if (constraints.maxDate && isAfter(day, startOfDay(constraints.maxDate))) return false;
26
+ if (constraints.isDateDisabled?.(day)) return false;
27
+ return true;
28
+ }
29
+
30
+ /**
31
+ * The range after pressing `pressed`, mirroring the familiar date-picker model:
32
+ * - no range yet, or a complete range exists → start fresh (`{ start: pressed, end: null }`),
33
+ * so a third press resets the selection.
34
+ * - an open range (a start but no end) → close it, auto-swapping when the press
35
+ * precedes the start so `start <= end` always holds.
36
+ *
37
+ * Returns `current` unchanged when `pressed` isn't selectable.
38
+ */
39
+ export function nextDateRange(
40
+ current: DateRange | null,
41
+ pressed: Date,
42
+ constraints: DateSelectionConstraints = {},
43
+ ): DateRange | null {
44
+ if (!isDateSelectable(pressed, constraints)) return current;
45
+ const day = startOfDay(pressed);
46
+ if (!current || current.end) return { start: day, end: null };
47
+ if (isBefore(day, current.start)) return { start: day, end: current.start };
48
+ return { start: current.start, end: day };
49
+ }
50
+
51
+ /** True when `date` is one of the range's two endpoints. */
52
+ export function isRangeEndpoint(date: Date, range: DateRange | null): boolean {
53
+ if (!range) return false;
54
+ if (isSameCalendarDay(date, range.start)) return true;
55
+ return range.end ? isSameCalendarDay(date, range.end) : false;
56
+ }
57
+
58
+ /** True when `date` falls within a complete range (endpoints included). */
59
+ export function isWithinDateRange(date: Date, range: DateRange | null): boolean {
60
+ if (!range || !range.end) return false;
61
+ const day = startOfDay(date).getTime();
62
+ const a = startOfDay(range.start).getTime();
63
+ const b = startOfDay(range.end).getTime();
64
+ return day >= Math.min(a, b) && day <= Math.max(a, b);
65
+ }
66
+
67
+ /** The selection/disabled flags for one day. */
68
+ export interface DaySelectionState {
69
+ /** Fails the min/max/disabled constraints. */
70
+ isDisabled: boolean;
71
+ /** A `selectedDates` day or a range endpoint (and not disabled). */
72
+ isSelected: boolean;
73
+ /** Inside a complete range, endpoints included (and not disabled). */
74
+ isInRange: boolean;
75
+ isRangeStart: boolean;
76
+ isRangeEnd: boolean;
77
+ }
78
+
79
+ /**
80
+ * The canonical per-day selection state, shared by `MonthView` (rendering) and
81
+ * `buildMonthGrid` (the headless grid) so the built-in views and a custom
82
+ * calendar can never disagree on what a day's state is.
83
+ */
84
+ export function daySelectionState(
85
+ date: Date,
86
+ selection: { selectedDates?: Date[]; selectedRange?: DateRange | null },
87
+ constraints: DateSelectionConstraints = {},
88
+ ): DaySelectionState {
89
+ const isDisabled = !isDateSelectable(date, constraints);
90
+ const range = selection.selectedRange ?? null;
91
+ return {
92
+ isDisabled,
93
+ isSelected:
94
+ !isDisabled &&
95
+ ((selection.selectedDates?.some((selected) => isSameCalendarDay(selected, date)) ?? false) ||
96
+ isRangeEndpoint(date, range)),
97
+ isInRange: !isDisabled && isWithinDateRange(date, range),
98
+ isRangeStart: !isDisabled && range != null && isSameCalendarDay(date, range.start),
99
+ isRangeEnd: !isDisabled && range?.end != null && isSameCalendarDay(date, range.end),
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Per-day state shared with the month grid via context: the current selection
105
+ * plus the selectability constraints. Threaded through context (not props) so
106
+ * cached/virtualized day cells still repaint when any of it changes.
107
+ */
108
+ export interface CalendarSelection extends DateSelectionConstraints {
109
+ selectedDates?: Date[];
110
+ selectedRange?: DateRange;
111
+ }
112
+
113
+ const CalendarSelectionContext = createContext<CalendarSelection>({});
114
+
115
+ /**
116
+ * Provides the active selection to the month grid. Day cells read it via
117
+ * {@link useCalendarSelection} so they repaint on selection changes even when
118
+ * the virtualized list has cached (and so won't re-render) their page.
119
+ */
120
+ export const CalendarSelectionProvider = CalendarSelectionContext.Provider;
121
+
122
+ export const useCalendarSelection = () => useContext(CalendarSelectionContext);
123
+
124
+ /** Options for {@link useDateRange}. */
125
+ export interface UseDateRangeOptions extends DateSelectionConstraints {
126
+ /** Pre-select a range on mount. */
127
+ initialRange?: DateRange | null;
128
+ }
129
+
130
+ /**
131
+ * Controlled-ish range selection state for the month view. Returns the current
132
+ * `range` plus an `onPressDate` handler to wire to `Calendar`'s `onPressDay`, a
133
+ * `reset`, and the raw `setRange` for full control.
134
+ *
135
+ * ```tsx
136
+ * const { range, onPressDate } = useDateRange({ minDate: new Date() });
137
+ * <Calendar mode="month" selectedRange={range ?? undefined} onPressDay={onPressDate} … />
138
+ * ```
139
+ */
140
+ export function useDateRange(options: UseDateRangeOptions = {}) {
141
+ const { initialRange = null, minDate, maxDate, isDateDisabled } = options;
142
+ const [range, setRange] = useState<DateRange | null>(initialRange);
143
+ const constraints = useMemo<DateSelectionConstraints>(
144
+ () => ({ minDate, maxDate, isDateDisabled }),
145
+ [minDate, maxDate, isDateDisabled],
146
+ );
147
+ const onPressDate = useCallback(
148
+ (date: Date) => setRange((previous) => nextDateRange(previous, date, constraints)),
149
+ [constraints],
150
+ );
151
+ // Set both endpoints at once (ordered), for drag-to-select. Ignores the update
152
+ // if either endpoint isn't selectable.
153
+ const selectRange = useCallback(
154
+ (a: Date, b: Date) => {
155
+ if (!isDateSelectable(a, constraints) || !isDateSelectable(b, constraints)) return;
156
+ const [start, end] = a.getTime() <= b.getTime() ? [a, b] : [b, a];
157
+ setRange({ start: startOfDay(start), end: startOfDay(end) });
158
+ },
159
+ [constraints],
160
+ );
161
+ const reset = useCallback(() => setRange(null), []);
162
+ return { range, onPressDate, selectRange, reset, setRange };
163
+ }
@@ -0,0 +1,118 @@
1
+ import {
2
+ addDays,
3
+ eachDayOfInterval,
4
+ endOfMonth,
5
+ endOfWeek,
6
+ getHours,
7
+ getMinutes,
8
+ isSameDay,
9
+ isToday,
10
+ startOfDay,
11
+ startOfMonth,
12
+ startOfWeek,
13
+ } from "date-fns";
14
+ import type { CalendarMode, WeekStartsOn } from "../types";
15
+
16
+ /** The seven dates of the week containing `date`, starting on `weekStartsOn`. */
17
+ export const getWeekDays = (date: Date, weekStartsOn: WeekStartsOn): Date[] => {
18
+ const start = startOfWeek(date, { weekStartsOn });
19
+ return Array.from({ length: 7 }, (_, index) => addDays(start, index));
20
+ };
21
+
22
+ /** How many day columns a time-grid mode shows. `custom` uses `numberOfDays`. */
23
+ export const viewDayCount = (mode: CalendarMode, numberOfDays = 1): number => {
24
+ switch (mode) {
25
+ case "week":
26
+ return 7;
27
+ case "3days":
28
+ return 3;
29
+ case "custom":
30
+ return Math.max(1, Math.floor(numberOfDays));
31
+ default:
32
+ return 1; // 'day'
33
+ }
34
+ };
35
+
36
+ /**
37
+ * Days in the inclusive span from `weekStartsOn` to `weekEndsOn` (1–7),
38
+ * wrapping when the end precedes the start (e.g. Sat→Wed). Mirrors
39
+ * react-native-big-calendar's `weekDaysCount`.
40
+ */
41
+ export const weekDaysCount = (weekStartsOn: WeekStartsOn, weekEndsOn: WeekStartsOn): number => {
42
+ if (weekEndsOn < weekStartsOn) {
43
+ let count = 1;
44
+ let i = weekStartsOn;
45
+ while (i !== weekEndsOn && count <= 7) {
46
+ i = (i + 1) % 7;
47
+ count++;
48
+ }
49
+ return count;
50
+ }
51
+ if (weekEndsOn > weekStartsOn) return weekEndsOn - weekStartsOn + 1;
52
+ return 1;
53
+ };
54
+
55
+ /**
56
+ * The day columns to render for a time-grid page. `week` spans the calendar week
57
+ * (honouring `weekStartsOn`). `custom` with a `weekEndsOn` spans the partial week
58
+ * from `weekStartsOn` to `weekEndsOn` (anchored to `date`'s week, paging by week);
59
+ * otherwise every mode shows `viewDayCount` consecutive days starting at `date`.
60
+ */
61
+ export const getViewDays = (
62
+ mode: CalendarMode,
63
+ date: Date,
64
+ weekStartsOn: WeekStartsOn,
65
+ numberOfDays = 1,
66
+ isRTL = false,
67
+ weekEndsOn?: WeekStartsOn,
68
+ ): Date[] => {
69
+ let days: Date[];
70
+ if (mode === "week") {
71
+ days = getWeekDays(date, weekStartsOn);
72
+ } else if (mode === "custom" && weekEndsOn != null) {
73
+ // Mirror big-calendar: anchor to `date`'s week and take the partial-week span.
74
+ const subject = startOfDay(date);
75
+ const offset = weekStartsOn - subject.getDay();
76
+ days = Array.from({ length: weekDaysCount(weekStartsOn, weekEndsOn) }, (_, index) =>
77
+ addDays(subject, index + offset),
78
+ );
79
+ } else {
80
+ days = Array.from({ length: viewDayCount(mode, numberOfDays) }, (_, index) =>
81
+ addDays(startOfDay(date), index),
82
+ );
83
+ }
84
+ return isRTL ? days.reverse() : days;
85
+ };
86
+
87
+ /**
88
+ * The calendar weeks covering `month`, padded to whole weeks starting on
89
+ * `weekStartsOn`. `showSixWeeks` always returns six rows (42 days) for a
90
+ * fixed-height grid; `isRTL` reverses each week's day order.
91
+ */
92
+ export const buildMonthWeeks = (
93
+ month: Date,
94
+ weekStartsOn: WeekStartsOn,
95
+ { showSixWeeks = false, isRTL = false }: { showSixWeeks?: boolean; isRTL?: boolean } = {},
96
+ ): Date[][] => {
97
+ const start = startOfWeek(startOfMonth(month), { weekStartsOn });
98
+ const end = showSixWeeks ? addDays(start, 41) : endOfWeek(endOfMonth(month), { weekStartsOn });
99
+ const days = eachDayOfInterval({ start, end });
100
+ const weeks: Date[][] = [];
101
+ for (let index = 0; index < days.length; index += 7) {
102
+ const week = days.slice(index, index + 7);
103
+ weeks.push(isRTL ? week.reverse() : week);
104
+ }
105
+ return weeks;
106
+ };
107
+
108
+ export const isWeekend = (date: Date): boolean => {
109
+ const day = date.getDay();
110
+ return day === 0 || day === 6;
111
+ };
112
+
113
+ export const getIsToday = (date: Date): boolean => isToday(date);
114
+
115
+ export const isSameCalendarDay = (a: Date, b: Date): boolean => isSameDay(a, b);
116
+
117
+ /** Minutes elapsed since midnight (0–1439). */
118
+ export const minutesIntoDay = (date: Date): number => getHours(date) * 60 + getMinutes(date);
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Minutes to shift an event, snapping a vertical pixel drag to the nearest
3
+ * `stepMinutes`. Runs on the UI thread inside the drag gesture. Returns 0 for a
4
+ * degenerate grid (non-positive height/step).
5
+ */
6
+ export function snapDeltaMinutes(
7
+ translationPx: number,
8
+ cellHeightPx: number,
9
+ stepMinutes: number,
10
+ ): number {
11
+ "worklet";
12
+ if (cellHeightPx <= 0 || stepMinutes <= 0) return 0;
13
+ const rawMinutes = (translationPx / cellHeightPx) * 60;
14
+ return Math.round(rawMinutes / stepMinutes) * stepMinutes;
15
+ }
16
+
17
+ /** A copy of `date` shifted by `minutes` (may be negative). */
18
+ export function shiftMinutes(date: Date, minutes: number): Date {
19
+ const next = new Date(date);
20
+ next.setMinutes(next.getMinutes() + minutes);
21
+ return next;
22
+ }
23
+
24
+ /**
25
+ * Resolve a committed drag into the event's new bounds: `start` shifts by
26
+ * `deltaStartMinutes`, `end` by `deltaEndMinutes` (a move passes the same delta
27
+ * to both; a resize passes 0 for the start). Returns `null` when the change
28
+ * would collapse the event below one `snapMinutes` step, so the gesture leaves
29
+ * the event untouched rather than committing a degenerate duration. Pure, so the
30
+ * commit path is unit-testable without a running gesture.
31
+ */
32
+ export function resolveDraggedBounds(
33
+ start: Date,
34
+ end: Date,
35
+ deltaStartMinutes: number,
36
+ deltaEndMinutes: number,
37
+ snapMinutes: number,
38
+ ): { start: Date; end: Date } | null {
39
+ const nextStart = shiftMinutes(start, deltaStartMinutes);
40
+ const nextEnd = shiftMinutes(end, deltaEndMinutes);
41
+ if (nextEnd.getTime() - nextStart.getTime() < snapMinutes * 60_000) return null;
42
+ return { start: nextStart, end: nextEnd };
43
+ }
44
+
45
+ /**
46
+ * The start/end of a new event swept out on `day` by dragging from `startPx` to
47
+ * `endPx` (vertical pixels from the grid's top, i.e. the `minHour` line). Both
48
+ * ends snap to `snapMinutes`; the range is ordered (drag up or down) and widened
49
+ * to at least one step so a stationary press still yields a usable event.
50
+ * Returns `null` for a degenerate grid (non-positive height/step). Pure, so the
51
+ * commit path is unit-testable without a running gesture.
52
+ */
53
+ export function cellRangeFromDrag(
54
+ day: Date,
55
+ startPx: number,
56
+ endPx: number,
57
+ cellHeightPx: number,
58
+ minHour: number,
59
+ snapMinutes: number,
60
+ ): { start: Date; end: Date } | null {
61
+ if (cellHeightPx <= 0 || snapMinutes <= 0) return null;
62
+ const snapAt = (px: number) => {
63
+ const rawMinutes = (minHour + px / cellHeightPx) * 60;
64
+ return Math.round(rawMinutes / snapMinutes) * snapMinutes;
65
+ };
66
+ const MAX = 24 * 60;
67
+ let lower = Math.max(0, Math.min(MAX - snapMinutes, snapAt(startPx)));
68
+ let upper = Math.max(0, Math.min(MAX, snapAt(endPx)));
69
+ if (upper < lower) [lower, upper] = [upper, lower];
70
+ // Keep the range at least one step wide without spilling past midnight.
71
+ if (upper - lower < snapMinutes) upper = Math.min(MAX, lower + snapMinutes);
72
+ const start = new Date(day);
73
+ start.setHours(0, 0, 0, 0);
74
+ start.setMinutes(lower);
75
+ const end = new Date(day);
76
+ end.setHours(0, 0, 0, 0);
77
+ end.setMinutes(upper);
78
+ return { start, end };
79
+ }