v1
Some checks are pending
Build and Push / build (push) Waiting to run

This commit is contained in:
Bastian Gruber 2026-02-23 14:21:23 -04:00
commit 34a9c068c5
No known key found for this signature in database
GPG key ID: 2E8AA0462DB41CBE
14 changed files with 2231 additions and 0 deletions

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
caltrack
*.db
.git
.github

40
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: Build and Push
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /caltrack .
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
COPY --from=build /caltrack /usr/local/bin/caltrack
RUN mkdir -p /data
ENV HOME=/data
EXPOSE 8080
ENTRYPOINT ["caltrack"]

123
ai/ai.go Normal file
View file

@ -0,0 +1,123 @@
package ai
import (
"caltrack/db"
"caltrack/types"
"context"
"encoding/json"
"fmt"
"strings"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
)
type Client struct {
cfg *types.Config
db *db.DB
}
func New(cfg *types.Config, database *db.DB) *Client {
return &Client{
cfg: cfg,
db: database,
}
}
const systemPrompt = `You are a nutrition and exercise expert. Your job is to estimate calories for food items and exercises.
When given a food description, respond with a JSON object containing an "items" array. Each item should have:
- "name": the food item name (e.g., "Banana")
- "brand": brand name if applicable, empty string otherwise
- "kcal": estimated calories (integer)
- "serving_size": the amount (e.g., 1, 200)
- "serving_unit": unit (e.g., "piece", "g", "ml")
When given an exercise description, respond with a JSON object containing an "items" array with a single item:
- "name": the exercise name
- "brand": empty string
- "kcal": estimated calories burned (integer, positive number)
- "serving_size": duration or distance
- "serving_unit": "min" or "km" etc.
For branded products (like "Snickers bar", "Coca Cola 330ml", "Lidl protein pudding"), use real nutritional data.
IMPORTANT: Respond ONLY with valid JSON. No markdown, no explanation, just the JSON object.
Examples:
Input: "ate a banana and a protein shake"
Output: {"items":[{"name":"Banana","brand":"","kcal":105,"serving_size":1,"serving_unit":"piece"},{"name":"Protein shake","brand":"","kcal":250,"serving_size":1,"serving_unit":"serving"}]}
Input: "ran 5k in 25 minutes"
Output: {"items":[{"name":"Running 5K","brand":"","kcal":350,"serving_size":5,"serving_unit":"km"}]}
Input: "Snickers bar"
Output: {"items":[{"name":"Snickers Bar","brand":"Mars","kcal":250,"serving_size":52,"serving_unit":"g"}]}`
func (c *Client) EstimateCalories(ctx context.Context, description string, entryType types.EntryType) (*types.AIEstimation, error) {
if c.cfg.AnthropicAPIKey == "" {
return nil, fmt.Errorf("Anthropic API key not configured - go to settings to add it")
}
client := anthropic.NewClient(
option.WithAPIKey(c.cfg.AnthropicAPIKey),
)
prompt := fmt.Sprintf("Estimate calories for this %s: %s", entryType, description)
msg, err := client.Messages.New(ctx, anthropic.MessageNewParams{
Model: "claude-haiku-4-5-20251001",
MaxTokens: 1024,
System: []anthropic.TextBlockParam{
{Text: systemPrompt},
},
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
},
})
if err != nil {
return nil, fmt.Errorf("anthropic API error: %w", err)
}
if len(msg.Content) == 0 {
return nil, fmt.Errorf("empty response from API")
}
text := msg.Content[0].Text
// Strip markdown code fences if present
text = strings.TrimSpace(text)
if strings.HasPrefix(text, "```") {
if i := strings.Index(text, "\n"); i != -1 {
text = text[i+1:]
}
if i := strings.LastIndex(text, "```"); i != -1 {
text = text[:i]
}
text = strings.TrimSpace(text)
}
var estimation types.AIEstimation
if err := json.Unmarshal([]byte(text), &estimation); err != nil {
return nil, fmt.Errorf("failed to parse AI response: %w (response: %s)", err, text)
}
// Cache products
for _, item := range estimation.Items {
if entryType == types.EntryFood {
existing, _ := c.db.FindProduct(item.Name, item.Brand)
if existing == nil {
c.db.SaveProduct(types.Product{
Name: item.Name,
Brand: item.Brand,
ServingSize: item.ServingSize,
ServingUnit: item.ServingUnit,
KcalPerServing: item.Kcal,
Source: "ai",
})
}
}
}
return &estimation, nil
}

298
api/handlers.go Normal file
View file

