mirror of
https://github.com/Alexander-D-Karpov/about.git
synced 2026-03-16 22:06:08 +03:00
1480 lines
43 KiB
Go
1480 lines
43 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Alexander-D-Karpov/about/internal/storage"
|
|
"github.com/Alexander-D-Karpov/about/internal/stream"
|
|
)
|
|
|
|
type HealthData struct {
|
|
StepsToday int64 `json:"steps_today"`
|
|
StepsWeek int64 `json:"steps_week"`
|
|
CaloriesToday float64 `json:"calories_today"`
|
|
CaloriesWeek float64 `json:"calories_week"`
|
|
WorkoutMinutesToday int64 `json:"workout_minutes_today"`
|
|
WorkoutMinutesWeek int64 `json:"workout_minutes_week"`
|
|
WorkoutCountToday int `json:"workout_count_today"`
|
|
WorkoutCountWeek int `json:"workout_count_week"`
|
|
CurrentHeartRate int `json:"current_heart_rate"`
|
|
RestingHeartRate int `json:"resting_heart_rate"`
|
|
SleepHoursLastNight float64 `json:"sleep_hours_last_night"`
|
|
SleepAvgWeek float64 `json:"sleep_avg_week"`
|
|
DistanceToday float64 `json:"distance_today"`
|
|
DistanceWeek float64 `json:"distance_week"`
|
|
HydrationToday float64 `json:"hydration_today"`
|
|
LastWorkout *WorkoutInfo `json:"last_workout"`
|
|
LastUpdated time.Time `json:"last_updated"`
|
|
LastHRUpdate time.Time `json:"last_hr_update"`
|
|
}
|
|
|
|
type WorkoutInfo struct {
|
|
Type string `json:"type"`
|
|
Duration int64 `json:"duration"`
|
|
Calories float64 `json:"calories"`
|
|
Date time.Time `json:"date"`
|
|
}
|
|
|
|
type DailyAverage struct {
|
|
Date string `json:"date"`
|
|
Steps int64 `json:"steps"`
|
|
Calories float64 `json:"calories"`
|
|
SleepHours float64 `json:"sleep_hours"`
|
|
HydrationML float64 `json:"hydration_ml"`
|
|
WorkoutMinutes int64 `json:"workout_minutes"`
|
|
WorkoutCount int `json:"workout_count"`
|
|
DistanceKM float64 `json:"distance_km"`
|
|
}
|
|
|
|
type IncomingHealthRecord struct {
|
|
ID string `json:"id,omitempty"`
|
|
StartTime string `json:"startTime,omitempty"`
|
|
EndTime string `json:"endTime,omitempty"`
|
|
Time string `json:"time,omitempty"`
|
|
Data map[string]interface{} `json:"-"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
func (r *IncomingHealthRecord) UnmarshalJSON(data []byte) error {
|
|
type Alias IncomingHealthRecord
|
|
aux := &struct {
|
|
*Alias
|
|
}{
|
|
Alias: (*Alias)(r),
|
|
}
|
|
if err := json.Unmarshal(data, aux); err != nil {
|
|
return err
|
|
}
|
|
var rawMap map[string]interface{}
|
|
if err := json.Unmarshal(data, &rawMap); err != nil {
|
|
return err
|
|
}
|
|
r.Data = rawMap
|
|
return nil
|
|
}
|
|
|
|
type SleepRecord struct {
|
|
ID string
|
|
StartTime time.Time
|
|
EndTime time.Time
|
|
Hours float64
|
|
}
|
|
|
|
type HealthPlugin struct {
|
|
storage *storage.Storage
|
|
hub *stream.Hub
|
|
username string
|
|
password string
|
|
healthData *HealthData
|
|
dailyAverages map[string]*DailyAverage
|
|
todayRawData map[string][]map[string]interface{}
|
|
mutex sync.RWMutex
|
|
lastPersist time.Time
|
|
|
|
processedRecords map[string]map[string]struct{}
|
|
processedRecordsDate string
|
|
sleepRecords map[string]*SleepRecord
|
|
lastResetDate string
|
|
}
|
|
|
|
func NewHealthPlugin(storage *storage.Storage, hub *stream.Hub, _, username, password string) *HealthPlugin {
|
|
p := &HealthPlugin{
|
|
storage: storage,
|
|
hub: hub,
|
|
username: username,
|
|
password: password,
|
|
healthData: &HealthData{},
|
|
dailyAverages: make(map[string]*DailyAverage),
|
|
todayRawData: make(map[string][]map[string]interface{}),
|
|
processedRecords: make(map[string]map[string]struct{}),
|
|
processedRecordsDate: time.Now().Format("2006-01-02"),
|
|
sleepRecords: make(map[string]*SleepRecord),
|
|
lastResetDate: time.Now().Format("2006-01-02"),
|
|
}
|
|
p.loadPersistedData()
|
|
return p
|
|
}
|
|
|
|
func (p *HealthPlugin) Name() string { return "health" }
|
|
|
|
func (p *HealthPlugin) ValidateAuth(username, password string) bool {
|
|
return p.username != "" && p.password != "" &&
|
|
username == p.username && password == p.password
|
|
}
|
|
|
|
func (p *HealthPlugin) HandleSync(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
username, password, ok := r.BasicAuth()
|
|
if !ok || !p.ValidateAuth(username, password) {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/health/sync/"), "/")
|
|
if len(pathParts) == 0 || pathParts[0] == "" {
|
|
http.Error(w, "Missing data type", http.StatusBadRequest)
|
|
return
|
|
}
|
|
dataType := strings.ToLower(pathParts[0])
|
|
|
|
var payload struct {
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var records []map[string]interface{}
|
|
if err := json.Unmarshal(payload.Data, &records); err != nil {
|
|
var single map[string]interface{}
|
|
if err2 := json.Unmarshal(payload.Data, &single); err2 != nil {
|
|
http.Error(w, "Invalid data format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
records = []map[string]interface{}{single}
|
|
}
|
|
|
|
p.processRecords(dataType, records)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"success": true,
|
|
"count": len(records),
|
|
})
|
|
}
|
|
|
|
func (p *HealthPlugin) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
|
p.mutex.RLock()
|
|
data := p.healthData
|
|
p.mutex.RUnlock()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(data)
|
|
}
|
|
|
|
func (p *HealthPlugin) getRecordID(dataType string, record map[string]interface{}) string {
|
|
if metadata, ok := record["metadata"].(map[string]interface{}); ok {
|
|
if id, ok := metadata["id"].(string); ok && id != "" {
|
|
return id
|
|
}
|
|
}
|
|
|
|
if id, ok := record["id"].(string); ok && id != "" {
|
|
return id
|
|
}
|
|
|
|
var startStr, endStr string
|
|
if v, ok := record["startTime"].(string); ok {
|
|
startStr = v
|
|
} else if v, ok := record["time"].(string); ok {
|
|
startStr = v
|
|
}
|
|
if v, ok := record["endTime"].(string); ok {
|
|
endStr = v
|
|
}
|
|
|
|
if startStr != "" {
|
|
return fmt.Sprintf("%s|%s|%s", dataType, startStr, endStr)
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (p *HealthPlugin) isRecordProcessed(dataType string, recordID string, recordDate string) bool {
|
|
if recordID == "" {
|
|
return false
|
|
}
|
|
if p.processedRecords[dataType] == nil {
|
|
return false
|
|
}
|
|
key := recordDate + "|" + recordID
|
|
_, exists := p.processedRecords[dataType][key]
|
|
return exists
|
|
}
|
|
|
|
func (p *HealthPlugin) markRecordProcessed(dataType string, recordID string, recordDate string) {
|
|
if recordID == "" {
|
|
return
|
|
}
|
|
if p.processedRecords[dataType] == nil {
|
|
p.processedRecords[dataType] = make(map[string]struct{})
|
|
}
|
|
key := recordDate + "|" + recordID
|
|
p.processedRecords[dataType][key] = struct{}{}
|
|
}
|
|
|
|
func (p *HealthPlugin) checkAndResetDaily() {
|
|
today := time.Now().Format("2006-01-02")
|
|
if p.lastResetDate != today {
|
|
p.ResetDailyData()
|
|
p.lastResetDate = today
|
|
}
|
|
}
|
|
|
|
func (p *HealthPlugin) processRecords(dataType string, records []map[string]interface{}) {
|
|
p.mutex.Lock()
|
|
defer p.mutex.Unlock()
|
|
|
|
p.checkAndResetDaily()
|
|
|
|
now := time.Now()
|
|
today := now.Format("2006-01-02")
|
|
|
|
startOfDay := dayStart(now)
|
|
startOfWeek := rollingWeekStart(now)
|
|
|
|
if dataType == "steps" {
|
|
log.Printf("[DEBUG] processRecords: type=%s, count=%d", dataType, len(records))
|
|
}
|
|
|
|
for _, record := range records {
|
|
recordID := p.getRecordID(dataType, record)
|
|
|
|
recordTime := p.parseRecordTime(record)
|
|
if recordTime.IsZero() {
|
|
recordTime = now
|
|
}
|
|
|
|
recordTimeLocal := recordTime.In(now.Location())
|
|
recordDate := recordTimeLocal.Format("2006-01-02")
|
|
|
|
if dataType != "heartrate" && p.isRecordProcessed(dataType, recordID, recordDate) {
|
|
continue
|
|
}
|
|
|
|
isToday := !recordTimeLocal.Before(startOfDay)
|
|
isThisWeek := !recordTimeLocal.Before(startOfWeek)
|
|
|
|
switch dataType {
|
|
|
|
case "steps":
|
|
count := p.extractStepCount(record)
|
|
log.Printf("[DEBUG] Steps record: count=%d, raw=%+v", count, record)
|
|
if count > 0 {
|
|
p.markRecordProcessed(dataType, recordID, recordDate)
|
|
|
|
p.ensureDailyAverage(recordDate)
|
|
p.dailyAverages[recordDate].Steps += count
|
|
|
|
if isToday {
|
|
p.healthData.StepsToday += count
|
|
log.Printf("[DEBUG] Added to StepsToday: +%d = %d", count, p.healthData.StepsToday)
|
|
}
|
|
if isThisWeek {
|
|
p.healthData.StepsWeek += count
|
|
log.Printf("[DEBUG] Added to StepsWeek: +%d = %d", count, p.healthData.StepsWeek)
|
|
}
|
|
}
|
|
|
|
case "activecaloriesburned", "totalcaloriesburned":
|
|
calories := p.extractCalories(record)
|
|
if calories > 0 {
|
|
p.markRecordProcessed(dataType, recordID, recordDate)
|
|
|
|
p.ensureDailyAverage(recordDate)
|
|
p.dailyAverages[recordDate].Calories += calories
|
|
|
|
if isToday {
|
|
p.healthData.CaloriesToday += calories
|
|
}
|
|
if isThisWeek {
|
|
p.healthData.CaloriesWeek += calories
|
|
}
|
|
}
|
|
|
|
case "heartrate":
|
|
bpm := p.extractHeartRate(record)
|
|
if bpm > 0 {
|
|
p.healthData.CurrentHeartRate = bpm
|
|
p.healthData.LastHRUpdate = now
|
|
if bpm < p.healthData.RestingHeartRate || p.healthData.RestingHeartRate == 0 {
|
|
if bpm > 40 {
|
|
p.healthData.RestingHeartRate = bpm
|
|
}
|
|
}
|
|
}
|
|
|
|
case "restingheartrate":
|
|
bpm := p.extractHeartRate(record)
|
|
if bpm > 0 {
|
|
p.healthData.RestingHeartRate = bpm
|
|
}
|
|
|
|
case "sleepsession":
|
|
hours := p.extractSleepHours(record)
|
|
if hours > 0 {
|
|
canonicalID := p.getCanonicalSleepID(record)
|
|
if canonicalID == "" {
|
|
continue
|
|
}
|
|
if _, exists := p.sleepRecords[canonicalID]; !exists {
|
|
endTime := p.parseRecordEndTime(record)
|
|
if endTime.IsZero() {
|
|
endTime = recordTime.Add(time.Duration(hours * float64(time.Hour)))
|
|
}
|
|
|
|
p.sleepRecords[canonicalID] = &SleepRecord{
|
|
ID: canonicalID,
|
|
StartTime: recordTime,
|
|
EndTime: endTime,
|
|
Hours: hours,
|
|
}
|
|
|
|
endDate := endTime.In(now.Location()).Format("2006-01-02")
|
|
p.ensureDailyAverage(endDate)
|
|
p.dailyAverages[endDate].SleepHours += hours
|
|
|
|
p.recalculateSleep(startOfDay)
|
|
}
|
|
}
|
|
|
|
case "exercisesession":
|
|
p.markRecordProcessed(dataType, recordID, recordDate)
|
|
duration, calories, exerciseType := p.extractWorkout(record)
|
|
|
|
p.ensureDailyAverage(recordDate)
|
|
p.dailyAverages[recordDate].WorkoutMinutes += duration
|
|
p.dailyAverages[recordDate].WorkoutCount++
|
|
|
|
if isToday {
|
|
p.healthData.WorkoutMinutesToday += duration
|
|
p.healthData.WorkoutCountToday++
|
|
}
|
|
if isThisWeek {
|
|
p.healthData.WorkoutMinutesWeek += duration
|
|
p.healthData.WorkoutCountWeek++
|
|
}
|
|
if p.healthData.LastWorkout == nil || recordTime.After(p.healthData.LastWorkout.Date) {
|
|
p.healthData.LastWorkout = &WorkoutInfo{
|
|
Type: exerciseType,
|
|
Duration: duration,
|
|
Calories: calories,
|
|
Date: recordTime,
|
|
}
|
|
}
|
|
|
|
case "hydration":
|
|
ml := p.extractHydration(record)
|
|
if ml > 0 {
|
|
p.markRecordProcessed(dataType, recordID, recordDate)
|
|
|
|
p.ensureDailyAverage(recordDate)
|
|
p.dailyAverages[recordDate].HydrationML += ml
|
|
|
|
if isToday {
|
|
p.healthData.HydrationToday += ml
|
|
}
|
|
}
|
|
|
|
case "distance":
|
|
km := p.extractDistance(record)
|
|
if km > 0 {
|
|
p.markRecordProcessed(dataType, recordID, recordDate)
|
|
|
|
p.ensureDailyAverage(recordDate)
|
|
p.dailyAverages[recordDate].DistanceKM += km
|
|
|
|
if isToday {
|
|
p.healthData.DistanceToday += km
|
|
}
|
|
if isThisWeek {
|
|
p.healthData.DistanceWeek += km
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
p.healthData.LastUpdated = now
|
|
|
|
if _, exists := p.dailyAverages[today]; !exists {
|
|
p.dailyAverages[today] = &DailyAverage{Date: today}
|
|
}
|
|
|
|
if dataType == "steps" {
|
|
log.Printf("[DEBUG] Before persist: StepsToday=%d StepsWeek=%d dailyAvg[%s].Steps=%d",
|
|
p.healthData.StepsToday, p.healthData.StepsWeek, today,
|
|
func() int64 {
|
|
if avg, ok := p.dailyAverages[today]; ok {
|
|
return avg.Steps
|
|
}
|
|
return -1
|
|
}())
|
|
}
|
|
p.persistData()
|
|
p.lastPersist = time.Now()
|
|
}
|
|
|
|
func (p *HealthPlugin) deduplicateSleepRecords() {
|
|
seen := make(map[string]*SleepRecord)
|
|
for id, sr := range p.sleepRecords {
|
|
canonicalKey := fmt.Sprintf("%s|%s", sr.StartTime.Format(time.RFC3339), sr.EndTime.Format(time.RFC3339))
|
|
if existing, exists := seen[canonicalKey]; exists {
|
|
delete(p.sleepRecords, id)
|
|
if existing.ID != id {
|
|
delete(p.sleepRecords, existing.ID)
|
|
}
|
|
seen[canonicalKey].ID = canonicalKey
|
|
p.sleepRecords[canonicalKey] = seen[canonicalKey]
|
|
} else {
|
|
seen[canonicalKey] = sr
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *HealthPlugin) recalculateSleep(startOfDay time.Time) {
|
|
now := time.Now()
|
|
todayAfternoon := time.Date(now.Year(), now.Month(), now.Day(), 16, 0, 0, 0, now.Location())
|
|
yesterdayEvening := time.Date(now.Year(), now.Month(), now.Day()-1, 18, 0, 0, 0, now.Location())
|
|
|
|
var totalSleepHours float64
|
|
var weekSleepHours float64
|
|
var weekSleepCount int
|
|
|
|
startOfWeek := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -int(now.Weekday()))
|
|
|
|
for _, sr := range p.sleepRecords {
|
|
endLocal := sr.EndTime.In(now.Location())
|
|
|
|
if endLocal.After(yesterdayEvening) && endLocal.Before(todayAfternoon) {
|
|
totalSleepHours += sr.Hours
|
|
}
|
|
|
|
if sr.EndTime.After(startOfWeek) {
|
|
weekSleepHours += sr.Hours
|
|
weekSleepCount++
|
|
}
|
|
}
|
|
|
|
p.healthData.SleepHoursLastNight = totalSleepHours
|
|
|
|
if weekSleepCount > 0 {
|
|
p.healthData.SleepAvgWeek = weekSleepHours / float64(weekSleepCount)
|
|
}
|
|
}
|
|
|
|
func (p *HealthPlugin) parseRecordTime(record map[string]interface{}) time.Time {
|
|
for _, key := range []string{"startTime", "time", "start"} {
|
|
if v, ok := record[key].(string); ok && v != "" {
|
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
|
return t
|
|
}
|
|
if t, err := time.Parse("2006-01-02T15:04:05", v); err == nil {
|
|
return t
|
|
}
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func (p *HealthPlugin) parseRecordEndTime(record map[string]interface{}) time.Time {
|
|
for _, key := range []string{"endTime", "end"} {
|
|
if v, ok := record[key].(string); ok && v != "" {
|
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
|
return t
|
|
}
|
|
if t, err := time.Parse("2006-01-02T15:04:05", v); err == nil {
|
|
return t
|
|
}
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func (p *HealthPlugin) extractInt(record map[string]interface{}, key string) int64 {
|
|
val := record[key]
|
|
switch v := val.(type) {
|
|
case float64:
|
|
return int64(v)
|
|
case float32:
|
|
return int64(v)
|
|
case int:
|
|
return int64(v)
|
|
case int64:
|
|
return v
|
|
case int32:
|
|
return int64(v)
|
|
case string:
|
|
if i, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
return i
|
|
}
|
|
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
|
return int64(f)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (p *HealthPlugin) extractCalories(record map[string]interface{}) float64 {
|
|
if energy, ok := record["energy"].(map[string]interface{}); ok {
|
|
if kcal, ok := energy["inKilocalories"].(float64); ok {
|
|
return kcal
|
|
}
|
|
}
|
|
if kcal, ok := record["calories"].(float64); ok {
|
|
return kcal
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (p *HealthPlugin) extractHeartRate(record map[string]interface{}) int {
|
|
if bpm, ok := record["bpm"].(float64); ok {
|
|
return int(bpm)
|
|
}
|
|
if bpm, ok := record["beatsPerMinute"].(float64); ok {
|
|
return int(bpm)
|
|
}
|
|
if samples, ok := record["samples"].([]interface{}); ok && len(samples) > 0 {
|
|
if sample, ok := samples[0].(map[string]interface{}); ok {
|
|
if bpm, ok := sample["beatsPerMinute"].(float64); ok {
|
|
return int(bpm)
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (p *HealthPlugin) extractSleepHours(record map[string]interface{}) float64 {
|
|
startStr, _ := record["startTime"].(string)
|
|
endStr, _ := record["endTime"].(string)
|
|
if startStr == "" || endStr == "" {
|
|
return 0
|
|
}
|
|
start, err1 := time.Parse(time.RFC3339, startStr)
|
|
end, err2 := time.Parse(time.RFC3339, endStr)
|
|
if err1 != nil || err2 != nil {
|
|
return 0
|
|
}
|
|
return end.Sub(start).Hours()
|
|
}
|
|
|
|
func (p *HealthPlugin) extractWorkout(record map[string]interface{}) (int64, float64, string) {
|
|
var duration int64
|
|
var calories float64
|
|
exerciseType := "Workout"
|
|
|
|
startStr, _ := record["startTime"].(string)
|
|
endStr, _ := record["endTime"].(string)
|
|
if startStr != "" && endStr != "" {
|
|
start, _ := time.Parse(time.RFC3339, startStr)
|
|
end, _ := time.Parse(time.RFC3339, endStr)
|
|
if !start.IsZero() && !end.IsZero() {
|
|
duration = int64(end.Sub(start).Minutes())
|
|
}
|
|
}
|
|
|
|
if energy, ok := record["energy"].(map[string]interface{}); ok {
|
|
if kcal, ok := energy["inKilocalories"].(float64); ok {
|
|
calories = kcal
|
|
}
|
|
} else if kcal, ok := record["calories"].(float64); ok {
|
|
calories = kcal
|
|
}
|
|
|
|
if t, ok := record["exerciseType"].(string); ok {
|
|
exerciseType = formatExerciseType(t)
|
|
} else if t, ok := record["exerciseType"].(float64); ok {
|
|
exerciseType = fmt.Sprintf("Exercise %d", int(t))
|
|
}
|
|
|
|
return duration, calories, exerciseType
|
|
}
|
|
|
|
func (p *HealthPlugin) extractHydration(record map[string]interface{}) float64 {
|
|
if volume, ok := record["volume"].(map[string]interface{}); ok {
|
|
if ml, ok := volume["inMilliliters"].(float64); ok {
|
|
return ml
|
|
}
|
|
if l, ok := volume["inLiters"].(float64); ok {
|
|
return l * 1000
|
|
}
|
|
}
|
|
if ml, ok := record["volume"].(float64); ok {
|
|
return ml
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (p *HealthPlugin) extractDistance(record map[string]interface{}) float64 {
|
|
if distance, ok := record["distance"].(map[string]interface{}); ok {
|
|
if m, ok := distance["inMeters"].(float64); ok {
|
|
return m / 1000
|
|
}
|
|
if km, ok := distance["inKilometers"].(float64); ok {
|
|
return km
|
|
}
|
|
}
|
|
if m, ok := record["distance"].(float64); ok {
|
|
return m / 1000
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (p *HealthPlugin) BroadcastHeartRate() {
|
|
p.mutex.RLock()
|
|
hr := p.healthData.CurrentHeartRate
|
|
lastUpdate := p.healthData.LastHRUpdate
|
|
p.mutex.RUnlock()
|
|
|
|
if hr > 0 {
|
|
p.hub.Broadcast("heartrate_update", map[string]interface{}{
|
|
"bpm": hr,
|
|
"last_update": lastUpdate.Unix(),
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
}
|
|
}
|
|
|
|
func (p *HealthPlugin) ResetDailyData() {
|
|
today := time.Now().Format("2006-01-02")
|
|
|
|
if avg, exists := p.dailyAverages[today]; !exists || avg.Steps == 0 {
|
|
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
|
if _, exists := p.dailyAverages[yesterday]; !exists {
|
|
p.dailyAverages[yesterday] = &DailyAverage{
|
|
Date: yesterday,
|
|
Steps: p.healthData.StepsToday,
|
|
Calories: p.healthData.CaloriesToday,
|
|
SleepHours: p.healthData.SleepHoursLastNight,
|
|
HydrationML: p.healthData.HydrationToday,
|
|
WorkoutMinutes: p.healthData.WorkoutMinutesToday,
|
|
DistanceKM: p.healthData.DistanceToday,
|
|
}
|
|
}
|
|
}
|
|
|
|
p.healthData.StepsToday = 0
|
|
p.healthData.CaloriesToday = 0
|
|
p.healthData.WorkoutMinutesToday = 0
|
|
p.healthData.WorkoutCountToday = 0
|
|
p.healthData.HydrationToday = 0
|
|
p.healthData.DistanceToday = 0
|
|
p.healthData.SleepHoursLastNight = 0
|
|
|
|
p.cleanOldProcessedRecords()
|
|
p.cleanOldSleepRecords()
|
|
|
|
p.recalculateWeeklyData()
|
|
p.cleanOldData()
|
|
p.persistData()
|
|
|
|
p.lastResetDate = today
|
|
}
|
|
|
|
func (p *HealthPlugin) recalculateWeeklyData() {
|
|
now := time.Now()
|
|
startOfWeek := rollingWeekStart(now)
|
|
|
|
var stepsWeek int64
|
|
var caloriesWeek float64
|
|
var workoutMinutesWeek int64
|
|
var workoutCountWeek int
|
|
var distanceWeek float64
|
|
var sleepTotal float64
|
|
var sleepCount int
|
|
|
|
for dateStr, avg := range p.dailyAverages {
|
|
date, err := time.ParseInLocation("2006-01-02", dateStr, now.Location())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if date.Before(startOfWeek) {
|
|
continue
|
|
}
|
|
|
|
stepsWeek += avg.Steps
|
|
caloriesWeek += avg.Calories
|
|
workoutMinutesWeek += avg.WorkoutMinutes
|
|
workoutCountWeek += avg.WorkoutCount
|
|
distanceWeek += avg.DistanceKM
|
|
if avg.SleepHours > 0 {
|
|
sleepTotal += avg.SleepHours
|
|
sleepCount++
|
|
}
|
|
}
|
|
|
|
p.healthData.StepsWeek = stepsWeek
|
|
p.healthData.CaloriesWeek = caloriesWeek
|
|
p.healthData.WorkoutMinutesWeek = workoutMinutesWeek
|
|
p.healthData.WorkoutCountWeek = workoutCountWeek
|
|
p.healthData.DistanceWeek = distanceWeek
|
|
|
|
if sleepCount > 0 {
|
|
p.healthData.SleepAvgWeek = sleepTotal / float64(sleepCount)
|
|
}
|
|
}
|
|
|
|
func (p *HealthPlugin) cleanOldData() {
|
|
cutoff := time.Now().AddDate(0, 0, -7).Format("2006-01-02")
|
|
toDelete := []string{}
|
|
for dateStr := range p.dailyAverages {
|
|
if dateStr < cutoff {
|
|
toDelete = append(toDelete, dateStr)
|
|
}
|
|
}
|
|
for _, dateStr := range toDelete {
|
|
delete(p.dailyAverages, dateStr)
|
|
}
|
|
}
|
|
|
|
func (p *HealthPlugin) cleanOldSleepRecords() {
|
|
cutoff := time.Now().AddDate(0, 0, -8)
|
|
toDelete := []string{}
|
|
for id, sr := range p.sleepRecords {
|
|
if sr.EndTime.Before(cutoff) {
|
|
toDelete = append(toDelete, id)
|
|
}
|
|
}
|
|
for _, id := range toDelete {
|
|
delete(p.sleepRecords, id)
|
|
}
|
|
}
|
|
|
|
func (p *HealthPlugin) cleanOldProcessedRecords() {
|
|
now := time.Now()
|
|
cutoffDate := now.AddDate(0, 0, -8).Format("2006-01-02")
|
|
|
|
for dataType, records := range p.processedRecords {
|
|
toDelete := []string{}
|
|
for key := range records {
|
|
parts := strings.SplitN(key, "|", 2)
|
|
if len(parts) == 2 && parts[0] < cutoffDate {
|
|
toDelete = append(toDelete, key)
|
|
}
|
|
}
|
|
for _, key := range toDelete {
|
|
delete(p.processedRecords[dataType], key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *HealthPlugin) persistData() {
|
|
config := p.storage.GetPluginConfig(p.Name())
|
|
if config.Settings == nil {
|
|
config.Settings = make(map[string]interface{})
|
|
}
|
|
|
|
dailyAvgCopy := make(map[string]*DailyAverage, len(p.dailyAverages))
|
|
for k, v := range p.dailyAverages {
|
|
dailyAvgCopy[k] = v
|
|
}
|
|
|
|
avgData := make(map[string]interface{})
|
|
for date, avg := range dailyAvgCopy {
|
|
avgData[date] = map[string]interface{}{
|
|
"steps": avg.Steps,
|
|
"calories": avg.Calories,
|
|
"sleep_hours": avg.SleepHours,
|
|
"hydration_ml": avg.HydrationML,
|
|
"workout_minutes": avg.WorkoutMinutes,
|
|
"workout_count": avg.WorkoutCount,
|
|
"distance_km": avg.DistanceKM,
|
|
}
|
|
}
|
|
config.Settings["daily_averages"] = avgData
|
|
|
|
config.Settings["current_data"] = map[string]interface{}{
|
|
"steps_today": p.healthData.StepsToday,
|
|
"steps_week": p.healthData.StepsWeek,
|
|
"calories_today": p.healthData.CaloriesToday,
|
|
"calories_week": p.healthData.CaloriesWeek,
|
|
"workout_minutes_today": p.healthData.WorkoutMinutesToday,
|
|
"workout_minutes_week": p.healthData.WorkoutMinutesWeek,
|
|
"workout_count_today": p.healthData.WorkoutCountToday,
|
|
"workout_count_week": p.healthData.WorkoutCountWeek,
|
|
"current_heart_rate": p.healthData.CurrentHeartRate,
|
|
"resting_heart_rate": p.healthData.RestingHeartRate,
|
|
"sleep_hours_last": p.healthData.SleepHoursLastNight,
|
|
"sleep_avg_week": p.healthData.SleepAvgWeek,
|
|
"distance_today": p.healthData.DistanceToday,
|
|
"distance_week": p.healthData.DistanceWeek,
|
|
"hydration_today": p.healthData.HydrationToday,
|
|
"last_updated": p.healthData.LastUpdated.Format(time.RFC3339),
|
|
"last_hr_update": p.healthData.LastHRUpdate.Format(time.RFC3339),
|
|
}
|
|
|
|
if p.healthData.LastWorkout != nil {
|
|
config.Settings["last_workout"] = map[string]interface{}{
|
|
"type": p.healthData.LastWorkout.Type,
|
|
"duration": p.healthData.LastWorkout.Duration,
|
|
"calories": p.healthData.LastWorkout.Calories,
|
|
"date": p.healthData.LastWorkout.Date.Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
sleepCopy := make(map[string]*SleepRecord, len(p.sleepRecords))
|
|
for k, v := range p.sleepRecords {
|
|
sleepCopy[k] = v
|
|
}
|
|
|
|
sleepData := make(map[string]interface{})
|
|
for id, sr := range sleepCopy {
|
|
sleepData[id] = map[string]interface{}{
|
|
"start_time": sr.StartTime.Format(time.RFC3339),
|
|
"end_time": sr.EndTime.Format(time.RFC3339),
|
|
"hours": sr.Hours,
|
|
}
|
|
}
|
|
config.Settings["sleep_records"] = sleepData
|
|
|
|
config.Settings["last_reset_date"] = p.lastResetDate
|
|
config.Settings["last_persist"] = time.Now().Format(time.RFC3339)
|
|
|
|
delete(config.Settings, "processed_records")
|
|
|
|
if err := p.storage.SetPluginConfig(p.Name(), config); err != nil {
|
|
log.Printf("Failed to persist health data: %v", err)
|
|
} else {
|
|
log.Printf("Health data persisted successfully")
|
|
}
|
|
|
|
p.lastPersist = time.Now()
|
|
}
|
|
|
|
func (p *HealthPlugin) loadPersistedData() {
|
|
config := p.storage.GetPluginConfig(p.Name())
|
|
if config.Settings == nil {
|
|
return
|
|
}
|
|
|
|
if avgData, ok := config.Settings["daily_averages"].(map[string]interface{}); ok {
|
|
for date, data := range avgData {
|
|
if dayData, ok := data.(map[string]interface{}); ok {
|
|
p.dailyAverages[date] = &DailyAverage{
|
|
Date: date,
|
|
Steps: int64(getFloat(dayData, "steps")),
|
|
Calories: getFloat(dayData, "calories"),
|
|
SleepHours: getFloat(dayData, "sleep_hours"),
|
|
HydrationML: getFloat(dayData, "hydration_ml"),
|
|
WorkoutMinutes: int64(getFloat(dayData, "workout_minutes")),
|
|
WorkoutCount: int(getFloat(dayData, "workout_count")),
|
|
DistanceKM: getFloat(dayData, "distance_km"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if sleepData, ok := config.Settings["sleep_records"].(map[string]interface{}); ok {
|
|
for id, data := range sleepData {
|
|
if srData, ok := data.(map[string]interface{}); ok {
|
|
sr := &SleepRecord{
|
|
ID: id,
|
|
Hours: getFloat(srData, "hours"),
|
|
}
|
|
if startStr, ok := srData["start_time"].(string); ok {
|
|
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
|
|
sr.StartTime = t
|
|
}
|
|
}
|
|
if endStr, ok := srData["end_time"].(string); ok {
|
|
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
|
|
sr.EndTime = t
|
|
}
|
|
}
|
|
p.sleepRecords[id] = sr
|
|
}
|
|
}
|
|
}
|
|
|
|
if resetDate, ok := config.Settings["last_reset_date"].(string); ok {
|
|
p.lastResetDate = resetDate
|
|
}
|
|
|
|
if currentData, ok := config.Settings["current_data"].(map[string]interface{}); ok {
|
|
p.healthData.CurrentHeartRate = int(getFloat(currentData, "current_heart_rate"))
|
|
p.healthData.RestingHeartRate = int(getFloat(currentData, "resting_heart_rate"))
|
|
|
|
if lastUpdated, ok := currentData["last_updated"].(string); ok {
|
|
if t, err := time.Parse(time.RFC3339, lastUpdated); err == nil {
|
|
p.healthData.LastUpdated = t
|
|
}
|
|
}
|
|
if lastHR, ok := currentData["last_hr_update"].(string); ok {
|
|
if t, err := time.Parse(time.RFC3339, lastHR); err == nil {
|
|
p.healthData.LastHRUpdate = t
|
|
}
|
|
}
|
|
}
|
|
|
|
if workoutData, ok := config.Settings["last_workout"].(map[string]interface{}); ok {
|
|
workout := &WorkoutInfo{
|
|
Type: getStringC(workoutData, "type"),
|
|
Duration: int64(getFloat(workoutData, "duration")),
|
|
Calories: getFloat(workoutData, "calories"),
|
|
}
|
|
if dateStr, ok := workoutData["date"].(string); ok {
|
|
if t, err := time.Parse(time.RFC3339, dateStr); err == nil {
|
|
workout.Date = t
|
|
}
|
|
}
|
|
p.healthData.LastWorkout = workout
|
|
}
|
|
|
|
p.processedRecords = make(map[string]map[string]struct{})
|
|
p.processedRecordsDate = time.Now().Format("2006-01-02")
|
|
|
|
p.deduplicateSleepRecords()
|
|
p.cleanOldSleepRecords()
|
|
p.cleanOldData()
|
|
|
|
p.recalculateTodayFromDailyAverages()
|
|
p.recalculateWeeklyData()
|
|
|
|
log.Printf("[DEBUG] After load: StepsToday=%d StepsWeek=%d WorkoutMinutesWeek=%d",
|
|
p.healthData.StepsToday, p.healthData.StepsWeek, p.healthData.WorkoutMinutesWeek)
|
|
log.Printf("[DEBUG] dailyAverages keys: %v", func() []string {
|
|
keys := make([]string, 0, len(p.dailyAverages))
|
|
for k := range p.dailyAverages {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}())
|
|
|
|
now := time.Now()
|
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
p.recalculateSleep(startOfDay)
|
|
|
|
log.Printf("Health data loaded: steps_today=%d, steps_week=%d, hr=%d, sleep=%.1fh",
|
|
p.healthData.StepsToday, p.healthData.StepsWeek, p.healthData.CurrentHeartRate, p.healthData.SleepHoursLastNight)
|
|
}
|
|
|
|
func (p *HealthPlugin) recalculateTodayFromDailyAverages() {
|
|
today := time.Now().Format("2006-01-02")
|
|
|
|
if avg, exists := p.dailyAverages[today]; exists {
|
|
p.healthData.StepsToday = avg.Steps
|
|
p.healthData.CaloriesToday = avg.Calories
|
|
p.healthData.WorkoutMinutesToday = avg.WorkoutMinutes
|
|
p.healthData.WorkoutCountToday = avg.WorkoutCount
|
|
p.healthData.HydrationToday = avg.HydrationML
|
|
p.healthData.DistanceToday = avg.DistanceKM
|
|
p.healthData.SleepHoursLastNight = avg.SleepHours
|
|
} else {
|
|
p.healthData.StepsToday = 0
|
|
p.healthData.CaloriesToday = 0
|
|
p.healthData.WorkoutMinutesToday = 0
|
|
p.healthData.WorkoutCountToday = 0
|
|
p.healthData.HydrationToday = 0
|
|
p.healthData.DistanceToday = 0
|
|
}
|
|
|
|
p.lastResetDate = today
|
|
}
|
|
|
|
func getStringC(m map[string]interface{}, key string) string {
|
|
if v, ok := m[key].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func getFloat(m map[string]interface{}, key string) float64 {
|
|
if v, ok := m[key].(float64); ok {
|
|
return v
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (p *HealthPlugin) Render(ctx context.Context) (string, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
default:
|
|
}
|
|
|
|
config := p.storage.GetPluginConfig(p.Name())
|
|
settings := config.Settings
|
|
|
|
sectionTitle := p.getConfigValue(settings, "ui.sectionTitle", "Health")
|
|
showSteps := p.getConfigBool(settings, "ui.showSteps", true)
|
|
showCalories := p.getConfigBool(settings, "ui.showCalories", true)
|
|
showWorkouts := p.getConfigBool(settings, "ui.showWorkouts", true)
|
|
showSleep := p.getConfigBool(settings, "ui.showSleep", true)
|
|
showHeartRate := p.getConfigBool(settings, "ui.showHeartRate", true)
|
|
showHydration := p.getConfigBool(settings, "ui.showHydration", true)
|
|
|
|
p.mutex.RLock()
|
|
data := p.healthData
|
|
p.mutex.RUnlock()
|
|
|
|
if data == nil {
|
|
data = &HealthData{}
|
|
}
|
|
|
|
if p.username == "" {
|
|
return p.renderNoConfig(sectionTitle), nil
|
|
}
|
|
|
|
lastUpdatedText := "never"
|
|
if !data.LastUpdated.IsZero() {
|
|
lastUpdatedText = p.formatTimeAgo(data.LastUpdated)
|
|
}
|
|
|
|
hrUpdateText := "no data"
|
|
if !data.LastHRUpdate.IsZero() {
|
|
hrUpdateText = p.formatTimeAgo(data.LastHRUpdate)
|
|
}
|
|
|
|
tmpl := `
|
|
<section class="health-section section plugin" data-w="2" data-plugin="health">
|
|
<header class="plugin-header">
|
|
<h3 class="plugin-title">{{.SectionTitle}}</h3>
|
|
<div class="health-updated">
|
|
<span class="update-time" data-health-updated>{{.LastUpdatedText}}</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="plugin__inner">
|
|
<div class="health-grid">
|
|
{{if .ShowSteps}}
|
|
<div class="health-card health-card--steps">
|
|
<div class="health-card-icon">
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M13.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zM9.8 8.9L7 23h2.1l1.8-8 2.1 2v6h2v-7.5l-2.1-2 .6-3C14.8 12 16.8 13 19 13v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1-.3 0-.5.1-.8.1L6 8.3V13h2V9.6l1.8-.7"/>
|
|
</svg>
|
|
</div>
|
|
<div class="health-card-content">
|
|
<div class="health-card-value" data-metric="steps-today">{{.StepsToday}}</div>
|
|
<div class="health-card-label">Steps Today</div>
|
|
<div class="health-card-sub">{{.StepsWeek}} this week</div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if .ShowCalories}}
|
|
<div class="health-card health-card--calories">
|
|
<div class="health-card-icon">
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="health-card-content">
|
|
<div class="health-card-value" data-metric="calories-today">{{.CaloriesToday}}</div>
|
|
<div class="health-card-label">Calories</div>
|
|
<div class="health-card-sub">{{.CaloriesWeek}} this week</div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if .ShowWorkouts}}
|
|
<div class="health-card health-card--workouts">
|
|
<div class="health-card-icon">
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M20.57 14.86L22 13.43 20.57 12 17 15.57 8.43 7 12 3.43 10.57 2 9.14 3.43 7.71 2 5.57 4.14 4.14 2.71 2.71 4.14l1.43 1.43L2 7.71l1.43 1.43L2 10.57 3.43 12 7 8.43 15.57 17 12 20.57 13.43 22l1.43-1.43L16.29 22l2.14-2.14 1.43 1.43 1.43-1.43-1.43-1.43L22 16.29z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="health-card-content">
|
|
<div class="health-card-value" data-metric="workout-minutes">{{.WorkoutMinutesToday}}</div>
|
|
<div class="health-card-label">Workout Today</div>
|
|
<div class="health-card-sub">{{.WorkoutMinutesWeek}} this week</div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if .ShowSleep}}
|
|
<div class="health-card health-card--sleep">
|
|
<div class="health-card-icon">
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M9 2c-1.05 0-2.05.16-3 .46 4.06 1.27 7 5.06 7 9.54 0 4.48-2.94 8.27-7 9.54.95.3 1.95.46 3 .46 5.52 0 10-4.48 10-10S14.52 2 9 2z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="health-card-content">
|
|
<div class="health-card-value" data-metric="sleep-hours">{{.SleepLastNight}}</div>
|
|
<div class="health-card-label">Sleep</div>
|
|
<div class="health-card-sub">{{.SleepAvg}} avg</div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if .ShowHeartRate}}
|
|
<div class="health-card health-card--heart">
|
|
<div class="health-card-icon">
|
|
<svg class="health-heartbeat" data-heartbeat viewBox="0 0 24 24" fill="currentColor" data-bpm="{{.CurrentBPM}}">
|
|
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="health-card-content">
|
|
<div class="health-card-value" data-metric="heart-rate">{{.CurrentHeartRate}}</div>
|
|
<div class="health-card-label">Current HR</div>
|
|
<div class="health-card-sub" data-metric="hr-update">{{.HRUpdateText}}</div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if .ShowHydration}}
|
|
<div class="health-card health-card--hydration">
|
|
<div class="health-card-icon">
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12 2c-5.33 4.55-8 8.48-8 11.8 0 4.98 3.8 8.2 8 8.2s8-3.22 8-8.2c0-3.32-2.67-7.25-8-11.8zm0 18c-3.35 0-6-2.57-6-6.2 0-2.34 1.95-5.44 6-9.14 4.05 3.7 6 6.79 6 9.14 0 3.63-2.65 6.2-6 6.2z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="health-card-content">
|
|
<div class="health-card-value" data-metric="hydration">{{.Hydration}}</div>
|
|
<div class="health-card-label">Hydration</div>
|
|
<div class="health-card-sub">ml today</div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</section>`
|
|
|
|
stepsToday := formatNumberCommas(data.StepsToday)
|
|
stepsWeek := formatNumberCommas(data.StepsWeek)
|
|
caloriesToday := fmt.Sprintf("%.0f", data.CaloriesToday)
|
|
caloriesWeek := fmt.Sprintf("%.0f", data.CaloriesWeek)
|
|
workoutMinutesToday := fmt.Sprintf("%d min", data.WorkoutMinutesToday)
|
|
workoutMinutesWeek := fmt.Sprintf("%d min", data.WorkoutMinutesWeek)
|
|
sleepLastNight := fmt.Sprintf("%.1fh", data.SleepHoursLastNight)
|
|
sleepAvg := fmt.Sprintf("%.1fh", data.SleepAvgWeek)
|
|
currentHeartRate := fmt.Sprintf("%d bpm", data.CurrentHeartRate)
|
|
hydration := fmt.Sprintf("%.0f", data.HydrationToday)
|
|
|
|
var lastWorkoutData map[string]interface{}
|
|
if data.LastWorkout != nil {
|
|
lastWorkoutData = map[string]interface{}{
|
|
"Type": data.LastWorkout.Type,
|
|
"Duration": data.LastWorkout.Duration,
|
|
"Calories": fmt.Sprintf("%.0f", data.LastWorkout.Calories),
|
|
"DateStr": data.LastWorkout.Date.Format("Mon, Jan 2"),
|
|
}
|
|
}
|
|
|
|
templateData := struct {
|
|
SectionTitle string
|
|
ShowSteps bool
|
|
ShowCalories bool
|
|
ShowWorkouts bool
|
|
ShowSleep bool
|
|
ShowHeartRate bool
|
|
ShowHydration bool
|
|
StepsToday string
|
|
StepsWeek string
|
|
CaloriesToday string
|
|
CaloriesWeek string
|
|
WorkoutMinutesToday string
|
|
WorkoutMinutesWeek string
|
|
WorkoutCount int
|
|
SleepLastNight string
|
|
SleepAvg string
|
|
CurrentHeartRate string
|
|
CurrentBPM int
|
|
HRUpdateText string
|
|
Hydration string
|
|
LastWorkout map[string]interface{}
|
|
LastUpdatedText string
|
|
}{
|
|
SectionTitle: sectionTitle,
|
|
ShowSteps: showSteps,
|
|
ShowCalories: showCalories,
|
|
ShowWorkouts: showWorkouts,
|
|
ShowSleep: showSleep,
|
|
ShowHeartRate: showHeartRate,
|
|
ShowHydration: showHydration,
|
|
StepsToday: stepsToday,
|
|
StepsWeek: stepsWeek,
|
|
CaloriesToday: caloriesToday,
|
|
CaloriesWeek: caloriesWeek,
|
|
WorkoutMinutesToday: workoutMinutesToday,
|
|
WorkoutMinutesWeek: workoutMinutesWeek,
|
|
WorkoutCount: data.WorkoutCountWeek,
|
|
SleepLastNight: sleepLastNight,
|
|
SleepAvg: sleepAvg,
|
|
CurrentHeartRate: currentHeartRate,
|
|
CurrentBPM: data.CurrentHeartRate,
|
|
HRUpdateText: hrUpdateText,
|
|
Hydration: hydration,
|
|
LastWorkout: lastWorkoutData,
|
|
LastUpdatedText: lastUpdatedText,
|
|
}
|
|
|
|
t, err := template.New("health").Parse(tmpl)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var buf strings.Builder
|
|
if err := t.Execute(&buf, templateData); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return buf.String(), nil
|
|
}
|
|
|
|
func (p *HealthPlugin) renderNoConfig(sectionTitle string) string {
|
|
return fmt.Sprintf(`<section class="health-section section plugin" data-w="1">
|
|
<header class="plugin-header">
|
|
<h3 class="plugin-title">%s</h3>
|
|
</header>
|
|
<div class="plugin__inner">
|
|
<div class="health-no-config">
|
|
<svg class="no-config-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/>
|
|
<path d="M12 8v4M12 16h.01"/>
|
|
</svg>
|
|
<p class="no-config-text">Health sync not configured</p>
|
|
<p class="no-config-hint">Set credentials in config to receive health data</p>
|
|
</div>
|
|
</div>
|
|
</section>`, sectionTitle)
|
|
}
|
|
|
|
func (p *HealthPlugin) UpdateData(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (p *HealthPlugin) GetSettings() map[string]interface{} {
|
|
config := p.storage.GetPluginConfig(p.Name())
|
|
return config.Settings
|
|
}
|
|
|
|
func (p *HealthPlugin) SetSettings(settings map[string]interface{}) error {
|
|
config := p.storage.GetPluginConfig(p.Name())
|
|
config.Settings = settings
|
|
|
|
if err := p.storage.SetPluginConfig(p.Name(), config); err != nil {
|
|
return err
|
|
}
|
|
|
|
p.hub.Broadcast("plugin_update", map[string]interface{}{
|
|
"plugin": p.Name(),
|
|
"action": "settings_changed",
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *HealthPlugin) RenderText(ctx context.Context) (string, error) {
|
|
p.mutex.RLock()
|
|
data := p.healthData
|
|
p.mutex.RUnlock()
|
|
|
|
if data == nil || data.StepsToday == 0 {
|
|
return "Health: No data available", nil
|
|
}
|
|
|
|
return fmt.Sprintf("Health: %s steps, %.0f cal, %.1fh sleep, %d bpm",
|
|
formatNumberCommas(data.StepsToday),
|
|
data.CaloriesToday,
|
|
data.SleepHoursLastNight,
|
|
data.CurrentHeartRate), nil
|
|
}
|
|
|
|
func (p *HealthPlugin) formatTimeAgo(t time.Time) string {
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
|
case d < 24*time.Hour:
|
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
|
default:
|
|
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
|
}
|
|
}
|
|
|
|
func (p *HealthPlugin) getConfigValue(settings map[string]interface{}, key string, defaultValue string) string {
|
|
keys := strings.Split(key, ".")
|
|
current := settings
|
|
for i, k := range keys {
|
|
if i == len(keys)-1 {
|
|
if value, ok := current[k].(string); ok {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
if next, ok := current[k].(map[string]interface{}); ok {
|
|
current = next
|
|
} else {
|
|
return defaultValue
|
|
}
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func (p *HealthPlugin) getCanonicalSleepID(record map[string]interface{}) string {
|
|
startStr, _ := record["startTime"].(string)
|
|
endStr, _ := record["endTime"].(string)
|
|
if startStr == "" || endStr == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("sleep|%s|%s", startStr, endStr)
|
|
}
|
|
|
|
func (p *HealthPlugin) getConfigBool(settings map[string]interface{}, key string, defaultValue bool) bool {
|
|
keys := strings.Split(key, ".")
|
|
current := settings
|
|
for i, k := range keys {
|
|
if i == len(keys)-1 {
|
|
if value, ok := current[k].(bool); ok {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
if next, ok := current[k].(map[string]interface{}); ok {
|
|
current = next
|
|
} else {
|
|
return defaultValue
|
|
}
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func (p *HealthPlugin) GetCurrentHeartRate() int {
|
|
p.mutex.RLock()
|
|
defer p.mutex.RUnlock()
|
|
return p.healthData.CurrentHeartRate
|
|
}
|
|
|
|
func (p *HealthPlugin) GetHealthData() *HealthData {
|
|
p.mutex.RLock()
|
|
defer p.mutex.RUnlock()
|
|
dataCopy := *p.healthData
|
|
return &dataCopy
|
|
}
|
|
|
|
func formatExerciseType(t string) string {
|
|
types := map[string]string{
|
|
"EXERCISE_TYPE_RUNNING": "Running",
|
|
"EXERCISE_TYPE_WALKING": "Walking",
|
|
"EXERCISE_TYPE_BIKING": "Cycling",
|
|
"EXERCISE_TYPE_SWIMMING": "Swimming",
|
|
"EXERCISE_TYPE_STRENGTH_TRAINING": "Strength",
|
|
"EXERCISE_TYPE_YOGA": "Yoga",
|
|
"EXERCISE_TYPE_HIKING": "Hiking",
|
|
"EXERCISE_TYPE_OTHER_WORKOUT": "Workout",
|
|
}
|
|
if formatted, ok := types[t]; ok {
|
|
return formatted
|
|
}
|
|
t = strings.TrimPrefix(t, "EXERCISE_TYPE_")
|
|
t = strings.ReplaceAll(t, "_", " ")
|
|
words := strings.Fields(strings.ToLower(t))
|
|
for i, word := range words {
|
|
if len(word) > 0 {
|
|
words[i] = strings.ToUpper(string(word[0])) + word[1:]
|
|
}
|
|
}
|
|
return strings.Join(words, " ")
|
|
}
|
|
|
|
func formatNumberCommas(n int64) string {
|
|
str := fmt.Sprintf("%d", n)
|
|
if len(str) <= 3 {
|
|
return str
|
|
}
|
|
var result []rune
|
|
for i, r := range str {
|
|
if i > 0 && (len(str)-i)%3 == 0 {
|
|
result = append(result, ',')
|
|
}
|
|
result = append(result, r)
|
|
}
|
|
return string(result)
|
|
}
|
|
|
|
func (p *HealthPlugin) PersistNow() {
|
|
p.mutex.Lock()
|
|
defer p.mutex.Unlock()
|
|
p.persistData()
|
|
}
|
|
|
|
func (p *HealthPlugin) GetMetrics() map[string]interface{} {
|
|
p.mutex.RLock()
|
|
defer p.mutex.RUnlock()
|
|
|
|
metrics := map[string]interface{}{
|
|
"steps_today": p.healthData.StepsToday,
|
|
"steps_week": p.healthData.StepsWeek,
|
|
"calories_today": p.healthData.CaloriesToday,
|
|
"calories_week": p.healthData.CaloriesWeek,
|
|
"workout_minutes_today": p.healthData.WorkoutMinutesToday,
|
|
"workout_minutes_week": p.healthData.WorkoutMinutesWeek,
|
|
"current_heart_rate": p.healthData.CurrentHeartRate,
|
|
"resting_heart_rate": p.healthData.RestingHeartRate,
|
|
"sleep_hours_last": p.healthData.SleepHoursLastNight,
|
|
"sleep_avg_week": p.healthData.SleepAvgWeek,
|
|
"distance_today_km": p.healthData.DistanceToday,
|
|
"distance_week_km": p.healthData.DistanceWeek,
|
|
"hydration_today_ml": p.healthData.HydrationToday,
|
|
}
|
|
|
|
return metrics
|
|
}
|
|
|
|
func dayStart(t time.Time) time.Time {
|
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
|
}
|
|
|
|
func rollingWeekStart(t time.Time) time.Time {
|
|
return dayStart(t).AddDate(0, 0, -6)
|
|
}
|
|
|
|
func (p *HealthPlugin) ensureDailyAverage(dateKey string) {
|
|
if _, ok := p.dailyAverages[dateKey]; !ok {
|
|
p.dailyAverages[dateKey] = &DailyAverage{Date: dateKey}
|
|
}
|
|
}
|
|
|
|
func (p *HealthPlugin) extractStepCount(record map[string]interface{}) int64 {
|
|
for _, key := range []string{"count", "value", "steps", "stepCount"} {
|
|
if v := p.extractInt(record, key); v > 0 {
|
|
return v
|
|
}
|
|
}
|
|
|
|
if samples, ok := record["samples"].([]interface{}); ok && len(samples) > 0 {
|
|
var total int64
|
|
for _, s := range samples {
|
|
if sample, ok := s.(map[string]interface{}); ok {
|
|
for _, key := range []string{"count", "value", "steps"} {
|
|
if v := p.extractInt(sample, key); v > 0 {
|
|
total += v
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if total > 0 {
|
|
return total
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|