Skip to content

fix(i18n): use singular customer key in transaction modal - #482

Open
sridharkalaibala wants to merge 784 commits into
developfrom
codex/refresh-english-i18n-map
Open

fix(i18n): use singular customer key in transaction modal#482
sridharkalaibala wants to merge 784 commits into
developfrom
codex/refresh-english-i18n-map

Conversation

@sridharkalaibala

Copy link
Copy Markdown
Contributor

Summary

  • replace the unrelated lang_Addexpensing key in the customer transaction modal with the existing singular lang_newcustomer_title key
  • regenerate the English translation map to remove the now-unused key

Validation

  • git diff --check

ode --test tests/i18n.test.js (45 tests)

The full local desktop suite starts successfully but this isolated worktree lacks generated frontend assets and optional jsdom/mongodb dependencies; GitHub CI provides that fully provisioned validation.

sridharkalaibala and others added 30 commits August 31, 2026 18:39
The guard called countExistingBranches unconditionally, so a repository that
does not implement it - an older one, or a test double - threw a TypeError
before the catch could apply, and every install failed. Nine tests said so and
I pushed before reading them.

An unanswerable question is treated as empty, deliberately: refusing then would
block genuine signups, which is a worse failure than the one being prevented.
Bumps the api-patch-and-minor group with 16 updates in the /api directory:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1111.0` | `3.1121.0` |
| [axios](https://github.com/axios/axios) | `1.19.0` | `1.20.0` |
| [html-to-text](https://github.com/html-to-text/node-html-to-text) | `10.0.0` | `10.0.1` |
| [joi](https://github.com/hapijs/joi) | `18.2.3` | `18.2.5` |
| [mongodb](https://github.com/mongodb/node-mongodb-native) | `7.5.0` | `7.6.0` |
| [mongoose](https://github.com/Automattic/mongoose) | `9.9.2` | `9.9.4` |
| [morgan](https://github.com/expressjs/morgan) | `1.11.0` | `1.12.0` |
| [multer](https://github.com/expressjs/multer) | `2.2.0` | `2.3.0` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `9.0.5` | `9.0.6` |
| [pdfkit](https://github.com/foliojs/pdfkit) | `0.19.1` | `0.20.1` |
| [unzipper](https://github.com/ZJONSSON/node-unzipper) | `0.10.14` | `0.12.5` |
| [uuid](https://github.com/uuidjs/uuid) | `14.0.1` | `14.0.2` |
| [@redocly/cli](https://github.com/Redocly/redocly-cli) | `2.46.1` | `2.49.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.2.0` | `26.4.0` |
| [eslint](https://github.com/eslint/eslint) | `10.8.1` | `10.9.1` |
| [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) | `30.4.2` | `30.5.0` |



Updates `@aws-sdk/client-s3` from 3.1111.0 to 3.1121.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1121.0/clients/client-s3)

Updates `axios` from 1.19.0 to 1.20.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](axios/axios@v1.19.0...v1.20.0)

Updates `html-to-text` from 10.0.0 to 10.0.1
- [Changelog](https://github.com/html-to-text/node-html-to-text/blob/master/CHANGELOG.md)
- [Commits](html-to-text/node-html-to-text@10.0.0...10.0.1)

Updates `joi` from 18.2.3 to 18.2.5
- [Commits](hapijs/joi@v18.2.3...v18.2.5)

Updates `mongodb` from 7.5.0 to 7.6.0
- [Release notes](https://github.com/mongodb/node-mongodb-native/releases)
- [Changelog](https://github.com/mongodb/node-mongodb-native/blob/main/HISTORY.md)
- [Commits](mongodb/node-mongodb-native@v7.5.0...v7.6.0)

Updates `mongoose` from 9.9.2 to 9.9.4
- [Release notes](https://github.com/Automattic/mongoose/releases)
- [Changelog](https://github.com/Automattic/mongoose/blob/master/CHANGELOG.md)
- [Commits](Automattic/mongoose@9.9.2...9.9.4)

Updates `morgan` from 1.11.0 to 1.12.0
- [Release notes](https://github.com/expressjs/morgan/releases)
- [Changelog](https://github.com/expressjs/morgan/blob/master/HISTORY.md)
- [Commits](expressjs/morgan@1.11.0...1.12.0)

Updates `multer` from 2.2.0 to 2.3.0
- [Release notes](https://github.com/expressjs/multer/releases)
- [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md)
- [Commits](expressjs/multer@v2.2.0...v2.3.0)

Updates `nodemailer` from 9.0.5 to 9.0.6
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](nodemailer/nodemailer@v9.0.5...v9.0.6)

Updates `pdfkit` from 0.19.1 to 0.20.1
- [Release notes](https://github.com/foliojs/pdfkit/releases)
- [Changelog](https://github.com/foliojs/pdfkit/blob/master/CHANGELOG.md)
- [Commits](foliojs/pdfkit@v0.19.1...v0.20.1)

Updates `unzipper` from 0.10.14 to 0.12.5
- [Release notes](https://github.com/ZJONSSON/node-unzipper/releases)
- [Commits](https://github.com/ZJONSSON/node-unzipper/commits)

Updates `uuid` from 14.0.1 to 14.0.2
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](uuidjs/uuid@v14.0.1...v14.0.2)

Updates `@redocly/cli` from 2.46.1 to 2.49.0
- [Release notes](https://github.com/Redocly/redocly-cli/releases)
- [Commits](https://github.com/Redocly/redocly-cli/compare/@redocly/cli@2.46.1...@redocly/cli@2.49.0)

Updates `@types/node` from 26.2.0 to 26.4.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 10.8.1 to 10.9.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](eslint/eslint@v10.8.1...v10.9.1)

Updates `jest` from 30.4.2 to 30.5.0
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.0/packages/jest)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1121.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: axios
  dependency-version: 1.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: html-to-text
  dependency-version: 10.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: api-patch-and-minor
- dependency-name: joi
  dependency-version: 18.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: api-patch-and-minor
- dependency-name: mongodb
  dependency-version: 7.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: mongoose
  dependency-version: 9.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: api-patch-and-minor
- dependency-name: morgan
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: multer
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: nodemailer
  dependency-version: 9.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: api-patch-and-minor
- dependency-name: pdfkit
  dependency-version: 0.20.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: unzipper
  dependency-version: 0.12.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: uuid
  dependency-version: 14.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: api-patch-and-minor
- dependency-name: "@redocly/cli"
  dependency-version: 2.49.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: "@types/node"
  dependency-version: 26.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: eslint
  dependency-version: 10.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
- dependency-name: jest
  dependency-version: 30.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: api-patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Nobody could answer "how much of the app is translated?". The build falls back
to the English already inside the <lang> tag when a key is missing, which is
right - it is why a half-translated language is usable rather than broken - but
it is also invisible. Tamil has been 88% translated for months and reads as
finished.

Three numbers, none of which were known before:

  the UI uses 648 DISTINCT keys, not the 1,408 <lang> tags - a key on six
  screens is one string to translate;

  Tamil answers 573 of them, so 75 screens' worth of words are showing English
  to somebody who asked for Tamil;

  and ta.json carries 313 entries nothing references - 35% of the file - so
  handing a translator "the whole file" asks for a third more work than the
  product has words in it.

An empty string counts as untranslated, or generating a skeleton would report a
language as finished.

--min gates a release without failing ordinary runs; --json is for CI;
--missing <lang> prints the list to hand to a translator.

Background and the path this serves: Intranet docs/MULTI_LANGUAGE_ARCHITECTURE.md
Owner: "okay start with tamil."

The app decided its language by comparing a page FILENAME - sixty-three copies
of

    PosnicPro.local.get('language_herf') === 'ta_dashboard.html'

across fifteen files, each a two-way branch with the Tamil written inline
beside the English. A third language did not fit anywhere in that, which is why
there has never been one. This is stage L1 of Intranet
docs/MULTI_LANGUAGE_ARCHITECTURE.md and changes no behaviour.

PosnicPro.i18n now holds a language CODE, derived from the old filename so
existing Tamil shops are migrated rather than quietly reset to English on
upgrade, and t(key, english) answers with the translation or the English it was
handed. That fallback is the whole safety property: before the pack loads, if
it 404s, if a key is missing or a language is half done, a real English word
ships - never "undefined", which the HTML build did for months.

63 branches became 82 t() calls and 2 branches. The two left are not text:
Tamil needs its own type sizes on the sale screen and dashboard.

WHAT THIS TURNED UP. sales.js held nine Tamil strings as mojibake - UTF-8 bytes
saved as cp1252 - so the most used screen in the product had been showing
Tamil shopkeepers "பà¯à®¤à®¿à®¯" where it meant "புதிய". Invisible in review,
unmistakable to the person using it. Moving the words to ta.json fixed them,
and the build now refuses a pack containing mojibake so it cannot return.

ta.json goes from 894 keys to 582 by dropping every entry nothing references -
a third of the file. The pack shrinks 71KB to 43KB, and a translator is now
asked for the 657 strings the product actually has rather than a third more.

The words are fetched at runtime from languages/<code>.json. English fetches
nothing at all, because it is in the markup and in every t() call.

Not retranslated, deliberately: two forms of "Edit" are kept because the sale
screen says திருத்த and every other screen says திருத்தப்பட்ட. Changing what a
shopkeeper reads is a decision for someone who speaks Tamil.

Tamil is 89% complete, and it always was - 75 keys have been falling back to
English silently. tests/tools/i18n-coverage.js now counts t() calls as well as
HTML, so that number is visible rather than discovered.
The build translated at build time, which meant writing a COMPLETE SECOND COPY
of every page for each language: 2.3MB of duplicated markup to deliver 43KB of
Tamil, and roughly 23MB by the tenth language before anyone had typed a word of
the eleventh.

Output is now 2.3MB whatever happens - one tree, and adding a language costs
the 43KB of its pack. That is the whole point of the exercise, and it is the
number the owner asked to hold down.

Pages keep their keys and their English:

    <lang class="lang_item_name">Item name</lang>

and PosnicPro.i18n.apply() swaps the words in at load. English is therefore not
"the default language" but the text physically in the file: the app renders
correctly with no dictionary, offline, on first run, and if every fetch fails.

TWO PLACES THE TAG CANNOT LIVE, and both were checked rather than assumed.
Inside <title> and <option> the parser builds no element for it, so a title
would have shown the customer literal angle brackets in their tab. There the
key is hoisted onto the parent as data-t. A survey of all 1,408 tags found
exactly 4 titles and 53 options and nothing in a textarea or an attribute.

1,351 places that held bare text now hold an inline element around it, so
lang { display: contents } removes its box entirely - spacing is unchanged on
screens nobody is going to re-check by hand. No CSS matched `lang` or any
`.lang_*` class before this, which was also checked rather than assumed.

Switching language no longer navigates. It used to load ta_dashboard.html and
lose whatever was on screen; there is one page now, so it fetches the words and
redraws in place.

The build also sweeps per-language pages an older build left behind. A stale
one still loads and still looks right, frozen at whatever the app said that
day, which is worse than a page that is simply missing.

Found while doing this: the earlier codemod only looked at modules/js, so
PosnicPro.js had quietly kept three filename comparisons and three Tamil words
of its own. The tests now scan the whole script tree, because a test scoped
more narrowly than the problem finds nothing and says so confidently.

Runtime behaviour is tested against a real DOM rather than by matching source
text - the change is about what the page looks like after load.
Pages ship in English and the words arrive by fetch, which is what made the
output stop multiplying - but it also means a Tamil shop that loses its
connection silently reverts to English, on a till, in front of a customer.

The worker now caches the pack on FIRST USE, with the same
stale-while-revalidate treatment the reference data already gets: answered
instantly from cache, refreshed behind the answer, swept by the versioned cache
name on release.

Cached on first use rather than precached, deliberately. Precaching every
language would put back exactly the weight this work removed - a shop should
carry the one language it chose, so the tenth language costs the other nine
shops nothing.

DELIVERY NEEDED NO CODE. The owner suggested keeping packs in S3 and
downloading on demand. The asset channel already ships frontend/public signed,
staged and auto-reverting, and the packs are written into that tree, so they
are already signed and delivered by construction - no second, weaker door. A
test pins that the bundler still walks the whole tree rather than a list that
could quietly forget them.

One of my own test expectations was wrong and is worth recording: I expected
/uploads/languages/x.json to match the pack pattern. It does not, and should
not - "x" is not a language code and that path is customer uploads. The
pattern was right; the expectation was not.
The last place a new language still meant editing code was the menu itself:
header.html carried one hand-written <a> per language. It is now built from
languages/index.json, which the build publishes from the language list, so
adding Hindi is languages/hi.json plus one line of config and nothing else.

One English entry stays in the markup deliberately. If the list cannot be read
the menu must still offer something - losing the OTHER languages is a bug,
leaving a shop with no way to choose at all is a worse one.

The click handler is delegated, because entries that did not exist when the
page loaded would otherwise be dead.

The list carries display names written in the language itself. Somebody looking
for Tamil is looking for "தமிழ்", not for "Tamil".

Proved rather than asserted: I added a language end to end - published, offered
in the menu, under its own name - and the only thing it dirtied was its own
JSON file. No HTML, no module JS, no build code. Then put it back and checked
the tree was as it started.

Also fixed while here: buildCss looped the languages and wrote the SAME path
each time, because the filename never carried the language. Every stylesheet
was rewritten once per language for no effect - harmless at two, and pointless
work that grows with every language added.

L1-L5 of Intranet docs/MULTI_LANGUAGE_ARCHITECTURE.md are now done. Output is
2.3MB whatever happens, and a language costs the 43KB of its pack.
--missing prints key names, which is the wrong thing to hand a person: nobody
can translate "lang_conversion_factor_title" without being told it says
"Conversion factor" and sits on the item screen.

--worksheet ta writes a JSON file with the English and the screen beside every
blank. --merge ta takes it back, sorted, without the duplicate keys and lost
ordering that hand-merging produces.

Blank entries are skipped rather than written. An empty string counts as
untranslated everywhere else in this system, so writing one would report the
language as finished while showing the customer nothing.

A merged value that looks like mojibake is refused outright, with the advice to
save as UTF-8. That is the exact failure that had been live on the sale screen
for who knows how long, and a worksheet round-tripped through the wrong editor
is precisely how it would come back.

The round trip was tested end to end - worksheet out, two strings filled in,
merged, coverage moved 582 to 584 - and then reverted.
A <lang> tag broken over three lines in the source gave back the newlines and
the indentation with it, so the worksheet showed

  "Your shop was set up with one
                trade's sample products. Pick a different"

which is hard to read and would have been translated with the whitespace baked
into the result. The sentence is what matters; the markup's shape is not.
--worksheet writes a file on its way to a person and back. Half-filled, it is
not the translation and not the language file; the finished words belong in
frontend/languages/<code>.json after --merge.
Signed-off-by: Sridhar Bala <sridharkalaibala@gmail.com>
Owner, who speaks Tamil, on the two forms I had deliberately kept apart:

    திருத்த -> For Edit
    திருத்தப்பட்ட -> Edted pastence

So every screen except the sale screen has been labelling its Edit button
"Edited" - a past participle on a control that has not done anything yet. The
sale screen was the only one that had it right, which is why the two forms
existed at all.

Both keys collapse onto திருத்த, and lang_action_edit_sale is gone: there is
one word for Edit now because there is one word for Edit.

This is exactly the class of mistake I said I would not make on my own. Moving
the words out of the code is a refactor; deciding what a shopkeeper reads is
not, and it took one line from someone who speaks the language to settle
something no amount of reading the source could.
Correcting "Edit" turned up the useful question: if a past participle sat on a
button for months, what else is wrong? I cannot answer that - I do not speak
Tamil - but two kinds of mistake are mechanical and do not need me to.

--review <lang> reports:

  the same English rendered two different ways, so the interface calls one
  thing by two names;

  the same translation used for two different English meanings, which means at
  least one of them is the wrong word.

Sixty-one on Tamil, and the first pass already shows real ones: "Item position"
is labelled with the words for "branch access", "Add fields" with the words for
"default user", and "Apply" shares its translation with "Search".

The searching is mechanical; the judgement stays with the person who speaks the
language. That is the split worth keeping - hundreds of strings narrowed to a
short list, rather than an opinion I am not qualified to have.

Also fixed here: --json stringified a Map and a Set, so `context` arrived as {}
and every consumer saw no English at all. It looked like it worked.
Owner: contributors are interested, and translations should be a first task for
newcomers - for every language, not just Tamil.

Language files move from frontend/languages/ to languages/ at the repository
root. They are a contribution surface before they are a build input: somebody
who speaks Kannada and has never opened this project should find them by
looking at the repository, not by knowing which build step reads them.

--new <code> starts a language that does not exist yet. The FIRST person to
offer Hindi previously had nothing to begin with: --worksheet needs a file to
compare against, so the tool could say how incomplete a language was but not
how to start one. --merge now creates the file, so a language begins existing
the moment somebody translates two strings.

Kept deliberately separate from --worksheet: a typo would otherwise generate a
worksheet for a language nobody speaks, and the first sign of it would be a
pull request adding languages/hj.json.

docs/TRANSLATING.md is the whole job on one page, and every command in it was
executed rather than written from memory. It says plainly that a half-finished
language is a real contribution, because that is the thing that stops people
starting.

check-translations.js and a CI job answer the three questions a translation PR
raises, in the time it takes to push. The encoding one is why this exists at
all: mojibake is invisible to a reviewer who does not read the script, and nine
Tamil strings reached the sale screen that way and stayed for months. Both
Latin-1 and cp1252 are caught - an earlier detector of mine tested only the
first and passed a visibly corrupt file.

Every failure names the fix, points at the guide, and says that an unclear
message is a bug in the check rather than in the contributor's work. Eleven
tests break a language file on purpose and confirm each is caught, because a
validator nobody has watched fail is indistinguishable from one that passes
everything.
Signed-off-by: Ansari Ibrahim <ansarifahad.7577@gmail.com>
npm run dev -> http://localhost:3000

npm start opens Electron, which is right for the product and wrong for a
contributor: you cannot hit refresh, and you cannot look at a translation in
place without building an installer. That last one mattered - P1 asked people
to translate an interface they had no way of seeing.

Nothing here is a second implementation, and that is the useful discovery.
api/app.js already serves the built frontend at /public on the API's own
origin, which is exactly how the desktop app loads it: main.js opens
`${origin}/public/dashboard.html`. So the pieces were all there and nobody had
arranged them - this only does what main.js does.

Four things, each of which failed a clean clone before:

  the API refuses to start without signing secrets, and rightly so - every
  Posnic once shared the same string. These are generated once into api/.env
  (gitignored) rather than per run, because regenerating them signs you out on
  every restart;

  the API serves files, not sources, so an unbuilt frontend is a 404 that
  explains nothing;

  mongod, started through the same manager main.js uses - it already handles
  Electron being absent;

  the API itself, on 3000.

Every failure names the fix. Somebody trying this for the first time should not
have to read the script to get past it.

Verified by running it, not by reading it: the login page, the Tamil pack and
the language manifest all answered 200, and the served HTML carries its <lang>
markers - so a translator can now switch language in the top bar and watch
their own words appear.
develop is now a real branch that contributor work lands on, so it needs the
same gate main has. Pull requests already ran it; pushes to develop did not,
which would have meant a merged change sitting untested until somebody opened
the next PR.
Owner: "main only me need to merge. develop also as of now only me."

Both branches are protected, and verified by reading the rules back rather than
trusting the API's 200:

  only sridharkalaibala may push or merge
  a pull request with one approval, and stale approvals dropped on new commits
  six CI checks, all of which run unconditionally - "Lint (reporting only)" is
    deliberately not one of them, because it reports and does not gate
  no force pushes, no branch deletion, review threads must be resolved

enforce_admins is off on purpose. With it on, the owner could not merge his own
release either, and the usual result is somebody turning the whole thing off in
a hurry. Contributors cannot merge regardless - nobody else has write access.

CI now runs on pushes to develop, not just main. Without that a merged change
would sit untested until the next pull request happened to run.

Seven labels for the state a change is in, named for the state rather than for
who should act: "ready for QA" says where the change is, where "needs tester"
would say what a volunteer owes, and volunteers owe nothing.

A merge to develop now labels its own pull request and comments where to try
it. Left to people to remember, that label gets set for a week and then never
again, and stops meaning anything. The workflow uses pull_request_target for
the write token and NEVER checks the pull request out, which is the thing that
makes that trigger dangerous.

Both templates and the quickstart now say to branch from develop, and the
quickstart explains what happens after a merge - that testing somebody else's
change needs no permissions at all.
Every push to develop goes to develop.posnic.io so anyone can try it without
write access, a checkout or a build.

THIS MACHINE RUNS UNREVIEWED CONTRIBUTOR CODE, and that one fact decides the
whole design:

  it is NOT registered in the estate console, because a registered instance can
  be handed a real customer's shop by the provisioner and nothing afterwards
  would undo that;

  it gets its own database and its own secrets, generated on the machine;

  it never receives the control-plane, S3, payment or mail credentials - not by
  guarding them, but by simply not sending them, so there is nothing on that
  box that can reach anything real.

The frontend builds in CI rather than on the box. A 2 GB machine running mongod
and the API should not also run a gulp build, and building in CI means a broken
build never reaches the sandbox at all.

The last step asks the live URL for a 200 and fails the run if it does not get
one. A deploy that reports success without checking is how a box sits broken
for a week with a green badge above it.

Off until DEVELOP_DEPLOY_ENABLED is set, because a workflow that fails on every
push teaches people to ignore red marks.

The machine itself needs AWS credentials this session does not have and should
not have. Intranet docs/DEVELOP_SANDBOX_SETUP.md is the runbook, including the
one thing that matters most: use a NEW key pair, because posnic-deploy opens
production.
Owner: "however we need sync but you decide on that."

The normal direction is develop -> main. main only moves ahead when something
skipped that path: a hotfix applied directly, or the merge commit a release
creates. Left alone, develop then silently lacks a fix that is already live,
and the next contributor branches from a tree missing it.

This opens a pull request instead of pushing. Both branches are restricted to
the owner on purpose, and a workflow that force-pushed past that would make the
protection a decoration - the rule would hold for people and not for automation,
which is the wrong way round. It notices; the owner still decides.

It merges rather than resets, because develop may carry work main has never
seen and a reset would delete it silently. On a conflict it leaves the branch
for a person rather than aborting, since aborting would leave develop quietly
behind with nothing to show for it. It does nothing at all when develop is
already current, which is the usual case.

Also, said plainly in the contributor docs: develop.posnic.io is a PUBLIC
sandbox running unreleased and untested code, holding demo data only, wiped,
and readable by anyone. Somebody will otherwise type a real customer into it.
Owner: "once develop merged then i will do release. from develop to main."

Promotion is a manual workflow that opens one reviewable develop -> main pull
request, listing the commit subjects rather than hashes - somebody deciding
whether to release wants to read what changed - and naming everyone whose work
is in it, so the release notes are not the first time they see their name.

Run by hand, never on a schedule. Deciding that develop has been tested enough
is a judgement, and a timer would only ever guess at it. The pull request says
plainly that merging it does not release anything: tagging main is what builds
the installers, and that stays a separate deliberate act.

docs/QA_PROCESS.md is written for somebody who wants to help and has no
permissions, because that is who will read it. It opens by saying testing needs
no write access and that reporting a break is as useful as fixing one, then
shows a good QA comment and a better one - the better example is the failure,
because a vague pass is worth less than a precise complaint.

What is worth checking is a prompt rather than a checklist: does it do what it
says, does the thing next to it still work, does it survive another language,
does it work offline, does it survive a reload. Those are where this
application actually breaks.

Contributor credit needed nothing: release.yml already generates notes from
merged pull requests, so names appear on their own.
The health check asked https://develop.posnic.io for a 200, which fails until a
DNS record exists that nobody has added yet - so a perfectly good deploy would
have gone red on somebody else's unfinished work. A job that reports on work it
does not own is one people learn to ignore.

It now checks the machine by IP, which is what the deploy actually changed, and
reports the public hostname as a notice rather than requiring it.
The box was built by hand over SSH. A machine that exists only as somebody's
shell history is one nobody can recreate after it breaks, so bootstrap, nginx
config and the reset script now live in the repository.

The banner is injected by nginx rather than built into the app. The application
has no business carrying a warning it must never show a real shop - a flag for
that is one bad default away from appearing on a customer's till - and this
config exists only on the sandbox, so there is no path by which it reaches
production. It sits at the bottom: the top of those screens is a working till,
and covering it is how a banner becomes something people ignore.

The nightly reset had to exist because the banner promises it. A promise the
software does not keep is worse than no promise; somebody decides the box is
safe for real data.

--capture is the part that matters. Without a seed every tester walks the setup
wizard before testing anything, and most will not bother. It refuses a dump
under 2KB, because seeding from an empty database would wipe the sandbox every
night while appearing to work - which was tested by trying it.

Also on the box, verified after installing rather than assumed: robots.txt
disallows everything, X-Robots-Tag says noindex, and nginx rate limits. A
sandbox in search results is worse than no sandbox.

One mistake worth recording: the first attempt patched the nginx config with
sed and left duplicate directives. nginx refused it and kept serving the
previous config, so the site never went down - which is the behaviour to rely
on, not the edit to repeat.
sridharkalaibala and others added 18 commits September 4, 2026 11:22
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge part d of the Swahili worksheet: 426 keys covering the register
session, receiving, quotes, roles and permissions, loyalty tiers, variants
and the roster. Vocabulary follows the existing pack - sanduku la fedha for
the till session, rejista for the terminal, ankara, nukuu ya bei, akiba.

Brand names keep their own spelling rather than being transliterated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge the remaining German and Thai worksheets. Both packs now answer every
key the interface asks for.

Two labels the load-time revert had left in English for good: the ITEM /
CUSTOMER / ACTION badge on a command-palette result, and the Movement
dropdown's own label on Stock Activity once a movement is picked. Both sit
in a top-level object literal, where a t() call runs before any pack exists,
so they take the same shape as the report titles - the key travels with the
English in the config, and the render, which runs long after the packs land,
does the asking.

The coverage scanner now reads a key as a word. It matched anything between
the quotes of <lang class="...">, so a render site building that class from
a variable was harvested as a key literally named "' + kindKey + '". Four
such phantoms were already sitting in the English map; they are gone, and
the pattern can no longer mint another.

Also mark .husky/pre-commit executable. It has a shebang and the suite
checks for that, so the hook was failing CI for everyone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge the last Swahili worksheet.

Three chips on the campaign editor insert a merge field into the message
template, and what they show is exactly what they insert. They were wrapped
in <lang>, so they went out to every translator and came back as
"{points} puntos", "Daraja {tier}", "{name} (jina)" - a chip that no longer
says what it does, in fifteen languages. The tags come off, the keys go, and
both sweep tools now read a lone {token} as data rather than prose, so the
next pass will not tag them again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge the Italian, Dutch and Sinhala worksheets. Italian and Dutch now
answer every key the interface asks for; Sinhala reaches 84%.

Six translators in a row reported the same fight and each lost it
differently: "Provider Way2sms", "TextLocal", "Msimbo wa Pharmacode",
"Stile Turbo C", a Razorpay tab prefixed with the Arabic for gateway. None
of them was careless. The validator rejects a value identical to its
English, which is right - that is what a skipped row looks like - but it had
no exemption for a proper noun, so changing the name was the only way to
pass. A payment tab that no longer names the payment provider.

languages/_glossary.json now carries the names, every pack spells them the
same way, and a test holds the line. Descriptive names are deliberately not
on that list: Soft Dark and Diamond are words, and a theme picker in Thai
should read in Thai. The two brands whose English was misspelled take the
casing their companies use.

The user permission grid says Read/Write and Read/Delete. It said R/Write,
the R abbreviating Read, and four translators read that as Receiving or
Return - the three packs that guessed wrong now match their own word for
Read. The table sits in .table-responsive; the two saved characters were
never worth the ambiguity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge part f for Arabic, Spanish, French, Hindi and Indonesian, and fill
Tamil's last four keys.

Three panels a shopkeeper reads were built by an inline <script> inside a
page, which is a seam neither sweep covered: the HTML tagger strips <script>
before it looks, and the JS tagger only walks static/script/js. The desktop
card under the sign-in form, the boot watchdog shown when the till will not
load, and the desktop tools button were all English in every language. They
carry keys now, and the gaps report reads what an inline script builds
rather than throwing it away.

With that, and with an onclick handler no longer misread as prose - it holds
a > that the text scan took for the end of a tag - the count of English no
pack can reach is zero. The guard says zero instead of "at most two".

tests/tools/i18n-screen.js answers the question the owner actually asked.
Coverage says how much of a language is done; this says which SCREEN is
still English, worst first, so an hour spent on a language can be spent
where it shows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
E-invoice validation module, GST return gap analysis, and the government registration routes
A <lang> tag says two things at once: which words to show in English, and
which key every other language is looked up by. Give two different labels the
same key and English stays right on both screens while every other language
is wrong on one of them - invisibly, because the person who can see it is not
the person reviewing the diff.

The branch form was the worst of it. City carried the key for "Supplier
list", State the key for "Customers", Country the key for "Address". Read in
English the form is correct. Nineteen columns labelled Tax carried the key
for "Denomination Field", so a French till read "Champ de coupures" over its
tax column and a Spanish one "Campo de denominación".

Seventy-eight keys carried more than one meaning. languages/_english.json
decides which meaning keeps the key, because that file is what every
translator was shown, so it is what the packs already say - a majority vote
would have been wrong thirty-one times, Tax among them. A hundred and
forty-nine labels moved to a key that already means what they say and needed
no new words; sixty-three took a new key, and those are translated here.
Punctuation counts: lang_name means "Name:" and lang_name_title means "Name".

Sixty-three tooltips were invisible to the tooling. The coverage scan looked
for a whole tag and then the data-t-title inside it, but a module builds its
buttons from three concatenated strings, so there is no closing bracket to
find. Edit this branch, Delete this category, Print bill, Show on kiosk -
none of them was in any pack, no worksheet ever offered them, and coverage
said a hundred per cent. The scan anchors on the key now.

Three tests hold the ground: no key carries two meanings, every key written
into a page is one the sweep can see, and the ones already there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It reported the same seventy-five sites on every run and offered to fix them
by wrapping each one in t(). All seventy-five were already correct. They are
config data written at the top of a module, where a t() call runs before any
pack exists - the failure that took the dashboard down - so they carry the
key beside the English instead and the render site does the asking.

A tool that always cries wolf teaches people to ignore it, and running
--write on this one reintroduces the outage. It knows that shape now, so its
count means something, and a test holds it at zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifty-three glossary rows were blank - Sinhala missing forty of them, Tamil
thirty. The words were already in those packs, under a key whose English is
the term itself, so the rows are filled from the packs rather than translated
again.

Twenty-seven Tamil rows are still blank on purpose. Tamil renders the same
English several ways - Add is three different words across three screens -
which is the inconsistency the glossary exists to record, and picking one by
frequency would settle it wrongly as often as rightly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Sridhar Bala <sridharkalaibala@gmail.com>
The sixty-one action tooltips are translated in all seventeen languages, and
with the last report titles filled every pack answers every key the interface
asks for. Two thousand nine hundred and twenty-five of them.

That leaves one source of English a shopkeeper still reads every day: the
toast. Every save, delete and refusal comes back from the API as a sentence -
"Customer added successfully" - and the frontend prints it as it arrives, at
five hundred and seventy-six call sites. No tag and no key can reach it.

The API cannot carry the key itself. It ships separately, so a till on a new
build talking to a server one release behind would be handed a key it has
never seen and would print it raw. The English is the identity instead, which
is what gettext has done for thirty years: PosnicPro.i18n.say() looks the
sentence up by its own words, ignoring case and runs of space, and hands back
exactly what it was given when it does not know it. A missing file, a missing
entry and an unrecognised sentence all read as they do today.

The words live in languages/server/<code>.json and the build ships them as
msg-<code>.json beside the pack. Seven hundred and forty-five sentences are
listed and none is translated yet, so nothing changes on screen until they
are - which is the point: this can land now and improve one language at a
time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oasts

The last eleven keys are filled - ten report titles and the alignment button
that six packs had left behind - so all seventeen languages now answer every
one of the 2,925 keys the interface asks for.

The first half of what the server says is translated into French: 373 of 745
sentences, shipped as msg-fr.json beside the pack. A till set to French now
reads "Client ajouté" where it read "Customer added successfully". The other
half, and the other sixteen languages, fall back to the English exactly as
before, which is what makes this safe to land in pieces.

docs/TRANSLATING.md explains why the English is the key and warns about the
one way to get it wrong: retyping the sentence instead of copying it, which
fails silently because the fallback is that same English.
The service worker caches the shop's language pack on first use, so a Tamil
till that goes offline keeps its screens in Tamil. The sentences the server
sends now live in a second file beside it, and the pattern did not match it -
so that same till would have kept every screen in Tamil and answered every
save in English.

msg-<code>.json rides along, on the same stale-while-revalidate terms and
swept by the same versioned cache name on release.
A contributor's pull request is checked by a workflow that asks three
questions a maintainer would: is it still valid JSON, did the encoding
survive, did anything outside the language files change. It did not look
inside languages/server/, where the sentences the API sends are answered.

It does now, and it asks a fourth question that only matters there: is this
English still a sentence the server sends. The key IS the English, so a
retyped one fails silently - the fallback is that same English, and the
translation is simply never found.
All 745 sentences the API sends are translated into French, so a French till
now reads its own language on every save, refusal and import, not only on the
screens. Arabic, German, Hindi and Spanish answer the first 373.

The rest still fall back to the English the server sent, which is what it
does today, so each language improves on its own without waiting for the
others.
A language is now two things: the screens, which every pack answers in full,
and the sentences the server sends, which only French answers in full. The
table said "all" and meant the first, which reads as finished when a third of
what a cashier sees in a day is still English.

Also corrects the seeding note, which still quoted 662 keys. It is 2,925.
A 401 sends the cashier back to the sign-in page carrying the reason, which
is shown as a toast - so it now goes through the same lookup as every other
sentence the server sends. The fallback used when the server gave no reason
was a bare English literal with no key; it has one.
Three languages now read their own words on every sentence the server sends,
and three more answer the first half. The rest still fall back to the English
the server sent.
Five languages now read their own words on every sentence the server sends.
Four tools spawned i18n-coverage.js --json and parsed six hundred kilobytes
of JSON out of a pipe. It truncates. Every local run was fine and CI died
mid-string at position 182714, which is exactly the shape of a bug that only
appears where it costs the most.

The module already returns that data, so they call it.
Sign up for free to 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.

6 participants