From 6516e8955e5f022c38814bd224a9af1c8c7fde2f Mon Sep 17 00:00:00 2001 From: Bakhomious Basta Date: Thu, 29 Oct 2026 18:40:59 +0100 Subject: [PATCH] feat(time): list the hours to log as attention items The time service now answers the attention contract, so the Home's "Needs you" lists each weekday of the last 7 days with nothing logged on Jira, today's included until anything is logged, due at 17:00 on its day in the service's zone (decision 188). The items come from the same poller as the time report and the week, so the three agree, and one read of Jira serves them all. --- docs/plan.md | 20 ++- services/time/attention_test.go | 74 +++++++++ services/time/grpc_test.go | 34 ++++ services/time/internal/attention/attention.go | 100 +++++++++++ .../time/internal/attention/attention_test.go | 157 ++++++++++++++++++ .../time/internal/attention/reads_test.go | 119 +++++++++++++ services/time/internal/report/report.go | 7 +- services/time/internal/report/report_test.go | 22 ++- services/time/jira.go | 13 +- services/time/jira_test.go | 58 ++++++- services/time/main.go | 20 ++- services/time/week_test.go | 33 +--- 12 files changed, 597 insertions(+), 60 deletions(-) create mode 100644 services/time/attention_test.go create mode 100644 services/time/grpc_test.go create mode 100644 services/time/internal/attention/attention.go create mode 100644 services/time/internal/attention/attention_test.go create mode 100644 services/time/internal/attention/reads_test.go diff --git a/docs/plan.md b/docs/plan.md index abdf6c0..e43e093 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -170,13 +170,14 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `time-week` | The second contract between services, `bakhyard.time.v1`, whose `TimeService.GetWeek` answers this week's hours against the week's target, for the Home's "This week". The time service answers it from the latest read of my worklogs to succeed: with no week when Jira isn't set up, and with `UNAVAILABLE` while no read has succeeded. The ninth of `time-tracking`'s thirteen steps (decision 185) | Done (#154) | | `home-time-week` | The Home's "This week" shows my real week: home asks the time service for it over the time contract, at the same time as it asks its peers for their items and with the same 1.5 s, and answers it as the Home's `week`, beside a new `weekState` that says how asking went, `ok` or `unavailable`. A week that time couldn't give still leaves "This week" out, until `week-unavailable` shows "Hours unavailable". The tenth of `time-tracking`'s fourteen steps (decision 186) | Done (#155) | | `week-unavailable` | The Home's "This week" says "Hours unavailable" when home couldn't get the week from the time service, its `weekState` being `unavailable`: the head and its "Day report" link stay, and the card says so in place of the hours, in their type and in `--ink-muted`, without the "Logged on Jira" label and the bar. With `ok` and no week, as without Jira, or with neither member, the section stays out (decision 161). The eleventh of `time-tracking`'s fourteen steps (decision 187) | Done (#156) | +| `time-attention` | The time service answers the attention contract, so the Home's "Needs you" lists the hours to log on Jira: a row for each weekday of the last 7 days with nothing logged, today's included until anything is logged, each due at 17:00 on its day, so the days before today show as late. The twelfth of `time-tracking`'s fourteen steps (decision 188) | Done (#157) | | `nudge-merge` | Nudge, the user's phone-questions tool for agents (`codedogapp/nudge`), moves into Bakhyard: one app, not two (decision 157). The nudge session does the work, one small PR per step, from its plan, [`bakhyard-merge.md`](https://github.com/codedogapp/nudge/blob/main/docs/bakhyard-merge.md); this session doesn't stop its own steps for it | In review | After these: the product phases (§8.2), each split into steps. **Deferred until a step needs it**: TypeScript code from the contracts and `buf breaking` (decision 158), NATS and events, OpenTelemetry, Prometheus metrics, streaming, unix sockets, a shared kit, per-service Go modules and `go.work`, the git hook, most mise tasks, and process-compose's detached mode and API server (§4.9). -**Ideas**: `sonar-issues` could page past 200 issues, or warn when it cuts them off. Longer task scripts could move into files that `check` shellchecks; today only the workflow's `run:` scripts are checked. A gate self-test could lint known-bad fixtures and expect every gate to fire, so a config typo can't switch one off silently (§10). pnpm's `savePrefix` setting could be empty, so `pnpm add` pins exact versions without `-E`. `//apps/web:check` could run `pnpm dedupe --check`, so a lockfile that a `pnpm add` by hand leaves with an older copy of a package fails too; Renovate's updates are deduped already (decision 101). The Home's components each style their own big numbers and mono labels (the app counts, the week's hours, the section head's note); shared type styles could replace the copies. The header's nav marks an app's link `aria-current="page"` on the app's deeper pages too (`/notes/x`); ARIA and GOV.UK's service navigation keep "page" for the link's own page and use "true" for a section the page is in, as the tab bar's More does (decision 52). Screen readers aren't told when a page changes in place; a polite live region that reads out the new title after each navigation could tell them, as SvelteKit's route announcer does. Each service's port is written twice in `process-compose.yaml`, in its `BAKHYARD_LISTEN` and its probe, and the gateway's `BAKHYARD_PEERS` repeats the other services'; a check could keep them in step. Nothing checks that every address is on the loopback, though nothing should listen on the LAN (§3). Pushover could join Web Push as a second notification channel, for alerts that repeat until acknowledged (§2, decision 64). After signing out, Back may bring back the signed-in page from the browser's back-forward cache, data included; the app could check its session again when a page is restored (`pageshow` with `persisted`). The web app's dev server (`//apps/web:dev`) proxies `/api` to the gateway on the loopback, which has answered 403 there since `auth-tailscale` (decision 77), so it shows the app without data; local development needs a way through the lock. The Notes filter matches the notes' paths only; a full-text search could look inside them too. A note with a dot in its name gets the web service's 404 when its page is reloaded (decision 94); the service could serve the app for every missing path under `/notes/`. Back and Forward can't put a note's page where it was left: the browser restores the scroll position once the page shows, before the note has loaded; the page could restore it itself once the note is in (the Navigation API's `scroll: 'manual'` and `event.scroll()`). Footnote links in a note go nowhere: rehype-sanitize prefixes the footnotes' ids but not the links to them, which GitHub fixes with a script (the vault has no footnotes yet). A task's checkbox has no accessible name, as on GitHub; it could take its item's text. These link forms wait until the vault uses them (decision 97): a note shown inline, where `![[Note]]` is a link today; heading and block links into other notes, which open the note at its top; links in a list property; links to files that aren't notes; relative paths (`../`); and links that match a note's name in another case. An image embed's size waits the same way (decision 98): `![[x.png|300]]` and `![[x.png|300x200]]` show the image at its own size today. So do embeds of other files, such as audio, video and PDFs, Markdown images of the vault's files (`![](x.png)`), and a way to see an image larger. Callouts, `> [!note]`, wait until the vault has one (decision 99). KaTeX's red for a formula that doesn't parse, `#cc0000`, has about 3.2:1 contrast on the dark paper, under WCAG AA's 4.5:1; a colour of our own could replace it. Every note's page loads KaTeX with the renderer, though one note in the vault has math; KaTeX could load only for a note that has some. The build also ships KaTeX's woff and ttf fonts, which a browser loads only when it can't read woff2, the first format KaTeX's stylesheet lists. Svelte's docs advise against the `class:` directive since 5.16, when the `class` attribute began to take an object or an array; `todos-groups` uses the attribute, while `AppCards` and the Notes `Tree` still use the directive. No test runs a migration's Down; a test could take each service's database down a migration and up again, so a Down that doesn't undo its Up fails. A todo dragged near the screen's top or bottom edge could scroll the page, so a todo on a long list can go further than the screen shows; today the user scrolls first, or uses the editor's moves (decision 120). Escape could cancel a drag. A dragged todo's row could follow the pointer while the other rows slide aside to make room, as iOS lists do, rather than a line marking the place; across groups, the cards would have to grow and shrink as it passes (decision 120). A todo could be dropped into a priority that has no todos, which has no section to drop it in; the editor's priority does that (decision 117). Thirteen components copy the card's style, a 1 px `--rule` border on the `--surface` with the offset shadow, from the Home's "Needs you" to the Time page's days; a shared class could replace the copies, as `list.css` did for the rows (decision 124). The end-to-end tests keep no trace of a failed test, so a flaky one can't be looked into afterwards; `trace: 'retain-on-failure'` would keep one. In `todos-ticket`, the Move test's five-second wait for the second move ran out twice in `mise run check`, on battery in Low Power Mode, though the move did land just after; it passed in 660 runs alone or in the whole suite. A linked todo's badge could link to its ticket in Jira; it sits in the box's label, where a tap ticks the todo, so the link needs a target of its own, at least 24 px and apart from the label (WCAG 2.2 SC 2.5.8, decision 126). The Jira inbox keeps a dismissal for good, so a dismissed ticket that leaves my open tickets and comes back later, reassigned to me or reopened, stays out of the inbox; the service could forget a dismissal once a search that succeeds no longer finds its ticket (decision 128). An inbox row could link to its ticket in Jira, as the Jira section's rows do; the row isn't a link, since it holds an Add button, so the link needs a target of its own, as the badge's does (decision 129). The Notes page's Sync could also make Gitea mirror the vault at once, through Gitea's `POST /repos/{owner}/{repo}/mirror-sync`, so a note that the Mac pushes shows on the laptop within a minute rather than up to ten (decision 151). The images workflow builds and tags all six images on every change, so Flux restarts every service each time (decision 152); it could build only the images that a change touches. GitHub's CI could build the images on each PR too, without pushing them, so a Dockerfile that no longer builds fails before the merge rather than in Gitea after it. The five Go services' Dockerfiles differ only in the service's name; one Dockerfile with the name as a build argument could replace them (decision 153). The Jira inbox's rule lives in two places, the Todos page's `inboxTickets` and todos' attention lister (decision 160); the page could take the inbox from todos through a JSON route, so the rule lives in one. The services talk in plaintext inside the cluster, gRPC over h2c and HTTP/1.1, behind the NetworkPolicies (decisions 137 and 158); mutual TLS between them, from a service mesh or from cert-manager's certificates, would encrypt that traffic. SonarQube Cloud's rule for clear-text protocols, S5332, looks for an `http://` literal, so it doesn't see these calls (decision 168). The services' connections to Postgres are encrypted but don't check the server's certificate (`sslmode=require`); with CloudNativePG's CA mounted and `sslmode=verify-full`, they would (decision 170). pulls could find my Bitbucket workspaces itself, through `GET /2.0/user/workspaces`, which needs the scope `read:workspace:bitbucket` that today's token lacks, and drop `BAKHYARD_BITBUCKET_WORKSPACES`. The time report's target is 8 hours each weekday, so a public holiday or a day off shows as a missing day (decision 180); a setting could list the days without a target. JSON can't encode a due time outside the years 0 to 9999, so an item that a peer sends with one makes home cut the whole Home's answer short, as hours that aren't finite would (decision 186); home could count that peer as unavailable instead. +**Ideas**: `sonar-issues` could page past 200 issues, or warn when it cuts them off. Longer task scripts could move into files that `check` shellchecks; today only the workflow's `run:` scripts are checked. A gate self-test could lint known-bad fixtures and expect every gate to fire, so a config typo can't switch one off silently (§10). pnpm's `savePrefix` setting could be empty, so `pnpm add` pins exact versions without `-E`. `//apps/web:check` could run `pnpm dedupe --check`, so a lockfile that a `pnpm add` by hand leaves with an older copy of a package fails too; Renovate's updates are deduped already (decision 101). The Home's components each style their own big numbers and mono labels (the app counts, the week's hours, the section head's note); shared type styles could replace the copies. The header's nav marks an app's link `aria-current="page"` on the app's deeper pages too (`/notes/x`); ARIA and GOV.UK's service navigation keep "page" for the link's own page and use "true" for a section the page is in, as the tab bar's More does (decision 52). Screen readers aren't told when a page changes in place; a polite live region that reads out the new title after each navigation could tell them, as SvelteKit's route announcer does. Each service's port is written twice in `process-compose.yaml`, in its `BAKHYARD_LISTEN` and its probe, and the gateway's `BAKHYARD_PEERS` repeats the other services'; a check could keep them in step. Nothing checks that every address is on the loopback, though nothing should listen on the LAN (§3). Pushover could join Web Push as a second notification channel, for alerts that repeat until acknowledged (§2, decision 64). After signing out, Back may bring back the signed-in page from the browser's back-forward cache, data included; the app could check its session again when a page is restored (`pageshow` with `persisted`). The web app's dev server (`//apps/web:dev`) proxies `/api` to the gateway on the loopback, which has answered 403 there since `auth-tailscale` (decision 77), so it shows the app without data; local development needs a way through the lock. The Notes filter matches the notes' paths only; a full-text search could look inside them too. A note with a dot in its name gets the web service's 404 when its page is reloaded (decision 94); the service could serve the app for every missing path under `/notes/`. Back and Forward can't put a note's page where it was left: the browser restores the scroll position once the page shows, before the note has loaded; the page could restore it itself once the note is in (the Navigation API's `scroll: 'manual'` and `event.scroll()`). Footnote links in a note go nowhere: rehype-sanitize prefixes the footnotes' ids but not the links to them, which GitHub fixes with a script (the vault has no footnotes yet). A task's checkbox has no accessible name, as on GitHub; it could take its item's text. These link forms wait until the vault uses them (decision 97): a note shown inline, where `![[Note]]` is a link today; heading and block links into other notes, which open the note at its top; links in a list property; links to files that aren't notes; relative paths (`../`); and links that match a note's name in another case. An image embed's size waits the same way (decision 98): `![[x.png|300]]` and `![[x.png|300x200]]` show the image at its own size today. So do embeds of other files, such as audio, video and PDFs, Markdown images of the vault's files (`![](x.png)`), and a way to see an image larger. Callouts, `> [!note]`, wait until the vault has one (decision 99). KaTeX's red for a formula that doesn't parse, `#cc0000`, has about 3.2:1 contrast on the dark paper, under WCAG AA's 4.5:1; a colour of our own could replace it. Every note's page loads KaTeX with the renderer, though one note in the vault has math; KaTeX could load only for a note that has some. The build also ships KaTeX's woff and ttf fonts, which a browser loads only when it can't read woff2, the first format KaTeX's stylesheet lists. Svelte's docs advise against the `class:` directive since 5.16, when the `class` attribute began to take an object or an array; `todos-groups` uses the attribute, while `AppCards` and the Notes `Tree` still use the directive. No test runs a migration's Down; a test could take each service's database down a migration and up again, so a Down that doesn't undo its Up fails. A todo dragged near the screen's top or bottom edge could scroll the page, so a todo on a long list can go further than the screen shows; today the user scrolls first, or uses the editor's moves (decision 120). Escape could cancel a drag. A dragged todo's row could follow the pointer while the other rows slide aside to make room, as iOS lists do, rather than a line marking the place; across groups, the cards would have to grow and shrink as it passes (decision 120). A todo could be dropped into a priority that has no todos, which has no section to drop it in; the editor's priority does that (decision 117). Thirteen components copy the card's style, a 1 px `--rule` border on the `--surface` with the offset shadow, from the Home's "Needs you" to the Time page's days; a shared class could replace the copies, as `list.css` did for the rows (decision 124). The end-to-end tests keep no trace of a failed test, so a flaky one can't be looked into afterwards; `trace: 'retain-on-failure'` would keep one. In `todos-ticket`, the Move test's five-second wait for the second move ran out twice in `mise run check`, on battery in Low Power Mode, though the move did land just after; it passed in 660 runs alone or in the whole suite. A linked todo's badge could link to its ticket in Jira; it sits in the box's label, where a tap ticks the todo, so the link needs a target of its own, at least 24 px and apart from the label (WCAG 2.2 SC 2.5.8, decision 126). The Jira inbox keeps a dismissal for good, so a dismissed ticket that leaves my open tickets and comes back later, reassigned to me or reopened, stays out of the inbox; the service could forget a dismissal once a search that succeeds no longer finds its ticket (decision 128). An inbox row could link to its ticket in Jira, as the Jira section's rows do; the row isn't a link, since it holds an Add button, so the link needs a target of its own, as the badge's does (decision 129). The Notes page's Sync could also make Gitea mirror the vault at once, through Gitea's `POST /repos/{owner}/{repo}/mirror-sync`, so a note that the Mac pushes shows on the laptop within a minute rather than up to ten (decision 151). The images workflow builds and tags all six images on every change, so Flux restarts every service each time (decision 152); it could build only the images that a change touches. GitHub's CI could build the images on each PR too, without pushing them, so a Dockerfile that no longer builds fails before the merge rather than in Gitea after it. The five Go services' Dockerfiles differ only in the service's name; one Dockerfile with the name as a build argument could replace them (decision 153). The Jira inbox's rule lives in two places, the Todos page's `inboxTickets` and todos' attention lister (decision 160); the page could take the inbox from todos through a JSON route, so the rule lives in one. The services talk in plaintext inside the cluster, gRPC over h2c and HTTP/1.1, behind the NetworkPolicies (decisions 137 and 158); mutual TLS between them, from a service mesh or from cert-manager's certificates, would encrypt that traffic. SonarQube Cloud's rule for clear-text protocols, S5332, looks for an `http://` literal, so it doesn't see these calls (decision 168). The services' connections to Postgres are encrypted but don't check the server's certificate (`sslmode=require`); with CloudNativePG's CA mounted and `sslmode=verify-full`, they would (decision 170). pulls could find my Bitbucket workspaces itself, through `GET /2.0/user/workspaces`, which needs the scope `read:workspace:bitbucket` that today's token lacks, and drop `BAKHYARD_BITBUCKET_WORKSPACES`. The time report's target is 8 hours each weekday, so a public holiday or a day off shows as a missing day (decision 180); a setting could list the days without a target. JSON can't encode a due time outside the years 0 to 9999, so an item that a peer sends with one makes home cut the whole Home's answer short, as hours that aren't finite would (decision 186); home could count that peer as unavailable instead. time's tests run in the process's zone, and CI's is UTC, so they can't tell a report or a due time in UTC from one in the local zone there (decision 188); `routes` could take the zone, so a test could pin one far from UTC. ## 1. Goal and scope @@ -413,7 +414,7 @@ flowchart LR ### 4.5 Contracts -> **v1.3:** for service-to-service calls and NATS payloads only (decision 43). The public APIs stay hand-written JSON. `attention-contract` built the first contract, `bakhyard.attention.v1`, as decision 158 describes, and pulls and todos answer it (decisions 158 to 160); `time-week` built the second, `bakhyard.time.v1`, which the time service answers (decision 185); the rest of this section is the v1.2 design. +> **v1.3:** for service-to-service calls and NATS payloads only (decision 43). The public APIs stay hand-written JSON. `attention-contract` built the first contract, `bakhyard.attention.v1`, as decision 158 describes, and pulls, todos and time answer it (decisions 158 to 160 and 188); `time-week` built the second, `bakhyard.time.v1`, which the time service answers (decision 185); the rest of this section is the v1.2 design. Schema-first protobuf in `proto/`, a buf v2 module (lint STANDARD, breaking FILE). @@ -583,7 +584,7 @@ process-compose runs the services on the Mac only, for development; the laptop r ### 4.11 Home logic -> **v1.3:** as built in `home-fanout` (decision 167): home still answers `GET /home` with JSON rather than `GetHome`, and asks the peers in its own `BAKHYARD_PEERS` (§4.7), todos, pulls and time, rather than every service. Each call is gRPC over h2c with 1.5 s to answer. A failure other than `Unimplemented`, the timeout included, leaves that peer's items out and logs `list the items` with the peer's name, unless the request has ended. Since `home-sources` (decision 169), the answer's `sources` says how asking each peer went, `{"app","state"}` in `BAKHYARD_PEERS`' order, with the states in lower case, `ok` and `unavailable`, and no `message` yet. An item's app is its peer's name, and an item without an outside source has "Bakhyard" as its source. `late` is worked out once every peer has answered. Since `home-time-week` (decision 186), home also asks the peer named `time` for `GetWeek`, at the same time as the items and with the same 1.5 s, and answers the week with `weekState`. +> **v1.3:** as built in `home-fanout` (decision 167): home still answers `GET /home` with JSON rather than `GetHome`, and asks the peers in its own `BAKHYARD_PEERS` (§4.7), todos, pulls and time, rather than every service. Each call is gRPC over h2c with 1.5 s to answer. A failure other than `Unimplemented`, the timeout included, leaves that peer's items out and logs `list the items` with the peer's name, unless the request has ended. Since `home-sources` (decision 169), the answer's `sources` says how asking each peer went, `{"app","state"}` in `BAKHYARD_PEERS`' order, with the states in lower case, `ok` and `unavailable`, and no `message` yet. An item's app is its peer's name, and an item without an outside source has "Bakhyard" as its source. `late` is worked out once every peer has answered. Since `home-time-week` (decision 186), home also asks the peer named `time` for `GetWeek`, at the same time as the items and with the same 1.5 s, and answers the week with `weekState`. Since `time-attention` (decision 188), time lists items too, the weekdays with nothing logged, where it answered `Unimplemented` before. - `GetHome` fans out `ListItems` to every peer except nats, concurrently (`wg.Go`), with a 1.5 s timeout each. - `Unimplemented` means that app has no items (OK). @@ -1201,7 +1202,7 @@ Every item is pending unless it says done. The service an item lands in is in br - `pulls-reminders`: at 10:00 and 15:00 on weekdays, pulls pushes one reminder that lists my PRs under their required approvals (decision 177). Done (#144). - `homelab-pulls-notify`: done (codedogapp/homelab#40). In the homelab repo, pulls' `BAKHYARD_PEERS` names notify, `TZ` gives it the user's zone, and a NetworkPolicy lets pulls reach notify. - Later, with a database for pulls: snooze (2 h / tomorrow / until updated) and mark as nudged, from the PRs page. -- `time-tracking` [time]. Needs `todos-jira`. Reads and writes standard Jira worklogs (§2, decision 46), with no timers: Clockwork already has them inside Jira. Fourteen steps (decisions 180, 181, 183, 186 and 187): +- `time-tracking` [time]. Needs `todos-jira`. Reads and writes standard Jira worklogs (§2, decision 46), with no timers: Clockwork already has them inside Jira. Fourteen steps (decisions 180, 181, 183, 186, 187 and 188): - `shared-jira`: todos' Jira client moves to the root `internal/jira`, with the code that reads its settings and token, for the `time` service; todos' poller of my tickets moves to a package of its own, `tickets`. Nothing changes in behaviour. Done (#147). - `jira-worklogs`: the client reads my worklogs in a span of days: my account from `/myself`, the tickets I logged on from a `worklogAuthor` search, and each ticket's worklogs. Done (#148). - `time-report`: a package of the `time` service, `report`, works out the time report from my worklogs: the hours I logged on each of the last 7 days, with every weekday before today that has none marked as missing, plus this week's total against the target. Done (#149). @@ -1213,7 +1214,7 @@ Every item is pending unless it says done. The service an item lands in is in br - `time-week`: `time` answers `TimeService/GetWeek` (§4.5) with this week's hours against the target, from the latest read of the worklogs to succeed: no week when Jira isn't set up, and `UNAVAILABLE` until a read succeeds (decision 185). Done (#154). - `home-time-week`: home asks `time` for the week, and answers it with how asking went, `weekState`, so the Home's "This week" shows my real week (decision 186). Done (#155). - `week-unavailable`: the Home's "This week" says "Hours unavailable" when home couldn't get the week from `time` (§7, decision 187). Done (#156). - - `time-attention`: `time` answers the attention contract with today's hours until they're logged, and each missing weekday. + - `time-attention`: `time` answers the attention contract with an item for each weekday with nothing logged, today's included until anything is logged, due at 17:00 on its day (decision 188). Done (#157). - `worklog-add`: the client adds a worklog, and `time` takes one at `POST /time/worklogs`. - `fill-day`: the Time page fills a missing day: pick one of my open tickets (assigned to me, not done) and the hours; Bakhyard shows the worklog it will add, starting at 09:00 local time, and writes it only after I confirm. @@ -1594,6 +1595,15 @@ Made on 2026-10-23: - **Changes**: each later Home decides afresh: the hours, "Hours unavailable", or no section. Offline, the last Home's state stays, as its week does. Nothing is announced when it changes, as nothing is when the hours change. - **Code**: `WeekCard`'s `week` becomes optional, and without one the card says "Hours unavailable"; `ThisWeek` passes it on, and `HomePage` gives it the week or, when the state is `unavailable`, none. The Time page shows the card only with a week, so it doesn't change. `home/api.ts` names the state type, `State`, shared by the sources and `weekState`. - **Rejected**: leaving the section out, as before, which reads as Jira not set up; a sentence in body text, such as "Couldn't load this week's hours.", which reads as the page failing and breaks the card's rhythm; keeping the label, "Hours unavailable · Logged on Jira", which contradicts itself; an empty or striped bar; an "Unavailable" chip; a card of the Home's own beside `WeekCard`, which would copy the card's style a fourteenth time (§0's ideas); an `unavailable` prop on `WeekCard` beside `week`, two props that could disagree. +188. **Hours to log on the Home** (`time-attention`): the lead's choices (2026-10-29), for the user to review. The time service answers the attention contract (decision 158), so the Home's "Needs you" lists the days whose hours I haven't logged on Jira. Home already asks it (decision 167), and got `Unimplemented` until now, which means no items. + - **The items**: one for each weekday of the time report's 7 days with nothing logged, today's included, the earliest first. The rule is the report's "missing" one (decision 181), nothing logged, so a day with a minute logged has no item, and weekends never have one. The report's days gain `Unlogged`, a weekday with nothing logged, today's included, on which `missing` now builds; it stays out of the JSON. + - **Today**: "Log today's hours", with the detail "Nothing logged yet", until anything is logged today. At midnight it becomes the item of a missing day, with the same ID and due time: only its words change. + - **The days before**: "Log Monday's hours", with the detail "Nothing logged". No date: within 7 days each weekday names one day, and the Time page, a tap away, shows the dates. + - **The fields**: the ID is the date, `2026-10-27`, so the Home's is `time/2026-10-27`. The source is Jira, where the hours go. The item is due at 17:00 on its day in the service's zone, 8 hours after the 09:00 at which a worklog that Bakhyard adds starts (decision 180), so today's turns late at 17:00 and the days before are late already, which puts them first on the Home (§4.11). The rows lead to the Time page, as any `time` item does. + - **When there are none**: the items come from the poller behind `GET /time/report` and `GetWeek` (decision 185), so the three agree. Without Jira, the answer has no items. With Jira set up but no read succeeded yet, the call fails with `UNAVAILABLE`, so the Home's sources say time is unavailable rather than nothing being due; after that, the items of the latest read to succeed, kept through the reads that fail. + - **A known limit**: the report's last day counts as today even when the report is old, as on the Time page. + - **Code**: `Lister` in `services/time/internal/attention`, beside `week`, as pulls' lister is in its `attention`; `routes` registers it on the connect server that serves `GetWeek`, and names the local zone once, for both the poller and the lister, so `poll` now takes the zone. + - **Rejected**: one item for all the missing days, which hides which they are; the "under 8 hours" rule, which would nag about every partly logged day; due at midnight, which makes today late only once it's over; no items until a read succeeds, which reads as nothing to log; the date in the detail, which Go and the app's en-GB format spell differently ("Sep" against "Sept"); a date type of its own, or a start time on the report's days; the weekday rule worked out again in `attention`. ## 10. Risks diff --git a/services/time/attention_test.go b/services/time/attention_test.go new file mode 100644 index 0000000..a4bb4ee --- /dev/null +++ b/services/time/attention_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "net/http" + "slices" + "testing" + "time" + + attentionv1 "github.com/codedogapp/bakhyard/internal/gen/bakhyard/attention/v1" + "github.com/codedogapp/bakhyard/internal/gen/bakhyard/attention/v1/attentionv1connect" +) + +func TestTimeListsTheWeekdaysOfItsReportWithNothingLogged(t *testing.T) { + router, err := routes(t.Context(), envOf(jiraSettings(jiraServer(t))), + secretsWith(map[string]string{"jira-token": "secret"})) + if err != nil { + t.Fatal(err) + } + + got := listItems(t, router) + + // Jira's worklog started a minute ago, which may be yesterday: the report says which day has it. + var want []string + + for _, day := range reportOf(t, router).Days { + date, err := time.ParseInLocation(time.DateOnly, day.Date, time.Local) + if err != nil { + t.Fatal(err) + } + + if day.LoggedHours == 0 && date.Weekday() != time.Saturday && + date.Weekday() != time.Sunday { + due := time.Date(date.Year(), date.Month(), date.Day(), 17, 0, 0, 0, time.Local) + want = append(want, day.Date+" due "+due.Format(time.RFC3339)) + } + } + + if len(want) == 0 || !slices.Equal(got, want) { + t.Errorf("got %q, want %q: a day each, due at 17:00", got, want) + } +} + +func TestTimeListsNoAttentionItemsWithoutJira(t *testing.T) { + router, err := routes(t.Context(), envOf(nil), secretsWith(nil)) + if err != nil { + t.Fatal(err) + } + + if got := listItems(t, router); len(got) != 0 { + t.Errorf("got %q, want no items", got) + } +} + +// listItems returns each item that router lists over the attention contract, called with the gRPC +// protocol, as " due ". +func listItems(t *testing.T, router http.Handler) []string { + t.Helper() + + client := attentionv1connect.NewAttentionServiceClient(grpcClient(t, router)) + + res, err := client.ListItems(t.Context(), &attentionv1.ListItemsRequest{}) + if err != nil { + t.Fatal(err) + } + + var items []string + + for _, item := range res.GetItems() { + due := item.GetDue().AsTime().In(time.Local) + items = append(items, item.GetId()+" due "+due.Format(time.RFC3339)) + } + + return items +} diff --git a/services/time/grpc_test.go b/services/time/grpc_test.go new file mode 100644 index 0000000..7eb2e47 --- /dev/null +++ b/services/time/grpc_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" +) + +// grpcClient returns a client that calls router, served until the test ends, with the gRPC +// protocol. +func grpcClient(t *testing.T, router http.Handler) *connect.Client { + t.Helper() + + // gRPC needs HTTP/2; between services it runs without TLS (h2c), as internal/server allows. + h2c := new(http.Protocols) + h2c.SetUnencryptedHTTP2(true) + + server := httptest.NewUnstartedServer(router) + server.Config.Protocols = h2c + server.Start() + t.Cleanup(server.Close) + + transport := &http.Transport{Protocols: h2c} + t.Cleanup(transport.CloseIdleConnections) + + httpClient := &http.Client{Transport: transport} + + return connect.NewClient( + connecthttp.NewTransport(httpClient, server.URL, connecthttp.WithGRPC()), + ) +} diff --git a/services/time/internal/attention/attention.go b/services/time/internal/attention/attention.go new file mode 100644 index 0000000..e2605f0 --- /dev/null +++ b/services/time/internal/attention/attention.go @@ -0,0 +1,100 @@ +// Package attention serves time's side of the attention contract: the weekdays of the time report +// with nothing logged, today's included, as items that need the user on the Home (docs/plan.md +// decision 188). +package attention + +import ( + "context" + "fmt" + "time" + + "connectrpc.com/connect/v2" + "google.golang.org/protobuf/types/known/timestamppb" + + attentionv1 "github.com/codedogapp/bakhyard/internal/gen/bakhyard/attention/v1" + "github.com/codedogapp/bakhyard/services/time/internal/report" + "github.com/codedogapp/bakhyard/services/time/internal/worklogs" +) + +// dueHour is the hour of a day by which its hours are due: 8 hours after the 09:00 at which the +// worklogs that Bakhyard adds start (decision 180). +const dueHour = 17 + +// Lister lists the weekdays with nothing logged in the time reports that a poller keeps, as the +// attention contract's items. +type Lister struct { + reports *worklogs.Poller + loc *time.Location +} + +// NewLister returns a lister of the weekdays with nothing logged in the reports that reports +// keeps, whose days follow loc, as the reports' do. It lists none when Jira isn't set up and +// reports is nil. +func NewLister(reports *worklogs.Poller, loc *time.Location) *Lister { + return &Lister{reports: reports, loc: loc} +} + +// ListItems lists the weekdays with nothing logged in the report that the latest poll to succeed +// worked out, today's included, the earliest first, once the first poll has ended, or returns +// ctx's error if ctx ends first. Until a poll succeeds, the items are unavailable. +func (l *Lister) ListItems( + ctx context.Context, + _ *attentionv1.ListItemsRequest, +) (*attentionv1.ListItemsResponse, error) { + if l.reports == nil { + return attentionv1.ListItemsResponse_builder{}.Build(), nil + } + + latest, err := l.reports.Latest(ctx) + if err != nil { + return nil, err + } + + if latest.Searched.IsZero() { + return nil, connect.NewError(connect.CodeUnavailable, + "no read of the worklogs on Jira has succeeded yet") + } + + days := latest.Found.Days + items := make([]*attentionv1.Item, 0, len(days)) + + for i, day := range days { + if !day.Unlogged { + continue + } + + item, err := l.item(day, i == len(days)-1) + if err != nil { + return nil, err + } + + items = append(items, item) + } + + return attentionv1.ListItemsResponse_builder{Items: items}.Build(), nil +} + +// item returns the attention item of day, a weekday with nothing logged, which is the report's +// today when today is true. It's due at 17:00 on the day. +func (l *Lister) item(day report.Day, today bool) (*attentionv1.Item, error) { + date, err := time.Parse(time.DateOnly, day.Date) + if err != nil { + return nil, fmt.Errorf("read the day's date: %w", err) + } + + title, detail := "Log "+date.Weekday().String()+"'s hours", "Nothing logged" + + if today { + title, detail = "Log today's hours", "Nothing logged yet" + } + + year, month, dayOfMonth := date.Date() + + return attentionv1.Item_builder{ + Id: new(day.Date), + Title: new(title), + Detail: new(detail), + Source: new(attentionv1.Source_SOURCE_JIRA), + Due: timestamppb.New(time.Date(year, month, dayOfMonth, dueHour, 0, 0, 0, l.loc)), + }.Build(), nil +} diff --git a/services/time/internal/attention/attention_test.go b/services/time/internal/attention/attention_test.go new file mode 100644 index 0000000..ebff593 --- /dev/null +++ b/services/time/internal/attention/attention_test.go @@ -0,0 +1,157 @@ +package attention_test + +import ( + "context" + "fmt" + "log/slog" + "slices" + "strings" + "testing" + "testing/synctest" + "time" + + attentionv1 "github.com/codedogapp/bakhyard/internal/gen/bakhyard/attention/v1" + "github.com/codedogapp/bakhyard/internal/jira" + "github.com/codedogapp/bakhyard/internal/poll" + "github.com/codedogapp/bakhyard/services/time/internal/attention" + "github.com/codedogapp/bakhyard/services/time/internal/report" + "github.com/codedogapp/bakhyard/services/time/internal/worklogs" +) + +// A bubble's clock starts at midnight UTC on Saturday, 1 January 2000. In Hawaii, ten hours behind +// UTC, that's 14:00 on Friday, 31 December 1999, so the report's days run from Saturday the 25th +// to today, Friday the 31st. +var hawaii = time.FixedZone("HST", -10*60*60) + +// onTuesday is the worklog that the reads find, unless they fail: two hours on Tuesday the 28th. +var onTuesday = []jira.Worklog{{Started: hawaiiAt(28, 9), Spent: 2 * time.Hour}} + +// withTuesday are the items when only Tuesday has hours. +var withTuesday = []string{ + "1999-12-27 Log Monday's hours · Nothing logged · SOURCE_JIRA · 1999-12-27T17:00:00-10:00", + "1999-12-29 Log Wednesday's hours · Nothing logged · SOURCE_JIRA · 1999-12-29T17:00:00-10:00", + "1999-12-30 Log Thursday's hours · Nothing logged · SOURCE_JIRA · 1999-12-30T17:00:00-10:00", + "1999-12-31 Log today's hours · Nothing logged yet · SOURCE_JIRA · 1999-12-31T17:00:00-10:00", +} + +func TestListItemsListsTheWeekdaysWithNothingLogged(t *testing.T) { + tests := []struct { + name string + loc *time.Location + logged []jira.Worklog + want []string + }{ + {name: "days before today and today", loc: hawaii, logged: onTuesday, want: withTuesday}, + { + name: "today with something logged", + loc: hawaii, + logged: append(slices.Clone(onTuesday), + jira.Worklog{Started: hawaiiAt(31, 9), Spent: time.Minute}), + want: withTuesday[:3], + }, + { + // In UTC, today is Saturday, 1 January 2000, and Friday is a day before it. + name: "a weekend today", + loc: time.UTC, + want: []string{ + "1999-12-27 Log Monday's hours · Nothing logged · SOURCE_JIRA · 1999-12-27T17:00:00Z", + "1999-12-28 Log Tuesday's hours · Nothing logged · SOURCE_JIRA · 1999-12-28T17:00:00Z", + "1999-12-29 Log Wednesday's hours · Nothing logged · SOURCE_JIRA · 1999-12-29T17:00:00Z", + "1999-12-30 Log Thursday's hours · Nothing logged · SOURCE_JIRA · 1999-12-30T17:00:00Z", + "1999-12-31 Log Friday's hours · Nothing logged · SOURCE_JIRA · 1999-12-31T17:00:00Z", + }, + }, + {name: "every weekday with something logged", loc: hawaii, logged: weekdaysLogged()}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + reports := started(t, test.loc, + func(context.Context, time.Time, time.Time) ([]jira.Worklog, error) { + return test.logged, nil + }) + + got, err := list(t.Context(), reports, test.loc) + if err != nil || !slices.Equal(got, test.want) { + t.Errorf("got %q and %v, want %q", got, err, test.want) + } + }) + }) + } +} + +func TestListItemsListsNothingWithoutJira(t *testing.T) { + if got, err := list(t.Context(), nil, hawaii); err != nil || len(got) != 0 { + t.Errorf("got %q and %v, want no items", got, err) + } +} + +func TestListItemsFailsOnADayWhoseDateIsNotADate(t *testing.T) { + look := func(context.Context) (report.Report, error) { + return report.Report{Days: []report.Day{{Date: "Tuesday", Unlogged: true}}}, nil + } + + // An interval of 0 polls once. + reports := poll.New("time reports", 0, look, slog.New(slog.DiscardHandler)) + go reports.Run(t.Context()) + + got, err := list(t.Context(), reports, hawaii) + if err == nil || !strings.HasPrefix(err.Error(), "read the day's date: ") || got != nil { + t.Errorf("got %q and %v, want no answer and the error read the day's date: …", got, err) + } +} + +// hawaiiAt returns the hour of a day in December 1999, in Hawaii. +func hawaiiAt(day, hour int) time.Time { + return time.Date(1999, time.December, day, hour, 0, 0, 0, hawaii) +} + +// weekdaysLogged returns worklogs of an hour on each weekday of the report, from Monday the 27th to +// Friday the 31st. +func weekdaysLogged() []jira.Worklog { + logged := make([]jira.Worklog, 0, 5) + for day := 27; day <= 31; day++ { + logged = append(logged, jira.Worklog{Started: hawaiiAt(day, 9), Spent: time.Hour}) + } + + return logged +} + +// started returns a poller in loc that reads the worklogs with read, and runs it until the test +// ends. +func started( + t *testing.T, + loc *time.Location, + read func(ctx context.Context, start, end time.Time) ([]jira.Worklog, error), +) *worklogs.Poller { + t.Helper() + + poller := worklogs.NewPoller(read, loc, slog.New(slog.DiscardHandler)) + go poller.Run(t.Context()) + + return poller +} + +// list returns the items that a lister of reports in loc lists, each as " · <detail> · +// <source> · <due in loc>", or the error that it returns. +func list(ctx context.Context, reports *worklogs.Poller, loc *time.Location) ([]string, error) { + res, err := attention.NewLister(reports, loc).ListItems(ctx, &attentionv1.ListItemsRequest{}) + if err != nil { + return nil, err + } + + items := make([]string, 0, len(res.GetItems())) + for _, item := range res.GetItems() { + items = append(items, fmt.Sprintf( + "%s %s · %s · %v · %s", + item.GetId(), + item.GetTitle(), + item.GetDetail(), + item.GetSource(), + item.GetDue().AsTime().In(loc).Format(time.RFC3339), + )) + } + + return items, nil +} diff --git a/services/time/internal/attention/reads_test.go b/services/time/internal/attention/reads_test.go new file mode 100644 index 0000000..fac29e6 --- /dev/null +++ b/services/time/internal/attention/reads_test.go @@ -0,0 +1,119 @@ +package attention_test + +import ( + "context" + "errors" + "fmt" + "slices" + "testing" + "testing/synctest" + "time" + + "github.com/codedogapp/bakhyard/internal/jira" +) + +// unavailable is the error of a call before a read of the worklogs has succeeded. +const unavailable = "unavailable: no read of the worklogs on Jira has succeeded yet" + +func TestListItemsIsUnavailableUntilAReadSucceeds(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + reads := 0 + reports := started(t, hawaii, + func(context.Context, time.Time, time.Time) ([]jira.Worklog, error) { + reads++ + if reads == 1 { + return nil, errors.New("jira is down") + } + + return onTuesday, nil + }) + + got, err := list(t.Context(), reports, hawaii) + if err == nil || err.Error() != unavailable { + t.Errorf("got %q and %v, want the error %s", got, err, unavailable) + } + + time.Sleep(5 * time.Minute) + synctest.Wait() + + got, err = list(t.Context(), reports, hawaii) + if err != nil || !slices.Equal(got, withTuesday) { + t.Errorf("after %d reads, got %q and %v, want %q", reads, got, err, withTuesday) + } + }) +} + +func TestListItemsKeepsTheItemsOfTheLastReadToSucceed(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + reads := 0 + reports := started(t, hawaii, + func(context.Context, time.Time, time.Time) ([]jira.Worklog, error) { + reads++ + if reads > 1 { + return nil, errors.New("jira is down") + } + + return onTuesday, nil + }) + + time.Sleep(5 * time.Minute) + synctest.Wait() + + got, err := list(t.Context(), reports, hawaii) + if reads != 2 || err != nil || !slices.Equal(got, withTuesday) { + t.Errorf("after %d reads, got %q and %v, want %q", reads, got, err, withTuesday) + } + }) +} + +func TestListItemsWaitsForTheFirstRead(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + release := make(chan struct{}) + reports := started(t, hawaii, + func(context.Context, time.Time, time.Time) ([]jira.Worklog, error) { + <-release + + return onTuesday, nil + }) + + answered := make(chan string) + + go func() { + got, err := list(t.Context(), reports, hawaii) + answered <- fmt.Sprintf("%d items %v", len(got), err) + }() + + synctest.Wait() + + select { + case got := <-answered: + t.Fatalf("answered %s before the first read ended", got) + default: + } + + close(release) + + if got := <-answered; got != "4 items <nil>" { + t.Errorf("got %s, want 4 items", got) + } + }) +} + +func TestListItemsStopsWaitingWhenItsContextEnds(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + reports := started(t, hawaii, + func(ctx context.Context, _, _ time.Time) ([]jira.Worklog, error) { + <-ctx.Done() + + return nil, ctx.Err() + }) + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + + got, err := list(ctx, reports, hawaii) + if !errors.Is(err, context.DeadlineExceeded) || got != nil { + t.Errorf("got %q and %v, want no answer and the context's error", got, err) + } + }) +} diff --git a/services/time/internal/report/report.go b/services/time/internal/report/report.go index 2ce4fa4..87eac13 100644 --- a/services/time/internal/report/report.go +++ b/services/time/internal/report/report.go @@ -33,6 +33,9 @@ type Day struct { LoggedHours float64 `json:"loggedHours"` // Missing says that the day is a weekday before today with nothing logged. Missing bool `json:"missing"` + // Unlogged says that the day is a weekday with nothing logged, today included: a missing day, + // or today until something is logged. + Unlogged bool `json:"-"` } // Week is the time logged this week against the week's target, in hours. @@ -69,11 +72,13 @@ func New(worklogs []jira.Worklog, now time.Time) Report { for i := range days { date := first.AddDate(0, 0, i) spent := logged[date.Format(time.DateOnly)] + unlogged := weekday(date) && spent == 0 report.Days = append(report.Days, Day{ Date: date.Format(time.DateOnly), LoggedHours: spent.Hours(), - Missing: date.Before(today) && weekday(date) && spent == 0, + Missing: unlogged && date.Before(today), + Unlogged: unlogged, }) if !date.Before(monday) { diff --git a/services/time/internal/report/report_test.go b/services/time/internal/report/report_test.go index 8ea730d..cde9386 100644 --- a/services/time/internal/report/report_test.go +++ b/services/time/internal/report/report_test.go @@ -39,7 +39,7 @@ func TestNewSumsTheTimeLoggedOnEachDay(t *testing.T) { {Date: "2026-10-24"}, {Date: "2026-10-25", LoggedHours: 1}, {Date: "2026-10-26", LoggedHours: 0.3}, - {Date: "2026-10-27", Missing: true}, + {Date: "2026-10-27", Missing: true, Unlogged: true}, {Date: "2026-10-28", LoggedHours: 4}, {Date: "2026-10-29", LoggedHours: 0.25}, } @@ -85,13 +85,13 @@ func TestNewCountsTheDaysInNowsZone(t *testing.T) { days := New(worklogs, time.Date(2026, time.October, 29, 12, 0, 0, 0, kiritimati)).Days - want := []Day{{Date: "2026-10-28", LoggedHours: 1}, {Date: "2026-10-29"}} + want := []Day{{Date: "2026-10-28", LoggedHours: 1}, {Date: "2026-10-29", Unlogged: true}} if got := days[len(days)-2:]; !slices.Equal(got, want) { t.Errorf("got the last two days %+v, want %+v", got, want) } } -func TestNewMarksTheWeekdaysWithNothingLoggedAsMissing(t *testing.T) { +func TestNewMarksTheWeekdaysWithNothingLogged(t *testing.T) { noonOn := func(month time.Month, day int) time.Time { return time.Date(2026, month, day, 12, 0, 0, 0, time.UTC) } @@ -102,16 +102,20 @@ func TestNewMarksTheWeekdaysWithNothingLoggedAsMissing(t *testing.T) { report := New(worklogs, noonOn(time.November, 2)) - // From Tuesday 27 October to today, Monday 2 November. - want := []bool{true, true, false, true, false, false, false} + // From Tuesday 27 October to today, Monday 2 November, which isn't missing until it's over. + wantMissing := []bool{true, true, false, true, false, false, false} + wantUnlogged := []bool{true, true, false, true, false, false, true} + + var gotMissing, gotUnlogged []bool - got := make([]bool, 0, len(report.Days)) for _, day := range report.Days { - got = append(got, day.Missing) + gotMissing = append(gotMissing, day.Missing) + gotUnlogged = append(gotUnlogged, day.Unlogged) } - if !slices.Equal(got, want) { - t.Errorf("got missing %v, want %v", got, want) + if !slices.Equal(gotMissing, wantMissing) || !slices.Equal(gotUnlogged, wantUnlogged) { + t.Errorf("got missing %v and unlogged %v, want %v and %v", + gotMissing, gotUnlogged, wantMissing, wantUnlogged) } } diff --git a/services/time/jira.go b/services/time/jira.go index 3e72663..f9a375d 100644 --- a/services/time/jira.go +++ b/services/time/jira.go @@ -10,14 +10,19 @@ import ( ) // poll starts polling Jira with client for the user's worklogs, until ctx ends, and returns the -// poller that keeps the time report that they make, or nil when Jira is off and client is nil. -// The report's days follow the local zone: the Mac's, or the one that the container's TZ names. -func poll(ctx context.Context, client *jira.Client, logger *slog.Logger) *worklogs.Poller { +// poller that keeps the time report that they make, whose days follow loc, or nil when Jira is off +// and client is nil. +func poll( + ctx context.Context, + client *jira.Client, + loc *time.Location, + logger *slog.Logger, +) *worklogs.Poller { if client == nil { return nil } - poller := worklogs.NewPoller(client.Worklogs, time.Local, logger) + poller := worklogs.NewPoller(client.Worklogs, loc, logger) go poller.Run(ctx) return poller diff --git a/services/time/jira_test.go b/services/time/jira_test.go index 2eee81d..cf98d44 100644 --- a/services/time/jira_test.go +++ b/services/time/jira_test.go @@ -47,6 +47,28 @@ func TestTimeReportsMyWorklogsInJira(t *testing.T) { } } +func TestTimeReadsTheWorklogsOnceForItsReportWeekAndItems(t *testing.T) { + address, reads := countedJira(t) + + router, err := routes(t.Context(), envOf(jiraSettings(address)), + secretsWith(map[string]string{"jira-token": "secret"})) + if err != nil { + t.Fatal(err) + } + + reportOf(t, router) + getWeek(t, router) + listItems(t, router) + + // Time reads them every 5 minutes, so only the first read is due yet. + if got := reads.Load(); got != 1 { + t.Errorf( + "read the worklogs %d times, want once for the report, the week and the items", + got, + ) + } +} + func TestTimeStopsReadingWorklogsWhenItStops(t *testing.T) { stopped, stop := context.WithCancel(t.Context()) stop() @@ -68,12 +90,22 @@ func TestTimeStopsOnABadJiraSetting(t *testing.T) { } } -// jiraServer returns the URL of a stand-in for Jira's REST API, for the email me@example.com with -// the token secret, where the user logged an hour and a half on one ticket, starting a minute -// before each answer, and an hour more with each later read of its worklogs. +// jiraServer returns the URL of a stand-in for Jira's REST API, as countedJira does. func jiraServer(t *testing.T) string { t.Helper() + address, _ := countedJira(t) + + return address +} + +// countedJira returns the URL of a stand-in for Jira's REST API, for the email me@example.com with +// the token secret, where the user logged an hour and a half on one ticket, starting a minute +// before each answer, and an hour more with each later read of its worklogs, and the number of +// those reads so far. +func countedJira(t *testing.T) (string, *atomic.Int64) { + t.Helper() + var reads atomic.Int64 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -100,7 +132,7 @@ func jiraServer(t *testing.T) string { })) t.Cleanup(server.Close) - return server.URL + return server.URL, &reads } // getReport returns the answer to GET /time/report of time with Jira's REST API at address, @@ -121,6 +153,24 @@ func getReport(ctx context.Context, t *testing.T, address string) *httptest.Resp return rec } +// reportOf returns router's answer to GET /time/report, which must be a report. +func reportOf(t *testing.T, router http.Handler) answer { + t.Helper() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/time/report", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + var got answer + + err := json.Unmarshal(rec.Body.Bytes(), &got) + if err != nil || rec.Code != http.StatusOK { + t.Fatalf("got %d %s (%v), want 200 and a report", rec.Code, rec.Body, err) + } + + return got +} + // jiraSettings returns the Jira settings, with the REST API at address. func jiraSettings(address string) map[string]string { return map[string]string{ diff --git a/services/time/main.go b/services/time/main.go index d4e6356..036104e 100644 --- a/services/time/main.go +++ b/services/time/main.go @@ -5,12 +5,14 @@ import ( "log/slog" "net/http" "os" + "time" "connectrpc.com/connect/v2" "connectrpc.com/connect/v2/connecthttp" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/codedogapp/bakhyard/internal/gen/bakhyard/attention/v1/attentionv1connect" "github.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1/timev1connect" "github.com/codedogapp/bakhyard/internal/health" "github.com/codedogapp/bakhyard/internal/jira" @@ -18,6 +20,7 @@ import ( "github.com/codedogapp/bakhyard/internal/secrets" "github.com/codedogapp/bakhyard/internal/service" "github.com/codedogapp/bakhyard/services/time/internal/api" + "github.com/codedogapp/bakhyard/services/time/internal/attention" "github.com/codedogapp/bakhyard/services/time/internal/week" ) @@ -34,8 +37,9 @@ func main() { } } -// routes returns time's handler, which serves its API and its side of the time contract. With -// Jira set up, it polls Jira for the user's worklogs in the background until ctx ends. +// routes returns time's handler, which serves its API and its sides of the time and attention +// contracts. With Jira set up, it polls Jira for the user's worklogs in the background until ctx +// ends. func routes( ctx context.Context, getenv func(string) string, @@ -51,12 +55,16 @@ func routes( router.Use(middleware.GetHead) health.Register(router) - reports := poll(ctx, client, slog.Default()) + // The days of the reports, and so the items' due times, follow the local zone: the Mac's, or + // the one that the container's TZ names. + loc := time.Local + reports := poll(ctx, client, loc, slog.Default()) api.Register(router, reports) - timeServer := connect.NewServer() - timev1connect.RegisterTimeServiceHandler(timeServer, week.NewServer(reports)) - connecthttp.Mount(router, timeServer) + server := connect.NewServer() + timev1connect.RegisterTimeServiceHandler(server, week.NewServer(reports)) + attentionv1connect.RegisterAttentionServiceHandler(server, attention.NewLister(reports, loc)) + connecthttp.Mount(router, server) return router, nil } diff --git a/services/time/week_test.go b/services/time/week_test.go index b85651e..2064093 100644 --- a/services/time/week_test.go +++ b/services/time/week_test.go @@ -1,14 +1,9 @@ package main import ( - "encoding/json/v2" "net/http" - "net/http/httptest" "testing" - "connectrpc.com/connect/v2" - "connectrpc.com/connect/v2/connecthttp" - timev1 "github.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1" "github.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1/timev1connect" ) @@ -21,17 +16,7 @@ func TestTimeAnswersTheWeekOfItsReport(t *testing.T) { } got := getWeek(t, router) - - req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/time/report", nil) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - var want answer - - err = json.Unmarshal(rec.Body.Bytes(), &want) - if err != nil { - t.Fatalf("got the report %s (%v), want JSON", rec.Body, err) - } + want := reportOf(t, router) // Jira logs an hour more with each read, so the two agree only when they come from the same // read. Its worklog started a minute ago, which may be last week: the report says which. @@ -56,21 +41,7 @@ func TestTimeAnswersNoWeekWithoutJira(t *testing.T) { func getWeek(t *testing.T, router http.Handler) *timev1.GetWeekResponse { t.Helper() - // gRPC needs HTTP/2; between services it runs without TLS (h2c), as internal/server allows. - h2c := new(http.Protocols) - h2c.SetUnencryptedHTTP2(true) - - server := httptest.NewUnstartedServer(router) - server.Config.Protocols = h2c - server.Start() - t.Cleanup(server.Close) - - transport := &http.Transport{Protocols: h2c} - t.Cleanup(transport.CloseIdleConnections) - - httpClient := &http.Client{Transport: transport} - client := timev1connect.NewTimeServiceClient( - connect.NewClient(connecthttp.NewTransport(httpClient, server.URL, connecthttp.WithGRPC()))) + client := timev1connect.NewTimeServiceClient(grpcClient(t, router)) res, err := client.GetWeek(t.Context(), &timev1.GetWeekRequest{}) if err != nil {