352 lines
8.3 KiB
Go
352 lines
8.3 KiB
Go
|
|
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
|
||
|
|
}
|