Skip to main content

Welcome Contributors!

toni is a small, focused project that welcomes contributions of all kinds:
  • Bug fixes and error handling improvements
  • UI/UX enhancements
  • New features (restaurant photos, export, tags, etc.)
  • Performance optimizations
  • Documentation improvements
  • Test coverage
Before starting major work, open an issue to discuss the approach. This prevents duplicate effort and ensures alignment with project goals.

Getting Started

1

Fork the repository

Click Fork on GitHub to create your own copy
2

Clone your fork

git clone https://github.com/YOUR_USERNAME/toni.git
cd toni
3

Create a branch

git checkout -b fix/visit-date-validation
# or
git checkout -b feature/export-csv
4

Make changes

Edit code, test locally, commit frequently
5

Push and open PR

git push origin fix/visit-date-validation
Then open a Pull Request on GitHub

Code Style

Go Conventions

toni follows standard Go style: Key conventions:
// Exported types: PascalCase
type RestaurantDetail struct { /* ... */ }

// Unexported types: camelCase
type visitFormState struct { /* ... */ }

// Constants: PascalCase or ALL_CAPS for exported
const DefaultTimeout = 5 * time.Second
const maxRetries = 3

// Acronyms: uppercase (ID, not Id; URL, not Url)
type VisitID int64

Project-Specific Conventions

Layer Boundaries

Database layer (internal/db/):
  • Functions accept *sql.DB as first parameter
  • Return domain types from internal/model
  • Never return *sql.Rows or sql.Result directly
  • Use descriptive function names: GetRestaurantWithStats, not GetRestaurant2
// Good
func ListVisits(db *sql.DB, filter string) ([]model.VisitRow, error)

// Bad
func GetVisits(db *sql.DB) (*sql.Rows, error)

Message Types

Bubble Tea messages (internal/model/msg.go):
  • Suffix with Msg: VisitsLoadedMsg, not VisitsLoaded
  • Include context for debugging: operation type, before/after state
  • Document when messages trigger commands
// Good: includes undo context
type VisitSavedMsg struct {
    ID        int64
    Operation string  // "insert" or "update"
    Before    *Visit  // nil for insert
    After     Visit
}

// Bad: not enough context
type VisitSavedMsg struct {
    ID int64
}

UI Components

Bubble Tea models (internal/ui/):
  • Implement Init(), Update(), View() interface
  • Keep View() pure - no side effects
  • Use tea.Cmd for async operations, never block in Update()
// Good: async database query
func (m Model) handleSave() (tea.Model, tea.Cmd) {
    return m, saveVisitCmd(m.db, m.visitForm.data)
}

func saveVisitCmd(db *sql.DB, data VisitData) tea.Cmd {
    return func() tea.Msg {
        // Runs in background
        id, err := db.CreateVisit(db, data)
        if err != nil {
            return ErrorMsg{Err: err}
        }
        return VisitSavedMsg{ID: id}
    }
}

// Bad: blocking in Update
func (m Model) handleSave() (tea.Model, tea.Cmd) {
    id, err := db.CreateVisit(m.db, m.visitForm.data) // BLOCKS!
    // ...
}

Styling

Lip Gloss styles (internal/ui/styles.go):
  • Define styles as package-level variables
  • Reuse base styles, compose with Copy()
  • Use semantic names: ErrorStyle, not RedText
// Base styles
var (
    ColorPrimary = lipgloss.Color("#7D56F4")
    ColorError   = lipgloss.Color("#FF6F61")
)

// Composed styles
var ErrorStyle = lipgloss.NewStyle().
    Foreground(ColorError).
    Bold(true)

var ErrorBanner = ErrorStyle.Copy().
    Padding(1, 2).
    Border(lipgloss.RoundedBorder())

Testing

Manual Testing

Before submitting a PR, test:

Happy Path

  • Create restaurant
  • Log visit with rating
  • Edit visit
  • Delete visit

Edge Cases

  • Empty database
  • Invalid date formats
  • Missing required fields
  • Keyboard shortcuts

Terminal Sizes

  • Minimum (72×18)
  • Standard (100×30)
  • Large (200×50)

Terminals

  • iTerm2 / Alacritty
  • Windows Terminal
  • Basic xterm
Test database:
# Use a throwaway database
go run main.go --db ./test.db

# Start fresh each test
rm ./test.db && go run main.go --db ./test.db

Automated Tests

When adding tests (currently none exist):
// internal/db/visits_test.go
func TestCreateVisit(t *testing.T) {
    db := setupTestDB(t)
    defer db.Close()
    
    visit := model.NewVisit{
        RestaurantID: 1,
        VisitedOn:    "2026-03-04",
        Rating:       ptr(8.5),
    }
    
    id, err := CreateVisit(db, visit)
    if err != nil {
        t.Fatalf("CreateVisit failed: %v", err)
    }
    if id == 0 {
        t.Error("Expected non-zero ID")
    }
}
Test helpers:
  • setupTestDB(t) - Create in-memory SQLite DB
  • ptr(v) - Helper for pointer values
  • Table-driven tests for multiple cases

Pull Request Guidelines

PR Title Format

Use conventional commits style:
feat: add CSV export functionality
fix: correct date validation in visit form
docs: update architecture diagram
refactor: extract table component
perf: optimize visit list query
test: add database layer tests
Prefixes:
  • feat: - New feature
  • fix: - Bug fix
  • docs: - Documentation only
  • refactor: - Code restructuring, no behavior change
  • perf: - Performance improvement
  • test: - Add or update tests
  • chore: - Build, dependencies, tooling

