This commit is contained in:
commit
34a9c068c5
14 changed files with 2231 additions and 0 deletions
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
caltrack
|
||||
*.db
|
||||
.git
|
||||
.github
|
||||
40
.github/workflows/build.yml
vendored
Normal file
40
.github/workflows/build.yml
vendored
Normal 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
14
Dockerfile
Normal 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
123
ai/ai.go
Normal 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
298
api/handlers.go
Normal 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
53
calc/calc.go
Normal 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
BIN
caltrack
Executable file
Binary file not shown.
84
config/config.go
Normal file
84
config/config.go
Normal 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
351
db/db.go
Normal 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
27
go.mod
Normal 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
79
go.sum
Normal 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
62
main.go
Normal 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
114
types/types.go
Normal 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
982
web/index.html
Normal 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)"><</span>
|
||||
<span id="log-title">Today's Log</span>
|
||||
<span id="nav-next" style="cursor:pointer;color:var(--dim)" onclick="navigateDay(1)">></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">></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>
|
||||
Loading…
Reference in a new issue