// Package autotrack ports the Python passive activity daemon: poll the // frontmost app / window title / browser tab / idle state, coalesce // consecutive same-context samples into segments, and append them to a daily // staging log (~/.local/share/ticktock/activity-YYYY-MM-DD.jsonl). It writes // NOTHING to tock — suggest-then-confirm. package autotrack import ( "net/url" "strings" "time" ) // Ctx is one classified foreground context. JSON tags match the Python // record keys (used by --once output and the winctx helper). type Ctx struct { App string `json:"app"` Title string `json:"title"` URL string `json:"url"` Domain string `json:"domain"` Idle bool `json:"idle"` } // Segment is an open (End zero) or closed span of one activity. type Segment struct { Ctx Start, End time.Time } // awayApps: frontmost one of these ⇒ locked/screensaver ⇒ away. var awayApps = map[string]bool{"loginwindow": true, "ScreenSaverEngine": true} // Away reports whether the raw context is the lock/screensaver window. func Away(raw *Ctx) bool { return raw != nil && awayApps[raw.App] } // Domain extracts the URL's hostname, lowercased, with "www." stripped — // parity with Python's urlparse(url).hostname.replace("www.", ""). func Domain(rawURL string) string { if rawURL == "" { return "" } u, err := url.Parse(rawURL) if err != nil { return "" } return strings.ReplaceAll(strings.ToLower(u.Hostname()), "www.", "") } // Classify maps (idle seconds, foreground context) to the segment context to // record. Locked or an away-app ⇒ idle immediately; idle >= idleGrace ⇒ idle; // otherwise the active context passes through (present-but-passive, e.g. // watching an agent run or being on a call, counts as active). nil when there // is nothing to record. func Classify(idle int, raw *Ctx, idleGrace int, locked bool) *Ctx { if locked || Away(raw) { return &Ctx{Idle: true} } if idle >= idleGrace { return &Ctx{Idle: true} } return raw } // Key identifies "the same activity". Idle is its own key; active is // (app, domain-or-title). The zero Key means no context. Comparable, so the // segmentation loop can use != directly. type Key struct { Valid bool Idle bool App string Rest string // domain, or title for non-browsers } // CtxKey computes the identity key for a classified context. func CtxKey(c *Ctx) Key { if c == nil { return Key{} } if c.Idle { return Key{Valid: true, Idle: true} } rest := c.Domain if rest == "" { rest = c.Title } return Key{Valid: true, App: c.App, Rest: rest} } // Step is the pure segmentation state machine. Given the open segment // (cur/curKey) and a fresh sample (now, idle seconds, classified ctx), it // returns (cur, curKey, emitted) where emitted is a finished segment to // persist, or nil. // // A context change closes the open segment and opens a new one; the same // context just extends the open segment's end. // // Crossing into idle anchors the transition boundary: // - hard idle (screen locked / away app): now - idle — the whole gap is // away, so the active tail is trimmed to the last real input. // - soft idle (HID gap alone): now - (idle - grace) — the grace window is // credited to the active app, so present-but-passive work keeps its time. // // The boundary is clamped to [cur.Start, now] so a segment never ends before // it began nor in the future. func Step(cur *Segment, curKey Key, now time.Time, idle int, ctx *Ctx, grace int, hard bool) (*Segment, Key, *Segment) { key := CtxKey(ctx) boundary := now if ctx != nil && ctx.Idle { offset := idle if !hard { offset = idle - grace if offset < 0 { offset = 0 } } boundary = now.Add(-time.Duration(offset) * time.Second) } var emitted *Segment if key != curKey { if cur != nil { if boundary.After(now) { boundary = now } if boundary.Before(cur.Start) { boundary = cur.Start } closed := *cur closed.End = boundary emitted = &closed } if ctx != nil { cur = &Segment{Ctx: *ctx, Start: boundary} } else { cur = nil } curKey = key } else if cur != nil { next := *cur next.End = now cur = &next } return cur, curKey, emitted }