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.
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.