package db import ( "caltrack/types" "database/sql" "fmt" "os" "path/filepath" "time" _ "modernc.org/sqlite" ) type DB struct { conn *sql.DB } func New() (*DB, error) { dbDir := os.Getenv("CALTRACK_DATA_DIR") if dbDir == "" { home, err := os.UserHomeDir() if err != nil { return nil, err } dbDir = filepath.Join(home, ".config", "caltrack") } if err := os.MkdirAll(dbDir, 0755); err != nil { return nil, err } dbPath := filepath.Join(dbDir, "caltrack.db") conn, err := sql.Open("sqlite", dbPath) if err != nil { return nil, err } conn.SetMaxOpenConns(1) db := &DB{conn: conn} if err := db.migrate(); err != nil { conn.Close() return nil, err } return db, nil } func (db *DB) Close() error { return db.conn.Close() } func (db *DB) migrate() error { _, err := db.conn.Exec(` CREATE TABLE IF NOT EXISTS entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, time TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('food', 'exercise')), description TEXT NOT NULL, kcal INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS daily_summary ( date TEXT PRIMARY KEY, total_consumed INTEGER NOT NULL DEFAULT 0, total_burned INTEGER NOT NULL DEFAULT 0, target_kcal INTEGER NOT NULL DEFAULT 0, net_kcal INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS weigh_ins ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, weight_kg REAL NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, brand TEXT NOT NULL DEFAULT '', serving_size REAL NOT NULL DEFAULT 0, serving_unit TEXT NOT NULL DEFAULT '', kcal_per_serving INTEGER NOT NULL, source TEXT NOT NULL DEFAULT 'ai', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_entries_date ON entries(date); CREATE INDEX IF NOT EXISTS idx_weigh_ins_date ON weigh_ins(date); CREATE INDEX IF NOT EXISTS idx_products_name ON products(name); `) return err } func (db *DB) AddEntry(entry types.Entry) (types.Entry, error) { now := time.Now() if entry.Date == "" { entry.Date = now.Format("2006-01-02") } if entry.Time == "" { entry.Time = now.Format("15:04") } res, err := db.conn.Exec( `INSERT INTO entries (date, time, type, description, kcal) VALUES (?, ?, ?, ?, ?)`, entry.Date, entry.Time, entry.Type, entry.Description, entry.Kcal, ) if err != nil { return entry, err } entry.ID, _ = res.LastInsertId() entry.CreatedAt = now if err := db.updateDailySummary(entry.Date); err != nil { return entry, err } return entry, nil } func (db *DB) ClearDay(date string) error { _, err := db.conn.Exec(`DELETE FROM entries WHERE date = ?`, date) if err != nil { return err } _, err = db.conn.Exec(`DELETE FROM daily_summary WHERE date = ?`, date) return err } func (db *DB) DeleteEntry(id int64) error { var date string err := db.conn.QueryRow(`SELECT date FROM entries WHERE id = ?`, id).Scan(&date) if err != nil { return err } _, err = db.conn.Exec(`DELETE FROM entries WHERE id = ?`, id) if err != nil { return err } return db.updateDailySummary(date) } func (db *DB) GetEntriesByDate(date string) ([]types.Entry, error) { rows, err := db.conn.Query( `SELECT id, date, time, type, description, kcal, created_at FROM entries WHERE date = ? ORDER BY time ASC`, date, ) if err != nil { return nil, err } defer rows.Close() var entries []types.Entry for rows.Next() { var e types.Entry if err := rows.Scan(&e.ID, &e.Date, &e.Time, &e.Type, &e.Description, &e.Kcal, &e.CreatedAt); err != nil { return nil, err } entries = append(entries, e) } return entries, nil } func (db *DB) GetDailySummary(date string, targetKcal int) (types.DailySummary, error) { var s types.DailySummary err := db.conn.QueryRow( `SELECT date, total_consumed, total_burned, target_kcal, net_kcal FROM daily_summary WHERE date = ?`, date, ).Scan(&s.Date, &s.TotalConsumed, &s.TotalBurned, &s.TargetKcal, &s.NetKcal) if err == sql.ErrNoRows { return types.DailySummary{ Date: date, TargetKcal: targetKcal, }, nil } s.TargetKcal = targetKcal return s, err } func (db *DB) updateDailySummary(date string) error { var consumed, burned int err := db.conn.QueryRow( `SELECT COALESCE(SUM(kcal), 0) FROM entries WHERE date = ? AND type = 'food'`, date, ).Scan(&consumed) if err != nil { return err } err = db.conn.QueryRow( `SELECT COALESCE(SUM(kcal), 0) FROM entries WHERE date = ? AND type = 'exercise'`, date, ).Scan(&burned) if err != nil { return err } net := consumed - burned _, err = db.conn.Exec(` INSERT INTO daily_summary (date, total_consumed, total_burned, target_kcal, net_kcal) VALUES (?, ?, ?, 0, ?) ON CONFLICT(date) DO UPDATE SET total_consumed = excluded.total_consumed, total_burned = excluded.total_burned, net_kcal = excluded.net_kcal `, date, consumed, burned, net) return err } func (db *DB) AddWeighIn(date string, weightKg float64) (types.WeighIn, error) { now := time.Now() if date == "" { date = now.Format("2006-01-02") } res, err := db.conn.Exec( `INSERT INTO weigh_ins (date, weight_kg) VALUES (?, ?)`, date, weightKg, ) if err != nil { return types.WeighIn{}, err } id, _ := res.LastInsertId() return types.WeighIn{ ID: id, Date: date, WeightKg: weightKg, CreatedAt: now, }, nil } func (db *DB) GetLatestWeight() (float64, error) { var weight float64 err := db.conn.QueryRow( `SELECT weight_kg FROM weigh_ins ORDER BY date DESC, created_at DESC LIMIT 1`, ).Scan(&weight) if err == sql.ErrNoRows { return 0, nil } return weight, err } func (db *DB) GetWeightHistory() ([]types.WeighIn, error) { rows, err := db.conn.Query( `SELECT id, date, weight_kg, created_at FROM weigh_ins ORDER BY date DESC LIMIT 90`, ) if err != nil { return nil, err } defer rows.Close() var weighIns []types.WeighIn for rows.Next() { var w types.WeighIn if err := rows.Scan(&w.ID, &w.Date, &w.WeightKg, &w.CreatedAt); err != nil { return nil, err } weighIns = append(weighIns, w) } return weighIns, nil } func (db *DB) GetHistory(days int) ([]types.DailySummary, error) { rows, err := db.conn.Query( `SELECT date, total_consumed, total_burned, target_kcal, net_kcal FROM daily_summary ORDER BY date DESC LIMIT ?`, days, ) if err != nil { return nil, err } defer rows.Close() var summaries []types.DailySummary for rows.Next() { var s types.DailySummary if err := rows.Scan(&s.Date, &s.TotalConsumed, &s.TotalBurned, &s.TargetKcal, &s.NetKcal); err != nil { return nil, err } summaries = append(summaries, s) } return summaries, nil } func (db *DB) SearchProducts(query string) ([]types.AutocompleteResult, error) { likeQuery := fmt.Sprintf("%%%s%%", query) rows, err := db.conn.Query( `SELECT name, brand, kcal_per_serving, serving_size, serving_unit FROM products WHERE name LIKE ? OR brand LIKE ? ORDER BY last_used DESC LIMIT 10`, likeQuery, likeQuery, ) if err != nil { return nil, err } defer rows.Close() var results []types.AutocompleteResult for rows.Next() { var r types.AutocompleteResult if err := rows.Scan(&r.Name, &r.Brand, &r.KcalPerServing, &r.ServingSize, &r.ServingUnit); err != nil { return nil, err } results = append(results, r) } return results, nil } func (db *DB) FindProduct(name, brand string) (*types.Product, error) { var p types.Product err := db.conn.QueryRow( `SELECT id, name, brand, serving_size, serving_unit, kcal_per_serving, source, created_at, last_used FROM products WHERE LOWER(name) = LOWER(?) AND (brand = '' OR LOWER(brand) = LOWER(?)) LIMIT 1`, name, brand, ).Scan(&p.ID, &p.Name, &p.Brand, &p.ServingSize, &p.ServingUnit, &p.KcalPerServing, &p.Source, &p.CreatedAt, &p.LastUsed) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } // Update last_used db.conn.Exec(`UPDATE products SET last_used = CURRENT_TIMESTAMP WHERE id = ?`, p.ID) return &p, nil } func (db *DB) SaveProduct(p types.Product) error { _, err := db.conn.Exec( `INSERT INTO products (name, brand, serving_size, serving_unit, kcal_per_serving, source) VALUES (?, ?, ?, ?, ?, ?)`, p.Name, p.Brand, p.ServingSize, p.ServingUnit, p.KcalPerServing, p.Source, ) return err }