@ -0,0 +1,298 @@
package api
import (
"caltrack/ai"
"caltrack/calc"
"caltrack/config"
"caltrack/db"
"caltrack/types"
"encoding/json"
"log"
"net/http"
"strconv"
"time"
)
type Handler struct {
db *db.DB
ai *Client
cfg *types.Config
}
type Client = ai.Client
func New(database *db.DB, aiClient *ai.Client, cfg *types.Config) *Handler {
return &Handler{
db: database,
ai: aiClient,
cfg: cfg,
}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/today", h.handleGetToday)
mux.HandleFunc("GET /api/day/{date}", h.handleGetDay)
mux.HandleFunc("POST /api/entry", h.handleAddEntry)
mux.HandleFunc("DELETE /api/entry/{id}", h.handleDeleteEntry)
mux.HandleFunc("DELETE /api/day/{date}", h.handleClearDay)
mux.HandleFunc("POST /api/weighin", h.handleWeighIn)
mux.HandleFunc("GET /api/autocomplete", h.handleAutocomplete)
mux.HandleFunc("GET /api/history", h.handleHistory)
mux.HandleFunc("GET /api/weight-history", h.handleWeightHistory)
mux.HandleFunc("GET /api/settings", h.handleGetSettings)
mux.HandleFunc("POST /api/settings", h.handleUpdateSettings)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func (h *Handler) handleGetToday(w http.ResponseWriter, r *http.Request) {
h.handleGetDayData(w, time.Now().Format("2006-01-02"))
}
func (h *Handler) handleGetDay(w http.ResponseWriter, r *http.Request) {
date := r.PathValue("date")
if date == "" {
writeError(w, http.StatusBadRequest, "date is required")
return
}
h.handleGetDayData(w, date)
}
func (h *Handler) handleGetDayData(w http.ResponseWriter, date string) {
entries, err := h.db.GetEntriesByDate(date)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if entries == nil {
entries = []types.Entry{}
}
targetKcal := calc.DailyTarget(*h.cfg)
summary, err := h.db.GetDailySummary(date, targetKcal)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
weight, _ := h.db.GetLatestWeight()
if weight == 0 {
weight = h.cfg.CurrentWeightKg
}
h.cfg.HasAPIKey = h.cfg.AnthropicAPIKey != ""
writeJSON(w, http.StatusOK, types.TodayResponse{
Date: date,
Entries: entries,
Summary: summary,
TargetKcal: targetKcal,
Deficit: calc.Deficit(*h.cfg),
Weight: weight,
Config: *h.cfg,
})
}
func (h *Handler) handleAddEntry(w http.ResponseWriter, r *http.Request) {
var req types.AddEntryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Description == "" {
writeError(w, http.StatusBadRequest, "description is required")
return
}
if req.Type == "" {
req.Type = types.EntryFood
}
// Use AI to estimate calories
estimation, err := h.ai.EstimateCalories(r.Context(), req.Description, req.Type)
if err != nil {
log.Printf("AI estimation error: %v", err)
writeError(w, http.StatusInternalServerError, "failed to estimate calories: "+err.Error())
return
}
var entries []types.Entry
for _, item := range estimation.Items {
desc := item.Name
if item.Brand != "" {
desc = item.Name + " (" + item.Brand + ")"
}
entry := types.Entry{
Type: req.Type,
Description: desc,
Kcal: item.Kcal,
}
entry, err = h.db.AddEntry(entry)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
entries = append(entries, entry)
}
writeJSON(w, http.StatusCreated, types.AddEntryResponse{
Entries: entries,
})
}
func (h *Handler) handleDeleteEntry(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid entry id")
return
}
if err := h.db.DeleteEntry(id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (h *Handler) handleClearDay(w http.ResponseWriter, r *http.Request) {
date := r.PathValue("date")
if date == "" {
writeError(w, http.StatusBadRequest, "date is required")
return
}
if err := h.db.ClearDay(date); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
}
func (h *Handler) handleWeighIn(w http.ResponseWriter, r *http.Request) {
var req types.WeighInRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.WeightKg <= 0 {
writeError(w, http.StatusBadRequest, "weight must be positive")
return
}
weighIn, err := h.db.AddWeighIn("", req.WeightKg)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Update config with new weight
h.cfg.CurrentWeightKg = req.WeightKg
config.Save(*h.cfg)
writeJSON(w, http.StatusCreated, weighIn)
}
func (h *Handler) handleAutocomplete(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if len(query) < 2 {
writeJSON(w, http.StatusOK, []types.AutocompleteResult{})
return
}
results, err := h.db.SearchProducts(query)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if results == nil {
results = []types.AutocompleteResult{}
}
writeJSON(w, http.StatusOK, results)
}
func (h *Handler) handleHistory(w http.ResponseWriter, r *http.Request) {
summaries, err := h.db.GetHistory(30)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if summaries == nil {
summaries = []types.DailySummary{}
}
writeJSON(w, http.StatusOK, summaries)
}
func (h *Handler) handleWeightHistory(w http.ResponseWriter, r *http.Request) {
weighIns, err := h.db.GetWeightHistory()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if weighIns == nil {
weighIns = []types.WeighIn{}
}
writeJSON(w, http.StatusOK, weighIns)
}
func (h *Handler) handleGetSettings(w http.ResponseWriter, r *http.Request) {
h.cfg.HasAPIKey = h.cfg.AnthropicAPIKey != ""
writeJSON(w, http.StatusOK, h.cfg)
}
func (h *Handler) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var req types.SettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.CurrentWeightKg != nil {
h.cfg.CurrentWeightKg = *req.CurrentWeightKg
}
if req.TargetWeightKg != nil {
h.cfg.TargetWeightKg = *req.TargetWeightKg
}
if req.HeightCm != nil {
h.cfg.HeightCm = *req.HeightCm
}
if req.Age != nil {
h.cfg.Age = *req.Age
}
if req.Sex != nil {
h.cfg.Sex = *req.Sex
}
if req.ActivityLevel != nil {
h.cfg.ActivityLevel = *req.ActivityLevel
}
if req.WeeklyLossKg != nil {
h.cfg.WeeklyLossKg = *req.WeeklyLossKg
}
if req.AnthropicAPIKey != nil {
h.cfg.AnthropicAPIKey = *req.AnthropicAPIKey
}
if err := config.Save(*h.cfg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save config: "+err.Error())
return
}
h.cfg.HasAPIKey = h.cfg.AnthropicAPIKey != ""
writeJSON(w, http.StatusOK, h.cfg)
}

53
calc/calc.go Normal file
View file

@ -0,0 +1,53 @@
package calc
import "caltrack/types"
// BMR calculates Basal Metabolic Rate using Mifflin-St Jeor equation.
func BMR(cfg types.Config) float64 {
weight := cfg.CurrentWeightKg
height := float64(cfg.HeightCm)
age := float64(cfg.Age)
if cfg.Sex == "female" {
return 10*weight + 6.25*height - 5*age - 161
}
return 10*weight + 6.25*height - 5*age + 5
}
// ActivityMultiplier returns the multiplier for the given activity level.
func ActivityMultiplier(level string) float64 {
switch level {
case "sedentary":
return 1.2
case "light":
return 1.375
case "moderate":
return 1.55
case "active":
return 1.725
default:
return 1.55
}
}
// TDEE calculates Total Daily Energy Expenditure.
func TDEE(cfg types.Config) float64 {
return BMR(cfg) * ActivityMultiplier(cfg.ActivityLevel)
}
// DailyTarget calculates the daily calorie target accounting for desired deficit.
func DailyTarget(cfg types.Config) int {
tdee := TDEE(cfg)
// 1 kg of fat ≈ 7700 kcal
dailyDeficit := (cfg.WeeklyLossKg * 7700) / 7
target := tdee - dailyDeficit
if target < 1200 {
target = 1200
}
return int(target)
}
// Deficit returns the daily calorie deficit.
func Deficit(cfg types.Config) int {
return int(TDEE(cfg)) - DailyTarget(cfg)
}

BIN
caltrack Executable file

Binary file not shown.

84
config/config.go Normal file
View file

@ -0,0 +1,84 @@
package config
import (
"caltrack/types"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
)
func DefaultConfig() types.Config {
return types.Config{
CurrentWeightKg: 80.0,
TargetWeightKg: 70.0,
HeightCm: 175,
Age: 30,
Sex: "male",
ActivityLevel: "moderate",
WeeklyLossKg: 0.5,
ListenAddr: ":8080",
}
}
func DataDir() (string, error) {
if dir := os.Getenv("CALTRACK_DATA_DIR"); dir != "" {
return dir, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "caltrack"), nil
}
func ConfigPath() (string, error) {
dir, err := DataDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "config.toml"), nil
}
func Load() (types.Config, error) {
cfg := DefaultConfig()
path, err := ConfigPath()
if err != nil {
return cfg, err
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return cfg, nil
}
_, err = toml.DecodeFile(path, &cfg)
if err != nil {
return cfg, err
}
if cfg.ListenAddr == "" {
cfg.ListenAddr = ":8080"
}
return cfg, nil
}
func Save(cfg types.Config) error {
path, err := ConfigPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return toml.NewEncoder(f).Encode(cfg)
}

