diff --git a/CHANGELOG.md b/CHANGELOG.md index ea4f8fc..cef3200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,16 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su `INTEGER`). Declared types are recorded per `(database, table, column)` in `server/ColumnMetaStore.ts`. (#45) +- `demos/overtime.prg` — an Overtime Tracker example app, and the showcase for this + release's engine work: `TIME(15)` columns validated per-cell as you type in `BROWSE`, + `WEEK()` for the ISO week number, and `DATEADD()` to walk a week's Monday through Friday. + Employees have their own weekly schedule, so standard hours are a real per-employee sum + rather than a flat 40; overtime is banked per week and drawn down as leave, with the + balance computed live from the source rows (no running-total field to drift when a week + is re-edited). Seeds a grouped report (`demos/reports/overtimebyemp.json`) and is + reachable from the splash screen, `HELP`, and the Assistant (Programs → Run Overtime + demo). (#46) + ### Fixed - `CREATE TABLE t (price NUM(8,2))` silently created a **phantom column named `2`** of type `)`: the parser read the precision, then treated the scale as the next column diff --git a/CLAUDE.md b/CLAUDE.md index 202daaf..11b068d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,8 +93,9 @@ data/ demos/ *.prg Demo programs — single source of truth; seeded into the program store on every server start (overwrites store copies). - crm.prg + INVENTORY.prg are usable example apps. + crm.prg, INVENTORY.prg + overtime.prg are usable example apps. reports/*.json Demo report definitions — seeded into the report store at startup + (dealsbystage, lowstock, overtimebyemp) .devcontainer/ devcontainer.json GitHub Codespaces config — auto npm install + npm run dev @@ -322,7 +323,10 @@ line. - ~~BROWSE per-cell validation~~ — grid rejects invalid edits per column type, validated on both client and server via `src/shared/cellValidation.ts` (#45) ✅ - ~~Test hardening~~ — strict `CREATE TABLE` grammar, golden demo schemas, coverage for every grid WS message, per-database index/column metadata scoping, `npm run coverage` (#50) ✅ -- `demos/overtime.prg` — overtime tracker showcasing all three of the above (#46) +- ~~`demos/overtime.prg`~~ — overtime tracker: per-employee weekly schedules, `TIME(15)` + timesheets edited in the validated grid, `WEEK()`/`DATEADD()`, live overtime balance, + grouped report, CSV export (#46) ✅ +- ~~`DATEADD()` built-in~~ — day arithmetic; W3Script had none (#52) ✅ ## Boolean literals @@ -331,12 +335,12 @@ Both styles accepted: `TRUE`/`FALSE` and `.T.`/`.TRUE.`/`.F.`/`.FALSE.` (dBASE I ## Testing ```bash -npm test # Vitest unit + integration (369 tests) +npm test # Vitest unit + integration (379 tests) npm run coverage # Vitest + v8 coverage report (reporting only, no thresholds) npx playwright test # E2E browser tests — requires dev server on :5173/:3000 ``` -Playwright suites (84 tests): `tests/assistant.spec.ts` (23 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, `TIME(15)` column + REPLACE validation, `NUM(p,s)` wizard, Browse-action grid validation, program run, CSV/SORT/SUM-AVERAGE/REINDEX/PACK actions, demo launchers), `tests/integration.spec.ts` (20 tests — full REPL scenario), `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` (6 tests — `?`/`??`, built-in functions, `WEEK()`, `DATEADD()`, `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/grid-validation.spec.ts` (3 tests — BROWSE per-cell validation: TIME(15), NUM(p,s)/DATE, Esc abandons), `tests/schema-errors.spec.ts` (3 tests — malformed CREATE TABLE errors, NUM(p,s) column count, bare INPUT stores its value), `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 (94 tests): `tests/assistant.spec.ts` (23 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, `TIME(15)` column + REPLACE validation, `NUM(p,s)` wizard, Browse-action grid validation, program run, CSV/SORT/SUM-AVERAGE/REINDEX/PACK actions, demo launchers), `tests/integration.spec.ts` (20 tests — full REPL scenario), `tests/overtime.spec.ts` (9 tests — overtime.prg: menu, seeding, DATEADD week prep, TIME(15) grid rejection, recalculation, live balance, quarter-hour leave check, report, CSV), `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` (6 tests — `?`/`??`, built-in functions, `WEEK()`, `DATEADD()`, `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/grid-validation.spec.ts` (3 tests — BROWSE per-cell validation: TIME(15), NUM(p,s)/DATE, Esc abandons), `tests/schema-errors.spec.ts` (3 tests — malformed CREATE TABLE errors, NUM(p,s) column count, bare INPUT stores its value), `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). ## Test discipline diff --git a/README.md b/README.md index 8d61250..eca1685 100644 --- a/README.md +++ b/README.md @@ -288,14 +288,17 @@ WebBase-III supports **unlimited work areas** — each independently holding a t > seeded into the program store on every server start, overwriting any store copy. > Matching report definitions live in `demos/reports/*.json` and are seeded the same way. > -> Two of them are **usable example apps** you can build off — run them, then `EDIT` to adapt: +> Three of them are **usable example apps** you can build off — run them, then `EDIT` to adapt: > - **`DO crm`** — a mini-CRM: companies, contacts, and deals with pipeline totals > (`SUM … FOR`), top-deals sort, a grouped report, CSV export, and a companies+deals JOIN. > - **`DO inventory`** — a stock manager: categories, products (with reorder levels), and a > stock-movements ledger, plus valuation totals, a low-stock report, sort, CSV export, and > a products+categories JOIN. +> - **`DO overtime`** — an overtime tracker: per-employee weekly schedules, `TIME(15)` +> timesheets you edit in the validated grid, `WEEK()` / `DATEADD()` week handling, a live +> overtime balance (banked minus leave taken), a grouped report, and CSV export. > -> Both lean on multi-work-area relations (`alias.field`), indexes, and `@ SAY … GET`/`READ` +> They lean on multi-work-area relations (`alias.field`), indexes, and `@ SAY … GET`/`READ` > forms, and invite you to open a second window to watch live multiuser propagation. ### Variables & I/O diff --git a/demos/overtime.prg b/demos/overtime.prg new file mode 100644 index 0000000..237146b --- /dev/null +++ b/demos/overtime.prg @@ -0,0 +1,458 @@ +* ============================================================ +* overtime.prg — WebBase-III Overtime Tracker (usable example app) +* +* Employees have a standard weekly shift schedule. Actual hours are +* entered per day, overtime is banked, and later taken as leave. +* +* Five linked tables: EMPLOYEES, SCHEDULEDAYS, TIMESHEET, +* WEEKSUMMARY, LEAVETAKEN. +* +* Shows off the v1.2.0 engine features: +* TIME(15) columns — quarter-hour times, validated on write and +* guarded per-cell as you type in BROWSE +* WEEK(date) — ISO-8601 week number +* DATEADD(date, n) — day arithmetic (Monday + 0..4 = Mon..Fri) +* plus multi-work-area + SET RELATION, SEEK, REPORT FORM, COPY TO csv. +* +* COPY THIS FILE (or EDIT overtime) to build your own tracker. +* ============================================================ + +* Start from a clean slate so work areas/relations left open by another +* program (or a previous run) in this session can't leak in. +CLOSE ALL + +USE DATABASE OVERTIME + +SELECT EMP +USE DATABASE OVERTIME +USE EMPLOYEES + +SELECT SCH +USE DATABASE OVERTIME +USE SCHEDULEDAYS + +SELECT TS +USE DATABASE OVERTIME +USE TIMESHEET + +SELECT WS +USE DATABASE OVERTIME +USE WEEKSUMMARY + +SELECT LV +USE DATABASE OVERTIME +USE LEAVETAKEN + +* ── First-run seeding ─────────────────────────────────────── +* Two employees on different schedules, so "standard hours" is a real +* per-employee sum and not a flat 40. +* +* The gate is EMPLOYEES, which the seed always fills. Gating on a table +* that legitimately stays empty (LEAVETAKEN, say) would re-seed — and so +* wipe TIMESHEET and WEEKSUMMARY — on every run until someone used it. +SELECT EMP +IF RECCOUNT() == 0 + DROP TABLE EMPLOYEES + CREATE TABLE EMPLOYEES (EMPID CHAR(4), NAME CHAR(30), SCHEDID CHAR(4)) + INDEX ON EMPID TO BYEMP + APPEND RECORD + REPLACE EMPID WITH "E001", NAME WITH "Ada Lovelace", SCHEDID WITH "S001" + APPEND RECORD + REPLACE EMPID WITH "E002", NAME WITH "Grace Hopper", SCHEDID WITH "S002" + + SELECT SCH + DROP TABLE SCHEDULEDAYS + CREATE TABLE SCHEDULEDAYS (SCHEDID CHAR(4), DOW NUM(1), TIMEIN TIME(15), BSTART TIME(15), BEND TIME(15), TIMEOUT TIME(15)) + INDEX ON SCHEDID TO BYSCHED + * S001 — 08:00-16:30 with a 30-minute break = 8.00 h/day, 40.00 h/week + STORE 1 TO d + DO WHILE d <= 5 + APPEND RECORD + REPLACE SCHEDID WITH "S001", DOW WITH d, TIMEIN WITH "08:00", BSTART WITH "12:00", BEND WITH "12:30", TIMEOUT WITH "16:30" + STORE d + 1 TO d + ENDDO + * S002 — 09:00-16:00 with a 45-minute break = 6.25 h/day, 31.25 h/week + STORE 1 TO d + DO WHILE d <= 5 + APPEND RECORD + REPLACE SCHEDID WITH "S002", DOW WITH d, TIMEIN WITH "09:00", BSTART WITH "12:00", BEND WITH "12:45", TIMEOUT WITH "16:00" + STORE d + 1 TO d + ENDDO + + SELECT TS + DROP TABLE TIMESHEET + CREATE TABLE TIMESHEET (EMPID CHAR(4), WEEKDATE DATE, DOW NUM(1), WORKDATE DATE, TIMEIN TIME(15), BSTART TIME(15), BEND TIME(15), TIMEOUT TIME(15), WORKEDHOURS NUM(6,2)) + INDEX ON EMPID TO TSEMP + + SELECT WS + DROP TABLE WEEKSUMMARY + CREATE TABLE WEEKSUMMARY (EMPID CHAR(4), WEEKDATE DATE, WEEKNO NUM(2), WORKEDHOURS NUM(6,2), STANDARDHOURS NUM(6,2), OVERTIME NUM(6,2)) + INDEX ON EMPID TO WSEMP + + SELECT LV + DROP TABLE LEAVETAKEN + CREATE TABLE LEAVETAKEN (EMPID CHAR(4), LDATE DATE, HOURS NUM(6,2), NOTES CHAR(40)) + INDEX ON EMPID TO LVEMP +ENDIF + +* ── Activate indexes + relations ───────────────────────────── +SELECT EMP +SET INDEX TO BYEMP + +SELECT TS +SET INDEX TO TSEMP +SET RELATION TO EMPID INTO EMP + +SELECT WS +SET INDEX TO WSEMP +SET RELATION TO EMPID INTO EMP + +SELECT LV +SET INDEX TO LVEMP +SET RELATION TO EMPID INTO EMP + +SELECT EMP + +* ── Main menu ──────────────────────────────────────────────── +STORE .T. TO running +DO WHILE running + CLEAR + @ 1, 5 SAY "================================================" + @ 2, 10 SAY " WEBBASE-III OVERTIME TRACKER (example app)" + @ 3, 5 SAY "================================================" + @ 5, 10 SAY "1. Add Employee" + @ 6, 10 SAY "2. Edit Standard Weekly Schedule (BROWSE)" + @ 7, 10 SAY "3. Open/Prep Week (creates + BROWSEs timesheet)" + @ 8, 10 SAY "4. Recalculate Week" + @ 9, 10 SAY "5. Register Leave Taken" + @ 10, 10 SAY "6. Overtime Balance" + @ 11, 10 SAY "7. Overtime Report" + @ 12, 10 SAY "8. Export Week Summary to CSV" + @ 13, 10 SAY "9. Browse Tables (read-only tour)" + @ 14, 10 SAY "Q. Quit" + @ 15, 5 SAY "================================================" + STORE " " TO choice + @ 16, 10 SAY "Enter choice: " GET choice + READ + + DO CASE + CASE UPPER(TRIM(choice)) == "1" + CLEAR + @ 2, 5 SAY "--- ADD EMPLOYEE ---" + STORE SPACE(4) TO m_emp + STORE SPACE(30) TO m_name + STORE SPACE(4) TO m_sch + @ 4, 5 SAY "Employee ID (4): " GET m_emp + @ 5, 5 SAY "Name (30): " GET m_name + @ 6, 5 SAY "Schedule ID (4): " GET m_sch + READ + SELECT EMP + SET INDEX TO BYEMP + SEEK TRIM(m_emp) + IF FOUND() + @ 8, 5 SAY "Employee already exists: " + TRIM(m_emp) + ELSE + APPEND RECORD + REPLACE EMPID WITH TRIM(m_emp), NAME WITH TRIM(m_name), SCHEDID WITH TRIM(m_sch) + @ 8, 5 SAY "Employee added: " + TRIM(m_emp) + ENDIF + INPUT "Press Enter to continue" TO pause + SELECT EMP + + CASE UPPER(TRIM(choice)) == "2" + CLEAR + @ 2, 5 SAY "--- STANDARD WEEKLY SCHEDULE ---" + @ 4, 5 SAY "All four time columns are TIME(15): the grid rejects" + @ 5, 5 SAY "anything that is not HH:MM on a quarter hour." + @ 7, 5 SAY "Try typing 08:07 into a time cell and press Enter." + INPUT "Press Enter to open the grid" TO pause + SELECT SCH + USE SCHEDULEDAYS + BROWSE + + CASE UPPER(TRIM(choice)) == "3" + CLEAR + @ 2, 5 SAY "--- OPEN / PREP WEEK ---" + STORE SPACE(4) TO m_emp + STORE "2026-07-06" TO m_week + @ 4, 5 SAY "Employee ID (4): " GET m_emp + @ 5, 5 SAY "Week Monday (YYYY-MM-DD): " GET m_week + READ + STORE TRIM(m_emp) TO m_emp + STORE TRIM(m_week) TO m_week + SELECT EMP + SET INDEX TO BYEMP + SEEK m_emp + IF FOUND() + STORE EMP.SCHEDID TO m_sch + STORE WEEK(m_week) TO m_wk + CLEAR + @ 2, 5 SAY "Employee : " + m_emp + " schedule " + TRIM(m_sch) + @ 3, 5 SAY "Week of : " + m_week + " ISO week " + STR(m_wk, 3) + + * Does this week already exist for this employee? + SELECT TS + USE TIMESHEET + STORE 0 TO m_have + GO TOP + DO WHILE .NOT. EOF() + IF EMPID == m_emp .AND. WEEKDATE == m_week + STORE m_have + 1 TO m_have + ENDIF + SKIP + ENDDO + + IF m_have == 0 + * Pre-fill Mon..Fri from the employee's standard schedule. + STORE 1 TO d + DO WHILE d <= 5 + * Read the schedule row for this day into memory variables. + STORE "" TO s_in + SELECT SCH + USE SCHEDULEDAYS + GO TOP + DO WHILE .NOT. EOF() + IF SCHEDID == m_sch .AND. DOW == d + STORE TIMEIN TO s_in + STORE BSTART TO s_bs + STORE BEND TO s_be + STORE TIMEOUT TO s_out + ENDIF + SKIP + ENDDO + IF .NOT. s_in == "" + SELECT TS + USE TIMESHEET + APPEND RECORD + REPLACE EMPID WITH m_emp, WEEKDATE WITH m_week, DOW WITH d, WORKDATE WITH DATEADD(m_week, d - 1) + REPLACE TIMEIN WITH s_in, BSTART WITH s_bs, BEND WITH s_be, TIMEOUT WITH s_out, WORKEDHOURS WITH 0 + ENDIF + STORE d + 1 TO d + ENDDO + @ 5, 5 SAY "Created 5 timesheet days, pre-filled from the schedule." + ELSE + @ 5, 5 SAY "Week already open: " + STR(m_have, 2) + " day(s) on file." + ENDIF + @ 7, 5 SAY "The grid guards the TIME(15) columns as you type." + @ 8, 5 SAY "(The grid shows every week on file, newest last.)" + INPUT "Press Enter to edit this week" TO pause + SELECT TS + USE TIMESHEET + BROWSE + ELSE + @ 7, 5 SAY "Employee not found: " + m_emp + INPUT "Press Enter to continue" TO pause + ENDIF + SELECT EMP + + CASE UPPER(TRIM(choice)) == "4" + CLEAR + @ 2, 5 SAY "--- RECALCULATE WEEK ---" + STORE SPACE(4) TO m_emp + STORE "2026-07-06" TO m_week + @ 4, 5 SAY "Employee ID (4): " GET m_emp + @ 5, 5 SAY "Week Monday (YYYY-MM-DD): " GET m_week + READ + STORE TRIM(m_emp) TO m_emp + STORE TRIM(m_week) TO m_week + + * Each day: worked = (TIMEOUT - TIMEIN) - (BEND - BSTART), in hours. + * Times are HH:MM text, so convert to minutes first. + SELECT TS + USE TIMESHEET + SET FILTER TO + STORE 0 TO m_worked + GO TOP + DO WHILE .NOT. EOF() + IF EMPID == m_emp .AND. WEEKDATE == m_week + STORE VAL(SUBSTR(TIMEOUT,1,2)) * 60 + VAL(SUBSTR(TIMEOUT,4,2)) TO t_out + STORE VAL(SUBSTR(TIMEIN,1,2)) * 60 + VAL(SUBSTR(TIMEIN,4,2)) TO t_in + STORE VAL(SUBSTR(BEND,1,2)) * 60 + VAL(SUBSTR(BEND,4,2)) TO t_be + STORE VAL(SUBSTR(BSTART,1,2)) * 60 + VAL(SUBSTR(BSTART,4,2)) TO t_bs + STORE ROUND(((t_out - t_in) - (t_be - t_bs)) / 60, 2) TO d_hours + REPLACE WORKEDHOURS WITH d_hours + STORE m_worked + d_hours TO m_worked + ENDIF + SKIP + ENDDO + + * Standard hours = the sum of this employee's own schedule days. + SELECT EMP + SET INDEX TO BYEMP + SEEK m_emp + STORE EMP.SCHEDID TO m_sch + SELECT SCH + USE SCHEDULEDAYS + STORE 0 TO m_std + GO TOP + DO WHILE .NOT. EOF() + IF SCHEDID == m_sch + STORE VAL(SUBSTR(TIMEOUT,1,2)) * 60 + VAL(SUBSTR(TIMEOUT,4,2)) TO t_out + STORE VAL(SUBSTR(TIMEIN,1,2)) * 60 + VAL(SUBSTR(TIMEIN,4,2)) TO t_in + STORE VAL(SUBSTR(BEND,1,2)) * 60 + VAL(SUBSTR(BEND,4,2)) TO t_be + STORE VAL(SUBSTR(BSTART,1,2)) * 60 + VAL(SUBSTR(BSTART,4,2)) TO t_bs + STORE m_std + ROUND(((t_out - t_in) - (t_be - t_bs)) / 60, 2) TO m_std + ENDIF + SKIP + ENDDO + + STORE ROUND(m_worked - m_std, 2) TO m_ot + STORE WEEK(m_week) TO m_wk + + * Upsert the week's summary row — never keep a running balance. + SELECT WS + USE WEEKSUMMARY + STORE .F. TO seen + GO TOP + DO WHILE .NOT. EOF() + IF EMPID == m_emp .AND. WEEKDATE == m_week + STORE .T. TO seen + REPLACE WEEKNO WITH m_wk, WORKEDHOURS WITH m_worked, STANDARDHOURS WITH m_std, OVERTIME WITH m_ot + ENDIF + SKIP + ENDDO + IF .NOT. seen + APPEND RECORD + REPLACE EMPID WITH m_emp, WEEKDATE WITH m_week, WEEKNO WITH m_wk + REPLACE WORKEDHOURS WITH m_worked, STANDARDHOURS WITH m_std, OVERTIME WITH m_ot + ENDIF + + CLEAR + @ 2, 5 SAY "--- WEEK RECALCULATED ---" + @ 4, 5 SAY "Employee : " + m_emp + @ 5, 5 SAY "Week of : " + m_week + " ISO week " + STR(m_wk, 3) + @ 6, 5 SAY "Worked hours : " + STR(m_worked, 8, 2) + @ 7, 5 SAY "Standard hours: " + STR(m_std, 8, 2) + @ 8, 5 SAY "Overtime : " + STR(m_ot, 8, 2) + INPUT "Press Enter to continue" TO pause + SELECT EMP + + CASE UPPER(TRIM(choice)) == "5" + CLEAR + @ 2, 5 SAY "--- REGISTER LEAVE TAKEN ---" + @ 3, 5 SAY "Hours must be a quarter-hour step (0.25 / 0.5 / 0.75 / 1.0 ...)" + STORE SPACE(4) TO m_emp + STORE "2026-07-13" TO m_date + STORE 0.00 TO m_hrs + STORE SPACE(40) TO m_note + @ 5, 5 SAY "Employee ID (4): " GET m_emp + @ 6, 5 SAY "Date (YYYY-MM-DD): " GET m_date + @ 7, 5 SAY "Hours (num): " GET m_hrs + @ 8, 5 SAY "Notes (40): " GET m_note + READ + STORE TRIM(m_emp) TO m_emp + * Quarter-hour check: hours * 4 must be a whole number. + STORE m_hrs * 4 TO m_q + IF .NOT. m_q == INT(m_q) + @ 10, 5 SAY "Rejected: " + STR(m_hrs, 8, 2) + " is not a quarter-hour step." + ELSE + IF m_hrs <= 0 + @ 10, 5 SAY "Rejected: hours must be greater than zero." + ELSE + SELECT EMP + SET INDEX TO BYEMP + SEEK m_emp + IF FOUND() + SELECT LV + USE LEAVETAKEN + APPEND RECORD + REPLACE EMPID WITH m_emp, LDATE WITH TRIM(m_date), HOURS WITH m_hrs, NOTES WITH TRIM(m_note) + @ 10, 5 SAY "Leave registered: " + STR(m_hrs, 6, 2) + " h for " + m_emp + ELSE + @ 10, 5 SAY "Employee not found: " + m_emp + ENDIF + ENDIF + ENDIF + INPUT "Press Enter to continue" TO pause + SELECT EMP + + CASE UPPER(TRIM(choice)) == "6" + CLEAR + @ 2, 5 SAY "--- OVERTIME BALANCE ---" + STORE SPACE(4) TO m_emp + @ 4, 5 SAY "Employee ID (4): " GET m_emp + READ + STORE TRIM(m_emp) TO m_emp + + * Computed live from the source rows — no stored balance field to + * drift when a week is re-edited. (SUM ... FOR is spliced into SQL + * and cannot see a memory variable, so accumulate in a loop.) + SELECT WS + USE WEEKSUMMARY + STORE 0 TO m_banked + GO TOP + DO WHILE .NOT. EOF() + IF EMPID == m_emp + STORE m_banked + OVERTIME TO m_banked + ENDIF + SKIP + ENDDO + + SELECT LV + USE LEAVETAKEN + STORE 0 TO m_taken + GO TOP + DO WHILE .NOT. EOF() + IF EMPID == m_emp + STORE m_taken + HOURS TO m_taken + ENDIF + SKIP + ENDDO + + STORE ROUND(m_banked - m_taken, 2) TO m_bal + CLEAR + @ 2, 5 SAY "--- OVERTIME BALANCE ---" + @ 4, 5 SAY "Employee : " + m_emp + @ 5, 5 SAY "Overtime banked : " + STR(m_banked, 8, 2) + @ 6, 5 SAY "Leave taken : " + STR(m_taken, 8, 2) + @ 7, 5 SAY "Balance : " + STR(m_bal, 8, 2) + INPUT "Press Enter to continue" TO pause + SELECT EMP + + CASE UPPER(TRIM(choice)) == "7" + CLEAR + SELECT WS + USE WEEKSUMMARY + REPORT FORM overtimebyemp + INPUT "Press Enter to continue" TO pause + SET INDEX TO WSEMP + SET RELATION TO EMPID INTO EMP + SELECT EMP + + CASE UPPER(TRIM(choice)) == "8" + CLEAR + SELECT WS + USE WEEKSUMMARY + COPY TO weeksummary.csv + @ 2, 5 SAY "Week summary exported to weeksummary.csv (check your downloads)." + INPUT "Press Enter to continue" TO pause + SET INDEX TO WSEMP + SET RELATION TO EMPID INTO EMP + SELECT EMP + + CASE UPPER(TRIM(choice)) == "9" + CLEAR + @ 2, 5 SAY "--- TABLE TOUR ---" + SELECT EMP + USE EMPLOYEES + LIST + SELECT SCH + USE SCHEDULEDAYS + LIST + SELECT WS + USE WEEKSUMMARY + LIST + SELECT LV + USE LEAVETAKEN + LIST + INPUT "Press Enter to continue (output is on the terminal)" TO pause + SELECT EMP + + CASE UPPER(TRIM(choice)) == "Q" + STORE .F. TO running + + ENDCASE +ENDDO +CLEAR +@ 2, 5 SAY "Overtime demo closed. Type DO overtime to run it again," +@ 3, 5 SAY "or EDIT overtime to customize it." diff --git a/demos/reports/overtimebyemp.json b/demos/reports/overtimebyemp.json new file mode 100644 index 0000000..28cd753 --- /dev/null +++ b/demos/reports/overtimebyemp.json @@ -0,0 +1,14 @@ +{ + "title": "Overtime by Employee", + "pageWidth": 80, + "columns": [ + { "field": "WEEKDATE", "heading": "Week of", "width": 12 }, + { "field": "WEEKNO", "heading": "Wk", "width": 5 }, + { "field": "WORKEDHOURS", "heading": "Worked", "width": 10, "total": true }, + { "field": "STANDARDHOURS", "heading": "Standard", "width": 10, "total": true }, + { "field": "OVERTIME", "heading": "Overtime", "width": 10, "total": true } + ], + "groupBy": "EMPID", + "pageHeader": "WebBase-III Overtime — Weekly Summary", + "pageFooter": "Page {PAGE}" +} diff --git a/src/interpreter/Executor.ts b/src/interpreter/Executor.ts index f155f03..f08a627 100644 --- a/src/interpreter/Executor.ts +++ b/src/interpreter/Executor.ts @@ -1063,6 +1063,7 @@ export class Executor implements IndexCommandsHost { { text: 'Demos / examples:' }, { text: 'DO crm — usable CRM example app (EDIT crm to customize)' }, { text: 'DO inventory — usable inventory example app (EDIT inventory to customize)' }, + { text: 'DO overtime — overtime tracker example app (TIME(15), WEEK(), DATEADD())' }, { text: '' }, { text: 'QUIT — exit' }, ]}; diff --git a/src/terminal/Terminal.ts b/src/terminal/Terminal.ts index 04df9c5..2afd2df 100644 --- a/src/terminal/Terminal.ts +++ b/src/terminal/Terminal.ts @@ -379,6 +379,7 @@ export class Terminal { { text: 'Try a full example app:', cls: 'hdr' }, { text: ' DO crm — a working mini-CRM (companies, contacts, deals)', cls: 'out' }, { text: ' DO inventory — a working stock manager (categories, products, movements)', cls: 'out' }, + { text: ' DO overtime — an overtime tracker (schedules, timesheets, leave)', cls: 'out' }, { text: ' These are complete, editable programs — EDIT crm to build your own.', cls: 'info' }, { text: '' }, ].forEach(l => this.printLine(l.text, l.cls)); diff --git a/src/ui/Assistant.ts b/src/ui/Assistant.ts index ab1177d..2a6e9ab 100644 --- a/src/ui/Assistant.ts +++ b/src/ui/Assistant.ts @@ -65,6 +65,7 @@ const CATEGORIES: { name: string; actions: ActionDef[] }[] = [ { name: 'Programs', actions: [ { label: 'Run CRM demo', command: 'DO crm' }, { label: 'Run Inventory demo', command: 'DO inventory' }, + { label: 'Run Overtime demo', command: 'DO overtime' }, { label: 'Run program…', picker: 'programs', onPick: (n, h) => h.run(`DO ${n}`) }, { label: 'Edit program…', picker: 'programs', onPick: (n, h) => h.run(`EDIT ${n}`) }, ]}, diff --git a/tests/DemoSchemas.test.ts b/tests/DemoSchemas.test.ts index a5f524a..4621c40 100644 --- a/tests/DemoSchemas.test.ts +++ b/tests/DemoSchemas.test.ts @@ -22,6 +22,13 @@ const DEMO_SCHEMAS: Record = { PRODUCTS: ['PRODID', 'CATID', 'NAME', 'STOCK', 'REORDER', 'PRICE', 'ACTIVE'], MOVEMENTS: ['MOVID', 'PRODID', 'KIND', 'QTY', 'MMONTH', 'REASON'], SALES: ['REGION', 'PRODUCT', 'AMOUNT', 'QTY'], + + // overtime.prg (#46) + EMPLOYEES: ['EMPID', 'NAME', 'SCHEDID'], + SCHEDULEDAYS: ['SCHEDID', 'DOW', 'TIMEIN', 'BSTART', 'BEND', 'TIMEOUT'], + TIMESHEET: ['EMPID', 'WEEKDATE', 'DOW', 'WORKDATE', 'TIMEIN', 'BSTART', 'BEND', 'TIMEOUT', 'WORKEDHOURS'], + WEEKSUMMARY: ['EMPID', 'WEEKDATE', 'WEEKNO', 'WORKEDHOURS', 'STANDARDHOURS', 'OVERTIME'], + LEAVETAKEN: ['EMPID', 'LDATE', 'HOURS', 'NOTES'], }; const DEMOS_DIR = path.join(process.cwd(), 'demos'); diff --git a/tests/overtime.spec.ts b/tests/overtime.spec.ts new file mode 100644 index 0000000..aeeafe5 --- /dev/null +++ b/tests/overtime.spec.ts @@ -0,0 +1,216 @@ +/** Playwright E2E for demos/overtime.prg — overtime tracker showcase (#46). */ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PRG_SRC = fs.readFileSync(path.join(__dirname, '..', 'demos', 'overtime.prg'), 'utf8'); +const PRG_NAME = 'overtime'; +const WEEK = '2026-07-06'; // a Monday; ISO week 28 + +async function cmd(page: Page, command: string, waitMs = 700): Promise { + const input = page.locator('#terminal-input'); + await input.fill(command); + await input.press('Enter'); + await page.waitForTimeout(waitMs); +} +async function waitForOutput(page: Page, text: string, timeout = 6000): Promise { + await expect(page.locator('#terminal-output')).toContainText(text, { timeout, ignoreCase: true }); +} +async function boot(page: Page): Promise { + await page.goto('http://localhost:5173'); + await waitForOutput(page, 'Connected.', 8000); +} +async function seedProgram(page: Page): Promise { + await cmd(page, `EDIT ${PRG_NAME}`, 1500); + await expect(page.locator('#editor-view')).toBeVisible({ timeout: 6000 }); + await page.locator('#editor-textarea').fill(PRG_SRC); + await page.waitForTimeout(300); + await page.keyboard.press('Control+s'); + await page.waitForTimeout(400); + await page.keyboard.press('Escape'); + await page.waitForTimeout(400); +} +/** Submit the menu's GET field (or any single-field form). */ +async function menuChoice(page: Page, choice: string, waitMs = 1500): Promise { + await expect(page.locator('#form-view')).toBeVisible({ timeout: 6000 }); + const input = page.locator('#form-view input.f-get').last(); + await input.fill(choice); + await input.press('Enter'); + await page.waitForTimeout(waitMs); +} +/** Fill the visible form's GET fields in order, then submit. */ +async function fillForm(page: Page, values: string[], waitMs = 1500): Promise { + await expect(page.locator('#form-view')).toBeVisible({ timeout: 6000 }); + const fields = page.locator('#form-view input.f-get'); + for (let i = 0; i < values.length; i++) await fields.nth(i).fill(values[i]); + await fields.nth(values.length - 1).press('Enter'); + await page.waitForTimeout(waitMs); +} +/** In the open TIMESHEET grid, make Friday (row 5) finish 2h late. */ +async function workLateOnFriday(page: Page): Promise { + await expect(page.locator('#grid-view')).toBeVisible({ timeout: 6000 }); + // TIMESHEET columns: EMPID(0) WEEKDATE(1) DOW(2) WORKDATE(3) TIMEIN(4) BSTART(5) BEND(6) TIMEOUT(7) + const td = page.locator('#grid-tbody td[data-ri="4"][data-ci="7"]'); + await td.dblclick(); + await td.locator('input.cell-ed').fill('18:30'); + await page.keyboard.press('Enter'); + await expect(td).toContainText('18:30'); + await page.waitForTimeout(400); + await page.keyboard.press('Escape'); // leave grid, back to the menu + await page.waitForTimeout(1200); +} + +/** Dismiss an "Press Enter to continue" INPUT prompt. */ +async function ack(page: Page, waitMs = 1000): Promise { + const form = page.locator('#form-view'); + if (await form.isVisible({ timeout: 1500 }).catch(() => false)) { + const input = form.locator('input.f-get').last(); + await input.fill(''); + await input.press('Enter'); + await page.waitForTimeout(waitMs); + } +} + +test.describe('Overtime demo', () => { + test.beforeEach(async ({ page }) => { + await boot(page); + await seedProgram(page); + // Clean slate: the OVERTIME database persists server-side across suites. + await cmd(page, 'USE DATABASE OVERTIME'); + for (const t of ['EMPLOYEES', 'SCHEDULEDAYS', 'TIMESHEET', 'WEEKSUMMARY', 'LEAVETAKEN']) { + await cmd(page, `DROP TABLE ${t}`, 150); + } + await cmd(page, `DO ${PRG_NAME}`, 2000); + }); + + test('runs and shows the main menu', async ({ page }) => { + await expect(page.locator('#form-view')).toBeVisible({ timeout: 8000 }); + await expect(page.locator('#form-view')).toContainText('OVERTIME TRACKER', { timeout: 6000 }); + }); + + test('seeds two employees on different schedules', async ({ page }) => { + await menuChoice(page, '9'); // table tour → LIST to the terminal + // Assert before dismissing: the menu loop's CLEAR wipes the terminal on its + // next iteration, and the form overlay is merely covering it, not clearing it. + await expect(page.locator('#terminal-output')).toContainText('Ada Lovelace'); + await expect(page.locator('#terminal-output')).toContainText('Grace Hopper'); + await expect(page.locator('#terminal-output')).toContainText('S002'); + await ack(page); + }); + + test('prep week derives Mon-Fri work dates with DATEADD and shows the ISO week', async ({ page }) => { + await menuChoice(page, '3'); + await fillForm(page, ['E001', WEEK], 2000); + // WEEK("2026-07-06") == 28, rendered in the form next to its label. + await expect(page.locator('#form-view')).toContainText('28', { timeout: 6000 }); + await expect(page.locator('#form-view')).toContainText(/created 5 timesheet days/i, { timeout: 6000 }); + + await ack(page, 1500); // "Press Enter to edit this week" → BROWSE + await expect(page.locator('#grid-view')).toBeVisible({ timeout: 6000 }); + // DATEADD walked Monday..Friday. + await expect(page.locator('#grid-tbody')).toContainText('2026-07-06'); + await expect(page.locator('#grid-tbody')).toContainText('2026-07-10'); + // Leaving the grid resumes the program, so the menu comes back — not the terminal. + await page.keyboard.press('Escape'); + await expect(page.locator('#form-view')).toContainText('OVERTIME TRACKER', { timeout: 6000 }); + }); + + test('the schedule grid rejects an off-quarter TIME(15) edit', async ({ page }) => { + await menuChoice(page, '2'); + await ack(page, 1500); // "Press Enter to open the grid" + await expect(page.locator('#grid-view')).toBeVisible({ timeout: 6000 }); + + // TIMEIN is column index 2 (SCHEDID, DOW, TIMEIN, ...). + const td = page.locator('#grid-tbody td[data-ri="0"][data-ci="2"]'); + await td.dblclick(); + await td.locator('input.cell-ed').fill('08:07'); + await page.keyboard.press('Enter'); + await expect(td).toHaveClass(/cell-invalid/); + await expect(td.locator('.cell-error')).toContainText('multiple of 15'); + + await td.locator('input.cell-ed').fill('08:15'); + await page.keyboard.press('Enter'); + await expect(td).toContainText('08:15'); + await page.keyboard.press('Escape'); + }); + + test('recalculate computes worked, standard and overtime hours', async ({ page }) => { + await menuChoice(page, '3'); + await fillForm(page, ['E001', WEEK], 2000); + await ack(page, 1500); // → BROWSE the new week + await workLateOnFriday(page); // 16:30 → 18:30, through the validated grid + + await menuChoice(page, '4'); + await fillForm(page, ['E001', WEEK], 2500); + + // 4 x 8.00 + 10.00 = 42.00 worked; schedule S001 = 40.00 standard; +2.00 overtime. + const form = page.locator('#form-view'); + await expect(form).toContainText('42.00', { timeout: 6000 }); + await expect(form).toContainText('40.00', { timeout: 6000 }); + await expect(form).toContainText('2.00', { timeout: 6000 }); + }); + + test('overtime balance = banked overtime minus leave taken', async ({ page }) => { + // Bank 2h of overtime first. + await menuChoice(page, '3'); + await fillForm(page, ['E001', WEEK], 2000); + await ack(page, 1500); + await workLateOnFriday(page); + + await menuChoice(page, '4'); + await fillForm(page, ['E001', WEEK], 2500); + await ack(page, 1200); + + // Take 1.5h of leave. + await menuChoice(page, '5'); + await fillForm(page, ['E001', '2026-07-13', '1.5', 'early friday'], 2000); + await expect(page.locator('#form-view')).toContainText(/leave registered/i, { timeout: 6000 }); + await ack(page, 1200); + + // Balance = 2.00 banked - 1.50 taken = 0.50 + await menuChoice(page, '6'); + await fillForm(page, ['E001'], 2000); + const form = page.locator('#form-view'); + await expect(form).toContainText('2.00', { timeout: 6000 }); // banked + await expect(form).toContainText('1.50', { timeout: 6000 }); // taken + await expect(form).toContainText('0.50', { timeout: 6000 }); // balance + }); + + test('leave form rejects hours that are not a quarter-hour step', async ({ page }) => { + await menuChoice(page, '5'); + await fillForm(page, ['E001', '2026-07-13', '1.3', 'bad step'], 2000); + await expect(page.locator('#form-view')).toContainText(/not a quarter-hour step/i, { timeout: 6000 }); + }); + + test('overtime report renders grouped by employee', async ({ page }) => { + await menuChoice(page, '3'); + await fillForm(page, ['E001', WEEK], 2000); + await ack(page, 1500); + await workLateOnFriday(page); + await menuChoice(page, '4'); + await fillForm(page, ['E001', WEEK], 2500); + await ack(page, 1200); + + await menuChoice(page, '7', 2500); + // Assert before the menu's next CLEAR wipes the terminal. + await expect(page.locator('#terminal-output')).toContainText('Overtime by Employee', { timeout: 6000 }); + await expect(page.locator('#terminal-output')).toContainText('E001'); + }); + + test('exports the week summary to CSV', async ({ page }) => { + await menuChoice(page, '3'); + await fillForm(page, ['E001', WEEK], 2000); + await ack(page, 1500); + await workLateOnFriday(page); + await menuChoice(page, '4'); + await fillForm(page, ['E001', WEEK], 2500); + await ack(page, 1200); + + const download = page.waitForEvent('download', { timeout: 10000 }); + await menuChoice(page, '8', 2000); + const file = await download; + expect(file.suggestedFilename()).toBe('weeksummary.csv'); + }); +});