Android: Timesheet - Stop working #289

Closed
opened 2026-10-17 09:00:42 +02:00 by kfickel · 2 comments
Owner

If currently the timesheet is running (within a card), show that in the top-menu, and add a permanent notification with a button to end it.

For this, timesheet needs a "default append file" like reminder has, the cli a new "streamd timesheet break" command, which is then wired up with that button.

If currently the timesheet is running (within a card), show that in the top-menu, and add a permanent notification with a button to end it. For this, timesheet needs a "default append file" like reminder has, the cli a new "streamd timesheet break" command, which is then wired up with that button.
Collaborator

Plan

A timesheet command gets the file command its breaks are written into. The CLI gets streamd <key> break [TEXT...]. When today has an open card, the app says so on the timesheet screen and on the Stream tab, and shows an ongoing notification with a Stop working action that writes the break. The notification side builds on #288. It reuses the Streamd application object, the notify/ package and the checks it runs whenever the stream is another Ready. #288 is implemented first.

Decisions (clarified with the user)

  • Field name: append_to, the file command a line is appended to. It is the same field on a reminder: into_file of a reminder is renamed to append_to. This is a breaking change. An old into_file is rejected with a pointed message (below).
  • Indicator: shown in the app bar of the timesheet screen and as a banner on the Stream tab, in a non-warning colour (a tonal container, not the amber of StreamdTheme.colors).
  • Scope: only the break. Starting work stays a manual entry. There is no card subcommand and no start button.
  • Text: break takes an optional text, which goes after the tags as with append. The button of the notification and the app writes none.

1. Core: append_to on reminders and timesheets

config/project/file.rs, validate.rs, schema.rs, change.rs, complete.rs, rename.rs, init.toml:

  • Reminder: into_file → append_to everywhere: the file model, ReminderCommand.append_to, schema docs, the completion of values (the file commands), change.rs, and rename_command, which renames the append_to of reminders and of timesheets.
  • An into_file in a reminder table is an error at its path: commands.<key>.into_file: `into_file` was renamed; write `append_to = "<value>"` . This is a special case before the generic unknown-field message.
  • CoreError::NoIntoFile → NoAppendTo { key }. The message becomes `<key>` cannot be marked as done: it has no `append_to`, the file command to write into; add one to [commands.<key>].
  • Timesheet: TimesheetCommand.append_to: Option<String>, which is optional, plus its schema entry, its change.rs line and its completion.
  • Validation of a timesheet with append_to:
    1. It must name a file command, with the same message and closest suggestion the reminder has.
    2. Some tags must write the break value into a file of that command (break_tags, below). Otherwise: commands.<key>.append_to: no tags place { <dimension> = "<BREAK>" }, so breakcannot write it into a file of; add a tag that does, or remove append_to``.
  • init.toml: the reminder examples use append_to. [commands.timesheet] gets append_to = "daily".

2. Core: working and the break

New commands/timesheet/clock.rs:

  • working<S: Borrow<LocalizedShard>>(command, shards, now, tz) -> Option<Working>, where Working { since: DateTime<Utc>, opened: EntrySource }. It returns the open timecard of today (local day in tz), i.e. the last TimecardSource of today whose closed is None, taken from extract_timesheets. It returns None if:

    • the card's start is after now (logged ahead), or
    • today is invalid, or
    • there is nothing open.

    An open card of an earlier day is a DayProblem, not running work.

  • break_tags(command, file, config) -> Option<Vec<String>> returns the tags of a line that places dimension = values[break]. It uses state::derive::derive(config, dimension, &inherited_markers, break_value) from the markers that a file of file hands down (the inherited of reminder/done.rs, moved to file/ so both share it). The result is canonical names, never an alias. With the starter it is @Timesheet @Break.

  • record_break(store, commands, config, shards, key, text: Option<&str>, now, tz) -> Result<Appended, CoreError> joins the tags with the text and appends them through file::append (which adds the moment markers). Its errors:

    • CoreError::NoAppendTo (worded for a timesheet: `<key>` cannot record a break: it has no `append_to` …; the variant gets a kind, or a second variant NoBreakTarget, whichever reads cleaner);
    • CoreError::NotWorking { key } when working is None (`<key>`: not working, no card of today is open), so a stray break is never written.
  • Tests on a MemoryStore:

    • working: open card, closed card, card after now, open card yesterday, invalid today;
    • break tags: starter, through a file type that already places something, with an alias;
    • record_break: with and without text, created daily file vs. existing one, not working, no append_to;
    • validation: both messages and the into_file hint;
    • rename_command renaming a file command updates append_to of both types.

