diff --git a/docs/plan.md b/docs/plan.md index fecc586..860acf0 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -167,6 +167,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `time-gateway` | The gateway forwards `/api/time` to the `time` service, behind both of its locks, so the app reaches my time report at `GET /api/time/report`. `time` is one of the gateway's peers, each of which `BAKHYARD_PEERS` must name, and `mise run up` starts the gateway once time is ready. The sixth of `time-tracking`'s twelve steps (decision 181) | Done (#151) | | `week-card` | The Home's "This week" card, the hours logged against the target over a bar, moves with its formatting and the week's type to the Time app's own folder, `src/time`, as a component of its own, `WeekCard`, ahead of `time-page`, which shows it too; nothing changes on screen or in behaviour. The seventh of `time-tracking`'s thirteen steps (decision 183) | Done (#152) | | `time-page` | The Time page, at `/time`, shows the time report: this week's hours against the target on the Home's week card, then the last 7 days, a row each with the date, the weekday or "Today" and the hours logged, a Missing chip on each weekday before today without hours, and the missing days' count as the section's note. It loads the report each time it shows, says why the last search failed and when its hours are from, and says when Jira isn't set up or the report couldn't load. The eighth of `time-tracking`'s thirteen steps (decision 184) | Done (#153) | +| `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) | | `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. @@ -410,7 +411,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); 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 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. Schema-first protobuf in `proto/`, a buf v2 module (lint STANDARD, breaking FILE). @@ -418,7 +419,7 @@ Schema-first protobuf in `proto/`, a buf v2 module (lint STANDARD, breaking FILE | --- | --- | --- | | `bakhyard.attention.v1` | `AttentionService/ListItems` (NO_SIDE_EFFECTS) → `Item{id, title, detail, source, due}`, with no `url`, since each row links to its app (decision 158). Event message `ItemsChanged{app, reason}`, with NATS | Internal: every feature service implements it; never routed by the gateway | | `bakhyard.home.v1` | `HomeService/GetHome` (NO_SIDE_EFFECTS, so the client uses HTTP GET) and `WatchHome` (server stream) → `Home{name, items, week, sources, generated_at}` | Public, via the gateway | -| `bakhyard.time.v1` | `TimeService/GetWeek` → `{logged_hours, target_hours}` | Internal | +| `bakhyard.time.v1` | `TimeService/GetWeek` (NO_SIDE_EFFECTS) → `Week{logged_hours, target_hours}` in hours, unset without Jira; `UNAVAILABLE` until a read of the worklogs succeeds (decision 185) | Internal: the time service implements it and home calls it; never routed by the gateway | `home.v1` messages: @@ -1207,8 +1208,8 @@ Every item is pending unless it says done. The service an item lands in is in br - `time-gateway`: the gateway forwards `/api/time` to `time`. Done (#151). - `week-card`: the Home's week card, its formatting and the week's type move to `src/time`, for the Time page to share; nothing changes on screen (decision 183). Done (#152). - `time-page`: the Time page shows the time report: this week's card, then the last 7 days with the missing ones marked (decision 184). Done (#153). - - `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-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 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. - `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. @@ -1558,7 +1559,7 @@ Made on 2026-10-23: - **The zone**: days follow `time.Local`, the Mac's zone or the container's `TZ` (decision 180). The distroless static image holds tzdata (its README lists it), so `TZ` works without importing `time/tzdata`, which would add about 450 KB to the binary. Without `TZ` a container keeps UTC, so `homelab-time` sets it, as `homelab-pulls-notify` did for pulls. - **The image**: `services/time/Dockerfile` builds as pulls' does, and the Gitea workflow builds and pushes `time` with the other services. - **Rejected**: the poller inside `report`, which would bring the clock and Jira into it; a Refresh that polls on demand; embedding `time/tzdata`; `time.Now()` read twice, once for the span and once for the report, which could disagree across midnight. -183. **The Time page in two steps**: the lead's choices (2026-10-29), for the user to review. The Time page shows this week's hours against the target on the card that the Home's "This week" shows, so the two share it. The card sat inside the Home's `ThisWeek`, under the section head whose "Day report" link leads to the Time page, with its formatting and the `Week` type in the Home's folder. So `time-page` splits in two and `time-tracking` has thirteen steps (§8.2): `week-card`, a pure refactor, moves the card into a component of its own, `WeekCard`, and moves it, `week.ts` and the `Week` type to the Time app's folder, `src/time`, as `shell-section` (#85) moved the section head ahead of `todos-groups`; `time-page` then builds the page. The Home takes the week's card from the Time app, as home will take the week from the time service (`home-week`). Rejected: the Time page importing the Home's files, which would make an app depend on the Home, as `MorePage` does on its app cards; a copy of the card. +183. **The Time page in two steps**: the lead's choices (2026-10-29), for the user to review. The Time page shows this week's hours against the target on the card that the Home's "This week" shows, so the two share it. The card sat inside the Home's `ThisWeek`, under the section head whose "Day report" link leads to the Time page, with its formatting and the `Week` type in the Home's folder. So `time-page` splits in two and `time-tracking` has thirteen steps (§8.2): `week-card`, a pure refactor, moves the card into a component of its own, `WeekCard`, and moves it, `week.ts` and the `Week` type to the Time app's folder, `src/time`, as `shell-section` (#85) moved the section head ahead of `todos-groups`; `time-page` then builds the page. The Home takes the week's card from the Time app, as home will take the week from the time service (`home-time-week`). Rejected: the Time page importing the Home's files, which would make an app depend on the Home, as `MorePage` does on its app cards; a copy of the card. 184. **The Time page** (`time-page`): the lead's choices (2026-10-29), for the user to review. The Time page, at `/time`, shows the time report that the gateway forwards from the time service, `GET /api/time/report` (decisions 181 and 182). - **The head**: "Time", with no eyebrow and no summary, as the PRs page's head: the sections' heads and notes say what the page holds. Nothing shows under it while the report loads. - **This week**: a section, "This *week*", with the Home's week card (decision 183) and no link, since the Home's "Day report" link leads here. @@ -1567,6 +1568,14 @@ Made on 2026-10-23: - **Loading and failures**: the report loads each time the page shows, as the PRs page's lists do (decision 149), through the shell's loader of a poller's snapshot (decision 147). A 404 says "Jira isn't set up.", and any other failure "Couldn't load the time report.", under the page head with no section. When the last search failed, one line under the head says why and, when there are days from an earlier search, adds "These hours are from 21 October at 09:38."; one line for the page rather than one in each section, since one snapshot feeds both. Over the sections it leaves the 30 px (24 on phones) that the Todos page's form leaves over its first section (decision 112); with no days yet, the line ends the page, as "Jira isn't set up." does. - **Code**: `src/time/report.ts` (the `Report` type and `loadReport`), `days.ts` (`dayRows` and `missingNote`), `DayList.svelte` and `TimePage.svelte`; `week.ts` now exports `formatHours` for the rows. The shell's pages gain a `time` kind, so `/time` and the paths under it show the page, as with `/todos` and `/pulls`. The app's tests of the pages it opens on move to a file of their own, `App.pages.test.ts`, since `App.test.ts` had 199 of Biome's 200 lines. `DayList` copies the card's style, as twelve other components do (§0's ideas). - **Rejected**: a failure line in each section; the newest day first, which runs the week backwards; the weekday as the row's label, with "Today" as a detail; Temporal's `PlainDate` for the dates, where one `Intl` option does; a summary under the head, which would repeat the sections' notes. +185. **The week over the time contract** (`time-week`): the lead's choices (2026-10-29), for the user to review. The second contract between services, `bakhyard.time.v1` in `proto/`, has one RPC, `TimeService.GetWeek`, with no side effects, which home will call for the Home's "This week" (§4.5). + - **The messages**: the answer holds a `Week` message, the shape of `home.v1`'s, so "no week" is one unset field; its hours are doubles, `logged_hours` and `target_hours`, as in the JSON report (decision 181). + - **The answer**: the time service answers from the poller behind `GET /time/report`, so the two always agree: the week of the latest read of my worklogs to succeed, kept through the reads that fail, as pulls' items are (decision 158). A call waits for the first read, and a call whose context ends first gets its context's error. + - **No week or `UNAVAILABLE`**: without Jira, the answer has no week, so the Home will leave "This week" out (decision 161). With Jira set up but no read succeeded yet, the call fails with `UNAVAILABLE`, so the Home will say "Hours unavailable" (§7) rather than look as if Jira weren't set up. + - **A known limit**: a kept week stays until a read succeeds, even past Monday; the Time page says how old its hours are, and the Home won't. + - **Code**: the server is `Server` in `services/time/internal/week`, a package of its own beside `api`, as pulls' `attention` is; `routes` mounts it on the router of the JSON API, with the same poller. + - **The next step**: §8.2's `home-week` becomes `home-time-week`, since `home-week` is the id of a step done in Phase 1 (#24). + - **Rejected**: no week until a read succeeds, which would make a failing Jira look unset; a zero week, which reads as nothing logged; `google.protobuf.Duration` fields, whose seconds the Home would turn back into hours; the read's time in the answer, which the Home doesn't show; the hours as two fields of the answer itself, so that "no week" needs both unset; the server inside `api`, which serves JSON. ## 10. Risks diff --git a/internal/gen/bakhyard/time/v1/time.pb.go b/internal/gen/bakhyard/time/v1/time.pb.go new file mode 100644 index 0000000..dbfb6d1 --- /dev/null +++ b/internal/gen/bakhyard/time/v1/time.pb.go @@ -0,0 +1,295 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: bakhyard/time/v1/time.proto + +package timev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetWeekRequest struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetWeekRequest) Reset() { + *x = GetWeekRequest{} + mi := &file_bakhyard_time_v1_time_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetWeekRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetWeekRequest) ProtoMessage() {} + +func (x *GetWeekRequest) ProtoReflect() protoreflect.Message { + mi := &file_bakhyard_time_v1_time_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +type GetWeekRequest_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + +} + +func (b0 GetWeekRequest_builder) Build() *GetWeekRequest { + m0 := &GetWeekRequest{} + b, x := &b0, m0 + _, _ = b, x + return m0 +} + +type GetWeekResponse struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Week *Week `protobuf:"bytes,1,opt,name=week"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetWeekResponse) Reset() { + *x = GetWeekResponse{} + mi := &file_bakhyard_time_v1_time_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetWeekResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetWeekResponse) ProtoMessage() {} + +func (x *GetWeekResponse) ProtoReflect() protoreflect.Message { + mi := &file_bakhyard_time_v1_time_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *GetWeekResponse) GetWeek() *Week { + if x != nil { + return x.xxx_hidden_Week + } + return nil +} + +func (x *GetWeekResponse) SetWeek(v *Week) { + x.xxx_hidden_Week = v +} + +func (x *GetWeekResponse) HasWeek() bool { + if x == nil { + return false + } + return x.xxx_hidden_Week != nil +} + +func (x *GetWeekResponse) ClearWeek() { + x.xxx_hidden_Week = nil +} + +type GetWeekResponse_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + // week is unset when there's none, as when Jira isn't set up. + Week *Week +} + +func (b0 GetWeekResponse_builder) Build() *GetWeekResponse { + m0 := &GetWeekResponse{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Week = b.Week + return m0 +} + +// Week is the time logged this week, from Monday through today, against the week's target. +type Week struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_LoggedHours float64 `protobuf:"fixed64,1,opt,name=logged_hours,json=loggedHours"` + xxx_hidden_TargetHours float64 `protobuf:"fixed64,2,opt,name=target_hours,json=targetHours"` + XXX_raceDetectHookData protoimpl.RaceDetectHookData + XXX_presence [1]uint32 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Week) Reset() { + *x = Week{} + mi := &file_bakhyard_time_v1_time_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Week) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Week) ProtoMessage() {} + +func (x *Week) ProtoReflect() protoreflect.Message { + mi := &file_bakhyard_time_v1_time_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *Week) GetLoggedHours() float64 { + if x != nil { + return x.xxx_hidden_LoggedHours + } + return 0 +} + +func (x *Week) GetTargetHours() float64 { + if x != nil { + return x.xxx_hidden_TargetHours + } + return 0 +} + +func (x *Week) SetLoggedHours(v float64) { + x.xxx_hidden_LoggedHours = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 0, 2) +} + +func (x *Week) SetTargetHours(v float64) { + x.xxx_hidden_TargetHours = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 1, 2) +} + +func (x *Week) HasLoggedHours() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 0) +} + +func (x *Week) HasTargetHours() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 1) +} + +func (x *Week) ClearLoggedHours() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 0) + x.xxx_hidden_LoggedHours = 0 +} + +func (x *Week) ClearTargetHours() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 1) + x.xxx_hidden_TargetHours = 0 +} + +type Week_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + LoggedHours *float64 + TargetHours *float64 +} + +func (b0 Week_builder) Build() *Week { + m0 := &Week{} + b, x := &b0, m0 + _, _ = b, x + if b.LoggedHours != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 0, 2) + x.xxx_hidden_LoggedHours = *b.LoggedHours + } + if b.TargetHours != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 1, 2) + x.xxx_hidden_TargetHours = *b.TargetHours + } + return m0 +} + +var File_bakhyard_time_v1_time_proto protoreflect.FileDescriptor + +const file_bakhyard_time_v1_time_proto_rawDesc = "" + + "\n" + + "\x1bbakhyard/time/v1/time.proto\x12\x10bakhyard.time.v1\"\x10\n" + + "\x0eGetWeekRequest\"=\n" + + "\x0fGetWeekResponse\x12*\n" + + "\x04week\x18\x01 \x01(\v2\x16.bakhyard.time.v1.WeekR\x04week\"L\n" + + "\x04Week\x12!\n" + + "\flogged_hours\x18\x01 \x01(\x01R\vloggedHours\x12!\n" + + "\ftarget_hours\x18\x02 \x01(\x01R\vtargetHours2b\n" + + "\vTimeService\x12S\n" + + "\aGetWeek\x12 .bakhyard.time.v1.GetWeekRequest\x1a!.bakhyard.time.v1.GetWeekResponse\"\x03\x90\x02\x01B\xc6\x01\n" + + "\x14com.bakhyard.time.v1B\tTimeProtoZCgithub.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1;timev1\xa2\x02\x03BTX\xaa\x02\x10Bakhyard.Time.V1\xca\x02\x10Bakhyard\\Time\\V1\xe2\x02\x1cBakhyard\\Time\\V1\\GPBMetadata\xea\x02\x12Bakhyard::Time::V1b\beditionsp\xe9\a" + +var file_bakhyard_time_v1_time_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_bakhyard_time_v1_time_proto_goTypes = []any{ + (*GetWeekRequest)(nil), // 0: bakhyard.time.v1.GetWeekRequest + (*GetWeekResponse)(nil), // 1: bakhyard.time.v1.GetWeekResponse + (*Week)(nil), // 2: bakhyard.time.v1.Week +} +var file_bakhyard_time_v1_time_proto_depIdxs = []int32{ + 2, // 0: bakhyard.time.v1.GetWeekResponse.week:type_name -> bakhyard.time.v1.Week + 0, // 1: bakhyard.time.v1.TimeService.GetWeek:input_type -> bakhyard.time.v1.GetWeekRequest + 1, // 2: bakhyard.time.v1.TimeService.GetWeek:output_type -> bakhyard.time.v1.GetWeekResponse + 2, // [2:3] is the sub-list for method output_type + 1, // [1:2] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_bakhyard_time_v1_time_proto_init() } +func file_bakhyard_time_v1_time_proto_init() { + if File_bakhyard_time_v1_time_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_bakhyard_time_v1_time_proto_rawDesc), len(file_bakhyard_time_v1_time_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_bakhyard_time_v1_time_proto_goTypes, + DependencyIndexes: file_bakhyard_time_v1_time_proto_depIdxs, + MessageInfos: file_bakhyard_time_v1_time_proto_msgTypes, + }.Build() + File_bakhyard_time_v1_time_proto = out.File + file_bakhyard_time_v1_time_proto_goTypes = nil + file_bakhyard_time_v1_time_proto_depIdxs = nil +} diff --git a/internal/gen/bakhyard/time/v1/timev1connect/time.connect.go b/internal/gen/bakhyard/time/v1/timev1connect/time.connect.go new file mode 100644 index 0000000..98beb74 --- /dev/null +++ b/internal/gen/bakhyard/time/v1/timev1connect/time.connect.go @@ -0,0 +1,102 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: bakhyard/time/v1/time.proto + +package timev1connect + +import ( + connect "connectrpc.com/connect/v2" + context "context" + v1 "github.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1" + sync "sync" +) + +const ( + // TimeServiceName is the fully-qualified name of the TimeService service. + TimeServiceName = "bakhyard.time.v1.TimeService" +) + +// These constants are the procedure names of the RPCs defined in this package. They're exposed at +// runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // TimeServiceGetWeekProcedure is the procedure name of the TimeService's GetWeek RPC. + TimeServiceGetWeekProcedure = "/bakhyard.time.v1.TimeService/GetWeek" +) + +var ( + timeServiceGetWeekSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeUnary, + Schema: v1.File_bakhyard_time_v1_time_proto.Services().ByName("TimeService").Methods().ByName("GetWeek"), + Procedure: TimeServiceGetWeekProcedure, + IdempotencyLevel: connect.IdempotencyNoSideEffects, + } + }) +) + +// TimeServiceClient is a client for the bakhyard.time.v1.TimeService service. +type TimeServiceClient interface { + // GetWeek answers this week's hours against the week's target. It fails with UNAVAILABLE while + // Jira is set up but no read of the worklogs has succeeded yet. + GetWeek(context.Context, *v1.GetWeekRequest) (*v1.GetWeekResponse, error) +} + +// NewTimeServiceClient constructs a client for the bakhyard.time.v1.TimeService service. Multiple +// service clients may share a single connect.Client. +func NewTimeServiceClient(client *connect.Client) TimeServiceClient { + return &timeServiceClient{client: client} +} + +// TimeServiceHandler is an implementation of the bakhyard.time.v1.TimeService service. +type TimeServiceHandler interface { + // GetWeek answers this week's hours against the week's target. It fails with UNAVAILABLE while + // Jira is set up but no read of the worklogs has succeeded yet. + GetWeek(context.Context, *v1.GetWeekRequest) (*v1.GetWeekResponse, error) +} + +// RegisterTimeServiceHandler registers svc as the bakhyard.time.v1.TimeService implementation on +// server. +func RegisterTimeServiceHandler(server *connect.Server, svc TimeServiceHandler) { + adapter := timeServiceHandler{svc: svc} + server.Register( + connect.Method{Spec: timeServiceGetWeekSpec(), Handler: adapter.getWeek}, + ) +} + +// UnimplementedTimeServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedTimeServiceHandler struct{} + +func (UnimplementedTimeServiceHandler) GetWeek(context.Context, *v1.GetWeekRequest) (*v1.GetWeekResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "bakhyard.time.v1.TimeService.GetWeek is not implemented") +} + +type timeServiceClient struct { + client *connect.Client +} + +func (c *timeServiceClient) GetWeek(ctx context.Context, req *v1.GetWeekRequest) (*v1.GetWeekResponse, error) { + var res v1.GetWeekResponse + if err := c.client.CallUnary(ctx, timeServiceGetWeekSpec(), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +type timeServiceHandler struct{ svc TimeServiceHandler } + +func (h timeServiceHandler) getWeek(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req v1.GetWeekRequest + if err := stream.Receive(&req); err != nil { + return err + } + res, err := h.svc.GetWeek(ctx, &req) + if err != nil { + return err + } + return stream.Send(res) +} diff --git a/proto/bakhyard/time/v1/time.proto b/proto/bakhyard/time/v1/time.proto new file mode 100644 index 0000000..64a8924 --- /dev/null +++ b/proto/bakhyard/time/v1/time.proto @@ -0,0 +1,26 @@ +edition = "2024"; + +package bakhyard.time.v1; + +// TimeService answers the time that the user logged on Jira, for the Home's "This week". It's +// internal: home calls it, and the gateway never routes it. +service TimeService { + // GetWeek answers this week's hours against the week's target. It fails with UNAVAILABLE while + // Jira is set up but no read of the worklogs has succeeded yet. + rpc GetWeek(GetWeekRequest) returns (GetWeekResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } +} + +message GetWeekRequest {} + +message GetWeekResponse { + // week is unset when there's none, as when Jira isn't set up. + Week week = 1; +} + +// Week is the time logged this week, from Monday through today, against the week's target. +message Week { + double logged_hours = 1; + double target_hours = 2; +} diff --git a/services/time/internal/week/week.go b/services/time/internal/week/week.go new file mode 100644 index 0000000..da58f55 --- /dev/null +++ b/services/time/internal/week/week.go @@ -0,0 +1,52 @@ +// Package week serves time's side of the time contract: this week's hours against the week's +// target, for the Home's "This week" (docs/plan.md decision 185). +package week + +import ( + "context" + + "connectrpc.com/connect/v2" + + timev1 "github.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1" + "github.com/codedogapp/bakhyard/services/time/internal/worklogs" +) + +// Server answers the week of the time reports that a poller keeps. +type Server struct { + reports *worklogs.Poller +} + +// NewServer returns a server of the week in the reports that reports keeps, which answers no week +// when Jira isn't set up and reports is nil. +func NewServer(reports *worklogs.Poller) *Server { + return &Server{reports: reports} +} + +// GetWeek answers the week of the report that the latest poll to succeed worked out, once the +// first poll has ended, or returns ctx's error if ctx ends first. Until a poll succeeds, the week +// is unavailable. +func (s *Server) GetWeek( + ctx context.Context, + _ *timev1.GetWeekRequest, +) (*timev1.GetWeekResponse, error) { + if s.reports == nil { + return timev1.GetWeekResponse_builder{}.Build(), nil + } + + latest, err := s.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") + } + + week := latest.Found.Week + + return timev1.GetWeekResponse_builder{Week: timev1.Week_builder{ + LoggedHours: new(week.LoggedHours), + TargetHours: new(week.TargetHours), + }.Build()}.Build(), nil +} diff --git a/services/time/internal/week/week_test.go b/services/time/internal/week/week_test.go new file mode 100644 index 0000000..f677caa --- /dev/null +++ b/services/time/internal/week/week_test.go @@ -0,0 +1,174 @@ +package week_test + +import ( + "context" + "errors" + "fmt" + "log/slog" + "testing" + "testing/synctest" + "time" + + timev1 "github.com/codedogapp/bakhyard/internal/gen/bakhyard/time/v1" + "github.com/codedogapp/bakhyard/internal/jira" + "github.com/codedogapp/bakhyard/services/time/internal/week" + "github.com/codedogapp/bakhyard/services/time/internal/worklogs" +) + +// A bubble's clock starts at midnight UTC on Saturday, 1 January 2000, and the pollers' zone is +// UTC, so the week starts on Monday, 27 December 1999, and the report's days on Sunday, 26th. + +// logged are the worklogs that the reads find, unless they fail: an hour last week and an hour +// and a half this week. +var logged = []jira.Worklog{ + {Started: time.Date(1999, 12, 26, 9, 0, 0, 0, time.UTC), Spent: time.Hour}, + {Started: time.Date(1999, 12, 27, 9, 0, 0, 0, time.UTC), Spent: 90 * time.Minute}, +} + +// 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 TestGetWeekAnswersTheWeek(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + reports := started(t, func(context.Context, time.Time, time.Time) ([]jira.Worklog, error) { + return logged, nil + }) + + if got, err := get(t.Context(), reports); err != nil || got != "1.5 of 40 hours" { + t.Errorf("got %q and %v, want 1.5 of 40 hours", got, err) + } + }) +} + +func TestGetWeekAnswersNoWeekWithoutJira(t *testing.T) { + if got, err := get(t.Context(), nil); err != nil || got != "no week" { + t.Errorf("got %q and %v, want no week", got, err) + } +} + +func TestGetWeekIsUnavailableUntilAReadSucceeds(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + reads := 0 + reports := started(t, func(context.Context, time.Time, time.Time) ([]jira.Worklog, error) { + reads++ + if reads == 1 { + return nil, errors.New("jira is down") + } + + return logged, nil + }) + + if got, err := get(t.Context(), reports); 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() + + if got, err := get(t.Context(), reports); err != nil || got != "1.5 of 40 hours" { + t.Errorf("after %d reads, got %q and %v, want 1.5 of 40 hours", reads, got, err) + } + }) +} + +func TestGetWeekKeepsTheWeekOfTheLastReadToSucceed(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + reads := 0 + reports := started(t, func(context.Context, time.Time, time.Time) ([]jira.Worklog, error) { + reads++ + if reads > 1 { + return nil, errors.New("jira is down") + } + + return logged, nil + }) + + time.Sleep(5 * time.Minute) + synctest.Wait() + + got, err := get(t.Context(), reports) + if reads != 2 || err != nil || got != "1.5 of 40 hours" { + t.Errorf("after %d reads, got %q and %v, want 1.5 of 40 hours", reads, got, err) + } + }) +} + +func TestGetWeekWaitsForTheFirstRead(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + release := make(chan struct{}) + reports := started(t, func(context.Context, time.Time, time.Time) ([]jira.Worklog, error) { + <-release + + return logged, nil + }) + + answered := make(chan string) + + go func() { + got, err := get(t.Context(), reports) + answered <- fmt.Sprintf("%s %v", 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 != "1.5 of 40 hours " { + t.Errorf("got %s, want 1.5 of 40 hours", got) + } + }) +} + +func TestGetWeekStopsWaitingWhenItsContextEnds(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + reports := started(t, 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() + + res, err := week.NewServer(reports).GetWeek(ctx, &timev1.GetWeekRequest{}) + if !errors.Is(err, context.DeadlineExceeded) || res != nil { + t.Errorf("got %v and %v, want no answer and the context's error", res, err) + } + }) +} + +// started returns a poller in UTC that reads the worklogs with read, and runs it until the test +// ends. +func started( + t *testing.T, + read func(ctx context.Context, start, end time.Time) ([]jira.Worklog, error), +) *worklogs.Poller { + t.Helper() + + poller := worklogs.NewPoller(read, time.UTC, slog.New(slog.DiscardHandler)) + go poller.Run(t.Context()) + + return poller +} + +// get returns how the week that a server of reports answers reads, "1.5 of 40 hours" or "no +// week", or the error that it returns. +func get(ctx context.Context, reports *worklogs.Poller) (string, error) { + res, err := week.NewServer(reports).GetWeek(ctx, &timev1.GetWeekRequest{}) + if err != nil { + return "", err + } + + if !res.HasWeek() { + return "no week", nil + } + + return fmt.Sprintf("%g of %g hours", res.GetWeek().GetLoggedHours(), + res.GetWeek().GetTargetHours()), nil +} diff --git a/services/time/jira_test.go b/services/time/jira_test.go index fdb4ecf..2eee81d 100644 --- a/services/time/jira_test.go +++ b/services/time/jira_test.go @@ -3,8 +3,10 @@ package main import ( "context" "encoding/json/v2" + "fmt" "net/http" "net/http/httptest" + "sync/atomic" "testing" "time" @@ -68,10 +70,12 @@ 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. +// before each answer, and an hour more with each later read of its worklogs. func jiraServer(t *testing.T) string { t.Helper() + var reads atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { email, token, ok := r.BasicAuth() if !ok || email != "me@example.com" || token != "secret" { @@ -87,8 +91,9 @@ func jiraServer(t *testing.T) string { _, _ = w.Write([]byte(`{"isLast":true,"issues":[{"id":"10001"}]}`)) case "/rest/api/3/issue/10001/worklog": started := time.Now().Add(-time.Minute).Format(startedLayout) - _, _ = w.Write([]byte(`{"total":1,"worklogs":[{"author":{"accountId":"me"},` + - `"started":"` + started + `","timeSpentSeconds":5400}]}`)) + spent := 1800 + 3600*reads.Add(1) + _, _ = fmt.Fprintf(w, `{"total":1,"worklogs":[{"author":{"accountId":"me"},`+ + `"started":"%s","timeSpentSeconds":%d}]}`, started, spent) default: w.WriteHeader(http.StatusNotFound) } diff --git a/services/time/main.go b/services/time/main.go index 634362b..d4e6356 100644 --- a/services/time/main.go +++ b/services/time/main.go @@ -6,15 +6,19 @@ import ( "net/http" "os" + "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/time/v1/timev1connect" "github.com/codedogapp/bakhyard/internal/health" "github.com/codedogapp/bakhyard/internal/jira" "github.com/codedogapp/bakhyard/internal/keychain" "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/week" ) func main() { @@ -30,8 +34,8 @@ func main() { } } -// routes returns time's handler, which serves its API. 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 side of the time contract. 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, @@ -46,7 +50,13 @@ func routes( // Answer HEAD wherever GET is served. router.Use(middleware.GetHead) health.Register(router) - api.Register(router, poll(ctx, client, slog.Default())) + + reports := poll(ctx, client, slog.Default()) + api.Register(router, reports) + + timeServer := connect.NewServer() + timev1connect.RegisterTimeServiceHandler(timeServer, week.NewServer(reports)) + connecthttp.Mount(router, timeServer) return router, nil } diff --git a/services/time/week_test.go b/services/time/week_test.go new file mode 100644 index 0000000..b85651e --- /dev/null +++ b/services/time/week_test.go @@ -0,0 +1,81 @@ +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" +) + +func TestTimeAnswersTheWeekOfItsReport(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 := 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) + } + + // 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. + if !got.HasWeek() || got.GetWeek().GetLoggedHours() != want.Week.LoggedHours || + got.GetWeek().GetTargetHours() != want.Week.TargetHours || want.Week.TargetHours != 40 { + t.Errorf("got %v, want the report's week %+v, with a target of 40 hours", got, want.Week) + } +} + +func TestTimeAnswersNoWeekWithoutJira(t *testing.T) { + router, err := routes(t.Context(), envOf(nil), secretsWith(nil)) + if err != nil { + t.Fatal(err) + } + + if got := getWeek(t, router); got.HasWeek() { + t.Errorf("got %v, want no week", got) + } +} + +// getWeek returns router's answer to GetWeek over the time contract, called with the gRPC protocol. +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()))) + + res, err := client.GetWeek(t.Context(), &timev1.GetWeekRequest{}) + if err != nil { + t.Fatal(err) + } + + return res +}