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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine
Sep 1, 2026
Merged

Period engine — period keys, boundaries and due dates, timezone-correct#25
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#1

One module owns what a period is, so the dispatcher, the backfill and the seed cannot disagree about it. Pure functions, no I/O, no platform imports beyond types, and no new runtime dependencyIntl.DateTimeFormat with a timeZone supplies both the local calendar parts and, by comparing those parts against the instant, the zone's offset at that instant. That is everything the arithmetic needs.

Files

File
src/functions/period.tsnew — the engine
test/period.test.tsnew — 89 tests

src/functions/index.ts is untouched: dulyFunctions is the map a script flow node resolves by name, no flow exists yet, and #2 imports the functions directly. Adding a name nothing resolves would be dead metadata and a needless collision point for the other three branches in flight. objectstack.config.ts is untouched.

API

periodKeyFor(frequency,instant: Date,timezone: string): stringperiodBounds(frequency,periodKey: string,timezone: string): { start: Date; end: Date}// [start, end)dueDateFor({ frequency, periodKey, timezone, dueAnchor, dueOffsetDays }): string// YYYY-MM-DDvisibleFromFor(dueDate: string,leadDays: number): string// YYYY-MM-DDperiodsBetween(frequency,from: Date,to: Date,timezone: string): string[]// ascending

The key spelling table is implemented exactly as written on the issue, 2026-W04 padding included, and every key is checked against period_key's maxLength: 16.

The two decisions worth reviewing

1. The inversion verifies its answer instead of trusting a second pass. The textbook wall-clock-to-instant conversion (guess - offsetAt(guess - offsetAt(guess))) is not merely imprecise on a zone that shifts at midnight — it lands in the wrong period. For America/Santiago 2026-09-06, where local midnight does not exist, it converges on 2026-09-06T03:00Z, whose local reading is 23:00 on the 5th. So candidates are drawn from the offsets a day either side of the target and then verified against the zone: an exact match wins (earliest, when a fall-back makes the reading happen twice), otherwise the answer resolves forward to the first instant past the requested reading. Measured, not assumed — ablating this back to the naive two-pass turns two tests red (below).

2. periodsBetween returns only keys periodKeyFor could itself return. The first draft walked civil dates and emitted 2011-12-30 for Pacific/Apia — a local day the zone skipped when it crossed the date line. A backfill would have filed a task for a day nobody lived through, whose periodBounds window is empty. The walk now carries each boundary instant forward and skips a period with no instants in it; it costs one zone inversion per period, not two, because neighbours share a boundary.

Behaviour the tests pin

  • Round-tripperiodKeyFor(f, periodBounds(f, k, tz).start, tz) === k for all seven frequencies × UTC / Europe/Berlin / Asia/Shanghai × ten awkward instants (year boundaries, both DST transitions, month ends, a leap day), plus the containment check that the probe falls inside the window it named.
  • ISO week-years: 2026-01-012026-W01, 2027-01-012026-W53. 2020 and 2026 have 53 weeks; 2021-W53 is refused with the week count in the message.
  • Fortnights start on odd ISO weeks and are named by the starting week. One consequence is worth stating out loud: in a 53-week week-year the final fortnight is one week long, because W01 of the next week-year must itself begin a fortnight and so cannot be the back half of this one. That is forced by the anchoring rule rather than chosen — the alternative reading (a 14-day cycle running continuously across the boundary) would mean W01 did not begin a fortnight in 2027, contradicting the contract. Periods stay contiguous and keys still round-trip either way; 2020 and 2026 both have a test.
  • DST: Berlin 2026-03-29 is a 23-hour day that still starts at local midnight; Berlin 2026-10-25 is a 25-hour day that starts at the first of its two midnights; Santiago 2026-09-06 has no local midnight and resolves forward to 01:00, with its neighbour's end equal to its start.
  • Clamping: period_start + 30 on February is the 28th, or the 29th in a leap year, never 2 March; period_end - 90 on a month is the 1st. A table drives ±9999 on both anchors across all seven frequencies and asserts the result never leaves the period.
  • periodsBetween over the 2026→2027 boundary for each frequency: ascending, no duplicates, and contiguous by construction — each period's end is asserted equal to the next period's start.
  • Malformed keys are refused, never repaired2026-W4, 2026-13, 2026-Q5, 2026-02-30, and an even fortnight key (whose message names the odd one you probably meant). duly_task is unique on (duty, owner, period_key), so two spellings of one period would be two tasks for one obligation and nothing downstream could tell they were meant to be the same.

Gates — all four green at 8946861

pnpm validate ✓ Validation passed (232ms)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed (2) · Tests 97 passed (97)
pnpm build ✓ Build complete · dist/objectstack.json (51.7 KB)

97 = the 89 added here plus the 8 existing invariant tests, which still pass untouched.

Reverse verification

Each mutation was confirmed on disk (injected marker present, removed anchor absent, git diff --stat non-empty) before the run, and a shell trap restored the tree after each leg; the tree was verified clean and marker-free afterwards. No build step is involved — vitest transforms src/ directly, so there is no dist/ for a stale artifact to hide in.

AblationResult
Inversion replaced with the naive unverified two-pass2 failed — the Santiago midnight-shift test and the skipped-day test
Clamp removed from dueDateFor5 failed — the February leap pair and all four clamping tests
Fortnights always 14 days (53-week year ignored)3 failed — both 53-week tests and the periodsBetween fortnightly walk

Raised, not changed

Neither is touched here: both live on src/objects/, outside this card's file surface.

Generated by Claude Code


Generated by Claude Code

One module owns what a period IS, so the dispatcher, the backfill and the
seed cannot disagree about it. Pure functions, no I/O, no runtime dependency.
Boundaries are computed on calendar parts in the supplied IANA zone and
converted to an instant exactly once. The inversion verifies its answer
rather than trusting a second pass: on a zone that shifts AT midnight
(America/Santiago, 2026-09-06) the naive two-pass converges on an instant
whose local reading is 23:00 the previous day — a whole period early.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:00
@os-warren
os-warren merged commit 82f88c9 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine
Sep 1, 2026
Merged

Period engine — period keys, boundaries and due dates, timezone-correct#25
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#1

One module owns what a period is, so the dispatcher, the backfill and the seed cannot disagree about it. Pure functions, no I/O, no platform imports beyond types, and no new runtime dependencyIntl.DateTimeFormat with a timeZone supplies both the local calendar parts and, by comparing those parts against the instant, the zone's offset at that instant. That is everything the arithmetic needs.

Files

File
src/functions/period.tsnew — the engine
test/period.test.tsnew — 89 tests

src/functions/index.ts is untouched: dulyFunctions is the map a script flow node resolves by name, no flow exists yet, and #2 imports the functions directly. Adding a name nothing resolves would be dead metadata and a needless collision point for the other three branches in flight. objectstack.config.ts is untouched.

API

periodKeyFor(frequency,instant: Date,timezone: string): stringperiodBounds(frequency,periodKey: string,timezone: string): { start: Date; end: Date}// [start, end)dueDateFor({ frequency, periodKey, timezone, dueAnchor, dueOffsetDays }): string// YYYY-MM-DDvisibleFromFor(dueDate: string,leadDays: number): string// YYYY-MM-DDperiodsBetween(frequency,from: Date,to: Date,timezone: string): string[]// ascending

The key spelling table is implemented exactly as written on the issue, 2026-W04 padding included, and every key is checked against period_key's maxLength: 16.

The two decisions worth reviewing

1. The inversion verifies its answer instead of trusting a second pass. The textbook wall-clock-to-instant conversion (guess - offsetAt(guess - offsetAt(guess))) is not merely imprecise on a zone that shifts at midnight — it lands in the wrong period. For America/Santiago 2026-09-06, where local midnight does not exist, it converges on 2026-09-06T03:00Z, whose local reading is 23:00 on the 5th. So candidates are drawn from the offsets a day either side of the target and then verified against the zone: an exact match wins (earliest, when a fall-back makes the reading happen twice), otherwise the answer resolves forward to the first instant past the requested reading. Measured, not assumed — ablating this back to the naive two-pass turns two tests red (below).

2. periodsBetween returns only keys periodKeyFor could itself return. The first draft walked civil dates and emitted 2011-12-30 for Pacific/Apia — a local day the zone skipped when it crossed the date line. A backfill would have filed a task for a day nobody lived through, whose periodBounds window is empty. The walk now carries each boundary instant forward and skips a period with no instants in it; it costs one zone inversion per period, not two, because neighbours share a boundary.

Behaviour the tests pin

  • Round-tripperiodKeyFor(f, periodBounds(f, k, tz).start, tz) === k for all seven frequencies × UTC / Europe/Berlin / Asia/Shanghai × ten awkward instants (year boundaries, both DST transitions, month ends, a leap day), plus the containment check that the probe falls inside the window it named.
  • ISO week-years: 2026-01-012026-W01, 2027-01-012026-W53. 2020 and 2026 have 53 weeks; 2021-W53 is refused with the week count in the message.
  • Fortnights start on odd ISO weeks and are named by the starting week. One consequence is worth stating out loud: in a 53-week week-year the final fortnight is one week long, because W01 of the next week-year must itself begin a fortnight and so cannot be the back half of this one. That is forced by the anchoring rule rather than chosen — the alternative reading (a 14-day cycle running continuously across the boundary) would mean W01 did not begin a fortnight in 2027, contradicting the contract. Periods stay contiguous and keys still round-trip either way; 2020 and 2026 both have a test.
  • DST: Berlin 2026-03-29 is a 23-hour day that still starts at local midnight; Berlin 2026-10-25 is a 25-hour day that starts at the first of its two midnights; Santiago 2026-09-06 has no local midnight and resolves forward to 01:00, with its neighbour's end equal to its start.
  • Clamping: period_start + 30 on February is the 28th, or the 29th in a leap year, never 2 March; period_end - 90 on a month is the 1st. A table drives ±9999 on both anchors across all seven frequencies and asserts the result never leaves the period.
  • periodsBetween over the 2026→2027 boundary for each frequency: ascending, no duplicates, and contiguous by construction — each period's end is asserted equal to the next period's start.
  • Malformed keys are refused, never repaired2026-W4, 2026-13, 2026-Q5, 2026-02-30, and an even fortnight key (whose message names the odd one you probably meant). duly_task is unique on (duty, owner, period_key), so two spellings of one period would be two tasks for one obligation and nothing downstream could tell they were meant to be the same.

Gates — all four green at 8946861

pnpm validate ✓ Validation passed (232ms)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed (2) · Tests 97 passed (97)
pnpm build ✓ Build complete · dist/objectstack.json (51.7 KB)

97 = the 89 added here plus the 8 existing invariant tests, which still pass untouched.

Reverse verification

Each mutation was confirmed on disk (injected marker present, removed anchor absent, git diff --stat non-empty) before the run, and a shell trap restored the tree after each leg; the tree was verified clean and marker-free afterwards. No build step is involved — vitest transforms src/ directly, so there is no dist/ for a stale artifact to hide in.

AblationResult
Inversion replaced with the naive unverified two-pass2 failed — the Santiago midnight-shift test and the skipped-day test
Clamp removed from dueDateFor5 failed — the February leap pair and all four clamping tests
Fortnights always 14 days (53-week year ignored)3 failed — both 53-week tests and the periodsBetween fortnightly walk

Raised, not changed

Neither is touched here: both live on src/objects/, outside this card's file surface.

Generated by Claude Code


Generated by Claude Code

One module owns what a period IS, so the dispatcher, the backfill and the
seed cannot disagree about it. Pure functions, no I/O, no runtime dependency.
Boundaries are computed on calendar parts in the supplied IANA zone and
converted to an instant exactly once. The inversion verifies its answer
rather than trusting a second pass: on a zone that shifts AT midnight
(America/Santiago, 2026-09-06) the naive two-pass converges on an instant
whose local reading is 23:00 the previous day — a whole period early.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:00
@os-warren
os-warren merged commit 82f88c9 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine
Sep 1, 2026
Merged