api.rs: timesheet_working(project, stream, key, now) -> Result<Option<Working>, CoreError>, can_break(project, key) -> bool, timesheet_break(store, project, stream, key, text, now) -> Result<Appended, CoreError>.

3. CLI

crates/streamd-cli/src/commands/timesheet/:

  • streamd <key> break [TEXT...] is a subcommand built like append of R31. It stands alone, so combining it with -i, -d or --debug is a usage error (exit 2). It prints Break at HH:MM in <path> (paths as in R21) and opens no editor.
  • NotWorking and NoAppendTo print the message and exit 1.
  • The reminder CLI and views: the wording follows append_to.
  • Integration tests in tests/cli.rs: a break after a card, with text, the not-working error, the missing append_to error, the usage errors, and into_file being rejected.

4. Bindings

crates/streamd-ffi:

  • Project.timesheetWorking(stream, key, now) -> Working? (since as a timestamp, opened as an EntrySource), canBreak(key), and timesheetBreak(store, stream, key, text?, now) -> Appended.
  • StreamdException: NoIntoFile → NoAppendTo, plus NotWorking.
  • outline.rs: reminder settings intoFile → appendTo, timesheet settings get appendTo, with the round-trip test.
  • Tests for each.

5. Android

core/:

  • StreamSession:
    • working(now): List<Working> gives one entry per timesheet command with an open card: key, since (Instant), the Entry that opened it, and canBreak.
    • takeBreak(key, now): TookBreak (Done(id) / Failed(reason)) calls timesheetBreak and then changed(file).
  • StreamReading / a new Working interface on StreamViewModel. After Done, the view model commits through repository.written(id), as markDone does.
  • reason() covers the new variants.
  • The rename intoFile → appendTo throughout StreamCard.kt, ConfigCommandForms.kt (the reminder field is now "Append to"), ConfigDraft.kt, ConfigList.kt (fileCommands serves both types) and their tests. The timesheet form gets the same "Append to" choice of fileCommands.

ui/:

  • Timesheet screen: below the ScreenBar of TimesheetScreen, or as its detail, a working row shows "Working since 09:12 · 2:31" (clockText / minutesText of TimesheetWeek.kt) with a Stop button (only if canBreak). It uses secondaryContainer / onSecondaryContainer, never the warning colour. TimesheetContent gets the Working? and an onStop.
  • Stream tab: a WorkingBanner in Components.kt, built like ReminderBanner but tonal. It reads "Working since 09:12 · " with Stop, and tapping it opens the timesheet command (Tab.Command route). There is one per working timesheet command, above the reminder banners. StreamContent gets the list.
  • The elapsed time is drawn from a now the screen is given. A LaunchedEffect ticks it once a minute in the screen, never in the content composable, so screenshots are fixed.
  • Strings are in strings.xml.

