--- /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 @@
Change the currency label?
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.
+ +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.
+ +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}`); + } + } +});