PR Description Template

## Summary
Brief description of what this PR does.

## Motivation
Why is this change needed? What problem does it solve?

## Changes
- List specific changes
- One per line
- Be specific

## Testing
How did you test this?
- [ ] Tested in iTerm2
- [ ] Tested with empty database
- [ ] Tested keyboard shortcuts

## Screenshots (if UI change)
[Attach before/after screenshots]

## Checklist
- [ ] Code follows project style
- [ ] Manual testing completed
- [ ] No new warnings or errors
- [ ] Documentation updated (if needed)

Review Process

1

Self-review

Before requesting review:
  • Read your own diff
  • Run go fmt ./...
  • Test all changed functionality
  • Check for leftover debug code
2

CI checks

Wait for automated checks (when added):
  • Build succeeds
  • Tests pass
  • Linter passes
3

Maintainer review

Maintainers will:
  • Review code quality
  • Test functionality
  • Suggest improvements
  • Approve or request changes
4

Merge

After approval:
  • Squash and merge (usually)
  • Delete branch
Response time: Expect review within 3-5 days. Ping after 1 week if no response.

Common Contribution Patterns

Adding a New Screen

1

Define screen enum

// internal/model/msg.go
const (
    // ...
    ScreenExport Screen = iota
)
2

Create screen model

// internal/ui/export.go
type ExportModel struct {
    // state
}

func (m ExportModel) Update(msg tea.Msg) (ExportModel, tea.Cmd)
func (m ExportModel) View(width, height int) string
3

Add to root model

// internal/ui/app.go
type Model struct {
    // ...
    export *ExportModel
}
4

Handle in Update

case model.ScreenExport:
    if m.export != nil {
        content = m.export.View(m.width, contentHeight)
    }
5

Add navigation

case "x": // Export shortcut
    m.screen = model.ScreenExport
    m.export = NewExportModel()
    return m, nil

Adding a Database Column

1

Create migration

// internal/db/migrations.go (create if needed)
const migration002 = `
ALTER TABLE visits ADD COLUMN photos TEXT;
`
2

Update schema version

// internal/db/db.go
const schemaVersion = 2

func migrate(db *sql.DB) error {
    // Check current version, run migrations
}
3

Update model types

// internal/model/types.go
type Visit struct {
    // ...
    Photos string
}
4

Update queries

// internal/db/visits.go
const createVisitSQL = `
    INSERT INTO visits (restaurant_id, visited_on, rating, notes, photos)
    VALUES (?, ?, ?, ?, ?)
`
Always test migrations with existing databases! Don’t break user data.

Adding a Keybinding

1

Define key

// internal/ui/keys.go
type KeyMap struct {
    // ...
    Export key.Binding
}

func DefaultKeyMap() KeyMap {
    return KeyMap{
        // ...
        Export: key.NewBinding(
            key.WithKeys("x"),
            key.WithHelp("x", "export"),
        ),
    }
}
2

Handle in Update

// internal/ui/app.go
case "x":
    return m.handleExport()
3

Update help

// internal/ui/help.go
func RenderHelp(screen Screen, mode Mode, width int) string {
    // Add to relevant screen's help text
}

Development Tips

Debugging TUI Apps

Problem: fmt.Println() doesn’t work in full-screen TUI. Solution: Log to a file.
// At startup
logFile, _ := os.OpenFile("/tmp/toni.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
defer logFile.Close()
log.SetOutput(logFile)

// In code
log.Printf("Debug: visitID=%d, rating=%v", visitID, rating)
Then tail the log:
tail -f /tmp/toni.log

Quick Iteration

Watch for changes and rebuild:
# Install air (live reload)
go install github.com/cosmtrek/air@latest

# Run with auto-reload
air
Or use a simple watch script:
#!/bin/bash
while true; do
  go build -o toni . && ./toni --db ./test.db
  sleep 1
done

Testing Terminal Output

Capture rendered output:
// In test
import "github.com/charmbracelet/x/exp/teatest"

func TestVisitsList(t *testing.T) {
    model := ui.New(testDB, nil, ui.TerminalCapabilities{})
    tm := teatest.NewTestModel(t, model)
    
    tm.Send(tea.WindowSizeMsg{Width: 100, Height: 30})
    output := tm.FinalOutput(t)
    
    if !strings.Contains(output, "Visits") {
        t.Error("Expected 'Visits' in output")
    }
}

Project Goals and Non-Goals

Goals ✓

  • Local-first: All data in SQLite, zero cloud dependencies
  • Fast: Startup <100ms, renders <5ms
  • Keyboard-driven: Full navigation without mouse
  • Offline-capable: Core features work without network
  • Portable: Single binary, no installation

Non-Goals ✗

  • Multi-user: toni is single-user by design
  • Web UI: Terminal-only, no web interface planned
  • Mobile apps: Desktop/laptop terminals only
  • Complex analytics: Basic stats only, not a BI tool
  • Social features: No sharing, reviews, or friends
When in doubt, prioritize:
  1. Simplicity over features
  2. Performance over polish
  3. Keyboard over mouse
  4. Local over cloud

Getting Help

Stuck? Have questions?

Recognition

All contributors will be:
  • Listed in CONTRIBUTORS.md (when created)
  • Mentioned in release notes
  • Credited in commit history
Thank you for contributing to toni!

Build docs developers (and LLMs) love