From 45019e9b811efb7fed29a0939661cffb6724f34d Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Thu, 9 Jul 2026 19:02:26 +0200 Subject: [PATCH] feat: WEEK() ISO-8601 week-number built-in (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WEEK(date) returns the ISO-8601 week number (1-53): Monday-start weeks, week 1 being the week that contains the year's first Thursday. Early-January dates therefore report the previous year's week 52/53, and late-December dates week 1 of the next year. Accepts ISO YYYY-MM-DD or MM/DD/YY. Computed in UTC so a local timezone offset can't shift the day, and ISO-shaped but impossible dates (2024-02-30, 2023-02-29) return 0 rather than silently rolling over the way Date.UTC does. Registered in Parser's BUILTIN_FUNCTIONS — implementing a built-in without whitelisting it there is how the #4 built-ins shipped broken. Covered directly (Builtins.test.ts), through the parser (BuiltinsParse.test.ts), and end-to-end in the REPL (parity-commands.spec.ts), asserting the printed value. Also refreshes the doc test counts and adds ColumnMetaStore.ts / TimeType.test.ts to the CLAUDE.md trees, both missed in #43. --- CHANGELOG.md | 4 ++++ CLAUDE.md | 34 ++++++++++++++++++++++++++++++++-- README.md | 1 + src/interpreter/Builtins.ts | 25 +++++++++++++++++++++++++ src/interpreter/Parser.ts | 1 + tests/Builtins.test.ts | 30 ++++++++++++++++++++++++++++++ tests/BuiltinsParse.test.ts | 2 ++ tests/parity-commands.spec.ts | 29 +++++++++++++++++++++++++++++ 8 files changed, 124 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cec76f2..1bc397c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su validated on `REPLACE ... WITH` (rejects malformed or off-granularity values — no silent coercion), and `LIST STRUCTURE` prints the declared type instead of the raw SQLite storage class. (#43) +- `WEEK(date)` built-in — ISO-8601 week number (1–53): Monday-start weeks, week 1 is the + week containing the year's first Thursday. Early-January dates correctly report the + previous year's week 52/53, and late-December dates week 1 of the next year. Accepts + ISO `YYYY-MM-DD` or `MM/DD/YY`; invalid input returns 0. (#44) --- diff --git a/CLAUDE.md b/CLAUDE.md index 5be64f9..1570138 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,7 @@ server/ ServerDatabaseBridge.ts IDatabaseBridge impl wrapping better-sqlite3 ProgramStore.ts .prg program storage in data/system.sqlite3 IndexStore.ts Index metadata + active index in data/system.sqlite3 + ColumnMetaStore.ts Declared column types (TIME, TIME(n)) in data/system.sqlite3 — SQLite affinity can't distinguish TIME from CHAR/DATE ReportStore.ts Report definition storage in data/system.sqlite3 (reports table) ReportRunner.ts ASCII and HTML report rendering, group breaks, subtotals, grand totals DemoSeeder.ts Seeds demos/*.prg into the program store and demos/reports/*.json into the report store at startup (demos win) @@ -109,6 +110,7 @@ tests/ ServerDatabaseBridge.test.ts ProgramStore.test.ts AlterTable.test.ts ALTER TABLE + MODIFY STRUCTURE integration tests + TimeType.test.ts TIME / TIME(n) columns — creation, structure, write validation Print.test.ts `?` / `??` print command Aggregate.test.ts `SUM` / `AVERAGE` Builtins.test.ts / BuiltinsParse.test.ts built-in functions (direct + through the parser) @@ -221,6 +223,25 @@ WebBase-III supports **unlimited work areas** (no DOS 10-area limit). Cross-area | `@ r,c SAY "text" GET ` | Define a form field | | `READ` | Display form and wait for submit | +### Built-in functions + +Implemented in `src/interpreter/Builtins.ts` (stateless) and `Executor.ts` (stateful: +`EOF()`, `BOF()`, `FOUND()`, `RECNO()`, `RECCOUNT()`). + +> **Adding a built-in:** implementing it in `Builtins.ts` is not enough — it must also be +> added to `BUILTIN_FUNCTIONS` in `src/interpreter/Parser.ts` or the parser rejects the +> call (`Unknown command: (`). This is how the #4 built-ins shipped broken. Always cover a +> new built-in in **both** `tests/Builtins.test.ts` (direct) and `tests/BuiltinsParse.test.ts` +> (through the parser), plus a Playwright case. + +Strings: `SUBSTR`, `LEN`, `TRIM`, `LTRIM`, `UPPER`, `LOWER`, `AT`, `STR`, `VAL`, `SPACE`, `REPLICATE`. +Numbers: `INT`, `ABS`, `ROUND`, `MOD`, `MAX`, `MIN`. +Dates/times: `DATE()`, `TIME()`, `DTOC`, `CTOD`, `YEAR`, `MONTH`, `DAY`, `WEEK`. + +| Function | What it returns | +|---|---| +| `WEEK(date)` | ISO-8601 week number (1–53). Monday-start weeks; week 1 is the week containing the year's first Thursday, so early-January dates can return 52/53 (belonging to the previous year's last week) and late-December dates can return 1. Accepts ISO `YYYY-MM-DD` or `MM/DD/YY`; invalid input → 0. | + ### Control flow | Command | What it does | |---|---| @@ -254,6 +275,7 @@ line. 1. ~~Indexing & Search~~ — `INDEX ON`, `SET INDEX TO`, `SEEK`, `FIND`, `REINDEX`, `LIST INDEXES` ✅ 2. ~~Language Completeness~~ — `DO CASE/ENDCASE`, built-in functions (`EOF()`, `BOF()`, `FOUND()`, `RECNO()`, `RECCOUNT()`, `SUBSTR()`, `STR()`, `AT()`, `UPPER()`, `LOWER()`, `ROUND()`, `MOD()`, `MAX()`, `MIN()`, `TIME()`, `YEAR()`, `MONTH()`, `DAY()`, and more) ✅ - `ROUND`/`MOD`/`MAX`/`MIN`/`TIME`/`YEAR`/`MONTH`/`DAY` contributed by [@kas2804](https://github.com/kas2804) in PR #17 (#4). 🙏 + - `WEEK()` added in v1.2.0 (#44). 3. ~~Multi-Work-Area~~ — unlimited `SELECT `, `SET RELATION TO`, `alias.field` notation ✅ 4. ~~Report & Label Engine~~ — `REPORT FORM`, group breaks, subtotals, HTML preview ✅ 5. ~~The Assistant~~ — sidebar GUI, wizards, catalog protocol ✅ @@ -266,6 +288,14 @@ line. so other sessions BROWSE-ing that table refresh automatically (#11) ✅ - ~~JOIN to materialize a combined table~~ — `JOIN WITH TO FOR [FIELDS ]`, snapshot table via SQLite join (#10) ✅ +### Beyond parity (v1.2.0 — in progress) + +- ~~`TIME` column type~~ — `TIME`/`TIME(n)` columns storing `HH:MM`, with a minute-granularity + qualifier validated on write; declared types tracked in `server/ColumnMetaStore.ts` (#43) ✅ +- ~~`WEEK()` built-in~~ — ISO-8601 week number (#44) ✅ +- BROWSE per-cell validation — grid rejects invalid edits per column type (#45) +- `demos/overtime.prg` — overtime tracker showcasing all three of the above (#46) + ## Boolean literals Both styles accepted: `TRUE`/`FALSE` and `.T.`/`.TRUE.`/`.F.`/`.FALSE.` (dBASE III style). Output always uses `.T.`/`.F.`. Logical operators likewise: `NOT`/`.NOT.`, `AND`/`.AND.`, `OR`/`.OR.`. @@ -273,11 +303,11 @@ Both styles accepted: `TRUE`/`FALSE` and `.T.`/`.TRUE.`/`.F.`/`.FALSE.` (dBASE I ## Testing ```bash -npm test # Vitest unit + integration (265 tests) +npm test # Vitest unit + integration (281 tests) npx playwright test # E2E browser tests — requires dev server on :5173/:3000 ``` -Playwright suites (73 tests): `tests/integration.spec.ts` (20 tests — full REPL scenario), `tests/assistant.spec.ts` (20 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, program run, CSV/SORT/SUM-AVERAGE/REINDEX/PACK actions, demo launchers), `tests/inventory.spec.ts` (8 tests — INVENTORY.prg menu + valuation/low-stock report/sort/CSV/JOIN), `tests/crm.spec.ts` (6 tests — CRM demo menu, pipeline summary, sort, report, CSV, JOIN), `tests/multiarea.spec.ts` (4 tests — multi-work-area, relations, alias.field), `tests/parity-commands.spec.ts` (4 tests — `?`/`??`, built-in functions, `SUM`/`AVERAGE`, `SORT ON … TO`), `tests/demos.spec.ts` (4 tests — demo program + report seeding), `tests/copycsv.spec.ts` (2 tests — COPY TO download + APPEND FROM upload), `tests/splash.spec.ts` (2 tests — version banner + demo discoverability), `tests/join.spec.ts` (1 test — JOIN materialization), `tests/propagation.spec.ts` (1 test — live multiuser refresh), `tests/program-side-effects.spec.ts` (1 test — CSV/report side-effects fire from inside a program block). +Playwright suites (75 tests): `tests/integration.spec.ts` (20 tests — full REPL scenario), `tests/assistant.spec.ts` (21 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, `TIME(15)` column + REPLACE validation, program run, CSV/SORT/SUM-AVERAGE/REINDEX/PACK actions, demo launchers), `tests/inventory.spec.ts` (8 tests — INVENTORY.prg menu + valuation/low-stock report/sort/CSV/JOIN), `tests/crm.spec.ts` (6 tests — CRM demo menu, pipeline summary, sort, report, CSV, JOIN), `tests/parity-commands.spec.ts` (5 tests — `?`/`??`, built-in functions, `WEEK()`, `SUM`/`AVERAGE`, `SORT ON … TO`), `tests/multiarea.spec.ts` (4 tests — multi-work-area, relations, alias.field), `tests/demos.spec.ts` (4 tests — demo program + report seeding), `tests/copycsv.spec.ts` (2 tests — COPY TO download + APPEND FROM upload), `tests/splash.spec.ts` (2 tests — version banner + demo discoverability), `tests/join.spec.ts` (1 test — JOIN materialization), `tests/propagation.spec.ts` (1 test — live multiuser refresh), `tests/program-side-effects.spec.ts` (1 test — CSV/report side-effects fire from inside a program block). ## Definition of done diff --git a/README.md b/README.md index 6e866c5..abaa01b 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,7 @@ Functions work anywhere an expression is accepted — `IF`, `DO WHILE`, `STORE`, | `YEAR(date)` | Numeric year from ISO date string | | `MONTH(date)` | Numeric month from ISO date string | | `DAY(date)` | Numeric day from ISO date string | +| `WEEK(date)` | ISO-8601 week number (1–53) — Monday-start weeks, week 1 holds the year's first Thursday | ### Boolean literals diff --git a/src/interpreter/Builtins.ts b/src/interpreter/Builtins.ts index a29c20f..9b8e560 100644 --- a/src/interpreter/Builtins.ts +++ b/src/interpreter/Builtins.ts @@ -92,6 +92,31 @@ export function callStateless(fn: string, args: unknown[]): unknown { const d = new Date(s(0)); return isNaN(d.getTime()) ? 0 : d.getDate(); } + case 'WEEK': { + // ISO-8601 week number: Monday-start weeks, week 1 holds the year's first + // Thursday. Dates in early January can therefore belong to week 52/53 of + // the previous year, and late December to week 1 of the next. + const raw = s(0); + const iso = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/); + let y: number, m: number, day: number; + if (iso) { + y = Number(iso[1]); m = Number(iso[2]); day = Number(iso[3]); + } else { + const d = new Date(raw); + if (isNaN(d.getTime())) return 0; + y = d.getFullYear(); m = d.getMonth() + 1; day = d.getDate(); + } + // Work in UTC so no local timezone offset can shift the day. + const dt = new Date(Date.UTC(y, m - 1, day)); + if (isNaN(dt.getTime())) return 0; + // Date.UTC rolls impossible dates over (Feb 30 → Mar 1), so reject any + // input the round-trip doesn't reproduce exactly. + if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== day) return 0; + const dow = dt.getUTCDay() || 7; // Mon=1 … Sun=7 + dt.setUTCDate(dt.getUTCDate() + 4 - dow); // Thursday fixes the week's year + const yearStart = Date.UTC(dt.getUTCFullYear(), 0, 1); + return Math.ceil(((dt.getTime() - yearStart) / 86400000 + 1) / 7); + } default: throw new Error(`Unknown function: ${fn}`); } diff --git a/src/interpreter/Parser.ts b/src/interpreter/Parser.ts index 2a2141b..b38f40d 100644 --- a/src/interpreter/Parser.ts +++ b/src/interpreter/Parser.ts @@ -9,6 +9,7 @@ const BUILTIN_FUNCTIONS = new Set([ // #4 (PR #17, @kas2804) — implemented in Builtins.ts; must be whitelisted here // too or the parser won't recognise the call. 'ROUND','MOD','MAX','MIN','TIME','YEAR','MONTH','DAY', + 'WEEK', ]); // ── AST Node Types ────────────────────────────────────────────────────────── diff --git a/tests/Builtins.test.ts b/tests/Builtins.test.ts index 621c200..33e6695 100644 --- a/tests/Builtins.test.ts +++ b/tests/Builtins.test.ts @@ -124,6 +124,36 @@ describe('DAY', () => { it('invalid date returns 0', () => expect(callStateless('DAY', ['not-a-date'])).toBe(0)); }); +describe('WEEK', () => { + it('returns the ISO week number of a mid-year date', () => expect(callStateless('WEEK', ['2024-05-12'])).toBe(19)); + it('week 1 starts on the Monday of the week holding the first Thursday', () => { + expect(callStateless('WEEK', ['2024-01-01'])).toBe(1); // Monday, Jan 1 + expect(callStateless('WEEK', ['2026-01-01'])).toBe(1); // Thursday, Jan 1 + }); + it('early-January dates can belong to the last week of the previous year', () => { + expect(callStateless('WEEK', ['2021-01-01'])).toBe(53); // Friday → week 53 of 2020 + expect(callStateless('WEEK', ['2022-01-01'])).toBe(52); // Saturday → week 52 of 2021 + expect(callStateless('WEEK', ['2023-01-01'])).toBe(52); // Sunday → week 52 of 2022 + }); + it('late-December dates can belong to week 1 of the next year', () => { + expect(callStateless('WEEK', ['2024-12-30'])).toBe(1); // Monday → week 1 of 2025 + expect(callStateless('WEEK', ['2019-12-30'])).toBe(1); // Monday → week 1 of 2020 + }); + it('handles 53-week years', () => { + expect(callStateless('WEEK', ['2020-12-31'])).toBe(53); + expect(callStateless('WEEK', ['2026-12-31'])).toBe(53); + expect(callStateless('WEEK', ['2016-01-03'])).toBe(53); // Sunday → week 53 of 2015 + }); + it('accepts MM/DD/YY display dates', () => expect(callStateless('WEEK', ['05/12/24'])).toBe(19)); + it('invalid date returns 0', () => expect(callStateless('WEEK', ['not-a-date'])).toBe(0)); + it('ISO-shaped but impossible dates return 0 rather than rolling over', () => { + expect(callStateless('WEEK', ['2024-13-45'])).toBe(0); // month 13, day 45 + expect(callStateless('WEEK', ['2024-02-30'])).toBe(0); // Feb 30 never exists + expect(callStateless('WEEK', ['2023-02-29'])).toBe(0); // 2023 is not a leap year + }); + it('accepts a real leap day', () => expect(callStateless('WEEK', ['2024-02-29'])).toBe(9)); +}); + describe('unknown function', () => { it('throws', () => expect(() => callStateless('FOOBAR', [])).toThrow('Unknown function: FOOBAR')); }); diff --git a/tests/BuiltinsParse.test.ts b/tests/BuiltinsParse.test.ts index 9cb5cfb..c78b673 100644 --- a/tests/BuiltinsParse.test.ts +++ b/tests/BuiltinsParse.test.ts @@ -23,4 +23,6 @@ describe('built-in functions reachable through the REPL parser', () => { it('YEAR', async () => { expect(await evalPrint('YEAR(CTOD("12/25/2026"))')).toContain('2026'); }); it('MONTH', async () => { expect(await evalPrint('MONTH(CTOD("12/25/2026"))')).toContain('12'); }); it('DAY', async () => { expect(await evalPrint('DAY(CTOD("12/25/2026"))')).toContain('25'); }); + it('WEEK', async () => { expect(await evalPrint('WEEK("2024-05-12")')).toContain('19'); }); + it('WEEK across a year boundary', async () => { expect(await evalPrint('WEEK("2021-01-01")')).toContain('53'); }); }); diff --git a/tests/parity-commands.spec.ts b/tests/parity-commands.spec.ts index 4581807..ecdae8b 100644 --- a/tests/parity-commands.spec.ts +++ b/tests/parity-commands.spec.ts @@ -18,6 +18,14 @@ async function boot(page: Page, dbName: string): Promise { await waitForOutput(page, 'Opened database', 3000); } +// Run `? ` and return the printed value — the last rendered output line, +// which is the result rather than the echoed command. +async function printResult(page: Page, expr: string): Promise { + await cmd(page, `? ${expr}`); + const text = await page.locator('#terminal-output .t-line').last().textContent() ?? ''; + return text.trim(); +} + test.describe('Parity commands e2e', () => { test('1. ? / ?? print expressions', async ({ page }) => { @@ -63,6 +71,27 @@ test.describe('Parity commands e2e', () => { await expect(page.locator('#terminal-output')).toContainText(/\d\d:\d\d:\d\d/, { timeout: 3000 }); }); + // #44 — WEEK() ISO-8601 week number, asserted on the printed value (not just + // "the digits appear somewhere in the scrollback"). + test('2b. WEEK() built-in via ?', async ({ page }) => { + await boot(page, `e2e_parity_week_${Date.now()}`); + + expect(await printResult(page, 'WEEK("2024-05-12")')).toBe('19'); + + // Week 1 is the week holding the year's first Thursday. + expect(await printResult(page, 'WEEK("2026-01-01")')).toBe('1'); + + // Early January can fall in the previous year's last week … + expect(await printResult(page, 'WEEK("2021-01-01")')).toBe('53'); + expect(await printResult(page, 'WEEK("2023-01-01")')).toBe('52'); + + // … and late December in week 1 of the next. + expect(await printResult(page, 'WEEK("2024-12-30")')).toBe('1'); + + // Composes with CTOD, like the other date built-ins. + expect(await printResult(page, 'WEEK(CTOD("05/12/24"))')).toBe('19'); + }); + test('3. SUM and AVERAGE', async ({ page }) => { const db = `e2e_parity_sum_${Date.now()}`; await boot(page, db);