Period engine — period keys, boundaries and due dates, timezone-correct#25
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#1

One module owns what a period is, so the dispatcher, the backfill and the seed cannot disagree about it. Pure functions, no I/O, no platform imports beyond types, and no new runtime dependencyIntl.DateTimeFormat with a timeZone supplies both the local calendar parts and, by comparing those parts against the instant, the zone's offset at that instant. That is everything the arithmetic needs.

Files

File
src/functions/period.tsnew — the engine
test/period.test.tsnew — 89 tests

src/functions/index.ts is untouched: dulyFunctions is the map a script flow node resolves by name, no flow exists yet, and #2 imports the functions directly. Adding a name nothing resolves would be dead metadata and a needless collision point for the other three branches in flight. objectstack.config.ts is untouched.

API

periodKeyFor(frequency,instant: Date,timezone: string): stringperiodBounds(frequency,periodKey: string,timezone: string): { start: Date; end: Date}// [start, end)dueDateFor({ frequency, periodKey, timezone, dueAnchor, dueOffsetDays }): string// YYYY-MM-DDvisibleFromFor(dueDate: string,leadDays: number): string// YYYY-MM-DDperiodsBetween(frequency,from: Date,to: Date,timezone: string): string[]// ascending

The key spelling table is implemented exactly as written on the issue, 2026-W04 padding included, and every key is checked against period_key's maxLength: 16.

The two decisions worth reviewing

1. The inversion verifies its answer instead of trusting a second pass. The textbook wall-clock-to-instant conversion (guess - offsetAt(guess - offsetAt(guess))) is not merely imprecise on a zone that shifts at midnight — it lands in the wrong period. For America/Santiago 2026-09-06, where local midnight does not exist, it converges on 2026-09-06T03:00Z, whose local reading is 23:00 on the 5th. So candidates are drawn from the offsets a day either side of the target and then verified against the zone: an exact match wins (earliest, when a fall-back makes the reading happen twice), otherwise the answer resolves forward to the first instant past the requested reading. Measured, not assumed — ablating this back to the naive two-pass turns two tests red (below).

2. periodsBetween returns only keys periodKeyFor could itself return. The first draft walked civil dates and emitted 2011-12-30 for Pacific/Apia — a local day the zone skipped when it crossed the date line. A backfill would have filed a task for a day nobody lived through, whose periodBounds window is empty. The walk now carries each boundary instant forward and skips a period with no instants in it; it costs one zone inversion per period, not two, because neighbours share a boundary.

Behaviour the tests pin

  • Round-tripperiodKeyFor(f, periodBounds(f, k, tz).start, tz) === k for all seven frequencies × UTC / Europe/Berlin / Asia/Shanghai × ten awkward instants (year boundaries, both DST transitions, month ends, a leap day), plus the containment check that the probe falls inside the window it named.
  • ISO week-years: 2026-01-012026-W01, 2027-01-012026-W53. 2020 and 2026 have 53 weeks; 2021-W53 is refused with the week count in the message.
  • Fortnights start on odd ISO weeks and are named by the starting week. One consequence is worth stating out loud: in a 53-week week-year the final fortnight is one week long, because W01 of the next week-year must itself begin a fortnight and so cannot be the back half of this one. That is forced by the anchoring rule rather than chosen — the alternative reading (a 14-day cycle running continuously across the boundary) would mean W01 did not begin a fortnight in 2027, contradicting the contract. Periods stay contiguous and keys still round-trip either way; 2020 and 2026 both have a test.
  • DST: Berlin 2026-03-29 is a 23-hour day that still starts at local midnight; Berlin 2026-10-25 is a 25-hour day that starts at the first of its two midnights; Santiago 2026-09-06 has no local midnight and resolves forward to 01:00, with its neighbour's end equal to its start.
  • Clamping: period_start + 30 on February is the 28th, or the 29th in a leap year, never 2 March; period_end - 90 on a month is the 1st. A table drives ±9999 on both anchors across all seven frequencies and asserts the result never leaves the period.
  • periodsBetween over the 2026→2027 boundary for each frequency: ascending, no duplicates, and contiguous by construction — each period's end is asserted equal to the next period's start.
  • Malformed keys are refused, never repaired2026-W4, 2026-13, 2026-Q5, 2026-02-30, and an even fortnight key (whose message names the odd one you probably meant). duly_task is unique on (duty, owner, period_key), so two spellings of one period would be two tasks for one obligation and nothing downstream could tell they were meant to be the same.

Gates — all four green at 8946861

pnpm validate ✓ Validation passed (232ms)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed (2) · Tests 97 passed (97)
pnpm build ✓ Build complete · dist/objectstack.json (51.7 KB)

97 = the 89 added here plus the 8 existing invariant tests, which still pass untouched.

Reverse verification

Each mutation was confirmed on disk (injected marker present, removed anchor absent, git diff --stat non-empty) before the run, and a shell trap restored the tree after each leg; the tree was verified clean and marker-free afterwards. No build step is involved — vitest transforms src/ directly, so there is no dist/ for a stale artifact to hide in.

AblationResult
Inversion replaced with the naive unverified two-pass2 failed — the Santiago midnight-shift test and the skipped-day test
Clamp removed from dueDateFor5 failed — the February leap pair and all four clamping tests
Fortnights always 14 days (53-week year ignored)3 failed — both 53-week tests and the periodsBetween fortnightly walk

Raised, not changed

Neither is touched here: both live on src/objects/, outside this card's file surface.

Generated by Claude Code


Generated by Claude Code

One module owns what a period IS, so the dispatcher, the backfill and the
seed cannot disagree about it. Pure functions, no I/O, no runtime dependency.
Boundaries are computed on calendar parts in the supplied IANA zone and
converted to an instant exactly once. The inversion verifies its answer
rather than trusting a second pass: on a zone that shifts AT midnight
(America/Santiago, 2026-09-06) the naive two-pass converges on an instant
whose local reading is 23:00 the previous day — a whole period early.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:00
@os-warren
os-warren merged commit 82f88c9 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine
Sep 1, 2026
Merged

