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
20 changes: 15 additions & 5 deletions docs/plan.md

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions services/time/attention_test.go
Original file line number Diff line number Diff line change
@@ -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 "<id> due <its due time in the local zone>".
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
}
34 changes: 34 additions & 0 deletions services/time/grpc_test.go
Original file line number Diff line number Diff line change
@@ -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()),
)
}
100 changes: 100 additions & 0 deletions services/time/internal/attention/attention.go
Original file line number Diff line number Diff line change
@@ -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
}
157 changes: 157 additions & 0 deletions services/time/internal/attention/attention_test.go
Original file line number Diff line number Diff line change
@@ -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 "<id> <title> · <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
}
Loading
Loading