phase 0-2 complete

This commit is contained in:
Blake Ridgway
2026-04-25 20:31:55 -05:00
parent c1d8b17147
commit 778c52fc69
8 changed files with 1447 additions and 132 deletions

134
internal/server/store.go Normal file
View File

@@ -0,0 +1,134 @@
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
}