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
+199
View File
@@ -0,0 +1,199 @@
package practice
import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
)
type RecordInput struct {
ProjectID string `json:"projectId"`
Date string `json:"date"`
Minutes int `json:"minutes"`
Note string `json:"note"`
RequestID string `json:"requestId"`
}
type Record struct {
updatedAt time.Time
ID string `json:"id"`
ProjectID string `json:"projectId"`
ProjectName string `json:"projectName"`
Archived bool `json:"archived"`
Date string `json:"date"`
Minutes int `json:"minutes"`
Note string `json:"note"`
}
type RecordList struct {
Records []Record `json:"records"`
NextCursor string `json:"nextCursor"`
}
var requestIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{16,128}$`)
var objectIDPattern = regexp.MustCompile(`^[A-Z2-7]{26}$`)
func validDate(date string) bool {
t, err := time.Parse(time.DateOnly, date)
return err == nil && t.Year() >= 1 && t.Format(time.DateOnly) == date
}
const recordSelect = `SELECT r.id,r.project_id,p.name,p.archived,r.practice_date::text,r.minutes,r.note,r.updated_at
FROM records r JOIN projects p ON p.user_id=r.user_id AND p.id=r.project_id`
func scanRecord(row pgx.Row) (Record, error) {
var r Record
err := row.Scan(&r.ID, &r.ProjectID, &r.ProjectName, &r.Archived, &r.Date, &r.Minutes, &r.Note, &r.updatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrUnavailable
}
return r, err
}
func (s *Store) Record(ctx context.Context, user, id string) (Record, error) {
return scanRecord(s.db.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, id))
}
// Records bounds every response. The updated-at/id cursor permits complete day lists
// without silently truncating them or loading an account's entire history.
// Recent entries include just-saved corrections and backdated practices first.
func (s *Store) Records(ctx context.Context, user, from, to, cursor string, limit int) (RecordList, error) {
if limit < 1 || limit > 100 {
return RecordList{}, ErrInvalid
}
if from == "" {
from = "0001-01-01"
}
if to == "" {
to = s.Today()
}
if !validDate(from) || !validDate(to) || from > to {
return RecordList{}, ErrInvalid
}
cursorTime, cursorID := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), "~"
if cursor != "" {
parts := strings.Split(cursor, "~")
if len(parts) != 2 || !objectIDPattern.MatchString(parts[1]) {
return RecordList{}, ErrInvalid
}
var err error
cursorTime, err = time.Parse(time.RFC3339Nano, parts[0])
if err != nil {
return RecordList{}, ErrInvalid
}
cursorID = parts[1]
}
rows, err := s.db.Query(ctx, recordSelect+` WHERE r.user_id=$1 AND r.practice_date BETWEEN $2::date AND $3::date
AND (r.updated_at,r.id)<($4,$5) ORDER BY r.updated_at DESC,r.id DESC LIMIT $6`, user, from, to, cursorTime, cursorID, limit+1)
if err != nil {
return RecordList{}, err
}
defer rows.Close()
result := RecordList{Records: []Record{}}
for rows.Next() {
r, err := scanRecord(rows)
if err != nil {
return RecordList{}, err
}
result.Records = append(result.Records, r)
}
if err := rows.Err(); err != nil {
return RecordList{}, err
}
if len(result.Records) > limit {
result.Records = result.Records[:limit]
last := result.Records[limit-1]
result.NextCursor = last.updatedAt.UTC().Format(time.RFC3339Nano) + "~" + last.ID
}
return result, nil
}
// SaveRecord commits the record and submission receipt together. Retrying an
// uncertain create keeps its ID; a different payload using the same key is rejected.
func (s *Store) SaveRecord(ctx context.Context, user, id string, input RecordInput) (Record, error) {
if !validDate(input.Date) || input.Date > s.Today() || input.Minutes < 1 || input.Minutes > MinuteLimit ||
input.ProjectID == "" || utf8.RuneCountInString(input.Note) > NoteLimit || strings.ContainsRune(input.Note, 0) {
return Record{}, ErrInvalid
}
if id == "" && !requestIDPattern.MatchString(input.RequestID) {
return Record{}, ErrInvalid
}
var result Record
err := s.write(ctx, user, func(tx pgx.Tx) error {
payload := input
payload.RequestID = ""
encoded, _ := json.Marshal(payload)
hash := digest(string(encoded))
if id == "" {
var savedHash, savedID string
err := tx.QueryRow(ctx, "SELECT payload_hash,record_id FROM submissions WHERE user_id=$1 AND request_id=$2", user, input.RequestID).Scan(&savedHash, &savedID)
if err == nil {
if savedHash != hash {
return ErrConflict
}
result, err = scanRecord(tx.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, savedID))
if errors.Is(err, ErrUnavailable) {
return ErrDeleted
}
return err
}
if !errors.Is(err, pgx.ErrNoRows) {
return err
}
}
originalProject := ""
if id != "" {
err := tx.QueryRow(ctx, "SELECT project_id FROM records WHERE user_id=$1 AND id=$2", user, id).Scan(&originalProject)
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnavailable
}
if err != nil {
return err
}
}
var archived bool
err := tx.QueryRow(ctx, "SELECT archived FROM projects WHERE user_id=$1 AND id=$2", user, input.ProjectID).Scan(&archived)
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnavailable
}
if err != nil {
return err
}
if archived && originalProject != input.ProjectID {
return ErrArchived
}
if id == "" {
id = newID()
if _, err = tx.Exec(ctx, `INSERT INTO records(id,user_id,project_id,practice_date,minutes,note)
VALUES($1,$2,$3,$4::date,$5,$6)`, id, user, input.ProjectID, input.Date, input.Minutes, input.Note); err != nil {
return err
}
if _, err = tx.Exec(ctx, "INSERT INTO submissions(user_id,request_id,payload_hash,record_id) VALUES($1,$2,$3,$4)", user, input.RequestID, hash, id); err != nil {
return err
}
} else {
if _, err = tx.Exec(ctx, `UPDATE records SET project_id=$3,practice_date=$4::date,minutes=$5,note=$6,updated_at=now()
WHERE user_id=$1 AND id=$2`, user, id, input.ProjectID, input.Date, input.Minutes, input.Note); err != nil {
return err
}
}
result, err = scanRecord(tx.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, id))
return err
})
return result, err
}
// DeleteRecord is scoped to the session owner and safe to retry, including after
// the last record of a date has gone. Submission receipts deliberately remain.
func (s *Store) DeleteRecord(ctx context.Context, user, id string) error {
return s.write(ctx, user, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, "DELETE FROM records WHERE user_id=$1 AND id=$2", user, id)
return err
})
}