add countdown -> and API for phone accessing
This commit is contained in:
parent
c7c422c31f
commit
796660c3cb
342
main.go
342
main.go
|
|
@ -1,3 +1,4 @@
|
||||||
|
// main.go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -7,7 +8,9 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
|
@ -19,6 +22,8 @@ var (
|
||||||
listenAddr = ":8080"
|
listenAddr = ":8080"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
// A single set row for display
|
// A single set row for display
|
||||||
type SetRow struct {
|
type SetRow struct {
|
||||||
ID int
|
ID int
|
||||||
|
|
@ -51,6 +56,25 @@ type Workout struct {
|
||||||
Template string
|
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() {
|
func main() {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
|
@ -61,6 +85,13 @@ func main() {
|
||||||
}
|
}
|
||||||
log.Printf("Using database file: %s", dbPath)
|
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
|
// Open SQLite DB
|
||||||
db, err = sql.Open("sqlite3", dbPath)
|
db, err = sql.Open("sqlite3", dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -78,17 +109,22 @@ func main() {
|
||||||
"eq": func(a, b interface{}) bool { return a == b },
|
"eq": func(a, b interface{}) bool { return a == b },
|
||||||
}).Parse(indexHTML))
|
}).Parse(indexHTML))
|
||||||
|
|
||||||
// Routes
|
// Routes (HTML)
|
||||||
http.HandleFunc("/", handleIndex)
|
http.HandleFunc("/", handleIndex)
|
||||||
http.HandleFunc("/start", handleStartWorkout)
|
http.HandleFunc("/start", handleStartWorkout)
|
||||||
http.HandleFunc("/finish", handleFinishWorkout)
|
http.HandleFunc("/finish", handleFinishWorkout)
|
||||||
http.HandleFunc("/add-set", handleAddSet)
|
http.HandleFunc("/add-set", handleAddSet)
|
||||||
|
|
||||||
// APIs
|
// Existing APIs
|
||||||
http.HandleFunc("/api/rest-times", handleRestTimes)
|
http.HandleFunc("/api/rest-times", handleRestTimes)
|
||||||
http.HandleFunc("/api/volume", handleVolumeStats)
|
http.HandleFunc("/api/volume", handleVolumeStats)
|
||||||
http.HandleFunc("/api/last-set", handleLastSet)
|
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)
|
log.Printf("Listening on %s ...", listenAddr)
|
||||||
if err := http.ListenAndServe(listenAddr, nil); err != nil {
|
if err := http.ListenAndServe(listenAddr, nil); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|
@ -124,7 +160,7 @@ CREATE TABLE IF NOT EXISTS sets (
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- HTML Handlers ---
|
||||||
|
|
||||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
// Current / latest workout
|
// Current / latest workout
|
||||||
|
|
@ -458,7 +494,25 @@ func handleVolumeStats(w http.ResponseWriter, r *http.Request) {
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
_ = 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) {
|
func handleLastSet(w http.ResponseWriter, r *http.Request) {
|
||||||
ex := r.URL.Query().Get("exercise")
|
ex := r.URL.Query().Get("exercise")
|
||||||
|
|
@ -497,22 +551,201 @@ func handleLastSet(w http.ResponseWriter, r *http.Request) {
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func queryVolume(start, end time.Time) (float64, error) {
|
// --- JSON APIs for mobile app ---
|
||||||
// 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(`
|
// /api/workouts
|
||||||
SELECT COALESCE(SUM(reps * weight), 0)
|
// GET -> list latest workouts
|
||||||
FROM sets
|
// POST -> start new workout { "template": "A" }
|
||||||
WHERE started_at >= ? AND started_at < ?
|
func handleAPIWorkouts(w http.ResponseWriter, r *http.Request) {
|
||||||
`, startStr, endStr)
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
var vol float64
|
workouts, err := getWorkoutSummaries(nil, nil)
|
||||||
if err := row.Scan(&vol); err != nil {
|
if err != nil {
|
||||||
return 0, err
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return vol, nil
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /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 ---
|
// --- Helpers & summaries ---
|
||||||
|
|
@ -720,7 +953,7 @@ func formatDuration(d time.Duration) string {
|
||||||
return strconv.Itoa(s) + "s"
|
return strconv.Itoa(s) + "s"
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HTML template (single page) ---
|
// --- HTML template (includes rest timer card) ---
|
||||||
|
|
||||||
const indexHTML = `
|
const indexHTML = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|
@ -831,11 +1064,16 @@ const indexHTML = `
|
||||||
.tag:hover {
|
.tag:hover {
|
||||||
background:#374151;
|
background:#374151;
|
||||||
}
|
}
|
||||||
|
.timer-display {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Minimal Fitness Tracker</h1>
|
<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 class="row">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -932,7 +1170,7 @@ const indexHTML = `
|
||||||
<input type="number" id="weightInput" name="weight" step="0.5" min="0" required>
|
<input type="number" id="weightInput" name="weight" step="0.5" min="0" required>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Save set (end set)</button>
|
<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>
|
</form>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p style="color:#fbbf24;">Start a workout first.</p>
|
<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>
|
<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>
|
<p id="volumeInfo" style="font-size:0.85rem; color:#9ca3af; margin-top:0.3rem;"></p>
|
||||||
</div>
|
</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, you’ll get a browser notification (if allowed).</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1053,6 +1302,8 @@ const indexHTML = `
|
||||||
<script>
|
<script>
|
||||||
let restChartInstance = null;
|
let restChartInstance = null;
|
||||||
let volumeChartInstance = null;
|
let volumeChartInstance = null;
|
||||||
|
let restTimerInterval = null;
|
||||||
|
let restTimerEnd = null;
|
||||||
|
|
||||||
function toggleCustomExercise() {
|
function toggleCustomExercise() {
|
||||||
const select = document.getElementById('exercise_select');
|
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', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
toggleCustomExercise();
|
toggleCustomExercise();
|
||||||
onExerciseChange();
|
onExerciseChange();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue