Skip to content

Time Tracking Decisions

Distilled from 5 DECISIONS.md files (KD-0097/0112/0153/0161 had only PLAN.md and KD-0311/0324 had TASKS-only bug fixes — those documents described implementations without rejected alternatives, so they yield design notes rather than decisions). Each entry below is a real fork in the road — what we chose, what we passed on, and why.


Time-measurement selector: unified options, not two controls

  • Chose: Single dropdown with 4 self-contained options (Hours / 7h day / 7.5h day / 8h day).
  • Rejected: Separate "format" toggle plus a "day length" dropdown.
  • Why: Each option encodes both display format and multipliers; one control is simpler than two coupled ones, fixed day-length values cover real cases.
  • Source: KD-0403

Hours display: hours+minutes, not decimal

  • Chose: 10h 30m format.
  • Rejected: Decimal 10.5h.
  • Why: Consistency with the existing compact w/d/h/m format users already know wins over easier-to-sum decimals.
  • Source: KD-0403

Avg/day card: always hours, regardless of selected measurement

  • Chose: Avg/day card formats in h/m even when "8h day" is selected elsewhere.
  • Rejected: Apply selected measurement uniformly across all summary cards.
  • Why: "0.8d per day" is nonsensical; avg-per-day fundamentally reads in hours.
  • Source: KD-0403

DurationInput parsing: mirror display measurement

  • Chose: Input parsing uses the same selected measurement as display; 1d always means the same thing.
  • Rejected: Fix DurationInput at 8h regardless of display selection.
  • Why: Splitting input semantics from display semantics is confusing; consistent meaning wins, even though changing the selector retroactively changes what 1d would mean.
  • Source: KD-0403

Calculation tooltip: CSS-only, not native title or subtitle text

  • Chose: Small InfoTooltip.vue with ::after pseudo-element styled to dark theme.
  • Rejected: Native title attribute, or static subtitle text below cards.
  • Why: Native title is unstyled and lacks mobile support; subtitle wastes vertical space; CSS tooltip needs no JS dependency.
  • Source: KD-0403

Tooltip scope: domain-local component

  • Chose: New InfoTooltip.vue in the timeEntries components directory.
  • Rejected: Promote to shared.
  • Why: Single use case at this point; promoting to shared would be speculative.
  • Source: KD-0403

Measurement persistence: existing storage service, not new pattern

  • Chose: Use @tenant/services/storage (put/get) with key timeEntry-measurement.
  • Rejected: Custom localStorage wrapper or composable.
  • Why: Same pattern as filters.ts — reactive ref + watcher; no new abstraction needed.
  • Source: KD-0403

Auto-fill state: module-level ref + watcher, not composable or local ref

  • Chose: New calculateStartTime.ts mirroring measurement.ts (module-level ref + watch through @tenant/services/storage).
  • Rejected: New useLocalStorage composable, or local ref inside the form.
  • Why: Matches the domain's existing convention exactly; a composable for the second consumer is premature abstraction; local ref loses state on modal close, breaking persistence requirement.
  • Source: KD-0419

localStorage key: timeEntry-calculate-start-time

  • Chose: Long descriptive key consistent with existing timeEntry- prefix.
  • Rejected: timeEntry-auto-start (shorter), timeEntry.autoStartedAt (namespaced with dots).
  • Why: Readable and follows the prefix convention; no other key uses dot namespacing.
  • Source: KD-0419

Auto-fill checkbox placement: helper line under Started At

  • Chose: Vertical extension of the Started At column.
  • Rejected: New full-width row above Started At, or inline with the label.
  • Why: Keeps the existing 3-column flex row intact; visually ties checkbox to the field it modifies.
  • Source: KD-0419

Auto-fill recalc behavior: every duration change while ON

  • Chose: watch(minutesSpent, ...) writes started_at whenever the ref is true.
  • Rejected: Recalc once when toggled ON; or recalc unless user manually overrode (touched flag).
  • Why: Last-write-wins on duration change is predictable; "touched" flags add state-tracking complexity without clear UX gain.
  • Source: KD-0419

Auto-fill manual override: respected until next duration change, no auto-uncheck

  • Chose: Checkbox stays ON; manual edit to started_at survives until duration changes.
  • Rejected: Auto-uncheck on manual edit, or respect manual edit forever.
  • Why: Auto-uncheck is magical (state changes without checkbox interaction); respect-forever breaks the recalc contract.
  • Source: KD-0419

