diff --git a/api/activities.go b/api/activities.go index 592c932..b37f343 100644 --- a/api/activities.go +++ b/api/activities.go @@ -1,8 +1,11 @@ package api // Copied from https://github.com/strava/go.strava/blob/99ebe972ba16ef3e1b1e5f62003dae3ac06f3adb/activities.go -// so that we were able to add WorkoutType into ActivitySummary struct. -// workout_type is documented attribute in Strava API v3, but for some reason it is missing from go.strava +// so that we were able to add SportType and WorkoutType into ActivitySummary struct. +// sport_type and workout_type are documented attributes in Strava API v3, +// but for some reason it is missing from go.strava +// sport_type is string value that can be same as type or something newer. Known exceptions +// - sport_type: TrailRun has type: Run // workout_type is integer value that needs separate transformation into string. import ( "fmt" @@ -78,3 +81,8 @@ func (as *ActivitySummary) WorkoutType() string { } return fmt.Sprintf("Unknown (%d)", as.WorkoutTypeId) } + +func NewActivitiesService(client *Client) *strava.ActivitiesService { + stravaClient := strava.NewClient(client.token, client.httpClient) + return strava.NewActivitiesService(stravaClient) +} diff --git a/api/api.go b/api/api.go index f2597f9..d1a3838 100644 --- a/api/api.go +++ b/api/api.go @@ -9,6 +9,8 @@ import ( "os" "path/filepath" "time" + + strava "github.com/strava/go.strava" ) type Config struct { @@ -83,7 +85,25 @@ func (cfg *Config) Refresh() (*Config, bool, error) { return &tokens, true, nil } -func ReadJSONs(fnames []string) ([]ActivitySummary, error) { +func ReadBestEffortJSONs(fnames []string) ([]strava.BestEffort, error) { + efforts := []strava.BestEffort{} + for _, fname := range fnames { + body, err := os.ReadFile(filepath.Clean(fname)) + if err != nil { + return efforts, err + } + activity := strava.ActivityDetailed{} + if err = json.Unmarshal(body, &activity); err != nil { + return efforts, err + } + for _, be := range activity.BestEfforts { + efforts = append(efforts, *be) + } + } + return efforts, nil +} + +func ReadSummaryJSONs(fnames []string) ([]ActivitySummary, error) { ids := map[int64]string{} activities := []ActivitySummary{} for _, fname := range fnames { diff --git a/cmd/best.go b/cmd/best.go new file mode 100644 index 0000000..1c4a05e --- /dev/null +++ b/cmd/best.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + + _ "github.com/mattn/go-sqlite3" + "github.com/spf13/cobra" + + "github.com/jylitalo/mystats/pkg/stats" +) + +// topCmd turns sqlite db into table or csv by week/month/... +func bestCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "best", + Short: "Best Run Efforts based on Strava", + RunE: func(cmd *cobra.Command, args []string) error { + flags := cmd.Flags() + format, _ := flags.GetString("format") + limit, _ := flags.GetInt("limit") + distance, _ := flags.GetString("distance") + update, _ := flags.GetBool("update") + formatFn := map[string]func(headers []string, results [][]string){ + "csv": printTopCSV, + "table": printTopTable, + } + if _, ok := formatFn[format]; !ok { + return fmt.Errorf("unknown format: %s", format) + } + db, err := makeDB(update) + if err != nil { + return err + } + defer db.Close() + headers, results, err := stats.Best(db, distance, limit) + if err != nil { + return err + } + formatFn[format](headers, results) + return nil + }, + } + cmd.Flags().String("format", "csv", "output format (csv, table)") + cmd.Flags().String("distance", "Marathon", "Best Efforts distance") + cmd.Flags().Int("limit", 10, "number of entries") + cmd.Flags().Bool("update", true, "update database") + return cmd +} diff --git a/cmd/cmd.go b/cmd/cmd.go index fe6c0ab..03251c5 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -16,7 +16,7 @@ func Execute() error { } rootCmd.AddCommand( configureCmd(), fetchCmd(), makeCmd(), - listCmd(types), plotCmd(types), statsCmd(types), topCmd(types), + bestCmd(), listCmd(types), plotCmd(types), statsCmd(types), topCmd(types), serverCmd(types), ) return rootCmd.Execute() diff --git a/cmd/fetch.go b/cmd/fetch.go index 7a401f8..d63b1b5 100644 --- a/cmd/fetch.go +++ b/cmd/fetch.go @@ -2,15 +2,21 @@ package cmd import ( "encoding/json" + "errors" "fmt" + "log/slog" "os" "path/filepath" + "slices" + "strconv" + "strings" "time" "github.com/spf13/cobra" "github.com/jylitalo/mystats/api" "github.com/jylitalo/mystats/config" + "github.com/jylitalo/mystats/storage" ) // fetchCmd fetches activity data from Strava @@ -19,25 +25,117 @@ func fetchCmd() *cobra.Command { Use: "fetch", Short: "Fetch activity data from Strava to JSON files", RunE: func(cmd *cobra.Command, args []string) error { - return fetch() + flags := cmd.Flags() + be, _ := flags.GetBool("best_efforts") + return fetch(be) }, } + cmd.Flags().Bool("best_efforts", false, "Fetch activities best efforts") return cmd } -func fetch() error { +func fetch(best_efforts bool) error { after, prior, err := getEpoch() if err != nil { return err } - call, err := callListActivities(after) + client, err := getClient() + if err != nil { + return err + } + call, err := callListActivities(client, after) if err != nil { return err } _, err = saveActivities(call, prior) + if err == nil && best_efforts { + return fetchBestEfforts(client) + } + return err +} + +func getClient() (*api.Client, error) { + cfg, err := config.Get(true) + if err != nil { + return nil, err + } + strava := cfg.Strava + api.ClientId = strava.ClientID + api.ClientSecret = strava.ClientSecret + client := api.NewClient(strava.AccessToken) + return client, err +} + +func fetchBestEfforts(client *api.Client) error { + db := &storage.Sqlite3{} + if err := db.Open(); err != nil { + return err + } + rows, err := db.QuerySummary( + []string{"StravaID"}, + storage.SummaryConditions{Types: []string{"Run"}}, + &storage.Order{OrderBy: []string{"StravaID desc"}}, + ) + if err != nil { + return err + } + defer rows.Close() + stravaIDs := []int64{} + for rows.Next() { + var stravaID int64 + err = rows.Scan(&stravaID) + if err != nil { + return err + } + stravaIDs = append(stravaIDs, stravaID) + } + if len(stravaIDs) < 1 { + return errors.New("no stravaIDs found from database") + } + fetched := 0 + _ = os.Mkdir("activities", 0750) + actFiles, err := activitiesFiles() + if err != nil { + return err + } + alreadyFetched := []int64{} + for _, actFile := range actFiles { + intStr := strings.Split(strings.Split(actFile, "_")[1], ".")[0] + i, _ := strconv.Atoi(intStr) + alreadyFetched = append(alreadyFetched, int64(i)) + } + service := api.NewActivitiesService(client) + for idx, stravaID := range stravaIDs { + if slices.Contains[[]int64, int64](alreadyFetched, stravaID) { + continue + } + call := service.Get(stravaID) + activity, err := call.Do() + if err != nil { + return err + } + j, err := json.Marshal(activity) + if err != nil { + return err + } + fmt.Printf("%d => activities/activity_%d.json ...\n", stravaID, stravaID) + if err = os.WriteFile(fmt.Sprintf("activities/activity_%d.json", stravaID), j, 0600); err != nil { + return err + } + fetched++ + if fetched >= 100 { + slog.Info("Already fetched 100 activities", "left", len(stravaIDs)-idx) + return nil + } + } + slog.Info("Activity details fetched", "fetched", fetched) return err } +func activitiesFiles() ([]string, error) { + return filepath.Glob("activities/activity_*.json") +} + func pageFiles() ([]string, error) { return filepath.Glob("pages/page*.json") } @@ -56,7 +154,7 @@ func getEpoch() (time.Time, int, error) { } } offset := len(fnames) - activities, err := api.ReadJSONs(fnames) + activities, err := api.ReadSummaryJSONs(fnames) if err != nil { return epoch, offset, err } @@ -68,15 +166,7 @@ func getEpoch() (time.Time, int, error) { return epoch, offset, nil } -func callListActivities(after time.Time) (*api.CurrentAthleteListActivitiesCall, error) { - cfg, err := config.Get(true) - if err != nil { - return nil, err - } - strava := cfg.Strava - api.ClientId = strava.ClientID - api.ClientSecret = strava.ClientSecret - client := api.NewClient(strava.AccessToken) +func callListActivities(client *api.Client, after time.Time) (*api.CurrentAthleteListActivitiesCall, error) { current := api.NewCurrentAthleteService(client) call := current.ListActivities() return call.After(int(after.Unix())), nil diff --git a/cmd/list.go b/cmd/list.go index fc7db42..06eb721 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -17,6 +17,7 @@ func listCmd(types []string) *cobra.Command { Short: "List races or long runs", RunE: func(cmd *cobra.Command, args []string) error { flags := cmd.Flags() + limit, _ := flags.GetInt("limit") types, _ := flags.GetStringSlice("type") update, _ := flags.GetBool("update") workouts, _ := flags.GetStringSlice("workout") @@ -26,7 +27,7 @@ func listCmd(types []string) *cobra.Command { } defer db.Close() table := tablewriter.NewWriter(os.Stdout) - headers, results, err := stats.List(db, types, workouts, nil) + headers, results, err := stats.List(db, types, workouts, nil, limit) if err != nil { return err } @@ -36,6 +37,7 @@ func listCmd(types []string) *cobra.Command { return nil }, } + cmd.Flags().Int("limit", 100, "number of activities") cmd.Flags().StringSlice("type", types, "sport types (run, trail run, ...)") cmd.Flags().Bool("update", true, "update database") cmd.Flags().StringSlice("workout", []string{}, "workout type") diff --git a/cmd/make.go b/cmd/make.go index c8d6ad1..b4bd237 100644 --- a/cmd/make.go +++ b/cmd/make.go @@ -15,10 +15,12 @@ import ( ) type Storage interface { - Query(fields []string, cond storage.Conditions, order *storage.Order) (*sql.Rows, error) - QueryTypes(cond storage.Conditions) ([]string, error) - QueryWorkoutTypes(cond storage.Conditions) ([]string, error) - QueryYears(cond storage.Conditions) ([]int, error) + QueryBestEffort(fields []string, name string, order *storage.Order) (*sql.Rows, error) + QueryBestEffortDistances() ([]string, error) + QuerySummary(fields []string, cond storage.SummaryConditions, order *storage.Order) (*sql.Rows, error) + QueryTypes(cond storage.SummaryConditions) ([]string, error) + QueryWorkoutTypes(cond storage.SummaryConditions) ([]string, error) + QueryYears(cond storage.SummaryConditions) ([]int, error) Close() error } @@ -60,7 +62,7 @@ func skipDB(db *storage.Sqlite3, fnames []string) bool { func makeDB(update bool) (Storage, error) { slog.Info("Fetch activities from Strava") if update { - if err := fetch(); err != nil { + if err := fetch(false); err != nil { return nil, err } } @@ -75,15 +77,15 @@ func makeDB(update bool) (Storage, error) { return db, nil } slog.Info("Making database") - activities, err := api.ReadJSONs(fnames) + activities, err := api.ReadSummaryJSONs(fnames) if err != nil { return nil, err } - dbActivities := []storage.Record{} + dbActivities := []storage.SummaryRecord{} for _, activity := range activities { t := activity.StartDateLocal year, week := t.ISOWeek() - dbActivities = append(dbActivities, storage.Record{ + dbActivities = append(dbActivities, storage.SummaryRecord{ StravaID: activity.Id, Year: year, Month: int(t.Month()), @@ -98,9 +100,28 @@ func makeDB(update bool) (Storage, error) { MovingTime: activity.MovingTime, }) } + fnames, err = activitiesFiles() + if err != nil { + return nil, err + } + detailed, err := api.ReadBestEffortJSONs(fnames) + if err != nil { + return nil, err + } + dbEfforts := []storage.BestEffortRecord{} + for _, activity := range detailed { + dbEfforts = append(dbEfforts, storage.BestEffortRecord{ + StravaID: activity.EffortSummary.Activity.Id, + Name: activity.EffortSummary.Name, + MovingTime: activity.EffortSummary.MovingTime, + ElapsedTime: activity.EffortSummary.ElapsedTime, + Distance: int(activity.Distance), + }) + } errR := db.Remove() errO := db.Open() errC := db.Create() - errI := db.Insert(dbActivities) - return db, errors.Join(errR, errO, errC, errI) + errI := db.InsertSummary(dbActivities) + errBE := db.InsertBestEffort(dbEfforts) + return db, errors.Join(errR, errO, errC, errI, errBE) } diff --git a/pkg/plot/plot.go b/pkg/plot/plot.go index 74dcc43..fb83b9c 100644 --- a/pkg/plot/plot.go +++ b/pkg/plot/plot.go @@ -16,13 +16,15 @@ import ( ) type Storage interface { - Query(fields []string, cond storage.Conditions, order *storage.Order) (*sql.Rows, error) - QueryYears(cond storage.Conditions) ([]int, error) + QuerySummary(fields []string, cond storage.SummaryConditions, order *storage.Order) (*sql.Rows, error) + QueryYears(cond storage.SummaryConditions) ([]int, error) } func Plot(db Storage, types, workoutTypes []string, measurement string, month, day int, years []int, filename string) error { tz, _ := time.LoadLocation("Europe/Helsinki") - cond := storage.Conditions{Types: types, WorkoutTypes: workoutTypes, Month: month, Day: day, Years: years} + cond := storage.SummaryConditions{ + Types: types, WorkoutTypes: workoutTypes, Month: month, Day: day, Years: years, + } years, err := db.QueryYears(cond) if err != nil { return err @@ -36,7 +38,7 @@ func Plot(db Storage, types, workoutTypes []string, measurement string, month, d totals[year] = 0 } o := []string{"year", "month", "day"} - rows, err := db.Query( + rows, err := db.QuerySummary( []string{"year", "month", "day", "sum(" + measurement + ")"}, cond, &storage.Order{GroupBy: o, OrderBy: o}, ) diff --git a/pkg/stats/best.go b/pkg/stats/best.go new file mode 100644 index 0000000..9b29ace --- /dev/null +++ b/pkg/stats/best.go @@ -0,0 +1,40 @@ +package stats + +import ( + "fmt" + + "github.com/jylitalo/mystats/storage" +) + +func Best(db Storage, distance string, limit int) ([]string, [][]string, error) { + o := []string{"besteffort.movingtime", "year", "month", "day"} + rows, err := db.QueryBestEffort( + []string{ + "year", "month", "day", "summary.name", "summary.distance", + "besteffort.movingtime", "besteffort.elapsedtime", + "summary.StravaID", + }, + distance, &storage.Order{OrderBy: o, Limit: limit}, + ) + if err != nil { + return nil, nil, fmt.Errorf("query caused: %w", err) + } + defer rows.Close() + results := [][]string{} + for rows.Next() { + var year, month, day, movingTime, elapsedTime, stravaID int + var distance float64 + var name string + err = rows.Scan(&year, &month, &day, &name, &distance, &movingTime, &elapsedTime, &stravaID) + if err != nil { + return nil, nil, err + } + results = append(results, []string{ + fmt.Sprintf("%2d.%2d.%d", day, month, year), name, + fmt.Sprintf("%2d:%02d:%02d", elapsedTime/3600, elapsedTime/60%60, elapsedTime%60), + fmt.Sprintf("%.2f", distance/1000), + fmt.Sprintf("https://strava.com/activities/%d", stravaID), + }) + } + return []string{"Date", distance, "Time", "Total (km)", "Link"}, results, nil +} diff --git a/pkg/stats/list.go b/pkg/stats/list.go index 2bb74e1..57e49aa 100644 --- a/pkg/stats/list.go +++ b/pkg/stats/list.go @@ -7,12 +7,12 @@ import ( "github.com/jylitalo/mystats/storage" ) -func List(db Storage, types, workouts []string, years []int) ([]string, [][]string, error) { +func List(db Storage, types, workouts []string, years []int, limit int) ([]string, [][]string, error) { o := []string{"year", "month", "day"} - rows, err := db.Query( + rows, err := db.QuerySummary( []string{"year", "month", "day", "name", "distance", "elevation", "movingtime", "type", "workouttype", "stravaid"}, - storage.Conditions{WorkoutTypes: workouts, Types: types, Years: years}, - &storage.Order{GroupBy: o, OrderBy: o}, + storage.SummaryConditions{WorkoutTypes: workouts, Types: types, Years: years}, + &storage.Order{GroupBy: o, OrderBy: o, Limit: limit}, ) if err != nil { return nil, nil, fmt.Errorf("query caused: %w", err) diff --git a/pkg/stats/stats.go b/pkg/stats/stats.go index 81a1844..0d5f882 100644 --- a/pkg/stats/stats.go +++ b/pkg/stats/stats.go @@ -9,8 +9,9 @@ import ( ) type Storage interface { - Query(fields []string, cond storage.Conditions, order *storage.Order) (*sql.Rows, error) - QueryYears(cond storage.Conditions) ([]int, error) + QueryBestEffort(fields []string, name string, order *storage.Order) (*sql.Rows, error) + QuerySummary(fields []string, cond storage.SummaryConditions, order *storage.Order) (*sql.Rows, error) + QueryYears(cond storage.SummaryConditions) ([]int, error) } func Stats(db Storage, measurement, period string, types, workoutTypes []string, month, day int, years []int) ([]int, [][]string, []string, error) { @@ -21,7 +22,9 @@ func Stats(db Storage, measurement, period string, types, workoutTypes []string, if _, ok := inYear[period]; !ok { return nil, nil, nil, fmt.Errorf("unknown period: %s", period) } - cond := storage.Conditions{Types: types, WorkoutTypes: workoutTypes, Month: month, Day: day, Years: years} + cond := storage.SummaryConditions{ + Types: types, WorkoutTypes: workoutTypes, Month: month, Day: day, Years: years, + } results := make([][]string, inYear[period]) years, err := db.QueryYears(cond) if err != nil { @@ -38,7 +41,7 @@ func Stats(db Storage, measurement, period string, types, workoutTypes []string, results[idx][year] = " " } } - rows, err := db.Query( + rows, err := db.QuerySummary( []string{"year", period, measurement}, cond, &storage.Order{GroupBy: []string{period, "year"}, OrderBy: []string{period, "year"}}, ) diff --git a/pkg/stats/top.go b/pkg/stats/top.go index 7529d37..cba2877 100644 --- a/pkg/stats/top.go +++ b/pkg/stats/top.go @@ -10,9 +10,9 @@ import ( func Top(db Storage, measurement, period string, types, workoutTypes []string, limit int, years []int) ([]string, [][]string, error) { results := [][]string{} - rows, err := db.Query( + rows, err := db.QuerySummary( []string{measurement + " as total", "year", period}, - storage.Conditions{Types: types, WorkoutTypes: workoutTypes, Years: years}, + storage.SummaryConditions{Types: types, WorkoutTypes: workoutTypes, Years: years}, &storage.Order{ GroupBy: []string{"year", period}, OrderBy: []string{"total desc", "year desc", period + " desc"}, diff --git a/server/best.go b/server/best.go new file mode 100644 index 0000000..072a1fd --- /dev/null +++ b/server/best.go @@ -0,0 +1,105 @@ +package server + +import ( + "errors" + "log" + "log/slog" + "net/url" + "slices" + "strconv" + "strings" + + "github.com/jylitalo/mystats/pkg/stats" + "github.com/labstack/echo/v4" +) + +type BestFormData struct { + Distances map[string]bool + InOrder []string + Limit int +} + +func newBestFormData() BestFormData { + return BestFormData{ + Distances: map[string]bool{}, + InOrder: []string{}, + Limit: 10, + } +} + +type BestData struct { + Data []TableData +} + +func newBestData() BestData { + return BestData{ + Data: []TableData{}, + } +} + +type BestPage struct { + Form BestFormData + Data BestData +} + +func newBestPage() *BestPage { + return &BestPage{ + Form: newBestFormData(), + Data: newBestData(), + } +} + +func selectedBestEfforts(distances map[string]bool) []string { + checked := []string{} + for k, v := range distances { + if v { + checked = append(checked, k) + } + } + return checked +} + +func bestEffortValues(values url.Values) (map[string]bool, error) { + if values == nil { + return nil, errors.New("no bename values given") + } + bestEfforts := map[string]bool{} + for k, v := range values { + if strings.HasPrefix(k, "be_") { + tv := strings.ReplaceAll(strings.ReplaceAll(k[3:], "_", " "), "X", "/") + bestEfforts[tv] = (len(tv) > 0 && v[0] == "on") + } + } + return bestEfforts, nil +} + +func bestPost(page *Page, db Storage) func(c echo.Context) error { + return func(c echo.Context) error { + var err error + + values, errV := c.FormParams() + bestEfforts, errB := bestEffortValues(values) + limit, errL := strconv.Atoi(c.FormValue("limit")) + if err = errors.Join(errV, errB, errL); err != nil { + log.Fatal(err) + } + slog.Info("POST /best", "values", values) + page.Best.Form.Distances = bestEfforts + page.Best.Data = newBestData() + selected := selectedBestEfforts(bestEfforts) + for _, be := range page.Best.Form.InOrder { + if !slices.Contains[[]string, string](selected, be) { + continue + } + headers, rows, err := stats.Best(db, be, limit) + if err != nil { + return err + } + page.Best.Data.Data = append(page.Best.Data.Data, TableData{ + Headers: headers, + Rows: rows, + }) + } + return errors.Join(err, c.Render(200, "best-data", page.Best.Data)) + } +} diff --git a/server/list.go b/server/list.go index 82e8544..b0bb9f2 100644 --- a/server/list.go +++ b/server/list.go @@ -4,6 +4,7 @@ import ( "errors" "log" "log/slog" + "strconv" "github.com/jylitalo/mystats/pkg/stats" "github.com/labstack/echo/v4" @@ -14,6 +15,7 @@ type ListFormData struct { Types map[string]bool WorkoutTypes map[string]bool Years map[int]bool + Limit int } func newListFormData() ListFormData { @@ -22,6 +24,7 @@ func newListFormData() ListFormData { Types: map[string]bool{}, WorkoutTypes: map[string]bool{}, Years: map[int]bool{}, + Limit: 100, } } @@ -45,13 +48,14 @@ func listPost(page *Page, db Storage) func(c echo.Context) error { types, errT := typeValues(values) workoutTypes, errW := workoutTypeValues(values) years, errY := yearValues(values) - if err = errors.Join(errV, errT, errW, errY); err != nil { + limit, errL := strconv.Atoi(c.FormValue("limit")) + if err = errors.Join(errV, errT, errW, errY, errL); err != nil { log.Fatal(err) } slog.Info("POST /list", "values", values) page.List.Form.Years = years page.List.Data.Headers, page.List.Data.Rows, err = stats.List( - db, selectedTypes(types), selectedWorkoutTypes(workoutTypes), selectedYears(years), + db, selectedTypes(types), selectedWorkoutTypes(workoutTypes), selectedYears(years), limit, ) return errors.Join(err, c.Render(200, "list-data", page.List.Data)) } diff --git a/server/server.go b/server/server.go index ea4c939..51ce277 100644 --- a/server/server.go +++ b/server/server.go @@ -19,10 +19,12 @@ import ( ) type Storage interface { - Query(fields []string, cond storage.Conditions, order *storage.Order) (*sql.Rows, error) - QueryTypes(cond storage.Conditions) ([]string, error) - QueryWorkoutTypes(cond storage.Conditions) ([]string, error) - QueryYears(cond storage.Conditions) ([]int, error) + QueryBestEffort(fields []string, name string, order *storage.Order) (*sql.Rows, error) + QueryBestEffortDistances() ([]string, error) + QuerySummary(fields []string, cond storage.SummaryConditions, order *storage.Order) (*sql.Rows, error) + QueryTypes(cond storage.SummaryConditions) ([]string, error) + QueryWorkoutTypes(cond storage.SummaryConditions) ([]string, error) + QueryYears(cond storage.SummaryConditions) ([]int, error) } type TableData struct { @@ -39,6 +41,7 @@ func newTableData() TableData { type Page struct { Plot *PlotPage + Best *BestPage List *ListPage Top *TopPage } @@ -46,6 +49,7 @@ type Page struct { func newPage() *Page { return &Page{ Plot: newPlotPage(), + Best: newBestPage(), List: newListPage(), Top: newTopPage(), } @@ -71,7 +75,7 @@ func newTemplate(path string) *Template { return i - 1 }, "esc": func(s string) string { - return strings.ReplaceAll(s, " ", "_") + return strings.ReplaceAll(strings.ReplaceAll(s, " ", "_"), "/", "X") }, "inc": func(i int) int { return i + 1 @@ -84,7 +88,7 @@ func newTemplate(path string) *Template { }, } return &Template{ - tmpl: template.Must(template.New("plot").Funcs(funcMap).ParseGlob(path)), + tmpl: template.Must(template.New("index").Funcs(funcMap).ParseGlob(path)), } } @@ -175,10 +179,11 @@ func Start(db Storage, selectedTypes []string, port int) error { e.Static("/css", "server/css") page := newPage() - types, errT := db.QueryTypes(storage.Conditions{}) - workoutTypes, errW := db.QueryWorkoutTypes(storage.Conditions{}) - years, errY := db.QueryYears(storage.Conditions{}) - if err := errors.Join(errT, errW, errY); err != nil { + types, errT := db.QueryTypes(storage.SummaryConditions{}) + workoutTypes, errW := db.QueryWorkoutTypes(storage.SummaryConditions{}) + years, errY := db.QueryYears(storage.SummaryConditions{}) + bestEfforts, errBE := db.QueryBestEffortDistances() + if err := errors.Join(errT, errW, errY, errBE); err != nil { return err } // it is faster to first mark everything false and afterwards change selected one to true, @@ -203,9 +208,16 @@ func Start(db Storage, selectedTypes []string, port int) error { page.Plot.Form.Years[y] = true page.Top.Form.Years[y] = true } + value := true + page.Best.Form.InOrder = bestEfforts + for _, be := range bestEfforts { + page.Best.Form.Distances[be] = value + value = false + } slog.Info("starting things", "page", page) e.GET("/", indexGet(page, db)) + e.POST("/best", bestPost(page, db)) e.POST("/list", listPost(page, db)) e.POST("/plot", plotPost(page, db)) e.POST("/top", topPost(page, db)) @@ -218,13 +230,27 @@ func indexGet(page *Page, db Storage) func(c echo.Context) error { var errL, errT error pf := &page.Plot.Form errP := page.Plot.render(db, pf.Types, pf.WorkoutTypes, pf.EndMonth, pf.EndDay, pf.Years) + // init List tab types := selectedTypes(pf.Types) workoutTypes := selectedWorkoutTypes(pf.WorkoutTypes) years := selectedYears(pf.Years) - page.List.Data.Headers, page.List.Data.Rows, errL = stats.List(db, types, workoutTypes, years) + pld := &page.List.Data + pld.Headers, pld.Rows, errL = stats.List(db, types, workoutTypes, years, page.List.Form.Limit) + // init Top tab tf := &page.Top.Form td := &page.Top.Data td.Headers, td.Rows, errT = stats.Top(db, tf.measurement, tf.period, types, workoutTypes, tf.limit, years) + // init Best tab + for _, be := range selectedBestEfforts(page.Best.Form.Distances) { + headers, rows, err := stats.Best(db, be, 3) + if err != nil { + return err + } + page.Best.Data.Data = append(page.Best.Data.Data, TableData{ + Headers: headers, + Rows: rows, + }) + } return errors.Join(errP, errL, errT, c.Render(200, "index", page)) } } diff --git a/server/server_test.go b/server/server_test.go index 83a3d7d..40fb3b1 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -13,19 +13,27 @@ import ( type testDB struct{} -func (t *testDB) Query(fields []string, cond storage.Conditions, order *storage.Order) (*sql.Rows, error) { +func (t *testDB) QueryBestEffort(fields []string, distance string, order *storage.Order) (*sql.Rows, error) { return nil, nil } -func (t *testDB) QueryTypes(cond storage.Conditions) ([]string, error) { +func (t *testDB) QueryBestEffortDistances() ([]string, error) { return nil, nil } -func (t *testDB) QueryWorkoutTypes(cond storage.Conditions) ([]string, error) { +func (t *testDB) QuerySummary(fields []string, cond storage.SummaryConditions, order *storage.Order) (*sql.Rows, error) { return nil, nil } -func (t *testDB) QueryYears(cond storage.Conditions) ([]int, error) { +func (t *testDB) QueryTypes(cond storage.SummaryConditions) ([]string, error) { + return nil, nil +} + +func (t *testDB) QueryWorkoutTypes(cond storage.SummaryConditions) ([]string, error) { + return nil, nil +} + +func (t *testDB) QueryYears(cond storage.SummaryConditions) ([]int, error) { return nil, nil } diff --git a/server/views/best.html b/server/views/best.html new file mode 100644 index 0000000..f894ca5 --- /dev/null +++ b/server/views/best.html @@ -0,0 +1,63 @@ +{{ block "best-tab" . }} +{{ template "best-form" .Form }} +
+{{ template "best-data" .Data }} +{{ end }} + +{{ block "best-form" . }} +
+ {{ template "bename" . }} +
+ Items per distance: + +
+
+{{ end }} + +{{ block "bename" . }} +
+ Distances: + {{ $m := .Distances }} + {{ range $b := .InOrder }} + {{ $v := index $m $b }} + + {{ end }} +
+{{ end }} + +{{ block "best-data" . }} +
+ {{ range $table := .Data }} + + + + {{ range $s := $table.Headers }} + + {{ end }} + + + + {{ range $row := $table.Rows }} + {{ $trimmed := joined $row }} + {{ if ne $trimmed "" }} + + {{ range $idx, $col := $row }} + {{ if eq $idx 4 }} + + {{ else }} + {{ $col }} + {{ end }} + {{ end }} + + {{ end }} + {{ end }} + +
{{ $s }}
{{ $col }}
+ {{ end }} +
+{{ end }} diff --git a/server/views/index.html b/server/views/index.html index 6245122..9e9ff10 100644 --- a/server/views/index.html +++ b/server/views/index.html @@ -11,12 +11,16 @@
+
{{ template "plot-tab" .Plot }}
+
+ {{ template "best-tab" .Best }} +
{{ template "list-tab" .List }}
@@ -76,7 +80,6 @@
Years: {{ range $y, $v := .Years }} - - {{ end }} + {{ end }}
{{ end }} diff --git a/server/views/list.html b/server/views/list.html index 59ad2d0..46c2f6a 100644 --- a/server/views/list.html +++ b/server/views/list.html @@ -6,12 +6,17 @@ {{ block "list-form" . }}
-
- -
{{ template "types" . }} {{ template "workouttypes" . }} {{ template "years" . }} +
+ Number of activities: + +
{{ end }} diff --git a/storage/storage.go b/storage/storage.go index b1f2a5c..fe26341 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -12,7 +12,7 @@ import ( _ "github.com/mattn/go-sqlite3" ) -type Record struct { +type SummaryRecord struct { Year int Month int Day int @@ -25,9 +25,18 @@ type Record struct { Distance float64 Elevation float64 MovingTime int + ElapsedTime int } -type Conditions struct { +type BestEffortRecord struct { + StravaID int64 + Name string + ElapsedTime int + MovingTime int + Distance int +} + +type SummaryConditions struct { Types []string WorkoutTypes []string Years []int @@ -35,6 +44,15 @@ type Conditions struct { Day int } +type conditions struct { + Types []string + WorkoutTypes []string + Years []int + Month int + Day int + BEName string +} + type Order struct { GroupBy []string OrderBy []string @@ -77,7 +95,7 @@ func (sq *Sqlite3) Create() error { if sq.db == nil { return errors.New("database is nil") } - _, err := sq.db.Exec(`create table mystats ( + _, errS := sq.db.Exec(`create table Summary ( Year integer, Month integer, Day integer, @@ -89,12 +107,20 @@ func (sq *Sqlite3) Create() error { WorkoutType text, Distance real, Elevation real, + ElapsedTime integer, MovingTime integer )`) - return err + _, errBE := sq.db.Exec(`create table BestEffort ( + StravaID integer, + Name text, + ElapsedTime integer, + MovingTime integer, + Distance integer + )`) + return errors.Join(errS, errBE) } -func (sq *Sqlite3) Insert(records []Record) error { +func (sq *Sqlite3) InsertSummary(records []SummaryRecord) error { if sq.db == nil { return errors.New("database is nil") } @@ -102,7 +128,7 @@ func (sq *Sqlite3) Insert(records []Record) error { if err != nil { return err } - stmt, err := tx.Prepare(`insert into mystats(Year,Month,Day,Week,StravaID,Name,Type,SportType,WorkoutType,Distance,Elevation,MovingTime) values (?,?,?,?,?,?,?,?,?,?,?,?)`) + stmt, err := tx.Prepare(`insert into summary(Year,Month,Day,Week,StravaID,Name,Type,SportType,WorkoutType,Distance,Elevation,ElapsedTime,MovingTime) values (?,?,?,?,?,?,?,?,?,?,?,?,?)`) if err != nil { return fmt.Errorf("insert caused %w", err) } @@ -111,7 +137,7 @@ func (sq *Sqlite3) Insert(records []Record) error { _, err = stmt.Exec( r.Year, r.Month, r.Day, r.Week, r.StravaID, r.Name, r.Type, r.SportType, r.WorkoutType, - r.Distance, r.Elevation, r.MovingTime, + r.Distance, r.Elevation, r.ElapsedTime, r.MovingTime, ) if err != nil { return fmt.Errorf("statement execution caused: %w", err) @@ -120,8 +146,34 @@ func (sq *Sqlite3) Insert(records []Record) error { return tx.Commit() } -func sqlQuery(fields []string, cond Conditions, order *Order) string { +func (sq *Sqlite3) InsertBestEffort(records []BestEffortRecord) error { + if sq.db == nil { + return errors.New("database is nil") + } + tx, err := sq.db.Begin() + if err != nil { + return err + } + stmt, err := tx.Prepare(`insert into BestEffort(StravaID,Name,ElapsedTime,MovingTime,Distance) values (?,?,?,?,?)`) + if err != nil { + return fmt.Errorf("insert caused %w", err) + } + defer stmt.Close() + for _, r := range records { + if _, err = stmt.Exec(r.StravaID, r.Name, r.ElapsedTime, r.MovingTime, r.Distance); err != nil { + return fmt.Errorf("statement execution caused: %w", err) + } + } + return tx.Commit() +} + +func sqlQuery(tables []string, fields []string, cond conditions, order *Order) string { where := []string{} + if len(tables) > 0 { + for _, table := range tables[1:] { + where = append(where, fmt.Sprintf("%s.StravaID=%s.StravaID", tables[0], table)) + } + } if cond.WorkoutTypes != nil { where = append(where, "(workouttype='"+strings.Join(cond.WorkoutTypes, "' or workouttype='")+"')") } @@ -138,6 +190,9 @@ func sqlQuery(fields []string, cond Conditions, order *Order) string { } where = append(where, "(year="+strings.Join(yearStr, " or year=")+")") } + if cond.BEName != "" { + where = append(where, "besteffort.name='"+cond.BEName+"'") + } condition := "" if len(where) > 0 { condition = " where " + strings.Join(where, " and ") @@ -154,14 +209,64 @@ func sqlQuery(fields []string, cond Conditions, order *Order) string { sorting += " limit " + strconv.FormatInt(int64(order.Limit), 10) } } - return fmt.Sprintf("select %s from mystats%s%s", strings.Join(fields, ","), condition, sorting) + return fmt.Sprintf( + "select %s from %s%s%s", strings.Join(fields, ","), strings.Join(tables, ","), + condition, sorting, + ) +} + +func (sq *Sqlite3) QueryBestEffort(fields []string, name string, order *Order) (*sql.Rows, error) { + if sq.db == nil { + return nil, errors.New("database is nil") + } + query := sqlQuery([]string{"besteffort", "summary"}, fields, conditions{BEName: name}, order) + // slog.Info("storage.Query", "query", query) + rows, err := sq.db.Query(query) + if err != nil { + return nil, fmt.Errorf("%s failed: %w", query, err) + } + return rows, err +} + +func (sq *Sqlite3) QueryBestEffortDistances() ([]string, error) { + if sq.db == nil { + return nil, errors.New("database is nil") + } + query := sqlQuery( + []string{"besteffort"}, []string{"distinct(name)"}, conditions{}, + &Order{OrderBy: []string{"distance desc"}}, + ) + // slog.Info("storage.Query", "query", query) + rows, err := sq.db.Query(query) + if err != nil { + return nil, fmt.Errorf("%s failed: %w", query, err) + } + defer rows.Close() + benames := []string{} + for rows.Next() { + var value string + if err = rows.Scan(&value); err != nil { + return benames, err + } + benames = append(benames, value) + } + // slog.Info("QueryBestEffortDistances", "benames", benames) + return benames, nil } -func (sq *Sqlite3) Query(fields []string, cond Conditions, order *Order) (*sql.Rows, error) { +func (sq *Sqlite3) QuerySummary(fields []string, cond SummaryConditions, order *Order) (*sql.Rows, error) { if sq.db == nil { return nil, errors.New("database is nil") } - query := sqlQuery(fields, cond, order) + query := sqlQuery( + []string{"summary"}, fields, + conditions{ + Types: cond.Types, WorkoutTypes: cond.WorkoutTypes, + Years: cond.Years, Month: cond.Month, Day: cond.Day, + }, + order, + ) + // slog.Info("storage.Query", "query", query) rows, err := sq.db.Query(query) if err != nil { return nil, fmt.Errorf("%s failed: %w", query, err) @@ -170,9 +275,9 @@ func (sq *Sqlite3) Query(fields []string, cond Conditions, order *Order) (*sql.R } // QueryTypes creates list of distinct years from which have records -func (sq *Sqlite3) QueryTypes(cond Conditions) ([]string, error) { +func (sq *Sqlite3) QueryTypes(cond SummaryConditions) ([]string, error) { types := []string{} - rows, err := sq.Query( + rows, err := sq.QuerySummary( []string{"distinct(type)"}, cond, &Order{GroupBy: []string{"type"}, OrderBy: []string{"type"}}, ) @@ -191,9 +296,9 @@ func (sq *Sqlite3) QueryTypes(cond Conditions) ([]string, error) { } // QueryTypes creates list of distinct years from which have records -func (sq *Sqlite3) QueryWorkoutTypes(cond Conditions) ([]string, error) { +func (sq *Sqlite3) QueryWorkoutTypes(cond SummaryConditions) ([]string, error) { types := []string{} - rows, err := sq.Query( + rows, err := sq.QuerySummary( []string{"distinct(workouttype)"}, cond, &Order{GroupBy: []string{"workouttype"}, OrderBy: []string{"workouttype"}}, ) @@ -212,9 +317,9 @@ func (sq *Sqlite3) QueryWorkoutTypes(cond Conditions) ([]string, error) { } // QueryYears creates list of distinct years from which have records -func (sq *Sqlite3) QueryYears(cond Conditions) ([]int, error) { +func (sq *Sqlite3) QueryYears(cond SummaryConditions) ([]int, error) { years := []int{} - rows, err := sq.Query( + rows, err := sq.QuerySummary( []string{"distinct(year)"}, cond, &Order{GroupBy: []string{"year"}, OrderBy: []string{"year desc"}}, ) diff --git a/storage/storage_test.go b/storage/storage_test.go index a328f12..2f5a048 100644 --- a/storage/storage_test.go +++ b/storage/storage_test.go @@ -5,30 +5,56 @@ import "testing" func TestSqlQuery(t *testing.T) { values := []struct { name string + tables []string fields []string - cond Conditions + cond conditions order *Order query string }{ - {"none", []string{"field"}, Conditions{}, nil, "select field from mystats"}, - {"simple", []string{"field"}, Conditions{Types: []string{"Run"}}, nil, "select field from mystats where (type='Run')"}, { - "multi-field", []string{"f1", "f2"}, - Conditions{Types: []string{"r1", "r2"}}, + "none", []string{"summary"}, []string{"field"}, conditions{}, nil, + "select field from summary"}, + { + "simple", []string{"summary"}, []string{"field"}, conditions{Types: []string{"Run"}}, nil, + "select field from summary where (type='Run')", + }, + { + "multi-field", []string{"summary"}, []string{"f1", "f2"}, + conditions{Types: []string{"r1", "r2"}}, &Order{GroupBy: []string{"f3"}, OrderBy: []string{"f3 desc"}}, - "select f1,f2 from mystats where (type='r1' or type='r2') group by f3 order by f3 desc", + "select f1,f2 from summary where (type='r1' or type='r2') group by f3 order by f3 desc", }, { - "order", []string{"k1", "k2"}, Conditions{Types: []string{"c1"}, WorkoutTypes: []string{"c3"}}, + "order", []string{"summary"}, []string{"k1", "k2"}, + conditions{Types: []string{"c1"}, WorkoutTypes: []string{"c3"}}, &Order{GroupBy: []string{"k3", "k4"}, OrderBy: []string{"k5", "k6"}, Limit: 7}, - "select k1,k2 from mystats where (workouttype='c3') and (type='c1') group by k3,k4 order by k5,k6 limit 7", + "select k1,k2 from summary where (workouttype='c3') and (type='c1') group by k3,k4 order by k5,k6 limit 7", + }, + { + "one_year", []string{"summary"}, []string{"field"}, + conditions{Types: []string{"Run"}, Years: []int{2023}}, nil, + "select field from summary where (type='Run') and (year=2023)", + }, + { + "multiple_years", []string{"summary"}, []string{"field"}, + conditions{Types: []string{"Run"}, Years: []int{2019, 2023}}, nil, + "select field from summary where (type='Run') and (year=2019 or year=2023)", + }, + { + "ids", []string{"summary"}, + []string{"StravaID"}, + conditions{Types: []string{"Run"}}, + &Order{OrderBy: []string{"StravaID desc"}}, + "select StravaID from summary where (type='Run') order by StravaID desc", + }, + { + "besteffort", []string{"summary", "besteffort"}, []string{"summary.Name"}, conditions{BEName: "400m"}, nil, + "select summary.Name from summary,besteffort where summary.StravaID=besteffort.StravaID and besteffort.name='400m'", }, - {"one_year", []string{"field"}, Conditions{Types: []string{"Run"}, Years: []int{2023}}, nil, "select field from mystats where (type='Run') and (year=2023)"}, - {"multiple_years", []string{"field"}, Conditions{Types: []string{"Run"}, Years: []int{2019, 2023}}, nil, "select field from mystats where (type='Run') and (year=2019 or year=2023)"}, } for _, value := range values { t.Run(value.name, func(t *testing.T) { - cmd := sqlQuery(value.fields, value.cond, value.order) + cmd := sqlQuery(value.tables, value.fields, value.cond, value.order) if cmd != value.query { t.Errorf("mismatch got '%s' vs. expected '%s'", cmd, value.query) }