351
db/db.go Normal file
View file

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

27
go.mod Normal file
View file

@ -0,0 +1,27 @@
module caltrack
go 1.25.0
require (
github.com/BurntSushi/toml v1.6.0
github.com/anthropics/anthropic-sdk-go v1.26.0
modernc.org/sqlite v1.46.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

79
go.sum Normal file
View file

@ -0,0 +1,79 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

62
main.go Normal file
View file

@ -0,0 +1,62 @@
package main
import (
"caltrack/ai"
"caltrack/api"
cfgpkg "caltrack/config"
"caltrack/db"
"embed"
"fmt"
"io/fs"
"log"
"net/http"
)
//go:embed web
var webFS embed.FS
func main() {
cfg, err := cfgpkg.Load()
if err != nil {
log.Printf("Warning: could not load config: %v (using defaults)", err)
}
database, err := db.New()
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer database.Close()
aiClient := ai.New(&cfg, database)
handler := api.New(database, aiClient, &cfg)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
// Serve embedded web files
webContent, err := fs.Sub(webFS, "web")
if err != nil {
log.Fatalf("Failed to setup web filesystem: %v", err)
}
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data, _ := fs.ReadFile(webContent, "index.html")
w.Write(data)
return
}
http.FileServer(http.FS(webContent)).ServeHTTP(w, r)
})
addr := cfg.ListenAddr
if addr == "" {
addr = ":8080"
}
fmt.Printf("CalTrack running at http://localhost%s\n", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("Server error: %v", err)
}
}

114
types/types.go Normal file
View file

