diff --git a/src/lib/date.js b/src/lib/date.js new file mode 100644 index 0000000..3f47198 --- /dev/null +++ b/src/lib/date.js @@ -0,0 +1,59 @@ +/** + * Centralized date-string utilities. + * + * All date values in the app use the 'YYYY-MM-DD' (wall-clock) format. + * These helpers are the single source of truth for formatting and + * arithmetic on that format, replacing ad-hoc inline code that was + * duplicated across organize.js, recur.js, useCalendar.js, and ical-io.js. + */ + +/** Pad a number to 2 digits with leading zero. */ +export function pad2(n) { + return String(n).padStart(2, '0') +} + +/** Convert a JS Date to 'YYYY-MM-DD' (local wall-clock). */ +export function toISODate(date) { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}` +} + +/** Parse 'YYYY-MM-DD' into { year, month, day } (month is 1-based). */ +export function parseISODate(s) { + const [y, m, d] = s.split('-').map(Number) + return { year: y, month: m, day: d } +} + +/** Build a local JS Date at midnight from 'YYYY-MM-DD'. */ +export function toDate(isoDate) { + return new Date(isoDate + 'T00:00:00') +} + +/** Add `days` days to an ISO date string, return new ISO string. */ +export function addDays(isoDate, days) { + const d = toDate(isoDate) + d.setDate(d.getDate() + days) + return toISODate(d) +} + +/** Whole-day difference between two ISO date strings (b - a). */ +export function daysBetween(isoA, isoB) { + return Math.round((toDate(isoB) - toDate(isoA)) / (24 * 3600 * 1000)) +} + +/** Today's date as 'YYYY-MM-DD' (local). */ +export function todayISO() { + return toISODate(new Date()) +} + +/** + * Current UTC timestamp in compact RFC 5545 form (e.g. '20260711T085008Z'). + * Used for DTSTAMP on new events. + */ +export function nowCompactTimestamp() { + return new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' +} + +/** Format a JS Date's time as 'HH:MM'. */ +export function toHHMM(date) { + return `${pad2(date.getHours())}:${pad2(date.getMinutes())}` +} diff --git a/src/lib/event-factory.js b/src/lib/event-factory.js new file mode 100644 index 0000000..0dff148 --- /dev/null +++ b/src/lib/event-factory.js @@ -0,0 +1,41 @@ +/** + * Event model factory and helpers. + * + * The EventModel is the plain-JS shape used throughout the app (see + * docs/superpowers/specs/... §4.2). This module centralizes creation + * and cloning so that useCalendar.js stays focused on state management. + */ +import { todayISO, nowCompactTimestamp } from './date.js' + +/** + * Create a new single-occurrence event with sensible defaults. + * @param {object} [overrides] - partial fields to override + * @returns {object} a fresh EventModel + */ +export function createEvent(overrides = {}) { + return { + uid: crypto.randomUUID(), + summary: '新日程', + location: '', + description: '', + dtstartDate: todayISO(), + dtstartTime: '09:00', + dtendTime: '10:00', + tzid: 'UTC', + rrule: null, + exdates: [], + _raw: { dtstamp: nowCompactTimestamp() }, + ...overrides, + } +} + +/** + * Deep-clone an event via structuredClone (available in modern browsers + * and Node 17+). Falls back to JSON round-trip for older environments. + */ +export function cloneEvent(ev) { + if (typeof structuredClone === 'function') { + return structuredClone(ev) + } + return JSON.parse(JSON.stringify(ev)) +} diff --git a/src/lib/organize.js b/src/lib/organize.js index 564d1e3..3cd4d76 100644 --- a/src/lib/organize.js +++ b/src/lib/organize.js @@ -3,6 +3,7 @@ * Collapses a flat list of per-occurrence events into RRULE + EXDATE. */ import { bydayFromDate } from './weekday.js' +import { toDate, toISODate, addDays, daysBetween } from './date.js' // ------------------------------------------------------------------ // // Grouping @@ -13,11 +14,11 @@ import { bydayFromDate } from './weekday.js' * wall-clock time, duration (minutes), and weekday. */ export function seriesKey(ev) { - const start = new Date(ev.dtstartDate + 'T' + ev.dtstartTime + ':00') const [sh, sm] = ev.dtstartTime.split(':').map(Number) const [eh, em] = ev.dtendTime.split(':').map(Number) const durationMin = (eh * 60 + em) - (sh * 60 + sm) - return [ev.summary, ev.location, ev.dtstartTime, durationMin, start.getDay()].join('|') + const weekday = toDate(ev.dtstartDate).getDay() + return [ev.summary, ev.location, ev.dtstartTime, durationMin, weekday].join('|') } // ------------------------------------------------------------------ // @@ -37,9 +38,7 @@ export function detectInterval(dateStrings) { const dates = [...dateStrings].sort() const gaps = [] for (let i = 0; i < dates.length - 1; i++) { - const a = new Date(dates[i] + 'T00:00:00') - const b = new Date(dates[i + 1] + 'T00:00:00') - gaps.push(Math.round((b - a) / (24 * 3600 * 1000))) + gaps.push(daysBetween(dates[i], dates[i + 1])) } let g = gaps[0] for (let i = 1; i < gaps.length; i++) { @@ -51,6 +50,25 @@ export function detectInterval(dateStrings) { // ------------------------------------------------------------------ // // Series fitting // ------------------------------------------------------------------ // +const byDate = (a, b) => (a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0) + +/** + * Build the expected occurrence grid from first to last at the given interval. + * Returns { expected: string[], missing: string[] } relative to present dates. + */ +function buildGrid(firstDate, lastDate, interval, present) { + const expected = [] + let cur = firstDate + while (cur <= lastDate) { + expected.push(cur) + cur = addDays(cur, interval) + } + const expectedSet = new Set(expected) + const missing = expected.filter((d) => !present.has(d)) + const offGrid = [...present].filter((d) => !expectedSet.has(d)) + return { expected, missing, offGrid } +} + /** * Try to collapse events into one recurring event. * Returns { base, rrule, exdates } or null to decline. @@ -58,10 +76,8 @@ export function detectInterval(dateStrings) { export function fitSeries(evs) { if (evs.length < 2) return null - const sorted = [...evs].sort((a, b) => - a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0, - ) - const starts = sorted.map(e => e.dtstartDate) + const sorted = [...evs].sort(byDate) + const starts = sorted.map((e) => e.dtstartDate) const interval = detectInterval(starts) if (!interval) return null @@ -69,28 +85,14 @@ export function fitSeries(evs) { const last = sorted[sorted.length - 1] const present = new Set(starts) - // Build expected grid first..last step interval days - const expected = [] - const cur = new Date(first.dtstartDate + 'T00:00:00') - const lastDate = new Date(last.dtstartDate + 'T00:00:00') - while (cur <= lastDate) { - const y = cur.getFullYear() - const m = String(cur.getMonth() + 1).padStart(2, '0') - const d = String(cur.getDate()).padStart(2, '0') - expected.push(`${y}-${m}-${d}`) - cur.setDate(cur.getDate() + interval) - } - - const expectedSet = new Set(expected) - const missing = expected.filter(d => !present.has(d)) - const offGrid = starts.filter(d => !expectedSet.has(d)) + const { missing, offGrid } = buildGrid(first.dtstartDate, last.dtstartDate, interval, present) if (offGrid.length > 0) return null // "Mostly regular": at least 2 occurrences and occurrences >= skips if (evs.length < 2 || missing.length >= evs.length) return null // Build rrule - const firstDate = new Date(first.dtstartDate + 'T00:00:00') + const firstDate = toDate(first.dtstartDate) let freq, step, byday if (interval % 7 === 0) { freq = 'WEEKLY' @@ -127,10 +129,7 @@ export function organize(events) { let nFlat = 0 for (const evs of groups.values()) { - // Sort by date - evs.sort((a, b) => - a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0, - ) + evs.sort(byDate) const fit = fitSeries(evs) if (fit) { diff --git a/src/lib/recur.js b/src/lib/recur.js index 7e3c3ae..b29b209 100644 --- a/src/lib/recur.js +++ b/src/lib/recur.js @@ -2,6 +2,7 @@ * Recurrence grid expansion - port of ical_editor.py's occurrence generation. * occurrences are computed on-demand from exdates, never stored on the model. */ +import { toISODate, toDate, addDays } from './date.js' /** Interval in days for a recurring event. */ export function intervalDays(ev) { @@ -11,12 +12,7 @@ export function intervalDays(ev) { return freq === 'WEEKLY' ? 7 * step : step } -function toISO(date) { - const y = date.getFullYear() - const m = String(date.getMonth() + 1).padStart(2, '0') - const d = String(date.getDate()).padStart(2, '0') - return `${y}-${m}-${d}` -} +const MAX_OCCURRENCES = 500 /** * Expand a recurring event into an occurrence grid. @@ -33,20 +29,18 @@ export function expandOccurrences(ev) { return [{ date: ev.dtstartDate, skipped: false }] } - const start = new Date(ev.dtstartDate + 'T00:00:00') const until = ev.rrule.untilDate - ? new Date(ev.rrule.untilDate + 'T00:00:00') - : new Date(start.getTime() + 365 * 24 * 3600 * 1000) // default +1 year + ? toDate(ev.rrule.untilDate) + : new Date(toDate(ev.dtstartDate).getTime() + 365 * 24 * 3600 * 1000) const exdateSet = new Set(ev.exdates || []) const occs = [] - const cur = new Date(start) + let cur = ev.dtstartDate let count = 0 - while (cur <= until && count < 500) { - const iso = toISO(cur) - occs.push({ date: iso, skipped: exdateSet.has(iso) }) - cur.setDate(cur.getDate() + step) + while (cur <= toISODate(until) && count < MAX_OCCURRENCES) { + occs.push({ date: cur, skipped: exdateSet.has(cur) }) + cur = addDays(cur, step) count++ } diff --git a/src/lib/split.js b/src/lib/split.js new file mode 100644 index 0000000..7d6a6ae --- /dev/null +++ b/src/lib/split.js @@ -0,0 +1,50 @@ +/** + * Split a recurring event into two at a given date. + * + * Extracted from useCalendar.js as a pure function so the store stays + * focused on state management. The grid-snapping logic reuses + * intervalDays from recur.js (single source of truth for step size). + * + * @param {object} ev - the event to split (must have a non-null rrule) + * @param {string} splitDate - 'YYYY-MM-DD', must be within the event's range + * @returns {[object, object]|null} [partA, partB] or null if the date is + * out of range or the event is not recurring. + */ +import { intervalDays } from './recur.js' +import { cloneEvent } from './event-factory.js' +import { toDate, toISODate, addDays, daysBetween } from './date.js' + +/** Snap a date forward to the next grid point on or after it. */ +function snapToGrid(startDate, targetDate, stepDays, untilDate) { + const start = toDate(startDate) + let snap = toDate(targetDate) + if (snap < start) snap = new Date(start) + while (daysBetween(startDate, toISODate(snap)) % stepDays !== 0) { + snap.setDate(snap.getDate() + 1) + if (snap > toDate(untilDate)) return null + } + return toISODate(snap) +} + +export function splitRecurringEvent(ev, splitDate) { + if (!ev.rrule) return null + const { untilDate } = ev.rrule + if (splitDate <= ev.dtstartDate || splitDate > untilDate) return null + + const step = intervalDays(ev) + const snapStr = snapToGrid(ev.dtstartDate, splitDate, step, untilDate) + if (!snapStr) return null + + const prevStr = addDays(snapStr, -step) + + const partA = cloneEvent(ev) + partA.rrule = { ...ev.rrule, untilDate: prevStr } + partA.exdates = ev.exdates.filter((d) => d < splitDate) + + const partB = cloneEvent(ev) + partB.uid = crypto.randomUUID() + partB.dtstartDate = snapStr + partB.exdates = ev.exdates.filter((d) => d >= splitDate) + + return [partA, partB] +}