refactor: extract date.js, event-factory.js, split.js; use shared date helpers in recur/organize
- date.js: single source of truth for ISO date formatting/arithmetic (toISODate, parseISODate, addDays, daysBetween, todayISO, nowCompactTimestamp) - event-factory.js: createEvent/cloneEvent (structuredClone with JSON fallback) - split.js: splitRecurringEvent pure function using intervalDays from recur.js - recur.js: replaced local toISO() and inline Date math with date.js helpers - organize.js: replaced inline date formatting and gap math with date.js helpers, extracted buildGrid() helper and byDate comparator
This commit is contained in:
59
src/lib/date.js
Normal file
59
src/lib/date.js
Normal file
@@ -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())}`
|
||||
}
|
||||
41
src/lib/event-factory.js
Normal file
41
src/lib/event-factory.js
Normal file
@@ -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))
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
|
||||
50
src/lib/split.js
Normal file
50
src/lib/split.js
Normal file
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user