@ -0,0 +1,114 @@
package types
import "time"
type EntryType string
const (
EntryFood EntryType = "food"
EntryExercise EntryType = "exercise"
)
type Entry struct {
ID int64 `json:"id"`
Date string `json:"date"`
Time string `json:"time"`
Type EntryType `json:"type"`
Description string `json:"description"`
Kcal int `json:"kcal"`
CreatedAt time.Time `json:"created_at"`
}
type DailySummary struct {
Date string `json:"date"`
TotalConsumed int `json:"total_consumed"`
TotalBurned int `json:"total_burned"`
TargetKcal int `json:"target_kcal"`
NetKcal int `json:"net_kcal"`
}
type WeighIn struct {
ID int64 `json:"id"`
Date string `json:"date"`
WeightKg float64 `json:"weight_kg"`
CreatedAt time.Time `json:"created_at"`
}
type Product struct {
ID int64 `json:"id"`
Name string `json:"name"`
Brand string `json:"brand"`
ServingSize float64 `json:"serving_size"`
ServingUnit string `json:"serving_unit"`
KcalPerServing int `json:"kcal_per_serving"`
Source string `json:"source"`
CreatedAt time.Time `json:"created_at"`
LastUsed time.Time `json:"last_used"`
}
type Config struct {
AnthropicAPIKey string `toml:"anthropic_api_key" json:"-"`
CurrentWeightKg float64 `toml:"current_weight_kg" json:"current_weight_kg"`
TargetWeightKg float64 `toml:"target_weight_kg" json:"target_weight_kg"`
HeightCm int `toml:"height_cm" json:"height_cm"`
Age int `toml:"age" json:"age"`
Sex string `toml:"sex" json:"sex"`
ActivityLevel string `toml:"activity_level" json:"activity_level"`
WeeklyLossKg float64 `toml:"weekly_loss_kg" json:"weekly_loss_kg"`
ListenAddr string `toml:"listen_addr" json:"listen_addr"`
HasAPIKey bool `toml:"-" json:"has_api_key"`
}
type TodayResponse struct {
Date string `json:"date"`
Entries []Entry `json:"entries"`
Summary DailySummary `json:"summary"`
TargetKcal int `json:"target_kcal"`
Deficit int `json:"deficit"`
Weight float64 `json:"weight"`
Config Config `json:"config"`
}
type AddEntryRequest struct {
Type EntryType `json:"type"`
Description string `json:"description"`
}
type AddEntryResponse struct {
Entries []Entry `json:"entries"`
}
type WeighInRequest struct {
WeightKg float64 `json:"weight_kg"`
}
type SettingsRequest struct {
CurrentWeightKg *float64 `json:"current_weight_kg,omitempty"`
TargetWeightKg *float64 `json:"target_weight_kg,omitempty"`
HeightCm *int `json:"height_cm,omitempty"`
Age *int `json:"age,omitempty"`
Sex *string `json:"sex,omitempty"`
ActivityLevel *string `json:"activity_level,omitempty"`
WeeklyLossKg *float64 `json:"weekly_loss_kg,omitempty"`
AnthropicAPIKey *string `json:"anthropic_api_key,omitempty"`
}
type AutocompleteResult struct {
Name string `json:"name"`
Brand string `json:"brand"`
KcalPerServing int `json:"kcal_per_serving"`
ServingSize float64 `json:"serving_size"`
ServingUnit string `json:"serving_unit"`
}
type AIEstimation struct {
Items []AIEstimationItem `json:"items"`
}
type AIEstimationItem struct {
Name string `json:"name"`
Brand string `json:"brand"`
Kcal int `json:"kcal"`
ServingSize float64 `json:"serving_size"`
ServingUnit string `json:"serving_unit"`
}

982
web/index.html Normal file
View file