Auto-fill scope: frontend-only feature

  • Chose: No backend / API / DTO changes.
  • Rejected: Add server-side support for the calculation flag.
  • Why: started_at already exists end-to-end; the feature is a UI helper that fills an existing field before submission.
  • Source: KD-0419

Auto-fill label: English "Auto-fill start time"

  • Chose: English label consistent with rest of the form.
  • Rejected: Direct Dutch translation "Bereken starttijd" or English "Calculate start time".
  • Why: Form's other labels are English ("Time Spent", "Started At", "Note"); "Auto-fill" is slightly clearer than "Calculate".
  • Source: KD-0419

Plans without DECISIONS — design notes for context

These plans had only PLAN.md or TASKS.md — no rejected alternatives to capture. Brief notes for historical reference:

  • KD-0097 (estimate field): Added estimated_minutes as a manual issue field separate from AI-estimated time logs. Manual estimate is a different concern than time logs.
  • KD-0112 (AI time estimation): AI run completion logs estimated time as a TimeLog row attributed to the bot user, with try/catch so logging failure doesn't block run completion.
  • KD-0153 (extended time tracking): Added optional started_at and note columns. Modal contains the new fields; sidebar inline stays simple. Open question: 10,000-char note limit, before_or_equal:now validation, native datetime-local input.
  • KD-0161 (timeLogs → timeEntries refactor): Frontend renamed to timeEntries while backend stays time_logs; bridged by TIME_ENTRY_RELATION_NAME = 'time-logs'. Hierarchy views (Project/Team/User tabs) deleted because AllEntries with filters covered the same data.
  • KD-0311 / KD-0324: Pure bug fixes — no design forks captured.

Sum aggregates on ResourceData: parallel constant + caller wiring, not model scope or auto-load

  • Chose: Add EAGER_LOAD_SUM constant mirroring EAGER_LOAD_COUNT, with callers wiring withSum/loadSum themselves.
  • Rejected: Issue::withRemainingMinutes() Eloquent scope, or auto-loading inside IssueResourceData::from() / ::collection().
  • Why: Mirroring the existing count convention keeps reviewer cost near zero; scopes couple the model to a single ResourceData field, and auto-load hides queries in a "dumb serializer" (violates ADR-0009).
  • Source: KD-0494

ResourceData aggregate preload: fail-fast validator, not silent ?? 0 fallback

  • Chose: Extend base ResourceData::validateRelationsLoaded() to check declared aggregate columns (<relation>_count, <relation>_sum_<column>) and throw MissingRelationException when unset.
  • Rejected: Keep the silent ?? 0 fallback used by the existing attachments_count precedent.
  • Why: The silent fallback already produced a latent bug (every issue in the index reported attachments_count: 0); a validator is the only way to enforce no-N+1 long-term and surfaces missing preloads at the call site instead of as wrong data.
  • Source: KD-0494

Sum semantics: hard-row sum plus arch tripwire against future soft-deletes

  • Chose: Use withSum('timeEntries', 'minutes_spent') with natural Eloquent semantics, plus an arch test asserting TimeEntry does not adopt SoftDeletes.
  • Rejected: Hard-row sum with no guard.
  • Why: Without the tripwire, a future SoftDeletes addition silently changes remaining_minutes semantics (tombstoned hours stop counting); a one-line arch test forces a design conversation when that day comes.
  • Source: KD-0494

remaining_minutes scope: full ResourceData only, skip list / search / broadcast resources

  • Chose: Add the field to IssueResourceData only; leave IssueListResourceData, IssueSearchResourceData, and IssueBroadcaster untouched.
  • Rejected: Extend to all three resource shapes so the field is visible on the board and my-issues views.
  • Why: IssueBroadcaster fires on Issue mutations but not TimeEntry mutations, so board cards would show stale values — staleness on a real-time channel is a worse bug than absence, and a parallel TimeEntry-broadcast mechanism is out of scope.
  • Source: KD-0494

remaining_minutes on the list resource: one DTO carrying the field on broadcasts too

  • Chose: Add remaining_minutes to the single existing list resource and let it ride on broadcasts unchanged.
  • Rejected: A separate broadcast-only DTO, or broadcaster-side stripping of the field at serialization.
  • Why: The value is computed at broadcast-dispatch time (not cached), so it is always correct when sent — the earlier KD-0494 "staleness" concern is a missing TimeEntry→Issue broadcast trigger, not a data-correctness problem, so it doesn't justify a doubled DTO surface or fragile strip logic.
  • Source: KD-0648