Period engine — period keys, boundaries and due dates, timezone-correct#25
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#1

One module owns what a period is, so the dispatcher, the backfill and the seed cannot disagree about it. Pure functions, no I/O, no platform imports beyond types, and no new runtime dependencyIntl.DateTimeFormat with a timeZone supplies both the local calendar parts and, by comparing those parts against the instant, the zone's offset at that instant. That is everything the arithmetic needs.

Files

File
src/functions/period.tsnew — the engine
test/period.test.tsnew — 89 tests

src/functions/index.ts is untouched: dulyFunctions is the map a script flow node resolves by name, no flow exists yet, and #2 imports the functions directly. Adding a name nothing resolves would be dead metadata and a needless collision point for the other three branches in flight. objectstack.config.ts is untouched.

API

periodKeyFor(frequency,instant: Date,timezone: string): stringperiodBounds(frequency,periodKey: string,timezone: string): { start: Date; end: Date}// [start, end)dueDateFor({ frequency, periodKey, timezone, dueAnchor, dueOffsetDays }): string// YYYY-MM-DDvisibleFromFor(dueDate: string,leadDays: number): string// YYYY-MM-DDperiodsBetween(frequency,from: Date,to: Date,timezone: string): string[]// ascending

The key spelling table is implemented exactly as written on the issue, 2026-W04 padding included, and every key is checked against period_key's maxLength: 16.

The two decisions worth reviewing

1. The inversion verifies its answer instead of trusting a second pass. The textbook wall-clock-to-instant conversion (guess - offsetAt(guess - offsetAt(guess))) is not merely imprecise on a zone that shifts at midnight — it lands in the wrong period. For America/Santiago 2026-09-06, where local midnight does not exist, it converges on 2026-09-06T03:00Z, whose local reading is 23:00 on the 5th. So candidates are drawn from the offsets a day either side of the target and then verified against the zone: an exact match wins (earliest, when a fall-back makes the reading happen twice), otherwise the answer resolves forward to the first instant past the requested reading. Measured, not assumed — ablating this back to the naive two-pass turns two tests red (below).

2. periodsBetween returns only keys periodKeyFor could itself return. The first draft walked civil dates and emitted 2011-12-30 for Pacific/Apia — a local day the zone skipped when it crossed the date line. A backfill would have filed a task for a day nobody lived through, whose periodBounds window is empty. The walk now carries each boundary instant forward and skips a period with no instants in it; it costs one zone inversion per period, not two, because neighbours share a boundary.

Behaviour the tests pin

  • Round-tripperiodKeyFor(f, periodBounds(f, k, tz).start, tz) === k for all seven frequencies × UTC / Europe/Berlin / Asia/Shanghai × ten awkward instants (year boundaries, both DST transitions, month ends, a leap day), plus the containment check that the probe falls inside the window it named.
  • ISO week-years: 2026-01-012026-W01, 2027-01-012026-W53. 2020 and 2026 have 53 weeks; 2021-W53 is refused with the week count in the message.
  • Fortnights start on odd ISO weeks and are named by the starting week. One consequence is worth stating out loud: in a 53-week week-year the final fortnight is one week long, because W01 of the next week-year must itself begin a fortnight and so cannot be the back half of this one. That is forced by the anchoring rule rather than chosen — the alternative reading (a 14-day cycle running continuously across the boundary) would mean W01 did not begin a fortnight in 2027, contradicting the contract. Periods stay contiguous and keys still round-trip either way; 2020 and 2026 both have a test.
  • DST: Berlin 2026-03-29 is a 23-hour day that still starts at local midnight; Berlin 2026-10-25 is a 25-hour day that starts at the first of its two midnights; Santiago 2026-09-06 has no local midnight and resolves forward to 01:00, with its neighbour's end equal to its start.
  • Clamping: period_start + 30 on February is the 28th, or the 29th in a leap year, never 2 March; period_end - 90 on a month is the 1st. A table drives ±9999 on both anchors across all seven frequencies and asserts the result never leaves the period.
  • periodsBetween over the 2026→2027 boundary for each frequency: ascending, no duplicates, and contiguous by construction — each period's end is asserted equal to the next period's start.
  • Malformed keys are refused, never repaired2026-W4, 2026-13, 2026-Q5, 2026-02-30, and an even fortnight key (whose message names the odd one you probably meant). duly_task is unique on (duty, owner, period_key), so two spellings of one period would be two tasks for one obligation and nothing downstream could tell they were meant to be the same.

Gates — all four green at 8946861

pnpm validate ✓ Validation passed (232ms)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed (2) · Tests 97 passed (97)
pnpm build ✓ Build complete · dist/objectstack.json (51.7 KB)

97 = the 89 added here plus the 8 existing invariant tests, which still pass untouched.

Reverse verification

Each mutation was confirmed on disk (injected marker present, removed anchor absent, git diff --stat non-empty) before the run, and a shell trap restored the tree after each leg; the tree was verified clean and marker-free afterwards. No build step is involved — vitest transforms src/ directly, so there is no dist/ for a stale artifact to hide in.

AblationResult
Inversion replaced with the naive unverified two-pass2 failed — the Santiago midnight-shift test and the skipped-day test
Clamp removed from dueDateFor5 failed — the February leap pair and all four clamping tests
Fortnights always 14 days (53-week year ignored)3 failed — both 53-week tests and the periodsBetween fortnightly walk

Raised, not changed

Neither is touched here: both live on src/objects/, outside this card's file surface.

Generated by Claude Code


Generated by Claude Code

One module owns what a period IS, so the dispatcher, the backfill and the
seed cannot disagree about it. Pure functions, no I/O, no runtime dependency.
Boundaries are computed on calendar parts in the supplied IANA zone and
converted to an instant exactly once. The inversion verifies its answer
rather than trusting a second pass: on a zone that shifts AT midnight
(America/Santiago, 2026-09-06) the naive two-pass converges on an instant
whose local reading is 23:00 the previous day — a whole period early.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:00
@os-warren
os-warren merged commit 82f88c9 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine
Sep 1, 2026
Merged

