Skip to content

Period engine — period keys, boundaries and due dates, timezone-correct #1

Description

@os-warren

Everything downstream agrees on periods only if one module owns them. This is that module: pure functions, no I/O, no platform imports beyond types.

Files you own

  • src/functions/period.ts (new)
  • src/functions/index.ts — add to the dulyFunctions map only if a flow node needs to call one; the dispatcher imports directly
  • test/period.test.ts (new)

Do not touch objectstack.config.ts — every barrel is already wired.

API

type Frequency = 'daily' | 'weekly' | 'fortnightly' | 'monthly' | 'quarterly' | 'semiannual' | 'annual';

periodKeyFor(frequency, instant: Date, timezone: string): string
periodBounds(frequency, periodKey: string, timezone: string): { start: Date; end: Date }   // [start, end), UTC instants
dueDateFor(opts: { frequency; periodKey; timezone; dueAnchor: 'period_start' | 'period_end'; dueOffsetDays: number }): string  // YYYY-MM-DD
visibleFromFor(dueDate: string, leadDays: number): string   // YYYY-MM-DD
periodsBetween(frequency, from: Date, to: Date, timezone: string): string[]   // ascending, for backfill

Period key spelling — this is a contract, not a preference

Frequency Key Example
daily YYYY-MM-DD 2026-08-21
weekly YYYY-Www (ISO-8601 week) 2026-W34
fortnightly YYYY-Www of the starting ISO week; fortnights anchored so ISO week 1 begins one 2026-W33
monthly YYYY-MM 2026-08
quarterly YYYY-Qn 2026-Q3
semiannual YYYY-Hn 2026-H2
annual YYYY 2026

duly_task.period_key is maxLength: 16. Keys must round-trip: periodKeyFor(f, periodBounds(f, k, tz).start, tz) === k.

Rules that will bite you

  • Everything resolves in the supplied IANA zone, never the server's. Use Intl.DateTimeFormat with timeZone to get local calendar parts; do not do naive UTC arithmetic and add hours.
  • DST-safe. A day is not 24 hours. Compute boundaries from calendar parts, then convert to an instant. Spring-forward days have no 02:00 local in many zones; some zones (e.g. America/Santiago) shift at midnight, so "local midnight" may not exist — resolve forward to the first valid instant.
  • ISO week years are not calendar years. 2026-01-01 is in ISO week 2026-W01, but 2027-01-01 falls in 2026-W53. The year in a YYYY-Www key is the ISO week-year.
  • Offsets clamp into the period, they never spill out. due_anchor: 'period_start', due_offset_days: 30 on February resolves to the 28th (29th in a leap year), not 2 March. Negative offsets from period_end clamp at period_start.
  • dueDateFor returns a calendar day string, because duly_duty/duly_task store date, not datetime.

Acceptance

test/period.test.ts, table-driven, covering at minimum:

  • round-trip for all seven frequencies across three zones (UTC, Europe/Berlin, Asia/Shanghai)
  • ISO week 53: 2020 and 2026
  • leap year: monthly offset 30 in Feb 2028 vs Feb 2027
  • month-end clamping and negative period_end offsets
  • DST spring-forward: Europe/Berlin 2026-03-29, America/Santiago 2026-09-06 (midnight shift)
  • periodsBetween over a year boundary for each frequency, ascending, no gaps, no duplicates

Gates

pnpm validate && pnpm typecheck && pnpm test && pnpm build all green before the draft PR.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions