Backend:
- ClosedWeekStore.SumDelta: single SQL aggregate returning total delta_ms and
row count across all closed_weeks
- WeekService.Balance: thin passthrough returning BalanceResult{TotalDeltaMs, ClosedWeekCount}
- GET /api/weeks/balance handler; route registered alongside /weeks list/close/reopen
- Tests: store-level SumDelta (empty + populated), service-level Balance (empty + 2 weeks)
Frontend:
- weeks.balance() added to API client
- History page: balance card at top, fetched in parallel with existing data
- Loading state shows '—'; once loaded shows formatDelta value in green/red/gray
- Shows 'across N closed weeks' count alongside the value
200 lines
6.0 KiB
Go
200 lines
6.0 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/wotra/wotra/internal/domain"
|
|
"github.com/wotra/wotra/internal/store"
|
|
)
|
|
|
|
// WeekService handles closing weeks and computing overtime/undertime.
|
|
type WeekService struct {
|
|
closedDays *store.ClosedDayStore
|
|
closedWeeks *store.ClosedWeekStore
|
|
entries *store.EntryStore
|
|
settings *store.SettingsStore
|
|
db interface {
|
|
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
|
|
}
|
|
rawDB *sql.DB
|
|
tz *time.Location
|
|
}
|
|
|
|
func NewWeekService(
|
|
closedDays *store.ClosedDayStore,
|
|
closedWeeks *store.ClosedWeekStore,
|
|
entries *store.EntryStore,
|
|
settings *store.SettingsStore,
|
|
rawDB *sql.DB,
|
|
tz *time.Location,
|
|
) *WeekService {
|
|
return &WeekService{
|
|
closedDays: closedDays,
|
|
closedWeeks: closedWeeks,
|
|
entries: entries,
|
|
settings: settings,
|
|
rawDB: rawDB,
|
|
tz: tz,
|
|
}
|
|
}
|
|
|
|
// BalanceResult holds the overall overtime/undertime balance across all closed weeks.
|
|
type BalanceResult struct {
|
|
TotalDeltaMs int64 `json:"total_delta_ms"`
|
|
ClosedWeekCount int `json:"closed_week_count"`
|
|
}
|
|
|
|
// Balance returns the sum of delta_ms across all closed weeks.
|
|
func (s *WeekService) Balance(ctx context.Context) (BalanceResult, error) {
|
|
total, count, err := s.closedWeeks.SumDelta(ctx)
|
|
if err != nil {
|
|
return BalanceResult{}, err
|
|
}
|
|
return BalanceResult{TotalDeltaMs: total, ClosedWeekCount: count}, nil
|
|
}
|
|
|
|
// WeekDayKeysExported is exported for testing.
|
|
var WeekDayKeysExported = weekDayKeys
|
|
|
|
// WeekKeyForDayKey returns the ISO week key (YYYY-Www) for a given YYYY-MM-DD day key.
|
|
func WeekKeyForDayKey(dayKey string, tz *time.Location) (string, error) {
|
|
t, err := time.ParseInLocation("2006-01-02", dayKey, tz)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid day_key %q: %w", dayKey, err)
|
|
}
|
|
year, week := t.ISOWeek()
|
|
return fmt.Sprintf("%d-W%02d", year, week), nil
|
|
}
|
|
|
|
// weekDayKeys returns the YYYY-MM-DD keys for Mon-Sun of the ISO week encoded as weekKey.
|
|
// weekKey format: "YYYY-Www" (e.g. "2024-W03").
|
|
func weekDayKeys(weekKey string, tz *time.Location) ([]string, error) {
|
|
var year, week int
|
|
if _, err := fmt.Sscanf(weekKey, "%d-W%02d", &year, &week); err != nil {
|
|
return nil, fmt.Errorf("invalid week_key %q: expected YYYY-Www", weekKey)
|
|
}
|
|
// Find the Monday of that ISO week.
|
|
// Jan 4 is always in week 1 of its year.
|
|
jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, tz)
|
|
// (weekday+6)%7 gives days-since-Monday (Mon=0 … Sun=6), avoiding the
|
|
// sign issue when Weekday()==0 (Sunday) with the naive subtraction.
|
|
daysSinceMonday := int(jan4.Weekday()+6) % 7
|
|
_, jan4Week := jan4.ISOWeek()
|
|
monday := jan4.AddDate(0, 0, -daysSinceMonday+(week-jan4Week)*7)
|
|
|
|
keys := make([]string, 7)
|
|
for i := 0; i < 7; i++ {
|
|
keys[i] = monday.AddDate(0, 0, i).Format("2006-01-02")
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
// CloseWeek closes an ISO week. All workdays must already be closed.
|
|
func (s *WeekService) CloseWeek(ctx context.Context, weekKey string) (*domain.ClosedWeek, error) {
|
|
// Already closed?
|
|
existing, err := s.closedWeeks.GetByWeekKey(ctx, weekKey)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
if existing != nil {
|
|
return nil, ErrWeekAlreadyClosed
|
|
}
|
|
|
|
dayKeys, err := weekDayKeys(weekKey, s.tz)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get settings effective at close time (today), not necessarily at the
|
|
// start of the week. This ensures settings changes made mid-week are
|
|
// reflected when the week is closed.
|
|
set, err := s.settings.Current(ctx, time.Now().In(s.tz).Format("2006-01-02"))
|
|
if err != nil {
|
|
return nil, ErrNoSettings
|
|
}
|
|
|
|
// Compute expected ms for the week (from settings frozen at week start)
|
|
expectedMs := int64(set.HoursPerWeek * 3_600_000)
|
|
|
|
// Verify all past workdays that have entries are closed; collect worked ms.
|
|
// Past workdays with no entries at all are skipped (they contribute 0h).
|
|
// Future workdays in the week are always skipped.
|
|
todayKey := time.Now().In(s.tz).Format("2006-01-02")
|
|
var totalWorkedMs int64
|
|
for _, dk := range dayKeys {
|
|
t, _ := time.ParseInLocation("2006-01-02", dk, s.tz)
|
|
if !set.IsWorkday(int(t.Weekday())) {
|
|
continue // weekend or non-workday — skip
|
|
}
|
|
if dk > todayKey {
|
|
continue // future workday — skip
|
|
}
|
|
cd, err := s.closedDays.GetByDayKey(ctx, dk)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
if cd != nil {
|
|
totalWorkedMs += cd.WorkedMs
|
|
continue
|
|
}
|
|
// No closed_days record — check whether the day has any entries.
|
|
dayEntries, err := s.entries.ListByDayKey(ctx, dk)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(dayEntries) > 0 {
|
|
// Day has tracked time but was never closed — require explicit close.
|
|
return nil, fmt.Errorf("%w: %s", ErrWeekHasUnclosedDays, dk)
|
|
}
|
|
// No entries, no closed record → untracked day, counts as 0h.
|
|
}
|
|
|
|
now := time.Now().UnixMilli()
|
|
cw := &domain.ClosedWeek{
|
|
WeekKey: weekKey,
|
|
ExpectedMs: expectedMs,
|
|
WorkedMs: totalWorkedMs,
|
|
DeltaMs: totalWorkedMs - expectedMs,
|
|
ClosedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := s.closedWeeks.Upsert(ctx, cw); err != nil {
|
|
return nil, err
|
|
}
|
|
return cw, nil
|
|
}
|
|
|
|
// ReopenWeek deletes the closed_weeks record, making it editable again.
|
|
// Individual closed days are NOT automatically reopened.
|
|
func (s *WeekService) ReopenWeek(ctx context.Context, weekKey string) error {
|
|
existing, err := s.closedWeeks.GetByWeekKey(ctx, weekKey)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrWeekNotClosed
|
|
}
|
|
return err
|
|
}
|
|
if existing == nil {
|
|
return ErrWeekNotClosed
|
|
}
|
|
return s.closedWeeks.Delete(ctx, weekKey)
|
|
}
|
|
|
|
// ListWeeks returns closed weeks within a range.
|
|
func (s *WeekService) ListWeeks(ctx context.Context, fromWeekKey, toWeekKey string) ([]*domain.ClosedWeek, error) {
|
|
return s.closedWeeks.ListByRange(ctx, fromWeekKey, toWeekKey)
|
|
}
|
|
|
|
// GetWeek returns a single closed week.
|
|
func (s *WeekService) GetWeek(ctx context.Context, weekKey string) (*domain.ClosedWeek, error) {
|
|
cw, err := s.closedWeeks.GetByWeekKey(ctx, weekKey)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return cw, err
|
|
}
|