--- /dev/null +++ b/budget-workbench/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# One small contribution: find the next unrecorded actual + +This is an example contribution for owner review, not an accepted release or a +public submission system. You can study it, test it, or make your own MIT-licensed +change while retaining `LICENSE.txt`. + +## The task + +A budget can contain 200 entries. When totals say actuals are partial, a visitor +should be able to reach the remaining blank actual fields without scanning every +row. Add a **Next unrecorded actual** button that moves focus, shows the remaining +count, cycles through blanks and leaves every value unchanged. + +Keep the change small: + +- A blank string is unrecorded. `0`, `0.00` and signed zero are recorded. +- Invalid nonblank amounts still need correction. Navigation must not coerce, + validate away, fill, erase or conceal an invalid edit elsewhere. +- Repeated clicks wrap to the first remaining blank. Focusing an actual field + establishes the next starting point. Structural row changes reset it. +- Pause this control while reading or reviewing a file or confirming a currency + label change. Resume it after cancellation without changing current work. +- Preserve exact arithmetic, schema/version 1, original JSON strings, CSV + protection, explicit replacement review, keyboard use and tab-only privacy. +- Add no dependencies, accounts, storage, network requests, telemetry or forms. + +## Run and check + +Serve the extracted source directory with an ordinary static server, for example +`python3 -m http.server 8000 --bind 127.0.0.1`, then visit +`http://127.0.0.1:8000/`. Direct `file://` launch is not supported. + +Run dependency-free checks with Node: + +```sh +node --check core.mjs +node --check app.mjs +node --test tests/*.test.mjs +``` + +The original tests cover arithmetic, input, files, CSV and replacement. Independent +navigation tests check exact blanks versus zero, wrap order, frozen inputs, +invalid unrelated fields and bounds. Source checks are not browser interaction +or accessibility certification. + +For a browser review, use only fictional data: + +1. Open the fictional example. Navigate to its missing actual without changing + any total. Record a known amount. Confirm the count updates and the control + disables when there are no blanks. +2. Clear two actual fields. Activate the button repeatedly and check focus moves + in row order and wraps. A known zero must be skipped. +3. Make a different field invalid. Navigation must work without fixing or losing + that invalid edit, and totals/downloads must remain paused. +4. Add/remove rows, cancel file replacement, and cancel a currency choice. Check + the next blank and current data after each operation. Confirm the control is + disabled while the choices are pending. +5. Download JSON before and after navigation without editing. Compare the bytes. + Reopen the saved file in a fresh tab, revise an actual, save and reopen again. +6. Check keyboard activation, visible focus and narrow-screen layout. Record the + browser/OS and what you actually tested. Do not infer physical-phone or + screen-reader coverage from a resized desktop browser. + +## Prepare a return package + +Include source, a patch or concise changed-file list, the original notice, +credit, exact baseline URL and SHA-256, test commands/results, the runnable +output, known limits and the behavior you want reviewed. Do not include real +budgets, browser profiles, passwords, private notes, accounts or credentials. +The full contributor kit's `build.py` and `verify.py` provide a reproducible +example using Python's standard library and Node's built-in test runner. + +Use a credit name you are entitled to use. State whether work was AI-assisted, +which source you modified and what you changed. An example package is not proof +of a real outside contributor, user study, accepted PR or upstream review. + +## Where to return it + +No verified public budget-specific submission endpoint, repository, issue tracker +or maintainer email is established by this kit. Keep the package locally until +the project owner confirms a destination through a channel you already trust. +Ask: “Where should I send this budget workbench patch for review?” Do not send it +to the Games submission route by assumption, upload it elsewhere, or open a pull +request against an invented repository. + +The owner should check the latest shipped source before integration, review the +changed files and license scope, rerun the tests and browser checklist, and +follow the Hub's normal release checks. The standalone `index.html` is not the +hosted Hub navigation template. If the baseline has moved, reapply and review the +small change against that newer source rather than overwriting it. Publication +and acceptance are separate decisions; this kit makes neither claim. --- /dev/null +++ b/budget-workbench/CREDITS.md @@ -0,0 +1,24 @@ +# Credit and provenance + +- Original work: Free4AllHub monthly budget workbench. +- Original credit: Copyright (c) 2026 Free4AllHub contributors. +- License: MIT, reproduced without changes in `LICENSE.txt`. It covers the + original workbench source, documentation, tests, fictional practice figures + and compact source-download wrapper. Other Hub content and visitor data retain + their own rights. +- Public source: https://free4allhub.com/tools/budget/budget-workbench-source.zip +- Public source page: https://free4allhub.com/tools/budget/ +- Baseline ZIP SHA-256: + `64e665456f2f1579aa3b1aeb88eb70cdf387c153341037095edc4d138c7289f7`. +- Candidate identifier: `budget-unrecorded-navigation-1`. +- Change: cycle to unrecorded actual fields without altering budget data, + accompanied by independent contract tests and contribution/review guidance. +- Preparation: AI-assisted example contribution prepared for project-owner + review. No outside human contributor, accepted submission, PR, upstream + endorsement or beginner-user outcome is claimed. +- Added material uses the same MIT license. Preserve the original notice and + this provenance when redistributing this candidate. Future contributors can + add accurate credit for their own work; do not invent another person's credit. + +No third-party library, font, icon or asset was added. Browser QA tooling, when +used, is a development-only check and is not part of the workbench runtime. --- a/budget-workbench/README.md +++ b/budget-workbench/README.md @@ -45,6 +45,9 @@ 2. Set the title, month and one supported currency. Add or remove entries. 3. Enter planned amounts and record actual amounts you know. A blank actual is **unrecorded**, not zero. Enter `0` for a known zero. + Use **Next unrecorded actual** to jump to a blank actual field. Repeat to cycle + through the remaining blanks. It only moves focus, never fills amounts. Known + zeroes are skipped; invalid nonblank amounts still need your correction. 4. Check totals. Recorded actuals are partial until every relevant row has an actual. Income/expense total variance is withheld when its group is partial; net variance is withheld while any actual is missing. @@ -188,7 +191,7 @@ ```sh node --check core.mjs node --check app.mjs -node --test tests/core.test.mjs +node --test tests/*.test.mjs ``` The built-in Node tests cover decimal cents, signed zero and adjustments, blank @@ -217,3 +220,12 @@ verify another fresh reopen. Compose a new bundle yourself to include the revision. No old bundle or Learn record is updated automatically, and this transport is not a learning assessment. The hosted guide includes explicitly fictional figures. + +## Contributor review candidate + +This isolated candidate adds the unrecorded-actual navigation aid. Arithmetic, +version 1 JSON and CSV formats remain unchanged. See `CONTRIBUTING.md` for a bounded +contribution task, verification checklist and honest return instructions, and +`CREDITS.md` for source provenance. No public submission endpoint is established by +this archive, and including a candidate here does not mean it has been accepted +or deployed by Free4AllHub. --- a/budget-workbench/app.mjs +++ b/budget-workbench/app.mjs @@ -1,5 +1,5 @@ /* Original Free4AllHub budget UI. MIT License. No network or persistent storage. */ -import { MAX_ROWS, MAX_FILE_BYTES, BudgetSession, calculateBudget, cloneBudget, exampleBudget, exportBudgetCSV, exportBudgetJSON, formatAmount, newBudget, parseAmount, parseBudgetBytes, validateBudget } from './core.mjs'; +import { MAX_ROWS, MAX_FILE_BYTES, BudgetSession, calculateBudget, cloneBudget, exampleBudget, exportBudgetCSV, exportBudgetJSON, findNextUnrecordedActual, formatAmount, newBudget, parseAmount, parseBudgetBytes, validateBudget } from './core.mjs'; const $ = id => document.getElementById(`budget-${id}`); const session = new BudgetSession(); @@ -9,6 +9,7 @@ let pendingCurrency = null; let reviewReturnFocus = null; let downloadRequested = false; +let lastActualIndex = -1; const rowElements = []; function setStatus(message, isError = false) { @@ -33,6 +34,9 @@ $('save-csv').disabled = locked || Boolean(pendingCurrency) || !valid; $('add-income').disabled = !draft || draft.entries.length >= MAX_ROWS; $('add-expense').disabled = !draft || draft.entries.length >= MAX_ROWS; + const missing = draft?.entries.filter(row => row.actual === '').length || 0; + $('next-unrecorded').disabled = locked || Boolean(pendingCurrency) || !missing; + $('next-unrecorded').textContent = missing ? `Next unrecorded actual (${missing} remaining)` : 'Next unrecorded actual'; } function fieldError(element, error = '') { element.setAttribute('aria-invalid', error ? 'true' : 'false'); @@ -113,6 +117,7 @@ return input; } function renderRows() { + lastActualIndex = -1; rowElements.length = 0; const fragment = document.createDocumentFragment(); draft.entries.forEach((row, index) => { @@ -216,6 +221,17 @@ $('blank').addEventListener('click', () => startBudget('blank')); $('add-income').addEventListener('click', () => addRow('income')); $('add-expense').addEventListener('click', () => addRow('expense')); +$('rows').addEventListener('focusin', event => { + if (event.target.dataset.field === 'actual') lastActualIndex = Number(event.target.dataset.index); +}); +$('next-unrecorded').addEventListener('click', () => { + if (!draft || busy || session.pending || pendingCurrency) return; + const index = findNextUnrecordedActual(draft.entries, lastActualIndex); + if (index < 0) return; + lastActualIndex = index; + rowElements[index].actual.focus(); + setStatus(`Entry ${index + 1}: ${draft.entries[index].label || 'untitled'} has an unrecorded actual. Enter an amount only when known; use 0 for a known zero.`); +}); $('name').addEventListener('input', () => { draft.title = $('name').value; validateDraft({ changed: true }); }); $('month').addEventListener('input', () => { draft.month = $('month').value; validateDraft({ changed: true }); }); $('rows').addEventListener('input', event => { --- a/budget-workbench/core.mjs +++ b/budget-workbench/core.mjs @@ -38,6 +38,20 @@ const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0')); if (!Number.isSafeInteger(cents) || cents > MAX_AMOUNT_CENTS) fail('Each amount must be between -999999999.99 and 999999999.99.'); return cents === 0 ? 0 : negative ? -cents : cents; +} + +/** Navigation only: blank actuals are not zero. Other invalid edits remain untouched. */ +export function findNextUnrecordedActual(entries, afterIndex = -1) { + if (!Array.isArray(entries) || entries.length > MAX_ROWS) fail(`Navigation requires at most ${MAX_ROWS} entries.`); + if (!Number.isInteger(afterIndex) || afterIndex < -1 || afterIndex >= entries.length) fail('Navigation start must be -1 or an existing entry index.'); + for (const entry of entries) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry) || typeof entry.actual !== 'string') fail('Navigation requires entries with actual amount text.'); + } + for (let offset = 1; offset <= entries.length; offset += 1) { + const index = (afterIndex + offset) % entries.length; + if (entries[index].actual === '') return index; + } + return -1; } export function formatAmount(cents) { --- a/budget-workbench/index.html +++ b/budget-workbench/index.html @@ -39,6 +39,8 @@

