Skip to content

Commit 7ec5e80

Browse files
cpcloudclaude
andauthored
feat(ui): format phone numbers for display (#873)
## Summary - Add `FormatPhoneNumber` in `internal/locale/phone.go` using `nyaruka/phonenumbers` for international phone formatting (NATIONAL for same-region, INTERNATIONAL for foreign numbers) - Add `Locale` field to `Vendor` model (ISO 3166-1 alpha-2, empty = system default via `DetectCountry()`) - Add `cellTelephoneNumber` kind with explicit exhaustive switch cases - Format phones in the TUI vendor table, CLI `show vendors` text output, and extraction preview - Preserve `Locale` through form round-trips; leave `FindOrCreateVendor` untouched - JSON `show vendors` output keeps raw phone values for machine consumption - Handle shared calling codes (+1 US/CA) by checking `+` prefix presence for fictional numbers closes #860 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f2f357a commit 7ec5e80

25 files changed

Lines changed: 1370 additions & 27 deletions

cmd/micasa/show.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import (
1212
"text/tabwriter"
1313
"time"
1414

15+
"github.com/micasa-dev/micasa/internal/config"
1516
"github.com/micasa-dev/micasa/internal/data"
17+
"github.com/micasa-dev/micasa/internal/locale"
1618
"github.com/spf13/cobra"
1719
"gorm.io/gorm"
1820
)
@@ -447,7 +449,13 @@ var vendorCols = []showCol[data.Vendor]{
447449
{"NAME", func(v data.Vendor) string { return fmtStr(v.Name) }},
448450
{"CONTACT", func(v data.Vendor) string { return fmtStr(v.ContactName) }},
449451
{"EMAIL", func(v data.Vendor) string { return fmtStr(v.Email) }},
450-
{"PHONE", func(v data.Vendor) string { return fmtStr(v.Phone) }},
452+
{"PHONE", func(v data.Vendor) string {
453+
region := strings.ToUpper(config.DetectCountry())
454+
if v.Locale != "" {
455+
region = strings.ToUpper(v.Locale)
456+
}
457+
return fmtStr(locale.FormatPhoneNumber(v.Phone, region))
458+
}},
451459
{"WEBSITE", func(v data.Vendor) string { return fmtStr(v.Website) }},
452460
}
453461

cmd/micasa/show_test.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,14 +169,16 @@ func TestShowProjectsJSON(t *testing.T) {
169169
}
170170

171171
func TestShowVendorsText(t *testing.T) {
172-
t.Parallel()
172+
// Not parallel: t.Setenv modifies process-global state.
173+
// LC_ALL has highest precedence in DetectCountry().
174+
t.Setenv("LC_ALL", "en_US.UTF-8")
173175
store := newTestStoreWithMigration(t)
174176

175177
require.NoError(t, store.CreateVendor(&data.Vendor{
176178
Name: "Acme Plumbing",
177179
ContactName: "John Doe",
178180
Email: "john@acme.com",
179-
Phone: "555-1234",
181+
Phone: "5551234567",
180182
}))
181183

182184
var buf bytes.Buffer
@@ -187,7 +189,7 @@ func TestShowVendorsText(t *testing.T) {
187189
assert.Contains(t, out, "Acme Plumbing")
188190
assert.Contains(t, out, "John Doe")
189191
assert.Contains(t, out, "john@acme.com")
190-
assert.Contains(t, out, "555-1234")
192+
assert.Contains(t, out, "(555) 123-4567")
191193
}
192194

193195
func TestShowVendorsJSON(t *testing.T) {
@@ -210,6 +212,25 @@ func TestShowVendorsJSON(t *testing.T) {
210212
assert.NotEmpty(t, result[0]["id"])
211213
}
212214

215+
func TestShowVendorsJSONPhoneRaw(t *testing.T) {
216+
t.Parallel()
217+
store := newTestStoreWithMigration(t)
218+
219+
require.NoError(t, store.CreateVendor(&data.Vendor{
220+
Name: "Raw Phone Co",
221+
Phone: "5551234567",
222+
}))
223+
224+
var buf bytes.Buffer
225+
require.NoError(t, runShow(&buf, store, "vendors", true, false))
226+
227+
var result []map[string]any
228+
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
229+
require.Len(t, result, 1)
230+
assert.Equal(t, "5551234567", result[0]["phone"],
231+
"JSON output must carry raw phone, not formatted")
232+
}
233+
213234
func TestShowAppliancesText(t *testing.T) {
214235
t.Parallel()
215236
store := newTestStoreWithMigration(t)

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ require (
1717
github.com/itchyny/gojq v0.12.18
1818
github.com/mark3labs/mcp-go v0.46.0
1919
github.com/mozilla-ai/any-llm-go v0.9.0
20+
github.com/nyaruka/phonenumbers v1.6.12
2021
github.com/oklog/ulid/v2 v2.1.1
2122
github.com/spf13/cobra v1.10.2
2223
github.com/stretchr/testify v1.11.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
193193
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
194194
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
195195
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
196+
github.com/nyaruka/phonenumbers v1.6.12 h1:aeGHjGQnfLhdN5/mZPevhoYMs13FWcQ0Vus0YQHh1Ec=
197+
github.com/nyaruka/phonenumbers v1.6.12/go.mod h1:IUu45lj2bSeYXQuxDyyuzOrdV10tyRa1YSsfH8EKN5c=
196198
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
197199
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
198200
github.com/ollama/ollama v0.18.3 h1:sOJhncLeA+ZlwLLjEksY393wojeYE52E1CF4E0ZgFN4=

internal/app/coldefs.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ var vendorColumnDefs = []columnDef{
218218
{"Name", columnSpec{Title: "Name", Min: 14, Max: 24, Flex: true}},
219219
{"Contact", columnSpec{Title: "Contact", Min: 10, Max: 20, Flex: true}},
220220
{"Email", columnSpec{Title: "Email", Min: 12, Max: 24, Flex: true}},
221-
{"Phone", columnSpec{Title: "Phone", Min: 12, Max: 16}},
221+
{"Phone", columnSpec{Title: "Phone", Min: 12, Max: 20, Kind: cellTelephoneNumber}},
222222
{"Website", columnSpec{Title: "Website", Min: 12, Max: 28, Flex: true}},
223223
{
224224
"Quotes",

internal/app/extraction_render.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"time"
1313

1414
"charm.land/lipgloss/v2"
15+
"github.com/micasa-dev/micasa/internal/config"
1516
"github.com/micasa-dev/micasa/internal/data"
1617
"github.com/micasa-dev/micasa/internal/extract"
1718
"github.com/micasa-dev/micasa/internal/locale"
@@ -707,7 +708,7 @@ func previewColumns(tableName string, cur locale.Currency) []previewColDef {
707708
{data.ColName, s[1], fmtAnyText},
708709
{data.ColContactName, s[2], fmtAnyText},
709710
{data.ColEmail, s[3], fmtAnyText},
710-
{data.ColPhone, s[4], fmtAnyText},
711+
{data.ColPhone, s[4], fmtPhone},
711712
{data.ColWebsite, s[5], fmtAnyText},
712713
}
713714
case tableDocuments:
@@ -873,6 +874,17 @@ func fmtAnyText(v any) string {
873874
}
874875
}
875876

877+
func fmtPhone(v any) string {
878+
if v == nil {
879+
return ""
880+
}
881+
s, ok := v.(string)
882+
if !ok {
883+
return fmtAnyText(v)
884+
}
885+
return locale.FormatPhoneNumber(s, strings.ToUpper(config.DetectCountry()))
886+
}
887+
876888
func fmtAnyFK(v any) string {
877889
s := fmtAnyText(v)
878890
if s != "" && s != "0" {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright 2026 Phillip Cloud
2+
// Licensed under the Apache License, Version 2.0
3+
4+
package app
5+
6+
import (
7+
"testing"
8+
9+
"github.com/micasa-dev/micasa/internal/data"
10+
"github.com/micasa-dev/micasa/internal/locale"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestPreviewColumnsVendorFormatsPhone(t *testing.T) {
16+
// Not parallel: t.Setenv modifies process-global state.
17+
// LC_ALL has highest precedence in DetectCountry().
18+
t.Setenv("LC_ALL", "en_US.UTF-8")
19+
cur, err := locale.ResolveDefault("")
20+
require.NoError(t, err)
21+
cols := previewColumns(data.TableVendors, cur)
22+
23+
// Find the phone column by key.
24+
var phoneFmt func(any) string
25+
for _, c := range cols {
26+
if c.dataKey == data.ColPhone {
27+
phoneFmt = c.format
28+
break
29+
}
30+
}
31+
require.NotNil(t, phoneFmt, "phone column not found in vendor preview")
32+
33+
// Should format parseable numbers, passthrough garbage.
34+
assert.Equal(t, "(555) 123-4567", phoneFmt("5551234567"))
35+
assert.Equal(t, "not a phone", phoneFmt("not a phone"))
36+
}

internal/app/forms.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ type vendorFormData struct {
126126
Phone string
127127
Website string
128128
Notes string
129+
Locale string
129130
}
130131

131132
// entityRef identifies a polymorphic document parent (kind + ID).
@@ -1102,6 +1103,7 @@ func (m *Model) parseVendorFormData() (data.Vendor, error) {
11021103
Phone: strings.TrimSpace(values.Phone),
11031104
Website: strings.TrimSpace(values.Website),
11041105
Notes: strings.TrimSpace(values.Notes),
1106+
Locale: strings.TrimSpace(values.Locale),
11051107
}, nil
11061108
}
11071109

@@ -1153,6 +1155,7 @@ func vendorFormValues(vendor data.Vendor) *vendorFormData {
11531155
Phone: vendor.Phone,
11541156
Website: vendor.Website,
11551157
Notes: vendor.Notes,
1158+
Locale: vendor.Locale,
11561159
}
11571160
}
11581161

internal/app/handlers.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ package app
55

66
import (
77
"fmt"
8+
"strings"
89
"time"
910

1011
"charm.land/bubbles/v2/table"
12+
"github.com/micasa-dev/micasa/internal/config"
1113
"github.com/micasa-dev/micasa/internal/data"
1214
)
1315

@@ -494,7 +496,8 @@ func (vendorHandler) Load(
494496
quoteCounts := fetchCounts(store.CountQuotesByVendor, ids)
495497
jobCounts := fetchCounts(store.CountServiceLogsByVendor, ids)
496498
docCounts := fetchDocCounts(store, data.DocumentEntityVendor, ids)
497-
rows, meta, cellRows := vendorRows(vendors, quoteCounts, jobCounts, docCounts)
499+
defaultRegion := strings.ToUpper(config.DetectCountry())
500+
rows, meta, cellRows := vendorRows(vendors, quoteCounts, jobCounts, docCounts, defaultRegion)
498501
return rows, meta, cellRows, nil
499502
}
500503

internal/app/mag.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@ func magFormat(c cell, includeUnit bool, currencySymbol string) string {
2626
}
2727

2828
// Only transform kinds that carry meaningful numeric data.
29-
// cellText is excluded because it covers phone numbers, serial numbers,
29+
// cellText is excluded because it covers serial numbers,
3030
// model numbers, and other identifiers that happen to look numeric.
3131
switch c.Kind {
3232
case cellMoney, cellDrilldown, cellOps:
3333
// Definitely numeric; continue to parsing below.
3434
case cellText, cellReadonly, cellDate, cellStatus, cellWarranty,
35-
cellUrgency, cellNotes, cellEntity:
35+
cellUrgency, cellNotes, cellEntity, cellTelephoneNumber:
3636
return value
3737
default:
3838
panic(fmt.Sprintf("unhandled cellKind: %d", c.Kind))

0 commit comments

Comments
 (0)