Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
297 changes: 190 additions & 107 deletions commands/awol.go

Large diffs are not rendered by default.

631 changes: 431 additions & 200 deletions commands/awol_test.go

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions commands/loa.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import (
// *utils.LOACache (GlobalLOACache); tests substitute a fake with canned
// entries and a forced health verdict so the handler stays deterministic
// without touching the process-global singleton. /awol's loaCacheReader is a
// separate, per-command minimal surface over the same cache with the same
// {GetEntry, IsHealthy} shape today; the two are kept distinct so each command's
// dependency stays scoped to what it actually reads (and so they can diverge —
// see #159, which adds GetEntries to loaCacheReader). /awol additionally gates on
// separate, per-command minimal surface over the same cache; the two share
// IsHealthy but read differently — /loa uses GetEntry (one most-relevant window)
// while /awol uses GetEntries (the full history, #159). They are kept distinct so
// each command's dependency stays scoped to what it actually reads. /awol gates on
// IsHealthy to render its On-LOA column (#96); /loa uses it for the staleness
// guard above.
type loaCacheView interface {
Expand Down
146 changes: 146 additions & 0 deletions utils/awol_accountable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package utils

import "time"

// awolRequirementDays is the SOP forum-activity requirement: a trooper must post
// at least once every 7 days. Accountable dates beyond this are AWOL overage.
const awolRequirementDays = 7

// utcDate truncates an instant to its UTC calendar date (midnight UTC). UTC is
// 7Cav standard time, so "a day" is a UTC calendar date throughout the AWOL calc.
func utcDate(t time.Time) time.Time {
u := t.UTC()
return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC)
}

// rawAccountableDates is the count of candidate UTC dates in (lastPost, now] with
// no LOA subtraction: the whole-date inactivity span. This is the figure the
// degraded path flags on when the LOA store can't be trusted (ADR 0008).
func rawAccountableDates(lastPost, now time.Time) int {
start := utcDate(lastPost)
today := utcDate(now)
if !today.After(start) {
return 0
}
// Days strictly after the post date through today inclusive: (today - start).
return int(today.Sub(start).Hours() / 24)
}

// coveredByLOA reports whether a UTC date is covered by any LOA window. An LOA
// covers the inclusive range [StartDate, EndDate]; a zero-width range
// (StartDate == EndDate) is valid and covers exactly that one day. Only an
// end-before-start range covers nothing — those are filtered upstream by
// validLOAWindows so they're DEBUG-logged once, not silently per-date.
func coveredByLOA(date time.Time, windows []LOAEntry) bool {
for _, w := range windows {
s := utcDate(w.StartDate)
e := utcDate(w.EndDate)
if !date.Before(s) && !date.After(e) {
return true
}
}
return false
}

// validLOAWindows returns the windows whose UTC date range is well-formed: both
// bounds set and EndDate not before StartDate. Backwards ranges and zero-date
// entries are dropped and DEBUG-logged so one malformed forum post can't crash or
// skew the count (ADR 0008). A zero-value entry is unreachable from production
// (parseLOAPost only emits when both dates parse) but filtering it keeps the calc
// from ever "covering" the epoch-zero UTC date.
func validLOAWindows(windows []LOAEntry) []LOAEntry {
out := make([]LOAEntry, 0, len(windows))
for _, w := range windows {
if w.StartDate.IsZero() || w.EndDate.IsZero() {
Debug("LOA window ignored (zero-value date)",
"username", w.Username,
"thread_id", w.ThreadID,
"start", w.StartDate,
"end", w.EndDate,
)
continue
}
if utcDate(w.EndDate).Before(utcDate(w.StartDate)) {
Debug("LOA window ignored (end before start)",
"username", w.Username,
"thread_id", w.ThreadID,
"start", w.StartDate,
"end", w.EndDate,
)
continue
}
out = append(out, w)
}
return out
}

// accountableDates counts candidate UTC dates in (lastPost, now] that are NOT
// covered by any (valid) LOA window. The candidate set excludes the post day and
// includes today; coverage is the union of inclusive LOA date ranges, so
// overlapping / adjacent / multiple / future LOAs need no special handling.
func accountableDates(lastPost, now time.Time, windows []LOAEntry) int {
start := utcDate(lastPost)
today := utcDate(now)
valid := validLOAWindows(windows)
count := 0
for date := start.AddDate(0, 0, 1); !date.After(today); date = date.AddDate(0, 0, 1) {
if !coveredByLOA(date, valid) {
count++
}
}
return count
}

// AccountableDaysAWOL is the pure accountable-day calc (ADR 0008): given a
// trooper's last forum post, the current instant, and their LOA windows, it
// returns days AWOL = accountable dates − 7 (the overage past the 7-day forum
// requirement), clamped at zero. Clock-injected and DB-free so every edge case is
// table-testable. Backwards LOA ranges are ignored and DEBUG-logged.
func AccountableDaysAWOL(lastPost, now time.Time, windows []LOAEntry) int {
return overage(accountableDates(lastPost, now, windows))
}

// RawDaysAWOL is the degraded-mode figure: whole-date inactivity with NO LOA
// subtraction, overage past the 7-day requirement, clamped at zero. Used when the
// LOA store is unhealthy so the report degrades loudly rather than silently
// treating everyone as non-LOA (ADR 0008).
func RawDaysAWOL(lastPost, now time.Time) int {
return overage(rawAccountableDates(lastPost, now))
}

// DaysSinceLastPost is the raw whole-date inactivity span (candidate UTC dates in
// (lastPost, now], no LOA subtraction, no 7-day clamp). It is the "last post Nd"
// secondary context shown on every row — the unadjusted figure, so staff still
// know when the trooper actually last posted regardless of LOA coverage.
func DaysSinceLastPost(lastPost, now time.Time) int {
return rawAccountableDates(lastPost, now)
}

func overage(accountable int) int {
if accountable <= awolRequirementDays {
return 0
}
return accountable - awolRequirementDays
}

// ActiveWindow returns the LOA window active at `now` (clock-injected by the
// caller, NOT an internal time.Now()), preferring the latest-ending if several
// overlap. The bool is false when no window is active. /awol uses this so the
// On-LOA verdict, the days-AWOL calc, and the [[LOA]] thread link are all decided
// against the SAME `now` — no boundary disagreement between selection and verdict
// (PR #161 clock-skew item). Clock-free GetEntries supplies the windows.
func ActiveWindow(windows []LOAEntry, now time.Time) (LOAEntry, bool) {
var (
best LOAEntry
found bool
)
for _, w := range windows {
if !w.isActiveAt(now) {
continue
}
if !found || w.EndDate.After(best.EndDate) {
best, found = w, true
}
}
return best, found
}
Loading