Files
honeyDueAPI/internal/services/lookup_i18n.go
T
Trey T 12de5a230a
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
i18n: backend-localized lookups, suggestions, and static data (10 languages)
- suggestion_service: fix scorer (stringList unmarshal accepts scalar|array;
  anchor scoring on base universal score so bool matches no longer tie); add
  localizeReasons for human-readable, Accept-Language-localized match reasons
- lookup_i18n: localize lookup display names, home-profile options, document
  types/categories via internal/i18n
- static_data_handler: per-locale seeded-data response (display_name, home
  profile options, document types/categories) with per-locale cache + ETag
- settings_handler: invalidate per-locale seeded-data cache on lookup change
  instead of pre-warming a single non-localized blob
- cache_service: per-locale seeded-data keys + ETag
- DTOs: add DisplayName fields (task/residence/contractor)
- translations: add suggestion.reason.* and lookup.* keys across all 10 langs
- cmd/api: extract startup helpers + tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:54:54 -05:00

160 lines
6.1 KiB
Go

package services
import (
"strings"
goi18n "github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/i18n"
)
// lookup kinds — the message-key namespace for each localizable lookup type.
const (
lookupKindResidenceType = "residence_type"
lookupKindTaskCategory = "task_category"
lookupKindTaskPriority = "task_priority"
lookupKindTaskFrequency = "task_frequency"
lookupKindSpecialty = "contractor_specialty"
lookupKindHomeProfile = "home_profile"
lookupKindDocumentType = "document_type"
lookupKindDocumentCat = "document_category"
)
// documentTypeValues / documentCategoryValues are the stable client enum codes
// (see iOS DocumentType/DocumentCategory). Display labels are localized at
// request time. Order is presentation order.
var documentTypeValues = []string{
"warranty", "manual", "receipt", "inspection", "permit", "deed", "insurance", "contract", "photo", "other",
}
var documentCategoryValues = []string{
"appliance", "hvac", "plumbing", "electrical", "roofing", "structural", "landscaping", "general", "other",
}
// localizedList maps a list of stable values to {value, localized display}.
func localizedList(localizer *goi18n.Localizer, kind string, values []string) []HomeProfileOption {
out := make([]HomeProfileOption, 0, len(values))
for _, v := range values {
out = append(out, HomeProfileOption{
Value: v,
DisplayName: localizeLookup(localizer, kind, v),
})
}
return out
}
// BuildDocumentTypes returns localized document-type options.
func BuildDocumentTypes(localizer *goi18n.Localizer) []HomeProfileOption {
return localizedList(localizer, lookupKindDocumentType, documentTypeValues)
}
// BuildDocumentCategories returns localized document-category options.
func BuildDocumentCategories(localizer *goi18n.Localizer) []HomeProfileOption {
return localizedList(localizer, lookupKindDocumentCat, documentCategoryValues)
}
// lookupSlug normalizes a stable English lookup name (or option value) into a
// message-key slug: lowercased, non-alphanumeric runs collapsed to "_".
// "Pest Control" -> "pest_control", "Bi-Weekly" -> "bi_weekly", "tank_gas" ->
// "tank_gas".
func lookupSlug(name string) string {
var b strings.Builder
prevUnderscore := false
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
switch {
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
b.WriteRune(r)
prevUnderscore = false
default:
if !prevUnderscore && b.Len() > 0 {
b.WriteByte('_')
prevUnderscore = true
}
}
}
return strings.Trim(b.String(), "_")
}
// localizeLookup returns the localized display label for a lookup value.
// Keys follow "lookup.<kind>.<slug>". If the locale lacks the key it falls back
// to English, and ultimately to the original name so a raw key never surfaces.
func localizeLookup(localizer *goi18n.Localizer, kind, name string) string {
key := "lookup." + kind + "." + lookupSlug(name)
msg := i18n.T(localizer, key, nil)
if msg == key {
msg = i18n.T(i18n.NewLocalizer(i18n.DefaultLanguage), key, nil)
}
if msg == key {
// No translation anywhere — fall back to the stable English name.
return name
}
return msg
}
// HomeProfileOption is a single selectable value for a home-profile field.
type HomeProfileOption struct {
Value string `json:"value"`
DisplayName string `json:"display_name"`
}
// homeProfileCatalog is the canonical set of home-profile field options,
// mirroring the (previously hardcoded) iOS dropdowns. Order is presentation
// order. Display labels are localized at request time via localizeLookup.
var homeProfileCatalog = []struct {
Field string
Values []string
}{
{"heating_type", []string{"gas_furnace", "electric_furnace", "heat_pump", "boiler", "radiant", "other"}},
{"cooling_type", []string{"central_ac", "window_ac", "heat_pump", "evaporative", "none", "other"}},
{"water_heater_type", []string{"tank_gas", "tank_electric", "tankless_gas", "tankless_electric", "heat_pump", "solar", "other"}},
{"roof_type", []string{"asphalt_shingle", "metal", "tile", "slate", "wood_shake", "flat", "other"}},
{"exterior_type", []string{"brick", "vinyl_siding", "wood_siding", "stucco", "stone", "fiber_cement", "other"}},
{"flooring_primary", []string{"hardwood", "laminate", "tile", "carpet", "vinyl", "concrete", "other"}},
{"landscaping_type", []string{"lawn", "desert", "xeriscape", "garden", "mixed", "none", "other"}},
}
// BuildHomeProfileOptions returns the home-profile field options with display
// labels localized for the supplied localizer (nil falls back to English).
func BuildHomeProfileOptions(localizer *goi18n.Localizer) map[string][]HomeProfileOption {
out := make(map[string][]HomeProfileOption, len(homeProfileCatalog))
for _, f := range homeProfileCatalog {
opts := make([]HomeProfileOption, 0, len(f.Values))
for _, v := range f.Values {
opts = append(opts, HomeProfileOption{
Value: v,
DisplayName: localizeLookup(localizer, lookupKindHomeProfile, v),
})
}
out[f.Field] = opts
}
return out
}
// LocalizeLookups fills the DisplayName of each lookup slice in place, using the
// supplied localizer. Mutates the passed slices.
func LocalizeLookups(
localizer *goi18n.Localizer,
residenceTypes []responses.ResidenceTypeResponse,
categories []responses.TaskCategoryResponse,
priorities []responses.TaskPriorityResponse,
frequencies []responses.TaskFrequencyResponse,
specialties []responses.ContractorSpecialtyResponse,
) {
for i := range residenceTypes {
residenceTypes[i].DisplayName = localizeLookup(localizer, lookupKindResidenceType, residenceTypes[i].Name)
}
for i := range categories {
categories[i].DisplayName = localizeLookup(localizer, lookupKindTaskCategory, categories[i].Name)
}
for i := range priorities {
priorities[i].DisplayName = localizeLookup(localizer, lookupKindTaskPriority, priorities[i].Name)
}
for i := range frequencies {
frequencies[i].DisplayName = localizeLookup(localizer, lookupKindTaskFrequency, frequencies[i].Name)
}
for i := range specialties {
specialties[i].DisplayName = localizeLookup(localizer, lookupKindSpecialty, specialties[i].Name)
}
}