commit 34a9c068c5713edc793d45ec9523e8777915ee73 Author: Bastian Gruber Date: Mon Feb 23 14:21:23 2026 -0400 v1 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..641108f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +caltrack +*.db +.git +.github diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..25146bd --- /dev/null +++ b/.github/workflows/build.yml @@ -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 }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..05a7632 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/ai/ai.go b/ai/ai.go new file mode 100644 index 0000000..25a111d --- /dev/null +++ b/ai/ai.go @@ -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 +} diff --git a/api/handlers.go b/api/handlers.go new file mode 100644 index 0000000..29bd9c5 --- /dev/null +++ b/api/handlers.go @@ -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) +} diff --git a/calc/calc.go b/calc/calc.go new file mode 100644 index 0000000..860fdf8 --- /dev/null +++ b/calc/calc.go @@ -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) +} diff --git a/caltrack b/caltrack new file mode 100755 index 0000000..cdf0fc7 Binary files /dev/null and b/caltrack differ diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..b9bcffc --- /dev/null +++ b/config/config.go @@ -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) +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..8e4173e --- /dev/null +++ b/db/db.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..79db35e --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f55a373 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..d8cf639 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/types/types.go b/types/types.go new file mode 100644 index 0000000..1fd7da7 --- /dev/null +++ b/types/types.go @@ -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"` +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..62f5df9 --- /dev/null +++ b/web/index.html @@ -0,0 +1,982 @@ + + + + + +CalTrack + + + + +
+ +
+
+
+ CalTrack - First Run Setup +
+
+

Configure your profile to get started.

+
+ Anthropic API Key + +
+
+ Current Weight (kg) + +
+
+ Target Weight (kg) + +
+
+ Height (cm) + +
+
+ Age + +
+
+ Sex + +
+
+ Activity Level + +
+
+ Weekly Loss Target (kg) + +
+ +
+
+
+ + + + + +
+
+
+
+ History (Last 30 Days) + [x] +
+
+
+
+
+ + +
+
+
+
+ Settings + [x] +
+
+
+ API Key + +
+
+ Current Weight (kg) + +
+
+ Target Weight (kg) + +
+
+ Height (cm) + +
+
+ Age + +
+
+ Sex + +
+
+ Activity Level + +
+
+ Weekly Loss (kg) + +
+ +
+
+
+
+ + +
+
+
+
+ Log Weigh-In + [x] +
+
+
+ Weight (kg) + +
+ +
+
Recent weigh-ins:
+
+
+
+
+
+
+
+ + + +