Skip to content

Latest commit

 

History

History
118 lines (109 loc) · 27.4 KB

File metadata and controls

118 lines (109 loc) · 27.4 KB

Decisions

  • Firestore-only, no local DB — design doc choice: Firestore's offline persistence gives us local-first behavior for free, so a separate local database adds nothing but sync complexity.
  • No state-management package — StreamBuilder + setState is enough at this app's size; a package would add ceremony without benefit.
  • Fonts: Source Serif 4 (display) + Source Sans 3 (body) — copied from Moxify; the design doc leaves the final font pairing open, and this pairing is proven and already licensed/bundled there.
  • Email/password auth for v1 — zero platform-specific configuration to ship; Apple/Google sign-in can be layered on later.
  • Tags store iconIndex, not an icon codepoint — a dynamically constructed IconData defeats Flutter's icon tree-shaking and fails release builds without --no-tree-shake-icons. An index into a fixed icon list avoids that and mirrors how colorIndex already works. Both lists are ordering contracts, pinned by tests.
  • No ==/hashCode on the models, no Tag.copyWith — nothing needs them yet. Serialization tests compare toMap() output instead. Add them alongside the first caller that genuinely requires them.
  • addDays uses calendar math, not Duration — a DST day is 23 or 25 hours, so Duration(days: 1) stalls on the autumn clock change and a day-by-day walk gets stuck there permanently.
  • macOS is signed with automatic signing, a named keychain access group and the sandbox off — Firebase Auth needs the data protection keychain, which requires the com.apple.application-identifier entitlement that only a provisioning profile carries. The sandbox was never the cause and a team identifier alone is not enough; each variable was tested separately. Xcode's Keychain Sharing capability writes an empty group array that grants nothing, so the group is named explicitly. See docs/macos-signing.md.
  • Firestore rules are version-controlled in firestore.rules — the project's default deny-all rules silently rejected every write, and fake_cloud_firestore cannot catch that class of bug because it has no rules engine.
  • All writes go through _write() in DayPage — a rejected save used to look identical to a successful one. Failures now surface in a SnackBar.
  • Calendar is read-only — Seedling is a lens on your calendar, not a calendar client, so events are shown and hidden but never edited. CalendarSource is an interface so day pages and their tests never touch a device.
  • Hide rules match a repeating series first, the exact title otherwise — hiding "water the plants" once should hide it forever, not one occurrence at a time. Cmd-Shift-H reveals hidden events so a wrong hide can be undone.
  • macOS runs unsandboxed, so calendar access needs no extra entitlement — but iOS does: NSCalendarsUsageDescription and NSCalendarsFullAccessUsageDescription are in both Info.plists.
  • A week review freezes the goals into itself — a review is a record of that week, so it snapshots the template's goal blocks when it is created rather than reading them live. Editing goals later never rewrites history.
  • Review answers are keyed by the question text — re-wording a prompt leaves the old answer under the old wording instead of silently re-labelling it.
  • Week keys are ISO-8601 (2026-W31) — matches the vault filenames and sorts chronologically. Day-of-year is computed in UTC: a local difference().inDays loses an hour across DST and lands a week early.
  • The vault is a one-way mirror — Markdown is written from the data and never read back, so pointing an agent or Obsidian at the folder is safe and nothing there can corrupt the app. Export is a full sweep rather than an incremental sync: the data is small and idempotent writes are easy to trust.
  • Export is desktop-only — a phone has nowhere useful to put a vault.
  • Automatic mirroring covers only the day on screen — it is the one day whose note and answers are loaded, so rewriting it costs nothing extra. Mirroring every day on every change would mean a Firestore read per day per keystroke. "Export everything now" still does a full sweep, which is what a backfill needs.
  • Mirror writes are debounced and fingerprinted — typing a sentence produces one file write, and a rebuild that changed nothing produces none. A failed write is swallowed: a vault that cannot be written must never break the app.
  • The widget's payload is a pure functionbuildWidgetPayload is fully tested without a device, so the only untestable part left is the SwiftUI rendering itself. The Xcode Widget Extension target must be added by hand (see docs/widget-setup.md); extension targets cannot be created from the command line.
  • The browser integration is a userscript, not two extensions — Tampermonkey and Greasemonkey both run one file unchanged, so Chrome and Firefox cost one thing to maintain instead of two manifests and two packaging paths. Revisit if it ever needs more than adding a task.
  • The userscript talks to Firestore directly over REST — the same path the app uses, so there is no server in between. It signs in once and keeps only the refresh token; the password is never stored. The embedded API key is public by design and grants nothing on its own — firestore.rules is what protects the data.
  • Titles come from Jira and Bitbucket's own APIs, not the DOM — same-origin calls with your existing session survive their UI being rewritten, with the tab title as a fallback.
  • Calendar times go through the epoch, not toLocal() — the calendar plugin returns a TZDateTime whose isUtc flag is already false while its digits are UTC, so toLocal() is a no-op and a 13:30 appointment rendered as 11:30. DateTime.fromMillisecondsSinceEpoch always resolves the absolute instant to system-local time. All-day events skip the conversion entirely, since converting midnight can push them onto the day before.
  • Appointments and timed tasks are one list, not two — they were grouped by kind, which put an 09:00 task below a 17:00 appointment. timedEntries interleaves them by the clock, with all-day events at the top because they frame the day rather than sit in it.
  • Ticking off a calendar event stores "done" in Seedling, keyed by day — the calendar is only ever read, so completion cannot live there. Keying by day as well as event means a repeating event done today is still waiting tomorrow.
  • Overdue is today-only — a past day is history rather than a to-do, and the current minute is not yet late. The day page ticks a clock every minute so something turns red while you are looking at it.
  • Pushed screens get a bare back arrow, not an AppBar — every screen already has its own serif heading, so an AppBar would stack a grey bar and a second title on top of it. The Mac has no edge-swipe to fall back on, so without the arrow a pushed screen was a dead end.
  • Wide layouts are timed and untimed side by side with the note underneath — matching how Bas reads the day. The three-column variant is gone: the note is the long thing on the page and deserves the full width rather than a third of it.
  • The observation emoji strip is most-recently-used, not a fixed palette — capped at ten, stored on the account so the phone and the Mac agree. The template's moodEmoji are only starters, shown until something has actually been used.
  • Any emoji can be typed, and only its first grapheme cluster is kept — a cluster rather than a code unit, so a flag or a skin-toned emoji survives as the one character it looks like. Typing several keeps the first, because the strip is a row of single marks.
  • The phone is the only device that reads the calendar; everything else reads a mirrordevice_calendar has no macOS implementation, and the BigMe cannot see iCloud. The iPhone publishes a 60-day window to Firestore and the other devices read it. Publishing replaces the window rather than merging, so a cancelled appointment disappears instead of lingering.
  • A calendar that cannot be read is not an errorDeviceCalendar.available returns false rather than throwing where the plugin is absent, and a failed read leaves the day working with no appointments on it.
  • Calendar mirror documents are keyed by an encoding of the event id, not the id itself — EventKit ids look like ______NativeStorePersistentID_______:gregorian/2DFB19BE-…: the slash is read as a path separator and __…__ is reserved, so using one directly crashed the app on launch with FIRInvalidArgumentException. The real id travels in the document body, so ticking an event off still matches. The exception is native and synchronous, so no Dart try/catch could have contained it — not constructing an invalid id is the only defence.
  • Reveal is on a long-press of the date as well as ⌘⇧H — a keyboard shortcut is no use on the iPhone or the BigMe, and hiding an event is a long-press, so hiding one by accident had no undo on those devices at all. While revealing, the header says so and how to stop.
  • Tag and time are editable after a task exists, not only while typing it — they were on the add line alone, so a task created in a hurry could never be filed or scheduled afterwards. Both now sit in the long-press menu, which also shows the current value and offers a way back to none.
  • path_provider_android is pinned to 2.2.23 — 2.3.x depends on jni, whose Gradle file skips applying the Kotlin plugin on AGP 9 but still calls its kotlin { } extension, breaking every AGP 9 Android build. An upstream bug, not ours; the pin is the smallest thing that unblocks the BigMe. Drop it when jni fixes the guard.
  • All-day events go through the same epoch conversion as timed ones — EventKit stores them at local midnight, which as an absolute instant is the previous evening in UTC. Reading the raw components put a Wednesday all-day event on Tuesday. One code path, and the only difference is that an all-day event carries no clock time.
  • Reading the device calendar is a per-device setting, not a platform check — the BigMe runs Android but has no iCloud account, so Platform.isAndroid was the wrong question. It defaults to on for the iPhone and off elsewhere, and only a device that reads a real calendar publishes — otherwise the BigMe would overwrite what the phone shared.
  • Only one copy of Seedling can run per machine — Firestore's offline cache holds an exclusive LevelDB lock, and a second instance dies on a native assertion no Dart code can catch. Left as is rather than adding a single-instance guard: macOS will not launch two copies of the same bundle, so it only occurs while developing with a debug and a release build at once. Diagnosis is in engineering.md.
  • Typed durations guess the unit from the size of the number — under 15 reads as hours and from 15 up as minutes, because 40 hours is not something you log against a day while 40 minutes is. A decimal is always hours, and an explicit unit always wins. Anything unreadable logs nothing rather than something wrong.
  • A Jira reference stores the key and the site, not the URL — the link is rebuilt from them, so a key fits on a tile and a pasted link with ?focusedId=…#comment still resolves cleanly. The site is remembered from the first URL pasted, after which a bare MAF-1234 is enough.
  • The week review is a page in the day pager, not a button — it sits straight after Sunday, where you would write it, and the header button that used to open it is gone. On the iPhone that row had run out of room anyway.
  • The daily check-offs move depending on whether they are answered — unanswered they sit under the date at full width, because they are something to do; answered they fold to one line beside the date and get out of the way. Settings stays pinned to the top right regardless.
  • The untimed add line has no clock — setting a time there moved the task into the other list, which reads as the row doing something it did not say.
  • Goal lists are only ever quarterly and yearly — the "new list" input is gone and a list can be removed, which exists to undo one added by accident rather than to curate a collection.
  • The emoji box is never rewritten while you type — resetting its TextEditingValue on every keystroke cancelled macOS's emoji insertion mid-composition, so a character picked from the Viewer only landed after repeated attempts. The text is left exactly as typed and reduced to one grapheme cluster when it is used.
  • A project can take a task or an idea from the tags screen — same line, two destinations: today, or that project's someday list, which is the difference between doing it and not yet.
  • An appointment can carry a tag, a ticket and logged time — an hour in a meeting is an hour spent, and it belongs to a project like any other. It cannot be snoozed, deleted or retimed: the calendar owns all three, and Seedling only ever reads it. These annotations hang off the event's hide key, so a repeating meeting is tagged once rather than every Tuesday.
  • The month hangs from the top, because that is where you pulled it from — dragging the date header down brings a swipeable month with it, and a day you did something on is filled in solid. The gesture lives on the header alone: inside the day's own blocks, a downward drag means scroll.
  • "Did something" means wrote, answered or ticked off — a note, a daily question, a ticked appointment or a task completed that day. Opening a day is not activity; the point of the fill is to show where the gaps are.
  • The day's check-offs fold to a chip beside the date, and the page owns whether they are open — the block cannot hold that state itself: folded it belongs next to the date, open it needs the width of the page, and only the page can move it between the two. Keeping it inside the block is also what crashed it, since a right-hand slot in a Stack has no width to give a full block.
  • The two standing goal lists have no delete button — they are the point of that screen. Any other list you ended up with can still go.
  • A review page in the pager gets its own header — "Week review · 2026-W30" rather than the Sunday it follows, and no daily check-offs: a review is about the week, not about that last day.
  • There is a paste button beside the emoji strip — some emoji pickers (Raycast's among them) copy and then simulate a paste, and that paste does not reliably land in a Flutter text field on macOS. Reading the clipboard on a button press does not depend on it.
  • A week review is mirrored on its own timer — it is only loaded while you are looking at it, so that is the only moment it can be written. Folding it into the sidecar signature, which every day page rebuilds, would have meant either rewriting it constantly or forgetting it the moment you paged away. Reviews land in reviews/2026-W30.md.
  • A day counts for export if anything happened on it — a note or an answer is enough; it used to take a task, so a day you only wrote on never reached the vault.
  • Three taps on the date opens the standup — what you finished the day before, then what is on this day's list. Read-only on purpose: it is something to read out, not somewhere to tick things off mid-sentence. The gesture is hidden because it is a thing you do on the way into a meeting, not a button that needs a home on the page.
  • A meeting on the calendar counts as attended — there is nothing to tick, and a standup with the meetings left out reads as an idle day. Hidden appointments stay hidden here too: the ones you blacklisted are exactly the ones nobody wants read out.
  • The standup looks back at your last working day, not yesterday — it walks back over the weekend, over the days you do not work, and over any day marked off on the timesheet. So Monday covers Thursday, and a Thursday after a Wednesday off covers Tuesday. This was the calendar day before at first, on the reasoning that guessing at a work week was the reader's problem; a Monday standup that opens on Sunday every single week proved that wrong.
  • Monday to Thursday is the default, and it is a setting — a four-day week is the common case here, and the weeks that do not go the usual way get two small arrows on the heading rather than a rule nobody can express.
  • Today's half keeps what is already done, struck through — a standup that hid your finished work would contradict the half above it.
  • The calendar mirror is keyed per occurrence, not per event — EventKit gives every occurrence of a repeating event the same identifier, so one document per id kept a single Tuesday of a weekly standup and silently dropped the rest. On the Mac and the BigMe that looked like the meeting not existing at all.
  • Publishing is chunked — Firestore refuses a batch over 500 operations, and two months of a working calendar is well past that once the deletes are counted. It used to fail the whole publish rather than write part of it, which is why the reading devices could see nothing whatsoever.
  • Settings says what was last shared, and when — a device that only reads the mirror has no other way to tell "no appointments today" from "nothing ever arrived". This failed silently twice before the line existed.
  • Ideas are refiled by dragging — long-press, because a plain drag fights the list's own scrolling on a phone. Every project gets a block while dragging, empty ones included: you cannot drop onto a heading that is not drawn.
  • Hold the calendar source across the awaitapp.dart builds a fresh one on every rebuild, and whether the events came off this device is state on the instance that read them. Asking widget.calendar after the read could ask a brand-new object that had read nothing, so a rebuild landing during a slow EventKit read silently skipped the publish and the other devices went stale. Nothing logged it, because nothing had failed.
  • A device reads its own calendar, and falls through to the mirror when there is nothing in it — the BigMe runs Android so it can read a calendar; it just has no account in one, and "read nothing" is indistinguishable from a quiet two months. Making that a setting you have to know to change was the wrong answer. A device that fell back does not publish: republishing a mirror over itself helps nobody.
  • Tapping a task's title renames it — the menu is for everything a task has; the words themselves are edited where they are written. The write merges, so a rename cannot drop the time logged or the completion. An empty title is refused rather than saved: a task with no words is one you cannot see to fix.
  • Appointments are re-read when the app comes back to the front — reading them once at startup meant a meeting you moved elsewhere stayed on the day it used to be on until Seedling was restarted. Coming back to the app is the moment you would expect it to have caught up; on the phone that re-read is also what republishes the mirror the other devices read.
  • The standup copies as Slack markup — "- " for a bullet, four spaces for a sub-bullet, meetings before tasks in each half. The two headings are Dutch (Gisteren: / Vandaag:) because that is the language the standup is given in, even though the app is English.
  • The name is typed, not derived — an email's local part is not what anyone calls you, and a wrong name pasted into Slack is worse than no name. Empty leaves the heading off entirely.

Jira time tracking

  • Seedling writes native Jira worklogs, not Clockwork's own — Clockwork reports on Jira's worklogs rather than keeping a store of its own, so the ordinary POST /rest/api/2/issue/{key}/worklog is what makes the timesheet fill in. API v2 rather than v3: v3 wants the comment as an Atlassian Document Format tree, and a sentence is a sentence.
  • Every sent worklog is recorded by source and day — the same hour logged twice is far worse for a timesheet than an hour never sent, so nothing goes without a record coming back, and each one is written the moment Jira accepts it rather than at the end of the run. A connection that drops halfway must not leave hours that are in Jira but not marked as sent.
  • Time taken back to zero deletes the worklog — logging an hour, sending it, and then deciding you had not worked it must not leave the hour sitting in someone's report.
  • The API token lives in the device keychain, never in Firestore — it is a credential for someone else's system, and a credential that syncs is a credential in more places than it needs to be.
  • The timesheet has two grids — what you did, and what you always do. Meetings and maintenance never become tasks, but they are most of some weeks and the sheet is wrong without them. A standing row is drawn even when empty, because an empty row is where this week's hours go.
  • A task earns a row if the week touched it — time logged, checked off, or merely planned. Planned counts because the row has to exist before you can put a number in it.
  • The grid is Monday to Friday, and a weekend day only when it has hours on it — nobody wants two empty columns every week, but an hour logged on a Sunday that no grid shows is an hour no button sends. It earns its column rather than being dropped.
  • Tapping a day's heading closes its column — a day off, struck through and with nothing to aim at. It is a guard against filling in a Friday you did not work, not a reason to hide anything: an hour already logged on a closed day still shows, greyed, because a stray hour on a day off is exactly what you want to notice and move. Kept on the day document, so every device agrees which Friday you took.
  • Send only sends the week on screen — the button says "this week", and a button that quietly does more than it says is a button you stop trusting.

The home-screen widget

  • Four cells wide, two high: appointments beside what is left to do — the two halves of a day you would want without unlocking anything. Four rows a side is what fits before the text shrinks past reading across a room, which is the only way a widget is ever read.
  • A checkbox on the widget queues the tap; it does not write it — the tap runs in a background isolate, and a second isolate opening Firestore's offline database while the app holds it is exactly what produced LOCK: Resource temporarily unavailable earlier on. So the id is queued, the widget redraws without that row so the tap feels like it did something, and the app makes it true on its next launch or resume. The cost is a task ticked on the widget not reaching the cloud until you open the app.
  • The iPhone widget depends on nothing but WidgetKit — its checkbox writes the two shared keys straight from Swift rather than waking a Flutter isolate. That keeps CocoaPods out of the extension target, and a target with no pods is one a script can add to the Xcode project instead of a person adding it by hand.
  • The extension reads the app's own generated xcconfig — an app extension whose version does not match its host is one iOS silently refuses to install, and a target with no base configuration has no version at all. Sharing Generated.xcconfig keeps the two in lockstep with pubspec.yaml rather than with somebody remembering.
  • Embed the extension before Flutter thins the binary — the thinning script re-signs the app bundle, so the extension has to already be inside it. Ordered the other way, Xcode reports Cycle inside Runner and the build simply stops.
  • A late tap can only ever complete, never un-complete — the queue is drained against the current state, so a tap on something already done somewhere else is dropped rather than toggled back.
  • Nothing in main() may throw — registering the widget callback did, on the Mac, where home_widget has no implementation, and an exception before runApp is not a missing feature but a black window. Anything the widget touches is now skipped outright where there is no home screen, and wrapped besides.

The turbo tagger

  • Keys are parsed loosely and filtered by Jira — the paste regex also matches UTF-8 and COVID-19, and that is the right trade: whatever Jira does not recognise is dropped, while guessing at project prefixes here would silently lose real tickets. The one screen where a false positive costs nothing and a false negative costs a paste.
  • Only untagged work is listed — the screen exists to empty that list, and a row that disappears when you deal with it is the whole feeling of the thing.
  • The list seeds itself from work you have already done — every ticket ever put on a task, a standing topic or an appointment is harvested when the tagger opens, dated by the last day it actually carried time, was finished, or was planned for. A list that starts empty until you paste something is a list you have to feed before it can help you.
  • The names are looked up on opening, not behind a button — a list of bare keys is not a list you can pick from, and you should not have to know that a lookup is a thing that exists.
  • The ticket list is ordered by when you last used it — the ticket you touched an hour ago is the one you are about to touch again. Imported-but-never-used ones sort last, alphabetically.
  • An hour is offered by default — it is the answer most of the time, and a wrong hour you can see beats a blank you have to remember to come back to.
  • A ticket can be tagged without logging any time — "No time" is a first-class answer, not a cancel.

Keyboard, and what the pointer is on

  • The shortcuts act on whatever the pointer is over, so the row has to show it — Ctrl-A fires at a task you did not point at otherwise. Rows wash faintly on hover, slow enough not to flicker as the mouse crosses a list, and absent entirely where there is no pointer.
  • The hover callbacks are tested end to end, through DayContent — they were declared on it and never passed to the blocks underneath, so hovering reported nothing and both shortcuts fired at a task that was always null. Nothing failed and nothing logged; it simply did nothing. Declaring a callback and forwarding it are two edits, and only the second one is the feature.
  • The time sheet opens on the task it already has, not on an empty frame — it waits on a stream to get the current minutes, and rendering nothing until that arrives meant the autofocus landed on a widget that did not exist yet. It starts from the task in hand and refines when the stream answers.
  • The two shortcuts are read straight off the keyboard, not through CallbackShortcuts — that only fires while focus is inside it, and after a click anywhere it is not, so macOS beeped at a key nothing had handled. They act on what the pointer is over, so they cannot depend on what happens to hold focus. Guarded against firing while a text field has focus: on macOS Ctrl-A and Ctrl-T are beginning-of-line and transpose, and there they belong to the text.
  • Control, not command — these fire with a hand still on the mouse, and the command versions belong to the text fields.
  • A ticket you type that Seedling has never seen is offered as a row of its own — a pasted link or an unfamiliar key should not mean going somewhere else to add it first.
  • todayKey() reads an overridable clock — an app left open overnight insisted it was still yesterday, and everything downstream believed it. The first test I wrote for the fix passed against the broken code, because it could not move the date; making the clock injectable is what made the bug expressible.
  • Midnight moves the page only if you were on today — left on another day on purpose, being yanked forward at midnight would be rude.