Files
krow_backend/go-api/internal/seeder/activity.go
Suriyakumarvijayanayagam c378bc00ed
Some checks failed
CI / test (push) Has been cancelled
CI / fixture (push) Has been cancelled
Rebase seeded time-series data to now, so the demo stops going quiet
ShiftRecord is generated against now (shifts.go); everything else stayed on the
fixed calendar in seed.js while the calendar moved on. Twenty-three days after
that file was written the activity agent truthfully reported zero events in the
last seven days, and applications were sixteen days stale. Nothing was broken —
the data had simply aged out of every window the product reports over, and it
gets worse every day nobody reseeds.

RebaseToNow moves a set of records so the newest sits at now, keeping every gap
exactly as authored. The SHAPE is what every reader of this data is looking at:
three hires on one day, a screening the day after, a quiet fortnight before it.
Shifting the whole set by one delta preserves all of it. Scaling into a window
or scattering events across recent days would invent a rhythm nobody wrote.

Applied per entity — UserActivity, JobApplication, AIInterview — because each
anchors on its own newest record. One shared anchor would drag the quieter
entities by another entity's delta and invent relationships between them.
Reference data is untouched: a course's date is a fact about the course, not a
position in a window.

Rebasing rather than generating, so seed.json stays the single authored source,
still deterministic and still comparable byte-for-byte by the drift check. The
alternative — excluding these from the fixture the way ShiftRecord is — means a
second generator to keep in step with the frontend's copy.

The existing TestSeedPreservesSourceValues caught a real bug in the first
attempt, and its comment is why: "Applications carry updated_date in the
source, and the gap from created_date is what buildHires reads as
time-to-hire." I had shifted created_date alone, which turned five-day hires
into three-week ones. EVERY timestamp on a record now moves by the same delta,
and there is a test on that specifically.

That test now asserts the GAP rather than the absolute dates, because for a
rebased entity the dates are deliberately different — which is the one reason
it is supposed to allow. Its real subject was always the interval.

Verified against the database: before, activity was 23 days old with 0 events
in the last 7; after, all three entities are current, with 24 applications
spread across the last 30 days and 15 activity events inside 30.

Not fixed here: the Hiring activity chart on Control Center still renders
empty, and it was equally empty before this change. Its bucketing is correct —
replaying it in the browser against live data matched all 24 applications into
the right days — so the fault is further down in that component, not in the
data. Naming it rather than leaving it implied by a chart that still looks
wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-08-29 15:13:33 +05:30

123 lines
4.6 KiB
Go

package seeder
import (
"strings"
"time"
)
// RebaseToNow moves a fixture's records so the most recent one sits at `now`,
// keeping every gap between them exactly as authored.
//
// The problem this solves is that the demo goes quiet. ShiftRecord is generated
// against now (see shifts.go); everything else stayed on the fixed calendar in
// seed.js while the calendar moved on. Twenty-three days after that file was
// written the activity agent truthfully reported zero events in the last seven
// days, and the hiring chart on Control Center drew one bar in a thirty-day
// window. Nothing was broken; the data had simply aged out of every window the
// product asks about.
//
// Applied per entity rather than globally, because each is anchored on its own
// newest record. Rebasing them together against one shared anchor would drag
// the quieter entities forward or back by another entity's delta and invent
// relationships between them that nobody authored.
//
// Rebasing rather than generating, deliberately. ShiftRecord is excluded from
// seed.json because it is built fresh each run; doing the same here would mean
// a second generator to keep in step with the frontend's copy, and a fixture
// that no longer describes what a seeded workspace contains. Rebasing keeps
// seed.json the single authored source — still deterministic, still comparable
// byte-for-byte by the drift check — and moves the window instead of the data.
//
// The SHAPE is what matters to every reader of this data: three hires on one
// day, a screening the day after, a quiet fortnight before it. Shifting the
// whole set by one delta preserves all of that. Scaling it into a window, or
// scattering events across recent days, would invent a rhythm nobody authored.
//
// EVERY timestamp on a record moves by the same delta, not just the anchor.
// The gaps WITHIN a record are as load-bearing as the gaps between them: an
// application's created_date and updated_date are what buildHires subtracts to
// get time-to-hire, so shifting one and not the other invents a hire that took
// three weeks or minus one. The seeder's own TestSeedPreservesSourceValues
// caught exactly that, which is why it says what it says about the gap.
//
// Idempotent: the delta is recomputed from the fixture every run, so seeding
// twice rebases the same authored dates twice rather than compounding.
// Returns the records unchanged when there are none, or when none of them
// carry a usable anchor.
func RebaseToNow(records []map[string]any, field string, now time.Time) []map[string]any {
if len(records) == 0 {
return records
}
var newest time.Time
for _, rec := range records {
if at, ok := recordTime(rec, field); ok && at.After(newest) {
newest = at
}
}
if newest.IsZero() {
// Nothing parseable to anchor on. Leaving the fixture alone is the
// honest outcome: a wrong guess about these dates is worse than dates
// that are visibly old.
return records
}
delta := now.Sub(newest)
out := make([]map[string]any, 0, len(records))
for _, rec := range records {
if _, ok := recordTime(rec, field); !ok {
out = append(out, rec)
continue
}
// Copied, not mutated: the fixture is read once and seeded into
// possibly several organizations, and rewriting it in place would make
// the second one depend on the first.
shifted := make(map[string]any, len(rec))
for k, v := range rec {
shifted[k] = v
// Every timestamp moves together. A field that is not a timestamp,
// or a null one, is copied across untouched.
if at, isTime := recordTime(rec, k); isTime {
shifted[k] = at.Add(delta).UTC().Format(dateLayoutOf(rec[k]))
}
}
out = append(out, shifted)
}
return out
}
// recordTime reads one timestamp field, whatever shape the fixture used.
func recordTime(rec map[string]any, field string) (time.Time, bool) {
raw, ok := rec[field]
if !ok {
return time.Time{}, false
}
switch v := raw.(type) {
case time.Time:
return v, true
case string:
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
if at, err := time.Parse(layout, v); err == nil {
return at, true
}
}
}
return time.Time{}, false
}
// dateLayoutOf keeps a rewritten timestamp in the shape the fixture used, so a
// date-only field stays a date and a full timestamp keeps its precision.
// Anything else the seeder reads back would differ from the source for reasons
// that have nothing to do with the shift.
func dateLayoutOf(raw any) string {
if s, ok := raw.(string); ok {
if len(s) == len("2006-01-02") {
return "2006-01-02"
}
if strings.Contains(s, ".") {
return "2006-01-02T15:04:05.000Z"
}
}
return time.RFC3339
}