MaksForge

Binky

Android app

An Android app for keeping track of a bunny's health.

Live

Symptoms are a seeded table, not an enum

Droppings, mood and care type are closed vocabularies and stay Kotlin enums. Symptoms are not: bunnies develop quirks no built-in list anticipates, and the ones an owner bothers to write down are usually the recurring ones. A symptom that can only be typed into a note can never answer “how often has this happened?”, which is the reason symptoms are a field at all rather than prose.

Symptoms therefore live in a table, seeded on first run with the built-in list — head tilt, drooling or wet chin, sneezing or nasal discharge, eye discharge, dirty bottom, loud teeth grinding, hunched posture, laboured breathing, not drinking, limping, ear scratching, blood in urine, hiding more than usual — with owner-added rows alongside, indistinguishable in use.

Loud teeth grinding is named deliberately: soft tooth purring means a content bunny and loud grinding means pain, so an unqualified “teeth grinding” would invert the meaning.

Consequences

Built-in symptoms carry a stable key, not an English display string, and are rendered through strings.xml so they translate like all other UI text (ADR-0013). Owner-added symptoms store their literal text; those are untranslatable, as expected. There is no ownerCreated flag — this ADR originally called for one, and it is dropped: key == null already says it, since a built-in has a key and no stored label while an owner row has a label and no key, and a second column could only drift out of step with the first. Both label columns do earn their place, for opposite reasons — built-in labels must not be stored, so they stay translatable, and owner labels must be. An observation references a symptom’s stable id, and the “how often has this happened?” count keys off that id — never the display text — so a symptom can be renamed or translated without orphaning its history. (The English strings the seed list uses above are the labels behind those keys, not the stored identity.)

Removing a symptom hides it from the picker and never deletes it from historical observations.

A symptom attaches to an observation as a binary tick — present at that noticed moment — and nothing more. Severity is carried by the symptom’s identity, not a field: “loud teeth grinding” is already a distinct symptom from soft tooth-purring, so grading is chosen by picking the right symptom. There is no duration or “resolved” state: an observation is a snapshot (“noticed at a specific moment”, CONTEXT.md), and a persistent symptom is expressed by re-ticking it on later observations — each tick honestly “one time I noticed it”, which is exactly what the count claims to be. The accepted limitation is that a chronic symptom logged once undercounts its persistence; ADR-0007 leaves room to add a stateful model later if that ever proves necessary.

The free-text note on an observation remains, for one-off detail that is not a symptom.

Seeding, and keeping the list free of duplicates

The built-in set is seeded on create and reconciled on open with an INSERT OR IGNORE keyed on key, so the list in code stays identical to the list in the database once wipes stop being free after Phase 3. That requires a unique index on key to have any conflict to ignore — without it the reconciliation inserts the entire built-in list on every launch. The index is safe on a nullable column because SQLite treats NULLs as distinct: it enforces uniqueness across built-ins while permitting unlimited owner rows. Matching on key leaves a hidden symptom’s hiddenAt untouched; built-ins are retired by hiding, never by deleting.

Nothing in the schema can stop an owner adding a symptom whose label duplicates a built-in’s, because built-in labels are deliberately not stored. So the check is made once, in the application, at add time: trim, compare case-insensitively against existing owner labels and the built-in labels as currently resolved, and on a match select the existing symptom rather than creating a row. A match on a hidden symptom unhides it — an owner typing in a symptom they previously retired is asking for it back, and the alternative is a duplicate shadow of a symptom that already exists. No index on label: it would need a collation Room’s @Index cannot express and still could not catch the built-in case, and two mechanisms where only one covers the hard case is worse than one that covers both. Accepted limitation: the check reads labels resolved in the current locale, so a later language switch can surface a duplicate-looking pair.

Zero ticks is ambiguous, and one column resolves it

An observation with no symptom links means either “I looked, nothing wrong” or “I never opened the picker” — indistinguishable, which is ADR-0001’s silence failure applied to the one field this ADR exists to make queryable. It also makes the one-tap healthy day’s central claim unrepresentable, since that shortcut records no symptoms affirmatively.

Observation.symptomsChecked is therefore a non-nullable boolean. Any link implies true, enforced in the repository rather than merely expected; the healthy day sets it true with no links; the full form sets it from an explicit “none seen” tick, mutually exclusive with having selections. Non-nullable deliberately — a Boolean? would make null and false two spellings of “didn’t look”, which is precisely why the graded vocabularies use null for absence and this does not. It is not a second spelling of “count of links > 0” either: it carries the one state the join table cannot express. Nor is it a sentinel “no symptoms” row in the symptom table, which would make none a member of the vocabulary it negates and force every query to special-case it forever.

Hi, I'm Maksymilian.

MaksForge is where I build the small tools I wish existed. Each one solves a single problem.