notify/ (on top of #288):

  • Channel working ("Working time", low importance, silent).
  • WorkingNotifications: one ongoing notification (setOngoing(true), setOnlyAlertOnce) per working timesheet command, with id and tag from its key:
    • The title is "Working" plus the command label. The body is "since 09:12", with setWhen(since) and setUsesChronometer(true), so Android counts the time without updates.
    • Tapping it opens MainActivity on that timesheet command.
    • The Stop working action is shown only if canBreak.
    • The notification is withdrawn as soon as the command is no longer working.
  • When it is updated: in the same check Streamd runs on every new Ready (#288), and in ReminderWorker after its pull. A card opened on the desktop therefore shows up at the next background check. While the app runs, a card written in the app shows at once.
    • Scheduling: if a card is open, the next run is also scheduled at local midnight, so the notification of a day that ended is withdrawn.
  • Stop action: a PendingIntent to BreakReceiver, which enqueues an expedited BreakWorker with the key. The worker:
    1. streamd.ready() and session.takeBreak(key, now).
    2. On Done(id): repository.written(id), which commits and pushes as the settings say, then withdraws the notification.
    3. On Failed(reason): replaces the notification with the reason and keeps Stop working. If the reason is NotWorking, it just withdraws it.
    4. Runs the check again.
  • Permission: the POST_NOTIFICATIONS request of #288 is asked as well when a timesheet command with append_to exists, not only for a reminder command.
  • On Android 14+ a user can swipe away an ongoing notification. The next check posts it again, which is fine for a status.

Tests:

  • Unit tests for the pure parts: what to post, update and cancel from the working list and the posted keys, kept pure in WorkingAlerts.kt like ReminderAlerts; the elapsed text; the appendTo rename in ConfigListTest and StreamSessionTest, plus takeBreak and working there.

  • New screenshot scenes:

    • TimesheetWorkingScreenshot: the working row, with and without Stop.
    • StreamWorkingBannerScreenshot: a working banner above a reminder banner.
    • The timesheet config form with "Append to".

    Record them and look at every changed image.

  • By hand on the emulator: write a card in the app, then the notification appears and counts. Stop writes @Timesheet @Break @HHMMSS into today's daily file, commits and pushes, and the banner and the notification go away. A card from the desktop shows after a pull.

6. Documentation

  • REQUIREMENTS.md:
    • R18a: append_to and its validation.
    • New R18f: Break: working, the break tags, streamd <key> break [TEXT...], its output and errors.
    • R29: into_file → append_to, with the rejection message of the old name.
    • R31: append is also written by a break.
    • R35: the working indicator, the banner and the ongoing notification.
  • README.md: append_to, streamd timesheet break, and the working notification of the app. Mention the rename for existing configurations.
  • AGENTS.md:
    • clock.rs (working, break_tags, record_break) and the shared inherited.
    • timesheet_working, can_break and timesheet_break in the facade list.
    • The into_file → append_to rename wherever it is named (reminder module, rename_command, ffi outline.rs, Android ConfigList).
    • WorkingNotifications / BreakWorker in notify/, and WorkingBanner in Components.kt.

Commits (suggested)

  1. feat(reminder)!: into_file is now append_to
  2. feat(timesheet): append_to and the break of a running card
  3. feat(timesheet): streamd <key> break
  4. feat(ffi): working, canBreak and timesheetBreak
  5. feat(android)!: append_to in the forms of reminders and timesheets
  6. feat(android): show running work and stop it from a notification
  7. test(android): record the working banner and row
  8. docs: the break of a timesheet and append_to
## Plan A timesheet command gets the file command its breaks are written into. The CLI gets `streamd <key> break [TEXT...]`. When today has an open card, the app says so on the timesheet screen and on the Stream tab, and shows an ongoing notification with a **Stop working** action that writes the break. The notification side builds on #288. It reuses the `Streamd` application object, the `notify/` package and the checks it runs whenever the stream is another `Ready`. **#288 is implemented first.** ### Decisions (clarified with the user) - **Field name:** `append_to`, the file command a line is appended to. It is the same field on a reminder: **`into_file` of a reminder is renamed to `append_to`**. This is a breaking change. An old `into_file` is rejected with a pointed message (below). - **Indicator:** shown in the app bar of the timesheet screen and as a banner on the Stream tab, in a **non-warning colour** (a tonal container, not the amber of `StreamdTheme.colors`). - **Scope:** only the break. Starting work stays a manual entry. There is no `card` subcommand and no start button. - **Text:** `break` takes an optional text, which goes after the tags as with `append`. The button of the notification and the app writes none. ### 1. Core: `append_to` on reminders and timesheets `config/project/file.rs`, `validate.rs`, `schema.rs`, `change.rs`, `complete.rs`, `rename.rs`, `init.toml`: - Reminder: `into_file` → `append_to` everywhere: the file model, `ReminderCommand.append_to`, schema docs, the completion of values (the file commands), `change.rs`, and `rename_command`, which renames the `append_to` of reminders **and of timesheets**. - An `into_file` in a reminder table is an error at its path: ``commands.<key>.into_file: `into_file` was renamed; write `append_to = "<value>"` ``. This is a special case before the generic unknown-field message. - `CoreError::NoIntoFile` → `NoAppendTo { key }`. The message becomes `` `<key>` cannot be marked as done: it has no `append_to`, the file command to write into; add one to [commands.<key>] ``. - Timesheet: `TimesheetCommand.append_to: Option<String>`, which is optional, plus its schema entry, its `change.rs` line and its completion. - Validation of a timesheet with `append_to`: 1. It must name a file command, with the same message and `closest` suggestion the reminder has. 2. Some tags must write the `break` value into a file of that command (`break_tags`, below). Otherwise: `commands.<key>.append_to: no tags place { <dimension> = "<BREAK>" }, so `break` cannot write it into a file of `<file>`; add a tag that does, or remove `append_to``. - `init.toml`: the reminder examples use `append_to`. `[commands.timesheet]` gets `append_to = "daily"`. ### 2. Core: working and the break New `commands/timesheet/clock.rs`: - `working<S: Borrow<LocalizedShard>>(command, shards, now, tz) -> Option<Working>`, where `Working { since: DateTime<Utc>, opened: EntrySource }`. It returns the open timecard of **today** (local day in `tz`), i.e. the last `TimecardSource` of today whose `closed` is `None`, taken from `extract_timesheets`. It returns `None` if: - the card's start is after `now` (logged ahead), or - today is invalid, or - there is nothing open. An open card of an earlier day is a `DayProblem`, not running work. - `break_tags(command, file, config) -> Option<Vec<String>>` returns the tags of a line that places `dimension = values[break]`. It uses `state::derive::derive(config, dimension, &inherited_markers, break_value)` from the markers that a file of `file` hands down (the `inherited` of `reminder/done.rs`, moved to `file/` so both share it). The result is canonical names, never an alias. With the starter it is `@Timesheet @Break`. - `record_break(store, commands, config, shards, key, text: Option<&str>, now, tz) -> Result<Appended, CoreError>` joins the tags with the text and appends them through `file::append` (which adds the moment markers). Its errors: - `CoreError::NoAppendTo` (worded for a timesheet: `` `<key>` cannot record a break: it has no `append_to` … ``; the variant gets a `kind`, or a second variant `NoBreakTarget`, whichever reads cleaner); - `CoreError::NotWorking { key }` when `working` is `None` (`` `<key>`: not working, no card of today is open ``), so a stray break is never written. - Tests on a `MemoryStore`: - working: open card, closed card, card after `now`, open card yesterday, invalid today; - break tags: starter, through a file type that already places something, with an alias; - `record_break`: with and without text, created daily file vs. existing one, not working, no `append_to`; - validation: both messages and the `into_file` hint; - `rename_command` renaming a file command updates `append_to` of both types. `api.rs`: `timesheet_working(project, stream, key, now) -> Result<Option<Working>, CoreError>`, `can_break(project, key) -> bool`, `timesheet_break(store, project, stream, key, text, now) -> Result<Appended, CoreError>`. ### 3. CLI `crates/streamd-cli/src/commands/timesheet/`: - `streamd <key> break [TEXT...]` is a subcommand built like `append` of R31. It stands alone, so combining it with `-i`, `-d` or `--debug` is a usage error (exit 2). It prints `Break at HH:MM in <path>` (paths as in R21) and opens no editor. - `NotWorking` and `NoAppendTo` print the message and exit 1. - The reminder CLI and views: the wording follows `append_to`. - Integration tests in `tests/cli.rs`: a break after a card, with text, the not-working error, the missing `append_to` error, the usage errors, and `into_file` being rejected. ### 4. Bindings `crates/streamd-ffi`: - `Project.timesheetWorking(stream, key, now) -> Working?` (`since` as a timestamp, `opened` as an `EntrySource`), `canBreak(key)`, and `timesheetBreak(store, stream, key, text?, now) -> Appended`. - `StreamdException`: `NoIntoFile` → `NoAppendTo`, plus `NotWorking`. - `outline.rs`: reminder settings `intoFile` → `appendTo`, timesheet settings get `appendTo`, with the round-trip test. - Tests for each. ### 5. Android `core/`: - `StreamSession`: - `working(now): List<Working>` gives one entry per timesheet command with an open card: `key`, `since` (`Instant`), the `Entry` that opened it, and `canBreak`. - `takeBreak(key, now): TookBreak` (`Done(id)` / `Failed(reason)`) calls `timesheetBreak` and then `changed(file)`. - `StreamReading` / a new `Working` interface on `StreamViewModel`. After `Done`, the view model commits through `repository.written(id)`, as `markDone` does. - `reason()` covers the new variants. - The rename `intoFile` → `appendTo` throughout `StreamCard.kt`, `ConfigCommandForms.kt` (the reminder field is now "Append to"), `ConfigDraft.kt`, `ConfigList.kt` (`fileCommands` serves both types) and their tests. The timesheet form gets the same "Append to" choice of `fileCommands`. `ui/`: - **Timesheet screen:** below the `ScreenBar` of `TimesheetScreen`, or as its `detail`, a working row shows "Working since 09:12 · 2:31" (`clockText` / `minutesText` of `TimesheetWeek.kt`) with a **Stop** button (only if `canBreak`). It uses `secondaryContainer` / `onSecondaryContainer`, never the warning colour. `TimesheetContent` gets the `Working?` and an `onStop`. - **Stream tab:** a `WorkingBanner` in `Components.kt`, built like `ReminderBanner` but tonal. It reads "Working since 09:12 · <command label>" with **Stop**, and tapping it opens the timesheet command (`Tab.Command` route). There is one per working timesheet command, above the reminder banners. `StreamContent` gets the list. - The elapsed time is drawn from a `now` the screen is given. A `LaunchedEffect` ticks it once a minute in the screen, never in the content composable, so screenshots are fixed. - Strings are in `strings.xml`. `notify/` (on top of #288): - **Channel** `working` ("Working time", low importance, silent). - **`WorkingNotifications`:** one ongoing notification (`setOngoing(true)`, `setOnlyAlertOnce`) per working timesheet command, with id and tag from its `key`: - The title is "Working" plus the command label. The body is "since 09:12", with `setWhen(since)` and `setUsesChronometer(true)`, so Android counts the time without updates. - Tapping it opens `MainActivity` on that timesheet command. - The **Stop working** action is shown only if `canBreak`. - The notification is withdrawn as soon as the command is no longer working. - **When it is updated:** in the same check `Streamd` runs on every new `Ready` (#288), and in `ReminderWorker` after its pull. A card opened on the desktop therefore shows up at the next background check. While the app runs, a card written in the app shows at once. - Scheduling: if a card is open, the next run is also scheduled at local midnight, so the notification of a day that ended is withdrawn. - **Stop action:** a `PendingIntent` to `BreakReceiver`, which enqueues an expedited `BreakWorker` with the `key`. The worker: 1. `streamd.ready()` and `session.takeBreak(key, now)`. 2. On `Done(id)`: `repository.written(id)`, which commits and pushes as the settings say, then withdraws the notification. 3. On `Failed(reason)`: replaces the notification with the reason and keeps **Stop working**. If the reason is `NotWorking`, it just withdraws it. 4. Runs the check again. - **Permission:** the `POST_NOTIFICATIONS` request of #288 is asked as well when a timesheet command with `append_to` exists, not only for a reminder command. - On Android 14+ a user can swipe away an ongoing notification. The next check posts it again, which is fine for a status. **Tests:** - Unit tests for the pure parts: what to post, update and cancel from the `working` list and the posted keys, kept pure in `WorkingAlerts.kt` like `ReminderAlerts`; the elapsed text; the `appendTo` rename in `ConfigListTest` and `StreamSessionTest`, plus `takeBreak` and `working` there. - New screenshot scenes: - `TimesheetWorkingScreenshot`: the working row, with and without Stop. - `StreamWorkingBannerScreenshot`: a working banner above a reminder banner. - The timesheet config form with "Append to". Record them and look at every changed image. - By hand on the emulator: write a card in the app, then the notification appears and counts. Stop writes `@Timesheet @Break @HHMMSS` into today's daily file, commits and pushes, and the banner and the notification go away. A card from the desktop shows after a pull. ### 6. Documentation - `REQUIREMENTS.md`: - R18a: `append_to` and its validation. - New **R18f: Break**: `working`, the break tags, `streamd <key> break [TEXT...]`, its output and errors. - R29: `into_file` → `append_to`, with the rejection message of the old name. - R31: `append` is also written by a break. - R35: the working indicator, the banner and the ongoing notification. - `README.md`: `append_to`, `streamd timesheet break`, and the working notification of the app. Mention the rename for existing configurations. - `AGENTS.md`: - `clock.rs` (`working`, `break_tags`, `record_break`) and the shared `inherited`. - `timesheet_working`, `can_break` and `timesheet_break` in the facade list. - The `into_file` → `append_to` rename wherever it is named (reminder module, `rename_command`, ffi `outline.rs`, Android `ConfigList`). - `WorkingNotifications` / `BreakWorker` in `notify/`, and `WorkingBanner` in `Components.kt`. ### Commits (suggested) 1. `feat(reminder)!: into_file is now append_to` 2. `feat(timesheet): append_to and the break of a running card` 3. `feat(timesheet): streamd <key> break` 4. `feat(ffi): working, canBreak and timesheetBreak` 5. `feat(android)!: append_to in the forms of reminders and timesheets` 6. `feat(android): show running work and stop it from a notification` 7. `test(android): record the working banner and row` 8. `docs: the break of a timesheet and append_to`
Collaborator

Implemented in PR #291 (branch 289_timesheet-stop-working, 8 commits, following the plan's split).

Effort: about 1 hour of agent time (15:21 → about 16:25 on 2026-10-17). Roughly 650k tokens of context processed over the session; this is an estimate, since the harness doesn't report exact usage.

Findings during implementation

  • check_reachable already requires that a tag places every timesheet value. So the new break check only fires where the value is placed together with another dimension, because derive keeps the other dimensions unchanged. The test uses that case (@Stop placing timesheet and project).
  • derive(config, dim, &[], value) from an empty line finds @Timesheet @Break directly: the search tries combinations of added tags. A placement already handed down by the file type needs no tag, as with reminders.
  • working() takes the moment of the card from the shard that matches the opened source. A card logged ahead of now is closed to a zero-length timecard by extract_timesheets, so it is filtered out explicitly.
  • The ticking now lives in App (not screenshot-tested) and is passed down. This keeps StreamScreen/TimesheetContent deterministic in layoutlib, which runs no effects.
  • Ready.breaking (timesheet keys that canBreak) is computed in the session, so wantsNotifications can ask for the permission without the UI calling the bindings.
  • The notification of running work is reposted at every check (silent, setUsesChronometer), so a swiped one comes back. While a card is open, a check is scheduled at the next local midnight to withdraw it.
  • Not verified by hand on the emulator: the ongoing notification, its Stop working, and the commit/push. All unit tests, screenshot tests, nix flake check and nix build .#streamd-app pass.
Implemented in PR #291 (branch `289_timesheet-stop-working`, 8 commits, following the plan's split). **Effort:** about 1 hour of agent time (15:21 → about 16:25 on 2026-10-17). Roughly 650k tokens of context processed over the session; this is an estimate, since the harness doesn't report exact usage. **Findings during implementation** - `check_reachable` already requires that a tag places every timesheet value. So the new break check only fires where the value is placed *together with another dimension*, because `derive` keeps the other dimensions unchanged. The test uses that case (`@Stop` placing `timesheet` and `project`). - `derive(config, dim, &[], value)` from an empty line finds `@Timesheet @Break` directly: the search tries combinations of added tags. A placement already handed down by the file type needs no tag, as with reminders. - `working()` takes the moment of the card from the shard that matches the `opened` source. A card logged ahead of now is closed to a zero-length timecard by `extract_timesheets`, so it is filtered out explicitly. - The ticking `now` lives in `App` (not screenshot-tested) and is passed down. This keeps `StreamScreen`/`TimesheetContent` deterministic in layoutlib, which runs no effects. - `Ready.breaking` (timesheet keys that `canBreak`) is computed in the session, so `wantsNotifications` can ask for the permission without the UI calling the bindings. - The notification of running work is reposted at every check (silent, `setUsesChronometer`), so a swiped one comes back. While a card is open, a check is scheduled at the next local midnight to withdraw it. - Not verified by hand on the emulator: the ongoing notification, its Stop working, and the commit/push. All unit tests, screenshot tests, `nix flake check` and `nix build .#streamd-app` pass.
Sign in to join this conversation.
No labels
android
cli
epic
planned
tui
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
kfickel/streamd#289
No description provided.