Inspiration

I applied for a relocation programme with a Japanese company. Got through the exam, through the process, and they picked someone else.

What stayed with me wasn't the rejection — it was how much I'd had to piece together on my own just to be ready for a yes. What 技術・人文知識・国際業務 actually requires. When a residence card gets issued versus when you need one. That you have fourteen days to register your address after you land, and that the counter where you do it is mostly not going to be in English.

I had spreadsheets and twenty browser tabs. Every checklist app I tried didn't know what a 在留カード was. Every flashcard app taught me colours and animals, not the word for "health insurance certificate".

So I built the thing I'd needed.

The name is what you say on your way out the door: 行ってきます. I'm off, and I'll be back.

What it does

Pick your visa route and a target move date, and the checklist builds itself. Every task carries its own date, counted from the day you land — visa paperwork, housing, banking, the ward office run. It keeps going past arrival, because that's where the tasks nobody warns you about live.

Alongside it, spaced repetition over 355 cards: JLPT N5, N4, N3, and a 55-card Ward Office deck — the vocabulary of registering your address, enrolling in national health insurance, and opening a bank account. 国民健康保険. 住民票. 在留カード. You won't find these grouped this way in a textbook, because a textbook teaches a language and this teaches an afternoon at a city office.

Both kana charts are free and unmetered, with stroke-order diagrams for all 148 glyphs and a quiz you build by tapping characters straight off the chart — pick the romaji from four or type it, with progress kept on every character. Not moving to Japan? Onboarding offers a language-only path that skips the checklist entirely.

How I built it

Mobile: Expo / React Native, Expo Router, TypeScript, Tailwind via uniwind. Backend: Java 25, Spring Boot 4.1, PostgreSQL with Flyway owning the schema — ddl-auto: validate, so the app refuses to start if entities and migrations disagree.

Self-hosted on a 2 vCPU VPS in Prague, behind Traefik in Docker. Prague over Lisbon on measured latency: 188ms with ±3ms jitter from Manila, against Lisbon's 209ms on its one good host and 277–328ms on the rest.

RevenueCat for subscriptions, OneSignal for reminders, Firebase Auth for sign-in, Sentry for crashes.

Offline was the hard part. Ratings given on a plane are queued through React Query's setMutationDefaults, so a rating survives the app being killed and replays on relaunch. The review screen advances the card in onMutate, never onSuccess — offline that never fires, and the session would freeze on card one. A failed fetch is treated as ground truth over the platform's reachability flag, which lies about exactly the cases that matter: captive portals, hotel splash pages, plane wifi.

The daily reminder is server-side — a scheduled job every 15 minutes finds users whose local hour has come round and pushes by external ID. Not a dashboard campaign: that route needs one segment and one recurring campaign per hour offered, which had once cut the time picker down to four slots. The 15-minute cadence is for timezones that don't start on the hour: India at half past, Nepal and Chatham at quarter to.

Challenges I ran into

A webhook that silently did nothing. RevenueCat's TRANSFER event carries no app_user_id — the two IDs arrive in transferred_from and transferred_to arrays instead. My handler looked up null, found nobody, logged a warning and returned 200. So a subscription moved between accounts and the server never learned: RevenueCat said premium, my database said free, and every premium deck returned 402 while the app cheerfully showed them unlocked. The fix reads both arrays: premium comes off the old account and goes onto the new one, and the store's anonymous IDs are skipped.

A race I only found in production logs. Signing in fires several authenticated requests at once, and on a brand-new account all of them run findOrCreate concurrently. All read "no row yet". All insert. The losers die on the unique constraint — a 500 on the first screen after sign-in, on the one path every user takes. The fix is INSERT ... ON CONFLICT DO NOTHING; catching the exception can't work, because by the time Postgres raises it the transaction is already marked rollback-only.

A timezone bug aimed squarely at my own users. The reminder timezone was captured once, when you picked the hour. So a 20:00 reminder set in Manila kept firing at Manila's 20:00 after its owner landed in Tokyo. In an app whose entire premise is that you're about to change timezone, that isn't an edge case — it's the main path, and it broke at the moment the habit was worth most. Now the app compares the phone's timezone with the stored one on every launch, and quietly updates it when they differ.

And an App Store rejection for something I never wrote: guideline 2.5.4, background audio declared but never played. expo-audio's config plugin turns background playback on by default, and registering the plugin as a plain string accepted that default. Three explicit false flags fixed it.

Accomplishments that I'm proud of

