Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions internal/storage/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,18 @@ func (s *Storage) CreateTaskAndStart(
ctx context.Context,
task Task,
startedAt time.Time,
) error {
return s.CreateTaskAndEntry(ctx, task, Entry{StartedAt: startedAt})
}

func (s *Storage) CreateTaskAndEntry(
ctx context.Context,
task Task,
entry Entry,
) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin task start: %w", err)
return fmt.Errorf("begin task entry: %w", err)
}
defer tx.Rollback()

Expand Down Expand Up @@ -96,14 +104,18 @@ func (s *Storage) CreateTaskAndStart(

if _, err := tx.ExecContext(
ctx,
`INSERT INTO ENTRY (TASK_ID, PROJECT_ID, RATE_ID, STARTED_AT)
VALUES ($1, $2, $3, $4)`,
`INSERT INTO ENTRY (
TASK_ID, PROJECT_ID, RATE_ID, STARTED_AT, ENDED_AT, NOTES
)
VALUES ($1, $2, $3, $4, $5, $6)`,
taskID,
task.ProjectID,
rateID,
startedAt.Unix(),
entry.StartedAt.Unix(),
unixTime(entry.EndedAt),
entry.Note,
); err != nil {
return fmt.Errorf("start task entry: %w", err)
return fmt.Errorf("create task entry: %w", err)
}

if err := tx.Commit(); err != nil {
Expand Down
41 changes: 41 additions & 0 deletions internal/storage/tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,47 @@ func TestCreateTaskAndStart(t *testing.T) {
})
}

func TestCreateTaskAndEntry(t *testing.T) {
stor := fixtureStorage(t)
ctx := t.Context()
project := fixtureProject(t, stor)
startedAt := time.Unix(1_700_000_000, 0)
endedAt := startedAt.Add(90 * time.Minute)

if err := stor.CreateTaskAndEntry(
ctx,
storage.Task{Name: "past task", ProjectID: project.ID},
storage.Entry{
StartedAt: startedAt,
EndedAt: &endedAt,
Note: "missed entry",
},
); err != nil {
t.Fatal(err)
}

tasks, err := stor.GetTasks(ctx)
if err != nil {
t.Fatal(err)
}
entries, err := stor.GetEntries(ctx)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 || len(entries) != 1 {
t.Fatalf("got %d tasks and %d entries", len(tasks), len(entries))
}
entry := entries[0]
if entry.TaskID == nil || *entry.TaskID != tasks[0].ID ||
entry.ProjectID == nil || *entry.ProjectID != project.ID ||
entry.RateID == nil || *entry.RateID != project.RateID ||
!entry.StartedAt.Equal(startedAt) ||
entry.EndedAt == nil || !entry.EndedAt.Equal(endedAt) ||
entry.Note != "missed entry" {
t.Fatalf("unexpected entry: %#v", entry)
}
}