Period engine — period keys, boundaries and due dates, timezone-correct#25
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#1

One module owns what a period is, so the dispatcher, the backfill and the seed cannot disagree about it. Pure functions, no I/O, no platform imports beyond types, and no new runtime dependencyIntl.DateTimeFormat with a timeZone supplies both the local calendar parts and, by comparing those parts against the instant, the zone's offset at that instant. That is everything the arithmetic needs.

Files

File
src/functions/period.tsnew — the engine
test/period.test.tsnew — 89 tests

src/functions/index.ts is untouched: dulyFunctions is the map a script flow node resolves by name, no flow exists yet, and #2 imports the functions directly. Adding a name nothing resolves would be dead metadata and a needless collision point for the other three branches in flight. objectstack.config.ts is untouched.

API

periodKeyFor(frequency,instant: Date,timezone: string): stringperiodBounds(frequency,periodKey: string,timezone: string): { start: Date; end: Date}// [start, end)dueDateFor({ frequency, periodKey, timezone, dueAnchor, dueOffsetDays }): string// YYYY-MM-DDvisibleFromFor(dueDate: string,leadDays: number): string// YYYY-MM-DDperiodsBetween(frequency,from: Date,to: Date,timezone: string): string[]// ascending

The key spelling table is implemented exactly as written on the issue, 2026-W04 padding included, and every key is checked against period_key's maxLength: 16.

The two decisions worth reviewing

1. The inversion verifies its answer instead of trusting a second pass. The textbook wall-clock-to-instant conversion (guess - offsetAt(guess - offsetAt(guess))) is not merely imprecise on a zone that shifts at midnight — it lands in the wrong period. For America/Santiago 2026-09-06, where local midnight does not exist, it converges on 2026-09-06T03:00Z, whose local reading is 23:00 on the 5th. So candidates are drawn from the offsets a day either side of the target and then verified against the zone: an exact match wins (earliest, when a fall-back makes the reading happen twice), otherwise the answer resolves forward to the first instant past the requested reading. Measured, not assumed — ablating this back to the naive two-pass turns two tests red (below).

2. periodsBetween returns only keys periodKeyFor could itself return. The first draft walked civil dates and emitted 2011-12-30 for Pacific/Apia — a local day the zone skipped when it crossed the date line. A backfill would have filed a task for a day nobody lived through, whose periodBounds window is empty. The walk now carries each boundary instant forward and skips a period with no instants in it; it costs one zone inversion per period, not two, because neighbours share a boundary.

Behaviour the tests pin

  • Round-tripperiodKeyFor(f, periodBounds(f, k, tz).start, tz) === k for all seven frequencies × UTC / Europe/Berlin / Asia/Shanghai × ten awkward instants (year boundaries, both DST transitions, month ends, a leap day), plus the containment check that the probe falls inside the window it named.
  • ISO week-years: 2026-01-012026-W01, 2027-01-012026-W53. 2020 and 2026 have 53 weeks; 2021-W53 is refused with the week count in the message.
  • Fortnights start on odd ISO weeks and are named by the starting week. One consequence is worth stating out loud: in a 53-week week-year the final fortnight is one week long, because W01 of the next week-year must itself begin a fortnight and so cannot be the back half of this one. That is forced by the anchoring rule rather than chosen — the alternative reading (a 14-day cycle running continuously across the boundary) would mean W01 did not begin a fortnight in 2027, contradicting the contract. Periods stay contiguous and keys still round-trip either way; 2020 and 2026 both have a test.
  • DST: Berlin 2026-03-29 is a 23-hour day that still starts at local midnight; Berlin 2026-10-25 is a 25-hour day that starts at the first of its two midnights; Santiago 2026-09-06 has no local midnight and resolves forward to 01:00, with its neighbour's end equal to its start.
  • Clamping: period_start + 30 on February is the 28th, or the 29th in a leap year, never 2 March; period_end - 90 on a month is the 1st. A table drives ±9999 on both anchors across all seven frequencies and asserts the result never leaves the period.
  • periodsBetween over the 2026→2027 boundary for each frequency: ascending, no duplicates, and contiguous by construction — each period's end is asserted equal to the next period's start.
  • Malformed keys are refused, never repaired2026-W4, 2026-13, 2026-Q5, 2026-02-30, and an even fortnight key (whose message names the odd one you probably meant). duly_task is unique on (duty, owner, period_key), so two spellings of one period would be two tasks for one obligation and nothing downstream could tell they were meant to be the same.

Gates — all four green at 8946861

pnpm validate ✓ Validation passed (232ms)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed (2) · Tests 97 passed (97)
pnpm build ✓ Build complete · dist/objectstack.json (51.7 KB)

97 = the 89 added here plus the 8 existing invariant tests, which still pass untouched.

Reverse verification

Each mutation was confirmed on disk (injected marker present, removed anchor absent, git diff --stat non-empty) before the run, and a shell trap restored the tree after each leg; the tree was verified clean and marker-free afterwards. No build step is involved — vitest transforms src/ directly, so there is no dist/ for a stale artifact to hide in.

AblationResult
Inversion replaced with the naive unverified two-pass2 failed — the Santiago midnight-shift test and the skipped-day test
Clamp removed from dueDateFor5 failed — the February leap pair and all four clamping tests
Fortnights always 14 days (53-week year ignored)3 failed — both 53-week tests and the periodsBetween fortnightly walk

Raised, not changed

Neither is touched here: both live on src/objects/, outside this card's file surface.

Generated by Claude Code


Generated by Claude Code

One module owns what a period IS, so the dispatcher, the backfill and the
seed cannot disagree about it. Pure functions, no I/O, no runtime dependency.
Boundaries are computed on calendar parts in the supplied IANA zone and
converted to an instant exactly once. The inversion verifies its answer
rather than trusting a second pass: on a zone that shifts AT midnight
(America/Santiago, 2026-09-06) the naive two-pass converges on an instant
whose local reading is 23:00 the previous day — a whole period early.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:00
@os-warren
os-warren merged commit 82f88c9 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine
Sep 1, 2026
Merged

