12de5a230a
- 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>
160 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|