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
23 changes: 13 additions & 10 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ func TestCLIResourceWorkflow(t *testing.T) {
"--ended-at", "2026-07-30T12:00:00Z", "--note", "second pass")
runCLI(t, stor, "payments", "create", "--project", "1",
"--amount-minor", "5000", "--currency", "usd",
"--paid-at", "2026-07-30", "--paid-for", "2026-07-01")
runCLI(t, stor, "payments", "update", "1", "--note", "deposit")
"--paid-at", "2026-07-30", "--paid-for", "2026-06-01")
runCLI(t, stor, "payments", "update", "1", "--note", "deposit",
"--paid-for", "discarded-value")

var rate rateOutput
decodeCLI(t, stor, &rate, "--json", "rates", "get", "1")
Expand Down Expand Up @@ -103,7 +104,8 @@ func TestCLIResourceWorkflow(t *testing.T) {

var payment paymentOutput
decodeCLI(t, stor, &payment, "--json", "payments", "get", "1")
if payment.ProjectName != "Acme Corp" || payment.Note != "deposit" {
if payment.ProjectName != "Acme Corp" || payment.Note != "deposit" ||
payment.PaidAt != "2026-07-30" {
t.Fatalf("unexpected payment: %#v", payment)
}

Expand Down Expand Up @@ -177,21 +179,22 @@ func TestCLIDashboardCustomPeriod(t *testing.T) {
"--amount-minor", "10000", "--currency", "USD")
runCLI(t, stor, "projects", "create", "--name", "Acme", "--rate", "1")
runCLI(t, stor, "tasks", "create", "--name", "Build", "--project", "1",
"--started-at", "2026-08-02T23:00:00Z",
"--ended-at", "2026-08-03T01:00:00Z")
"--started-at", "2026-08-02 23:00",
"--ended-at", "2026-08-03 01:00")
runCLI(t, stor, "payments", "create", "--project", "1",
"--amount-minor", "5000", "--currency", "USD",
"--paid-at", "2026-08-09", "--paid-for", "2026-08-03")
"--amount-minor", "20000", "--currency", "USD",
"--paid-at", "2026-08-09")

output := runCLIAt(t, stor, time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC),
output := runCLIAt(t, stor, time.Date(2026, 8, 9, 12, 0, 0, 0, time.Local),
"--json", "dashboard", "--from", "2026-08-03", "--to", "2026-08-03")
var got dashboardOutput
if err := json.Unmarshal([]byte(output), &got); err != nil {
t.Fatal(err)
}
if got.Period != "custom" || got.TrackedSeconds != 3600 ||
got.EarnedMinor["USD"] != 10_000 || got.PaidMinor["USD"] != 5_000 ||
len(got.Projects) != 1 {
got.EarnedMinor["USD"] != 10_000 || got.PaidMinor["USD"] != 0 ||
got.BalanceMinor["USD"] != 0 || len(got.Projects) != 1 ||
got.Projects[0].BalanceMinor["USD"] != 0 {
t.Fatalf("dashboard = %#v", got)
}
}
Expand Down
22 changes: 14 additions & 8 deletions internal/cli/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type dashboardOutput struct {
EarnedMinor map[string]int64 `json:"earned_minor"`
PaidMinor map[string]int64 `json:"paid_minor"`
NetMinor map[string]int64 `json:"net_minor"`
BalanceMinor map[string]int64 `json:"balance_minor"`
Projects []dashboardProjectOutput `json:"projects"`
}

Expand All @@ -25,6 +26,7 @@ type dashboardProjectOutput struct {
EarnedMinor map[string]int64 `json:"earned_minor"`
PaidMinor map[string]int64 `json:"paid_minor"`
NetMinor map[string]int64 `json:"net_minor"`
BalanceMinor map[string]int64 `json:"balance_minor"`
Tasks []dashboardTaskOutput `json:"tasks"`
}

Expand Down Expand Up @@ -126,15 +128,19 @@ func (r runner) runDashboard(args []string) error {
}
rows := make([]string, len(output.Projects)+1)
for i, project := range output.Projects {
rows[i] = fmt.Sprintf("%s\t%s\t%s\t%s\t%s",
rows[i] = fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s",
project.ProjectName, formatTracked(project.TrackedSeconds),
formatMinorMap(project.EarnedMinor), formatMinorMap(project.PaidMinor),
formatMinorMap(project.NetMinor))
formatMinorMap(project.NetMinor), formatMinorMap(project.BalanceMinor))
}
rows[len(rows)-1] = fmt.Sprintf("TOTAL\t%s\t%s\t%s\t%s",
rows[len(rows)-1] = fmt.Sprintf("TOTAL\t%s\t%s\t%s\t%s\t%s",
formatTracked(output.TrackedSeconds), formatMinorMap(output.EarnedMinor),
formatMinorMap(output.PaidMinor), formatMinorMap(output.NetMinor))
return r.table("PROJECT\tTRACKED\tEARNED_MINOR\tPAID_MINOR\tNET_MINOR", rows)
formatMinorMap(output.PaidMinor), formatMinorMap(output.NetMinor),
formatMinorMap(output.BalanceMinor))
return r.table(
"PROJECT\tTRACKED\tEARNED_MINOR\tPAID_MINOR\tNET_MINOR\tBALANCE_MINOR",
rows,
)
}