Period engine — period keys, boundaries and due dates, timezone-correct#25
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#1

One module owns what a period is, so the dispatcher, the backfill and the seed cannot disagree about it. Pure functions, no I/O, no platform imports beyond types, and no new runtime dependencyIntl.DateTimeFormat with a timeZone supplies both the local calendar parts and, by comparing those parts against the instant, the zone's offset at that instant. That is everything the arithmetic needs.

Files

File
src/functions/period.tsnew — the engine
test/period.test.tsnew — 89 tests

src/functions/index.ts is untouched: dulyFunctions is the map a script flow node resolves by name, no flow exists yet, and #2 imports the functions directly. Adding a name nothing resolves would be dead metadata and a needless collision point for the other three branches in flight. objectstack.config.ts is untouched.

API

periodKeyFor(frequency,instant: Date,timezone: string): stringperiodBounds(frequency,periodKey: string,timezone: string): { start: Date; end: Date}// [start, end)dueDateFor({ frequency, periodKey, timezone, dueAnchor, dueOffsetDays }): string// YYYY-MM-DDvisibleFromFor(dueDate: string,leadDays: number): string// YYYY-MM-DDperiodsBetween(frequency,from: Date,to: Date,timezone: string): string[]// ascending

The key spelling table is implemented exactly as written on the issue, 2026-W04 padding included, and every key is checked against period_key's maxLength: 16.

The two decisions worth reviewing

1. The inversion verifies its answer instead of trusting a second pass. The textbook wall-clock-to-instant conversion (guess - offsetAt(guess - offsetAt(guess))) is not merely imprecise on a zone that shifts at midnight — it lands in the wrong period. For America/Santiago 2026-09-06, where local midnight does not exist, it converges on 2026-09-06T03:00Z, whose local reading is 23:00 on the 5th. So candidates are drawn from the offsets a day either side of the target and then verified against the zone: an exact match wins (earliest, when a fall-back makes the reading happen twice), otherwise the answer resolves forward to the first instant past the requested reading. Measured, not assumed — ablating this back to the naive two-pass turns two tests red (below).

2. periodsBetween returns only keys periodKeyFor could itself return. The first draft walked civil dates and emitted 2011-12-30 for Pacific/Apia — a local day the zone skipped when it crossed the date line. A backfill would have filed a task for a day nobody lived through, whose periodBounds window is empty. The walk now carries each boundary instant forward and skips a period with no instants in it; it costs one zone inversion per period, not two, because neighbours share a boundary.

Behaviour the tests pin

  • Round-tripperiodKeyFor(f, periodBounds(f, k, tz).start, tz) === k for all seven frequencies × UTC / Europe/Berlin / Asia/Shanghai × ten awkward instants (year boundaries, both DST transitions, month ends, a leap day), plus the containment check that the probe falls inside the window it named.
  • ISO week-years: 2026-01-012026-W01, 2027-01-012026-W53. 2020 and 2026 have 53 weeks; 2021-W53 is refused with the week count in the message.
  • Fortnights start on odd ISO weeks and are named by the starting week. One consequence is worth stating out loud: in a 53-week week-year the final fortnight is one week long, because W01 of the next week-year must itself begin a fortnight and so cannot be the back half of this one. That is forced by the anchoring rule rather than chosen — the alternative reading (a 14-day cycle running continuously across the boundary) would mean W01 did not begin a fortnight in 2027, contradicting the contract. Periods stay contiguous and keys still round-trip either way; 2020 and 2026 both have a test.
  • DST: Berlin 2026-03-29 is a 23-hour day that still starts at local midnight; Berlin 2026-10-25 is a 25-hour day that starts at the first of its two midnights; Santiago 2026-09-06 has no local midnight and resolves forward to 01:00, with its neighbour's end equal to its start.
  • Clamping: period_start + 30 on February is the 28th, or the 29th in a leap year, never 2 March; period_end - 90 on a month is the 1st. A table drives ±9999 on both anchors across all seven frequencies and asserts the result never leaves the period.
  • periodsBetween over the 2026→2027 boundary for each frequency: ascending, no duplicates, and contiguous by construction — each period's end is asserted equal to the next period's start.
  • Malformed keys are refused, never repaired2026-W4, 2026-13, 2026-Q5, 2026-02-30, and an even fortnight key (whose message names the odd one you probably meant). duly_task is unique on (duty, owner, period_key), so two spellings of one period would be two tasks for one obligation and nothing downstream could tell they were meant to be the same.

Gates — all four green at 8946861

pnpm validate ✓ Validation passed (232ms)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed (2) · Tests 97 passed (97)
pnpm build ✓ Build complete · dist/objectstack.json (51.7 KB)

97 = the 89 added here plus the 8 existing invariant tests, which still pass untouched.

Reverse verification

Each mutation was confirmed on disk (injected marker present, removed anchor absent, git diff --stat non-empty) before the run, and a shell trap restored the tree after each leg; the tree was verified clean and marker-free afterwards. No build step is involved — vitest transforms src/ directly, so there is no dist/ for a stale artifact to hide in.

AblationResult
Inversion replaced with the naive unverified two-pass2 failed — the Santiago midnight-shift test and the skipped-day test
Clamp removed from dueDateFor5 failed — the February leap pair and all four clamping tests
Fortnights always 14 days (53-week year ignored)3 failed — both 53-week tests and the periodsBetween fortnightly walk

Raised, not changed

Neither is touched here: both live on src/objects/, outside this card's file surface.

Generated by Claude Code


Generated by Claude Code

One module owns what a period IS, so the dispatcher, the backfill and the
seed cannot disagree about it. Pure functions, no I/O, no runtime dependency.
Boundaries are computed on calendar parts in the supplied IANA zone and
converted to an instant exactly once. The inversion verifies its answer
rather than trusting a second pass: on a zone that shifts AT midnight
(America/Santiago, 2026-09-06) the naive two-pass converges on an instant
whose local reading is 23:00 the previous day — a whole period early.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:00
@os-warren
os-warren merged commit 82f88c9 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine
Sep 1, 2026
Merged

