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
11 changes: 9 additions & 2 deletions cmd/entries/manual.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/DylanDevelops/tmpo/internal/config"
"github.com/DylanDevelops/tmpo/internal/currency"
"github.com/DylanDevelops/tmpo/internal/project"
"github.com/DylanDevelops/tmpo/internal/storage"
"github.com/DylanDevelops/tmpo/internal/ui"
Expand Down Expand Up @@ -167,9 +168,15 @@ func ManualCmd() *cobra.Command {
}

if entry.HourlyRate != nil {
// Get currency from config, fallback to USD
currencyCode := "USD"
if cfg, _, err := config.FindAndLoad(); err == nil {
currencyCode = cfg.GetCurrencyOrDefault()
}

earnings := entry.RoundedHours() * *entry.HourlyRate
fmt.Printf(" %s %s\n", ui.BoldInfo("Hourly Rate:"), fmt.Sprintf("$%.2f", *entry.HourlyRate))
fmt.Printf(" %s %s\n", ui.BoldInfo("Earnings:"), fmt.Sprintf("$%.2f", earnings))
fmt.Printf(" %s %s\n", ui.BoldInfo("Hourly Rate:"), currency.FormatCurrency(*entry.HourlyRate, currencyCode))
fmt.Printf(" %s %s\n", ui.BoldInfo("Earnings:"), currency.FormatCurrency(earnings, currencyCode))
}

ui.NewlineBelow()
Expand Down
24 changes: 20 additions & 4 deletions cmd/history/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"sort"
"time"

"github.com/DylanDevelops/tmpo/internal/config"
"github.com/DylanDevelops/tmpo/internal/currency"
"github.com/DylanDevelops/tmpo/internal/storage"
"github.com/DylanDevelops/tmpo/internal/ui"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -119,13 +121,15 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) {
}
}

currencyCode := getCurrencyCode()

ui.PrintSuccess(ui.EmojiStats, fmt.Sprintf("Stats for %s", ui.Bold(periodName)))
fmt.Println()
ui.PrintInfo(4, ui.Bold("Total Time"), fmt.Sprintf("%s (%.2f hours)", ui.FormatDuration(totalDuration), totalDuration.Hours()))
ui.PrintInfo(4, ui.Bold("Total Entries"), fmt.Sprintf("%d", len(entries)))

if hasAnyEarnings {
ui.PrintInfo(4, ui.Bold("Earnings"), fmt.Sprintf("$%.2f", totalEarnings))
ui.PrintInfo(4, ui.Bold("Earnings"), currency.FormatCurrency(totalEarnings, currencyCode))
}

fmt.Println()
Expand All @@ -144,7 +148,7 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) {
fmt.Printf(" %s %s (%.1f%%)\n", ui.Bold(fmt.Sprintf("%-20s", project)), ui.FormatDuration(duration), percentage)

if earnings, ok := projectEarnings[project]; ok && earnings > 0 {
fmt.Printf(" %s %s\n", ui.Muted("└─ Earnings:"), fmt.Sprintf("$%.2f", earnings))
fmt.Printf(" %s %s\n", ui.Muted("└─ Earnings:"), currency.FormatCurrency(earnings, currencyCode))
}
}

Expand Down Expand Up @@ -193,14 +197,15 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) {
}

allProjects, _ := db.GetAllProjects()
currencyCode := getCurrencyCode()

ui.PrintSuccess(ui.EmojiStats, ui.Bold("All-Time Statistics"))
ui.PrintInfo(4, ui.Bold("Total Time"), fmt.Sprintf("%s (%.2f hours)", ui.FormatDuration(totalDuration), totalDuration.Hours()))
ui.PrintInfo(4, ui.Bold("Total Entries"), fmt.Sprintf("%d", len(entries)))
ui.PrintInfo(4, ui.Bold("Projects Tracked"), fmt.Sprintf("%d", len(allProjects)))

if hasAnyEarnings {
ui.PrintInfo(4, ui.Bold("Earnings"), fmt.Sprintf("$%.2f", totalEarnings))
ui.PrintInfo(4, ui.Bold("Earnings"), currency.FormatCurrency(totalEarnings, currencyCode))
}

fmt.Println()
Expand All @@ -219,9 +224,20 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) {
fmt.Printf(" %s %s (%.1f%%)\n", ui.Bold(fmt.Sprintf("%-20s", project)), ui.FormatDuration(duration), percentage)

if earnings, ok := projectEarnings[project]; ok && earnings > 0 {
fmt.Printf(" %s %s\n", ui.Muted("└─ Earnings:"), fmt.Sprintf("$%.2f", earnings))
fmt.Printf(" %s %s\n", ui.Muted("└─ Earnings:"), currency.FormatCurrency(earnings, currencyCode))
}
}

ui.NewlineBelow()
}

