Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh
| `notify-header` | The control that turns notifications on moves from the Nudge page to a bell in the header, before the theme toggle: its name and look say how this device's notifications stand, and its panel says it in words and turns them on. The last step of `notifications` (decision 179) | Done (#146) |
| `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. The first of `time-tracking`'s eleven steps (decision 180) | Done (#147) |
| `jira-worklogs` | The shared Jira client reads my worklogs in a span of time: my account from `/myself`, the tickets I logged work on from a `worklogAuthor` search, then each ticket's worklogs, keeping mine that start in the span, the earliest first. The second of `time-tracking`'s eleven steps (decision 180) | Done (#148) |
| `time-report` | A package of the new `time` service, `report`, works out the time report from my worklogs: the hours I logged on each of the last 7 days, the weekdays before today with nothing logged marked as missing, and this week's hours against its 40-hour target. The third of `time-tracking`'s twelve steps (decision 181) | Done (#149) |
| `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.
Expand Down Expand Up @@ -1192,13 +1193,14 @@ 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. Eleven steps (decision 180):
- `time-tracking` [time]. Needs `todos-jira`. Reads and writes standard Jira worklogs (§2, decision 46), with no timers: Clockwork already has them inside Jira. Twelve steps (decisions 180 and 181):
- `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-service`: a new `time` service polls the last 7 days' worklogs every 5 minutes and answers the day report at `GET /time/report`: the hours I logged on each day, with every weekday that has none marked as missing, plus the week's total against the target.
- `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).
- `time-service`: a new `time` service polls the last 7 days' worklogs every 5 minutes and answers the time report at `GET /time/report`.
- `homelab-time`: in the homelab repo, `time` runs on the laptop with todos' Jira settings and token, and the gateway's and home's peers name it.
- `time-gateway`: the gateway forwards `/api/time` to `time`.
- `time-page`: the Time page shows the day report.
- `time-page`: the Time page shows the time report.
- `time-week`: `time` answers `TimeService/GetWeek` (§4.10).
- `home-week`: home asks `time` for the week, and the Home's "This week" shows it, or "Hours unavailable" when `time` doesn't answer (§7).
- `time-attention`: `time` answers the attention contract with today's hours until they're logged, and each missing weekday.
Expand Down Expand Up @@ -1536,6 +1538,13 @@ Made on 2026-10-23:
- **Days and the target**: days and weeks follow `time.Local`, the pod's `TZ`, as decision 175's reminders do. A week runs Monday to Sunday, and a worklog counts on the day it starts. The target is 8 hours a weekday, Monday to Friday, 40 a week, fixed in code. Today isn't missing until it's over; holidays show as missing until a setting for them (§0 idea).
- **Writing worklogs** (`worklog-add`, `fill-day`): `POST /rest/api/3/issue/{key}/worklog` with `started` and `timeSpentSeconds`, keeping Jira's defaults (`notifyUsers`, `adjustEstimate=auto`); it answers 201, so the client takes a 201 there. A new worklog starts at 09:00 local time, and `time` polls again after a write.
- **Rejected**: copying todos' client into `time`; a Jira client per service; timers (decision 46); a database of worklogs, which Jira already keeps.
181. **The time report** (`time-report`): the lead's choices (2026-10-29), for the user to review. `time-service` is over a step's 150 hand-written lines, so it splits in two and `time-tracking` has twelve steps (§8.2): `time-report` works the report out in a package of its own, `services/time/internal/report`, and `time-service` polls Jira and answers the report, as `bitbucket-client` (#112) came before `pulls-service`.
- **The span** is the last 7 days, today last: from the start of the day six days before today up to, but not including, the start of tomorrow, the span that `Client.Worklogs` reads. A worklog that starts after today isn't read, so time logged ahead counts once its day comes. Where the clocks skip midnight, a day starts as they skip it: in zones behind UTC (Havana, Santiago), Go's `time.Date` puts a skipped midnight before the skip, on the day before, so the day starts where the zone in effect then ends. Checked against every zone in tzdata 2025c from 2026 to 2030; Budapest's clocks never skip midnight.
- **Each day** has its date (`2026-10-29`) and the hours of the worklogs that start on it. A day is missing when it's a weekday before today with nothing logged at all: a day with some hours isn't, and today never is.
- **The week** runs from this Monday through today, so it always lies within the 7 days. Its target is 40 hours.
- **Hours**: the JSON gives hours as numbers, `loggedHours` and `targetHours`, the shape that the web app's `Week` type already reads, so the Time page and the Home's "This week" can share its formatting. `encoding/json/v2` has no default encoding for a `time.Duration`. The sums are kept as durations and turned into hours once, so 6 and 12 minutes make 0.3 hours rather than 0.30000000000000004.
- **Nothing reads the clock or the zone**: the time it's given brings both, and the service will give `time.Now()`, in `time.Local` (decision 180).
- **Rejected**: minutes or seconds in the JSON; a week through Sunday, which needs a longer span; a day under 8 hours counting as missing; `time.Date`'s midnight in every zone.

## 10. Risks

Expand Down
123 changes: 123 additions & 0 deletions services/time/internal/report/report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Package report works out the time report from the user's worklogs: the hours logged on each of
// the last 7 days, the weekdays among them with nothing logged, and this week's hours against the
// week's target (docs/plan.md decisions 180 and 181).
package report

import (
"time"

"github.com/codedogapp/bakhyard/internal/jira"
)

const (
// days is the number of days that the report covers, today the last.
days = 7
// weekTarget is the time to log each week: 8 hours each weekday, Monday to Friday.
weekTarget = 40 * time.Hour
)

// Report is what the user logged in the last 7 days.
type Report struct {
// Days are the 7 days, the earliest first and today last.
Days []Day `json:"days"`
// Week is what the user logged this week, from Monday through today.
Week Week `json:"week"`
}

// Day is what the user logged on one day.
type Day struct {
// Date is the day's date, such as 2026-10-29.
Date string `json:"date"`
// LoggedHours is the time logged on the worklogs that start on the day, in hours.
LoggedHours float64 `json:"loggedHours"`
// Missing says that the day is a weekday before today with nothing logged.
Missing bool `json:"missing"`
}

// Week is the time logged this week against the week's target, in hours.
type Week struct {
LoggedHours float64 `json:"loggedHours"`
TargetHours float64 `json:"targetHours"`
}

// Span returns the span of time that the report at now covers, in now's location: from the start
// of the day six days before now's up to, but not including, the start of the day after now's.
func Span(now time.Time) (start, end time.Time) {
today := dateOf(now)

return dayStart(today.AddDate(0, 0, 1-days), now.Location()),
dayStart(today.AddDate(0, 0, 1), now.Location())
}

// New returns the report at now of the worklogs. Days and weeks follow now's location: a worklog
// counts on the day it starts, and a week runs from Monday to Sunday. Worklogs that start outside
// Span(now) are left out.
func New(worklogs []jira.Worklog, now time.Time) Report {
logged := make(map[string]time.Duration)
for _, worklog := range worklogs {
logged[worklog.Started.In(now.Location()).Format(time.DateOnly)] += worklog.Spent
}

today := dateOf(now)
monday := mondayOf(today)
first := today.AddDate(0, 0, 1-days)
report := Report{Days: make([]Day, 0, days)}

var week time.Duration

for i := range days {
date := first.AddDate(0, 0, i)
spent := logged[date.Format(time.DateOnly)]

report.Days = append(report.Days, Day{
Date: date.Format(time.DateOnly),
LoggedHours: spent.Hours(),
Missing: date.Before(today) && weekday(date) && spent == 0,
})

if !date.Before(monday) {
week += spent
}
}

report.Week = Week{LoggedHours: week.Hours(), TargetHours: weekTarget.Hours()}

return report
}

// dateOf returns t's date in t's location, at midnight UTC, where days can be counted without
// clocks that change.
func dateOf(t time.Time) time.Time {
year, month, day := t.Date()

return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
}

// dayStart returns when date's day starts in loc: at midnight, or, where the clocks skip midnight,
// as they skip it.
func dayStart(date time.Time, loc *time.Location) time.Time {
year, month, day := date.Date()

start := time.Date(year, month, day, 0, 0, 0, 0, loc)
if start.Day() != day {
// time.Date put the skipped midnight before the skip, on the day before; the day starts as
// the zone in effect then ends.
_, start = start.ZoneBounds()
}

return start
}

// mondayOf returns the Monday of date's week.
func mondayOf(date time.Time) time.Time {
for date.Weekday() != time.Monday {
date = date.AddDate(0, 0, -1)
}

return date
}

// weekday says whether date is Monday to Friday.
func weekday(date time.Time) bool {
return date.Weekday() != time.Saturday && date.Weekday() != time.Sunday
}
146 changes: 146 additions & 0 deletions services/time/internal/report/report_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package report

import (
"encoding/json/jsontext"
"encoding/json/v2"
"slices"
"testing"
"time"

"github.com/codedogapp/bakhyard/internal/jira"
)

func TestNewSumsTheTimeLoggedOnEachDay(t *testing.T) {
budapest := zone(t, "Europe/Budapest")
// 2026-10-29 is a Thursday.
inBudapest := func(day, hour, minute int) time.Time {
return time.Date(2026, time.October, day, hour, minute, 0, 0, budapest)
}
india := time.FixedZone("IST", 5*60*60+30*60)
worklogs := []jira.Worklog{
{Started: inBudapest(22, 10, 0), Spent: 2 * time.Hour},
{Started: inBudapest(23, 9, 0), Spent: 90 * time.Minute},
// Budapest's clocks went back at 03:00 that day.
{Started: inBudapest(25, 2, 30), Spent: time.Hour},
// 0.1 + 0.2 hours make 0.30000000000000004 in floating point.
{Started: inBudapest(26, 9, 0), Spent: 6 * time.Minute},
{Started: inBudapest(26, 11, 0), Spent: 12 * time.Minute},
{Started: inBudapest(28, 23, 0), Spent: 2 * time.Hour},
// 21:30 on the 28th in Budapest.
{Started: time.Date(2026, time.October, 29, 2, 0, 0, 0, india), Spent: 2 * time.Hour},
{Started: inBudapest(29, 8, 0), Spent: 15 * time.Minute},
{Started: inBudapest(30, 9, 0), Spent: 8 * time.Hour},
}

report := New(worklogs, inBudapest(29, 15, 4))

wantDays := []Day{
{Date: "2026-10-23", LoggedHours: 1.5},
{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-28", LoggedHours: 4},
{Date: "2026-10-29", LoggedHours: 0.25},
}
if !slices.Equal(report.Days, wantDays) {
t.Errorf("got the days %+v, want %+v", report.Days, wantDays)
}

// From Monday the 26th through today: 18 minutes, 4 hours and 15 minutes.
if wantWeek := (Week{LoggedHours: 4.55, TargetHours: 40}); report.Week != wantWeek {
t.Errorf("got the week %+v, want %+v", report.Week, wantWeek)
}
}

func TestNewCountsTheWeekFromMonday(t *testing.T) {
monday := time.Date(2026, time.October, 26, 12, 0, 0, 0, time.UTC)
worklogs := make([]jira.Worklog, 0, 14)

// A tenth of an hour on each day from Monday the 19th to Sunday 1 November.
for day := -7; day < 7; day++ {
started := monday.AddDate(0, 0, day)
worklogs = append(worklogs, jira.Worklog{Started: started, Spent: 6 * time.Minute})
}

// Three tenths added up as hours make 0.30000000000000004.
for day, want := range []float64{0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7} {
now := monday.AddDate(0, 0, day)
t.Run(now.Weekday().String(), func(t *testing.T) {
if got := New(worklogs, now).Week.LoggedHours; got != want {
t.Errorf("got %v hours this week, want %v", got, want)
}
})
}
}

func TestNewCountsTheDaysInNowsZone(t *testing.T) {
// Kiritimati is 14 hours ahead of UTC, so its dates differ from UTC's and, almost surely, from
// those of the machine running the test.
kiritimati := zone(t, "Pacific/Kiritimati")
// 00:30 on the 28th in Kiritimati.
worklogs := []jira.Worklog{
{Started: time.Date(2026, time.October, 27, 10, 30, 0, 0, time.UTC), Spent: time.Hour},
}

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"}}
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) {
noonOn := func(month time.Month, day int) time.Time {
return time.Date(2026, month, day, 12, 0, 0, 0, time.UTC)
}
worklogs := []jira.Worklog{
{Started: noonOn(time.October, 29), Spent: time.Minute},
{Started: noonOn(time.October, 30)},
}

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}

got := make([]bool, 0, len(report.Days))
for _, day := range report.Days {
got = append(got, day.Missing)
}

if !slices.Equal(got, want) {
t.Errorf("got missing %v, want %v", got, want)
}
}

func TestReportJSON(t *testing.T) {
now := time.Date(2026, time.October, 28, 12, 0, 0, 0, time.UTC)
worklogs := []jira.Worklog{{Started: now.AddDate(0, 0, -1), Spent: 90 * time.Minute}}

got, err := json.Marshal(New(worklogs, now))
if err != nil {
t.Fatal(err)
}

want := jsontext.Value(`{
"days": [
{"date": "2026-10-22", "loggedHours": 0, "missing": true},
{"date": "2026-10-23", "loggedHours": 0, "missing": true},
{"date": "2026-10-24", "loggedHours": 0, "missing": false},
{"date": "2026-10-25", "loggedHours": 0, "missing": false},
{"date": "2026-10-26", "loggedHours": 0, "missing": true},
{"date": "2026-10-27", "loggedHours": 1.5, "missing": false},
{"date": "2026-10-28", "loggedHours": 0, "missing": false}
],
"week": {"loggedHours": 1.5, "targetHours": 40}
}`)
if err := want.Compact(); err != nil {
t.Fatal(err)
}

if string(got) != string(want) {
t.Errorf("got %s, want %s", got, want)
}
}
Loading
Loading