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>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user