Appearance
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 30mformat. - 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;
1dalways 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
1dwould mean. - Source: KD-0403
Calculation tooltip: CSS-only, not native title or subtitle text
- Chose: Small
InfoTooltip.vuewith::afterpseudo-element styled to dark theme. - Rejected: Native
titleattribute, or static subtitle text below cards. - Why: Native
titleis 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.vuein 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 keytimeEntry-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.tsmirroringmeasurement.ts(module-level ref + watch through@tenant/services/storage). - Rejected: New
useLocalStoragecomposable, or localrefinside 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, ...)writesstarted_atwhenever 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_atsurvives 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_atalready 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_minutesas 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_atandnotecolumns. Modal contains the new fields; sidebar inline stays simple. Open question: 10,000-char note limit,before_or_equal:nowvalidation, native datetime-local input. - KD-0161 (timeLogs → timeEntries refactor): Frontend renamed to
timeEntrieswhile backend staystime_logs; bridged byTIME_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_SUMconstant mirroringEAGER_LOAD_COUNT, with callers wiringwithSum/loadSumthemselves. - Rejected:
Issue::withRemainingMinutes()Eloquent scope, or auto-loading insideIssueResourceData::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 throwMissingRelationExceptionwhen unset. - Rejected: Keep the silent
?? 0fallback used by the existingattachments_countprecedent. - 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 assertingTimeEntrydoes not adoptSoftDeletes. - Rejected: Hard-row sum with no guard.
- Why: Without the tripwire, a future
SoftDeletesaddition silently changesremaining_minutessemantics (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
IssueResourceDataonly; leaveIssueListResourceData,IssueSearchResourceData, andIssueBroadcasteruntouched. - Rejected: Extend to all three resource shapes so the field is visible on the board and my-issues views.
- Why:
IssueBroadcasterfires 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_minutesto 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:
loadSumon an already-fetched collection fires per-model queries (N+1) whilewithSumis a single subselect, and the existingvalidateRelationsLoaded()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'ssubmit()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
:disableddoesn't stop@submit.preventfiring 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
.vuefiles and fails when a mutating@submit/@clickhandler 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 newusePendingGuardcomposable. - 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
@submitand enumerated mutating@click/@emithandlers; exclude@dropand 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_minuteson both the list resource and the fullIssueResourceData. - 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 requirednumber, unlike the nullableestimated_minutes. - Rejected: Mirror
estimated_minutesas 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-tscflags every fixture missing it. - Source: KD-0843
Keep remaining_minutes despite zero Vue consumers (rejected the cleanup)
- Chose: Leave
remaining_minuteson both resources untouched while addinglogged_minutesalongside. - Rejected: Remove
remaining_minutesas 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