package server import ( "sync" "time" "github.com/blakeridgway/heloha/internal/radar" ) const maxFrames = 12 type productKey struct{ site, product string } type frameRing struct { frames [maxFrames]*radar.RadarProduct head int // next write slot count int // filled slots (0..maxFrames) } func (r *frameRing) push(p *radar.RadarProduct) { // Deduplicate: skip if same or older scan time as the newest stored frame. if r.count > 0 { newest := r.frames[(r.head-1+maxFrames)%maxFrames] if !newest.Time.Before(p.Time) { return } } r.frames[r.head] = p r.head = (r.head + 1) % maxFrames if r.count < maxFrames { r.count++ } } // get returns the frame at index i where 0=oldest, count-1=newest. func (r *frameRing) get(i int) *radar.RadarProduct { if i < 0 || i >= r.count { return nil } slot := (r.head - r.count + i + maxFrames) % maxFrames return r.frames[slot] } // RadarStore holds multi-site, multi-product frame history. type RadarStore struct { mu sync.RWMutex rings map[productKey]*frameRing } func NewRadarStore() *RadarStore { return &RadarStore{rings: make(map[productKey]*frameRing)} } func (s *RadarStore) Set(site, product string, p *radar.RadarProduct) { s.mu.Lock() defer s.mu.Unlock() k := productKey{site: site, product: product} if s.rings[k] == nil { s.rings[k] = &frameRing{} } s.rings[k].push(p) } // GetLatest returns the newest frame for a given site and product, or nil. func (s *RadarStore) GetLatest(site, product string) *radar.RadarProduct { s.mu.RLock() defer s.mu.RUnlock() k := productKey{site: site, product: product} r := s.rings[k] if r == nil || r.count == 0 { return nil } return r.get(r.count - 1) } // GetAllLatest returns the newest frame for every site for the given product. func (s *RadarStore) GetAllLatest(product string) []*radar.RadarProduct { s.mu.RLock() defer s.mu.RUnlock() out := make([]*radar.RadarProduct, 0, 16) for k, r := range s.rings { if k.product == product && r.count > 0 { out = append(out, r.get(r.count-1)) } } return out } // GetAllAtFrame returns one frame per site at the given age index (0=oldest, N-1=newest). func (s *RadarStore) GetAllAtFrame(product string, frameIdx int) []*radar.RadarProduct { s.mu.RLock() defer s.mu.RUnlock() out := make([]*radar.RadarProduct, 0, 16) for k, r := range s.rings { if k.product != product { continue } // Map frameIdx relative to this ring's count. // frameIdx 0 = oldest across all rings → use offset from back. p := r.get(frameIdx) if p != nil { out = append(out, p) } } return out } // FrameInfo is one entry in the frames API response. type FrameInfo struct { Index int `json:"index"` Time time.Time `json:"time"` AgeSeconds int `json:"age_seconds"` } // FrameMeta returns frame metadata ordered oldest→newest using KTLX as the reference. func (s *RadarStore) FrameMeta(product string) (int, []FrameInfo) { s.mu.RLock() defer s.mu.RUnlock() r := s.rings[productKey{site: "ktlx", product: product}] if r == nil || r.count == 0 { return 0, nil } now := time.Now() out := make([]FrameInfo, r.count) for i := 0; i < r.count; i++ { p := r.get(i) out[i] = FrameInfo{ Index: i, Time: p.Time, AgeSeconds: int(now.Sub(p.Time).Seconds()), } } return r.count, out }