Period engine — period keys, boundaries and due dates, timezone-correct#25
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#1

One module owns what a period is, so the dispatcher, the backfill and the seed cannot disagree about it. Pure functions, no I/O, no platform imports beyond types, and no new runtime dependencyIntl.DateTimeFormat with a timeZone supplies both the local calendar parts and, by comparing those parts against the instant, the zone's offset at that instant. That is everything the arithmetic needs.

Files

File
src/functions/period.tsnew — the engine
test/period.test.tsnew — 89 tests

src/functions/index.ts is untouched: dulyFunctions is the map a script flow node resolves by name, no flow exists yet, and #2 imports the functions directly. Adding a name nothing resolves would be dead metadata and a needless collision point for the other three branches in flight. objectstack.config.ts is untouched.

API

periodKeyFor(frequency,instant: Date,timezone: string): stringperiodBounds(frequency,periodKey: string,timezone: string): { start: Date; end: Date}// [start, end)dueDateFor({ frequency, periodKey, timezone, dueAnchor, dueOffsetDays }): string// YYYY-MM-DDvisibleFromFor(dueDate: string,leadDays: number): string// YYYY-MM-DDperiodsBetween(frequency,from: Date,to: Date,timezone: string): string[]// ascending

The key spelling table is implemented exactly as written on the issue, 2026-W04 padding included, and every key is checked against period_key's maxLength: 16.

The two decisions worth reviewing

1. The inversion verifies its answer instead of trusting a second pass. The textbook wall-clock-to-instant conversion (guess - offsetAt(guess - offsetAt(guess))) is not merely imprecise on a zone that shifts at midnight — it lands in the wrong period. For America/Santiago 2026-09-06, where local midnight does not exist, it converges on 2026-09-06T03:00Z, whose local reading is 23:00 on the 5th. So candidates are drawn from the offsets a day either side of the target and then verified against the zone: an exact match wins (earliest, when a fall-back makes the reading happen twice), otherwise the answer resolves forward to the first instant past the requested reading. Measured, not assumed — ablating this back to the naive two-pass turns two tests red (below).

2. periodsBetween returns only keys periodKeyFor could itself return. The first draft walked civil dates and emitted 2011-12-30 for Pacific/Apia — a local day the zone skipped when it crossed the date line. A backfill would have filed a task for a day nobody lived through, whose periodBounds window is empty. The walk now carries each boundary instant forward and skips a period with no instants in it; it costs one zone inversion per period, not two, because neighbours share a boundary.

Behaviour the tests pin

  • Round-tripperiodKeyFor(f, periodBounds(f, k, tz).start, tz) === k for all seven frequencies × UTC / Europe/Berlin / Asia/Shanghai × ten awkward instants (year boundaries, both DST transitions, month ends, a leap day), plus the containment check that the probe falls inside the window it named.
  • ISO week-years: 2026-01-012026-W01, 2027-01-012026-W53. 2020 and 2026 have 53 weeks; 2021-W53 is refused with the week count in the message.
  • Fortnights start on odd ISO weeks and are named by the starting week. One consequence is worth stating out loud: in a 53-week week-year the final fortnight is one week long, because W01 of the next week-year must itself begin a fortnight and so cannot be the back half of this one. That is forced by the anchoring rule rather than chosen — the alternative reading (a 14-day cycle running continuously across the boundary) would mean W01 did not begin a fortnight in 2027, contradicting the contract. Periods stay contiguous and keys still round-trip either way; 2020 and 2026 both have a test.
  • DST: Berlin 2026-03-29 is a 23-hour day that still starts at local midnight; Berlin 2026-10-25 is a 25-hour day that starts at the first of its two midnights; Santiago 2026-09-06 has no local midnight and resolves forward to 01:00, with its neighbour's end equal to its start.
  • Clamping: period_start + 30 on February is the 28th, or the 29th in a leap year, never 2 March; period_end - 90 on a month is the 1st. A table drives ±9999 on both anchors across all seven frequencies and asserts the result never leaves the period.
  • periodsBetween over the 2026→2027 boundary for each frequency: ascending, no duplicates, and contiguous by construction — each period's end is asserted equal to the next period's start.
  • Malformed keys are refused, never repaired2026-W4, 2026-13, 2026-Q5, 2026-02-30, and an even fortnight key (whose message names the odd one you probably meant). duly_task is unique on (duty, owner, period_key), so two spellings of one period would be two tasks for one obligation and nothing downstream could tell they were meant to be the same.

Gates — all four green at 8946861

pnpm validate ✓ Validation passed (232ms)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed (2) · Tests 97 passed (97)
pnpm build ✓ Build complete · dist/objectstack.json (51.7 KB)

97 = the 89 added here plus the 8 existing invariant tests, which still pass untouched.

Reverse verification

Each mutation was confirmed on disk (injected marker present, removed anchor absent, git diff --stat non-empty) before the run, and a shell trap restored the tree after each leg; the tree was verified clean and marker-free afterwards. No build step is involved — vitest transforms src/ directly, so there is no dist/ for a stale artifact to hide in.

AblationResult
Inversion replaced with the naive unverified two-pass2 failed — the Santiago midnight-shift test and the skipped-day test
Clamp removed from dueDateFor5 failed — the February leap pair and all four clamping tests
Fortnights always 14 days (53-week year ignored)3 failed — both 53-week tests and the periodsBetween fortnightly walk

Raised, not changed

Neither is touched here: both live on src/objects/, outside this card's file surface.

Generated by Claude Code


Generated by Claude Code

One module owns what a period IS, so the dispatcher, the backfill and the
seed cannot disagree about it. Pure functions, no I/O, no runtime dependency.
Boundaries are computed on calendar parts in the supplied IANA zone and
converted to an instant exactly once. The inversion verifies its answer
rather than trusting a second pass: on a zone that shifts AT midnight
(America/Santiago, 2026-09-06) the naive two-pass converges on an instant
whose local reading is 23:00 the previous day — a whole period early.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:00
@os-warren
os-warren merged commit 82f88c9 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine
Sep 1, 2026
Merged

