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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,34 @@ All notable changes to `@datelane/core` are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/) and this project adheres to
[Semantic Versioning](https://semver.org/).

## [0.2.0] - 2026-06-21

Adds recurrence expansion plus navigation and scrolling features. Backward compatible — no breaking
API changes. Still zero hard runtime dependencies.

### Added

- **Recurrence** — recurring records expand into per-occurrence events for the visible range via a
built-in RFC 5545 RRULE subset (`FREQ` DAILY/WEEKLY/MONTHLY/YEARLY, `INTERVAL`, `COUNT`, `UNTIL`,
`BYDAY`, `BYMONTHDAY`) with `EXDATE` exceptions. No `rrule` dependency. New `FieldMap`
`recurrenceExceptions` mapping; occurrences carry `seriesId` + `recurrenceId`, and CRUD on an
occurrence emits `scope: 'occurrence'`.
- **Calendar popover** — clicking the header date label opens a keyboard-navigable mini-calendar
(arrows / Home / End / PageUp / PageDown / Enter / Esc) to jump `viewDate` while keeping the view.
- **Drill-down navigation** — Timeline Day/Week/WorkWeek headers drill into Agenda; Timeline
Month/Year headers drill into Timeline Day (complements the existing day-cell → Day drill).
- **Virtual scrolling** — `allowVirtualScrolling` on Agenda / Timeline views skips off-screen rows
via CSS `content-visibility` (zero-dependency, SSR-safe).
- `DateAdapter.fromParts(...)` primitive (implemented across Native / Luxon / Moment) for building
absolute dates from calendar parts.

### Fixed

- Timeline rows now use a composite track key so a recurring series renders multiple bars in one row
without duplicate-key collisions.
- Long-running open-ended DAILY/WEEKLY series viewed far from their start now fast-forward to the
visible window instead of exhausting the iteration cap and rendering nothing.

## [0.1.0] - 2026-06-07

First public pre-release. All 12 view modes render; the core ships with zero hard runtime
Expand Down
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ A lightweight, fully customizable **Angular scheduler / calendar** — all 12 vi
(Day, Week, Work Week, Month, Year, Agenda, Month Agenda, and the five Timeline views) — with
**zero hard runtime dependencies** and a **pluggable date layer** (Native, Luxon, or Moment).

> Status: `0.1.0` pre-release. The 12 views, drag/resize, a host-driven quick-view, resources,
> and auto-scroll are implemented. Recurrence, a full editor window, and keyboard grid navigation
> are on the roadmap (see [Limitations](#limitations)).
> Status: `0.2.0` pre-release. The 12 views, drag/resize, a host-driven quick-view, resources,
> auto-scroll, **recurrence (RRULE expansion + EXDATE)**, a **date-jump calendar popover**,
> **header/cell drill-down navigation**, and **virtual scrolling** are implemented. A full editor
> window and full keyboard grid navigation are on the roadmap (see [Limitations](#limitations)).

## Highlights

Expand Down Expand Up @@ -170,7 +171,8 @@ fieldMap: FieldMap = {
start: 'StartTime',
end: 'EndTime',
isAllDay: 'IsAllDay', // optional
recurrenceRule: 'RecurrenceRule', // optional (reserved; engine pending)
recurrenceRule: 'RecurrenceRule', // optional — RFC 5545 RRULE, expanded automatically
recurrenceExceptions: 'ExDates', // optional — EXDATE list (skipped occurrences)
resource: 'OwnerId', // optional — string or string[]
color: 'Color', // optional — overrides resource color
location: 'Location', // optional — shown in the quick-view
Expand Down Expand Up @@ -367,10 +369,12 @@ all five majors.

## Limitations

- Recurrence (RRULE/EXDATE), a full editor window, and keyboard grid navigation are not yet
implemented.
- Recurrence covers a pragmatic RRULE subset (FREQ/INTERVAL/COUNT/UNTIL/BYDAY/BYMONTHDAY); ordinal
`BYDAY` (`2MO`), `BYSETPOS`, `BYMONTH`, and a recurrence **editor UI** are not yet implemented.
- A full editor window and complete keyboard grid navigation are still on the roadmap.
- Timeline resource grouping is single-level (no hierarchy yet).
- Agenda does not yet virtualize long ranges.
- Virtual scrolling uses CSS `content-visibility` (off-screen rows skip render); it is not a
windowed/recycled list.
- Luxon/Moment adapters lack a shared parity test suite; Moment format-token parity is unverified.
- No pre-compiled CSS is shipped yet — import the SCSS (see [Styling](#styling)).

Expand Down
3 changes: 3 additions & 0 deletions projects/datelane/luxon-adapter/src/luxon-date-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export class LuxonDateAdapter extends DateAdapter<DateTime> {
now(): DateTime { return DateTime.now(); }
clone(d: DateTime): DateTime { return DateTime.fromMillis(d.toMillis(), { zone: d.zone }); }
fromNative(d: Date): DateTime { return DateTime.fromJSDate(d); }
fromParts(year: number, month: number, day: number, hours = 0, minutes = 0, seconds = 0): DateTime {
return DateTime.local(year, month + 1, day, hours, minutes, seconds);
}
toNative(d: DateTime): Date { return d.toJSDate(); }
isValid(d: DateTime): boolean { return d.isValid; }

Expand Down
3 changes: 3 additions & 0 deletions projects/datelane/moment-adapter/src/moment-date-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
now(): Moment { return moment(); }
clone(d: Moment): Moment { return d.clone(); }
fromNative(d: Date): Moment { return moment(d); }
fromParts(year: number, month: number, day: number, hours = 0, minutes = 0, seconds = 0): Moment {
return moment({ year, month, day, hour: hours, minute: minutes, second: seconds });
}
toNative(d: Moment): Date { return d.toDate(); }
isValid(d: Moment): boolean { return d.isValid(); }

Expand Down Expand Up @@ -50,7 +53,7 @@
return d.clone().locale(locale).format(pattern);
}
parse(value: unknown, pattern?: string): Moment {
return pattern ? moment(value as string, pattern) : moment(value as any);

Check warning on line 56 in projects/datelane/moment-adapter/src/moment-date-adapter.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
}
getDayNames(style: 'long' | 'short' | 'narrow', locale = this.locale): string[] {
const m = moment().locale(locale);
Expand Down
6 changes: 5 additions & 1 deletion projects/datelane/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@datelane/core",
"version": "0.1.0",
"version": "0.2.0",
"description": "Lightweight, customizable, dependency-free Angular scheduler/calendar with pluggable date adapters (Native/Luxon/Moment).",
"license": "MIT",
"keywords": ["angular", "scheduler", "calendar", "timeline", "agenda", "events", "luxon", "moment"],
Expand All @@ -9,6 +9,10 @@
"bugs": { "url": "https://github.com/devendramilmile121/datelane/issues" },
"sideEffects": false,
"publishConfig": { "access": "public" },
"exports": {
"./styles/*": "./styles/*",
"./styles/scheduler": "./styles/scheduler.scss"
},
"peerDependencies": {
"@angular/core": ">=18.0.0 <23.0.0",
"@angular/common": ">=18.0.0 <23.0.0",
Expand Down
7 changes: 6 additions & 1 deletion projects/datelane/src/lib/core/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,14 @@ export interface SchedulerEvent<D = unknown> {
start: D;
end: D;
isAllDay: boolean;
recurrenceRule?: string;
recurrenceRule?: string; // RFC 5545 RRULE
recurrenceExceptions?: string; // EXDATE list (comma/newline separated)
resourceIds?: Array<string | number>;
color?: string;
/** Set on expanded recurrence occurrences: the original series event id. */
seriesId?: string | number;
/** Set on expanded occurrences: start of the original (non-overridden) slot, for EXDATE/override matching. */
recurrenceId?: D;
raw: Record<string, unknown>; // original record, for round-tripping
}

Expand Down
5 changes: 5 additions & 0 deletions projects/datelane/src/lib/date-adapter/date-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export abstract class DateAdapter<D = unknown> {
abstract now(): D; // current date AND time (for the now-line)
abstract clone(date: D): D;
abstract fromNative(date: Date): D;
/** Build an absolute date from calendar parts (local time). `month` is 0-11. */
abstract fromParts(
year: number, month: number, day: number,
hours?: number, minutes?: number, seconds?: number,
): D;
abstract toNative(date: D): Date;
abstract isValid(date: D): boolean;

Expand Down
15 changes: 15 additions & 0 deletions projects/datelane/src/lib/date-adapter/native-date-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import { NativeDateAdapter } from './native-date-adapter';
import { DateAdapter } from './date-adapter';

export function runAdapterParitySuite(makeAdapter: () => DateAdapter<any>, label: string) {

Check warning on line 6 in projects/datelane/src/lib/date-adapter/native-date-adapter.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
describe(`DateAdapter parity — ${label}`, () => {
let a: DateAdapter<any>;

Check warning on line 8 in projects/datelane/src/lib/date-adapter/native-date-adapter.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
const d = (y: number, m: number, day: number, h = 0, min = 0) =>
a.fromNative(new Date(y, m, day, h, min));

Expand All @@ -30,6 +30,21 @@
expect(a.getMonth(feb)).toBe(1);
});

it('fromParts builds a local date from calendar parts (month 0-11)', () => {
const made = a.fromParts(2025, 2, 9, 14, 30, 0); // 9 Mar 2025, 14:30 local
expect(a.getYear(made)).toBe(2025);
expect(a.getMonth(made)).toBe(2);
expect(a.getDate(made)).toBe(9);
expect(a.getHours(made)).toBe(14);
expect(a.getMinutes(made)).toBe(30);
});

it('fromParts defaults the time to midnight', () => {
const made = a.fromParts(2025, 0, 1);
expect(a.getHours(made)).toBe(0);
expect(a.getMinutes(made)).toBe(0);
});

it('day names start on Sunday', () => {
expect(a.getDayNames('short')[0].toLowerCase()).toContain('s'); // Sun
expect(a.getDayNames('long').length).toBe(7);
Expand Down
3 changes: 3 additions & 0 deletions projects/datelane/src/lib/date-adapter/native-date-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export class NativeDateAdapter extends DateAdapter<Date> {
now(): Date { return new Date(); }
clone(d: Date): Date { return new Date(d.getTime()); }
fromNative(d: Date): Date { return new Date(d.getTime()); }
fromParts(year: number, month: number, day: number, hours = 0, minutes = 0, seconds = 0): Date {
return new Date(year, month, day, hours, minutes, seconds, 0);
}
toNative(d: Date): Date { return new Date(d.getTime()); }
isValid(d: Date): boolean { return d instanceof Date && !isNaN(d.getTime()); }

Expand Down
12 changes: 6 additions & 6 deletions projects/datelane/src/lib/editor/quick-view.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,15 @@ describe('QuickViewComponent', () => {
expect([edited, deleted, dismissed]).toEqual([1, 1, 1]);
});

it('anchors via CSS custom properties so small screens can re-center it', () => {
it('renders a centered dialog with no pointer-anchored inline coordinates', () => {
const fixture = setup();
fixture.componentRef.setInput('x', 120);
fixture.componentRef.setInput('y', 240);
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector('.dl-qv') as HTMLElement;
expect(el.style.getPropertyValue('--dl-qv-x')).toBe('120px');
expect(el.style.getPropertyValue('--dl-qv-y')).toBe('240px');
expect(el.style.top).toBe(''); // no inline top → the mobile media query can win
expect(el.getAttribute('role')).toBe('dialog');
// Centering is purely CSS now — no inline anchor props leak onto the element.
expect(el.style.getPropertyValue('--dl-qv-x')).toBe('');
expect(el.style.getPropertyValue('--dl-qv-y')).toBe('');
expect(el.style.top).toBe('');
});

it('dismisses on an outside pointerdown but not on an inside one', () => {
Expand Down
3 changes: 0 additions & 3 deletions projects/datelane/src/lib/editor/quick-view.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import { SCHEDULER_MESSAGES } from '../i18n/messages';
},
template: `
<div class="dl-qv" role="dialog" aria-modal="false" [attr.aria-label]="event().subject"
[style.--dl-qv-x.px]="x()" [style.--dl-qv-y.px]="y()"
[style.--dl-event-accent]="event().color || null">
@if (template(); as tpl) {
<ng-container [ngTemplateOutlet]="tpl" [ngTemplateOutletContext]="context"></ng-container>
Expand All @@ -49,8 +48,6 @@ import { SCHEDULER_MESSAGES } from '../i18n/messages';
})
export class QuickViewComponent {
readonly event = input.required<SchedulerEvent<unknown>>();
readonly x = input(0);
readonly y = input(0);
readonly readonly = input(false);
/** Host override template; when set, replaces the entire default body. */
readonly template = input<TemplateRef<QuickViewContext> | null>(null);
Expand Down
3 changes: 3 additions & 0 deletions projects/datelane/src/lib/engine/normalize-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export function normalizeEvents<D>(
end: adapter.parse(rec[fieldMap.end]),
isAllDay: fieldMap.isAllDay ? Boolean(rec[fieldMap.isAllDay]) : false,
recurrenceRule: fieldMap.recurrenceRule ? (rec[fieldMap.recurrenceRule] as string) : undefined,
recurrenceExceptions: fieldMap.recurrenceExceptions
? (rec[fieldMap.recurrenceExceptions] as string)
: undefined,
resourceIds,
color: fieldMap.color ? (rec[fieldMap.color] as string) : undefined,
raw: rec,
Expand Down
123 changes: 123 additions & 0 deletions projects/datelane/src/lib/engine/recurrence.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Tests for the RRULE expansion engine (subset). Uses the Native adapter.
import { NativeDateAdapter } from '../date-adapter/native-date-adapter';
import { expandOccurrences, parseRecurrenceRule } from './recurrence';
import { SchedulerEvent } from '../core/models';

describe('parseRecurrenceRule', () => {
it('parses freq/interval/count and tolerates an RRULE: prefix + casing', () => {
const r = parseRecurrenceRule('RRULE:freq=weekly;interval=2;count=5');
expect(r?.freq).toBe('WEEKLY');
expect(r?.interval).toBe(2);
expect(r?.count).toBe(5);
});

it('defaults interval to 1', () => {
expect(parseRecurrenceRule('FREQ=DAILY')?.interval).toBe(1);
});

it('parses BYDAY into 0=Sun..6=Sat and ignores ordinal prefixes', () => {
expect(parseRecurrenceRule('FREQ=WEEKLY;BYDAY=MO,WE,FR')?.byDay).toEqual([1, 3, 5]);
expect(parseRecurrenceRule('FREQ=MONTHLY;BYDAY=2MO')?.byDay).toEqual([1]);
});

it('returns null for empty / missing-FREQ / unknown-FREQ input', () => {
expect(parseRecurrenceRule('')).toBeNull();
expect(parseRecurrenceRule(undefined)).toBeNull();
expect(parseRecurrenceRule('INTERVAL=2')).toBeNull();
expect(parseRecurrenceRule('FREQ=HOURLY')).toBeNull();
});
});

describe('expandOccurrences', () => {
const a = new NativeDateAdapter('en-US');
const D = (y: number, mo: number, d: number, h = 0, mi = 0) => new Date(y, mo, d, h, mi);
const range = (s: Date, e: Date) => ({ start: s, end: e });
const ev = (start: Date, end: Date, rule?: string, ex?: string): SchedulerEvent<Date> =>
({ id: 's1', subject: 'x', start, end, isAllDay: false, recurrenceRule: rule, recurrenceExceptions: ex, raw: {} });
const starts = (out: SchedulerEvent<Date>[]) => out.map((o) => o.start.getTime());

const wholeYear = range(D(2025, 0, 1), D(2025, 11, 31, 23, 59));

it('non-recurring event: returned when it overlaps the range, dropped otherwise', () => {
const e = ev(D(2025, 0, 6, 9), D(2025, 0, 6, 10));
expect(expandOccurrences(e, range(D(2025, 0, 1), D(2025, 0, 31)), a).length).toBe(1);
expect(expandOccurrences(e, range(D(2025, 1, 1), D(2025, 1, 28)), a).length).toBe(0);
});

it('DAILY COUNT=3 yields three consecutive days with series metadata', () => {
const out = expandOccurrences(ev(D(2025, 0, 6, 9), D(2025, 0, 6, 10), 'FREQ=DAILY;COUNT=3'), wholeYear, a);
expect(starts(out)).toEqual([D(2025, 0, 6, 9), D(2025, 0, 7, 9), D(2025, 0, 8, 9)].map((d) => d.getTime()));
expect(out[0].seriesId).toBe('s1');
expect((out[1].recurrenceId as Date).getTime()).toBe(D(2025, 0, 7, 9).getTime());
});

it('DAILY INTERVAL=2 steps every other day', () => {
const out = expandOccurrences(ev(D(2025, 0, 6, 9), D(2025, 0, 6, 10), 'FREQ=DAILY;INTERVAL=2;COUNT=3'), wholeYear, a);
expect(starts(out)).toEqual([D(2025, 0, 6, 9), D(2025, 0, 8, 9), D(2025, 0, 10, 9)].map((d) => d.getTime()));
});

it('WEEKLY BYDAY=MO,WE,FR emits the listed weekdays in order', () => {
// 2025-01-06 is a Monday.
const out = expandOccurrences(ev(D(2025, 0, 6, 9), D(2025, 0, 6, 10), 'FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=4'), wholeYear, a);
expect(starts(out)).toEqual([D(2025, 0, 6, 9), D(2025, 0, 8, 9), D(2025, 0, 10, 9), D(2025, 0, 13, 9)].map((d) => d.getTime()));
});

it('WEEKLY without BYDAY repeats the DTSTART weekday', () => {
const out = expandOccurrences(ev(D(2025, 0, 7, 9), D(2025, 0, 7, 10), 'FREQ=WEEKLY;COUNT=3'), wholeYear, a);
expect(starts(out)).toEqual([D(2025, 0, 7, 9), D(2025, 0, 14, 9), D(2025, 0, 21, 9)].map((d) => d.getTime()));
});

it('MONTHLY BYMONTHDAY=31 skips months without that day (counts only valid occurrences)', () => {
const out = expandOccurrences(ev(D(2025, 0, 31, 9), D(2025, 0, 31, 10), 'FREQ=MONTHLY;BYMONTHDAY=31;COUNT=3'), wholeYear, a);
expect(starts(out)).toEqual([D(2025, 0, 31, 9), D(2025, 2, 31, 9), D(2025, 4, 31, 9)].map((d) => d.getTime()));
});

it('YEARLY repeats on the anniversary', () => {
const out = expandOccurrences(
ev(D(2025, 0, 6, 9), D(2025, 0, 6, 10), 'FREQ=YEARLY;COUNT=2'),
range(D(2025, 0, 1), D(2026, 11, 31)), a,
);
expect(starts(out)).toEqual([D(2025, 0, 6, 9), D(2026, 0, 6, 9)].map((d) => d.getTime()));
});

it('UNTIL (date-only) is inclusive of the whole final day', () => {
const out = expandOccurrences(ev(D(2025, 0, 6, 9), D(2025, 0, 6, 10), 'FREQ=DAILY;UNTIL=20250108'), wholeYear, a);
expect(starts(out)).toEqual([D(2025, 0, 6, 9), D(2025, 0, 7, 9), D(2025, 0, 8, 9)].map((d) => d.getTime()));
});

it('EXDATE removes the matching day but still consumes COUNT', () => {
const out = expandOccurrences(ev(D(2025, 0, 6, 9), D(2025, 0, 6, 10), 'FREQ=DAILY;COUNT=3', '20250107'), wholeYear, a);
expect(starts(out)).toEqual([D(2025, 0, 6, 9), D(2025, 0, 8, 9)].map((d) => d.getTime()));
});

it('windows an open-ended series to the visible range only', () => {
const out = expandOccurrences(ev(D(2025, 0, 1, 9), D(2025, 0, 1, 10), 'FREQ=DAILY'), range(D(2025, 0, 10), D(2025, 0, 12, 23, 59)), a);
expect(starts(out)).toEqual([D(2025, 0, 10, 9), D(2025, 0, 11, 9), D(2025, 0, 12, 9)].map((d) => d.getTime()));
});

it('windows a long-running DAILY series far in the future (fast-forward, no iteration cap miss)', () => {
// DTSTART 26 years before the window — naive iteration from start would exhaust the cap.
const out = expandOccurrences(
ev(D(2000, 0, 1, 9), D(2000, 0, 1, 10), 'FREQ=DAILY'),
range(D(2026, 5, 1), D(2026, 5, 3, 23, 59)), a,
);
expect(starts(out)).toEqual([D(2026, 5, 1, 9), D(2026, 5, 2, 9), D(2026, 5, 3, 9)].map((d) => d.getTime()));
});

it('fast-forwards a WEEKLY series while preserving the weekday + time alignment', () => {
// 2000-01-03 is a Monday; BYDAY=MO. Window is a single Monday 26 years later.
const out = expandOccurrences(
ev(D(2000, 0, 3, 9), D(2000, 0, 3, 10), 'FREQ=WEEKLY;BYDAY=MO'),
range(D(2026, 5, 15), D(2026, 5, 15, 23, 59)), a, // 15 Jun 2026 is a Monday
);
expect(out.length).toBe(1);
expect(out[0].start.getDay()).toBe(1); // still a Monday
expect(out[0].start.getHours()).toBe(9); // time preserved
expect(out[0].start.getDate()).toBe(15);
});

it('preserves wall-clock start time across many daily occurrences (DST-safe)', () => {
const out = expandOccurrences(ev(D(2025, 2, 1, 9, 30), D(2025, 2, 1, 10, 30), 'FREQ=DAILY;COUNT=20'), wholeYear, a);
expect(out.every((o) => o.start.getHours() === 9 && o.start.getMinutes() === 30)).toBe(true);
});
});
Loading
Loading