Case study · 2026

HakaiTask — a to-do list that reads Indonesian, with no model behind it

A task manager where one typed Indonesian sentence becomes a scheduled task — read by a dictionary of about 1,900 entries you can open and edit, not by a language model, and written locally first so nothing ever waits on a network.

When in doubt, read. A wrong read costs you a list you did not want; a wrong write costs you data you have to clean up.

HakaiTask is a task manager for people who think in Indonesian. You type one sentence — jadwalin rapat sama klien rabu jam 3 — and it becomes a task called Rapat sama klien, Wednesday at 15:00. That sounds unremarkable until you try the same sentence in an app you already use. Natural-language parsing in to-do apps is built for English: next tuesday 3pm lands cleanly, while lusa jam 3an turns into a task titled after the entire sentence, with no date attached at all.

The reason I wanted that is smaller than it looks. Recording a task the usual way is a button, a title field, a date picker, a month scroll, a time picker, save — eight taps and two hands, at the exact moment you are walking out of a room or sitting in an angkot. The cost of writing it down ends up higher than the risk of forgetting it, so you forget it. Everything else in this app follows from trying to get that cost under five seconds.

There is no model in it

This is a decision, not a limitation. The sentence is read by a lexicon and a parser that run on the device in under five milliseconds, with no network call anywhere in the path. An LLM would have cost one to three seconds per line, would have been dead the moment the connection dropped, would have sent every task — client names, medical appointments, family things — to somebody else's server, and would have billed per call forever.

The part that decided it, though, is what happens when the reading is wrong. With a dictionary, a wrong reading is one line of JSON: add it, and that mistake is gone permanently, on every device. With a model, you report it and wait. And the failure modes are not equally kind. A sentence outside the dictionary comes in as a plain title with no date — visibly incomplete, so you notice and fix it on the spot. A model guesses a date confidently and is silently wrong, and you find out by being late.

The dictionary is the product

It holds roughly 1,900 entries: formal synonyms, the way people actually talk, abbreviations like bsk, tggl, jm, and the typos that show up often enough to matter — pagii, kmrin, malem. So tolong catat fisioterapi bsk pagii comes through whole: title, date, and time. It covers relative dates, weekday names, ranges, exact dates, clock times including setengah 3 and jam 3 lewat 15, parts of the day, approximations, durations, and recurrence. Ambiguity is resolved by fixed rules rather than by guessing: jam 3 is 15:00, jam 9 is 09:00, and an explicit pagi or malam wins over both.

For anyone who prefers speed to prose there are tagged tokens in the same line — !p1 for priority, #konten for a label, @skripsi for a project, ~berat for energy, *30m for a reminder, +riset for a subtask, // for a note, and quotes to stop a fragment being read as a time. So revisi vlog besok jam 2 !p1 #konten 90m arrives complete in one Enter.

A dictionary that size rots quietly, so there is an audit that checks every entry actually changes an outcome. It is not a formality: penting banget had been sitting there as a dead entry, because banget is stripped at an earlier stage and the phrase could therefore never match anything. Nobody would have found that by reading the file.

When in doubt, read

Early on, ok terimakasih became a task titled Terimakasih. Creating a task had been wired up as the default for any sentence with words left over — which meant being polite to the app wrote data. Now creation only happens automatically when there is a cue: a command word like tambahin or jadwalin, or a time like besok jam 9. Anything else gets asked first.

That produced the rule the rest of the chat engine is built on. A wrong read costs you a list you did not want; a wrong write costs you data you have to go clean up. So when the sentence is ambiguous the system does not pick — ubah meeting gw besok, with two meetings tomorrow, gets answered with both and a question. When the sentence is incomplete it keeps the intent and asks for the missing piece instead of rejecting the whole line. Every destructive command confirms with the full list of what it is about to hit, and batal brings back what was just deleted.

Teaching it your own words

Nobody says Discrete Mathematics — Lecture. They say dismath, responsi, clientan, kondangan, ngelab. So you teach it: kalau gw bilang clientan, maksudnya meeting client. From then on clientan works in every command.

Mechanically this is a text macro, not machine learning. It runs before the parser and never teaches the parser anything new — it rewrites your sentence into one the parser already understands, expands exactly once so cycles are impossible, and stays as a single readable row: clientan to meeting client. Built-in command words and every time word are locked and cannot be shadowed, because being unable to say batal would trap you inside your own conversation, and a redefined besok is the most expensive kind of wrong.

Offline is the write path, not a fallback

Ticking a task off never waits for the network, because the network is not in the write path. Every change is written to the local store first and queued in an outbox that drains on its own once there is a connection. Conflicts resolve last-write-wins per field rather than per row, so editing a title on the phone and a time on the laptop keeps both. Deletes are tombstones, so something deleted on the phone does not come back to life out of the laptop's cache.

Two failures shaped that layer. Mutations that failed ten times went to a dead letter queue with no way back, so the needs-checking badge stuck permanently even after the cause was fixed — that badge is now a button that retries the queue. And every send to the server was failing with null value in column title, found in the Supabase logs rather than by guessing: an update mutation sent only the changed fields, so if the original row had never reached the server the upsert tried to insert a new row without its required columns, and failed forever. It sends a full snapshot now.

One kind of thing, not two

Words like rapat and zoom used to create a schedule entry, a type separate from a task. Every time, the answer was literally correct and experientially wrong: you make something with the word rapat, then tampilin task does not find it. It was patched once with a hint — but there is 1 schedule, did you mean that? — which is treating the symptom. In the end the two types were merged and the whole class of failure became impossible. Task, jadwal, and agenda now all search the same thing, so you never have to remember which word you happened to use when you created something.

How it is put together

It is a pnpm and Turborepo monorepo. apps/web is React 18 on Vite with Tailwind v4; packages/core is plain TypeScript holding the parser and its lexicon, the chat engine, the Zustand store and outbox, and the sync layer. core is forbidden from importing React, React Native, or the DOM — that rule is what would let the logic move to a mobile app later without being rewritten.

The chat engine is pure. chatTurn() touches no database, makes no network call, and does not even generate an id; it reads state and returns a description of the change, which the application layer then performs. So the entire conversational behaviour is testable without React, a browser, or a network, and every write goes through one door, which means no path can slip past the offline-first guarantee. There are 312 tests, including a suite that runs every example printed in the README — which is how ubah task laporan jadi jam 9 was caught silently searching for a task named laporan jadi jam 9.

What it does not do

Reminders do not ring yet. The *30m token is parsed and stored, but nothing sounds it — which is the single most common reason to use a to-do app, so it is worth saying plainly. There is no collaboration and no sharing; no native app, and not yet an installable PWA; no Google Calendar, email, or Slack integration, and that last one is deliberate, to keep one source of truth. A settings page is still missing, so the vocabulary is managed through chat. Time blocking, a focus timer, and weekly review are designed but not built.

It was built to be used, not shown. The nice thing about a parser made of a dictionary is that when it misreads something, the fix is usually one more line in a file I can open.