● Zion Boggan
repos/Agent Time Ledger/internal/ledger/ledger.go
zionboggan.com ↗
390 lines · go
History for this file →
1
package ledger
2
 
3
import (
4
	"fmt"
5
	"os"
6
	"time"
7
 
8
	"github.com/zionboggan/agent-time-ledger/internal/clock"
9
	"github.com/zionboggan/agent-time-ledger/internal/state"
10
)
11
 
12
type Service struct {
13
	Store           *state.Store
14
	Now             func() time.Time
15
	DefaultTimezone string
16
}
17
 
18
type NowResponse struct {
19
	Timestamp    string `json:"timestamp"`
20
	UTCTimestamp string `json:"utc_timestamp"`
21
	Unix         int64  `json:"unix"`
22
	Timezone     string `json:"timezone"`
23
	UTCOffset    string `json:"utc_offset"`
24
	Confidence   string `json:"confidence"`
25
}
26
 
27
type SessionStatusResponse struct {
28
	Active         bool    `json:"active"`
29
	ID             string  `json:"id,omitempty"`
30
	Name           string  `json:"name,omitempty"`
31
	StartedAt      string  `json:"started_at,omitempty"`
32
	EndedAt        string  `json:"ended_at,omitempty"`
33
	ElapsedSeconds float64 `json:"elapsed_seconds,omitempty"`
34
	ElapsedHuman   string  `json:"elapsed_human,omitempty"`
35
	Confidence     string  `json:"confidence"`
36
}
37
 
38
type MarkElapsedResponse struct {
39
	Name           string  `json:"name"`
40
	StartedAt      string  `json:"started_at"`
41
	Now            string  `json:"now"`
42
	ElapsedSeconds float64 `json:"elapsed_seconds"`
43
	ElapsedHuman   string  `json:"elapsed_human"`
44
	Confidence     string  `json:"confidence"`
45
}
46
 
47
type MarkSummary struct {
48
	Name           string  `json:"name"`
49
	StartedAt      string  `json:"started_at"`
50
	ElapsedSeconds float64 `json:"elapsed_seconds"`
51
	ElapsedHuman   string  `json:"elapsed_human"`
52
	Confidence     string  `json:"confidence"`
53
}
54
 
55
type StaleResponse struct {
56
	CheckedAt      string  `json:"checked_at"`
57
	ReferenceTime  string  `json:"reference_time"`
58
	TTL            string  `json:"ttl"`
59
	ElapsedSeconds float64 `json:"elapsed_seconds"`
60
	ElapsedHuman   string  `json:"elapsed_human"`
61
	Stale          bool    `json:"stale"`
62
	Confidence     string  `json:"confidence"`
63
}
64
 
65
type ReportResponse struct {
66
	Date             string         `json:"date"`
67
	GeneratedAt      string         `json:"generated_at"`
68
	ActiveSession    bool           `json:"active_session"`
69
	ActiveSessionID  string         `json:"active_session_id,omitempty"`
70
	SessionsStarted  int            `json:"sessions_started"`
71
	SessionsEnded    int            `json:"sessions_ended"`
72
	OpenMarks        int            `json:"open_marks"`
73
	EventsByType     map[string]int `json:"events_by_type"`
74
	TotalEventsToday int            `json:"total_events_today"`
75
	Confidence       string         `json:"confidence"`
76
}
77
 
78
func NewService(store *state.Store) *Service {
79
	return &Service{Store: store, Now: clock.Now, DefaultTimezone: os.Getenv("ATL_TIMEZONE")}
80
}
81
 
82
func (s *Service) NowResponse() (NowResponse, error) {
83
	return s.NowResponseIn("")
84
}
85
 
