shots

package
v1.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 22, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package shots caches shot history from the Meticulous machine in a local SQLite database and serves it to the UI.

The machine exposes a single GET /api/v1/history endpoint that returns every shot, including the full per-sample time-series, in one blob. For the UI we want cheap list-many / read-one semantics. This package:

  1. Periodically fetches /api/v1/history and upserts each shot by id.
  2. Stores metadata in one row per shot plus a JSON blob for samples.
  3. Serves compact list + full-detail reads straight from SQLite.

Index

Constants

This section is empty.

Variables

View Source
var ErrNotFound = errors.New("shot not found")

ErrNotFound is returned by GetShot when no row matches.

Functions

This section is empty.

Types

type LiveShotInput

type LiveShotInput struct {
	ID          string
	Time        float64 // unix seconds (ms fraction ok)
	Name        string
	ProfileID   string
	ProfileName string
	Samples     json.RawMessage
	Profile     json.RawMessage // may be nil; stored inside summary_json
}

LiveShotInput is a shot captured from the live WebSocket stream. The recorder in internal/live builds this from a sequence of status events. Samples must be a JSON array matching the /api/v1/history per-shot `data` shape so readers don't branch on source.

type Shot

type Shot struct {
	ShotListItem
	DebugFile string          `json:"debug_file,omitempty"`
	Samples   json.RawMessage `json:"samples"`
	Profile   json.RawMessage `json:"profile,omitempty"`
}

Shot is the full detail view including raw samples and the profile snapshot.

type ShotListItem

type ShotListItem struct {
	ID          string  `json:"id"`
	Time        float64 `json:"time"`
	Name        string  `json:"name"`
	File        string  `json:"file,omitempty"`
	ProfileID   string  `json:"profile_id,omitempty"`
	ProfileName string  `json:"profile_name,omitempty"`
	SampleCount int     `json:"sample_count"`
	// User feedback. Rating is 1..5, or nil if unrated. Note is a
	// free-form tasting note the user attached to the shot.
	Rating *int   `json:"rating,omitempty"`
	Note   string `json:"note,omitempty"`
	// BeanID links this shot to a record in the beans table (empty when unset).
	BeanID string `json:"bean_id,omitempty"`
	// Grind is a free-form grinder setting label (e.g. "2.8" on a
	// Niche, "12" clicks on a Kingrinder). RPM is only meaningful for
	// variable-speed grinders (DF64, P100) and is nil otherwise.
	Grind    string   `json:"grind,omitempty"`
	GrindRPM *float64 `json:"grind_rpm,omitempty"`
}

ShotListItem is the compact list view (no sample data).

type ShotMetrics

type ShotMetrics struct {
	Spark        []float64 `json:"spark,omitempty"`
	PeakPressure float64   `json:"peak_pressure,omitempty"`
	FinalWeight  float64   `json:"final_weight,omitempty"`
}

ShotMetrics is the per-shot summary used to decorate list rows: a downsampled pressure trace (for the thumbnail) plus cheap-to-derive headline numbers. Everything here is computed from samples_json on demand, so we don't have to migrate the schema when we want a new metric.

type ShotSibling

type ShotSibling struct {
	ID           string  `json:"id"`
	Name         string  `json:"name"`
	TimeISO      string  `json:"time_iso"`
	Duration     float64 `json:"duration_s"`
	PeakPressure float64 `json:"peak_pressure_bar"`
	FinalWeight  float64 `json:"final_weight_g"`
	Rating       *int    `json:"rating,omitempty"`
	Note         string  `json:"note,omitempty"`
}

ShotSibling is a compact per-shot row the coach / comparator use for historical context. Metrics come from samples_json on demand, so no schema migration is needed to add more fields later.

type Status

type Status struct {
	LastSync     time.Time `json:"last_sync"`
	LastError    string    `json:"last_error,omitempty"`
	ShotsCached  int       `json:"shots_cached"`
	MachineURL   string    `json:"machine_url"`
	IntervalSecs float64   `json:"interval_secs"`
}

Status is the sync status surface used by the API.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store wraps a SQLite database holding the cached shot history.

func OpenStore

func OpenStore(path string) (*Store, error)

OpenStore opens (or creates) a SQLite database at path. Use ":memory:" for tests.

func (*Store) Close

func (s *Store) Close() error

Close releases the underlying database.

func (*Store) DB

func (s *Store) DB() *sql.DB

DB exposes the raw *sql.DB so sibling packages (ai usage recorder, beans store, …) can reuse the same SQLite file without opening a second connection and fighting over WAL locks.

func (*Store) GetAnalysis

func (s *Store) GetAnalysis(ctx context.Context, shotID, model string) (json.RawMessage, error)

GetAnalysis returns the cached analysis JSON for (shotID, model), or ErrNotFound if none is cached.

func (*Store) GetCoachSuggestion

func (s *Store) GetCoachSuggestion(ctx context.Context, shotID, model string) (json.RawMessage, error)

GetCoachSuggestion returns the cached suggestion for (shotID, model), or ErrNotFound.

func (*Store) GetCompare

func (s *Store) GetCompare(ctx context.Context, a, b, model string) (json.RawMessage, error)

GetCompare returns the cached comparison for (a,b,model). a and b may be supplied in either order.

func (*Store) GetLatestAnalysis

func (s *Store) GetLatestAnalysis(ctx context.Context, shotID string) (model string, analysis json.RawMessage, err error)

