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:
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user