Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 0.3.0-rc.19

### Added

- `planCalendarOccurrenceChoice` describes both choices for a calendar gesture on a generated recurring occurrence: editing only that occurrence (the occurrence date and the `scheduled`/`due`/`timeEstimate` overrides for its occurrence note, with the due date keeping its offset) or the whole series (`time`, `all-day`, `timed` or `duration`; absent when a series edit would change nothing, such as a move to another day at the same time). `buildInheritedOccurrenceTask` is exported.

### Fixed

- Materializing a recurring occurrence the parent records as completed or skipped creates the note with the completed (and `completedDate`) or skipped status instead of reopening it. `buildMaterializeOccurrencePlan` accepts `completedStatus` and `skippedStatus`, and throws `missing_skipped_status` for a skipped date when no skipped status is available.
- Moving a scheduled-to-due span shifts both dates by calendar days and wall-clock time, so a daylight-saving change inside the span no longer moves the due time by an hour. Dropping a timed span on a day (all-day row or month cell) stores date-only scheduled and due dates instead of midnight times.
- Calendar date/timezone conversions reuse one `Intl.DateTimeFormat` per zone and memoize results. Projecting 2,000 tasks into a new week range drops from about 970 ms to 15 ms (TaskNotes calendar view switches with large vaults).

## 0.3.0-rc.18

### Fixed
Expand Down
126 changes: 126 additions & 0 deletions fixtures/calendar/scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -2587,5 +2587,131 @@
}
}
]
},
{
"name": "timed-span-moves-by-wall-clock-across-dst",
"input": {
"tasks": [
{
"id": "a",
"path": "a.md",
"title": "A",
"status": "open",
"priority": "normal",
"archived": false,
"scheduled": "2026-10-03T09:00",
"due": "2026-10-04T09:00"
}
],
"settings": {
"showScheduledToDueSpan": true
},
"range": {
"start": "2026-09-28",
"end": "2026-10-05"
},
"timezone": "Australia/Melbourne",
"now": "2026-09-28T08:00:00Z"
},
"expectedEntries": [
{
"id": "span-a-2026-10-03",
"taskId": "a",
"title": "A",
"type": "scheduledToDueSpan",
"start": "2026-10-03T09:00",
"allDay": false,
"durationMinutes": 60,
"isCompleted": false,
"isSkipped": false,
"startEditable": true,
"durationEditable": false,
"editReason": "span-shift"
},
{
"id": "span-a-2026-10-04",
"taskId": "a",
"title": "A",
"type": "scheduledToDueSpan",
"start": "2026-10-04T09:00",
"allDay": false,
"durationMinutes": 60,
"isCompleted": false,
"isSkipped": false,
"startEditable": true,
"durationEditable": false,
"editReason": "span-shift"
}
],
"edits": [
{
"entryId": "span-a-2026-10-03",
"change": {
"kind": "drop",
"start": "2026-10-04T09:00",
"allDay": false
},
"expectedUpdates": {
"scheduled": "2026-10-04T09:00",
"due": "2026-10-05T09:00",
"dateModified": "2026-09-28T08:00:00Z"
},
"expectedFrontmatterPatch": [
{
"op": "set",
"field": "scheduled",
"value": "2026-10-04T09:00"
},
{
"op": "set",
"field": "due",
"value": "2026-10-05T09:00"
},
{
"op": "set",
"field": "dateModified",
"value": "2026-09-28T08:00:00Z"
}
],
"expectedClearedInstances": {
"complete": [],
"skipped": []
}
},
{
"entryId": "span-a-2026-10-03",
"change": {
"kind": "drop",
"start": "2026-10-04",
"allDay": true
},
"expectedUpdates": {
"scheduled": "2026-10-04",
"due": "2026-10-05",
"dateModified": "2026-09-28T08:00:00Z"
},
"expectedFrontmatterPatch": [
{
"op": "set",
"field": "scheduled",
"value": "2026-10-04"
},
{
"op": "set",
"field": "due",
"value": "2026-10-05"
},
{
"op": "set",
"field": "dateModified",
"value": "2026-09-28T08:00:00Z"
}
],
"expectedClearedInstances": {
"complete": [],
"skipped": []
}
}
]
}
]
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tasknotes/model",
"version": "0.3.0-rc.18",
"version": "0.3.0-rc.19",
"description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.",
"license": "MIT",
"type": "module",
Expand Down
30 changes: 29 additions & 1 deletion src/calendar-date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,28 @@ export function calendarDaysBetween(start: string, end: string): number {
);
}