Deleting the kana decks, and what replaced them. Hiragana and katakana first shipped as two SRS courses, one card per character, and I deleted both in a migration. A syllabary is a table you learn to read off, not a hundred independent facts — pushing あ six days out for being answered correctly is the algorithm fighting the material, and it put an exam in front of the first ten minutes of the app. It's a chart now: no request, works offline from a cold start, spends none of the free daily reviews. Version 1.1 gave it practice without bringing the schedule back. Pick characters off the 107-per-script chart — a cell, a whole あ row, a whole script — and quiz them, choosing from four romaji or typing it. Answers are compared by sound, not by character, because じゃ and ぢゃ are both "ja" and marking the right sound wrong would teach the wrong lesson. Progress is a count of right answers per character, kept on the phone for everyone and backed up for subscribers; the server keeps the larger of each number, so a replayed upload is harmless and two phones can't erase each other. Kana stayed free the whole way.

No punitive streak loss, anywhere. Clear a deck and SM-2 gives you six days of silence by design; breaking a streak for that is the algorithm punishing its own success. There is no red in the palette and no "wrong" — "Again" is a neutral scheduling signal. The quiz follows the same rule: green for right, amber for not yet, and a miss adds an attempt without taking anything away, so the chart only ever fills in. Characters you keep missing are listed under "Worth another look", never "Mistakes".

A paywall that states a limit it doesn't lift. New cards are paced at ten per deck per day for everyone, subscribers included. An earlier draft said "unlimited daily reviews", which people read as unlimited cards — and the first ten-card session after paying would then look like the app reneging. The bullet now names the cap that actually lifts, and the small print states the one that doesn't.

A notification that refuses to send itself. If you already studied today, or every card is scheduled ahead, the reminder is skipped entirely. "A few cards is all it takes to keep your streak", sent to someone whose streak is already safe, is how a push channel gets muted.

One mark, one generator. Every shipped icon — the iOS Icon Composer layer, the Android adaptive trio, the splash tile, the website's favicons and Open Graph image — renders from a single file of path data. I added that after finding the marketing site had been serving a different product's logo for months, byte-identical across every icon file on the site, because nothing in the code named them.

What I learned

The hardest problem was legal, not technical.

Stroke-order data almost universally means KanjiVG. It's CC BY-SA 3.0, and §4(a) forbids distributing the work with "effective technological measures" that restrict a recipient's exercise of the licence. An App Store binary is FairPlay-encrypted. That's the same clause that keeps GPL software off the App Store — and CC BY-SA 4.0 keeps an equivalent bar in §2(a)(5)(B), so relicensing forward doesn't cure it.

So the app ships two sources under two licences. Kana strokes are traced outlines from strokesvg (SIL OFL 1.1, no anti-TPM clause), pinned to a commit, with the notice shipped inside the app — the OFL asks for its terms to travel with the software, and a link would put a licence obligation behind a connection the kana chart doesn't otherwise need. Kanji stroke order is a different solution entirely: a BSD-3 font subset to the 575 Japanese characters in the decks, 957KB down from 16.3MB. The numbers are contours inside each glyph, so showing stroke order is a change of fontFamily — no path data to maintain, and no way for a number to drift off the stroke it labels.

I also learned to delete things that work. I built animated stroke order first — each stroke drawing itself in sequence. It demoed beautifully. あ took nearly three seconds, during which the thing you'd opened the sheet to look at was incomplete, with no way to scan back to stroke two or take it in at a glance. Every kana worksheet ever printed uses a numbered diagram, and there's a reason. Deleting the animation also removed a dependency, a build step, the sheet's page-tracking, and an entire class of rendering artefact.

What's next for Ittekimasu: Move to Japan

The other six visa routes. Working Holiday, Instructor, Student, Spouse of a Japanese National, Dependent and Digital Nomad are already in the picker, greyed out and honest about it. Each needs its task content written and dated — the hard part isn't the code, it's getting the deadlines right.

A native-speaker pass over every deck. The 355 cards are verified for structure — contiguous ordering, no word repeated across decks — but not against an official JLPT list. Spaced repetition makes a wrong reading stick, so this matters more than it would in any other kind of app.

A per-user day boundary. Everyone's day currently rolls over at midnight Asia/Tokyo. That's correct for the half of these users who've already landed and wrong for the half who haven't.

Reviews timestamped when they're answered, not when they sync. A session done on a flight currently counts toward the day it uploads. Fixing it properly means trusting a client-supplied timestamp, which needs thinking about before it's built.

Android, once the iOS release has told me what's actually wrong.

Built With

Share this project:

Updates

Submission history