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 { TotalMinutes int64 `json:"totalMinutes"` PracticeDays int `json:"practiceDays"` RecordCount int64 `json:"recordCount"` Projects []ProjectMinutes `json:"projects"` } 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 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 }