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 directlytest/period.test.ts (new)
Do not touch objectstack.config.ts — every barrel is already wired.
API
typeFrequency='daily'|'weekly'|'fortnightly'|'monthly'|'quarterly'|'semiannual'|'annual';periodKeyFor(frequency,instant: Date,timezone: string): stringperiodBounds(frequency,periodKey: string,timezone: string): { start: Date; end: Date}// [start, end), UTC instantsdueDateFor(opts: {frequency;periodKey;timezone; dueAnchor: 'period_start'|'period_end'; dueOffsetDays: number}): string// YYYY-MM-DDvisibleFromFor(dueDate: string,leadDays: number): string// YYYY-MM-DDperiodsBetween(frequency,from: Date,to: Date,timezone: string): string[]// ascending, for backfillPeriod 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.
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 thedulyFunctionsmap only if a flow node needs to call one; the dispatcher imports directlytest/period.test.ts(new)Do not touch
objectstack.config.ts— every barrel is already wired.API
Period key spelling — this is a contract, not a preference
YYYY-MM-DD2026-08-21YYYY-Www(ISO-8601 week)2026-W34YYYY-Wwwof the starting ISO week; fortnights anchored so ISO week 1 begins one2026-W33YYYY-MM2026-08YYYY-Qn2026-Q3YYYY-Hn2026-H2YYYY2026duly_task.period_keyismaxLength: 16. Keys must round-trip:periodKeyFor(f, periodBounds(f, k, tz).start, tz) === k.Rules that will bite you
Intl.DateTimeFormatwithtimeZoneto get local calendar parts; do not do naive UTC arithmetic and add hours.America/Santiago) shift at midnight, so "local midnight" may not exist — resolve forward to the first valid instant.2026-01-01is in ISO week2026-W01, but2027-01-01falls in2026-W53. The year in aYYYY-Wwwkey is the ISO week-year.due_anchor: 'period_start', due_offset_days: 30on February resolves to the 28th (29th in a leap year), not 2 March. Negative offsets fromperiod_endclamp atperiod_start.dueDateForreturns a calendar day string, becauseduly_duty/duly_taskstoredate, notdatetime.Acceptance
test/period.test.ts, table-driven, covering at minimum:UTC,Europe/Berlin,Asia/Shanghai)period_endoffsetsEurope/Berlin2026-03-29,America/Santiago2026-09-06 (midnight shift)periodsBetweenover a year boundary for each frequency, ascending, no gaps, no duplicatesGates
pnpm validate && pnpm typecheck && pnpm test && pnpm buildall green before the draft PR.