GetLatestAnalysis returns the most recently cached analysis for a shot regardless of model, plus the model it was generated with. Used by the read path so switching the configured model in Settings doesn't hide existing analyses from the user — they're still in the DB, we just weren't looking for them.

func (*Store) GetLatestCoachSuggestion

func (s *Store) GetLatestCoachSuggestion(ctx context.Context, shotID string) (model string, suggestion json.RawMessage, err error)

GetLatestCoachSuggestion returns the most recently cached suggestion for a shot regardless of model.

func (*Store) GetLatestCompare

func (s *Store) GetLatestCompare(ctx context.Context, a, b string) (model string, compare json.RawMessage, err error)

GetLatestCompare returns the most recent cached comparison for (a,b) regardless of model. a and b may be supplied in either order.

func (*Store) GetShot

func (s *Store) GetShot(ctx context.Context, id string) (*Shot, error)

GetShot returns the full shot (samples + profile snapshot) by id.

func (*Store) HideShot

func (s *Store) HideShot(ctx context.Context, id string) error

HideShot soft-deletes a shot: flips its hidden flag so it drops out of the list and sparkline views. We don't hard-delete because the next sync from the machine would just bring it back — hidden survives the upsert (the ON CONFLICT clause doesn't touch it).

func (*Store) ListShotMetrics

func (s *Store) ListShotMetrics(ctx context.Context, limit, points int) (map[string]ShotMetrics, error)

ListShotMetrics returns a newest-first map of shot id -> ShotMetrics, one entry per shot up to limit. The sparkline trace is normalised to `points` values using nearest-neighbour bucketing. Shots with no samples are omitted entirely.

func (*Store) ListShotSiblings

func (s *Store) ListShotSiblings(ctx context.Context, profileID, excludeID string, limit int) ([]ShotSibling, error)

ListShotSiblings returns up to `limit` recent shots with the given profile id (newest first), optionally excluding `excludeID`. Used by the profile-coach endpoint to pass historical context to the LLM.

func (*Store) ListShots

func (s *Store) ListShots(ctx context.Context, limit int) ([]ShotListItem, error)

ListShots returns the newest-first summary list, capped at limit.

func (*Store) ListSparklines

func (s *Store) ListSparklines(ctx context.Context, limit, points int) (map[string][]float64, error)

ListSparklines returns a newest-first map of shot id -> downsampled pressure series, one entry per shot up to limit. The series is normalised to `points` values using nearest-neighbour bucketing; shots with no samples are omitted. Intended for the history list thumbnail.

func (*Store) SaveAnalysis

func (s *Store) SaveAnalysis(ctx context.Context, shotID, model string, analysis json.RawMessage) error

SaveAnalysis upserts an analysis for (shotID, model).

func (*Store) SaveCoachSuggestion

func (s *Store) SaveCoachSuggestion(ctx context.Context, shotID, model string, suggestion json.RawMessage) error

SaveCoachSuggestion upserts a suggestion for (shotID, model).

func (*Store) SaveCompare

func (s *Store) SaveCompare(ctx context.Context, a, b, model string, compare json.RawMessage) error

SaveCompare upserts a comparison for (a,b,model). a and b are canonicalised so save order doesn't matter.

func (*Store) SaveLiveShot

func (s *Store) SaveLiveShot(ctx context.Context, in LiveShotInput) error

SaveLiveShot inserts a shot iff no row with the same id already exists. This keeps the canonical /history-synced row authoritative: if the history sync lands first (or later), it wins via ON CONFLICT in the full upsert path. We do NOT upsert here — the live stream lacks some metadata fields the machine provides in /history.

func (*Store) SetActiveBeanResolver

func (s *Store) SetActiveBeanResolver(fn func(context.Context) (id, grind string, rpm *float64))

SetActiveBeanResolver wires the "which bag is currently loaded" lookup. Called by main.go after the beans store is constructed. Safe to call more than once; last setter wins.

func (*Store) SetBean

func (s *Store) SetBean(ctx context.Context, id, beanID string) error

SetBean attaches (or clears) a bean id on a shot. Pass "" to clear. Like SetFeedback, this column is not overwritten by machine sync.

func (*Store) SetFeedback

func (s *Store) SetFeedback(ctx context.Context, id string, rating *int, note string) error

SetFeedback persists the user's rating (1..5, or nil to clear) and free-form note for a shot. Like `hidden`, these columns aren't touched by the sync upsert, so they survive future machine syncs.

func (*Store) SetGrind

func (s *Store) SetGrind(ctx context.Context, id, grind string, rpm *float64) error

SetGrind persists the grinder setting label and optional RPM for a shot. Pass nil for rpm to clear it, or a non-nil pointer to set it. These columns are not touched by machine sync.

type Syncer

type Syncer struct {
	// contains filtered or unexported fields
}

Syncer keeps the Store up to date by polling the machine.

func NewSyncer

func NewSyncer(store *Store, machineURL string, interval time.Duration) *Syncer

NewSyncer builds a Syncer. interval must be > 0 (sensible default: 30s).

func (*Syncer) Run

func (s *Syncer) Run(ctx context.Context)

Run performs an initial sync, then repeats every interval until ctx ends. Errors are logged but never terminate the loop.

func (*Syncer) Status

func (s *Syncer) Status(ctx context.Context) Status

Status reports the current sync status. Safe to call from any goroutine (the struct is written only by Run).

func (*Syncer) SyncOnce

func (s *Syncer) SyncOnce(ctx context.Context) error

SyncOnce performs a single sync cycle. Exported for tests and manual triggers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL