Skip to content

Commit 8f451be

Browse files
committed
fix(day): hide tracking bar once a work period has any end, even a future one
Pressing Stop with a future custom time (or editing times to declare a future end) previously kept the Stop/Start-subtask bar visible, since Planned-Stop WorkPeriods were treated as still live per ADR 0009. There is no data-model way to tell that apart from "already stopped, with a projected end", so findActiveTracking now only counts a fully open WorkPeriod (end === null) as active. Documented in ADR 0012.
1 parent 222b325 commit 8f451be

5 files changed

Lines changed: 81 additions & 30 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# ADR 0012: Planned-Stop WorkPeriods Are Not "Live" for the Tracking Bar
2+
3+
## Status
4+
5+
Accepted
6+
7+
## Context
8+
9+
ADR 0009 classifies a WorkPeriod whose `end` is a future HH:MM as a
10+
Planned-Stop WorkPeriod, and states it "is treated as live-tracked until
11+
`end` passes". `findActiveTracking` (`src/features/day/dayStreamModel.ts`)
12+
implemented this literally: it fell back to `findActivePeriod`, which
13+
returns a Planned-Stop WorkPeriod when there is no fully open one.
14+
15+
A future `end` can be written in two ways that are indistinguishable
16+
afterward: pressing "Stop work" with a custom future time in
17+
`TimeNowField`, or editing an open period's times directly and setting its
18+
end to a future value. Either way, the tracking bar (`ActiveTrackingRow`)
19+
stayed visible with "■ Stop work" and "▶ Start subtask" — because the
20+
period still looked "live" by ADR 0009's rule. For the Stop-button case in
21+
particular this reads as a bug: the user just pressed Stop, there is no
22+
open WorkPeriod (`end !== null`), yet the UI still offers to stop tracking.
23+
24+
There is no data-model hook to tell the two paths apart after the fact —
25+
both just leave a WorkPeriod with a future `end`. Fixing the Stop-button
26+
case without also changing the edit-times case is not possible without a
27+
new field (the Option A that ADR 0009 already rejected), so both now
28+
behave the same way.
29+
30+
## Decision
31+
32+
`findActiveTracking` now only treats a fully open WorkPeriod (`end ===
33+
null`) as the currently-tracked session. A Planned-Stop WorkPeriod no
34+
longer counts as active for this purpose. Once any WorkPeriod's `end` is
35+
set — whether by pressing Stop or by editing times to declare a future
36+
end — the tracking bar falls back to `NotTrackingRow`/`LogPastWorkRow`
37+
immediately, even if that end is still in the future.
38+
39+
This only changes what counts as "active tracking" for the bar and for
40+
`DayStats.runningSince`/`lastStop`. It does not touch `isPlannedStop`,
41+
`findPlannedStopPeriod`, `findActivePeriod`, or `derivePlannedStopState` in
42+
`src/shared/worktime.ts` — the countdown-to-planned-stop badge/tray display
43+
and the projected-worked-hours totals (`BalanceRows`, `OvertimeBar`,
44+
`MonthProgressMeter`, `DayTotalsPanel`) still treat a declared future stop
45+
as something to project towards. Those features describe what the rest of
46+
the day is expected to look like; they don't need the Stop/Start-subtask
47+
controls to stay on screen to do that.
48+
49+
## Consequences
50+
51+
- ✅ Pressing Stop, or editing times to declare a future end, ends live
52+
tracking in the UI, matching the invariant that no open WorkPeriod means
53+
no "what's running" bar.
54+
- ❌ There is no way, with today's data model, to keep the bar live for "I'm
55+
still working, planning to leave later" while hiding it for "I already
56+
stopped, with a future timestamp" — both produce the same `end` value.
57+
Reintroducing the former would need a separate field (ADR 0009's Option
58+
A).
59+
- ✅ Countdown and projected-total features (ADR 0009) are unaffected — they
60+
read `end` directly via `worktime.ts`, not through `findActiveTracking`.
61+
- ❌ ADR 0009's "treated as live-tracked" language no longer holds for the
62+
tracking bar specifically; it now only describes the projection-facing
63+
helpers in `worktime.ts`. Readers of ADR 0009 should cross-reference this
64+
ADR for that narrower scope.

src/features/day/DayTimeline.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ describe('DayTimeline', () => {
280280
})
281281
})
282282