func TestGetTasks(t *testing.T) {
t.Run("ordered by ID", func(t *testing.T) {
stor := fixtureStorage(t)
Expand Down
5 changes: 5 additions & 0 deletions internal/tui/components/crud.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,11 @@ func (m *Page[T]) Reload() tea.Cmd {
return m.load()
}

func (m Page[T]) OpenForm(form *Form[T]) (Page[T], tea.Cmd) {
m.form = form
return m, form.Init()
}

func (m Page[T]) load() tea.Cmd {
return func() tea.Msg {
items, meta, err := m.config.Load(m.ctx)
Expand Down
7 changes: 7 additions & 0 deletions internal/tui/components/crud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ func TestCRUDPage(t *testing.T) {
}
})

t.Run("opens supplied form", func(t *testing.T) {
updated, cmd := page.OpenForm(form())
if updated.form == nil || cmd == nil {
t.Fatal("supplied form was not opened")
}
})

t.Run("escape closes form", func(t *testing.T) {
updated, _ := page.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyEsc})
Expand Down
9 changes: 8 additions & 1 deletion internal/tui/components/formatting.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import (
"time"
)

const DateLayout = "2006-01-02"
const (
DateLayout = "2006-01-02"
DateTimeLayout = "2006-01-02 15:04"
)

type currencyFormat struct {
symbol string
Expand Down Expand Up @@ -76,6 +79,10 @@ func FormatDate(value time.Time) string {
return value.Format(DateLayout)
}

func FormatDateTime(value time.Time) string {
return value.Format(DateTimeLayout)
}

func FormatDuration(duration time.Duration) string {
if duration < 0 {
duration = 0
Expand Down
20 changes: 20 additions & 0 deletions internal/tui/components/formatting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,23 @@ func TestFormatDate(t *testing.T) {
t.Fatalf("got %q", got)
}
}

func TestDateTime(t *testing.T) {
value := time.Date(2026, 7, 20, 12, 30, 0, 0, time.Local)
if got := FormatDateTime(value); got != "2026-07-20 12:30" {
t.Fatalf("got %q", got)
}
parsed, err := ParseDateTime(" 2026-07-20 12:30 ")
if err != nil {
t.Fatal(err)
}
if !parsed.Equal(value) {
t.Fatalf("got %v, want %v", parsed, value)
}
if err := DateTime("20/07/2026 12:30"); err == nil {
t.Fatal("invalid datetime accepted")
}
if err := OptionalDateTime(""); err != nil {
t.Fatalf("blank optional datetime rejected: %v", err)
}
}
30 changes: 30 additions & 0 deletions internal/tui/components/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"strconv"
"strings"
"time"
)

func Required(name string) func(string) error {
Expand All @@ -29,3 +30,32 @@ func CurrencyCode(value string) error {
}
return nil
}

func Date(value string) error {
if _, err := ParseDate(value); err != nil {
return errors.New("date must use YYYY-MM-DD")
}
return nil
}

func ParseDate(value string) (time.Time, error) {
return time.ParseInLocation(DateLayout, strings.TrimSpace(value), time.Local)
}

func DateTime(value string) error {
if _, err := ParseDateTime(value); err != nil {
return errors.New("time must use YYYY-MM-DD HH:MM")
}
return nil
}

func OptionalDateTime(value string) error {
if strings.TrimSpace(value) == "" {
return nil
}
return DateTime(value)
}

func ParseDateTime(value string) (time.Time, error) {
return time.ParseInLocation(DateTimeLayout, strings.TrimSpace(value), time.Local)
}
19 changes: 4 additions & 15 deletions internal/tui/screens/payments.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,11 @@ func paymentForm(
huh.NewInput().
Title("Paid at (YYYY-MM-DD)").
Value(&paidAt).
Validate(paymentDate),
Validate(components.Date),
huh.NewInput().
Title("Paid for (YYYY-MM-DD)").
Value(&paidForDate).
Validate(paymentDate),
Validate(components.Date),
huh.NewInput().
Title("Note").
Value(&values.Note),
Expand All @@ -163,12 +163,8 @@ func paymentForm(
values.AmountMinor, _ = strconv.Atoi(amountMinor)
values.Currency = strings.ToUpper(strings.TrimSpace(values.Currency))
values.Note = strings.TrimSpace(values.Note)
values.PaidAt, _ = time.ParseInLocation(
components.DateLayout, paidAt, time.Local,
)
values.PaidForDate, _ = time.ParseInLocation(
components.DateLayout, paidForDate, time.Local,
)
values.PaidAt, _ = components.ParseDate(paidAt)
values.PaidForDate, _ = components.ParseDate(paidForDate)
if payment == nil {
return stor.CreatePayment(ctx, values)
}
Expand All @@ -190,10 +186,3 @@ func deletePaymentForm(
},
)
}

func paymentDate(value string) error {
if _, err := time.Parse(components.DateLayout, value); err != nil {
return errors.New("date must use YYYY-MM-DD")
}
return nil
}
5 changes: 3 additions & 2 deletions internal/tui/screens/payments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"time"

"chankat/internal/storage"
"chankat/internal/tui/components"
)

func TestPaymentItem(t *testing.T) {
Expand All @@ -31,10 +32,10 @@ func TestPaymentItem(t *testing.T) {
}

func TestPaymentDate(t *testing.T) {
if err := paymentDate("2026-07-20"); err != nil {
if err := components.Date("2026-07-20"); err != nil {
t.Fatalf("valid date rejected: %v", err)
}
if err := paymentDate("20/07/2026"); err == nil {
if err := components.Date("20/07/2026"); err == nil {
t.Fatal("invalid date accepted")
}
}
Loading