diff --git a/README.md b/README.md index 05a3c4c0..4a4fb9d4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ config: - Issue panel with description (edited as markdown), comments, links, subtasks, an epic's children, attachments and private notes; ask an LLM command about it - Sprint planning and refinement; burndown, velocity, cycle time and retro charts; an epic roadmap; releases - A home screen of your day: your work, inbox, sprint health, timer and saved searches -- Time tracking: log work, a timer, the day's and the week's worklogs, proposals from git, your agents' activity and your calendar +- Time tracking: log work, a timer, the day's and the week's worklogs, proposals from git, your agents' activity and your calendar; to Jira or to your own time registration - Inbox: a thread per issue others changed, on every site; a standup that walks the board, with a timer - Git: branch keys in commits, draft pull requests, a prompt segment - GitLab merge requests: the ones waiting on you; pipelines and job logs, a diff to review line by line with notes and suggestions, or a coding agent reviews it for you; merge and edit them (reviewers, assignees, labels, draft) diff --git a/docs/guide/05-your-day.md b/docs/guide/05-your-day.md index 3fbb980d..d229d195 100644 --- a/docs/guide/05-your-day.md +++ b/docs/guide/05-your-day.md @@ -99,6 +99,15 @@ totals and how short each past workday is of 8h. Log on any cell. > **Try it:** on Friday, open the week, fill the gaps, `y`, paste it into > the time registration. +### Your own time registration + +Time goes to Jira's worklogs unless `timesheet:` in the config points at +a server speaking laneway's [timesheet API](../reference/timesheet-api.md): +a time registration tool of your own, or an adapter in front of one. The +timer, the day, the week and proposals then use it. When that server runs +a timer itself, `T` starts and stops that one, and a timer started there +shows in laneway within half a minute. `left:` is Jira's only. + ## Standup The standup since the previous workday (Friday on a Monday; diff --git a/docs/reference/config.md b/docs/reference/config.md index ee537dd2..37240aa1 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -80,6 +80,22 @@ when the issue opens and kept a day; a list opened before that ranks by the picks alone, and a list never reorders once open. A GitLab person is matched to Jira by `git_people`, then by email, then by a name exactly one Jira user has. +## Timesheet + +Time goes to Jira's worklogs unless `timesheet:` names another place: a +server speaking laneway's [timesheet API](timesheet-api.md). + +```yaml +timesheet: + provider: http # jira (default) or http + url: http://127.0.0.1:29174/laneway # the API's root + token_cmd: [pass, show, timesheet] # optional bearer token, or token: +``` + +The timer, the timesheet, the week view and proposals then use that +server, on every site. `left:` (Jira's remaining estimate) is Jira's only. +laneway web for many people always uses Jira. + ## ui options Every option is optional. `,` (terminal) and `g ,` (browser) list them by diff --git a/docs/reference/index.md b/docs/reference/index.md index 83cf1931..0affe5f6 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -11,3 +11,4 @@ Every option, key and command. New to laneway? Start with the - [Markdown editor](editor.md): how Jira's rich text edits as markdown. - [Rules](rules.md): react to changes on your boards. - [Command line](cli.md): flags, scripts, the prompt, git hooks. +- [Timesheet API](timesheet-api.md): log time somewhere other than Jira. diff --git a/docs/reference/timesheet-api.md b/docs/reference/timesheet-api.md new file mode 100644 index 00000000..d11f8947 --- /dev/null +++ b/docs/reference/timesheet-api.md @@ -0,0 +1,98 @@ +# Timesheet API + +laneway logs time to Jira's worklogs by default. With `timesheet: provider: http` it logs to any +server that speaks the small JSON API below instead: a time registration tool of your own, or an +adapter in front of one. One provider is active at a time. + +```yaml +timesheet: + provider: http + url: http://127.0.0.1:29174/laneway # the API's root, without /v1 + token_cmd: [pass, show, timesheet] # optional: a bearer token (or token:) +``` + +The timer (`T`), the timesheet (`W`), the week view and proposals then read and +write through the server. When the server runs a timer itself (`serverTimer`), laneway's timer is +that one: started in laneway it runs on the server, started elsewhere it shows in laneway. + +## Conventions + +- JSON in and out. Times are RFC 3339 with an offset; durations are whole seconds. A server + that stores minutes may round; laneway reads the entries again after each write. +- Requests carry `Authorization: Bearer ` when one is configured. +- An error is any non-2xx status with `{"error": "what went wrong", "hint": "…", "url": "…"}`; + `hint` and `url` are optional and shown with the error. `401`: the server needs a sign-in + of its own (`url` is where); `404`: no such entry; `400`, `409`, `422`: refused. +- An entry the server accepted but has not stored yet is `pending`. Its `id` may be temporary: + the server must accept that id on later calls (an edit or delete right after a create). + +## Entry + +```json +{ + "id": "425824", + "key": "ABC-123", + "summary": "Make the image configurable", + "description": "first draft", + "start": "2026-11-03T07:15:00Z", + "seconds": 5400, + "running": false, + "pending": false +} +``` + +| Field | | +|---|---| +| `id` | the server's id, a string | +| `key` | the Jira issue key; `""` for an entry without an issue (`keyless`) | +| `summary` | the issue's title, when the server knows it | +| `description` | what you did; laneway's worklog comment | +| `start` | when the work started | +| `seconds` | time logged; for a running entry the time so far | +| `running` | the server's running timer | +| `pending` | accepted, not stored yet | + +## Endpoints + +All under the configured root. + +### `GET /v1/info` + +```json +{"name": "easyreg", "caps": {"serverTimer": true, "move": true, "keyless": true}} +``` + +`name` labels the provider in laneway. `caps`, each `false` when left out: + +- `serverTimer`: the server runs a timer (`GET /v1/running`, `POST` without `seconds`). +- `move`: an entry's `key` can change (`PATCH` with `key`). +- `keyless`: entries without an issue exist. The server may still refuse to create one (`422`). + +### `GET /v1/entries?from=…&to=…` + +Your entries that start in `[from, to)`, a running one included, by start: +`{"entries": [Entry, …]}`. laneway asks for a day or a week. + +### `GET /v1/running` + +The running entry, or `null`. Only with `serverTimer`. laneway polls it about twice a minute. + +### `POST /v1/entries` + +```json +{"key": "ABC-123", "description": "…", "start": "2026-11-03T09:00:00+01:00", "seconds": 1800} +``` + +Logs an entry; answers `201` with the Entry. Without `seconds` it starts the timer at `start` +(`serverTimer`), and a timer already running ends there. + +### `PATCH /v1/entries/{id}` + +Any of `key`, `description`, `start`, `seconds`; answers the Entry. `seconds` sets the end to +the start (the new one when `start` is given) plus `seconds`: on a running entry that stops it. `start` +without `seconds` keeps the end. +`key` moves the entry to another issue (`move`). + +### `DELETE /v1/entries/{id}` + +Answers `204`. laneway uses it for the timer's discard too (`ctrl+d` at its stop prompt). diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index a8a6c1ea..e4b89ca8 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -23,8 +23,8 @@ import ( "github.com/emersion/go-ical" - "github.com/cornedor/laneway/internal/jira" "github.com/cornedor/laneway/internal/safeterm" + "github.com/cornedor/laneway/internal/timesheet" "github.com/cornedor/laneway/internal/work" ) @@ -226,10 +226,10 @@ func localZones(e *ical.Event) { // Proposals are the meetings as worklogs on key, each its own with its // summary as the comment; one a worklog on key already starts in is left out. -func Proposals(ms []Meeting, key string, logs []jira.Worklog) []work.Proposal { +func Proposals(ms []Meeting, key string, logs []timesheet.Entry) []work.Proposal { out := []work.Proposal{} for _, m := range ms { - if slices.ContainsFunc(logs, func(w jira.Worklog) bool { + if slices.ContainsFunc(logs, func(w timesheet.Entry) bool { return w.Key == key && !w.Started.Before(m.Start) && w.Started.Before(m.End) }) { continue @@ -241,7 +241,7 @@ func Proposals(ms []Meeting, key string, logs []jira.Worklog) []work.Proposal { } // Day is source's meetings on day as proposals on key; none without either. -func Day(ctx context.Context, source, key string, day time.Time, logs []jira.Worklog) ([]work.Proposal, error) { +func Day(ctx context.Context, source, key string, day time.Time, logs []timesheet.Entry) ([]work.Proposal, error) { if strings.TrimSpace(source) == "" || key == "" { return nil, nil } diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go index 859708b5..1a61c6ef 100644 --- a/internal/calendar/calendar_test.go +++ b/internal/calendar/calendar_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/cornedor/laneway/internal/jira" + "github.com/cornedor/laneway/internal/timesheet" ) // A week of a calendar as apps export it: a daily standup (not on the 2nd, @@ -143,7 +143,7 @@ func TestRead(t *testing.T) { func TestProposals(t *testing.T) { at := time.Date(2026, 10, 1, 11, 0, 0, 0, time.Local) ms := []Meeting{{Start: at, End: at.Add(15 * time.Minute), Summary: "Standup"}, {Start: at.Add(2 * time.Hour), End: at.Add(3 * time.Hour), Summary: "Review"}} - logs := []jira.Worklog{{Key: "OPS-1", Started: at.Add(5 * time.Minute)}, {Key: "ABC-1", Started: at.Add(2 * time.Hour)}} + logs := []timesheet.Entry{{Key: "OPS-1", Started: at.Add(5 * time.Minute)}, {Key: "ABC-1", Started: at.Add(2 * time.Hour)}} ps := Proposals(ms, "OPS-1", logs) if len(ps) != 1 || ps[0].Key != "OPS-1" || ps[0].Seconds != 3600 || ps[0].Comment != "Review" || !ps[0].Start.Equal(at.Add(2*time.Hour)) { t.Errorf("proposals %+v", ps) diff --git a/internal/config/config.go b/internal/config/config.go index 8b175cd4..5421df74 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -79,7 +79,10 @@ type Config struct { // GitLab are the GitLab instances merge requests are read from; a host // none names uses glab's login there. GitLab []GitLabConfig `yaml:"gitlab"` - UI UIConfig `yaml:"ui"` + // Timesheet is where time is logged: Jira's worklogs, or a server + // speaking laneway's timesheet API. + Timesheet TimesheetConfig `yaml:"timesheet"` + UI UIConfig `yaml:"ui"` // Rules fire on the changes a board refresh shows; see internal/rules. Rules []rules.Rule `yaml:"rules"` // RulesTest overrides the issue type and status `laneway rules test` @@ -105,6 +108,44 @@ type GitLabConfig struct { Repos map[string]string `yaml:"repos,omitempty"` } +// TimesheetConfig picks the timesheet provider +// (docs/reference/timesheet-api.md). +type TimesheetConfig struct { + // Provider is "jira" (the default: worklogs on the site) or "http". + Provider string `yaml:"provider,omitempty"` + // URL is the http provider's API root, without /v1. + URL string `yaml:"url,omitempty"` + // Token is sent as a bearer token; unset, token_cmd prints it. + Token string `yaml:"token,omitempty"` + TokenCmd []string `yaml:"token_cmd,omitempty"` +} + +// IsHTTP reports whether time goes to an http provider; an error is a +// provider laneway doesn't know or an http one without a URL. +func (t TimesheetConfig) IsHTTP() (bool, error) { + switch strings.ToLower(strings.TrimSpace(t.Provider)) { + case "", "jira": + return false, nil + case "http": + u, err := url.Parse(strings.TrimSpace(t.URL)) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return false, fmt.Errorf("timesheet.url: %q is not an http(s) URL", t.URL) + } + return true, nil + } + return false, fmt.Errorf("timesheet.provider: %q is not jira or http", t.Provider) +} + +// WithToken runs token_cmd for the token when token is unset. +func (t TimesheetConfig) WithToken() (TimesheetConfig, error) { + if strings.TrimSpace(t.Token) != "" || len(t.TokenCmd) == 0 { + return t, nil + } + tok, err := runTokenCmd("timesheet token_cmd", t.TokenCmd) + t.Token = tok + return t, err +} + // WithToken runs token_cmd for the token when token is unset. func (g GitLabConfig) WithToken() (GitLabConfig, error) { if strings.TrimSpace(g.Token) != "" || len(g.TokenCmd) == 0 { @@ -805,6 +846,8 @@ func unknownKeys(raw []byte) []string { check("ui.", v, reflect.TypeFor[UIConfig]()) case "rules_test": check("rules_test.", v, reflect.TypeFor[RulesTest]()) + case "timesheet": + check("timesheet.", v, reflect.TypeFor[TimesheetConfig]()) case "gitlab": if v.Kind == yaml.SequenceNode { for j, it := range v.Content { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6a508f01..20faeff2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -57,9 +57,30 @@ func TestRequestTimeout(t *testing.T) { } } +// TestTimesheetIsHTTP: jira by default, http with an http(s) URL, and +// anything else an error. +func TestTimesheetIsHTTP(t *testing.T) { + for _, tc := range []struct { + in TimesheetConfig + is, bad bool + }{ + {TimesheetConfig{}, false, false}, + {TimesheetConfig{Provider: "Jira"}, false, false}, + {TimesheetConfig{Provider: "http", URL: "http://127.0.0.1:29174/laneway"}, true, false}, + {TimesheetConfig{Provider: "http"}, false, true}, + {TimesheetConfig{Provider: "http", URL: "file:///tmp/x"}, false, true}, + {TimesheetConfig{Provider: "statz"}, false, true}, + } { + is, err := tc.in.IsHTTP() + if is != tc.is || (err != nil) != tc.bad { + t.Errorf("%+v = %v %v", tc.in, is, err) + } + } +} + // TestUnknownKeys: a key no option reads warns, with the nearest known one. func TestUnknownKeys(t *testing.T) { - raw := []byte("jira:\n base_url: x\n emial: a@b\nui:\n panel_widht: 40\n card_colors: off\n keys: {search: f}\nsites:\n work:\n api_tokn: t\ngitlab:\n - base_url: x\n tokn: t\nbogus: 1\n") + raw := []byte("jira:\n base_url: x\n emial: a@b\nui:\n panel_widht: 40\n card_colors: off\n keys: {search: f}\nsites:\n work:\n api_tokn: t\ngitlab:\n - base_url: x\n tokn: t\ntimesheet:\n ur: x\nbogus: 1\n") got := unknownKeys(raw) want := []string{ "bogus: unknown option", @@ -67,6 +88,7 @@ func TestUnknownKeys(t *testing.T) { "ui.panel_widht: unknown option, did you mean panel_width?", "sites.work.api_tokn: unknown option, did you mean api_token?", "gitlab[0].tokn: unknown option, did you mean token?", + "timesheet.ur: unknown option, did you mean url?", } if !slices.Equal(got, want) { t.Errorf("got %q\nwant %q", got, want) diff --git a/internal/i18n/nl/ui-shell.json b/internal/i18n/nl/ui-shell.json index 5da3a20c..9a63b4d9 100644 --- a/internal/i18n/nl/ui-shell.json +++ b/internal/i18n/nl/ui-shell.json @@ -479,6 +479,9 @@ "the whole file / the changes only": "het hele bestand / alleen de wijzigingen", "the worktrees without an agent too / not": "ook de worktrees zonder agent / niet", "this Jira can't move worklogs": "deze Jira kan geen werklogs verplaatsen", + "%s can't move worklogs": "%s kan geen werklogs verplaatsen", + "the timer on %s no longer runs in %s": "de timer op %s loopt niet meer in %s", + "left: sets Jira's estimate; %s has none": "left: zet de schatting in Jira; %s heeft die niet", "time machine: the board on earlier days": "tijdmachine: het bord op eerdere dagen", "time machine: ← → the board a day earlier / later, esc now": "tijdmachine: ← → het bord een dag eerder / later, esc nu", "timer / today's worklogs": "timer / werklogs van vandaag", diff --git a/internal/timesheet/httpsheet/httpsheet.go b/internal/timesheet/httpsheet/httpsheet.go new file mode 100644 index 00000000..6d2b9d64 --- /dev/null +++ b/internal/timesheet/httpsheet/httpsheet.go @@ -0,0 +1,277 @@ +// Package httpsheet is the timesheet provider on a server that speaks +// laneway's timesheet API (docs/reference/timesheet-api.md). +package httpsheet + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/cornedor/laneway/internal/timesheet" +) + +// Sheet is one timesheet server. +type Sheet struct { + base string // the API's root, without /v1 or a trailing slash + token string + hc *http.Client + + mu sync.Mutex + name string + caps timesheet.Caps + loaded bool +} + +// New is the provider on the server at base; token, when set, goes along +// as a bearer token. +func New(base, token string) *Sheet { + return &Sheet{base: strings.TrimRight(strings.TrimSpace(base), "/"), token: token, hc: &http.Client{Timeout: 20 * time.Second}} +} + +// Name is the server's own name once read, else its host. +func (s *Sheet) Name() string { + s.mu.Lock() + defer s.mu.Unlock() + if s.name != "" { + return s.name + } + if u, err := url.Parse(s.base); err == nil && u.Host != "" { + return u.Host + } + return "timesheet" +} + +// Caps are zero until a call has read them. +func (s *Sheet) Caps() timesheet.Caps { + s.mu.Lock() + defer s.mu.Unlock() + return s.caps +} + +// info reads the server's name and caps once. +func (s *Sheet) info(ctx context.Context) (timesheet.Caps, error) { + s.mu.Lock() + if s.loaded { + defer s.mu.Unlock() + return s.caps, nil + } + s.mu.Unlock() + var in struct { + Name string `json:"name"` + Caps struct { + ServerTimer bool `json:"serverTimer"` + Move bool `json:"move"` + Keyless bool `json:"keyless"` + } `json:"caps"` + } + if err := s.do(ctx, http.MethodGet, "/v1/info", nil, &in); err != nil { + return timesheet.Caps{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + s.name, s.loaded = strings.TrimSpace(in.Name), true + s.caps = timesheet.Caps{ServerTimer: in.Caps.ServerTimer, Move: in.Caps.Move, Keyless: in.Caps.Keyless} + return s.caps, nil +} + +// entry is an Entry on the wire. +type entry struct { + ID string `json:"id"` + Key string `json:"key"` + Summary string `json:"summary"` + Description string `json:"description"` + Start time.Time `json:"start"` + Seconds int `json:"seconds"` + Running bool `json:"running"` + Pending bool `json:"pending"` +} + +func (e entry) entry() timesheet.Entry { + return timesheet.Entry{ID: e.ID, Key: e.Key, Summary: e.Summary, Started: e.Start, Seconds: e.Seconds, + Comment: e.Description, Running: e.Running, Pending: e.Pending} +} + +func (s *Sheet) Entries(ctx context.Context, from, to time.Time) ([]timesheet.Entry, error) { + if _, err := s.info(ctx); err != nil { + return nil, err + } + var in struct { + Entries []entry `json:"entries"` + } + q := url.Values{"from": {from.Format(time.RFC3339)}, "to": {to.Format(time.RFC3339)}} + if err := s.do(ctx, http.MethodGet, "/v1/entries?"+q.Encode(), nil, &in); err != nil { + return nil, err + } + out := make([]timesheet.Entry, len(in.Entries)) + for i, e := range in.Entries { + out[i] = e.entry() + } + return out, nil +} + +// newEntry is POST /v1/entries's body; no Seconds starts the timer. +type newEntry struct { + Key string `json:"key"` + Description string `json:"description"` + Start time.Time `json:"start"` + Seconds *int `json:"seconds,omitempty"` +} + +func (s *Sheet) Add(ctx context.Context, e timesheet.New) error { + if e.Seconds < 60 { + return errors.New("timesheet: log at least a minute") + } + return s.do(ctx, http.MethodPost, "/v1/entries", newEntry{Key: e.Key, Description: e.Comment, Start: e.Started, Seconds: &e.Seconds}, nil) +} + +// patch is PATCH /v1/entries/{id}'s body. +type patch struct { + Key *string `json:"key,omitempty"` + Description *string `json:"description,omitempty"` + Start *time.Time `json:"start,omitempty"` + Seconds *int `json:"seconds,omitempty"` +} + +func entryPath(id string) string { return "/v1/entries/" + url.PathEscape(id) } + +func (s *Sheet) Update(ctx context.Context, _, id string, seconds int, started time.Time, comment *string) error { + if seconds < 60 { + return errors.New("timesheet: log at least a minute") + } + p := patch{Description: comment, Seconds: &seconds} + if !started.IsZero() { + p.Start = &started + } + return s.do(ctx, http.MethodPatch, entryPath(id), p, nil) +} + +func (s *Sheet) Delete(ctx context.Context, _ string, ids []string) error { + for _, id := range ids { + if err := s.do(ctx, http.MethodDelete, entryPath(id), nil, nil); err != nil { + return err + } + } + return nil +} + +func (s *Sheet) Move(ctx context.Context, _ string, ids []string, to string) error { + if caps, err := s.info(ctx); err != nil || !caps.Move { + return orElse(err, timesheet.ErrUnsupported) + } + for _, id := range ids { + if err := s.do(ctx, http.MethodPatch, entryPath(id), patch{Key: &to}, nil); err != nil { + return err + } + } + return nil +} + +func (s *Sheet) Running(ctx context.Context) (*timesheet.Entry, error) { + if caps, err := s.info(ctx); err != nil || !caps.ServerTimer { + return nil, err + } + var in *entry + if err := s.do(ctx, http.MethodGet, "/v1/running", nil, &in); err != nil || in == nil { + return nil, err + } + e := in.entry() + return &e, nil +} + +func (s *Sheet) Start(ctx context.Context, key, comment string, at time.Time) error { + if caps, err := s.info(ctx); err != nil || !caps.ServerTimer { + return orElse(err, timesheet.ErrUnsupported) + } + return s.do(ctx, http.MethodPost, "/v1/entries", newEntry{Key: key, Description: comment, Start: at}, nil) +} + +// orElse is err, else fallback. +func orElse(err, fallback error) error { + if err != nil { + return err + } + return fallback +} + +// Error is the server's refusal, with its hint and where to fix it. +type Error struct { + Status int + Msg string + Hint string + URL string +} + +func (e *Error) Error() string { + out := e.Msg + if out == "" { + out = http.StatusText(e.Status) + } + if e.Hint != "" { + out += " — " + e.Hint + } + if e.URL != "" { + out += " (" + e.URL + ")" + } + return out +} + +// maxBody bounds a response. +const maxBody = 8 << 20 + +func (s *Sheet) do(ctx context.Context, method, path string, body, out any) error { + var r io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + r = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, s.base+path, r) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if s.token != "" { + req.Header.Set("Authorization", "Bearer "+s.token) + } + resp, err := s.hc.Do(req) + if err != nil { + return fmt.Errorf("%s: %w", s.Name(), err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return fmt.Errorf("%s: %w", s.Name(), err) + } + if resp.StatusCode/100 != 2 { + e := &Error{Status: resp.StatusCode} + var in struct { + Error string `json:"error"` + Hint string `json:"hint"` + URL string `json:"url"` + } + if json.Unmarshal(raw, &in) == nil { + e.Msg, e.Hint, e.URL = in.Error, in.Hint, in.URL + } + return fmt.Errorf("%s: %w", s.Name(), e) + } + if out == nil || len(bytes.TrimSpace(raw)) == 0 { + return nil + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("%s: %s %s: %w", s.Name(), method, path, err) + } + return nil +} diff --git a/internal/timesheet/httpsheet/httpsheet_test.go b/internal/timesheet/httpsheet/httpsheet_test.go new file mode 100644 index 00000000..00351102 --- /dev/null +++ b/internal/timesheet/httpsheet/httpsheet_test.go @@ -0,0 +1,140 @@ +package httpsheet + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "time" + + "github.com/cornedor/laneway/internal/timesheet" + "github.com/cornedor/laneway/internal/timesheet/sheettest" +) + +func TestRoundTrip(t *testing.T) { + srv := sheettest.New() + defer srv.Close() + ctx := context.Background() + s := New(srv.URL+"/", "") + if s.Caps() != (timesheet.Caps{}) { + t.Fatalf("caps before a call = %+v, want zero", s.Caps()) + } + day := time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC) + at := day.Add(9 * time.Hour) + if err := s.Add(ctx, timesheet.New{Key: "ABC-1", Seconds: 1800, Started: at, Comment: "review"}); err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, timesheet.New{Key: "", Seconds: 600, Started: at.Add(time.Hour), Comment: "standup"}); err != nil { + t.Fatal(err) + } + got, err := timesheet.Day(ctx, s, day) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Key != "ABC-1" || got[0].Seconds != 1800 || got[0].Comment != "review" || !got[0].Started.Equal(at) || got[1].Key != "" { + t.Fatalf("entries = %+v", got) + } + if want := (timesheet.Caps{ServerTimer: true, Move: true, Keyless: true}); s.Caps() != want || s.Name() != "fake" { + t.Fatalf("after a call: caps %+v name %q", s.Caps(), s.Name()) + } + + comment := "review, done" + if err := s.Update(ctx, "ABC-1", got[0].ID, 3600, time.Time{}, &comment); err != nil { + t.Fatal(err) + } + if err := s.Move(ctx, "ABC-1", []string{got[0].ID}, "ABC-2"); err != nil { + t.Fatal(err) + } + if err := s.Delete(ctx, "", []string{got[1].ID}); err != nil { + t.Fatal(err) + } + got, _ = timesheet.Day(ctx, s, day) + if len(got) != 1 || got[0].Key != "ABC-2" || got[0].Seconds != 3600 || got[0].Comment != comment { + t.Fatalf("after update, move and delete: %+v", got) + } +} + +func TestTimer(t *testing.T) { + srv := sheettest.New() + defer srv.Close() + now := time.Date(2026, 11, 3, 10, 0, 0, 0, time.UTC) + srv.Now = func() time.Time { return now } + ctx := context.Background() + s := New(srv.URL, "") + if run, err := s.Running(ctx); err != nil || run != nil { + t.Fatalf("no timer: %+v %v", run, err) + } + if err := s.Start(ctx, "ABC-1", "", now.Add(-20*time.Minute)); err != nil { + t.Fatal(err) + } + run, err := s.Running(ctx) + if err != nil || run == nil || run.Key != "ABC-1" || !run.Running || run.Seconds != 1200 { + t.Fatalf("running = %+v %v", run, err) + } + // A second start ends the first where it begins. + if err := s.Start(ctx, "ABC-2", "", now); err != nil { + t.Fatal(err) + } + all, _ := s.Entries(ctx, now.Add(-time.Hour), now.Add(time.Hour)) + if len(all) != 2 || all[0].Running || all[0].Seconds != 1200 || !all[1].Running { + t.Fatalf("after a switch: %+v", all) + } + // Stopping is an update of the running entry. + if err := s.Update(ctx, "ABC-2", all[1].ID, 900, time.Time{}, nil); err != nil { + t.Fatal(err) + } + if run, _ := s.Running(ctx); run != nil { + t.Fatalf("still running: %+v", run) + } +} + +func TestNoServerTimer(t *testing.T) { + srv := sheettest.New() + defer srv.Close() + srv.ServerTimer, srv.Move = false, false + ctx := context.Background() + s := New(srv.URL, "") + if run, err := s.Running(ctx); err != nil || run != nil { + t.Fatalf("running without the cap = %+v %v", run, err) + } + if err := s.Start(ctx, "ABC-1", "", time.Now()); !errors.Is(err, timesheet.ErrUnsupported) { + t.Fatalf("start = %v, want ErrUnsupported", err) + } + if err := s.Move(ctx, "ABC-1", []string{"1"}, "ABC-2"); !errors.Is(err, timesheet.ErrUnsupported) { + t.Fatalf("move = %v, want ErrUnsupported", err) + } +} + +func TestErrorCarriesHint(t *testing.T) { + srv := sheettest.New() + defer srv.Close() + srv.Login = "statz wants its 2FA code" + _, err := New(srv.URL, "").Entries(context.Background(), time.Now(), time.Now().Add(time.Hour)) + var e *Error + if !errors.As(err, &e) || e.Status != 401 || !strings.Contains(err.Error(), "sign in first — statz wants its 2FA code") { + t.Fatalf("err = %v", err) + } +} + +func TestBearerToken(t *testing.T) { + srv := sheettest.New() + defer srv.Close() + var got string + inner := srv.Config.Handler + srv.Config.Handler = httpHandlerFunc(func(auth string) { got = auth }, inner) + if _, err := New(srv.URL, "s3cret").Running(context.Background()); err != nil { + t.Fatal(err) + } + if got != "Bearer s3cret" { + t.Fatalf("Authorization = %q", got) + } +} + +// httpHandlerFunc hands see each request's Authorization, then serves it. +func httpHandlerFunc(see func(string), next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + see(r.Header.Get("Authorization")) + next.ServeHTTP(w, r) + }) +} diff --git a/internal/timesheet/jirasheet/jirasheet.go b/internal/timesheet/jirasheet/jirasheet.go new file mode 100644 index 00000000..3513a6db --- /dev/null +++ b/internal/timesheet/jirasheet/jirasheet.go @@ -0,0 +1,56 @@ +// Package jirasheet is the timesheet provider on Jira's worklogs: your own +// worklogs are the entries, and laneway keeps the timer itself. +package jirasheet + +import ( + "context" + "time" + + "github.com/cornedor/laneway/internal/jira" + "github.com/cornedor/laneway/internal/timesheet" +) + +// Sheet logs time as worklogs on c's site. +type Sheet struct{ c *jira.Client } + +// New is the provider on c. +func New(c *jira.Client) *Sheet { return &Sheet{c: c} } + +func (s *Sheet) Name() string { return "Jira" } + +// Caps: Move is false on Jira Server and Data Center, and once the site +// turned a move away. +func (s *Sheet) Caps() timesheet.Caps { + return timesheet.Caps{Move: s.c.MovesWorklogs(), Remaining: true} +} + +func (s *Sheet) Entries(ctx context.Context, from, to time.Time) ([]timesheet.Entry, error) { + logs, err := s.c.MyWorklogsBetween(ctx, from, to) + out := make([]timesheet.Entry, len(logs)) + for i, w := range logs { + out[i] = timesheet.Entry{ID: w.ID, Key: w.Key, Summary: w.Summary, Started: w.Started, Seconds: w.Seconds, Comment: w.Comment} + } + return out, err +} + +func (s *Sheet) Add(ctx context.Context, e timesheet.New) error { + return s.c.AddWorklog(ctx, e.Key, e.Seconds, e.Started, e.Comment, e.Left) +} + +func (s *Sheet) Update(ctx context.Context, key, id string, seconds int, started time.Time, comment *string) error { + return s.c.UpdateWorklog(ctx, key, id, seconds, started, comment) +} + +func (s *Sheet) Delete(ctx context.Context, key string, ids []string) error { + return s.c.DeleteWorklogs(ctx, key, ids) +} + +func (s *Sheet) Move(ctx context.Context, key string, ids []string, to string) error { + return s.c.MoveWorklogs(ctx, key, ids, to) +} + +func (s *Sheet) Running(context.Context) (*timesheet.Entry, error) { return nil, nil } + +func (s *Sheet) Start(context.Context, string, string, time.Time) error { + return timesheet.ErrUnsupported +} diff --git a/internal/timesheet/sheettest/sheettest.go b/internal/timesheet/sheettest/sheettest.go new file mode 100644 index 00000000..b53dbfd9 --- /dev/null +++ b/internal/timesheet/sheettest/sheettest.go @@ -0,0 +1,213 @@ +// Package sheettest is an in-memory server speaking laneway's timesheet API +// (docs/reference/timesheet-api.md), for tests. +package sheettest + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "sync" + "time" +) + +// Entry is a stored entry; End nil runs. +type Entry struct { + ID, Key, Description string + Start time.Time + End *time.Time +} + +// Server is the fake. Its fields are safe to read under Lock. +type Server struct { + *httptest.Server + sync.Mutex + Entries []*Entry + // Caps are what /v1/info reports. + ServerTimer, Move, Keyless bool + // Login, when set, answers every call 401 with it as the hint. + Login string + // Now is the clock; time.Now when nil. + Now func() time.Time + next int +} + +// New starts a server with every cap on. +func New() *Server { + s := &Server{ServerTimer: true, Move: true, Keyless: true} + mux := http.NewServeMux() + mux.HandleFunc("GET /v1/info", s.info) + mux.HandleFunc("GET /v1/entries", s.list) + mux.HandleFunc("GET /v1/running", s.running) + mux.HandleFunc("POST /v1/entries", s.create) + mux.HandleFunc("PATCH /v1/entries/{id}", s.patch) + mux.HandleFunc("DELETE /v1/entries/{id}", s.delete) + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.Lock() + defer s.Unlock() + if s.Login != "" { + fail(w, http.StatusUnauthorized, "sign in first", s.Login) + return + } + mux.ServeHTTP(w, r) + })) + return s +} + +func (s *Server) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} + +// Running is the running entry, nil for none. Call under Lock. +func (s *Server) Running() *Entry { + for _, e := range s.Entries { + if e.End == nil { + return e + } + } + return nil +} + +func fail(w http.ResponseWriter, status int, msg, hint string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg, "hint": hint}) +} + +func reply(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func (s *Server) wire(e *Entry) map[string]any { + end := s.now() + if e.End != nil { + end = *e.End + } + return map[string]any{"id": e.ID, "key": e.Key, "summary": "", "description": e.Description, + "start": e.Start, "seconds": int(end.Sub(e.Start).Seconds()), "running": e.End == nil, "pending": false} +} + +func (s *Server) info(w http.ResponseWriter, _ *http.Request) { + reply(w, http.StatusOK, map[string]any{"name": "fake", "caps": map[string]bool{"serverTimer": s.ServerTimer, "move": s.Move, "keyless": s.Keyless}}) +} + +func (s *Server) list(w http.ResponseWriter, r *http.Request) { + from, err1 := time.Parse(time.RFC3339, r.URL.Query().Get("from")) + to, err2 := time.Parse(time.RFC3339, r.URL.Query().Get("to")) + if err1 != nil || err2 != nil { + fail(w, http.StatusBadRequest, "bad from or to", "") + return + } + out := []map[string]any{} + sorted := slices.Clone(s.Entries) + slices.SortFunc(sorted, func(a, b *Entry) int { return a.Start.Compare(b.Start) }) + for _, e := range sorted { + if !e.Start.Before(from) && e.Start.Before(to) { + out = append(out, s.wire(e)) + } + } + reply(w, http.StatusOK, map[string]any{"entries": out}) +} + +func (s *Server) running(w http.ResponseWriter, _ *http.Request) { + if e := s.Running(); e != nil { + reply(w, http.StatusOK, s.wire(e)) + return + } + reply(w, http.StatusOK, nil) +} + +type body struct { + Key *string `json:"key"` + Description *string `json:"description"` + Start *time.Time `json:"start"` + Seconds *int `json:"seconds"` +} + +func (s *Server) create(w http.ResponseWriter, r *http.Request) { + var b body + if json.NewDecoder(r.Body).Decode(&b) != nil || b.Start == nil || b.Key == nil { + fail(w, http.StatusBadRequest, "want key and start", "") + return + } + if *b.Key == "" && !s.Keyless { + fail(w, http.StatusUnprocessableEntity, "an entry needs an issue", "") + return + } + s.next++ + e := &Entry{ID: strconv.Itoa(s.next), Key: *b.Key, Start: *b.Start} + if b.Description != nil { + e.Description = *b.Description + } + if b.Seconds != nil { + end := e.Start.Add(time.Duration(*b.Seconds) * time.Second) + e.End = &end + } else { + if !s.ServerTimer { + fail(w, http.StatusBadRequest, "want seconds", "") + return + } + if run := s.Running(); run != nil { + end := e.Start + run.End = &end + } + } + s.Entries = append(s.Entries, e) + reply(w, http.StatusCreated, s.wire(e)) +} + +func (s *Server) find(id string) *Entry { + for _, e := range s.Entries { + if e.ID == id { + return e + } + } + return nil +} + +func (s *Server) patch(w http.ResponseWriter, r *http.Request) { + e := s.find(r.PathValue("id")) + if e == nil { + fail(w, http.StatusNotFound, "no such entry", "") + return + } + var b body + if json.NewDecoder(r.Body).Decode(&b) != nil { + fail(w, http.StatusBadRequest, "bad body", "") + return + } + if b.Key != nil { + if !s.Move { + fail(w, http.StatusUnprocessableEntity, "entries don't move", "") + return + } + e.Key = *b.Key + } + if b.Description != nil { + e.Description = *b.Description + } + if b.Start != nil { + e.Start = *b.Start + } + if b.Seconds != nil { + end := e.Start.Add(time.Duration(*b.Seconds) * time.Second) + e.End = &end + } + reply(w, http.StatusOK, s.wire(e)) +} + +func (s *Server) delete(w http.ResponseWriter, r *http.Request) { + i := slices.IndexFunc(s.Entries, func(e *Entry) bool { return e.ID == r.PathValue("id") }) + if i < 0 { + fail(w, http.StatusNotFound, "no such entry", "") + return + } + s.Entries = slices.Delete(s.Entries, i, i+1) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/timesheet/timesheet.go b/internal/timesheet/timesheet.go new file mode 100644 index 00000000..06bc848f --- /dev/null +++ b/internal/timesheet/timesheet.go @@ -0,0 +1,95 @@ +// Package timesheet is the provider-neutral half of laneway's time +// tracking: the entries the timesheet, week and proposals show, and the +// Provider interface a backend implements. Jira's worklogs are one backend +// (internal/timesheet/jirasheet); a timesheet server speaking laneway's HTTP +// contract (docs/reference/timesheet-api.md) is another +// (internal/timesheet/httpsheet). One provider is active at a time. +package timesheet + +import ( + "context" + "errors" + "time" +) + +// Entry is one stretch of logged work. +type Entry struct { + ID string + // Key is the issue it is logged on; "" for an entry without one, which + // a backend with Caps.Keyless allows. + Key string + Summary string + Started time.Time + // Seconds is the time logged; a running entry's is the time so far. + Seconds int + Comment string + // Running is the backend's running timer (Caps.ServerTimer). + Running bool `json:",omitempty"` + // Pending is an entry the backend took but has not stored yet: shown, + // but its ID may still change. + Pending bool `json:",omitempty"` +} + +// Caps are what a provider can do beyond logging, reading, editing and +// deleting entries. +type Caps struct { + // ServerTimer: the backend runs the timer (Running, Start); laneway's + // own timer then shows and stops that one. + ServerTimer bool + // Move: entries move to another issue (Move). + Move bool + // Remaining: a new entry's Left sets the issue's remaining estimate. + Remaining bool + // Keyless: entries may lack an issue. + Keyless bool +} + +// New is an entry to log. +type New struct { + Key string + Seconds int + Started time.Time + Comment string + // Left is what the remaining estimate becomes (Caps.Remaining): "" takes + // the time off it, "keep" leaves it, else a duration ("2h"). + Left string +} + +// ErrUnsupported is a call the provider's Caps rule out, for errors.Is. +var ErrUnsupported = errors.New("timesheet: not supported by this provider") + +// Provider is one timesheet backend. Implementations are safe for +// concurrent use: the UI calls them from background goroutines. Calls that +// name an entry pass its issue key too: Jira's worklog API needs it, a +// backend with its own ids ignores it. +type Provider interface { + // Name is the backend's display name ("Jira", "easyreg"). + Name() string + // Caps are what it can do; a provider that reads them from its backend + // reports zero Caps until a call has. + Caps() Caps + // Entries are yours that start in [from, to), by start. + Entries(ctx context.Context, from, to time.Time) ([]Entry, error) + // Add logs a new entry. + Add(ctx context.Context, e New) error + // Update sets entry id's time, its comment unless comment is nil, and + // its start unless started is zero. On a running entry it stops it, + // seconds after its start. + Update(ctx context.Context, key, id string, seconds int, started time.Time, comment *string) error + // Delete removes entries ids, all on key. + Delete(ctx context.Context, key string, ids []string) error + // Move puts entries ids, all on key, on the issue to (Caps.Move). + Move(ctx context.Context, key string, ids []string, to string) error + // Running is the backend's running entry, nil when none runs or the + // backend has no timer (Caps.ServerTimer). + Running(ctx context.Context) (*Entry, error) + // Start starts the backend's timer on key at at, ending a running one + // there (Caps.ServerTimer). + Start(ctx context.Context, key, comment string, at time.Time) error +} + +// Day is p's entries on day (local midnight to midnight). +func Day(ctx context.Context, p Provider, day time.Time) ([]Entry, error) { + from := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()) + return p.Entries(ctx, from, from.AddDate(0, 0, 1)) +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 25f541cc..50449008 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -24,6 +24,7 @@ import ( "github.com/cornedor/laneway/internal/people" "github.com/cornedor/laneway/internal/rules" "github.com/cornedor/laneway/internal/store" + "github.com/cornedor/laneway/internal/timesheet" "github.com/cornedor/laneway/internal/viewport" ) @@ -269,6 +270,9 @@ type Model struct { rulesLog string jiraClient *jira.Client + // sheetProvider is where time is logged; nil: worklogs on jiraClient. + sheetProvider timesheet.Provider + timerSyncErr string // the last failed read of the backend's timer, logged once // eventRefreshPending: a change-stream refresh is armed (events.go). eventRefreshPending bool eventInboxPending bool // a change-stream inbox sync is armed @@ -915,6 +919,8 @@ func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleWorklogFailed(msg) case timerTickMsg: return m.handleTimerTick() + case timerSyncMsg: + return m.handleTimerSync(msg) case standupMsg: return m.handleStandup(msg) case standupTickMsg: diff --git a/internal/ui/prompt.go b/internal/ui/prompt.go index 6ade671b..a4f46fba 100644 --- a/internal/ui/prompt.go +++ b/internal/ui/prompt.go @@ -31,10 +31,9 @@ type Prompt struct { func ReadPrompt(st *store.Store, now time.Time) Prompt { p := Prompt{Key: BranchIssue()} if v, ok, _ := st.GetMeta(timerMeta); ok { - key, unix, _ := strings.Cut(v, " ") - if sec, err := strconv.ParseInt(unix, 10, 64); key != "" && err == nil { - p.TimerKey = key - p.Timer = jira.FormatDuration(max(int(now.Sub(time.Unix(sec, 0)).Seconds()), 0)) + if t := parseTimer(v); t.key != "" { + p.TimerKey = t.key + p.Timer = jira.FormatDuration(max(int(now.Sub(t.start).Seconds()), 0)) } } if v, ok, _ := st.GetMeta(inboxUnreadMeta); ok { diff --git a/internal/ui/propose.go b/internal/ui/propose.go index 46442066..7b3b7de2 100644 --- a/internal/ui/propose.go +++ b/internal/ui/propose.go @@ -13,6 +13,7 @@ import ( "github.com/cornedor/laneway/internal/calendar" "github.com/cornedor/laneway/internal/jira" + "github.com/cornedor/laneway/internal/timesheet" "github.com/cornedor/laneway/internal/work" ) @@ -48,11 +49,11 @@ type proposalsMsg struct { // loadProposals reads the day's work and what is logged, for p. func (m *Model) loadProposals() tea.Cmd { p := m.jiraPicker - gen, day, c, ctx, repos, acts := p.gen, p.day, m.jiraClient, m.ctx, work.Repos(m.jiraRepos), m.uiConfig.Activity + gen, day, sh, ctx, repos, acts := p.gen, p.day, m.sheet(), m.ctx, work.Repos(m.jiraRepos), m.uiConfig.Activity cal, meetingKey := m.uiConfig.Calendar, strings.TrimSpace(m.uiConfig.MeetingKey) m.status = i18n.T("reading git and ui.activity…") return func() tea.Msg { - logs, err := c.MyWorklogs(ctx, day) + logs, err := timesheet.Day(ctx, sh, day) if err != nil { return proposalsMsg{gen: gen, day: day, err: err} } diff --git a/internal/ui/timesheet.go b/internal/ui/timesheet.go new file mode 100644 index 00000000..65a9e06c --- /dev/null +++ b/internal/ui/timesheet.go @@ -0,0 +1,21 @@ +package ui + +import ( + "github.com/cornedor/laneway/internal/timesheet" + "github.com/cornedor/laneway/internal/timesheet/jirasheet" +) + +// WithTimesheet logs time to p instead of Jira's worklogs. +func (m Model) WithTimesheet(p timesheet.Provider) Model { + m.sheetProvider = p + return m +} + +// sheet is where time is logged: the configured provider, else the +// current site's worklogs. +func (m *Model) sheet() timesheet.Provider { + if m.sheetProvider != nil { + return m.sheetProvider + } + return jirasheet.New(m.jiraClient) +} diff --git a/internal/ui/timesheet_test.go b/internal/ui/timesheet_test.go new file mode 100644 index 00000000..e90f9c40 --- /dev/null +++ b/internal/ui/timesheet_test.go @@ -0,0 +1,114 @@ +package ui + +import ( + "context" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/cornedor/laneway/internal/timesheet/httpsheet" + "github.com/cornedor/laneway/internal/timesheet/sheettest" +) + +// sheetModel is the board logging time to a fake timesheet server that +// runs the timer, its caps read as the first tick would. +func sheetModel(t *testing.T) (Model, *sheettest.Server) { + t.Helper() + srv := sheettest.New() + t.Cleanup(srv.Close) + sh := httpsheet.New(srv.URL, "") + if _, err := sh.Running(context.Background()); err != nil { + t.Fatal(err) + } + return jiraTabModel(t).WithTimesheet(sh), srv +} + +// feed feeds cmd's message (a batch's, in turn) back into m. +func feed(t *testing.T, m Model, cmd tea.Cmd) Model { + t.Helper() + if cmd == nil { + return m + } + switch msg := cmd().(type) { + case tea.BatchMsg: + for _, c := range msg { + m = feed(t, m, c) + } + return m + case timerTickMsg: + return m // the next tick, not now + default: + out, next := m.Update(msg) + return feed(t, out.(Model), next) + } +} + +// TestServerTimer: T starts the timesheet's timer, its stop logs the time on +// that running entry, and a switch made elsewhere shows on the next tick. +func TestServerTimer(t *testing.T) { + m, srv := sheetModel(t) + out, cmd := m.handleJiraKey(keyMsg(t, "T")) + m = feed(t, out.(Model), cmd) + srv.Lock() + r := srv.Running() + srv.Unlock() + if r == nil || r.Key != "ABC-1" || m.timer.key != "ABC-1" || m.timer.id != r.ID { + t.Fatalf("after T: server %+v, timer %+v", r, m.timer) + } + if v, _, _ := m.store.GetMeta(timerMeta); !strings.HasSuffix(v, " "+r.ID) { + t.Errorf("state file %q lacks the entry's id", v) + } + + // Switched to another issue elsewhere (easyreg's own page). + srv.Lock() + start := time.Now().Add(-25 * time.Minute).Truncate(time.Second) + r.Start = start.Add(-time.Hour) + r.End = &start + srv.Entries = append(srv.Entries, &sheettest.Entry{ID: "99", Key: "ABC-2", Start: start}) + srv.Unlock() + m = feed(t, m, m.syncTimer()) // the tick's read + if m.timer.key != "ABC-2" || m.timer.id != "99" { + t.Fatalf("after a switch elsewhere: %+v", m.timer) + } + if v := ansi.Strip(m.View().Content); !strings.Contains(v, "⏱ ABC-2 25m") { + t.Errorf("header lacks the switched timer:\n%s", v) + } + + // The stop logs on the running entry, not a new one. (T on the + // board's ABC-1 would switch the timer there after.) + m.toggleTimer("") + m.jiraFieldInput.SetValue("30m review") + out, cmd = m.applyJiraField() + m = feed(t, out.(Model), cmd) + srv.Lock() + defer srv.Unlock() + e := srv.Entries[len(srv.Entries)-1] + if len(srv.Entries) != 2 || srv.Running() != nil || e.End == nil || e.End.Sub(e.Start) != 30*time.Minute || e.Description != "review" { + t.Fatalf("after the stop: %+v", e) + } + if m.timer.key != "" { + t.Errorf("timer after the stop = %+v", m.timer) + } +} + +// TestServerTimerDrop: ctrl+d at the stop prompt deletes the running entry. +func TestServerTimerDrop(t *testing.T) { + m, srv := sheetModel(t) + out, cmd := m.handleJiraKey(keyMsg(t, "T")) + m = feed(t, out.(Model), cmd) + out, _ = m.handleJiraKey(keyMsg(t, "T")) + m = out.(Model) + out, cmd, ok := m.timerStopKey(keyMsg(t, "ctrl+d")) + if !ok { + t.Fatal("ctrl+d not taken") + } + m = feed(t, out.(Model), cmd) + srv.Lock() + defer srv.Unlock() + if len(srv.Entries) != 0 || m.timer.key != "" { + t.Fatalf("after the drop: server %+v, timer %+v", srv.Entries, m.timer) + } +} diff --git a/internal/ui/week.go b/internal/ui/week.go index 30e2f94e..83f896db 100644 --- a/internal/ui/week.go +++ b/internal/ui/week.go @@ -13,6 +13,7 @@ import ( "github.com/cornedor/laneway/internal/i18n" "github.com/cornedor/laneway/internal/jira" + "github.com/cornedor/laneway/internal/timesheet" ) // W in today's worklogs opens the week in place of the board: an issue per @@ -41,7 +42,7 @@ type weekRow struct { type weekMsg struct { seq int - logs []jira.Worklog + logs []timesheet.Entry err error } @@ -61,9 +62,9 @@ func (m *Model) loadWeek() tea.Cmd { w := m.jiraTab.week w.seq++ w.loading = true - seq, from, c, ctx := w.seq, w.from, m.jiraClient, m.ctx + seq, from, sh, ctx := w.seq, w.from, m.sheet(), m.ctx return func() tea.Msg { - logs, err := c.MyWorklogsBetween(ctx, from, from.AddDate(0, 0, 7)) + logs, err := sh.Entries(ctx, from, from.AddDate(0, 0, 7)) return weekMsg{seq: seq, logs: logs, err: err} } } @@ -85,7 +86,7 @@ func (m Model) handleWeek(msg weekMsg) (tea.Model, tea.Cmd) { } // weekRows sums logs per issue and day, the issues by key. -func weekRows(logs []jira.Worklog, from time.Time) []weekRow { +func weekRows(logs []timesheet.Entry, from time.Time) []weekRow { var rows []weekRow for _, l := range logs { d := int(l.Started.In(from.Location()).Sub(from).Hours() / 24) diff --git a/internal/ui/week_test.go b/internal/ui/week_test.go index c1facf79..c4708093 100644 --- a/internal/ui/week_test.go +++ b/internal/ui/week_test.go @@ -8,7 +8,7 @@ import ( "github.com/charmbracelet/x/ansi" - "github.com/cornedor/laneway/internal/jira" + "github.com/cornedor/laneway/internal/timesheet" ) // TestWeek: W in the timesheet opens the week grid with day and week @@ -28,7 +28,7 @@ func TestWeek(t *testing.T) { } w.from, w.col = time.Date(2026, 9, 21, 0, 0, 0, 0, time.Local), 0 // a past week, all its gaps show at := func(d, h int) time.Time { return w.from.AddDate(0, 0, d).Add(time.Duration(h) * time.Hour) } - out, _ = m.Update(weekMsg{seq: w.seq, logs: []jira.Worklog{ + out, _ = m.Update(weekMsg{seq: w.seq, logs: []timesheet.Entry{ {Key: "ABC-10", Summary: "Tenth", Seconds: 3600, Started: at(0, 9)}, {Key: "ABC-2", Summary: "Second", Seconds: 5 * 3600, Started: at(0, 10)}, {Key: "ABC-2", Summary: "Second", Seconds: 2 * 3600, Started: at(1, 9)}, diff --git a/internal/ui/worklog.go b/internal/ui/worklog.go index da772d96..51baa3be 100644 --- a/internal/ui/worklog.go +++ b/internal/ui/worklog.go @@ -1,6 +1,7 @@ package ui import ( + "context" "errors" "fmt" "slices" @@ -15,16 +16,36 @@ import ( "github.com/cornedor/laneway/internal/i18n" "github.com/cornedor/laneway/internal/jira" "github.com/cornedor/laneway/internal/standup" + "github.com/cornedor/laneway/internal/timesheet" ) // Time tracking: w logs work on the panel's issue ("1h 30m what you did"), // T starts a timer on an issue and stops it into that log, W lists what you // logged today. The timer is kept in the store, so it outlives a restart. -// workTimer is the running timer, zero when none. +// workTimer is the running timer, zero when none. With a provider that +// runs the timer (Caps.ServerTimer) it is that one: id is its running +// entry, "" until read back after a start. type workTimer struct { key string start time.Time + id string +} + +// same is whether t and o are the one timer. +func (t workTimer) same(o workTimer) bool { + return t.key == o.key && t.start.Equal(o.start) && t.id == o.id +} + +// parseTimer reads the store's "KEY unix [id]". +func parseTimer(v string) workTimer { + key, rest, _ := strings.Cut(v, " ") + unix, id, _ := strings.Cut(rest, " ") + sec, err := strconv.ParseInt(unix, 10, 64) + if key == "" || err != nil { + return workTimer{} + } + return workTimer{key: key, start: time.Unix(sec, 0), id: id} } const timerMeta = jiraMetaPrefix + "timer" @@ -43,13 +64,9 @@ func (m *Model) loadTimer() bool { if m.store == nil || m.worklogFromTimer { return false } - var t workTimer v, _, _ := m.store.GetMeta(timerMeta) - key, unix, _ := strings.Cut(v, " ") - if sec, err := strconv.ParseInt(unix, 10, 64); key != "" && err == nil { - t = workTimer{key: key, start: time.Unix(sec, 0)} - } - if t.key == m.timer.key && t.start.Equal(m.timer.start) { + t := parseTimer(v) + if t.same(m.timer) { return false } m.timer = t @@ -73,6 +90,9 @@ func (m *Model) saveTimer() error { v := "" if m.timer.key != "" { v = m.timer.key + " " + strconv.FormatInt(m.timer.start.Unix(), 10) + if m.timer.id != "" { + v += " " + m.timer.id + } } return m.store.SetMeta(timerMeta, v) } @@ -81,10 +101,102 @@ func (m Model) handleTimerTick() (tea.Model, tea.Cmd) { if m.loadTimer() || m.timer.key != "" { m.redrawTimed() } - if m.store == nil && m.timer.key == "" { + if m.store == nil && m.timer.key == "" && m.sheetProvider == nil { return m, nil } - return m, timerTick() + return m, tea.Batch(timerTick(), m.syncTimer()) +} + +// serverTimer is whether the timesheet runs the timer itself. +func (m *Model) serverTimer() bool { return m.sheet().Caps().ServerTimer } + +// timerSyncMsg is the backend's running entry as read; err is a failed +// timer action (op) or read. +type timerSyncMsg struct { + run *timesheet.Entry + server bool + op string + err error +} + +// syncTimer reads the backend's running entry, so a timer started or +// stopped elsewhere shows here. Not while its stop prompt is open. +func (m *Model) syncTimer() tea.Cmd { + if m.sheetProvider == nil || m.worklogFromTimer { + return nil + } + sh, ctx := m.sheetProvider, m.ctx + return func() tea.Msg { + run, err := sh.Running(ctx) + return timerSyncMsg{run: run, server: sh.Caps().ServerTimer, err: err} + } +} + +// timerOp runs op on the backend's running entry (its id read first when +// the timer has none yet), then reads the timer back. +func (m *Model) timerOp(what string, op func(sh timesheet.Provider, id string) error) tea.Cmd { + sh, ctx, t := m.sheet(), m.ctx, m.timer + return func() tea.Msg { + id, err := runningID(ctx, sh, t) + if err == nil { + err = op(sh, id) + } + run, rerr := sh.Running(ctx) + if err == nil { + err = rerr + } + return timerSyncMsg{run: run, server: true, op: what, err: err} + } +} + +// runningID is t's entry on the backend: its id, else the running one's +// when it is on t's issue. +func runningID(ctx context.Context, sh timesheet.Provider, t workTimer) (string, error) { + if t.id != "" { + return t.id, nil + } + run, err := sh.Running(ctx) + switch { + case err != nil: + return "", err + case run == nil || run.Key != t.key: + return "", errors.New(i18n.Tf("the timer on %s no longer runs in %s", t.key, sh.Name())) + } + return run.ID, nil +} + +// handleTimerSync takes the backend's timer as laneway's. A failed read +// goes to the log once per error; a failed action shows. +func (m Model) handleTimerSync(msg timerSyncMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + if text := i18n.Tf("timer: %s", msg.err.Error()); msg.op != "" { + m.fail(text) + } else if text != m.timerSyncErr { + m.logError(text) + } + m.timerSyncErr = msg.err.Error() + if msg.op == "" { + return m, nil + } + return m, m.syncTimer() + } + m.timerSyncErr = "" + if !msg.server || m.worklogFromTimer { + return m, nil + } + var t workTimer + if r := msg.run; r != nil && r.Key != "" { // an entry without an issue is no timer of laneway's + t = workTimer{key: r.Key, start: time.Unix(r.Started.Unix(), 0), id: r.ID} + } + if t.same(m.timer) { + return m, nil + } + m.timer = t + m.redrawTimed() + if err := m.saveTimer(); err != nil { + m.logError(i18n.Tf("timer: %s", err.Error())) + } + return m, nil } // timerMark is "⏱ 12m" on the timed issue's card, row and panel; "" on @@ -130,10 +242,22 @@ func (m *Model) toggleTimer(key string) tea.Cmd { if err := m.saveTimer(); err != nil { m.fail(i18n.Tf("timer started on %s, but a restart loses it: %s", key, err.Error())) } + var start tea.Cmd + if m.serverTimer() { + sh, ctx, at := m.sheet(), m.ctx, m.timer.start + start = func() tea.Msg { + err := sh.Start(ctx, key, "", at) + run, rerr := sh.Running(ctx) + if err == nil { + err = rerr + } + return timerSyncMsg{run: run, server: true, op: "start", err: err} + } + } if m.store != nil { - return nil // the ticks run already + return start // the ticks run already } - return timerTick() + return tea.Batch(start, timerTick()) } // timerSeconds is the time a stopped timer logs: to the minute (at least @@ -182,6 +306,7 @@ const timerDropAfter = 5 * time.Minute // card T was pressed on. ok is false for any other key. func (m Model) timerStopKey(msg tea.KeyPressMsg) (_ tea.Model, _ tea.Cmd, ok bool) { ran := jira.FormatDuration(max(int(time.Since(m.timer.start).Seconds()), 0)) + var cmd tea.Cmd switch msg.String() { case "ctrl+d": if !m.timerDropArmed && time.Since(m.timer.start) >= timerDropAfter { @@ -190,12 +315,27 @@ func (m Model) timerStopKey(msg tea.KeyPressMsg) (_ tea.Model, _ tea.Cmd, ok boo return m, nil, true } key := m.timer.key + if m.serverTimer() { + cmd = m.timerOp("drop", func(sh timesheet.Provider, id string) error { + return sh.Delete(m.ctx, key, []string{id}) + }) + } m.timer = workTimer{} m.status = i18n.Tf("timer dropped: %s on %s not logged", ran, key) case "ctrl+t": if m.timerNext == "" { return m, nil, false } + if m.serverTimer() { + if sh := m.sheet(); !sh.Caps().Move { + m.fail(i18n.Tf("%s can't move worklogs", sh.Name())) + return m, nil, true + } + from, to := m.timer.key, m.timerNext + cmd = m.timerOp("move", func(sh timesheet.Provider, id string) error { + return sh.Move(m.ctx, from, []string{id}, to) + }) + } m.timer.key = m.timerNext m.status = i18n.Tf("timer moved to %s, %s on it", m.timerNext, ran) default: @@ -207,7 +347,7 @@ func (m Model) timerStopKey(msg tea.KeyPressMsg) (_ tea.Model, _ tea.Cmd, ok boo } m.closeJiraField() m.redrawTimed() - return m, nil, true + return m, cmd, true } // worklogLeft takes a left:2h (the remaining estimate after this work) or @@ -255,8 +395,12 @@ func worklogDay(raw string, now time.Time) (day time.Time, rest string, ok bool) func (m Model) applyWorklog(raw string) (tea.Model, tea.Cmd) { day, raw, onDay := worklogDay(raw, time.Now()) left, raw, err := worklogLeft(raw) - if err == nil && left != "" && m.worklogEdit != "" { + switch { + case err != nil || left == "": + case m.worklogEdit != "": err = errors.New(i18n.T("left: is for new work, not an edit")) + case !m.sheet().Caps().Remaining: + err = errors.New(i18n.Tf("left: sets Jira's estimate; %s has none", m.sheet().Name())) } if err != nil { m.fail(err.Error()) @@ -280,11 +424,11 @@ func (m Model) applyWorklog(raw string) (tea.Model, tea.Cmd) { input, editDay := m.jiraFieldInput.Value(), m.worklogEditDay m.worklogEdit = "" m.closeJiraField() - c, ctx := m.jiraClient, m.ctx + sh, ctx := m.sheet(), m.ctx reload := m.openTimesheetDay(showDay) m.status = i18n.Tf("updating the worklog on %s…", key) return m, func() tea.Msg { - if err := c.UpdateWorklog(ctx, key, id, secs, moved, newComment); err != nil { + if err := sh.Update(ctx, key, id, secs, moved, newComment); err != nil { return worklogFailedMsg{key: key, id: id, day: editDay, input: input, err: err} } return reload() @@ -297,10 +441,25 @@ func (m Model) applyWorklog(raw string) (tea.Model, tea.Cmd) { started = time.Now().Add(-time.Duration(secs) * time.Second) } m.closeJiraField() - c, ctx, fromTimer, next := m.jiraClient, m.ctx, m.worklogFromTimer, m.timerNext + sh, ctx, fromTimer, next := m.sheet(), m.ctx, m.worklogFromTimer, m.timerNext m.status = i18n.Tf("logging %s on %s…", jira.FormatDuration(secs), key) + if fromTimer && m.serverTimer() { + // The backend's running entry stops with the time and comment typed. + t, moved := m.timer, time.Time{} + if onDay { + moved = started + } + return m, func() tea.Msg { + id, err := runningID(ctx, sh, t) + if err == nil { + err = sh.Update(ctx, key, id, secs, moved, &comment) + } + return worklogLoggedMsg{key: key, fromTimer: true, next: next, err: err} + } + } return m, func() tea.Msg { - return worklogLoggedMsg{key: key, fromTimer: fromTimer, next: next, err: c.AddWorklog(ctx, key, secs, started, comment, left)} + err := sh.Add(ctx, timesheet.New{Key: key, Seconds: secs, Started: started, Comment: comment, Left: left}) + return worklogLoggedMsg{key: key, fromTimer: fromTimer, next: next, err: err} } } @@ -376,16 +535,16 @@ func (m *Model) openTimesheetDay(day time.Time) tea.Cmd { gen := m.startJiraPicker(jiraPickTimesheet, standup.Day(day, time.Now()), false) m.jiraPicker.day = day seq := m.jiraPicker.fetchSeq - c, ctx, k := m.jiraClient, m.ctx, m.keys + sh, ctx, k := m.sheet(), m.ctx, m.keys empty := i18n.Tf("nothing logged · %s in the panel logs work · %s %s another day", helpKey(k.LogWork), helpKey(k.PrevView), helpKey(k.NextView)) hint := i18n.Tf(" · %s %s day · %s edit · %s %s delete · %s mark", helpKey(k.PrevView), helpKey(k.NextView), helpKey(k.EditEntry), helpKey(k.DeleteEntry), helpKey(k.DeleteEntry), helpKey(k.Mark)) - if c.MovesWorklogs() { + if sh.Caps().Move { hint += i18n.Tf(" · %s move", helpKey(k.MoveEntry)) } hint += i18n.Tf(" · %s propose · %s copy · %s week", helpKey(k.ProposeWork), helpKey(k.CopyKey), helpKey(k.Timesheet)) return func() tea.Msg { - logs, err := c.MyWorklogs(ctx, day) + logs, err := timesheet.Day(ctx, sh, day) total := 0 items := make([]jiraPickerItem, len(logs)) rows := make([][]string, len(logs)) @@ -478,7 +637,7 @@ func (m *Model) timesheetKey(k string) (tea.Cmd, bool) { return nil, true } keys, ids := worklogsByIssue(rows) - day, c, ctx := p.day, m.jiraClient, m.ctx + day, sh, ctx := p.day, m.sheet(), m.ctx m.status = i18n.Tf("deleting a worklog on %s…", keys[0]) if len(rows) > 1 { m.status = i18n.Tf("deleting %d worklogs…", len(rows)) @@ -486,7 +645,7 @@ func (m *Model) timesheetKey(k string) (tea.Cmd, bool) { reload := m.openTimesheetDay(day) return func() tea.Msg { for _, key := range keys { // the API takes one issue's at a time - if err := c.DeleteWorklogs(ctx, key, ids[key]); err != nil { + if err := sh.Delete(ctx, key, ids[key]); err != nil { return worklogFailedMsg{key: key, day: day, err: err} } } @@ -497,8 +656,11 @@ func (m *Model) timesheetKey(k string) (tea.Cmd, bool) { if len(rows) == 0 { return nil, true } - if !m.jiraClient.MovesWorklogs() { + if sh := m.sheet(); !sh.Caps().Move { m.status = i18n.T("this Jira can't move worklogs") + if sh.Name() != "Jira" { + m.status = i18n.Tf("%s can't move worklogs", sh.Name()) + } return nil, true } day := p.day @@ -572,12 +734,12 @@ func (m Model) applyWorklogMove(raw string) (tea.Model, tea.Cmd) { m.closeJiraField() keys, ids := worklogsByIssue(rows) keys = slices.DeleteFunc(keys, func(k string) bool { return k == to }) // there already - c, ctx := m.jiraClient, m.ctx + sh, ctx := m.sheet(), m.ctx reload := m.openTimesheetDay(day) m.status = i18n.Tf("moving %s to %s…", i18n.Tn(len(rows), "%d worklog", "%d worklogs", len(rows)), to) return m, func() tea.Msg { for _, key := range keys { - if err := c.MoveWorklogs(ctx, key, ids[key], to); err != nil { + if err := sh.Move(ctx, key, ids[key], to); err != nil { return worklogFailedMsg{key: key, day: day, err: err} } } diff --git a/internal/web/api_core.go b/internal/web/api_core.go index bceb14c0..0ff66d0a 100644 --- a/internal/web/api_core.go +++ b/internal/web/api_core.go @@ -9,6 +9,7 @@ import ( "slices" "strconv" "strings" + "time" "github.com/cornedor/laneway/internal/config" "github.com/cornedor/laneway/internal/i18n" @@ -103,9 +104,24 @@ func session(ctx context.Context, s *Server, r *http.Request) (any, error) { "projects": s.projects(), "ui": s.UIConfig(), "autostart": s.autostartInfo(), "pins": fieldPins(s), "repos": slices.Sorted(maps.Keys(o.Jira.Repos)), // projects with a checkout: draft PR, removing a worktree "addSite": addSiteInfo(s), "home": homeInfo(s), "hosted": o.Hosted, + "timesheet": timesheetInfo(ctx, s), }, nil } +// timesheetInfo is where time is logged and what it takes, for the log +// dialog and the timer. An http provider reads its caps on its first +// call: a short one here, whose failure leaves them off. +func timesheetInfo(ctx context.Context, s *Server) map[string]any { + sh := s.Sheet() + if s.opt.Timesheet != nil { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + _, _ = sh.Running(ctx) + } + caps := sh.Caps() + return map[string]any{"name": sh.Name(), "remaining": caps.Remaining, "serverTimer": caps.ServerTimer} +} + func boardID(r *http.Request) (int, error) { id, err := strconv.Atoi(r.PathValue("board")) if err != nil { diff --git a/internal/web/api_standup.go b/internal/web/api_standup.go index c80d0756..81fa0986 100644 --- a/internal/web/api_standup.go +++ b/internal/web/api_standup.go @@ -13,6 +13,7 @@ import ( "github.com/cornedor/laneway/internal/i18n" "github.com/cornedor/laneway/internal/jira" "github.com/cornedor/laneway/internal/standup" + "github.com/cornedor/laneway/internal/timesheet" "github.com/cornedor/laneway/internal/work" ) @@ -167,7 +168,7 @@ func worklogProposals(ctx context.Context, s *Server, r *http.Request) (any, err if err != nil { return nil, err } - logs, err := s.Client().MyWorklogs(ctx, d) + logs, err := timesheet.Day(ctx, s.Sheet(), d) if err != nil { return nil, err } diff --git a/internal/web/api_timer.go b/internal/web/api_timer.go index e1b0f1c6..f8c64f87 100644 --- a/internal/web/api_timer.go +++ b/internal/web/api_timer.go @@ -12,36 +12,106 @@ import ( ) // The work timer, kept where the TUI keeps its own (internal/ui/worklog.go: -// "KEY unix"), so T in either runs the one timer and `laneway prompt` shows it. +// "KEY unix [id]"), so T in either runs the one timer and `laneway prompt` +// shows it. With a timesheet that runs the timer itself (Caps.ServerTimer) +// the timer is its running entry, and the state file mirrors it. const timerMeta = "jira_tab:timer" type workTimer struct { Key string Start time.Time + // ID is the timesheet's running entry, "" for laneway's own timer. + ID string `json:",omitempty"` +} + +func (t workTimer) meta() string { + if t.Key == "" { + return "" + } + v := t.Key + " " + strconv.FormatInt(t.Start.Unix(), 10) + if t.ID != "" { + v += " " + t.ID + } + return v +} + +func parseTimer(v string) workTimer { + key, rest, _ := strings.Cut(v, " ") + unix, id, _ := strings.Cut(rest, " ") + sec, err := strconv.ParseInt(unix, 10, 64) + if key == "" || err != nil { + return workTimer{} // Key "": none runs + } + return workTimer{key, time.Unix(sec, 0), id} +} + +// serverTimer is the timesheet's running entry as the timer, mirrored into +// the state file; ok is false when the timesheet has no timer. +func serverTimer(ctx context.Context, s *Server) (t workTimer, ok bool, err error) { + if s.opt.Timesheet == nil { + return t, false, nil + } + sh := s.Sheet() + run, err := sh.Running(ctx) + if err != nil || !sh.Caps().ServerTimer { + return t, false, err + } + if run != nil && run.Key != "" { // an entry without an issue is no timer of laneway's + t = workTimer{run.Key, time.Unix(run.Started.Unix(), 0), run.ID} + } + return t, true, s.opt.Store.SetMeta(timerMeta, t.meta()) +} + +// timerBody is PUT /timer's: Key and Start start or move the timer, Key "" +// clears it. Drop, with Key "", is the timer discarded: on the timesheet's +// timer its running entry on that issue is deleted (a clear after logging +// leaves it, the log stopped it already). +type timerBody struct { + Key string + Start time.Time + Drop string } func init() { get("/timer", func(ctx context.Context, s *Server, r *http.Request) (any, error) { - v, ok, _ := s.opt.Store.GetMeta(timerMeta) - key, unix, _ := strings.Cut(v, " ") - sec, err := strconv.ParseInt(unix, 10, 64) - if !ok || key == "" || err != nil { - return workTimer{}, nil // Key "": none runs + if t, ok, err := serverTimer(ctx, s); ok || err != nil { + return t, err } - return workTimer{key, time.Unix(sec, 0)}, nil + v, _, _ := s.opt.Store.GetMeta(timerMeta) + return parseTimer(v), nil }) put("/timer", func(ctx context.Context, s *Server, r *http.Request) (any, error) { - b, err := Body[workTimer](r) + b, err := Body[timerBody](r) if err != nil { return nil, err } - if b.Key == "" { - return nil, s.opt.Store.SetMeta(timerMeta, "") - } - if !jira.ValidKey(b.Key) || b.Start.IsZero() { + if b.Key != "" && (!jira.ValidKey(b.Key) || b.Start.IsZero()) { return nil, badRequest(i18n.T("timer needs an issue key and a start")) } - return nil, s.opt.Store.SetMeta(timerMeta, b.Key+" "+strconv.FormatInt(b.Start.Unix(), 10)) + run, ok, err := serverTimer(ctx, s) + if err != nil { + return nil, err + } + if !ok { + return nil, s.opt.Store.SetMeta(timerMeta, workTimer{Key: b.Key, Start: b.Start}.meta()) + } + sh := s.Sheet() + switch { + case b.Key == "": + if b.Drop != "" && run.Key == b.Drop { + err = sh.Delete(ctx, run.Key, []string{run.ID}) + } + case b.Key == run.Key: + case run.Key != "": + err = sh.Move(ctx, run.Key, []string{run.ID}, b.Key) + default: + err = sh.Start(ctx, b.Key, "", b.Start) + } + if err != nil { + return nil, err + } + _, _, err = serverTimer(ctx, s) + return nil, err }) } diff --git a/internal/web/api_timer_test.go b/internal/web/api_timer_test.go index e19497fe..85172165 100644 --- a/internal/web/api_timer_test.go +++ b/internal/web/api_timer_test.go @@ -23,7 +23,7 @@ func TestTimer(t *testing.T) { t.Fatalf("no timer = %+v", got) } start := time.Unix(1700000000, 0) - if code := issueCall(t, "PUT", ts.URL+"/api/timer", workTimer{"DEMO-3", start}, nil); code != 200 { + if code := issueCall(t, "PUT", ts.URL+"/api/timer", workTimer{Key: "DEMO-3", Start: start}, nil); code != 200 { t.Fatalf("put: %d", code) } if v, _, _ := st.GetMeta(timerMeta); v != "DEMO-3 1700000000" { diff --git a/internal/web/api_timesheet_test.go b/internal/web/api_timesheet_test.go new file mode 100644 index 00000000..54ff787b --- /dev/null +++ b/internal/web/api_timesheet_test.go @@ -0,0 +1,123 @@ +package web + +import ( + "context" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/cornedor/laneway/internal/config" + "github.com/cornedor/laneway/internal/store" + "github.com/cornedor/laneway/internal/timesheet" + "github.com/cornedor/laneway/internal/timesheet/httpsheet" + "github.com/cornedor/laneway/internal/timesheet/sheettest" +) + +// sheetServer is laneway web logging time to a fake timesheet server. +func sheetServer(t *testing.T) (*httptest.Server, *sheettest.Server) { + t.Helper() + sheet := sheettest.New() + t.Cleanup(sheet.Close) + st, err := store.Open(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(New(context.Background(), Options{Jira: config.JiraConfig{}, Store: st, Site: "s", + Timesheet: httpsheet.New(sheet.URL, "")})) + t.Cleanup(ts.Close) + return ts, sheet +} + +func TestWorklogsOnHTTPTimesheet(t *testing.T) { + ts, sheet := sheetServer(t) + start := time.Now().Add(-time.Hour).Truncate(time.Second) + from, tomorrow := start.Format(time.DateOnly), time.Now().AddDate(0, 0, 1).Format(time.DateOnly) + if c := workCall(t, "POST", ts.URL+"/api/worklog/ABC-1", `{"Seconds":1800,"Started":"`+start.Format(time.RFC3339)+`","Comment":"tests"}`, nil); c != 200 { + t.Fatalf("add = %d", c) + } + if c := workCall(t, "POST", ts.URL+"/api/worklog/ABC-1", `{"Seconds":1800,"Comment":"x","Left":"2h"}`, nil); c != 400 { + t.Fatalf("left: on a provider without estimates = %d, want 400", c) + } + var logs struct { + Worklogs []timesheet.Entry + CanMove bool + CanLeave bool + } + workCall(t, "GET", ts.URL+"/api/worklogs?from="+from+"&to="+tomorrow, "", &logs) + if len(logs.Worklogs) != 1 || logs.Worklogs[0].Key != "ABC-1" || logs.Worklogs[0].Seconds != 1800 || !logs.CanMove || logs.CanLeave { + t.Fatalf("worklogs = %+v", logs) + } + id := logs.Worklogs[0].ID + if c := workCall(t, "POST", ts.URL+"/api/worklog/ABC-1/move", `{"IDs":["`+id+`"],"To":"ABC-2"}`, nil); c != 200 { + t.Fatalf("move = %d", c) + } + if c := workCall(t, "DELETE", ts.URL+"/api/worklog/ABC-2", `{"IDs":["`+id+`"]}`, nil); c != 200 { + t.Fatalf("delete = %d", c) + } + sheet.Lock() + defer sheet.Unlock() + if len(sheet.Entries) != 0 { + t.Fatalf("left on the server: %+v", sheet.Entries) + } +} + +func TestSessionTimesheet(t *testing.T) { + ts, _ := sheetServer(t) + var s struct { + Timesheet struct { + Name string + Remaining, ServerTimer bool + } + } + workCall(t, "GET", ts.URL+"/api/session", "", &s) + if s.Timesheet.Name != "fake" || s.Timesheet.Remaining || !s.Timesheet.ServerTimer { + t.Fatalf("session timesheet = %+v", s.Timesheet) + } +} + +// TestServerTimer: on a timesheet that runs the timer, /timer is its +// running entry: started, moved and discarded there, mirrored for the TUI. +func TestServerTimer(t *testing.T) { + ts, sheet := sheetServer(t) + start := time.Now().Add(-10 * time.Minute).Truncate(time.Second) + if c := workCall(t, "PUT", ts.URL+"/api/timer", `{"Key":"ABC-1","Start":"`+start.Format(time.RFC3339)+`"}`, nil); c != 200 { + t.Fatalf("start = %d", c) + } + var got workTimer + workCall(t, "GET", ts.URL+"/api/timer", "", &got) + if got.Key != "ABC-1" || !got.Start.Equal(start) || got.ID == "" { + t.Fatalf("timer = %+v", got) + } + // Started elsewhere: the server's switch shows. + sheet.Lock() + run := sheet.Running() + end := start.Add(5 * time.Minute) + run.End = &end + sheet.Entries = append(sheet.Entries, &sheettest.Entry{ID: "99", Key: "ABC-2", Start: end}) + sheet.Unlock() + workCall(t, "GET", ts.URL+"/api/timer", "", &got) + if got.Key != "ABC-2" || got.ID != "99" { + t.Fatalf("after a switch elsewhere: %+v", got) + } + workCall(t, "PUT", ts.URL+"/api/timer", `{"Key":"ABC-3","Start":"`+end.Format(time.RFC3339)+`"}`, nil) + sheet.Lock() + if r := sheet.Running(); r == nil || r.ID != "99" || r.Key != "ABC-3" { + t.Fatalf("moved = %+v", r) + } + sheet.Unlock() + // A clear after logging leaves the entry; a discard deletes it. + workCall(t, "PUT", ts.URL+"/api/timer", `{"Key":""}`, nil) + workCall(t, "PUT", ts.URL+"/api/timer", `{"Key":"","Drop":"ABC-1"}`, nil) + sheet.Lock() + if sheet.Running() == nil { + t.Fatal("a clear or another issue's discard deleted the running entry") + } + sheet.Unlock() + workCall(t, "PUT", ts.URL+"/api/timer", `{"Key":"","Drop":"ABC-3"}`, nil) + sheet.Lock() + defer sheet.Unlock() + if sheet.Running() != nil || len(sheet.Entries) != 1 { + t.Fatalf("after discard: %+v", sheet.Entries) + } +} diff --git a/internal/web/api_work.go b/internal/web/api_work.go index 728aafbd..275a0645 100644 --- a/internal/web/api_work.go +++ b/internal/web/api_work.go @@ -15,6 +15,7 @@ import ( "github.com/cornedor/laneway/internal/i18n" "github.com/cornedor/laneway/internal/jira" + "github.com/cornedor/laneway/internal/timesheet" ) // Personal views: my work, time tracking and the inbox. @@ -83,14 +84,18 @@ func myWorklogs(ctx context.Context, s *Server, r *http.Request) (any, error) { if !to.After(from) || to.Sub(from) > 62*24*time.Hour { return nil, badRequest(i18n.T("to must follow from, within two months")) } - logs, err := s.Client().MyWorklogsBetween(ctx, from, to) + sh := s.Sheet() + logs, err := sh.Entries(ctx, from, to) if logs == nil { - logs = []jira.Worklog{} + logs = []timesheet.Entry{} } - // canMove is false on Server and DC (serverInfo, read here first) and - // once the site turned a move away. - s.Client().Laneway(ctx) - return map[string]any{"worklogs": logs, "canMove": s.Client().MovesWorklogs()}, err + if s.opt.Timesheet == nil { + // canMove is false on Server and DC (serverInfo, read here first) + // and once the site turned a move away. + s.Client().Laneway(ctx) + } + caps := sh.Caps() + return map[string]any{"worklogs": logs, "canMove": caps.Move, "canLeave": caps.Remaining}, err } type worklogBody struct { @@ -131,7 +136,10 @@ func addWorklog(ctx context.Context, s *Server, r *http.Request) (any, error) { if b.Comment != nil { comment = *b.Comment } - return nil, s.Client().AddWorklog(ctx, key, b.Seconds, started, comment, b.Left) + if b.Left != "" && !s.Sheet().Caps().Remaining { + return nil, badRequest(i18n.Tf("left: sets Jira's estimate; %s has none", s.Sheet().Name())) + } + return nil, s.Sheet().Add(ctx, timesheet.New{Key: key, Seconds: b.Seconds, Started: started, Comment: comment, Left: b.Left}) } func updateWorklog(ctx context.Context, s *Server, r *http.Request) (any, error) { @@ -147,7 +155,7 @@ func updateWorklog(ctx context.Context, s *Server, r *http.Request) (any, error) if err != nil { return nil, err } - return nil, s.Client().UpdateWorklog(ctx, key, r.PathValue("id"), b.Seconds, started, b.Comment) + return nil, s.Sheet().Update(ctx, key, r.PathValue("id"), b.Seconds, started, b.Comment) } func deleteWorklog(ctx context.Context, s *Server, r *http.Request) (any, error) { @@ -155,7 +163,7 @@ func deleteWorklog(ctx context.Context, s *Server, r *http.Request) (any, error) if err != nil { return nil, err } - return nil, s.Client().DeleteWorklog(ctx, key, r.PathValue("id")) + return nil, s.Sheet().Delete(ctx, key, []string{r.PathValue("id")}) } // worklogsBody is a bulk delete's or move's worklogs, all on the path's @@ -177,7 +185,7 @@ func deleteWorklogs(ctx context.Context, s *Server, r *http.Request) (any, error if len(b.IDs) == 0 { return nil, badRequest(i18n.T("no worklogs")) } - return nil, s.Client().DeleteWorklogs(ctx, key, b.IDs) + return nil, s.Sheet().Delete(ctx, key, b.IDs) } func moveWorklogs(ctx context.Context, s *Server, r *http.Request) (any, error) { @@ -195,7 +203,7 @@ func moveWorklogs(ctx context.Context, s *Server, r *http.Request) (any, error) case !jira.ValidKey(b.To): return nil, badRequest(i18n.T("bad issue key")) } - return nil, s.Client().MoveWorklogs(ctx, key, b.IDs, b.To) + return nil, s.Sheet().Move(ctx, key, b.IDs, b.To) } // ---- inbox diff --git a/internal/web/server.go b/internal/web/server.go index e9818628..5b429375 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -33,6 +33,8 @@ import ( "github.com/cornedor/laneway/internal/jira" "github.com/cornedor/laneway/internal/rules" "github.com/cornedor/laneway/internal/store" + "github.com/cornedor/laneway/internal/timesheet" + "github.com/cornedor/laneway/internal/timesheet/jirasheet" ) //go:embed static @@ -83,6 +85,8 @@ type Options struct { // GitLabRepos their projects' checkouts by path (gitlab: repos:). GitLab *gitlab.Sites GitLabRepos map[string]string + // Timesheet is where time is logged; nil: the site's Jira worklogs. + Timesheet timesheet.Provider } // Server is the HTTP handler: API under /api, assets everywhere else. @@ -96,6 +100,15 @@ type Server struct { // Client is the Jira client; handlers take it from the request's Server. func (s *Server) Client() *jira.Client { return s.opt.Client } +// Sheet is where time is logged: Options.Timesheet, else the site's +// worklogs. +func (s *Server) Sheet() timesheet.Provider { + if s.opt.Timesheet != nil { + return s.opt.Timesheet + } + return jirasheet.New(s.opt.Client) +} + // Options returns the server's options. func (s *Server) Options() Options { return s.opt } diff --git a/internal/web/static/js/lib/timer.js b/internal/web/static/js/lib/timer.js index 254e9444..86a70456 100644 --- a/internal/web/static/js/lib/timer.js +++ b/internal/web/static/js/lib/timer.js @@ -16,7 +16,7 @@ function load() { function save(t) { try { store.set('timer', t ? JSON.stringify(t) : ''); } catch (e) { /* private mode */ } } -const same = (a, b) => (a && a.key) === (b && b.key) && (a && a.start) === (b && b.start); +const same = (a, b) => (a && a.key) === (b && b.key) && (a && a.start) === (b && b.start) && (a && a.id) === (b && b.id); // The issue `T` and `w` act on: the panel's while it has the focus, else the selected row of the view, else the // open panel, else the route's issue. @@ -30,6 +30,7 @@ export function target(app) { export function install(app) { let timer = load(); + const serverTimer = () => !!(app.session && app.session.timesheet && app.session.timesheet.serverTimer); const chip = app.chrome.add(h('button.timer-chip.accent', { hidden: true, onclick: () => toggle('') }), 30); let tick = 0; @@ -54,10 +55,12 @@ export function install(app) { timer = t; save(t); paint(); app.bus.emit('timer', timer); } let gen = 0; // a sync that started before a local change must not undo it - function set(t) { + // set(null, key) discards the timer on key: a timesheet that runs the timer deletes its running entry. + function set(t, drop) { gen++; show(t); - app.api.put('/timer', t ? { Key: t.key, Start: new Date(t.start).toISOString() } : { Key: '' }) + app.api.put('/timer', t ? { Key: t.key, Start: new Date(t.start).toISOString() } : { Key: '', Drop: drop || '' }) + .then(() => { if (serverTimer()) sync(); }) .catch(e => app.ui.toast(T('Timer kept in this browser only: %s', e.message), { kind: 'err' })); } // What the TUI (or another window) did: read on start, on coming back to the tab and twice a minute. @@ -69,7 +72,7 @@ export function install(app) { // A timer from before the state file kept it: hand it over once. if (!(t && t.Key) && timer && !store.get('timer.shared')) { store.set('timer.shared', '1'); return set(timer); } store.set('timer.shared', '1'); - show(t && t.Key ? { key: t.Key, start: Date.parse(t.Start) } : null); + show(t && t.Key ? { key: t.Key, start: Date.parse(t.Start), id: t.ID || undefined } : null); } catch (e) { /* offline: the mirror stands */ } } document.addEventListener('visibilitychange', () => { if (!document.hidden) sync(); }); @@ -87,15 +90,18 @@ export function install(app) { } // Stop into the log dialog. The timer keeps running until the work is in; `next` starts afterwards. + // A timesheet that runs the timer stops its running entry with the logged time: the dialog edits it. async function stop(next) { + if (!timer) return; + if (serverTimer() && !timer.id) await sync(); if (!timer) return; const t = timer; - const res = await logDialog(app, { key: t.key, seconds: round(Date.now() - t.start), started: new Date(t.start), discard: true, move: next, note: next && T('Then the timer starts on %s', next) }); + const res = await logDialog(app, { key: t.key, seconds: round(Date.now() - t.start), started: new Date(t.start), edit: t.id, discard: true, move: next, note: next && T('Then the timer starts on %s', next) }); if (res === 'move') return move(next); if (res === 'discard') { if (Date.now() - t.start >= MIN5 && !(await app.ui.confirm({ title: T('Discard timer'), text: T('%s on %s will not be logged.', duration(Math.floor((Date.now() - t.start) / 1000)), t.key), ok: T('Discard'), danger: true }))) return; } else if (res !== 'logged') return; - if (timer && timer.start === t.start) set(null); + if (timer && timer.start === t.start) set(null, res === 'discard' ? t.key : ''); if (next) start(next); } diff --git a/internal/web/static/js/lib/worktime.js b/internal/web/static/js/lib/worktime.js index 4fc1a0ef..950c6940 100644 --- a/internal/web/static/js/lib/worktime.js +++ b/internal/web/static/js/lib/worktime.js @@ -88,7 +88,7 @@ export function logDialog(app, o) { const form = h('form.wl-form', { onsubmit: e => { e.preventDefault(); submit(); } }, o.note && h('div.dim.wl-note', o.note), row(T('Time'), time), row(T('Date'), date), row(T('Comment'), comment), - !o.edit && row(T('Remaining'), left, leftVal), err, + !o.edit && !(app.session && app.session.timesheet && app.session.timesheet.remaining === false) && row(T('Remaining'), left, leftVal), err, h('div.row.end', o.discard && h('button.btn.ghost', { type: 'button', onclick: () => { result = 'discard'; m.close(); } }, T('Discard timer')), o.move && h('button.btn.ghost', { type: 'button', title: T('Its time goes along; nothing is logged'), onclick: () => { result = 'move'; m.close(); } }, T('Move timer to %s', o.move)), h('span.spacer'), h('button.btn', { type: 'button', onclick: () => m.close() }, T('Cancel'), keyHint('Escape')), save)); diff --git a/internal/work/work.go b/internal/work/work.go index 0ea4b965..808bc4c8 100644 --- a/internal/work/work.go +++ b/internal/work/work.go @@ -21,6 +21,7 @@ import ( "time" "github.com/cornedor/laneway/internal/jira" + "github.com/cornedor/laneway/internal/timesheet" ) // NoTicket names the keyless commits' group. @@ -187,7 +188,7 @@ func Propose(events []Event, logged map[string]int) []Proposal { // Day is day's proposals: git in repos and the activity commands' sessions, // less what logs has, and extra (a calendar's meetings), by start; failed // are the activity commands that failed. -func Day(repos, activity []string, day time.Time, logs []jira.Worklog, extra []Proposal) (ps []Proposal, failed []string) { +func Day(repos, activity []string, day time.Time, logs []timesheet.Entry, extra []Proposal) (ps []Proposal, failed []string) { logged := map[string]int{} for _, w := range logs { logged[w.Key] += w.Seconds diff --git a/main.go b/main.go index eb7560d8..003ddcf9 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "runtime/debug" + "slices" "strconv" "strings" "time" @@ -211,7 +212,8 @@ func runSite(cfg config.Config, cfgPath, site string) (string, int, error) { defer cancel() rulesLog := filepath.Join(filepath.Dir(path), "rules.log") gl, glWarn := gitlabSites(cfg) - m := ui.New(ctx, jc, cfg.UI, cfg.Rules, rulesLog, st).WithSites(cfg.SiteNames(), site, cfg.Jira.Name).WithConfigPath(cfgPath).WithWarnings(append(cfg.Warnings, glWarn...)).WithIndex(ix).WithVersion(version, upgradeCmd()).WithGitLab(gl).WithGitLabRepos(gitlabRepos(cfg)). + sheet, sheetWarn := timesheetProvider(cfg) + m := ui.New(ctx, jc, cfg.UI, cfg.Rules, rulesLog, st).WithSites(cfg.SiteNames(), site, cfg.Jira.Name).WithConfigPath(cfgPath).WithWarnings(slices.Concat(cfg.Warnings, glWarn, sheetWarn)).WithTimesheet(sheet).WithIndex(ix).WithVersion(version, upgradeCmd()).WithGitLab(gl).WithGitLabRepos(gitlabRepos(cfg)). WithSiteClients(func(other string) (*jira.Client, error) { j, err := cfg.Site(other) if err != nil || j.Check(siteName(other)) != nil { diff --git a/mkdocs.yml b/mkdocs.yml index 19f2f59c..275b8652 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -58,5 +58,6 @@ nav: - reference/editor.md - reference/rules.md - reference/cli.md + - reference/timesheet-api.md - extension-privacy.md - Privacy: privacy.md diff --git a/timesheet.go b/timesheet.go new file mode 100644 index 00000000..634d64da --- /dev/null +++ b/timesheet.go @@ -0,0 +1,25 @@ +package main + +import ( + "github.com/cornedor/laneway/internal/config" + "github.com/cornedor/laneway/internal/timesheet" + "github.com/cornedor/laneway/internal/timesheet/httpsheet" +) + +// timesheetProvider is the timesheet: section's http provider, nil for +// Jira's worklogs on the site. A section laneway can't use warns and +// leaves time on Jira. +func timesheetProvider(cfg config.Config) (timesheet.Provider, []string) { + isHTTP, err := cfg.Timesheet.IsHTTP() + if err != nil { + return nil, []string{err.Error() + "; time goes to Jira's worklogs"} + } + if !isHTTP { + return nil, nil + } + t, err := cfg.Timesheet.WithToken() + if err != nil { + return nil, []string{err.Error() + "; time goes to Jira's worklogs"} + } + return httpsheet.New(t.URL, t.Token), nil +} diff --git a/webcmd.go b/webcmd.go index 03fb4e18..00cc01b0 100644 --- a/webcmd.go +++ b/webcmd.go @@ -440,6 +440,7 @@ func webSite(cfg config.Config, site string, opt web.Options) (web.Options, erro opt.Client = webClient(jc, cfg.UI) opt.GitLab, _ = gitlabSites(cfg) opt.GitLabRepos = gitlabRepos(cfg) + opt.Timesheet, _ = timesheetProvider(cfg) opt.Client.SetQueue(offline.To(opt.Store)) opt.Client.SetLayouts(opt.Store) return opt, nil