Case study
Taskr
Splits a household's chores by the time each person has.
- Role
- Sole developer of the rebuild: the charter, the design, the code, and the migrations applied to the live project.
- Stack
- React as an installable web app, Supabase Postgres with row-level security and column grants, Vercel, and an allocation module in plain JavaScript with no React in it.
- Timeframe
- A five-person classroom project in 2020, rebuilt from a charter since August 2026. Still in progress.
- Links
- The app · How to start testing it · Source on GitHub
The problem
Households divide chores by habit, by nagging, or by an even split that is not even. Give a parent with 300 free minutes a week and a child with 60 the same number of chores, and the child’s week is gone while the parent’s has barely started. A count is the wrong unit. So is a total of minutes: what has to match is how much of each person’s own week the work takes.
Taskr began in 2020 as a five-person classroom project with that idea
at its centre and a view with one progress bar per person. It ran as
desktop pages against localhost, over a database a startup
script regenerated, so it had no deployment, no persistence and no
phone. The rebuild’s charter kept the idea and set the bar at
phone-usable for a real household, which the old architecture failed by
its structure rather than by degree. So it was rebuilt, not
refactored.
Fairness by time has a sharp edge, too. When somebody’s week is close to the size of one chore, no arrangement of the work is fair, and an app that divides it anyway has to decide what to tell the household.
What I built
An installable web app for a household’s phones. Each person is a budget of minutes for the week and each chore a number of expected minutes with a due date, and the app opens on the split: every person’s load drawn as a share of their own capacity. A parent at 150 of 300 minutes and a child at 30 of 60 are level, and the bars show it without a number being read. Members stay in roster order and are never sorted by load, because ranking people by output is the exact inversion of what the app is for.
The allocator is a pure module with no React and no database in it. It is handed each person’s minutes for the week rather than reading them, so capacity is a fact about a week and not about a person, and an override of zero minutes wins over the baseline instead of being mistaken for a missing value. It is judged against a corpus of thirteen household shapes whose expected outcomes were worked out by hand before the module ran against any of them, because expectations generated by the code under test only prove the code still does what it did.
Its answer is written, not only drawn. When somebody’s week changes, the household’s open chores re-assign with nobody pressing anything, and a chore a person placed by hand stays where they put it. At setup, when nobody holds anything yet, one control, Deal these out, hands out the unheld work. There is no re-balance button: the charter ruled one out, because a button someone has to press is the negotiation moved rather than removed.
Re-running an allocator from scratch moves most of the list, which is fair and infuriating. Over the corpus with each household’s largest capacity halved, a re-balance with no stability rule moved 25 of 43 held jobs and 1,240 minutes. Preferring whoever already holds a job when two placements tie, plus a budget of 120 minutes of movement, brings that to 11 jobs and 355 minutes.
The rest is where trust has to live. Row-level security scopes every row to its household, and column grants decide which columns a client may read or write, since a row policy says nothing about columns. The moment a chore is done is stamped by the database’s clock, so a phone set to the wrong date cannot move work between weeks. The build refuses a key shaped like a secret in any variable that gets inlined into the client, a check added after one reached a preview bundle in August. Every migration is replayed in CI against an in-process Postgres. The live suite that tested the policies over the wire, as a client would, ran until September, when retiring the organizer’s password mint broke its fixture; rebuilding it is Taskr #436.
Shipping is two acts in an order somebody chooses. Production builds from a release branch that only a pull request I merge can enter, and a migration is applied to the live database before the client that needs it is promoted. The order exists because it once ran the other way: in August a merge was a deploy, client code went live ahead of two migrations nobody had applied, and for a day the live app could not hold a household at all.
Where two phones can race, the race is run rather than reasoned about. Two clients finishing the same shopping run at the same instant, ten repetitions a run against the live project, produced one winner and one refusal forty times out of forty.
A decision I’d defend
When a fair split is out of reach, the split says so and says why. It never rounds until the bars look level.
Level has a number. The split counts as level when the highest and lowest shares of their own capacity are at most 10 percentage points apart. Outside that, the verdict above the bars says how many minutes off level the household is and who is carrying the extra. When no arrangement can reach level, it says that instead, and names the person and the two figures that make it arithmetic: their fair share and the smallest job on the list.
The case that forced it is small. A household with 25, 100 and 150 minutes this week and jobs of 40, 35, 30, 30, 25, 20 and 10 minutes sits near 69% of its capacity, so the person with 25 minutes should carry about 17. The smallest job is 10. They land on 40% or 80%, and no arrangement puts them near 69%. The prototype hit exactly this before the rebuild began: a greedy allocator was ragged in 3 of 5 scenarios, and a local-search pass fixed two of them and could not fix this one.
So the honest answer had to be the product’s answer. An allocator that reports level over a visibly ragged set spends the one number the whole app rests on, and the households it would mislead are the ones it was built for: a child’s week, or somebody’s short one, is where a budget comes close to the size of a single job. Naming the reason is what makes the verdict read as the fairness claim holding up rather than as the app failing.
What it rejected, and why:
- A tolerance of 15 points or more. The prototype measured a split with a 15% spread and called it ragged. A tolerance at or above that calls a measured-ragged split level, which is the one failure the verdict exists to prevent.
- A tighter tolerance. Ordinary indivisibility would trip it on healthy households, and a notice that fires on a healthy household is an absent notice: it teaches everyone to ignore the real one.
- Reporting level whenever the allocator has done its best. The bars would contradict the sentence above them, and the first household to notice would stop believing either.
- Moving a chore somebody placed by hand to reach level. A person overrode the model on purpose. The allocator leaves the chore where it is and says the week cannot be level, rather than undoing them to make its own number look better.
- Counting chores instead of minutes. Equal counts are not equal shares. The corpus’s first shape is a child and a parent at exactly level with one chore against five, and the prototype’s first narration, which counted ten chores moved while each person’s count changed by one, was true and read as broken.
The cost is stated rather than hidden. Level is a band, so two people up to 10 points apart both read as level. The allocator is greedy, so when it places a job it cannot see the final spread. In the stability corpus that cost one shape its levelness once ties went to the incumbent, and it makes the movement budget non-monotone. A budget of 0 minutes reaches level in 2 of the 10 contested shapes, 30 and 60 minutes in 1, and 120 in 2 again. And the split counts only time spent doing the work. Noticing, planning and remembering it are work the app does not count, and the split screen says so.
Outcome
Open for testing at taskr.madcowhq.com, where anybody can start a household. As recorded in the repo on 2026-08-08 and read from it on 2026-09-14, the allocator reaches level in 10 of its 13 corpus shapes (76.9%), and in 7 of the 10 (70.0%) where level is a real question. Both figures are given because three shapes have fewer than two people with any minutes, and a set that small is level by arithmetic rather than by anything the allocator did.
The 2020 code is kept at a tag, and none of it is on the branch that ships. The charter’s test for every proposal in the rebuild was whether a screenshot of the old one-bar-per-person view could satisfy it; if one could, the proposal had failed.
What is not proven yet is the part only a real household can settle: whether people accept a split nobody negotiated, week after week. That has its own epic in the tracker, and until it is read this page makes no claim about it. Still in progress.
Households wanting to try Taskr rather than read about it are better served by the tester page, which walks through the first steps.