86
func (s *Service) NowResponseIn(timezone string) (NowResponse, error) {
87
	if timezone == "" {
88
		timezone = s.DefaultTimezone
89
	}
90
	location, err := clock.LoadLocation(timezone)
91
	if err != nil {
92
		return NowResponse{}, err
93
	}
94
	now := s.now()
95
	localNow := now.In(location)
96
	return NowResponse{
97
		Timestamp:    clock.FormatRFC3339In(now, location),
98
		UTCTimestamp: clock.FormatRFC3339(now),
99
		Unix:         now.Unix(),
100
		Timezone:     clock.LocationName(location),
101
		UTCOffset:    clock.UTCOffset(localNow),
102
		Confidence:   clock.ConfidenceHostClock,
103
	}, nil
104
}
105
 
106
func (s *Service) StartSession(name string) (SessionStatusResponse, error) {
107
	if name == "" {
108
		return SessionStatusResponse{}, fmt.Errorf("session name is required")
109
	}
110
	st, err := s.Store.Load()
111
	if err != nil {
112
		return SessionStatusResponse{}, err
113
	}
114
	if st.ActiveSessionID != "" {
115
		return SessionStatusResponse{}, fmt.Errorf("session already active: %s", st.ActiveSessionID)
116
	}
117
	now := s.now()
118
	id := "sess_" + now.Format("20060102T150405.000000000Z")
119
	session := state.Session{ID: id, Name: name, StartedAt: now}
120
	st.ActiveSessionID = id
121
	st.Sessions[id] = session
122
	if err := s.Store.Save(st); err != nil {
123
		return SessionStatusResponse{}, err
124
	}
125
	if err := s.Store.AppendEvent(state.Event{
126
		Timestamp: now,
127
		Type:      "session_start",
128
		Fields:    map[string]any{"session_id": id, "name": name},
129
	}); err != nil {
130
		return SessionStatusResponse{}, err
131
	}
132
	return s.sessionResponse(session, now), nil
133
}
134
 
135
func (s *Service) SessionStatus() (SessionStatusResponse, error) {
136
	st, err := s.Store.Load()
137
	if err != nil {
138
		return SessionStatusResponse{}, err
139
	}
140
	if st.ActiveSessionID == "" {
141
		return SessionStatusResponse{Active: false, Confidence: clock.ConfidenceWallFallback}, nil
142
	}
143
	session, ok := st.Sessions[st.ActiveSessionID]
144
	if !ok {
145
		return SessionStatusResponse{}, fmt.Errorf("active session %q not found in state", st.ActiveSessionID)
146
	}
147
	return s.sessionResponse(session, s.now()), nil
148
}
149
 
150
func (s *Service) EndSession() (SessionStatusResponse, error) {
151
	st, err := s.Store.Load()
152
	if err != nil {
153
		return SessionStatusResponse{}, err
154
	}
155
	if st.ActiveSessionID == "" {
156
		return SessionStatusResponse{}, fmt.Errorf("no active session")
157
	}
158
	session, ok := st.Sessions[st.ActiveSessionID]
159
	if !ok {
160
		return SessionStatusResponse{}, fmt.Errorf("active session %q not found in state", st.ActiveSessionID)
161
	}
162
	now := s.now()
163
	session.EndedAt = &now
164
	st.Sessions[session.ID] = session
165
	st.ActiveSessionID = ""
166
	if err := s.Store.Save(st); err != nil {
167
		return SessionStatusResponse{}, err
168
	}
169
	if err := s.Store.AppendEvent(state.Event{
170
		Timestamp: now,
171
		Type:      "session_end",
172
		Fields:    map[string]any{"session_id": session.ID, "name": session.Name},
173
	}); err != nil {
174
		return SessionStatusResponse{}, err
175
	}
176
	return s.sessionResponse(session, now), nil
177
}
178
 