// Formatter construction dominates conversion cost; conversions are pure, so both are memoized.
const formatters = new Map<string, Intl.DateTimeFormat>();
const CACHE_LIMIT = 20_000;
const toInstant = new Map<string, number>();
const toCivil = new Map<string, string>();

function remember<T>(cache: Map<string, T>, key: string, value: T): T {
if (cache.size >= CACHE_LIMIT) cache.clear();
cache.set(key, value);
return value;
}

function formatter(timezone: string): Intl.DateTimeFormat {
let format = formatters.get(timezone);
if (!format) {
format = createFormatter(timezone);
formatters.set(timezone, format);
}
return format;
}

function createFormatter(timezone: string): Intl.DateTimeFormat {
return new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
Expand All @@ -39,6 +60,12 @@ function civilAt(ms: number, format: Intl.DateTimeFormat): string {
* DST overlaps choose the earlier instant; gaps move forward (Date-compatible).
*/
export function calendarDateToInstant(value: string, timezone: string): number {
const key = `${timezone}\n${value}`;
const cached = toInstant.get(key);
return cached !== undefined ? cached : remember(toInstant, key, dateToInstant(value, timezone));
}

function dateToInstant(value: string, timezone: string): number {
const match = CIVIL.exec(value);
if (!match) {
if (!/T.*(?:Z|[+-]\d{2}:?\d{2})$/i.test(value)) throw new Error("invalid-calendar-date");
Expand Down Expand Up @@ -67,6 +94,7 @@ export function calendarDateToInstant(value: string, timezone: string): number {
}

export function calendarInstantToDate(ms: number, timezone: string, allDay = false): string {
const civil = civilAt(ms, formatter(timezone));
const key = `${timezone}\n${ms}`;
const civil = toCivil.get(key) ?? remember(toCivil, key, civilAt(ms, formatter(timezone)));
return civil.slice(0, allDay ? 10 : 16);
}
103 changes: 95 additions & 8 deletions src/calendar.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { addDTSTARTToRecurrenceRule, generateRecurringInstances } from "./recurrence.js";
import { normalizeRecurrenceOriginalDate } from "./mapping.js";
import { normalizeTaskReference, taskInfoUpdatesToFrontmatterPatch } from "./operations.js";
import {
buildInheritedOccurrenceTask,
normalizeTaskReference,
taskInfoUpdatesToFrontmatterPatch,
} from "./operations.js";
import type { FieldMapping, TaskInfo, TaskOperationPlan, TaskUpdateInput } from "./types.js";
import {
addCalendarDays,
Expand Down Expand Up @@ -545,14 +549,28 @@ export function planCalendarEdit({
? Math.round((end - start) / 86_400_000) * 1440
: Math.round((end - start) / 60000);
} else if (entry.type === "scheduledToDueSpan") {
const delta = start - calendarDateToInstant(entry.start, timezone);
// Shift by calendar days and wall-clock minutes, not elapsed time, so a
// daylight-saving change inside the span never moves either clock. A drop
// on a day (all-day row, month cell) makes both dates date-only.
const toDay = change.allDay || change.kind === "drop-on-day";
const from = calendarInstantToDate(calendarDateToInstant(entry.start, timezone), timezone, entry.allDay);
const days = calendarDaysBetween(from, value);
const clock = (civil: string) => Number(civil.slice(11, 13)) * 60 + Number(civil.slice(14, 16));
const minutes = !toDay && from.includes("T") ? clock(value) - clock(from) : 0;
for (const property of ["scheduled", "due"] as const) {
if (task[property])
updates[property] = calendarInstantToDate(
calendarDateToInstant(task[property]!, timezone) + delta,
timezone,
!task[property]!.includes("T"),
);
const current = task[property];
if (!current) continue;
const civil = calendarInstantToDate(
calendarDateToInstant(current, timezone),
timezone,
!current.includes("T"),
);
updates[property] =
toDay || !civil.includes("T")
? addCalendarDays(calendarDay(civil), days)
: new Date(Date.parse(`${civil}Z`) + days * 86_400_000 + minutes * 60_000)
.toISOString()
.slice(0, 16);
}
} else if (entry.recurringKind === "pattern" && !entry.isMaterializedOccurrence) {
if (!task.recurrence) throw new Error("missing-recurrence");
Expand Down Expand Up @@ -682,6 +700,75 @@ export function planCalendarEdit({
};
}

/**
* What a series-wide calendar edit of a pattern occurrence changes: its clock
* ("time"), all occurrences to all-day ("all-day") or to timed ("timed"), or
* their duration ("duration"). A pattern drop never moves the series' days.
*/
export type CalendarSeriesEffect = "time" | "all-day" | "timed" | "duration";
export interface CalendarOccurrenceChoice {
/** Edit only this occurrence: materialize its note with these overrides. */
occurrence: {
occurrenceDate: string;
overrides: Pick<Partial<TaskInfo>, "scheduled" | "due" | "timeEstimate">;
};
/** Edit every occurrence through planCalendarEdit; absent when that would change nothing. */
series?: CalendarSeriesEffect;
}
export interface PlanCalendarOccurrenceChoiceInput {
task: TaskInfo;
entry: CalendarEntry;
change: CalendarChange;
timezone: string;
}

/**
* For a gesture on a generated (pattern) occurrence, describes both choices a
* host should offer: this occurrence only, or the whole series. Returns
* undefined for any other entry, which planCalendarEdit handles directly.
*/
export function planCalendarOccurrenceChoice({
task,
entry,
change,
timezone,
}: PlanCalendarOccurrenceChoiceInput): CalendarOccurrenceChoice | undefined {
if (entry.recurringKind !== "pattern" || entry.isMaterializedOccurrence) return undefined;
if (entry.taskId !== calendarTaskId(task)) throw new Error("calendar-task-mismatch");
const occurrenceDate = entry.instanceDate || calendarDay(entry.start);
const start = calendarDateToInstant(change.start, timezone);
if (change.kind === "resize") {
if (!change.end) throw new Error("missing-calendar-range");
const end = calendarDateToInstant(change.end, timezone);
const timeEstimate = change.allDay
? Math.round((end - start) / 86_400_000) * 1440
: Math.round((end - start) / 60000);
return {
occurrence: { occurrenceDate, overrides: { timeEstimate } },
series: "duration",
};
}
const toDay = change.allDay || change.kind === "drop-on-day";
const scheduled = calendarInstantToDate(start, timezone, toDay);
const overrides: CalendarOccurrenceChoice["occurrence"]["overrides"] = { scheduled };
// The due date keeps its offset from the occurrence's day.
const inheritedDue = buildInheritedOccurrenceTask(task, occurrenceDate).due;
if (inheritedDue) {
const days = calendarDaysBetween(occurrenceDate, calendarDay(scheduled));
overrides.due = `${addCalendarDays(calendarDay(inheritedDue), days)}${inheritedDue.slice(10)}`;
}
const from = calendarInstantToDate(calendarDateToInstant(entry.start, timezone), timezone, entry.allDay);
const series: CalendarSeriesEffect | undefined =
toDay && !entry.allDay
? "all-day"
: !toDay && entry.allDay
? "timed"
: !toDay && from.slice(11, 16) !== scheduled.slice(11, 16)
? "time"
: undefined;
return { occurrence: { occurrenceDate, overrides }, series };
}

export interface CalendarEntryIndex {
setTasks(tasks: readonly CalendarTask[]): void;
/** New object identity OR revision invalidates task-local range expansions. */
Expand Down
Loading
Loading