Amounts use a dot and up to two decimal places, for example 1200.50. No commas or currency symbols. Blank actual means unrecorded, not zero. Enter 0 for a known zero. Negative amounts are allowed for adjustments: a negative expense reduces expenses; a negative income reduces income.

Income and expense entries

0 of 200 entries

+
+

Jump to the next blank actual without changing any amount. Repeat to move through unrecorded entries, returning to the first after the last. A known zero is already recorded.

No entries yet. Add an income or expense below.

--- a/budget-workbench/page.html +++ b/budget-workbench/page.html @@ -39,6 +39,8 @@

Amounts use a dot and up to two decimal places, for example 1200.50. No commas or currency symbols. Blank actual means unrecorded, not zero. Enter 0 for a known zero. Negative amounts are allowed for adjustments: a negative expense reduces expenses; a negative income reduces income.

Income and expense entries

0 of 200 entries

+
+

Jump to the next blank actual without changing any amount. Repeat to move through unrecorded entries, returning to the first after the last. A known zero is already recorded.

No entries yet. Add an income or expense below.

--- /dev/null +++ b/budget-workbench/tests/navigation.test.mjs @@ -0,0 +1,53 @@ +// Independent contract tests written before reading candidate implementation. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { findNextUnrecordedActual as next } from '../core.mjs'; +const rows = (...values) => values.map(actual => ({actual})); + +test('empty and fully recorded rows have no missing actual', () => { + assert.equal(next([]), -1); + assert.equal(next(rows('0', '0.00', '-0', '-0.00', '12.34', 'junk', ' ')), -1); +}); +test('exact blank is selected, not zero or invalid nonblank', () => { + assert.equal(next(rows('0', '0.00', '-0', 'junk', ' ', '')), 5); + assert.equal(next(rows('', 'junk', '')), 0); +}); +test('search starts strictly after supplied index and wraps', () => { + const data = rows('', '0', '', '3', ''); + assert.deepEqual([-1,0,1,2,3,4].map(i => next(data,i)), [0,2,2,4,4,0]); + assert.equal(next(rows('0',''),1),1); + assert.equal(next(rows(''),0),0); +}); +test('unrelated malformed draft fields do not interfere', () => { + assert.equal(next([{label:'',planned:'bad',type:'oops',notes:null,actual:'0'},{label:42,planned:null,actual:''}]),1); +}); +test('frozen input remains untouched', () => { + const data = Object.freeze([Object.freeze({actual:'0', label:''}),Object.freeze({actual:'', notes:{keep:true}})]); + const before = JSON.stringify(data); + assert.equal(next(data,1),1); + assert.equal(JSON.stringify(data),before); +}); +test('200-row boundary is accepted', () => { + const data = Array.from({length:200},(_,i) => ({actual:i===199?'':'0'})); + assert.equal(next(data),199); + assert.equal(next(data,199),199); +}); +test('unsupported entries containers and row forms reject', () => { + for (const data of [null, undefined, {}, '', 0, {length:1,0:{actual:''}}, Array(201).fill({actual:''}), [null], [undefined], [1], [''], [[]], [{}], [{actual:0}], [{actual:null}], [{actual:false}], [{actual:undefined}], new Array(2)]) { + assert.throws(() => next(data), `must reject ${String(data)}`); + } +}); +test('invalid cursor inputs reject, including empty-array cursor zero', () => { + for (const cursor of [-2,1,2,0.5,NaN,Infinity,-Infinity,'0',null,true,{},[]]) assert.throws(() => next(rows(''),cursor)); + assert.throws(() => next([],0)); +}); +test('all small blank/recorded arrangements match independent circular-order oracle', () => { + for (let length=1;length<=8;length++) for(let mask=0;mask<2**length;mask++) { + const data=Array.from({length},(_,i)=>({actual:mask & (1<(cursor+1+offset)%length); + const expected=order.find(index=>data[index].actual==='') ?? -1; + assert.equal(next(data,cursor),expected,`${length}/${mask}/${cursor}`); + } + } +});