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:
timeTable2 dev
2026-07-13 20:01:10 +08:00
parent e4f549f848
commit 02cfdda900
5 changed files with 186 additions and 43 deletions

59
src/lib/date.js Normal file
View 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
View 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))
}

View File

@@ -3,6 +3,7 @@
* Collapses a flat list of per-occurrence events into RRULE + EXDATE. * Collapses a flat list of per-occurrence events into RRULE + EXDATE.
*/ */
import { bydayFromDate } from './weekday.js' import { bydayFromDate } from './weekday.js'
import { toDate, toISODate, addDays, daysBetween } from './date.js'
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Grouping // Grouping
@@ -13,11 +14,11 @@ import { bydayFromDate } from './weekday.js'
* wall-clock time, duration (minutes), and weekday. * wall-clock time, duration (minutes), and weekday.
*/ */
export function seriesKey(ev) { export function seriesKey(ev) {
const start = new Date(ev.dtstartDate + 'T' + ev.dtstartTime + ':00')
const [sh, sm] = ev.dtstartTime.split(':').map(Number) const [sh, sm] = ev.dtstartTime.split(':').map(Number)
const [eh, em] = ev.dtendTime.split(':').map(Number) const [eh, em] = ev.dtendTime.split(':').map(Number)
const durationMin = (eh * 60 + em) - (sh * 60 + sm) 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 dates = [...dateStrings].sort()
const gaps = [] const gaps = []
for (let i = 0; i < dates.length - 1; i++) { for (let i = 0; i < dates.length - 1; i++) {
const a = new Date(dates[i] + 'T00:00:00') gaps.push(daysBetween(dates[i], dates[i + 1]))
const b = new Date(dates[i + 1] + 'T00:00:00')
gaps.push(Math.round((b - a) / (24 * 3600 * 1000)))
} }
let g = gaps[0] let g = gaps[0]
for (let i = 1; i < gaps.length; i++) { for (let i = 1; i < gaps.length; i++) {
@@ -51,6 +50,25 @@ export function detectInterval(dateStrings) {
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Series fitting // 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. * Try to collapse events into one recurring event.
* Returns { base, rrule, exdates } or null to decline. * Returns { base, rrule, exdates } or null to decline.
@@ -58,10 +76,8 @@ export function detectInterval(dateStrings) {
export function fitSeries(evs) { export function fitSeries(evs) {
if (evs.length < 2) return null if (evs.length < 2) return null
const sorted = [...evs].sort((a, b) => const sorted = [...evs].sort(byDate)
a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0, const starts = sorted.map((e) => e.dtstartDate)
)
const starts = sorted.map(e => e.dtstartDate)
const interval = detectInterval(starts) const interval = detectInterval(starts)
if (!interval) return null if (!interval) return null
@@ -69,28 +85,14 @@ export function fitSeries(evs) {
const last = sorted[sorted.length - 1] const last = sorted[sorted.length - 1]
const present = new Set(starts) const present = new Set(starts)
// Build expected grid first..last step interval days const { missing, offGrid } = buildGrid(first.dtstartDate, last.dtstartDate, interval, present)
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))
if (offGrid.length > 0) return null if (offGrid.length > 0) return null
// "Mostly regular": at least 2 occurrences and occurrences >= skips // "Mostly regular": at least 2 occurrences and occurrences >= skips
if (evs.length < 2 || missing.length >= evs.length) return null if (evs.length < 2 || missing.length >= evs.length) return null
// Build rrule // Build rrule
const firstDate = new Date(first.dtstartDate + 'T00:00:00') const firstDate = toDate(first.dtstartDate)
let freq, step, byday let freq, step, byday
if (interval % 7 === 0) { if (interval % 7 === 0) {
freq = 'WEEKLY' freq = 'WEEKLY'
@@ -127,10 +129,7 @@ export function organize(events) {
let nFlat = 0 let nFlat = 0
for (const evs of groups.values()) { for (const evs of groups.values()) {
// Sort by date evs.sort(byDate)
evs.sort((a, b) =>
a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0,
)
const fit = fitSeries(evs) const fit = fitSeries(evs)
if (fit) { if (fit) {

View File

@@ -2,6 +2,7 @@
* Recurrence grid expansion - port of ical_editor.py's occurrence generation. * Recurrence grid expansion - port of ical_editor.py's occurrence generation.
* occurrences are computed on-demand from exdates, never stored on the model. * 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. */ /** Interval in days for a recurring event. */
export function intervalDays(ev) { export function intervalDays(ev) {
@@ -11,12 +12,7 @@ export function intervalDays(ev) {
return freq === 'WEEKLY' ? 7 * step : step return freq === 'WEEKLY' ? 7 * step : step
} }
function toISO(date) { const MAX_OCCURRENCES = 500
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}`
}
/** /**
* Expand a recurring event into an occurrence grid. * Expand a recurring event into an occurrence grid.
@@ -33,20 +29,18 @@ export function expandOccurrences(ev) {
return [{ date: ev.dtstartDate, skipped: false }] return [{ date: ev.dtstartDate, skipped: false }]
} }
const start = new Date(ev.dtstartDate + 'T00:00:00')
const until = ev.rrule.untilDate const until = ev.rrule.untilDate
? new Date(ev.rrule.untilDate + 'T00:00:00') ? toDate(ev.rrule.untilDate)
: new Date(start.getTime() + 365 * 24 * 3600 * 1000) // default +1 year : new Date(toDate(ev.dtstartDate).getTime() + 365 * 24 * 3600 * 1000)
const exdateSet = new Set(ev.exdates || []) const exdateSet = new Set(ev.exdates || [])
const occs = [] const occs = []
const cur = new Date(start) let cur = ev.dtstartDate
let count = 0 let count = 0
while (cur <= until && count < 500) { while (cur <= toISODate(until) && count < MAX_OCCURRENCES) {
const iso = toISO(cur) occs.push({ date: cur, skipped: exdateSet.has(cur) })
occs.push({ date: iso, skipped: exdateSet.has(iso) }) cur = addDays(cur, step)
cur.setDate(cur.getDate() + step)
count++ count++
} }

50
src/lib/split.js Normal file
View 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]
}