func filterDashboardEntries(entries []storage.Entry, projectID int) []storage.Entry {
Expand Down Expand Up @@ -164,7 +170,7 @@ func makeDashboardOutput(
output := dashboardOutput{
Period: string(period.Kind), TrackedSeconds: int64(summary.Tracked / time.Second),
EarnedMinor: summary.EarnedMinor, PaidMinor: summary.PaidMinor,
NetMinor: summary.NetMinor,
NetMinor: summary.NetMinor, BalanceMinor: summary.BalanceMinor,
Projects: make([]dashboardProjectOutput, 0, len(summary.Projects)),
}
if period.Kind == "" {
Expand All @@ -181,8 +187,8 @@ func makeDashboardOutput(
ProjectID: project.ProjectID, ProjectName: project.ProjectName,
TrackedSeconds: int64(project.Tracked / time.Second),
EarnedMinor: project.EarnedMinor, PaidMinor: project.PaidMinor,
NetMinor: project.NetMinor,
Tasks: make([]dashboardTaskOutput, 0, len(project.Tasks)),
NetMinor: project.NetMinor, BalanceMinor: project.BalanceMinor,
Tasks: make([]dashboardTaskOutput, 0, len(project.Tasks)),
}
for _, task := range project.Tasks {
item.Tasks = append(item.Tasks, dashboardTaskOutput{
Expand Down
5 changes: 1 addition & 4 deletions internal/cli/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ type paymentOutput struct {
AmountMinor int `json:"amount_minor"`
Currency string `json:"currency"`
PaidAt string `json:"paid_at"`
PaidForDate string `json:"paid_for_date"`
Note string `json:"note"`
}

Expand Down Expand Up @@ -171,9 +170,7 @@ func paymentOutputs(
ID: payment.ID, ProjectID: payment.ProjectID,
ProjectName: projectNames[payment.ProjectID],
AmountMinor: payment.AmountMinor, Currency: payment.Currency,
PaidAt: payment.PaidAt.Format("2006-01-02"),
PaidForDate: payment.PaidForDate.Format("2006-01-02"),
Note: payment.Note,
PaidAt: payment.PaidAt.Format("2006-01-02"), Note: payment.Note,
}
}
return result
Expand Down
28 changes: 9 additions & 19 deletions internal/cli/payments.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,12 @@ func (r runner) listPayments(args []string) error {
}
rows := make([]string, len(output))
for i, item := range output {
rows[i] = fmt.Sprintf("%d\t%d\t%s\t%d\t%s\t%s\t%s\t%s", item.ID,
rows[i] = fmt.Sprintf("%d\t%d\t%s\t%d\t%s\t%s\t%s", item.ID,
item.ProjectID, item.ProjectName, item.AmountMinor, item.Currency,
item.PaidAt, item.PaidForDate, item.Note)
item.PaidAt, item.Note)
}
return r.table(
"ID\tPROJECT_ID\tPROJECT\tAMOUNT_MINOR\tCURRENCY\tPAID_AT\tPAID_FOR\tNOTE",
"ID\tPROJECT_ID\tPROJECT\tAMOUNT_MINOR\tCURRENCY\tPAID_AT\tNOTE",
rows,
)
}
Expand All @@ -86,10 +86,10 @@ func (r runner) getPayment(args []string) error {
return r.writeJSON(output)
}
return r.table(
"ID\tPROJECT_ID\tPROJECT\tAMOUNT_MINOR\tCURRENCY\tPAID_AT\tPAID_FOR\tNOTE",
[]string{fmt.Sprintf("%d\t%d\t%s\t%d\t%s\t%s\t%s\t%s", output.ID,
"ID\tPROJECT_ID\tPROJECT\tAMOUNT_MINOR\tCURRENCY\tPAID_AT\tNOTE",
[]string{fmt.Sprintf("%d\t%d\t%s\t%d\t%s\t%s\t%s", output.ID,
output.ProjectID, output.ProjectName, output.AmountMinor,
output.Currency, output.PaidAt, output.PaidForDate, output.Note)},
output.Currency, output.PaidAt, output.Note)},
)
}

Expand All @@ -100,7 +100,7 @@ func (r runner) createPayment(args []string) error {
amount := flags.Int("amount-minor", 0, "amount in minor units")
currency := flags.String("currency", "", "three-letter currency code")
paidAt := flags.String("paid-at", today, "payment date")
paidFor := flags.String("paid-for", today, "date the payment covers")
flags.String("paid-for", "", "legacy payment date (ignored)")
note := flags.String("note", "", "payment note")
if err := flags.Parse(args); err != nil {
return err
Expand All @@ -115,13 +115,9 @@ func (r runner) createPayment(args []string) error {
if err != nil {
return err
}
paidForValue, err := parseDate(*paidFor)
if err != nil {
return err
}
id, err := r.stor.CreatePaymentID(r.ctx, storage.Payment{
ProjectID: *projectID, AmountMinor: *amount, Currency: *currency,
PaidAt: paidAtValue, PaidForDate: paidForValue, Note: *note,
PaidAt: paidAtValue, Note: *note,
})
if err != nil {
return err
Expand All @@ -146,8 +142,7 @@ func (r runner) updatePayment(args []string) error {
amount := flags.Int("amount-minor", payment.AmountMinor, "amount in minor units")
currency := flags.String("currency", payment.Currency, "three-letter currency code")
paidAt := flags.String("paid-at", payment.PaidAt.Format(dateLayout), "payment date")
paidFor := flags.String("paid-for", payment.PaidForDate.Format(dateLayout),
"date the payment covers")
flags.String("paid-for", "", "legacy payment date (ignored)")
note := flags.String("note", payment.Note, "payment note")
if err := flags.Parse(args[1:]); err != nil {
return err
Expand All @@ -162,15 +157,10 @@ func (r runner) updatePayment(args []string) error {
if err != nil {
return err
}
paidForValue, err := parseDate(*paidFor)
if err != nil {
return err
}
payment.ProjectID = *projectID
payment.AmountMinor = *amount
payment.Currency = *currency
payment.PaidAt = paidAtValue
payment.PaidForDate = paidForValue
payment.Note = *note
if err := r.stor.UpdatePayment(r.ctx, payment); err != nil {
return err
Expand Down
4 changes: 3 additions & 1 deletion internal/cli/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ func (r runner) loadProjects() ([]storage.ProjectSummary, error) {
if err != nil {
return nil, err
}
return storage.SummarizeProjects(projects, rates, entries, payments), nil
return storage.SummarizeProjects(
projects, rates, entries, payments, r.now(),
), nil
}

func (r runner) listProjects(args []string) error {
Expand Down
3 changes: 0 additions & 3 deletions internal/storage/constraints.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,6 @@ func validatePayment(payment Payment) error {
if payment.PaidAt.IsZero() {
return fmt.Errorf("paid-at date is required")
}
if payment.PaidForDate.IsZero() {
return fmt.Errorf("paid-for date is required")
}
return nil
}

Expand Down
78 changes: 54 additions & 24 deletions internal/storage/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@ import (
)

type DashboardSummary struct {
Tracked time.Duration
EarnedMinor map[string]int64
PaidMinor map[string]int64
NetMinor map[string]int64
Projects []DashboardProjectSummary
Tracked time.Duration
EarnedMinor map[string]int64
PaidMinor map[string]int64
NetMinor map[string]int64
BalanceMinor map[string]int64
Projects []DashboardProjectSummary
}

type DashboardProjectSummary struct {
ProjectID *int
ProjectName string
Tracked time.Duration
EarnedMinor map[string]int64
PaidMinor map[string]int64
NetMinor map[string]int64
Tasks []DashboardTaskSummary
ProjectID *int
ProjectName string
Tracked time.Duration
EarnedMinor map[string]int64
PaidMinor map[string]int64
NetMinor map[string]int64
BalanceMinor map[string]int64
Tasks []DashboardTaskSummary
}

type DashboardTaskSummary struct {
Expand Down Expand Up @@ -49,8 +51,10 @@ type dashboardProjectTotals struct {
tasks map[int]*dashboardTaskTotals
}

// SummarizeDashboard reports activity in the half-open interval [start, end).
// A zero start means there is no lower bound. Active entries stop at now.
// SummarizeDashboard reports work and receipts in the half-open interval
// [start, end). BalanceMinor is the current project ledger balance through now,
// independent of the selected interval. A zero start means there is no lower
// bound. Active entries stop at now.
func SummarizeDashboard(
projects []Project,
tasks []Task,
Expand All @@ -71,6 +75,18 @@ func SummarizeDashboard(
}
ratesByID := RatesByID(rates)
totals := make(map[int]*dashboardProjectTotals)
periodLocation := now.Location()
if !start.IsZero() {
periodLocation = start.Location()
} else if !end.IsZero() {
periodLocation = end.Location()
}
balances := make(map[int]map[string]int64, len(projects))
for _, project := range SummarizeProjects(
projects, rates, entries, payments, now,
) {
balances[project.ID] = project.BalanceMinor
}

projectTotals := func(projectID int) *dashboardProjectTotals {
if item, ok := totals[projectID]; ok {
Expand Down Expand Up @@ -137,34 +153,45 @@ func SummarizeDashboard(
}

for _, payment := range payments {
if !start.IsZero() && payment.PaidForDate.Before(start) {
paidAt := paymentDateInLocation(payment.PaidAt, periodLocation)
if !now.IsZero() && paidAt.After(now) {
continue
}
if !start.IsZero() && paidAt.Before(start) {
continue
}
if !end.IsZero() && !payment.PaidForDate.Before(end) {
if !end.IsZero() && !paidAt.Before(end) {
continue
}
projectTotals(payment.ProjectID).paid[payment.Currency] +=
int64(payment.AmountMinor)
}

result := DashboardSummary{
EarnedMinor: make(map[string]int64),
PaidMinor: make(map[string]int64),
NetMinor: make(map[string]int64),
EarnedMinor: make(map[string]int64),
PaidMinor: make(map[string]int64),
NetMinor: make(map[string]int64),
BalanceMinor: make(map[string]int64),
}
for projectID, totals := range totals {
project := DashboardProjectSummary{
ProjectName: totals.name,
Tracked: totals.tracked,
EarnedMinor: minorSecondsToAmounts(totals.minorSeconds),
PaidMinor: cloneAmounts(totals.paid),
ProjectName: totals.name,
Tracked: totals.tracked,
EarnedMinor: minorSecondsToAmounts(totals.minorSeconds),
PaidMinor: cloneAmounts(totals.paid),
BalanceMinor: cloneAmounts(balances[projectID]),
}
if projectID != 0 {
id := projectID
project.ProjectID = &id
}
project.NetMinor = subtractAmounts(project.EarnedMinor, project.PaidMinor)

taskMinorSeconds := make(map[int]map[string]int64, len(totals.tasks))
for taskID, taskTotals := range totals.tasks {
taskMinorSeconds[taskID] = taskTotals.minorSeconds
}
taskAmounts := groupedMinorSecondsToAmounts(taskMinorSeconds)
for taskID, totals := range totals.tasks {
taskName := taskNames[taskID]
if taskID == 0 {
Expand All @@ -174,7 +201,7 @@ func SummarizeDashboard(
}
task := DashboardTaskSummary{
TaskName: taskName, Tracked: totals.tracked,
EarnedMinor: minorSecondsToAmounts(totals.minorSeconds),
EarnedMinor: taskAmounts[taskID],
}
if taskID != 0 {
id := taskID
Expand All @@ -195,6 +222,9 @@ func SummarizeDashboard(
result.Projects = append(result.Projects, project)
}
result.NetMinor = subtractAmounts(result.EarnedMinor, result.PaidMinor)
for _, balance := range balances {
addAmounts(result.BalanceMinor, balance)
}
sort.Slice(result.Projects, func(i, j int) bool {
if result.Projects[i].Tracked != result.Projects[j].Tracked {
return result.Projects[i].Tracked > result.Projects[j].Tracked
Expand Down
Loading