179
func (s *Service) StartMark(name string) (MarkElapsedResponse, error) {
180
	if name == "" {
181
		return MarkElapsedResponse{}, fmt.Errorf("mark name is required")
182
	}
183
	st, err := s.Store.Load()
184
	if err != nil {
185
		return MarkElapsedResponse{}, err
186
	}
187
	now := s.now()
188
	mark := state.Mark{Name: name, StartedAt: now}
189
	st.Marks[name] = mark
190
	if err := s.Store.Save(st); err != nil {
191
		return MarkElapsedResponse{}, err
192
	}
193
	if err := s.Store.AppendEvent(state.Event{
194
		Timestamp: now,
195
		Type:      "mark_start",
196
		Fields:    map[string]any{"name": name},
197
	}); err != nil {
198
		return MarkElapsedResponse{}, err
199
	}
200
	return s.markResponse(mark, now), nil
201
}
202
 
203
func (s *Service) MarkElapsed(name string) (MarkElapsedResponse, error) {
204
	st, err := s.Store.Load()
205
	if err != nil {
206
		return MarkElapsedResponse{}, err
207
	}
208
	mark, ok := st.Marks[name]
209
	if !ok {
210
		return MarkElapsedResponse{}, fmt.Errorf("mark %q not found", name)
211
	}
212
	return s.markResponse(mark, s.now()), nil
213
}
214
 
215
func (s *Service) ListMarks() ([]MarkSummary, error) {
216
	st, err := s.Store.Load()
217
	if err != nil {
218
		return nil, err
219
	}
220
	now := s.now()
221
	marks := make([]MarkSummary, 0, len(st.Marks))
222
	for _, mark := range st.Marks {
223
		elapsed := now.Sub(mark.StartedAt)
224
		marks = append(marks, MarkSummary{
225
			Name:           mark.Name,
226
			StartedAt:      clock.FormatRFC3339(mark.StartedAt),
227
			ElapsedSeconds: elapsed.Seconds(),
228
			ElapsedHuman:   clock.FormatDuration(elapsed),
229
			Confidence:     clock.ConfidenceWallFallback,
230
		})
231
	}
232
	return marks, nil
233
}
234
 
235
func (s *Service) DeleteMark(name string) error {
236
	st, err := s.Store.Load()
237
	if err != nil {
238
		return err
239
	}
240
	if _, ok := st.Marks[name]; !ok {
241
		return fmt.Errorf("mark %q not found", name)
242
	}
243
	delete(st.Marks, name)
244
	now := s.now()
245
	if err := s.Store.Save(st); err != nil {
246
		return err
247
	}
248
	return s.Store.AppendEvent(state.Event{
249
		Timestamp: now,
250
		Type:      "mark_delete",
251
		Fields:    map[string]any{"name": name},
252
	})
253
}
254
 
255
func (s *Service) StaleFromTimestamp(reference time.Time, ttl time.Duration) (StaleResponse, error) {
256
	return s.stale(reference.UTC(), ttl, map[string]any{"source": "timestamp"})
257
}
258
 
259
func (s *Service) StaleFromMark(name string, ttl time.Duration) (StaleResponse, error) {
260
	st, err := s.Store.Load()
261
	if err != nil {
262
		return StaleResponse{}, err
263
	}
264
	mark, ok := st.Marks[name]
265
	if !ok {
266
		return StaleResponse{}, fmt.Errorf("mark %q not found", name)
267
	}
268
	return s.stale(mark.StartedAt, ttl, map[string]any{"source": "mark", "name": name})
269
}
270
 
271
func (s *Service) LedgerEvent(note string) error {
272
	now := s.now()
273
	return s.Store.AppendEvent(state.Event{
274
		Timestamp: now,
275
		Type:      "manual_note",
276
		Fields:    map[string]any{"note": note},
277
	})
278
}
279
 