Period engine — period keys, boundaries and due dates, timezone-correct#25
os-warren merged 1 commit into
mainfrom
claude/issue-1-period-engine

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#1

One module owns what a period is, so the dispatcher, the backfill and the seed cannot disagree about it. Pure functions, no I/O, no platform imports beyond types, and no new runtime dependencyIntl.DateTimeFormat with a timeZone supplies both the local calendar parts and, by comparing those parts against the instant, the zone's offset at that instant. That is everything the arithmetic needs.

Files

File
src/functions/period.tsnew — the engine
test/period.test.tsnew — 89 tests

src/functions/index.ts is untouched: dulyFunctions is the map a script flow node resolves by name, no flow exists yet, and #2 imports the functions directly. Adding a name nothing resolves would be dead metadata and a needless collision point for the other three branches in flight. objectstack.config.ts is untouched.

API

periodKeyFor(frequency,instant: Date,timezone: string): stringperiodBounds(frequency,periodKey: string,timezone: string): { start: Date; end: Date}// [start, end)dueDateFor({ frequency, periodKey, timezone, dueAnchor, dueOffsetDays }): string// YYYY-MM-DDvisibleFromFor(dueDate: string,leadDays: number): string// YYYY-MM-DDperiodsBetween(frequency,from: Date,to: Date,timezone: string): string[]// ascending

The key spelling table is implemented exactly as written on the issue, 2026-W04 padding included, and every key is checked against period_key's maxLength: 16.

The two decisions worth reviewing

1. The inversion verifies its answer instead of trusting a second pass. The textbook wall-clock-to-instant conversion (guess - offsetAt(guess - offsetAt(guess))) is not merely imprecise on a zone that shifts at midnight — it lands in the wrong period. For America/Santiago 2026-09-06, where local midnight does not exist, it converges on 2026-09-06T03:00Z, whose local reading is 23:00 on the 5th. So candidates are drawn from the offsets a day either side of the target and then verified against the zone: an exact match wins (earliest, when a fall-back makes the reading happen twice), otherwise the answer resolves forward to the first instant past the requested reading. Measured, not assumed — ablating this back to the naive two-pass turns two tests red (below).

2. periodsBetween returns only keys periodKeyFor could itself return. The first draft walked civil dates and emitted 2011-12-30 for Pacific/Apia — a local day the zone skipped when it crossed the date line. A backfill would have filed a task for a day nobody lived through, whose periodBounds window is empty. The walk now carries each boundary instant forward and skips a period with no instants in it; it costs one zone inversion per period, not two, because neighbours share a boundary.

Behaviour the tests pin

  • Round-tripperiodKeyFor(f, periodBounds(f, k, tz).start, tz) === k for all seven frequencies × UTC / Europe/Berlin / Asia/Shanghai × ten awkward instants (year boundaries, both DST transitions, month ends, a leap day), plus the containment check that the probe falls inside the window it named.
  • ISO week-years: 2026-01-012026-W01, 2027-01-012026-W53. 2020 and 2026 have 53 weeks; 2021-W53 is refused with the week count in the message.
  • Fortnights start on odd ISO weeks and are named by the starting week. One consequence is worth stating out loud: in a 53-week week-year the final fortnight is one week long, because W01 of the next week-year must itself begin a fortnight and so cannot be the back half of this one. That is forced by the anchoring rule rather than chosen — the alternative reading (a 14-day cycle running continuously across the boundary) would mean W01 did not begin a fortnight in 2027, contradicting the contract. Periods stay contiguous and keys still round-trip either way; 2020 and 2026 both have a test.
  • DST: Berlin 2026-03-29 is a 23-hour day that still starts at local midnight; Berlin 2026-10-25 is a 25-hour day that starts at the first of its two midnights; Santiago 2026-09-06 has no local midnight and resolves forward to 01:00, with its neighbour's end equal to its start.
  • Clamping: period_start + 30 on February is the 28th, or the 29th in a leap year, never 2 March; period_end - 90 on a month is the 1st. A table drives ±9999 on both anchors across all seven frequencies and asserts the result never leaves the period.
  • periodsBetween over the 2026→2027 boundary for each frequency: ascending, no duplicates, and contiguous by construction — each period's end is asserted equal to the next period's start.
  • Malformed keys are refused, never repaired2026-W4, 2026-13, 2026-Q5, 2026-02-30, and an even fortnight key (whose message names the odd one you probably meant). duly_task is unique on (duty, owner, period_key), so two spellings of one period would be two tasks for one obligation and nothing downstream could tell they were meant to be the same.

Gates — all four green at 8946861

pnpm validate ✓ Validation passed (232ms)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed (2) · Tests 97 passed (97)
pnpm build ✓ Build complete · dist/objectstack.json (51.7 KB)

97 = the 89 added here plus the 8 existing invariant tests, which still pass untouched.

Reverse verification

Each mutation was confirmed on disk (injected marker present, removed anchor absent, git diff --stat non-empty) before the run, and a shell trap restored the tree after each leg; the tree was verified clean and marker-free afterwards. No build step is involved — vitest transforms src/ directly, so there is no dist/ for a stale artifact to hide in.

AblationResult
Inversion replaced with the naive unverified two-pass2 failed — the Santiago midnight-shift test and the skipped-day test
Clamp removed from dueDateFor5 failed — the February leap pair and all four clamping tests
Fortnights always 14 days (53-week year ignored)3 failed — both 53-week tests and the periodsBetween fortnightly walk

Raised, not changed

Neither is touched here: both live on src/objects/, outside this card's file surface.

Generated by Claude Code


Generated by Claude Code

One module owns what a period IS, so the dispatcher, the backfill and the
seed cannot disagree about it. Pure functions, no I/O, no runtime dependency.
Boundaries are computed on calendar parts in the supplied IANA zone and
converted to an instant exactly once. The inversion verifies its answer
rather than trusting a second pass: on a zone that shifts AT midnight
(America/Santiago, 2026-09-06) the naive two-pass converges on an instant
whose local reading is 23:00 the previous day — a whole period early.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:00
@os-warren
os-warren merged commit 82f88c9 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-warren@claude