Sum preload location: withSum on the query in each Action, not in ResourceData::collection()

  • Chose: Each list Action adds ->withSum('timeEntries', 'minutes_spent') to its query builder.
  • Rejected: Extend ResourceData::collection() to auto-handle counts and sums on the loaded collection.
  • Why: loadSum on an already-fetched collection fires per-model queries (N+1) while withSum is a single subselect, and the existing validateRelationsLoaded() arch test already catches any caller that forgets to add it.
  • Source: KD-0648

BaseFormModal double-submit guard: one form-level guard in the base component

  • Chose: Add an early-return guard inside BaseFormModal's submit() so a disabled state blocks Enter-key submits for all 19 consumers at once.
  • Rejected: Fix each consumer individually, leaving the base component purely presentational.
  • Why: The button's :disabled doesn't stop @submit.prevent firing on Enter, and one base-component change is the highest-leverage fix versus migrating 19 callsites by hand.
  • Source: KD-0737

Double-submit convention enforcement: L1 arch test, not code review

  • Chose: A Vitest architecture test that scans .vue files and fails when a mutating @submit/@click handler lacks the guard or an inline N/A marker.
  • Rejected: Document the convention and rely on reviewers to catch unguarded new forms.
  • Why: A review-only convention erodes as the team and form count grow; a CI gate makes coverage structural, and the precedent arch test made the cost of bundling it small.
  • Source: KD-0737

Migrate hand-rolled isSubmitting forms onto the shared guard composable

  • Chose: Replace KD-0654's three hand-rolled ref(false) + try/finally forms with the new usePendingGuard composable.
  • Rejected: Leave the working hand-rolled implementations in place.
  • Why: A single detection pattern (presence of the composable) keeps the enforcing arch test simple, where also accepting the hand-rolled variant would complicate it; uniformity also eases new-form onboarding.
  • Source: KD-0737

Double-submit audit excludes deliberate-gesture events by design

  • Chose: Scan only @submit and enumerated mutating @click/@emit handlers; exclude @drop and drag-drop wiring from the guard.
  • Rejected: Make the audit event-type-agnostic and guard drag-drop and file-drop too.
  • Why: The bug class is intent-miscapture (one user intent firing twice inside a ~50ms reactivity window), but a drag or multi-file drop takes seconds and each is a separate deliberate intent, so guarding them would break legitimate consecutive use without fixing a real bug.
  • Source: KD-0737

logged_minutes scope: add to BOTH issue resources, not list-only

  • Chose: Expose logged_minutes on both the list resource and the full IssueResourceData.
  • Rejected: Add it only to the list resource the board fetches.
  • Why: The board's most common live interactions (self-assign, single- and bulk-issue broadcasts) re-render cards from the full resource, so a list-only field would render on load then silently blank on the first move/edit/assign.
  • Source: KD-0843

logged_minutes type: non-nullable int defaulting to 0, not nullable like estimate

  • Chose: Backend int (= 0 when no entries) and frontend required number, unlike the nullable estimated_minutes.
  • Rejected: Mirror estimated_minutes as a nullable/optional field.
  • Why: Logged time is always a concrete sum where 0 is the meaningful "none logged" value, and a flat required field gets compiler-enforced completeness so vue-tsc flags every fixture missing it.
  • Source: KD-0843

Keep remaining_minutes despite zero Vue consumers (rejected the cleanup)

  • Chose: Leave remaining_minutes on both resources untouched while adding logged_minutes alongside.
  • Rejected: Remove remaining_minutes as derivable dead weight since no Vue code reads it.
  • Why: It carries a 14-file test blast radius, is guarded by an arch test protecting its soft-delete semantics, and the ungreppable Go CLI and MCP consumers hit the same API — so "no Vue consumer" does not mean "unused," and the cleanup deserves its own verified issue.
  • Source: KD-0843

Cross-surface logged figure: snapshot consistency, not live re-broadcast

  • Chose: Guarantee the board figure equals Σ time entries at every fetch and on any issue broadcast, accepting it can be stale until the next fetch.
  • Rejected: Make the time-log write path re-broadcast the parent issue so cards live-update the instant anyone logs time.
  • Why: TimeEntry actions broadcast the TimeEntry channel (which the sidebar subscribes to as the live surface), and re-broadcasting the parent issue from the write path is the integration the issue explicitly scoped out — the card is never wrong on load, only potentially stale.
  • Source: KD-0843