add countdown -> and API for phone accessing

This commit is contained in:
Dejan R. 2025-12-04 19:13:18 +01:00
parent c7c422c31f
commit 796660c3cb

358
main.go
View file

@ -1,3 +1,4 @@
// main.go
package main
import (
@ -7,18 +8,22 @@ import (
"log"
"net/http"
"os"
"path"
"strconv"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
var (
db *sql.DB
indexTmpl *template.Template
db *sql.DB
indexTmpl *template.Template
listenAddr = ":8080"
)
// --- Types ---
// A single set row for display
type SetRow struct {
ID int
@ -51,6 +56,25 @@ type Workout struct {
Template string
}
// DTOs for API
type WorkoutDTO struct {
ID int `json:"id"`
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
Template string `json:"template"`
SetCount int `json:"set_count"`
Volume float64 `json:"volume"`
}
type SetDTO struct {
ID int `json:"id"`
WorkoutID int `json:"workout_id"`
Exercise string `json:"exercise"`
Reps int `json:"reps"`
Weight float64 `json:"weight"`
StartedAt time.Time `json:"started_at"`
}
func main() {
var err error
@ -61,6 +85,13 @@ func main() {
}
log.Printf("Using database file: %s", dbPath)
// Ensure directory exists if path includes folder
if dir := path.Dir(dbPath); dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
log.Fatalf("failed to create db directory %s: %v", dir, err)
}
}
// Open SQLite DB
db, err = sql.Open("sqlite3", dbPath)
if err != nil {
@ -78,17 +109,22 @@ func main() {
"eq": func(a, b interface{}) bool { return a == b },
}).Parse(indexHTML))
// Routes
// Routes (HTML)
http.HandleFunc("/", handleIndex)
http.HandleFunc("/start", handleStartWorkout)
http.HandleFunc("/finish", handleFinishWorkout)
http.HandleFunc("/add-set", handleAddSet)
// APIs
// Existing APIs
http.HandleFunc("/api/rest-times", handleRestTimes)
http.HandleFunc("/api/volume", handleVolumeStats)
http.HandleFunc("/api/last-set", handleLastSet)
// New JSON APIs for mobile app
http.HandleFunc("/api/workouts", handleAPIWorkouts)
http.HandleFunc("/api/workouts/", handleAPIWorkoutDetail) // /api/workouts/{id}
http.HandleFunc("/api/sets", handleAPIAddSetJSON)
log.Printf("Listening on %s ...", listenAddr)
if err := http.ListenAndServe(listenAddr, nil); err != nil {
log.Fatal(err)
@ -124,7 +160,7 @@ CREATE TABLE IF NOT EXISTS sets (
return nil
}
// --- Handlers ---
// --- HTML Handlers ---
func handleIndex(w http.ResponseWriter, r *http.Request) {
// Current / latest workout
@ -170,13 +206,13 @@ func handleIndex(w http.ResponseWriter, r *http.Request) {
}
data := struct {
HasWorkout bool
WorkoutID int
WorkoutActive bool
WorkoutStart *time.Time
WorkoutEnd *time.Time
HasWorkout bool
WorkoutID int
WorkoutActive bool
WorkoutStart *time.Time
WorkoutEnd *time.Time
WorkoutTemplate string
Sets []SetRow
Sets []SetRow
From string
To string
@ -458,7 +494,25 @@ func handleVolumeStats(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(resp)
}
// --- API: last set for exercise (for autofill) ---
func queryVolume(start, end time.Time) (float64, error) {
// Sum of (reps * weight) for sets whose started_at is in [start, end)
startStr := start.UTC().Format(time.RFC3339)
endStr := end.UTC().Format(time.RFC3339)
row := db.QueryRow(`
SELECT COALESCE(SUM(reps * weight), 0)
FROM sets
WHERE started_at >= ? AND started_at < ?
`, startStr, endStr)
var vol float64
if err := row.Scan(&vol); err != nil {
return 0, err
}
return vol, nil
}
// --- API: last set for exercise (autofill) ---
func handleLastSet(w http.ResponseWriter, r *http.Request) {
ex := r.URL.Query().Get("exercise")
@ -497,22 +551,201 @@ func handleLastSet(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(resp)
}
func queryVolume(start, end time.Time) (float64, error) {
// Sum of (reps * weight) for sets whose started_at is in [start, end)
startStr := start.UTC().Format(time.RFC3339)
endStr := end.UTC().Format(time.RFC3339)
// --- JSON APIs for mobile app ---
row := db.QueryRow(`
SELECT COALESCE(SUM(reps * weight), 0)
FROM sets
WHERE started_at >= ? AND started_at < ?
`, startStr, endStr)
var vol float64
if err := row.Scan(&vol); err != nil {
return 0, err
// /api/workouts
// GET -> list latest workouts
// POST -> start new workout { "template": "A" }
func handleAPIWorkouts(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
workouts, err := getWorkoutSummaries(nil, nil)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
// convert to DTOs
var out []WorkoutDTO
for _, wsum := range workouts {
out = append(out, WorkoutDTO{
ID: wsum.ID,
StartedAt: wsum.StartedAt,
EndedAt: wsum.EndedAt,
Template: wsum.Template,
SetCount: wsum.SetCount,
Volume: wsum.Volume,
})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
case http.MethodPost:
var req struct {
Template string `json:"template"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
now := time.Now().UTC().Format(time.RFC3339)
res, err := db.Exec(`INSERT INTO workouts (started_at, template) VALUES (?, ?)`, now, req.Template)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
id64, _ := res.LastInsertId()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
ID int64 `json:"id"`
}{ID: id64})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
return vol, nil
}
// /api/workouts/{id}
func handleAPIWorkoutDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// path: /api/workouts/{id}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) != 3 {
http.Error(w, "not found", http.StatusNotFound)
return
}
idStr := parts[2]
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
// get workout
row := db.QueryRow(`SELECT id, started_at, ended_at, template FROM workouts WHERE id = ?`, id)
var wid int
var startedStr string
var endedStr sql.NullString
var templateStr sql.NullString
if err := row.Scan(&wid, &startedStr, &endedStr, &templateStr); err != nil {
if err == sql.ErrNoRows {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "db error", http.StatusInternalServerError)
return
}
started, err := time.Parse(time.RFC3339, startedStr)
if err != nil {
http.Error(w, "time parse error", http.StatusInternalServerError)
return
}
var ended *time.Time
if endedStr.Valid && endedStr.String != "" {
t, err := time.Parse(time.RFC3339, endedStr.String)
if err == nil {
ended = &t
}
}
tmpl := ""
if templateStr.Valid {
tmpl = templateStr.String
}
// sets
setsRows, err := getSetsForWorkout(id)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
var sets []SetDTO
for _, s := range setsRows {
sets = append(sets, SetDTO{
ID: s.ID,
WorkoutID: id,
Exercise: s.Exercise,
Reps: s.Reps,
Weight: s.Weight,
StartedAt: s.StartedAt,
})
}
// volume
var volume float64
for _, s := range setsRows {
volume += float64(s.Reps) * s.Weight
}
resp := struct {
Workout WorkoutDTO `json:"workout"`
Sets []SetDTO `json:"sets"`
}{
Workout: WorkoutDTO{
ID: wid,
StartedAt: started,
EndedAt: ended,
Template: tmpl,
SetCount: len(setsRows),
Volume: volume,
},
Sets: sets,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// /api/sets (POST JSON) -> create set
func handleAPIAddSetJSON(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
WorkoutID int `json:"workout_id"`
Exercise string `json:"exercise"`
Reps int `json:"reps"`
Weight float64 `json:"weight"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
// choose workout id: if 0 -> latest
var workoutID int
if req.WorkoutID == 0 {
latest, err := getLatestWorkout()
if err != nil || latest == nil {
http.Error(w, "no active workout", http.StatusBadRequest)
return
}
workoutID = latest.ID
} else {
workoutID = req.WorkoutID
}
if req.Exercise == "" || req.Reps <= 0 || req.Weight < 0 {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
now := time.Now().UTC().Format(time.RFC3339)
res, err := db.Exec(
`INSERT INTO sets (workout_id, exercise, reps, weight, started_at)
VALUES (?, ?, ?, ?, ?)`,
workoutID, req.Exercise, req.Reps, req.Weight, now,
)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
id64, _ := res.LastInsertId()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
ID int64 `json:"id"`
}{ID: id64})
}
// --- Helpers & summaries ---
@ -720,7 +953,7 @@ func formatDuration(d time.Duration) string {
return strconv.Itoa(s) + "s"
}
// --- HTML template (single page) ---
// --- HTML template (includes rest timer card) ---
const indexHTML = `
<!DOCTYPE html>
@ -831,11 +1064,16 @@ const indexHTML = `
.tag:hover {
background:#374151;
}
.timer-display {
font-size: 1.5rem;
font-weight: 600;
margin-top: 0.5rem;
}
</style>
</head>
<body>
<h1>Minimal Fitness Tracker</h1>
<p style="color:#9ca3af;">Self-hosted 5×5 log A/B templates, volume, rest, and history.</p>
<p style="color:#9ca3af;">Self-hosted 5×5 log A/B templates, volume, rest, history & basic API.</p>
<div class="row">
<div>
@ -932,7 +1170,7 @@ const indexHTML = `
<input type="number" id="weightInput" name="weight" step="0.5" min="0" required>
</label>
<button type="submit">Save set (end set)</button>
<p class="small">Tip: the time you press Save set is used as the set time. Rest between sets is calculated automatically.</p>
<p class="small">Time you press Save set = set timestamp. Rest between sets is automatic.</p>
</form>
{{else}}
<p style="color:#fbbf24;">Start a workout first.</p>
@ -988,6 +1226,17 @@ const indexHTML = `
<canvas id="volumeChart" height="120" style="margin-top:0.5rem;"></canvas>
<p id="volumeInfo" style="font-size:0.85rem; color:#9ca3af; margin-top:0.3rem;"></p>
</div>
<div class="card">
<h2>Rest timer (with notification)</h2>
<label>
Rest seconds
<input type="number" id="restSeconds" min="10" step="5" value="90">
</label>
<button type="button" onclick="startRestTimer()">Start rest</button>
<div id="restCountdown" class="timer-display"></div>
<p class="small">When the timer ends, youll get a browser notification (if allowed).</p>
</div>
</div>
</div>
@ -1053,6 +1302,8 @@ const indexHTML = `
<script>
let restChartInstance = null;
let volumeChartInstance = null;
let restTimerInterval = null;
let restTimerEnd = null;
function toggleCustomExercise() {
const select = document.getElementById('exercise_select');
@ -1220,6 +1471,55 @@ const indexHTML = `
});
}
// --- Rest timer with notification ---
function startRestTimer() {
const input = document.getElementById('restSeconds');
const display = document.getElementById('restCountdown');
let sec = parseInt(input.value, 10);
if (isNaN(sec) || sec <= 0) {
sec = 90;
input.value = 90;
}
// Ask for notification permission once
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
restTimerEnd = Date.now() + sec * 1000;
updateRestCountdown(); // immediate
if (restTimerInterval) {
clearInterval(restTimerInterval);
}
restTimerInterval = setInterval(updateRestCountdown, 250);
}
function updateRestCountdown() {
const display = document.getElementById('restCountdown');
if (!restTimerEnd) {
display.textContent = '';
return;
}
const now = Date.now();
const remainingMs = restTimerEnd - now;
if (remainingMs <= 0) {
display.textContent = "0s";
clearInterval(restTimerInterval);
restTimerInterval = null;
restTimerEnd = null;
// Fire notification if allowed
if ('Notification' in window && Notification.permission === 'granted') {
new Notification("Rest done time to lift! 💪");
}
return;
}
const sec = Math.round(remainingMs / 1000);
display.textContent = sec + "s";
}
document.addEventListener('DOMContentLoaded', () => {
toggleCustomExercise();
onExerciseChange();