Katsu is a conjugation practice web app for Japanese verbs and adjectives.
The name is an abbreviation of the Japanese word 活用 (katsuyō), which means conjugation.
Conjugations can be practised for verbs, i-adjectives and na-adjectives.
The following forms can be practised:
- Formal or informal
- Past or non-past
- Positive or negative
- Te-form
- Volitional
- Tai-form
- Tari-form
- Potential
- Imperative / prohibitive
- Conditional
- Passive
- Causative
- Causative-passive
Use the app at katsu.arthurhoek.nl.
Katsu is growing a second trainer next to conjugation: kanji writing. The pattern for living together is one shell, sibling features:
- The shell (
src/app) owns everything both trainers need: routing, the navigation menu, the home page, the options page, i18n bootstrapping, theme, settings persistence, service-worker updates and analytics. The cross-cutting services live insrc/app/shared. - A trainer is a lazily-loaded feature: its own routes file, pages, services and storage namespace, and its own translations installed when the feature is entered. Nothing of it lands in the initial bundle - that is the one boundary worth keeping.
- Sharing is the point, not the exception: features use the shell's services and components freely, and the shell reaches into a feature where that reads best (the options page shows the kanji pad switches next to everything else). Lazy loading already keeps the bundles apart; the code does not need to be kept apart as well.
- Settings split by reach: app-wide options (theme, language) belong to the
shell's
SettingsService, so a change made anywhere holds everywhere. Options only one trainer understands keep their own service (the kanji pad annotations, the conjugation forms), and the shared options page shows them all, grouped by reach.
The kanji feature already has the target shape. Conjugation is still interwoven
with the shell (home, review and summary are its pages); the way to unify
is to extract it into a sibling feature - conjugation.routes.ts, its pages
moved out of the shell, SettingsService reduced to what is app-wide - and to
do that the next time conjugation needs real work, not as a rewrite for its own
sake.
- Angular 22 with standalone components
- Ionic 8 for the UI
- wanakana for kana input and conversion
- ngx-translate for i18n (English, Dutch)
- Installable as a PWA with offline support (Angular service worker)
Requires Node.js 24 or newer (nvm use picks the right version).
git clone https://github.com/ahoek/katsu.git
cd katsu
npm install
npm startThe app is served at http://localhost:4200/.
npm test # unit tests (vitest)
npm run lintnpm run buildThe production build is written to dist/browser.
ng build leaves one index.html for a router that decides everything in the
browser, which GitHub Pages cannot serve and Google cannot tell apart, so the
build follows it with tools/build-pages.mjs. That
writes a file per URL - each with its own title, description, canonical,
sharing card and structured data - the 404 fallback, and the sitemap. The list
of URLs lives in tools/site-pages.mjs, which is also
where the kanji deck turns into a page per character.
Pages end in a slash (/about/, not /about): that is the URL GitHub Pages
answers with the page rather than with a redirect to it.
Every push to master is built and deployed to GitHub Pages automatically by the
deploy workflow. The same workflow deploys the
sync service for the kanji writing feature - a Cloudflare Worker configured in
wrangler.toml - when the Cloudflare secrets are present. Its
one-time setup is in src/kanji/README.md.
Measured from ng build --stats-json on 2026-08-22, at 835 kanji: the initial
bundle is 1.08 MB raw against a 1.2 MB warning budget, of which main is
965 kB raw and 246 kB gzipped. Where that goes, by bytes as they land in
main:
| part | raw | share |
|---|---|---|
@ionic/core |
476 kB | 49% |
@angular/* (core, router, forms, common, platform-browser) |
285 kB | 30% |
@ionic/angular |
45 kB | 5% |
ionicons |
44 kB | 5% |
| the app's own | 39 kB | 4% |
localforage, rxjs, ngx-translate |
63 kB | 7% |
Two things worth knowing before hunting for savings. Half of the app's own
39 kB is not code but the two translation files - nl.json 9.9 kB and
en.json 9.4 kB, imported by the root component - so a string added in the
wrong place costs more than a component does. And no kanji data reaches main
at all: it holds no CJK character, so strokes.json and the verb dictionary
really do travel with their lazy routes.
Inside Ionic, the two form controls are the only components with real weight -
ion-input 41 kB and ion-select 40 kB - and roughly 240 kB is Stencil's own
runtime, animation and gesture machinery, pulled in by ion-app and
ion-router-outlet and not tree-shakeable. So the ranked levers, if this ever
matters: making the conjugation trainer lazy beats everything, then swapping
those two controls for native <input> and <select> (~80 kB), then leaving
Ionic entirely (~550 kB raw, and days of work).
69CF
Judge any of it by the
compressed number, since Cloudflare fronts the site.
Cloudflare fronts the site; GitHub Pages sends max-age=600 for everything it
serves. Cloudflare caches only the extensions it caches by default - .js,
.css, .txt, .svg - and rewrites those to a year through Browser Cache TTL.
Everything else passes through as cf-cache-status: DYNAMIC and keeps the ten
minutes.
That split happens to land correctly, which is why it is left alone: the year
goes to the content-hashed bundles, whose names change whenever their contents
do. Everything that changes in place and matters - the HTML, the sitemap,
ngsw.json, the i18n JSON, strokes.json - is uncached, so a deploy that grows
the kanji deck reaches visitors within ten minutes.
Three files get the year while keeping a stable name: robots.txt, the favicon,
and ngsw-worker.js (harmless - browsers cap a service worker script at 24
hours regardless, so update checks still happen). If one of those ever has to
change, purge that URL rather than waiting a year: zone arthurhoek.nl →
Caching → Configuration → Purge Cache → Custom Purge → URL. A stale
/robots.txt at the edge cost an afternoon of confusion on 2026-08-08 before an
unrelated deploy happened to refresh it.
One lever deliberately not pulled: HTML is not edge-cached at all, so every page request travels to GitHub. Caching it would cut time-to-first-byte, but it needs either a short edge TTL or a purge on every deploy, and the largest paint is dominated by the JavaScript bundle rather than by the document.
The word definitions were retrieved from the online dictionary Jisho.