280
func (s *Service) ReportToday() (ReportResponse, error) {
281
	st, err := s.Store.Load()
282
	if err != nil {
283
		return ReportResponse{}, err
284
	}
285
	events, err := s.Store.ReadEvents()
286
	if err != nil {
287
		return ReportResponse{}, err
288
	}
289
 
290
	now := s.now()
291
	start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
292
	end := start.Add(24 * time.Hour)
293
	report := ReportResponse{
294
		Date:            start.Format("2006-01-02"),
295
		GeneratedAt:     clock.FormatRFC3339(now),
296
		ActiveSession:   st.ActiveSessionID != "",
297
		ActiveSessionID: st.ActiveSessionID,
298
		OpenMarks:       len(st.Marks),
299
		EventsByType:    map[string]int{},
300
		Confidence:      clock.ConfidenceWallFallback,
301
	}
302
	for _, event := range events {
303
		if event.Timestamp.Before(start) || !event.Timestamp.Before(end) {
304
			continue
305
		}
306
		report.EventsByType[event.Type]++
307
		report.TotalEventsToday++
308
		switch event.Type {
309
		case "session_start":
310
			report.SessionsStarted++
311
		case "session_end":
312
			report.SessionsEnded++
313
		}
314
	}
315
	return report, nil
316
}
317
 
318
func (s *Service) stale(reference time.Time, ttl time.Duration, fields map[string]any) (StaleResponse, error) {
319
	if ttl <= 0 {
320
		return StaleResponse{}, fmt.Errorf("ttl must be greater than zero")
321
	}
322
	now := s.now()
323
	elapsed := now.Sub(reference)
324
	response := StaleResponse{
325
		CheckedAt:      clock.FormatRFC3339(now),
326
		ReferenceTime:  clock.FormatRFC3339(reference),
327
		TTL:            ttl.String(),
328
		ElapsedSeconds: elapsed.Seconds(),
329
		ElapsedHuman:   clock.FormatDuration(elapsed),
330
		Stale:          elapsed > ttl,
331
		Confidence:     clock.ConfidenceWallFallback,
332
	}
333
	eventFields := map[string]any{
334
		"reference_time":  response.ReferenceTime,
335
		"ttl":             response.TTL,
336
		"elapsed_seconds": response.ElapsedSeconds,
337
		"stale":           response.Stale,
338
	}
339
	for key, value := range fields {
340
		eventFields[key] = value
341
	}
342
	if err := s.Store.AppendEvent(state.Event{
343
		Timestamp: now,
344
		Type:      "stale_check",
345
		Fields:    eventFields,
346
	}); err != nil {
347
		return StaleResponse{}, err
348
	}
349
	return response, nil
350
}
351
 
352
func (s *Service) sessionResponse(session state.Session, now time.Time) SessionStatusResponse {
353
	end := now
354
	endedAt := ""
355
	active := session.EndedAt == nil
356
	if session.EndedAt != nil {
357
		end = *session.EndedAt
358
		endedAt = clock.FormatRFC3339(*session.EndedAt)
359
	}
360
	elapsed := end.Sub(session.StartedAt)
361
	return SessionStatusResponse{
362
		Active:         active,
363
		ID:             session.ID,
364
		Name:           session.Name,
365
		StartedAt:      clock.FormatRFC3339(session.StartedAt),
366
		EndedAt:        endedAt,
367
		ElapsedSeconds: elapsed.Seconds(),
368
		ElapsedHuman:   clock.FormatDuration(elapsed),
369
		Confidence:     clock.ConfidenceWallFallback,
370
	}
371
}
372
 
373
func (s *Service) markResponse(mark state.Mark, now time.Time) MarkElapsedResponse {
374
	elapsed := now.Sub(mark.StartedAt)
375
	return MarkElapsedResponse{
376
		Name:           mark.Name,
377
		StartedAt:      clock.FormatRFC3339(mark.StartedAt),
378
		Now:            clock.FormatRFC3339(now),
379
		ElapsedSeconds: elapsed.Seconds(),
380
		ElapsedHuman:   clock.FormatDuration(elapsed),
381
		Confidence:     clock.ConfidenceWallFallback,
382
	}
383
}
384
 
385
func (s *Service) now() time.Time {
386
	if s.Now != nil {
387
		return s.Now().UTC()
388
	}
389
	return clock.Now()
390
}