// getCurrencyCode attempts to load the currency code from the current project's
// .tmporc configuration file. If no config is found or currency is not set,
// it returns "USD" as the default.
func getCurrencyCode() string {
cfg, _, err := config.FindAndLoad()
if err != nil {
return "USD"
}
return cfg.GetCurrencyOrDefault()
}
49 changes: 47 additions & 2 deletions cmd/setup/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/DylanDevelops/tmpo/internal/config"
"github.com/DylanDevelops/tmpo/internal/currency"
"github.com/DylanDevelops/tmpo/internal/project"
"github.com/DylanDevelops/tmpo/internal/ui"
"github.com/manifoldco/promptui"
Expand Down Expand Up @@ -37,12 +38,14 @@ func InitCmd() *cobra.Command {
var name string
var hourlyRate float64
var description string
var currencyCode string

if acceptDefaults {
// Use all defaults without prompting
name = defaultName
hourlyRate = 0
description = ""
currencyCode = "USD"
} else {
// Interactive form
ui.PrintSuccess(ui.EmojiInit, "Initialize Project Configuration")
Expand Down Expand Up @@ -98,10 +101,27 @@ func InitCmd() *cobra.Command {
}

description = strings.TrimSpace(descInput)

// Currency prompt
currencyPrompt := promptui.Prompt{
Label: "Currency code (press Enter for USD)",
Validate: validateCurrency,
}

currencyInput, err := currencyPrompt.Run()
if err != nil {
ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err))
os.Exit(1)
}

currencyCode = strings.ToUpper(strings.TrimSpace(currencyInput))
if currencyCode == "" {
currencyCode = "USD"
}
}

// Create the .tmporc file
err := config.CreateWithTemplate(name, hourlyRate, description)
err := config.CreateWithTemplate(name, hourlyRate, description, currencyCode)
if err != nil {
ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err))
os.Exit(1)
Expand All @@ -110,11 +130,14 @@ func InitCmd() *cobra.Command {
fmt.Println()
ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created .tmporc for project %s", ui.Bold(name)))
if hourlyRate > 0 {
ui.PrintInfo(4, ui.Bold("Hourly Rate"), fmt.Sprintf("$%.2f", hourlyRate))
ui.PrintInfo(4, ui.Bold("Hourly Rate"), currency.FormatCurrency(hourlyRate, currencyCode))
}
if description != "" {
ui.PrintInfo(4, ui.Bold("Description"), description)
}
if currencyCode != "" && currencyCode != "USD" {
ui.PrintInfo(4, ui.Bold("Currency"), currencyCode)
}

fmt.Println()
ui.PrintMuted(0, "You can edit .tmporc to customize your project settings.")
Expand Down Expand Up @@ -168,3 +191,25 @@ func validateHourlyRate(input string) error {

return nil
}

// validateCurrency validates that the input is empty or a valid currency code format
func validateCurrency(input string) error {
input = strings.TrimSpace(input)
if input == "" {
return nil // Allow empty for default
}

// Currency codes should be 3 letters
if len(input) != 3 {
return fmt.Errorf("currency code must be 3 letters (e.g., USD, EUR, GBP)")
}

// Check that it's all letters
for _, char := range input {
if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') {
return fmt.Errorf("currency code must contain only letters")
}
}

return nil
}
58 changes: 54 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ tmpo init
# - Project name (defaults to auto-detected name)
# - Hourly rate (optional, press Enter to skip)
# - Description (optional, press Enter to skip)
# - Currency code (optional, defaults to USD)
```

For quick setup without prompts, use the `--accept-defaults` flag:
Expand Down Expand Up @@ -57,6 +58,9 @@ hourly_rate: 125.50

# [OPTIONAL] Description for this project
description: Client project for Acme Corp

# [OPTIONAL] Currency code for billing display (USD, EUR, GBP, JPY, etc.)
currency: USD
```

### Configuration Fields
Expand All @@ -73,12 +77,13 @@ project_name: Client Website Redesign

#### `hourly_rate` (optional)

Your billing rate in dollars per hour. When set, tmpo will calculate estimated earnings based on tracked time.
Your billing rate per hour. When set, tmpo will calculate estimated earnings based on tracked time. The currency symbol displayed is determined by the `currency` field (defaults to USD if not specified).

**Example:**

```yaml
hourly_rate: 150.00
currency: USD # Will display as $150.00
```

Set to `0` or omit to disable rate tracking:
Expand All @@ -97,6 +102,47 @@ A longer description or notes about the project. This is for your reference and
description: Q1 2024 website redesign for Acme Corp. Main contact: john@acme.com
```

#### `currency` (optional)

The ISO 4217 currency code for displaying billing rates and earnings. This determines the currency symbol shown in stats, reports, and summaries.

**Supported Currencies:**

tmpo supports 30+ currencies including:

- **Americas:** USD ($), CAD (CA$), BRL (R$), MXN (MX$)
- **Europe:** EUR (€), GBP (£), CHF (Fr), SEK (kr), NOK (kr)
- **Asia:** JPY (¥), CNY (¥), INR (₹), KRW (₩), SGD (S$)
- **Oceania:** AUD (A$), NZD (NZ$)

And many more. See the [full currency code list](https://en.wikipedia.org/wiki/ISO_4217#Active_codes).

**Examples:**

```yaml
# US-based project
hourly_rate: 150
currency: USD
# Displays as: $150.00

