2026-09-29 13:47:26 +08:00
|
|
|
package practice
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"sort"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type ProjectMinutes struct {
|
|
|
|
|
ProjectID string `json:"projectId"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Archived bool `json:"archived"`
|
|
|
|
|
Minutes int64 `json:"minutes"`
|
|
|
|
|
}
|
|
|
|
|
type Summary struct {
|
2026-09-29 18:40:25 +08:00
|
|
|
TotalMinutes int64 `json:"totalMinutes"`
|
|
|
|
|
PracticeDays int `json:"practiceDays"`
|
|
|
|
|
// FirstDate is the earliest actual practice date, or empty without records.
|
|
|
|
|
FirstDate string `json:"firstDate"`
|
|
|
|
|
RecordCount int64 `json:"recordCount"`
|
|
|
|
|
Projects []ProjectMinutes `json:"projects"`
|
2026-09-29 13:47:26 +08:00
|
|
|
}
|
|
|
|
|
type DayMinutes struct {
|
|
|
|
|
Date string `json:"date"`
|
|
|
|
|
Minutes int64 `json:"minutes"`
|
|
|
|
|
}
|
|
|
|
|
type Review struct {
|
|
|
|
|
Today string `json:"today"`
|
|
|
|
|
From string `json:"from"`
|
|
|
|
|
To string `json:"to"`
|
|
|
|
|
AllTime Summary `json:"allTime"`
|
|
|
|
|
Period Summary `json:"period"`
|
|
|
|
|
Days []DayMinutes `json:"days"`
|
|
|
|
|
}
|
|
|
|
|
type summaryBuilder struct {
|
|
|
|
|
summary Summary
|
|
|
|
|
dates map[string]bool
|
|
|
|
|
projects map[string]ProjectMinutes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newSummary() *summaryBuilder {
|
|
|
|
|
return &summaryBuilder{dates: map[string]bool{}, projects: map[string]ProjectMinutes{}}
|
|
|
|
|
}
|
|
|
|
|
func (b *summaryBuilder) add(date string, project ProjectMinutes, count int64) {
|
|
|
|
|
b.summary.TotalMinutes += project.Minutes
|
|
|
|
|
b.summary.RecordCount += count
|
2026-09-29 18:40:25 +08:00
|
|
|
if b.summary.FirstDate == "" || date < b.summary.FirstDate {
|
|
|
|
|
b.summary.FirstDate = date
|
|
|
|
|
}
|
2026-09-29 13:47:26 +08:00
|
|
|
b.dates[date] = true
|
|
|
|
|
previous := b.projects[project.ProjectID]
|
|
|
|
|
project.Minutes += previous.Minutes
|
|
|
|
|
b.projects[project.ProjectID] = project
|
|
|
|
|
}
|
|
|
|
|
func (b *summaryBuilder) result() Summary {
|
|
|
|
|
b.summary.PracticeDays = len(b.dates)
|
|
|
|
|
b.summary.Projects = []ProjectMinutes{}
|
|
|
|
|
for _, p := range b.projects {
|
|
|
|
|
b.summary.Projects = append(b.summary.Projects, p)
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(b.summary.Projects, func(i, j int) bool {
|
|
|
|
|
a, c := b.summary.Projects[i], b.summary.Projects[j]
|
|
|
|
|
if a.Minutes == c.Minutes {
|
|
|
|
|
return a.ProjectID < c.ProjectID
|
|
|
|
|
}
|
|
|
|
|
return a.Minutes > c.Minutes
|
|
|
|
|
})
|
|
|
|
|
return b.summary
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Review derives totals, daily minutes and project shares from one SQL snapshot.
|
|
|
|
|
// Archived projects participate and always use their current name. Monday starts a week.
|
|
|
|
|
func (s *Store) Review(ctx context.Context, user, period, date string) (Review, error) {
|
|
|
|
|
if date == "" {
|
|
|
|
|
date = s.Today()
|
|
|
|
|
}
|
|
|
|
|
if !validDate(date) || date > s.Today() {
|
|
|
|
|
return Review{}, ErrInvalid
|
|
|
|
|
}
|
|
|
|
|
anchor, _ := time.Parse(time.DateOnly, date)
|
|
|
|
|
var start, end time.Time
|
|
|
|
|
switch period {
|
|
|
|
|
case "week":
|
|
|
|
|
start = anchor.AddDate(0, 0, -(int(anchor.Weekday())+6)%7)
|
|
|
|
|
end = start.AddDate(0, 0, 6)
|
|
|
|
|
case "month":
|
|
|
|
|
start = time.Date(anchor.Year(), anchor.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
end = start.AddDate(0, 1, -1)
|
|
|
|
|
default:
|
|
|
|
|
return Review{}, ErrInvalid
|
|
|
|
|
}
|
|
|
|
|
result := Review{Today: s.Today(), From: start.Format(time.DateOnly), To: end.Format(time.DateOnly), Days: []DayMinutes{}}
|
|
|
|
|
// A single grouped query keeps all-time and selected-period summaries consistent
|
|
|
|
|
// during concurrent edits, without storing a second set of cumulative balances.
|
|
|
|
|
rows, err := s.db.Query(ctx, `SELECT r.practice_date::text,p.id,p.name,p.archived,sum(r.minutes)::bigint,count(*)
|
|
|
|
|
FROM records r JOIN projects p ON p.user_id=r.user_id AND p.id=r.project_id
|
|
|
|
|
WHERE r.user_id=$1 GROUP BY r.practice_date,p.id,p.name,p.archived`, user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Review{}, err
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
all, selected := newSummary(), newSummary()
|
|
|
|
|
daily := map[string]int64{}
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var date string
|
|
|
|
|
var p ProjectMinutes
|
|
|
|
|
var count int64
|
|
|
|
|
if err := rows.Scan(&date, &p.ProjectID, &p.Name, &p.Archived, &p.Minutes, &count); err != nil {
|
|
|
|
|
return Review{}, err
|
|
|
|
|
}
|
|
|
|
|
all.add(date, p, count)
|
|
|
|
|
if date >= result.From && date <= result.To {
|
|
|
|
|
selected.add(date, p, count)
|
|
|
|
|
daily[date] += p.Minutes
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return Review{}, err
|
|
|
|
|
}
|
|
|
|
|
result.AllTime = all.result()
|
|
|
|
|
result.Period = selected.result()
|
|
|
|
|
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
|
|
|
|
date := d.Format(time.DateOnly)
|
|
|
|
|
result.Days = append(result.Days, DayMinutes{Date: date, Minutes: daily[date]})
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|