@ -0,0 +1,982 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CalTrack</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0d1117;
--bg2: #161b22;
--border: #30363d;
--green: #3fb950;
--amber: #d29922;
--red: #f85149;
--dim: #484f58;
--text: #c9d1d9;
--bright: #f0f6fc;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
display: flex;
justify-content: center;
padding: 20px;
min-height: 100vh;
}
#app {
max-width: 720px;
width: 100%;
}
.box {
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 0;
}
.box + .box { border-top: none; }
.box-header {
padding: 8px 16px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg2);
}
.box-body { padding: 12px 16px; }
.title { color: var(--green); font-weight: bold; }
.dim { color: var(--dim); }
.amber { color: var(--amber); }
.red { color: var(--red); }
.green { color: var(--green); }
.bright { color: var(--bright); }
/* Header */
#header-date { color: var(--amber); }
#header-weight { color: var(--text); }
.stats-row {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 4px;
}
/* Entries */
.entry {
display: flex;
align-items: center;
padding: 2px 0;
gap: 8px;
}
.entry:hover { background: var(--bg2); }
.entry-time { color: var(--dim); min-width: 50px; }
.entry-type {
font-weight: bold;
min-width: 24px;
}
.entry-type.food { color: var(--green); }
.entry-type.exercise { color: var(--amber); }
.entry-desc { flex: 1; }
.entry-kcal { min-width: 90px; text-align: right; }
.entry-kcal.positive { color: var(--text); }
.entry-kcal.negative { color: var(--amber); }
.entry-delete {
color: var(--dim);
cursor: pointer;
padding: 0 4px;
border: none;
background: none;
font-family: inherit;
font-size: inherit;
}
.entry-delete:hover { color: var(--red); }
.empty-log { color: var(--dim); padding: 16px 0; text-align: center; }
/* Summary bar */
.summary-row {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.progress-bar {
display: flex;
align-items: center;
gap: 8px;
}
.progress-track {
flex: 1;
min-width: 200px;
font-family: inherit;
}
.progress-pct { color: var(--amber); min-width: 80px; text-align: right; }
/* Input area */
#input-box .box-body { padding: 8px 16px; }
.input-row {
display: flex;
align-items: center;
gap: 8px;
}
.input-prompt { color: var(--green); font-weight: bold; }
#main-input {
flex: 1;
background: transparent;
border: none;
color: var(--bright);
font-family: inherit;
font-size: inherit;
outline: none;
caret-color: var(--green);
}
#main-input::placeholder { color: var(--dim); }
.mode-indicator {
color: var(--dim);
font-size: 12px;
}
/* Autocomplete */
.autocomplete {
display: none;
padding: 4px 0;
border-top: 1px solid var(--border);
margin-top: 8px;
}
.autocomplete.active { display: block; }
.ac-item {
padding: 4px 8px;
cursor: pointer;
display: flex;
justify-content: space-between;
}
.ac-item:hover, .ac-item.selected { background: var(--bg2); color: var(--bright); }
.ac-name { color: var(--text); }
.ac-kcal { color: var(--dim); }
/* Shortcuts */
.shortcuts {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
.shortcut { color: var(--dim); }
.shortcut kbd {
color: var(--amber);
font-family: inherit;
}
/* Loading */
.loading {
display: none;
color: var(--amber);
padding: 8px 0;
text-align: center;
}
.loading.active { display: block; }
@keyframes blink { 50% { opacity: 0; } }
.blink { animation: blink 1s step-end infinite; }
/* History overlay */
.overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.85);
z-index: 100;
justify-content: center;
padding: 20px;
overflow-y: auto;
}
.overlay.active { display: flex; }
.overlay-content {
max-width: 720px;
width: 100%;
margin: auto;
}
.overlay .box { border-color: var(--dim); }
.overlay-close {
cursor: pointer;
color: var(--dim);
}
.overlay-close:hover { color: var(--red); }
/* Settings form */
.form-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.form-row:last-child { border-bottom: none; }
.form-label { color: var(--dim); }
.form-input {
background: var(--bg);
border: 1px solid var(--border);
color: var(--bright);
font-family: inherit;
font-size: inherit;
padding: 4px 8px;
width: 200px;
text-align: right;
}
.form-input:focus { outline: 1px solid var(--green); }
select.form-input { text-align: left; }
.btn {
background: var(--bg2);
border: 1px solid var(--border);
color: var(--green);
font-family: inherit;
font-size: inherit;
padding: 6px 16px;
cursor: pointer;
margin-top: 12px;
}
.btn:hover { background: var(--border); }
/* History items */
.history-item {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.history-item:hover { background: var(--bg2); }
.history-item:last-child { border-bottom: none; }
/* Weight history */
.weight-item {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
/* Setup screen */
#setup-screen {
display: none;
}
#setup-screen.active {
display: block;
}
#main-screen.hidden {
display: none;
}
/* Navigation hint */
.nav-hint {
color: var(--dim);
text-align: center;
padding: 4px 0;
font-size: 12px;
}
/* Responsive */
@media (max-width: 500px) {
body { padding: 8px; font-size: 12px; }
.stats-row { flex-direction: column; }
.shortcuts { gap: 8px; }
}
</style>
</head>
<body>
<div id="app">
<!-- Setup Screen -->
<div id="setup-screen">
<div class="box">
<div class="box-header">
<span class="title">CalTrack - First Run Setup</span>
</div>
<div class="box-body">
<p class="dim" style="margin-bottom:12px">Configure your profile to get started.</p>
<div class="form-row">
<span class="form-label">Anthropic API Key</span>
<input type="password" class="form-input" id="setup-apikey" placeholder="sk-ant-..." style="width:280px">
</div>
<div class="form-row">
<span class="form-label">Current Weight (kg)</span>
<input type="number" class="form-input" id="setup-weight" value="80" step="0.1">
</div>
<div class="form-row">
<span class="form-label">Target Weight (kg)</span>
<input type="number" class="form-input" id="setup-target" value="70" step="0.1">
</div>
<div class="form-row">
<span class="form-label">Height (cm)</span>
<input type="number" class="form-input" id="setup-height" value="175">
</div>
<div class="form-row">
<span class="form-label">Age</span>
<input type="number" class="form-input" id="setup-age" value="30">
</div>
<div class="form-row">
<span class="form-label">Sex</span>
<select class="form-input" id="setup-sex">
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</div>
<div class="form-row">
<span class="form-label">Activity Level</span>
<select class="form-input" id="setup-activity">
<option value="sedentary">Sedentary</option>
<option value="light">Light</option>
<option value="moderate" selected>Moderate</option>
<option value="active">Active</option>
</select>
</div>
<div class="form-row">
<span class="form-label">Weekly Loss Target (kg)</span>
<input type="number" class="form-input" id="setup-loss" value="0.5" step="0.1">
</div>
<button class="btn" onclick="saveSetup()" style="width:100%">[ Start Tracking ]</button>
</div>
</div>
</div>
<!-- Main Screen -->
<div id="main-screen" class="hidden">
<!-- Header -->
<div class="box">
<div class="box-header">
<span class="title">CalTrack</span>
<span id="header-date" class="amber"></span>
</div>
<div class="box-body">
<div class="stats-row">
<span>Weight: <span id="header-weight" class="bright">--</span> kg</span>
<span>Target: <span id="header-target" class="green">--</span> kcal/day</span>
<span>Deficit: <span id="header-deficit" class="amber">--</span> kcal</span>
</div>
</div>
</div>
<!-- Day Log -->
<div class="box">
<div class="box-header">
<span>
<span id="nav-prev" style="cursor:pointer;color:var(--dim)" onclick="navigateDay(-1)">&lt;</span>
<span id="log-title">Today's Log</span>
<span id="nav-next" style="cursor:pointer;color:var(--dim)" onclick="navigateDay(1)">&gt;</span>
</span>
<span>
<span class="nav-hint" style="margin-right:8px">arrow keys to navigate</span>
<span style="cursor:pointer;color:var(--dim)" onclick="clearDay()" title="Clear all entries for this day">[clear day]</span>
</span>
</div>
<div class="box-body">
<div id="entries-list">
<div class="empty-log">No entries yet. Start typing below!</div>
</div>
</div>
</div>
<!-- Summary -->
<div class="box">
<div class="box-body">
<div class="summary-row">
<span>Consumed: <span id="sum-consumed" class="bright">0</span></span>
<span>Burned: <span id="sum-burned" class="amber">0</span></span>
<span>Remaining: <span id="sum-remaining" class="bright">0</span></span>
</div>
<div class="progress-bar">
<span class="dim">[</span>
<span id="progress-track" class="progress-track"></span>
<span class="dim">]</span>
<span id="progress-pct" class="progress-pct">0%</span>
</div>
</div>
</div>
<!-- Input -->
<div class="box" id="input-box">
<div class="box-body">
<div class="input-row">
<span class="input-prompt">&gt;</span>
<input id="main-input" type="text" placeholder="ate a banana and a protein shake..." autocomplete="off">
<span class="mode-indicator" id="mode-label">[food]</span>
</div>
<div id="loading" class="loading">
<span class="blink">Estimating calories...</span>
</div>
<div id="autocomplete" class="autocomplete"></div>
</div>
</div>
<!-- Shortcuts -->
<div class="box">
<div class="box-body">
<div class="shortcuts">
<span class="shortcut"><kbd>f</kbd>ood</span>
<span class="shortcut"><kbd>e</kbd>xercise</span>
<span class="shortcut"><kbd>w</kbd>eigh-in</span>
<span class="shortcut"><kbd>c</kbd>lear day</span>
<span class="shortcut"><kbd>h</kbd>istory</span>
<span class="shortcut"><kbd>s</kbd>ettings</span>
</div>
</div>
</div>
</div>
<!-- History Overlay -->
<div id="history-overlay" class="overlay">
<div class="overlay-content">
<div class="box">
<div class="box-header">
<span class="title">History (Last 30 Days)</span>
<span class="overlay-close" onclick="closeOverlay('history')">[x]</span>
</div>
<div class="box-body" id="history-list"></div>
</div>
</div>
</div>
<!-- Settings Overlay -->
<div id="settings-overlay" class="overlay">
<div class="overlay-content">
<div class="box">
<div class="box-header">
<span class="title">Settings</span>
<span class="overlay-close" onclick="closeOverlay('settings')">[x]</span>
</div>
<div class="box-body">
<div class="form-row">
<span class="form-label">API Key</span>
<input type="password" class="form-input" id="s-apikey" style="width:280px">
</div>
<div class="form-row">
<span class="form-label">Current Weight (kg)</span>
<input type="number" class="form-input" id="s-weight" step="0.1">
</div>
<div class="form-row">
<span class="form-label">Target Weight (kg)</span>
<input type="number" class="form-input" id="s-target" step="0.1">
</div>
<div class="form-row">
<span class="form-label">Height (cm)</span>
<input type="number" class="form-input" id="s-height">
</div>
<div class="form-row">
<span class="form-label">Age</span>
<input type="number" class="form-input" id="s-age">
</div>
<div class="form-row">
<span class="form-label">Sex</span>
<select class="form-input" id="s-sex">
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</div>
<div class="form-row">
<span class="form-label">Activity Level</span>
<select class="form-input" id="s-activity">
<option value="sedentary">Sedentary</option>
<option value="light">Light</option>
<option value="moderate">Moderate</option>
<option value="active">Active</option>
</select>
</div>
<div class="form-row">
<span class="form-label">Weekly Loss (kg)</span>
<input type="number" class="form-input" id="s-loss" step="0.1">
</div>
<button class="btn" onclick="saveSettings()" style="width:100%">[ Save Settings ]</button>
</div>
</div>
</div>
</div>
<!-- Weigh-in Overlay -->
<div id="weighin-overlay" class="overlay">
<div class="overlay-content">
<div class="box">
<div class="box-header">
<span class="title">Log Weigh-In</span>
<span class="overlay-close" onclick="closeOverlay('weighin')">[x]</span>
</div>
<div class="box-body">
<div class="form-row">
<span class="form-label">Weight (kg)</span>
<input type="number" class="form-input" id="w-weight" step="0.1" autofocus>
</div>
<button class="btn" onclick="saveWeighIn()" style="width:100%">[ Log Weight ]</button>
<div style="margin-top:16px">
<div class="dim" style="margin-bottom:8px">Recent weigh-ins:</div>
<div id="weight-history-list"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// State
let currentMode = 'food';
let currentDate = new Date().toISOString().slice(0, 10);
let todayStr = currentDate;
let acIndex = -1;
let acResults = [];
let acTimeout = null;
const $ = id => document.getElementById(id);
const input = $('main-input');
// Format date for display
function fmtDate(dateStr) {
const d = new Date(dateStr + 'T12:00:00');
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
}
function fmtKcal(n) {
return n.toLocaleString();
}
// Fetch helpers
async function api(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
return res.json();
}
// Load day data
async function loadDay(date) {
currentDate = date;
const path = date === todayStr ? '/api/today' : `/api/day/${date}`;
const data = await api('GET', path);
// Header
$('header-date').textContent = fmtDate(date);
$('header-weight').textContent = data.weight ? data.weight.toFixed(1) : '--';
$('header-target').textContent = fmtKcal(data.target_kcal);
$('header-deficit').textContent = `-${fmtKcal(data.deficit)}`;
// Log title
$('log-title').textContent = date === todayStr ? "Today's Log" : fmtDate(date);
// Entries
const list = $('entries-list');
if (!data.entries || data.entries.length === 0) {
list.innerHTML = '<div class="empty-log">No entries yet.</div>';
} else {
list.innerHTML = data.entries.map(e => `
<div class="entry">
<span class="entry-time">${e.time}</span>
<span class="entry-type ${e.type}">[${e.type === 'food' ? 'F' : 'E'}]</span>
<span class="entry-desc">${escHtml(e.description)}</span>
<span class="entry-kcal ${e.type === 'food' ? 'positive' : 'negative'}">${e.type === 'food' ? '+' : '-'}${fmtKcal(e.kcal)} kcal</span>
<button class="entry-delete" onclick="deleteEntry(${e.id})" title="Delete">x</button>
</div>
`).join('');
}
// Summary
const s = data.summary;
$('sum-consumed').textContent = fmtKcal(s.total_consumed);
$('sum-burned').textContent = fmtKcal(s.total_burned);
const remaining = data.target_kcal - s.net_kcal;
$('sum-remaining').textContent = fmtKcal(remaining);
$('sum-remaining').style.color = remaining < 0 ? 'var(--red)' : 'var(--green)';
// Progress bar
const pct = data.target_kcal > 0 ? Math.min(Math.round((s.net_kcal / data.target_kcal) * 100), 150) : 0;
const barWidth = 40;
const filled = Math.min(Math.round((pct / 100) * barWidth), barWidth);
const empty = barWidth - filled;
const barColor = pct > 100 ? 'var(--red)' : 'var(--green)';
$('progress-track').innerHTML = `<span style="color:${barColor}">${'='.repeat(filled)}</span><span class="dim">${'-'.repeat(Math.max(0, empty))}</span>`;
$('progress-pct').textContent = `${pct}% of target`;
$('progress-pct').style.color = pct > 100 ? 'var(--red)' : 'var(--amber)';
// Check if setup needed
if (!data.config || (!data.config.height_cm && !data.config.age)) {
showSetup();
}
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// Navigate days
function navigateDay(delta) {
const d = new Date(currentDate + 'T12:00:00');
d.setDate(d.getDate() + delta);
const next = d.toISOString().slice(0, 10);
if (next > todayStr) return;
loadDay(next);
}
// Add entry
async function addEntry(desc) {
if (!desc.trim()) return;
$('loading').classList.add('active');
input.disabled = true;
try {
const data = await api('POST', '/api/entry', {
type: currentMode,
description: desc.trim()
});
if (data.error) {
alert('Error: ' + data.error);
} else {
input.value = '';
await loadDay(currentDate);
}
} catch (e) {
alert('Network error: ' + e.message);
} finally {
$('loading').classList.remove('active');
input.disabled = false;
input.focus();
}
}
// Delete entry
async function deleteEntry(id) {
await api('DELETE', `/api/entry/${id}`);
loadDay(currentDate);
}
// Clear all entries for current day
async function clearDay() {
if (!confirm(`Clear all entries for ${fmtDate(currentDate)}?`)) return;
await api('DELETE', `/api/day/${currentDate}`);
loadDay(currentDate);
}
// Autocomplete
async function fetchAutocomplete(query) {
if (query.length < 2) {
hideAutocomplete();
return;
}
try {
acResults = await api('GET', `/api/autocomplete?q=${encodeURIComponent(query)}`);
if (acResults.length > 0) {
showAutocomplete();
} else {
hideAutocomplete();
}
} catch {
hideAutocomplete();
}
}
function showAutocomplete() {
const ac = $('autocomplete');
ac.innerHTML = acResults.map((r, i) => {
const label = r.brand ? `${r.name} - ${r.brand}` : r.name;
return `<div class="ac-item${i === acIndex ? ' selected' : ''}" onmousedown="selectAc(${i})">`
+ `<span class="ac-name">${escHtml(label)}</span>`
+ `<span class="ac-kcal">${r.kcal_per_serving} kcal</span></div>`;
}).join('');
ac.classList.add('active');
}
function hideAutocomplete() {
$('autocomplete').classList.remove('active');
acIndex = -1;
acResults = [];
}
function selectAc(index) {
if (acResults[index]) {
const r = acResults[index];
input.value = r.brand ? `${r.name} (${r.brand})` : r.name;
hideAutocomplete();
input.focus();
}
}
// Mode switching
function setMode(mode) {
currentMode = mode;
const labels = { food: '[food]', exercise: '[exercise]' };
$('mode-label').textContent = labels[mode] || '[food]';
const placeholders = {
food: 'ate a banana and a protein shake...',
exercise: 'ran 5k in 25 minutes...'
};
input.placeholder = placeholders[mode] || '';
input.focus();
}
// Overlays
function openOverlay(name) {
$(`${name}-overlay`).classList.add('active');
if (name === 'history') loadHistory();
if (name === 'weighin') loadWeightHistory();
if (name === 'settings') loadSettings();
}
function closeOverlay(name) {
$(`${name}-overlay`).classList.remove('active');
input.focus();
}
// History
async function loadHistory() {
const data = await api('GET', '/api/history');
const list = $('history-list');
if (!data || data.length === 0) {
list.innerHTML = '<div class="dim">No history yet.</div>';
return;
}
list.innerHTML = data.map(s => `
<div class="history-item" onclick="closeOverlay('history');loadDay('${s.date}')">
<span>${fmtDate(s.date)}</span>
<span>In: ${fmtKcal(s.total_consumed)} | Out: ${fmtKcal(s.total_burned)} | Net: ${fmtKcal(s.net_kcal)}</span>
</div>
`).join('');
}
// Weight
async function loadWeightHistory() {
const data = await api('GET', '/api/weight-history');
const list = $('weight-history-list');
if (!data || data.length === 0) {
list.innerHTML = '<div class="dim">No weigh-ins yet.</div>';
return;
}
list.innerHTML = data.slice(0, 10).map(w => `
<div class="weight-item">
<span class="dim">${fmtDate(w.date)}</span>
<span class="bright">${w.weight_kg.toFixed(1)} kg</span>
</div>
`).join('');
}
async function saveWeighIn() {
const weight = parseFloat($('w-weight').value);
if (!weight || weight <= 0) return;
await api('POST', '/api/weighin', { weight_kg: weight });
closeOverlay('weighin');
loadDay(currentDate);
}
// Settings
async function loadSettings() {
const data = await api('GET', '/api/settings');
$('s-weight').value = data.current_weight_kg;
$('s-target').value = data.target_weight_kg;
$('s-height').value = data.height_cm;
$('s-age').value = data.age;
$('s-sex').value = data.sex;
$('s-activity').value = data.activity_level;
$('s-loss').value = data.weekly_loss_kg;
}
async function saveSettings() {
const apikey = $('s-apikey').value;
const body = {
current_weight_kg: parseFloat($('s-weight').value),
target_weight_kg: parseFloat($('s-target').value),
height_cm: parseInt($('s-height').value),
age: parseInt($('s-age').value),
sex: $('s-sex').value,
activity_level: $('s-activity').value,
weekly_loss_kg: parseFloat($('s-loss').value)
};
if (apikey) body.anthropic_api_key = apikey;
await api('POST', '/api/settings', body);
closeOverlay('settings');
loadDay(currentDate);
}
// Setup
function showSetup() {
$('setup-screen').classList.add('active');
$('main-screen').classList.add('hidden');
}
async function saveSetup() {
const body = {
anthropic_api_key: $('setup-apikey').value,
current_weight_kg: parseFloat($('setup-weight').value),
target_weight_kg: parseFloat($('setup-target').value),
height_cm: parseInt($('setup-height').value),
age: parseInt($('setup-age').value),
sex: $('setup-sex').value,
activity_level: $('setup-activity').value,
weekly_loss_kg: parseFloat($('setup-loss').value)
};
if (!body.anthropic_api_key) {
alert('API key is required');
return;
}
await api('POST', '/api/settings', body);
$('setup-screen').classList.remove('active');
$('main-screen').classList.remove('hidden');
loadDay(todayStr);
}
// Keyboard handling
document.addEventListener('keydown', (e) => {
const overlayActive = document.querySelector('.overlay.active');
if (e.key === 'Escape') {
if (overlayActive) {
overlayActive.classList.remove('active');
input.focus();
return;
}
input.blur();
return;
}
if (overlayActive) return;
// If input not focused, handle shortcuts
if (document.activeElement !== input) {
switch (e.key) {
case 'f': setMode('food'); e.preventDefault(); break;
case 'e': setMode('exercise'); e.preventDefault(); break;
case 'w': openOverlay('weighin'); e.preventDefault(); break;
case 'h': openOverlay('history'); e.preventDefault(); break;
case 's': openOverlay('settings'); e.preventDefault(); break;
case 'c': clearDay(); e.preventDefault(); break;
case 'ArrowLeft': navigateDay(-1); e.preventDefault(); break;
case 'ArrowRight': navigateDay(1); e.preventDefault(); break;
case '/': case 'i': input.focus(); e.preventDefault(); break;
}
return;
}
// Input focused
if (e.key === 'Enter') {
if (acIndex >= 0 && acResults[acIndex]) {
selectAc(acIndex);
e.preventDefault();
return;
}
if (input.value.trim()) {
addEntry(input.value);
hideAutocomplete();
}
e.preventDefault();
return;
}
if (e.key === 'ArrowDown' && acResults.length > 0) {
acIndex = Math.min(acIndex + 1, acResults.length - 1);
showAutocomplete();
e.preventDefault();
return;
}
if (e.key === 'ArrowUp' && acResults.length > 0) {
acIndex = Math.max(acIndex - 1, -1);
showAutocomplete();
e.preventDefault();
return;
}
if (e.key === 'Tab') {
if (acResults.length > 0) {
if (acIndex < 0) acIndex = 0;
selectAc(acIndex);
e.preventDefault();
}
return;
}
});
// Input event for autocomplete
input.addEventListener('input', () => {
clearTimeout(acTimeout);
acIndex = -1;
if (currentMode === 'food') {
acTimeout = setTimeout(() => fetchAutocomplete(input.value), 200);
}
});
// Focus input on click anywhere on input box
$('input-box').addEventListener('click', () => input.focus());
// Init
async function init() {
try {
const data = await api('GET', '/api/settings');
if (!data.has_api_key || !data.height_cm || !data.age) {
showSetup();
} else {
$('main-screen').classList.remove('hidden');
loadDay(todayStr);
}
} catch {
$('main-screen').classList.remove('hidden');
loadDay(todayStr);
}
}
init();
</script>
</body>
</html>