# European project
hourly_rate: 120
currency: EUR
# Displays as: €120.00

# UK project
hourly_rate: 100
currency: GBP
# Displays as: £100.00

# Japanese project
hourly_rate: 15000
currency: JPY
# Displays as: ¥15000.00
```

If not specified or if an unknown currency code is provided, `currency` defaults to USD. Currency codes are case-insensitive (USD, usd, or Usd all work).

## Project Detection Priority

When you run `tmpo start`, the project name is determined in this order:
Expand Down Expand Up @@ -150,13 +196,15 @@ tmpo init
# Project name: Client A - Web Development
# Hourly rate: 150
# Description: [press Enter to skip]
# Currency code: USD

# Client B - $175/hour
# Client B - £175/hour
cd ~/projects/client-b
tmpo init
# Project name: Client B - Game Development
# Hourly rate: 175
# Description: [press Enter to skip]
# Currency code: GBP

# Personal project - no billing
cd ~/projects/my-app
Expand All @@ -166,9 +214,11 @@ tmpo init --accept-defaults # Quick setup with defaults
Alternatively, you can manually create `.tmporc` files:

```bash
cat > ~/projects/client-a/.tmporc << EOF
project_name: Client A - Web Development
# Client configuration
cat > ~/projects/client-project/.tmporc << EOF
project_name: Client Project - Web Development
hourly_rate: 150.00
currency: USD
EOF
```

Expand Down
20 changes: 18 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"

"github.com/DylanDevelops/tmpo/internal/currency"
"go.yaml.in/yaml/v3"
)

Expand All @@ -14,12 +15,14 @@ import (
// ProjectName is the human-readable name of the project.
// HourlyRate is the billable hourly rate for the project; when zero it will be omitted from YAML.
// Description is an optional free-form description of the project; when empty it will be omitted from YAML.
// Currency is the ISO 4217 currency code (e.g., USD, EUR, GBP) for billing display; when empty it will be omitted from YAML.
//
// ! IMPORTANT When adding new fields to this struct, also update configTemplate below. !
type Config struct {
ProjectName string `yaml:"project_name"`
HourlyRate float64 `yaml:"hourly_rate,omitempty"`
Description string `yaml:"description,omitempty"`
Currency string `yaml:"currency,omitempty"`
}

// configTemplate is the template used when creating new .tmporc files via CreateWithTemplate.
Expand All @@ -29,6 +32,7 @@ type Config struct {
// %s - project name (string)
// %.2f - hourly rate (float64, 2 decimal places)
// %s - description (string)
// %s - currency code (string)
//
// ! IMPORTANT: When adding new fields to the Config struct above, update this template. !
const configTemplate = `# tmpo project configuration
Expand All @@ -42,6 +46,9 @@ hourly_rate: %.2f

# [OPTIONAL] Description for this project
description: "%s"

# [OPTIONAL] Currency code for billing display (USD, EUR, GBP, JPY, etc.)
currency: %s
`

// Load reads a YAML configuration file from the provided path and unmarshals it into a Config.
Expand Down Expand Up @@ -99,13 +106,13 @@ func Create(projectName string, hourlyRate float64) error {
// CreateWithTemplate creates a new .tmporc file with a user-friendly format that includes
// all fields (even if empty) and helpful comments. This provides a better user experience
// by showing all available configuration options.
func CreateWithTemplate(projectName string, hourlyRate float64, description string) error {
func CreateWithTemplate(projectName string, hourlyRate float64, description string, currency string) error {
tmporc := filepath.Join(".", ".tmporc")
if _, err := os.Stat(tmporc); err == nil {
return fmt.Errorf(".tmporc already exists")
}

content := fmt.Sprintf(configTemplate, projectName, hourlyRate, description)
content := fmt.Sprintf(configTemplate, projectName, hourlyRate, description, currency)

if err := os.WriteFile(tmporc, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write config: %w", err)
Expand Down Expand Up @@ -148,3 +155,12 @@ func FindAndLoad() (*Config, string, error) {

return nil, "", fmt.Errorf(".tmporc not found")
}

// GetCurrencyOrDefault returns the configured currency code, or "USD" if not set.
// This provides a convenient way to get the currency with a sensible default.
func (c *Config) GetCurrencyOrDefault() string {
if c.Currency == "" {
return currency.DefaultCurrency
}
return c.Currency
}
Loading