283-
it('keeps tracking when the end is set to a time still in the future', async () => {
283+
it('stops tracking when the end is set to a time still in the future', async () => {
284284
// Given work running since 09:00
285285
const { repo } = setup([period('a', '09:00', null, 'Work')])
286286
const future = '23:59'
@@ -290,11 +290,11 @@ describe('DayTimeline', () => {
290290
fireEvent.change(screen.getByLabelText(/work period 1 end/i), { target: { value: future } })
291291
await userEvent.click(screen.getByRole('button', { name: /^save$/i }))
292292

293-
// Then the planned end is stored and work is still being tracked
293+
// Then the planned end is stored, but there is no open WorkPeriod anymore
294294
await vi.waitFor(async () => {
295295
expect((await getWindows(repo))[0]?.end).toBe(future)
296296
})
297-
expect(await screen.findByRole('button', { name: /stop work/i })).toBeInTheDocument()
297+
expect(screen.queryByRole('button', { name: /stop work/i })).not.toBeInTheDocument()
298298
})
299299

300300
it('changes the main category of a work period', async () => {

src/features/day/DayTimeline.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export function DayTimeline(props: DayTimelineProps) {
9090
const mutations = useWorkPeriodMutations(repository)
9191
const stream = buildDayStream(windows, now, dayOptions)
9292
const stats = deriveDayStats(windows, now, dayOptions)
93-
const active = findActiveTracking(windows, now, dayOptions)
93+
const active = findActiveTracking(windows, now)
9494
const categories = getAllCategories(customCategories, categoryOrder)
9595
const [loggingFor, setLoggingFor] = useState<string | null>(null)
9696
const [deleting, setDeleting] = useState<PendingDelete | null>(null)

src/features/day/dayStreamModel.test.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -76,25 +76,14 @@ describe('findActiveTracking', () => {
7676
expect(active).toMatchObject({ category: '_GUILDS', since: '13:50', elapsed: 0.5 })
7777
})
7878

79-
it('still counts a Planned-Stop WorkPeriod as running', () => {
80-
// Given a WorkPeriod declared to run until 18:00
79+
it('does not count a Planned-Stop WorkPeriod as running', () => {
80+
// Given a WorkPeriod declared to stop at 18:00
8181
const windows = [period('13:00', '18:00')]
8282

83-
// When it is 14:00
83+
// When it is 14:00, before that declared stop
8484
const active = findActiveTracking(windows, '14:00')
8585

86-
// Then work is still being tracked
87-
expect(active).toMatchObject({ category: '_COREMEDIA', since: '13:00', elapsed: 1 })
88-
})
89-
90-
it('does not read a past day’s finished WorkPeriod as a planned stop', () => {
91-
// Given a day that is not today, holding a period that ended at 17:00
92-
const windows = [period('09:00', '17:00')]
93-
94-
// When it is 14:00 on the wall clock but the viewed day is over
95-
const active = findActiveTracking(windows, '14:00', { isToday: false })
96-
97-
// Then nothing is being tracked
86+
// Then nothing is being tracked — the declared stop already closed it
9887
expect(active).toBeUndefined()
9988
})
10089

src/features/day/dayStreamModel.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { WorkPeriod, WorkPeriodSubtask } from '../../infra/repositories/types'
2-
import { calculateWorkedHours, elapsedHours, findActivePeriod, parseMinutes } from '../../shared/worktime'
2+
import { calculateWorkedHours, elapsedHours, parseMinutes } from '../../shared/worktime'
33
import { findBreaks, type DayBreak } from './dayBreaks'
44
import { deriveSegments, type DaySegment } from './daySegments'
55
import { isLiveSubtask } from './workPeriodShared'
@@ -17,19 +17,17 @@ export interface ActiveTracking {
1717
}
1818

1919
export interface DayOptions {
20-
/** A Planned-Stop WorkPeriod only counts as running on today's day (ADR 0009). */
20+
/** A Planned-Stop WorkPeriod's projected segment only renders on today's day (ADR 0009). */
2121
isToday?: boolean
2222
}
2323

24-
export function findActiveTracking(
25-
windows: WorkPeriod[],
26-
now: string,
27-
{ isToday = true }: DayOptions = {},
28-
): ActiveTracking | undefined {
24+
export function findActiveTracking(windows: WorkPeriod[], now: string): ActiveTracking | undefined {
2925
const ordered = orderedPeriods(windows)
30-
// Per ADR 0006 the latest open WorkPeriod is the current session; on today a
31-
// Planned-Stop WorkPeriod counts as running too (ADR 0009).
32-
const period = ordered.findLast((w) => w.end === null) ?? (isToday ? findActivePeriod(ordered, now) : undefined)
26+
// Per ADR 0006 the latest open WorkPeriod is the current session. A
27+
// Planned-Stop WorkPeriod (ADR 0009) is not: it already has a declared
28+
// stop, so the tracking bar and "what's running" state treat it as done
29+
// (ADR 0012).
30+
const period = ordered.findLast((w) => w.end === null)
3331
if (!period) return undefined
3432
const subtask = period.subtasks.find(isLiveSubtask)
3533
const since = subtask?.startedAt ?? period.start
@@ -127,7 +125,7 @@ export function deriveDayStats(windows: WorkPeriod[], now: string, options: DayO
127125
const breaks = findBreaks(windows)
128126
const breakHours = breaks.reduce((sum, b) => sum + b.hours, 0)
129127
const worked = calculateWorkedHours(windows, options.isToday === false ? undefined : now)
130-
const active = findActiveTracking(windows, now, options)
128+
const active = findActiveTracking(windows, now)
131129

132130
return {
133131
worked,

0 commit comments

Comments
 (0)