feat: add growth tracking page and related functionality

- Implemented a new Growth page to track practice time and trends.
- Added API integration for fetching review data.
- Created components for displaying practice statistics and trends.
- Updated navigation titles for the main index and growth pages.
- Removed unused styles from the index page.
- Introduced a Projects management page for adding and editing practice projects.
- Developed a Record form for logging practice sessions with validation.
- Added utility functions for date manipulation and duration formatting.
- Implemented error handling and session management in the practice service.
- Created unit tests for the practice service to ensure reliability.
This commit is contained in:
yuxuanhui
2026-09-29 13:47:26 +08:00
parent 4b42b55928
commit 6224ef5980
49 changed files with 2824 additions and 71 deletions
+122
View File
@@ -0,0 +1,122 @@
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
}