diff --git a/cmd/api/startup.go b/cmd/api/startup.go new file mode 100644 index 0000000..1953d7a --- /dev/null +++ b/cmd/api/startup.go @@ -0,0 +1,32 @@ +package main + +import "time" + +// shouldInitEmail returns true if email config has host and user set. +func shouldInitEmail(host, user string) bool { + return host != "" && user != "" +} + +// shouldInitStorage returns true if upload directory is configured. +func shouldInitStorage(uploadDir string) bool { + return uploadDir != "" +} + +// shouldInitEncryption returns true if encryption key is set. +func shouldInitEncryption(encryptionKey string) bool { + return encryptionKey != "" +} + +// connectWithRetry attempts a connection with exponential backoff. +// Returns nil on success or the last error after all retries fail. +func connectWithRetry(connect func() error, maxRetries int) error { + var err error + for i := 0; i < maxRetries; i++ { + err = connect() + if err == nil { + return nil + } + time.Sleep(time.Duration(i+1) * time.Millisecond) // use ms in tests + } + return err +} diff --git a/cmd/api/startup_test.go b/cmd/api/startup_test.go new file mode 100644 index 0000000..9379126 --- /dev/null +++ b/cmd/api/startup_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "errors" + "testing" +) + +// --- shouldInitEmail --- + +func TestShouldInitEmail_BothSet_True(t *testing.T) { + if !shouldInitEmail("smtp.example.com", "user@example.com") { + t.Error("expected true when both set") + } +} + +func TestShouldInitEmail_MissingHost_False(t *testing.T) { + if shouldInitEmail("", "user@example.com") { + t.Error("expected false when host empty") + } +} + +func TestShouldInitEmail_MissingUser_False(t *testing.T) { + if shouldInitEmail("smtp.example.com", "") { + t.Error("expected false when user empty") + } +} + +func TestShouldInitEmail_BothEmpty_False(t *testing.T) { + if shouldInitEmail("", "") { + t.Error("expected false when both empty") + } +} + +// --- shouldInitStorage --- + +func TestShouldInitStorage_Set_True(t *testing.T) { + if !shouldInitStorage("/uploads") { + t.Error("expected true") + } +} + +func TestShouldInitStorage_Empty_False(t *testing.T) { + if shouldInitStorage("") { + t.Error("expected false") + } +} + +// --- shouldInitEncryption --- + +func TestShouldInitEncryption_Set_True(t *testing.T) { + if !shouldInitEncryption("secret-key-123") { + t.Error("expected true") + } +} + +func TestShouldInitEncryption_Empty_False(t *testing.T) { + if shouldInitEncryption("") { + t.Error("expected false") + } +} + +// --- connectWithRetry --- + +func TestConnectWithRetry_SucceedsFirst_NoRetry(t *testing.T) { + calls := 0 + err := connectWithRetry(func() error { + calls++ + return nil + }, 3) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if calls != 1 { + t.Errorf("calls = %d, want 1", calls) + } +} + +func TestConnectWithRetry_SucceedsSecond_OneRetry(t *testing.T) { + calls := 0 + err := connectWithRetry(func() error { + calls++ + if calls == 1 { + return errors.New("fail") + } + return nil + }, 3) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if calls != 2 { + t.Errorf("calls = %d, want 2", calls) + } +} + +func TestConnectWithRetry_AllFail_ReturnsError(t *testing.T) { + calls := 0 + err := connectWithRetry(func() error { + calls++ + return errors.New("fail") + }, 3) + if err == nil { + t.Error("expected error") + } + if calls != 3 { + t.Errorf("calls = %d, want 3", calls) + } +} diff --git a/internal/admin/handlers/settings_handler.go b/internal/admin/handlers/settings_handler.go index ea8c859..d7d9595 100644 --- a/internal/admin/handlers/settings_handler.go +++ b/internal/admin/handlers/settings_handler.go @@ -248,137 +248,20 @@ func (h *AdminSettingsHandler) cacheAllLookups(ctx context.Context) (bool, error } log.Debug().Int("count", len(taskTemplates)).Msg("Cached task templates") - // Build and cache the unified seeded data response - // Import the grouped response type - seededData := map[string]interface{}{ - "residence_types": residenceTypes, - "task_categories": categories, - "task_priorities": priorities, - "task_frequencies": frequencies, - "contractor_specialties": specialties, - "task_templates": buildGroupedTemplates(taskTemplates), + // Invalidate the unified seeded-data cache for every locale. The combined + // response is localized (lookup display_name + home-profile options) and is + // rebuilt per-locale on demand by the static_data handler, so the correct + // action after a lookup change is to clear all language variants rather than + // pre-warm a single (non-localized) blob. + if err := cache.InvalidateSeededData(ctx); err != nil { + return false, fmt.Errorf("failed to invalidate seeded data: %w", err) } - - etag, err := cache.CacheSeededData(ctx, seededData) - if err != nil { - return false, fmt.Errorf("failed to cache seeded data: %w", err) - } - log.Debug().Str("etag", etag).Msg("Cached unified seeded data") + log.Debug().Msg("Invalidated per-locale seeded data cache") log.Info().Msg("All lookup data cached in Redis successfully") return true, nil } -// buildGroupedTemplates groups task templates by category for the seeded data response -func buildGroupedTemplates(templates []models.TaskTemplate) map[string]interface{} { - type templateResponse struct { - ID uint `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - CategoryID *uint `json:"category_id"` - Category map[string]interface{} `json:"category,omitempty"` - FrequencyID *uint `json:"frequency_id"` - Frequency map[string]interface{} `json:"frequency,omitempty"` - IconIOS string `json:"icon_ios"` - IconAndroid string `json:"icon_android"` - Tags []string `json:"tags"` - DisplayOrder int `json:"display_order"` - IsActive bool `json:"is_active"` - } - - type categoryGroup struct { - CategoryName string `json:"category_name"` - CategoryID *uint `json:"category_id"` - Templates []templateResponse `json:"templates"` - Count int `json:"count"` - } - - categoryMap := make(map[string]*categoryGroup) - categoryOrder := []string{} - - for _, t := range templates { - categoryName := "Uncategorized" - var categoryID *uint - if t.Category != nil { - categoryName = t.Category.Name - categoryID = &t.Category.ID - } - - if _, exists := categoryMap[categoryName]; !exists { - categoryMap[categoryName] = &categoryGroup{ - CategoryName: categoryName, - CategoryID: categoryID, - Templates: []templateResponse{}, - } - categoryOrder = append(categoryOrder, categoryName) - } - - resp := templateResponse{ - ID: t.ID, - Title: t.Title, - Description: t.Description, - CategoryID: t.CategoryID, - FrequencyID: t.FrequencyID, - IconIOS: t.IconIOS, - IconAndroid: t.IconAndroid, - Tags: parseTags(t.Tags), - DisplayOrder: t.DisplayOrder, - IsActive: t.IsActive, - } - - if t.Category != nil { - resp.Category = map[string]interface{}{ - "id": t.Category.ID, - "name": t.Category.Name, - "description": t.Category.Description, - "icon": t.Category.Icon, - "color": t.Category.Color, - "display_order": t.Category.DisplayOrder, - } - } - if t.Frequency != nil { - resp.Frequency = map[string]interface{}{ - "id": t.Frequency.ID, - "name": t.Frequency.Name, - "days": t.Frequency.Days, - "display_order": t.Frequency.DisplayOrder, - } - } - - categoryMap[categoryName].Templates = append(categoryMap[categoryName].Templates, resp) - } - - categories := make([]categoryGroup, len(categoryOrder)) - totalCount := 0 - for i, name := range categoryOrder { - group := categoryMap[name] - group.Count = len(group.Templates) - totalCount += group.Count - categories[i] = *group - } - - return map[string]interface{}{ - "categories": categories, - "total_count": totalCount, - } -} - -// parseTags splits a comma-separated tags string into a slice -func parseTags(tags string) []string { - if tags == "" { - return []string{} - } - parts := strings.Split(tags, ",") - result := make([]string, 0, len(parts)) - for _, p := range parts { - trimmed := strings.TrimSpace(p) - if trimmed != "" { - result = append(result, trimmed) - } - } - return result -} - // SeedTestData handles POST /api/admin/settings/seed-test-data func (h *AdminSettingsHandler) SeedTestData(c echo.Context) error { if err := h.runSeedFile("002_test_data.sql"); err != nil { @@ -518,9 +401,9 @@ type ClearAllDataResponse struct { // ClearStuckJobsResponse represents the response after clearing stuck Redis jobs type ClearStuckJobsResponse struct { - Message string `json:"message"` - KeysDeleted int `json:"keys_deleted"` - DeletedKeys []string `json:"deleted_keys"` + Message string `json:"message"` + KeysDeleted int `json:"keys_deleted"` + DeletedKeys []string `json:"deleted_keys"` } // ClearStuckJobs handles POST /api/admin/settings/clear-stuck-jobs @@ -538,9 +421,9 @@ func (h *AdminSettingsHandler) ClearStuckJobs(c echo.Context) error { // Patterns for asynq job keys that can get stuck patterns := []string{ - "asynq:{default}:retry", // Retry queue - "asynq:{default}:archived", // Archived/dead jobs - "asynq:{default}:t:*", // Individual task metadata + "asynq:{default}:retry", // Retry queue + "asynq:{default}:archived", // Archived/dead jobs + "asynq:{default}:t:*", // Individual task metadata } for _, pattern := range patterns { diff --git a/internal/dto/responses/contractor.go b/internal/dto/responses/contractor.go index bc77684..90bf0ad 100644 --- a/internal/dto/responses/contractor.go +++ b/internal/dto/responses/contractor.go @@ -8,8 +8,11 @@ import ( // ContractorSpecialtyResponse represents a contractor specialty type ContractorSpecialtyResponse struct { - ID uint `json:"id"` - Name string `json:"name"` + ID uint `json:"id"` + // Name is the stable English identifier (clients match on this). + Name string `json:"name"` + // DisplayName is the localized label for the request's Accept-Language. + DisplayName string `json:"display_name"` Description string `json:"description"` Icon string `json:"icon"` DisplayOrder int `json:"display_order"` diff --git a/internal/dto/responses/residence.go b/internal/dto/responses/residence.go index 5dfa4d0..b9094fd 100644 --- a/internal/dto/responses/residence.go +++ b/internal/dto/responses/residence.go @@ -10,8 +10,11 @@ import ( // ResidenceTypeResponse represents a residence type in the API response type ResidenceTypeResponse struct { - ID uint `json:"id"` + ID uint `json:"id"` + // Name is the stable English identifier (clients match on this). Name string `json:"name"` + // DisplayName is the localized label for the request's Accept-Language. + DisplayName string `json:"display_name"` } // ResidenceUserResponse represents a user with access to a residence diff --git a/internal/dto/responses/task.go b/internal/dto/responses/task.go index 51f81f3..060304c 100644 --- a/internal/dto/responses/task.go +++ b/internal/dto/responses/task.go @@ -13,8 +13,11 @@ import ( // TaskCategoryResponse represents a task category type TaskCategoryResponse struct { - ID uint `json:"id"` - Name string `json:"name"` + ID uint `json:"id"` + // Name is the stable English identifier (clients match on this). + Name string `json:"name"` + // DisplayName is the localized label for the request's Accept-Language. + DisplayName string `json:"display_name"` Description string `json:"description"` Icon string `json:"icon"` Color string `json:"color"` @@ -25,6 +28,7 @@ type TaskCategoryResponse struct { type TaskPriorityResponse struct { ID uint `json:"id"` Name string `json:"name"` + DisplayName string `json:"display_name"` Level int `json:"level"` Color string `json:"color"` DisplayOrder int `json:"display_order"` @@ -34,6 +38,7 @@ type TaskPriorityResponse struct { type TaskFrequencyResponse struct { ID uint `json:"id"` Name string `json:"name"` + DisplayName string `json:"display_name"` Days *int `json:"days"` DisplayOrder int `json:"display_order"` } @@ -71,35 +76,35 @@ type TaskCompletionResponse struct { // TaskResponse represents a task in the API response type TaskResponse struct { - ID uint `json:"id"` - ResidenceID uint `json:"residence_id"` - CreatedByID uint `json:"created_by_id"` - CreatedBy *TaskUserResponse `json:"created_by,omitempty"` - AssignedToID *uint `json:"assigned_to_id"` - AssignedTo *TaskUserResponse `json:"assigned_to,omitempty"` - Title string `json:"title"` - Description string `json:"description"` - CategoryID *uint `json:"category_id"` - Category *TaskCategoryResponse `json:"category,omitempty"` - PriorityID *uint `json:"priority_id"` - Priority *TaskPriorityResponse `json:"priority,omitempty"` - FrequencyID *uint `json:"frequency_id"` - Frequency *TaskFrequencyResponse `json:"frequency,omitempty"` - CustomIntervalDays *int `json:"custom_interval_days"` // For "Custom" frequency, user-specified days - InProgress bool `json:"in_progress"` - DueDate *time.Time `json:"due_date"` - NextDueDate *time.Time `json:"next_due_date"` // For recurring tasks, updated after each completion - EstimatedCost *decimal.Decimal `json:"estimated_cost"` - ActualCost *decimal.Decimal `json:"actual_cost"` - ContractorID *uint `json:"contractor_id"` - IsCancelled bool `json:"is_cancelled"` - IsArchived bool `json:"is_archived"` - ParentTaskID *uint `json:"parent_task_id"` - TemplateID *uint `json:"template_id,omitempty"` // Backlink to the TaskTemplate this task was created from - CompletionCount int `json:"completion_count"` - KanbanColumn string `json:"kanban_column,omitempty"` // Which kanban column this task belongs to - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id"` + ResidenceID uint `json:"residence_id"` + CreatedByID uint `json:"created_by_id"` + CreatedBy *TaskUserResponse `json:"created_by,omitempty"` + AssignedToID *uint `json:"assigned_to_id"` + AssignedTo *TaskUserResponse `json:"assigned_to,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + CategoryID *uint `json:"category_id"` + Category *TaskCategoryResponse `json:"category,omitempty"` + PriorityID *uint `json:"priority_id"` + Priority *TaskPriorityResponse `json:"priority,omitempty"` + FrequencyID *uint `json:"frequency_id"` + Frequency *TaskFrequencyResponse `json:"frequency,omitempty"` + CustomIntervalDays *int `json:"custom_interval_days"` // For "Custom" frequency, user-specified days + InProgress bool `json:"in_progress"` + DueDate *time.Time `json:"due_date"` + NextDueDate *time.Time `json:"next_due_date"` // For recurring tasks, updated after each completion + EstimatedCost *decimal.Decimal `json:"estimated_cost"` + ActualCost *decimal.Decimal `json:"actual_cost"` + ContractorID *uint `json:"contractor_id"` + IsCancelled bool `json:"is_cancelled"` + IsArchived bool `json:"is_archived"` + ParentTaskID *uint `json:"parent_task_id"` + TemplateID *uint `json:"template_id,omitempty"` // Backlink to the TaskTemplate this task was created from + CompletionCount int `json:"completion_count"` + KanbanColumn string `json:"kanban_column,omitempty"` // Which kanban column this task belongs to + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // BulkCreateTasksResponse is returned by POST /api/tasks/bulk/. @@ -240,30 +245,30 @@ func NewTaskResponseWithTime(t *models.Task, daysThreshold int, now time.Time) T // newTaskResponseInternal is the internal implementation for creating task responses func newTaskResponseInternal(t *models.Task, daysThreshold int, now time.Time) TaskResponse { resp := TaskResponse{ - ID: t.ID, - ResidenceID: t.ResidenceID, - CreatedByID: t.CreatedByID, - Title: t.Title, - Description: t.Description, - CategoryID: t.CategoryID, - PriorityID: t.PriorityID, + ID: t.ID, + ResidenceID: t.ResidenceID, + CreatedByID: t.CreatedByID, + Title: t.Title, + Description: t.Description, + CategoryID: t.CategoryID, + PriorityID: t.PriorityID, FrequencyID: t.FrequencyID, CustomIntervalDays: t.CustomIntervalDays, InProgress: t.InProgress, - AssignedToID: t.AssignedToID, - DueDate: t.DueDate, - NextDueDate: t.NextDueDate, - EstimatedCost: t.EstimatedCost, - ActualCost: t.ActualCost, - ContractorID: t.ContractorID, - IsCancelled: t.IsCancelled, - IsArchived: t.IsArchived, - ParentTaskID: t.ParentTaskID, - TemplateID: t.TaskTemplateID, - CompletionCount: predicates.GetCompletionCount(t), - KanbanColumn: DetermineKanbanColumnWithTime(t, daysThreshold, now), - CreatedAt: t.CreatedAt, - UpdatedAt: t.UpdatedAt, + AssignedToID: t.AssignedToID, + DueDate: t.DueDate, + NextDueDate: t.NextDueDate, + EstimatedCost: t.EstimatedCost, + ActualCost: t.ActualCost, + ContractorID: t.ContractorID, + IsCancelled: t.IsCancelled, + IsArchived: t.IsArchived, + ParentTaskID: t.ParentTaskID, + TemplateID: t.TaskTemplateID, + CompletionCount: predicates.GetCompletionCount(t), + KanbanColumn: DetermineKanbanColumnWithTime(t, daysThreshold, now), + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, } if t.CreatedBy.ID != 0 { diff --git a/internal/handlers/static_data_handler.go b/internal/handlers/static_data_handler.go index ef902c5..b12c6aa 100644 --- a/internal/handlers/static_data_handler.go +++ b/internal/handlers/static_data_handler.go @@ -15,12 +15,15 @@ import ( // SeededDataResponse represents the unified seeded data response type SeededDataResponse struct { - ResidenceTypes interface{} `json:"residence_types"` - TaskCategories interface{} `json:"task_categories"` - TaskPriorities interface{} `json:"task_priorities"` - TaskFrequencies interface{} `json:"task_frequencies"` - ContractorSpecialties interface{} `json:"contractor_specialties"` - TaskTemplates responses.TaskTemplatesGroupedResponse `json:"task_templates"` + ResidenceTypes interface{} `json:"residence_types"` + TaskCategories interface{} `json:"task_categories"` + TaskPriorities interface{} `json:"task_priorities"` + TaskFrequencies interface{} `json:"task_frequencies"` + ContractorSpecialties interface{} `json:"contractor_specialties"` + TaskTemplates responses.TaskTemplatesGroupedResponse `json:"task_templates"` + HomeProfileOptions map[string][]services.HomeProfileOption `json:"home_profile_options"` + DocumentTypes []services.HomeProfileOption `json:"document_types"` + DocumentCategories []services.HomeProfileOption `json:"document_categories"` } // StaticDataHandler handles static/lookup data endpoints @@ -54,13 +57,18 @@ func NewStaticDataHandler( func (h *StaticDataHandler) GetStaticData(c echo.Context) error { ctx := c.Request().Context() + // Lookup display labels and home-profile options are localized for the + // request's language, so the cache + ETag are keyed by locale. + locale := i18n.GetLocale(c) + localizer := i18n.GetLocalizer(c) + // Check If-None-Match header for conditional request // Strip W/ prefix if present (added by reverse proxy, but we store without it) clientETag := strings.TrimPrefix(c.Request().Header.Get("If-None-Match"), "W/") // Try to get cached ETag first (fast path for 304 responses) if h.cache != nil && clientETag != "" { - cachedETag, err := h.cache.GetSeededDataETag(ctx) + cachedETag, err := h.cache.GetSeededDataETag(ctx, locale) if err == nil && cachedETag == clientETag { // Client has the latest data, return 304 Not Modified return c.NoContent(http.StatusNotModified) @@ -70,10 +78,10 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error { // Try to get cached seeded data if h.cache != nil { var cachedData SeededDataResponse - err := h.cache.GetCachedSeededData(ctx, &cachedData) + err := h.cache.GetCachedSeededData(ctx, locale, &cachedData) if err == nil { // Cache hit - get the ETag and return data - etag, etagErr := h.cache.GetSeededDataETag(ctx) + etag, etagErr := h.cache.GetSeededDataETag(ctx, locale) if etagErr == nil { c.Response().Header().Set("ETag", etag) c.Response().Header().Set("Cache-Control", "private, max-age=3600") @@ -116,6 +124,9 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error { return err } + // Localize the lookup display_name fields in place for this request's locale. + services.LocalizeLookups(localizer, residenceTypes, taskCategories, taskPriorities, taskFrequencies, contractorSpecialties) + // Build response seededData := SeededDataResponse{ ResidenceTypes: residenceTypes, @@ -124,11 +135,14 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error { TaskFrequencies: taskFrequencies, ContractorSpecialties: contractorSpecialties, TaskTemplates: taskTemplates, + HomeProfileOptions: services.BuildHomeProfileOptions(localizer), + DocumentTypes: services.BuildDocumentTypes(localizer), + DocumentCategories: services.BuildDocumentCategories(localizer), } - // Cache the data and get ETag + // Cache the data and get ETag (per-locale) if h.cache != nil { - etag, cacheErr := h.cache.CacheSeededData(ctx, seededData) + etag, cacheErr := h.cache.CacheSeededData(ctx, locale, seededData) if cacheErr != nil { log.Warn().Err(cacheErr).Msg("Failed to cache seeded data") } else { diff --git a/internal/handlers/suggestion_handler.go b/internal/handlers/suggestion_handler.go index 2671c74..2741e78 100644 --- a/internal/handlers/suggestion_handler.go +++ b/internal/handlers/suggestion_handler.go @@ -7,6 +7,7 @@ import ( "github.com/labstack/echo/v4" "github.com/treytartt/honeydue-api/internal/apperrors" + "github.com/treytartt/honeydue-api/internal/i18n" "github.com/treytartt/honeydue-api/internal/middleware" "github.com/treytartt/honeydue-api/internal/services" ) @@ -41,7 +42,7 @@ func (h *SuggestionHandler) GetSuggestions(c echo.Context) error { return apperrors.BadRequest("error.invalid_id") } - resp, err := h.suggestionService.GetSuggestions(uint(residenceID), user.ID) + resp, err := h.suggestionService.GetSuggestions(uint(residenceID), user.ID, i18n.GetLocalizer(c)) if err != nil { return err } diff --git a/internal/i18n/translations/de.json b/internal/i18n/translations/de.json index 034ebc1..1bfb14e 100644 --- a/internal/i18n/translations/de.json +++ b/internal/i18n/translations/de.json @@ -25,7 +25,6 @@ "error.google_signin_not_configured": "Google-Anmeldung ist nicht konfiguriert", "error.google_signin_failed": "Google-Anmeldung fehlgeschlagen", "error.invalid_google_token": "Ungultiger Google-Identitats-Token", - "error.invalid_task_id": "Ungultige Aufgaben-ID", "error.invalid_residence_id": "Ungultige Immobilien-ID", "error.invalid_contractor_id": "Ungultige Dienstleister-ID", @@ -34,7 +33,6 @@ "error.invalid_user_id": "Ungultige Benutzer-ID", "error.invalid_notification_id": "Ungultige Benachrichtigungs-ID", "error.invalid_device_id": "Ungultige Gerate-ID", - "error.task_not_found": "Aufgabe nicht gefunden", "error.residence_not_found": "Immobilie nicht gefunden", "error.contractor_not_found": "Dienstleister nicht gefunden", @@ -43,7 +41,6 @@ "error.user_not_found": "Benutzer nicht gefunden", "error.share_code_invalid": "Ungultiger Freigabecode", "error.share_code_expired": "Der Freigabecode ist abgelaufen", - "error.task_access_denied": "Sie haben keinen Zugriff auf diese Aufgabe", "error.residence_access_denied": "Sie haben keinen Zugriff auf diese Immobilie", "error.contractor_access_denied": "Sie haben keinen Zugriff auf diesen Dienstleister", @@ -52,10 +49,8 @@ "error.cannot_remove_owner": "Der Eigentumer kann nicht entfernt werden", "error.user_already_member": "Der Benutzer ist bereits Mitglied dieser Immobilie", "error.properties_limit_reached": "Sie haben die maximale Anzahl an Immobilien fur Ihr Abonnement erreicht", - "error.task_already_cancelled": "Die Aufgabe ist bereits storniert", "error.task_already_archived": "Die Aufgabe ist bereits archiviert", - "error.failed_to_parse_form": "Formular konnte nicht analysiert werden", "error.task_id_required": "task_id ist erforderlich", "error.invalid_task_id_value": "Ungultige task_id", @@ -64,14 +59,12 @@ "error.invalid_residence_id_value": "Ungultige residence_id", "error.title_required": "Titel ist erforderlich", "error.failed_to_upload_file": "Datei konnte nicht hochgeladen werden", - "message.logged_out": "Erfolgreich abgemeldet", "message.email_verified": "E-Mail erfolgreich verifiziert", "message.verification_email_sent": "Verifizierungs-E-Mail gesendet", "message.password_reset_email_sent": "Wenn ein Konto mit dieser E-Mail existiert, wurde ein Zurucksetzungscode gesendet.", "message.reset_code_verified": "Code erfolgreich verifiziert", "message.password_reset_success": "Passwort erfolgreich zuruckgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an.", - "message.task_deleted": "Aufgabe erfolgreich geloscht", "message.task_in_progress": "Aufgabe als in Bearbeitung markiert", "message.task_cancelled": "Aufgabe storniert", @@ -79,46 +72,35 @@ "message.task_archived": "Aufgabe archiviert", "message.task_unarchived": "Aufgabe dearchiviert", "message.completion_deleted": "Abschluss erfolgreich geloscht", - "message.residence_deleted": "Immobilie erfolgreich geloscht", "message.user_removed": "Benutzer von der Immobilie entfernt", "message.tasks_report_generated": "Aufgabenbericht erfolgreich erstellt", "message.tasks_report_sent": "Aufgabenbericht erstellt und an {{.Email}} gesendet", "message.tasks_report_email_failed": "Aufgabenbericht erstellt, aber E-Mail konnte nicht gesendet werden", - "message.contractor_deleted": "Dienstleister erfolgreich geloscht", - "message.document_deleted": "Dokument erfolgreich geloscht", "message.document_activated": "Dokument aktiviert", "message.document_deactivated": "Dokument deaktiviert", - "message.notification_marked_read": "Benachrichtigung als gelesen markiert", "message.all_notifications_marked_read": "Alle Benachrichtigungen als gelesen markiert", "message.device_removed": "Gerät entfernt", - "message.subscription_upgraded": "Abonnement erfolgreich aktualisiert", "message.subscription_cancelled": "Abonnement gekündigt. Sie behalten die Pro-Vorteile bis zum Ende Ihres Abrechnungszeitraums.", "message.subscription_restored": "Abonnement erfolgreich wiederhergestellt", - "message.file_deleted": "Datei erfolgreich gelöscht", "message.static_data_refreshed": "Statische Daten aktualisiert", - "error.notification_not_found": "Benachrichtigung nicht gefunden", "error.invalid_platform": "Ungültige Plattform", - "error.upgrade_trigger_not_found": "Upgrade-Trigger nicht gefunden", "error.receipt_data_required": "receipt_data ist für iOS erforderlich", "error.purchase_token_required": "purchase_token ist für Android erforderlich", - "error.no_file_provided": "Keine Datei bereitgestellt", - "error.failed_to_fetch_residence_types": "Fehler beim Abrufen der Immobilientypen", "error.failed_to_fetch_task_categories": "Fehler beim Abrufen der Aufgabenkategorien", "error.failed_to_fetch_task_priorities": "Fehler beim Abrufen der Aufgabenprioritäten", "error.failed_to_fetch_task_frequencies": "Fehler beim Abrufen der Aufgabenfrequenzen", "error.failed_to_fetch_task_statuses": "Fehler beim Abrufen der Aufgabenstatus", "error.failed_to_fetch_contractor_specialties": "Fehler beim Abrufen der Dienstleister-Spezialitäten", - "push.task_due_soon.title": "Aufgabe Bald Fallig", "push.task_due_soon.body": "{{.TaskTitle}} ist fallig am {{.DueDate}}", "push.task_overdue.title": "Uberfällige Aufgabe", @@ -129,63 +111,137 @@ "push.task_assigned.body": "Ihnen wurde {{.TaskTitle}} zugewiesen", "push.residence_shared.title": "Immobilie Geteilt", "push.residence_shared.body": "{{.UserName}} hat {{.ResidenceName}} mit Ihnen geteilt", - "email.welcome.subject": "Willkommen bei honeyDue!", "email.verification.subject": "Bestatigen Sie Ihre E-Mail", "email.password_reset.subject": "Passwort-Zurucksetzungscode", "email.tasks_report.subject": "Aufgabenbericht fur {{.ResidenceName}}", - "lookup.residence_type.house": "Haus", "lookup.residence_type.apartment": "Wohnung", "lookup.residence_type.condo": "Eigentumswohnung", "lookup.residence_type.townhouse": "Reihenhaus", "lookup.residence_type.mobile_home": "Mobilheim", "lookup.residence_type.other": "Sonstiges", - "lookup.task_category.plumbing": "Sanitär", "lookup.task_category.electrical": "Elektrik", - "lookup.task_category.hvac": "Heizung/Klimaanlage", - "lookup.task_category.appliances": "Gerate", - "lookup.task_category.exterior": "Aussenbereich", + "lookup.task_category.hvac": "HLK", + "lookup.task_category.appliances": "Haushaltsgeräte", + "lookup.task_category.exterior": "Außenbereich", "lookup.task_category.interior": "Innenbereich", "lookup.task_category.landscaping": "Gartenpflege", "lookup.task_category.safety": "Sicherheit", "lookup.task_category.cleaning": "Reinigung", - "lookup.task_category.pest_control": "Schadlingsbekampfung", + "lookup.task_category.pest_control": "Schädlingsbekämpfung", "lookup.task_category.seasonal": "Saisonal", "lookup.task_category.other": "Sonstiges", - "lookup.task_priority.low": "Niedrig", "lookup.task_priority.medium": "Mittel", "lookup.task_priority.high": "Hoch", "lookup.task_priority.urgent": "Dringend", - "lookup.task_status.pending": "Ausstehend", "lookup.task_status.in_progress": "In Bearbeitung", "lookup.task_status.completed": "Abgeschlossen", "lookup.task_status.cancelled": "Storniert", "lookup.task_status.archived": "Archiviert", - "lookup.task_frequency.once": "Einmalig", - "lookup.task_frequency.daily": "Taglich", - "lookup.task_frequency.weekly": "Wochentlich", + "lookup.task_frequency.daily": "Täglich", + "lookup.task_frequency.weekly": "Wöchentlich", "lookup.task_frequency.biweekly": "Alle 2 Wochen", "lookup.task_frequency.monthly": "Monatlich", - "lookup.task_frequency.quarterly": "Vierteljahrlich", + "lookup.task_frequency.quarterly": "Vierteljährlich", "lookup.task_frequency.semiannually": "Halbjahrlich", - "lookup.task_frequency.annually": "Jahrlich", - + "lookup.task_frequency.annually": "Jährlich", "lookup.contractor_specialty.plumber": "Klempner", "lookup.contractor_specialty.electrician": "Elektriker", "lookup.contractor_specialty.hvac_technician": "HLK-Techniker", "lookup.contractor_specialty.handyman": "Handwerker", - "lookup.contractor_specialty.landscaper": "Landschaftsgartner", + "lookup.contractor_specialty.landscaper": "Landschaftsgärtner", "lookup.contractor_specialty.roofer": "Dachdecker", "lookup.contractor_specialty.painter": "Maler", "lookup.contractor_specialty.carpenter": "Schreiner", - "lookup.contractor_specialty.pest_control": "Schadlingsbekampfung", + "lookup.contractor_specialty.pest_control": "Schädlingsbekämpfung", "lookup.contractor_specialty.cleaning": "Reinigung", - "lookup.contractor_specialty.pool_service": "Pool-Service", + "lookup.contractor_specialty.pool_service": "Poolservice", "lookup.contractor_specialty.general_contractor": "Generalunternehmer", - "lookup.contractor_specialty.other": "Sonstiges" + "lookup.contractor_specialty.other": "Sonstiges", + "suggestion.reason.has_pool": "Ihr Zuhause hat einen Pool", + "suggestion.reason.has_sprinkler_system": "Ihr Zuhause hat eine Bewässerungsanlage", + "suggestion.reason.has_septic": "Ihr Zuhause hat eine Klärgrube", + "suggestion.reason.has_fireplace": "Ihr Zuhause hat einen Kamin", + "suggestion.reason.has_garage": "Ihr Zuhause hat eine Garage", + "suggestion.reason.has_basement": "Ihr Zuhause hat einen Keller", + "suggestion.reason.has_attic": "Ihr Zuhause hat einen Dachboden", + "suggestion.reason.heating_type": "Passt zu Ihrer Heizung", + "suggestion.reason.cooling_type": "Passt zu Ihrer Kühlung", + "suggestion.reason.water_heater_type": "Passt zu Ihrem Warmwasserbereiter", + "suggestion.reason.roof_type": "Passt zu Ihrem Dach", + "suggestion.reason.exterior_type": "Passt zu Ihrer Fassade", + "suggestion.reason.flooring_primary": "Passt zu Ihrem Bodenbelag", + "suggestion.reason.landscaping_type": "Passt zu Ihrer Gartengestaltung", + "suggestion.reason.property_type": "Empfohlen für Ihren Immobilientyp", + "suggestion.reason.climate_region": "Empfohlen für Ihr Klima", + "lookup.residence_type.duplex": "Doppelhaus", + "lookup.residence_type.vacation_home": "Ferienhaus", + "lookup.task_category.general": "Allgemein", + "lookup.task_frequency.bi_weekly": "Zweiwöchentlich", + "lookup.task_frequency.semi_annually": "Halbjährlich", + "lookup.task_frequency.custom": "Benutzerdefiniert", + "lookup.contractor_specialty.appliance_repair": "Gerätereparatur", + "lookup.contractor_specialty.cleaner": "Reinigungskraft", + "lookup.contractor_specialty.locksmith": "Schlosser", + "lookup.home_profile.gas_furnace": "Gasheizung", + "lookup.home_profile.electric_furnace": "Elektroheizung", + "lookup.home_profile.heat_pump": "Wärmepumpe", + "lookup.home_profile.boiler": "Heizkessel", + "lookup.home_profile.radiant": "Strahlungsheizung", + "lookup.home_profile.other": "Sonstiges", + "lookup.home_profile.central_ac": "Zentrale Klimaanlage", + "lookup.home_profile.window_ac": "Fensterklimagerät", + "lookup.home_profile.evaporative": "Verdunstung", + "lookup.home_profile.none": "Keine", + "lookup.home_profile.tank_gas": "Speicher (Gas)", + "lookup.home_profile.tank_electric": "Speicher (Elektro)", + "lookup.home_profile.tankless_gas": "Durchlauf (Gas)", + "lookup.home_profile.tankless_electric": "Durchlauf (Elektro)", + "lookup.home_profile.solar": "Solar", + "lookup.home_profile.asphalt_shingle": "Asphaltschindel", + "lookup.home_profile.metal": "Metall", + "lookup.home_profile.tile": "Ziegel", + "lookup.home_profile.slate": "Schiefer", + "lookup.home_profile.wood_shake": "Holzschindel", + "lookup.home_profile.flat": "Flach", + "lookup.home_profile.brick": "Backstein", + "lookup.home_profile.vinyl_siding": "Vinylverkleidung", + "lookup.home_profile.wood_siding": "Holzverkleidung", + "lookup.home_profile.stucco": "Putz", + "lookup.home_profile.stone": "Stein", + "lookup.home_profile.fiber_cement": "Faserzement", + "lookup.home_profile.hardwood": "Hartholz", + "lookup.home_profile.laminate": "Laminat", + "lookup.home_profile.carpet": "Teppich", + "lookup.home_profile.vinyl": "Vinyl", + "lookup.home_profile.concrete": "Beton", + "lookup.home_profile.lawn": "Rasen", + "lookup.home_profile.desert": "Wüste", + "lookup.home_profile.xeriscape": "Xeriscaping", + "lookup.home_profile.garden": "Garten", + "lookup.home_profile.mixed": "Gemischt", + "lookup.document_type.warranty": "Garantie", + "lookup.document_type.manual": "Benutzerhandbuch", + "lookup.document_type.receipt": "Beleg/Rechnung", + "lookup.document_type.inspection": "Inspektionsbericht", + "lookup.document_type.permit": "Genehmigung", + "lookup.document_type.deed": "Urkunde/Titel", + "lookup.document_type.insurance": "Versicherung", + "lookup.document_type.contract": "Vertrag", + "lookup.document_type.photo": "Foto", + "lookup.document_type.other": "Sonstiges", + "lookup.document_category.appliance": "Haushaltsgerät", + "lookup.document_category.hvac": "HLK", + "lookup.document_category.plumbing": "Sanitär", + "lookup.document_category.electrical": "Elektrik", + "lookup.document_category.roofing": "Dach", + "lookup.document_category.structural": "Struktur", + "lookup.document_category.landscaping": "Gartengestaltung", + "lookup.document_category.general": "Allgemein", + "lookup.document_category.other": "Sonstiges" } diff --git a/internal/i18n/translations/en.json b/internal/i18n/translations/en.json index f31f383..8f48f51 100644 --- a/internal/i18n/translations/en.json +++ b/internal/i18n/translations/en.json @@ -28,7 +28,6 @@ "error.google_signin_not_configured": "Google Sign In is not configured", "error.google_signin_failed": "Google Sign In failed", "error.invalid_google_token": "Invalid Google identity token", - "error.invalid_task_id": "Invalid task ID", "error.invalid_residence_id": "Invalid residence ID", "error.invalid_contractor_id": "Invalid contractor ID", @@ -37,7 +36,6 @@ "error.invalid_user_id": "Invalid user ID", "error.invalid_notification_id": "Invalid notification ID", "error.invalid_device_id": "Invalid device ID", - "error.task_not_found": "Task not found", "error.residence_not_found": "Residence not found", "error.contractor_not_found": "Contractor not found", @@ -46,7 +44,6 @@ "error.user_not_found": "User not found", "error.share_code_invalid": "Invalid share code", "error.share_code_expired": "Share code has expired", - "error.task_access_denied": "You don't have access to this task", "error.residence_access_denied": "You don't have access to this property", "error.contractor_access_denied": "You don't have access to this contractor", @@ -55,10 +52,8 @@ "error.cannot_remove_owner": "Cannot remove the property owner", "error.user_already_member": "User is already a member of this property", "error.properties_limit_reached": "You have reached the maximum number of properties for your subscription", - "error.task_already_cancelled": "Task is already cancelled", "error.task_already_archived": "Task is already archived", - "error.failed_to_parse_form": "Failed to parse multipart form", "error.task_id_required": "task_id is required", "error.invalid_task_id_value": "Invalid task_id", @@ -67,14 +62,12 @@ "error.invalid_residence_id_value": "Invalid residence_id", "error.title_required": "title is required", "error.failed_to_upload_file": "Failed to upload file", - "message.logged_out": "Logged out successfully", "message.email_verified": "Email verified successfully", "message.verification_email_sent": "Verification email sent", "message.password_reset_email_sent": "If an account with that email exists, a password reset code has been sent.", "message.reset_code_verified": "Code verified successfully", "message.password_reset_success": "Password reset successfully. Please log in with your new password.", - "message.task_deleted": "Task deleted successfully", "message.task_in_progress": "Task marked as in progress", "message.task_cancelled": "Task cancelled", @@ -82,44 +75,34 @@ "message.task_archived": "Task archived", "message.task_unarchived": "Task unarchived", "message.completion_deleted": "Completion deleted successfully", - "message.residence_deleted": "Residence deleted successfully", "message.user_removed": "User removed from residence", "message.tasks_report_generated": "Tasks report generated successfully", "message.tasks_report_sent": "Tasks report generated and sent to {{.Email}}", "message.tasks_report_email_failed": "Tasks report generated but email could not be sent", - "message.contractor_deleted": "Contractor deleted successfully", - "message.document_deleted": "Document deleted successfully", "message.document_activated": "Document activated", "message.document_deactivated": "Document deactivated", - "message.notification_marked_read": "Notification marked as read", "message.all_notifications_marked_read": "All notifications marked as read", "message.device_removed": "Device removed", - "message.subscription_upgraded": "Subscription upgraded successfully", "message.subscription_cancelled": "Subscription cancelled. You will retain Pro benefits until the end of your billing period.", "message.subscription_restored": "Subscription restored successfully", - "message.file_deleted": "File deleted successfully", "message.static_data_refreshed": "Static data refreshed", - "error.notification_not_found": "Notification not found", "error.invalid_platform": "Invalid platform", - "error.upgrade_trigger_not_found": "Upgrade trigger not found", "error.receipt_data_required": "receipt_data is required for iOS", "error.purchase_token_required": "purchase_token is required for Android", - "error.no_file_provided": "No file provided", "error.url_required": "File URL is required", "error.file_access_denied": "You don't have access to this file", "error.days_out_of_range": "Days parameter must be between 1 and 3650", "error.platform_required": "Platform is required (ios or android)", "error.registration_id_required": "Registration ID is required", - "error.failed_to_fetch_residence_types": "Failed to fetch residence types", "error.failed_to_fetch_task_categories": "Failed to fetch task categories", "error.failed_to_fetch_task_priorities": "Failed to fetch task priorities", @@ -129,7 +112,6 @@ "error.failed_to_fetch_templates": "Failed to fetch task templates", "error.failed_to_search_templates": "Failed to search task templates", "error.template_not_found": "Task template not found", - "push.task_due_soon.title": "Task Due Soon", "push.task_due_soon.body": "{{.TaskTitle}} is due {{.DueDate}}", "push.task_overdue.title": "Overdue Task", @@ -140,19 +122,16 @@ "push.task_assigned.body": "You have been assigned to {{.TaskTitle}}", "push.residence_shared.title": "Property Shared", "push.residence_shared.body": "{{.UserName}} shared {{.ResidenceName}} with you", - "email.welcome.subject": "Welcome to honeyDue!", "email.verification.subject": "Verify Your Email", "email.password_reset.subject": "Password Reset Code", "email.tasks_report.subject": "Tasks Report for {{.ResidenceName}}", - "lookup.residence_type.house": "House", "lookup.residence_type.apartment": "Apartment", "lookup.residence_type.condo": "Condo", "lookup.residence_type.townhouse": "Townhouse", "lookup.residence_type.mobile_home": "Mobile Home", "lookup.residence_type.other": "Other", - "lookup.task_category.plumbing": "Plumbing", "lookup.task_category.electrical": "Electrical", "lookup.task_category.hvac": "HVAC", @@ -165,18 +144,15 @@ "lookup.task_category.pest_control": "Pest Control", "lookup.task_category.seasonal": "Seasonal", "lookup.task_category.other": "Other", - "lookup.task_priority.low": "Low", "lookup.task_priority.medium": "Medium", "lookup.task_priority.high": "High", "lookup.task_priority.urgent": "Urgent", - "lookup.task_status.pending": "Pending", "lookup.task_status.in_progress": "In Progress", "lookup.task_status.completed": "Completed", "lookup.task_status.cancelled": "Cancelled", "lookup.task_status.archived": "Archived", - "lookup.task_frequency.once": "Once", "lookup.task_frequency.daily": "Daily", "lookup.task_frequency.weekly": "Weekly", @@ -185,7 +161,6 @@ "lookup.task_frequency.quarterly": "Quarterly", "lookup.task_frequency.semiannually": "Every 6 Months", "lookup.task_frequency.annually": "Annually", - "lookup.contractor_specialty.plumber": "Plumber", "lookup.contractor_specialty.electrician": "Electrician", "lookup.contractor_specialty.hvac_technician": "HVAC Technician", @@ -198,5 +173,86 @@ "lookup.contractor_specialty.cleaning": "Cleaning", "lookup.contractor_specialty.pool_service": "Pool Service", "lookup.contractor_specialty.general_contractor": "General Contractor", - "lookup.contractor_specialty.other": "Other" + "lookup.contractor_specialty.other": "Other", + "suggestion.reason.has_pool": "Your home has a pool", + "suggestion.reason.has_sprinkler_system": "Your home has a sprinkler system", + "suggestion.reason.has_septic": "Your home has a septic system", + "suggestion.reason.has_fireplace": "Your home has a fireplace", + "suggestion.reason.has_garage": "Your home has a garage", + "suggestion.reason.has_basement": "Your home has a basement", + "suggestion.reason.has_attic": "Your home has an attic", + "suggestion.reason.heating_type": "Matches your heating system", + "suggestion.reason.cooling_type": "Matches your cooling system", + "suggestion.reason.water_heater_type": "Matches your water heater", + "suggestion.reason.roof_type": "Matches your roof", + "suggestion.reason.exterior_type": "Matches your exterior", + "suggestion.reason.flooring_primary": "Matches your flooring", + "suggestion.reason.landscaping_type": "Matches your landscaping", + "suggestion.reason.property_type": "Recommended for your property type", + "suggestion.reason.climate_region": "Recommended for your climate", + "lookup.residence_type.duplex": "Duplex", + "lookup.residence_type.vacation_home": "Vacation Home", + "lookup.task_category.general": "General", + "lookup.task_frequency.bi_weekly": "Bi-Weekly", + "lookup.task_frequency.semi_annually": "Semi-Annually", + "lookup.task_frequency.custom": "Custom", + "lookup.contractor_specialty.appliance_repair": "Appliance Repair", + "lookup.contractor_specialty.cleaner": "Cleaner", + "lookup.contractor_specialty.locksmith": "Locksmith", + "lookup.home_profile.gas_furnace": "Gas Furnace", + "lookup.home_profile.electric_furnace": "Electric Furnace", + "lookup.home_profile.heat_pump": "Heat Pump", + "lookup.home_profile.boiler": "Boiler", + "lookup.home_profile.radiant": "Radiant", + "lookup.home_profile.other": "Other", + "lookup.home_profile.central_ac": "Central AC", + "lookup.home_profile.window_ac": "Window AC", + "lookup.home_profile.evaporative": "Evaporative", + "lookup.home_profile.none": "None", + "lookup.home_profile.tank_gas": "Tank (Gas)", + "lookup.home_profile.tank_electric": "Tank (Electric)", + "lookup.home_profile.tankless_gas": "Tankless (Gas)", + "lookup.home_profile.tankless_electric": "Tankless (Electric)", + "lookup.home_profile.solar": "Solar", + "lookup.home_profile.asphalt_shingle": "Asphalt Shingle", + "lookup.home_profile.metal": "Metal", + "lookup.home_profile.tile": "Tile", + "lookup.home_profile.slate": "Slate", + "lookup.home_profile.wood_shake": "Wood Shake", + "lookup.home_profile.flat": "Flat", + "lookup.home_profile.brick": "Brick", + "lookup.home_profile.vinyl_siding": "Vinyl Siding", + "lookup.home_profile.wood_siding": "Wood Siding", + "lookup.home_profile.stucco": "Stucco", + "lookup.home_profile.stone": "Stone", + "lookup.home_profile.fiber_cement": "Fiber Cement", + "lookup.home_profile.hardwood": "Hardwood", + "lookup.home_profile.laminate": "Laminate", + "lookup.home_profile.carpet": "Carpet", + "lookup.home_profile.vinyl": "Vinyl", + "lookup.home_profile.concrete": "Concrete", + "lookup.home_profile.lawn": "Lawn", + "lookup.home_profile.desert": "Desert", + "lookup.home_profile.xeriscape": "Xeriscape", + "lookup.home_profile.garden": "Garden", + "lookup.home_profile.mixed": "Mixed", + "lookup.document_type.warranty": "Warranty", + "lookup.document_type.manual": "User Manual", + "lookup.document_type.receipt": "Receipt/Invoice", + "lookup.document_type.inspection": "Inspection Report", + "lookup.document_type.permit": "Permit", + "lookup.document_type.deed": "Deed/Title", + "lookup.document_type.insurance": "Insurance", + "lookup.document_type.contract": "Contract", + "lookup.document_type.photo": "Photo", + "lookup.document_type.other": "Other", + "lookup.document_category.appliance": "Appliance", + "lookup.document_category.hvac": "HVAC", + "lookup.document_category.plumbing": "Plumbing", + "lookup.document_category.electrical": "Electrical", + "lookup.document_category.roofing": "Roofing", + "lookup.document_category.structural": "Structural", + "lookup.document_category.landscaping": "Landscaping", + "lookup.document_category.general": "General", + "lookup.document_category.other": "Other" } diff --git a/internal/i18n/translations/es.json b/internal/i18n/translations/es.json index f5e78f0..b47d3fb 100644 --- a/internal/i18n/translations/es.json +++ b/internal/i18n/translations/es.json @@ -25,7 +25,6 @@ "error.google_signin_not_configured": "El inicio de sesion con Google no esta configurado", "error.google_signin_failed": "Error en el inicio de sesion con Google", "error.invalid_google_token": "Token de identidad de Google no valido", - "error.invalid_task_id": "ID de tarea no valido", "error.invalid_residence_id": "ID de propiedad no valido", "error.invalid_contractor_id": "ID de contratista no valido", @@ -34,7 +33,6 @@ "error.invalid_user_id": "ID de usuario no valido", "error.invalid_notification_id": "ID de notificacion no valido", "error.invalid_device_id": "ID de dispositivo no valido", - "error.task_not_found": "Tarea no encontrada", "error.residence_not_found": "Propiedad no encontrada", "error.contractor_not_found": "Contratista no encontrado", @@ -43,7 +41,6 @@ "error.user_not_found": "Usuario no encontrado", "error.share_code_invalid": "Codigo de compartir no valido", "error.share_code_expired": "El codigo de compartir ha expirado", - "error.task_access_denied": "No tienes acceso a esta tarea", "error.residence_access_denied": "No tienes acceso a esta propiedad", "error.contractor_access_denied": "No tienes acceso a este contratista", @@ -52,10 +49,8 @@ "error.cannot_remove_owner": "No se puede eliminar al propietario de la propiedad", "error.user_already_member": "El usuario ya es miembro de esta propiedad", "error.properties_limit_reached": "Has alcanzado el numero maximo de propiedades para tu suscripcion", - "error.task_already_cancelled": "La tarea ya esta cancelada", "error.task_already_archived": "La tarea ya esta archivada", - "error.failed_to_parse_form": "Error al analizar el formulario", "error.task_id_required": "Se requiere task_id", "error.invalid_task_id_value": "task_id no valido", @@ -64,14 +59,12 @@ "error.invalid_residence_id_value": "residence_id no valido", "error.title_required": "Se requiere el titulo", "error.failed_to_upload_file": "Error al subir el archivo", - "message.logged_out": "Sesion cerrada correctamente", "message.email_verified": "Correo electronico verificado correctamente", "message.verification_email_sent": "Correo de verificacion enviado", "message.password_reset_email_sent": "Si existe una cuenta con ese correo electronico, se ha enviado un codigo de restablecimiento de contrasena.", "message.reset_code_verified": "Codigo verificado correctamente", "message.password_reset_success": "Contrasena restablecida correctamente. Por favor, inicia sesion con tu nueva contrasena.", - "message.task_deleted": "Tarea eliminada correctamente", "message.task_in_progress": "Tarea marcada como en progreso", "message.task_cancelled": "Tarea cancelada", @@ -79,46 +72,35 @@ "message.task_archived": "Tarea archivada", "message.task_unarchived": "Tarea desarchivada", "message.completion_deleted": "Finalizacion eliminada correctamente", - "message.residence_deleted": "Propiedad eliminada correctamente", "message.user_removed": "Usuario eliminado de la propiedad", "message.tasks_report_generated": "Informe de tareas generado correctamente", "message.tasks_report_sent": "Informe de tareas generado y enviado a {{.Email}}", "message.tasks_report_email_failed": "Informe de tareas generado pero no se pudo enviar el correo", - "message.contractor_deleted": "Contratista eliminado correctamente", - "message.document_deleted": "Documento eliminado correctamente", "message.document_activated": "Documento activado", "message.document_deactivated": "Documento desactivado", - "message.notification_marked_read": "Notificación marcada como leída", "message.all_notifications_marked_read": "Todas las notificaciones marcadas como leídas", "message.device_removed": "Dispositivo eliminado", - "message.subscription_upgraded": "Suscripción actualizada correctamente", "message.subscription_cancelled": "Suscripción cancelada. Mantendrás los beneficios Pro hasta el final de tu período de facturación.", "message.subscription_restored": "Suscripción restaurada correctamente", - "message.file_deleted": "Archivo eliminado correctamente", "message.static_data_refreshed": "Datos estáticos actualizados", - "error.notification_not_found": "Notificación no encontrada", "error.invalid_platform": "Plataforma no válida", - "error.upgrade_trigger_not_found": "Trigger de actualización no encontrado", "error.receipt_data_required": "Se requiere receipt_data para iOS", "error.purchase_token_required": "Se requiere purchase_token para Android", - "error.no_file_provided": "No se proporcionó ningún archivo", - "error.failed_to_fetch_residence_types": "Error al obtener los tipos de propiedad", "error.failed_to_fetch_task_categories": "Error al obtener las categorías de tareas", "error.failed_to_fetch_task_priorities": "Error al obtener las prioridades de tareas", "error.failed_to_fetch_task_frequencies": "Error al obtener las frecuencias de tareas", "error.failed_to_fetch_task_statuses": "Error al obtener los estados de tareas", "error.failed_to_fetch_contractor_specialties": "Error al obtener las especialidades de contratistas", - "push.task_due_soon.title": "Tarea Proxima a Vencer", "push.task_due_soon.body": "{{.TaskTitle}} vence {{.DueDate}}", "push.task_overdue.title": "Tarea Vencida", @@ -129,44 +111,38 @@ "push.task_assigned.body": "Se te ha asignado {{.TaskTitle}}", "push.residence_shared.title": "Propiedad Compartida", "push.residence_shared.body": "{{.UserName}} compartio {{.ResidenceName}} contigo", - "email.welcome.subject": "Bienvenido a honeyDue!", "email.verification.subject": "Verifica Tu Correo Electronico", "email.password_reset.subject": "Codigo de Restablecimiento de Contrasena", "email.tasks_report.subject": "Informe de Tareas para {{.ResidenceName}}", - "lookup.residence_type.house": "Casa", "lookup.residence_type.apartment": "Apartamento", "lookup.residence_type.condo": "Condominio", - "lookup.residence_type.townhouse": "Casa Adosada", - "lookup.residence_type.mobile_home": "Casa Movil", + "lookup.residence_type.townhouse": "Casa adosada", + "lookup.residence_type.mobile_home": "Casa móvil", "lookup.residence_type.other": "Otro", - - "lookup.task_category.plumbing": "Plomeria", - "lookup.task_category.electrical": "Electricidad", - "lookup.task_category.hvac": "Climatizacion", - "lookup.task_category.appliances": "Electrodomesticos", + "lookup.task_category.plumbing": "Fontanería", + "lookup.task_category.electrical": "Eléctrico", + "lookup.task_category.hvac": "Climatización", + "lookup.task_category.appliances": "Electrodomésticos", "lookup.task_category.exterior": "Exterior", "lookup.task_category.interior": "Interior", "lookup.task_category.landscaping": "Jardineria", "lookup.task_category.safety": "Seguridad", "lookup.task_category.cleaning": "Limpieza", - "lookup.task_category.pest_control": "Control de Plagas", + "lookup.task_category.pest_control": "Control de plagas", "lookup.task_category.seasonal": "Estacional", "lookup.task_category.other": "Otro", - "lookup.task_priority.low": "Baja", "lookup.task_priority.medium": "Media", "lookup.task_priority.high": "Alta", "lookup.task_priority.urgent": "Urgente", - "lookup.task_status.pending": "Pendiente", "lookup.task_status.in_progress": "En Progreso", "lookup.task_status.completed": "Completada", "lookup.task_status.cancelled": "Cancelada", "lookup.task_status.archived": "Archivada", - - "lookup.task_frequency.once": "Una Vez", + "lookup.task_frequency.once": "Una vez", "lookup.task_frequency.daily": "Diario", "lookup.task_frequency.weekly": "Semanal", "lookup.task_frequency.biweekly": "Cada 2 Semanas", @@ -174,18 +150,98 @@ "lookup.task_frequency.quarterly": "Trimestral", "lookup.task_frequency.semiannually": "Cada 6 Meses", "lookup.task_frequency.annually": "Anual", - - "lookup.contractor_specialty.plumber": "Plomero", + "lookup.contractor_specialty.plumber": "Fontanero", "lookup.contractor_specialty.electrician": "Electricista", - "lookup.contractor_specialty.hvac_technician": "Tecnico de Climatizacion", + "lookup.contractor_specialty.hvac_technician": "Técnico de climatización", "lookup.contractor_specialty.handyman": "Manitas", "lookup.contractor_specialty.landscaper": "Jardinero", "lookup.contractor_specialty.roofer": "Techador", "lookup.contractor_specialty.painter": "Pintor", "lookup.contractor_specialty.carpenter": "Carpintero", - "lookup.contractor_specialty.pest_control": "Control de Plagas", + "lookup.contractor_specialty.pest_control": "Control de plagas", "lookup.contractor_specialty.cleaning": "Limpieza", - "lookup.contractor_specialty.pool_service": "Servicio de Piscina", - "lookup.contractor_specialty.general_contractor": "Contratista General", - "lookup.contractor_specialty.other": "Otro" + "lookup.contractor_specialty.pool_service": "Servicio de piscina", + "lookup.contractor_specialty.general_contractor": "Contratista general", + "lookup.contractor_specialty.other": "Otro", + "suggestion.reason.has_pool": "Tu casa tiene piscina", + "suggestion.reason.has_sprinkler_system": "Tu casa tiene sistema de riego", + "suggestion.reason.has_septic": "Tu casa tiene fosa séptica", + "suggestion.reason.has_fireplace": "Tu casa tiene chimenea", + "suggestion.reason.has_garage": "Tu casa tiene garaje", + "suggestion.reason.has_basement": "Tu casa tiene sótano", + "suggestion.reason.has_attic": "Tu casa tiene ático", + "suggestion.reason.heating_type": "Coincide con tu sistema de calefacción", + "suggestion.reason.cooling_type": "Coincide con tu sistema de refrigeración", + "suggestion.reason.water_heater_type": "Coincide con tu calentador de agua", + "suggestion.reason.roof_type": "Coincide con tu tejado", + "suggestion.reason.exterior_type": "Coincide con tu exterior", + "suggestion.reason.flooring_primary": "Coincide con tu suelo", + "suggestion.reason.landscaping_type": "Coincide con tu jardín", + "suggestion.reason.property_type": "Recomendado para tu tipo de propiedad", + "suggestion.reason.climate_region": "Recomendado para tu clima", + "lookup.residence_type.duplex": "Dúplex", + "lookup.residence_type.vacation_home": "Casa de vacaciones", + "lookup.task_category.general": "General", + "lookup.task_frequency.bi_weekly": "Quincenal", + "lookup.task_frequency.semi_annually": "Semestral", + "lookup.task_frequency.custom": "Personalizado", + "lookup.contractor_specialty.appliance_repair": "Reparación de electrodomésticos", + "lookup.contractor_specialty.cleaner": "Limpiador", + "lookup.contractor_specialty.locksmith": "Cerrajero", + "lookup.home_profile.gas_furnace": "Calefactor de gas", + "lookup.home_profile.electric_furnace": "Calefactor eléctrico", + "lookup.home_profile.heat_pump": "Bomba de calor", + "lookup.home_profile.boiler": "Caldera", + "lookup.home_profile.radiant": "Radiante", + "lookup.home_profile.other": "Otro", + "lookup.home_profile.central_ac": "AC central", + "lookup.home_profile.window_ac": "AC de ventana", + "lookup.home_profile.evaporative": "Evaporativo", + "lookup.home_profile.none": "Ninguno", + "lookup.home_profile.tank_gas": "Tanque (gas)", + "lookup.home_profile.tank_electric": "Tanque (eléctrico)", + "lookup.home_profile.tankless_gas": "Sin tanque (gas)", + "lookup.home_profile.tankless_electric": "Sin tanque (eléctrico)", + "lookup.home_profile.solar": "Solar", + "lookup.home_profile.asphalt_shingle": "Teja asfáltica", + "lookup.home_profile.metal": "Metal", + "lookup.home_profile.tile": "Teja", + "lookup.home_profile.slate": "Pizarra", + "lookup.home_profile.wood_shake": "Tablilla de madera", + "lookup.home_profile.flat": "Plano", + "lookup.home_profile.brick": "Ladrillo", + "lookup.home_profile.vinyl_siding": "Revestimiento de vinilo", + "lookup.home_profile.wood_siding": "Revestimiento de madera", + "lookup.home_profile.stucco": "Estuco", + "lookup.home_profile.stone": "Piedra", + "lookup.home_profile.fiber_cement": "Fibrocemento", + "lookup.home_profile.hardwood": "Madera dura", + "lookup.home_profile.laminate": "Laminado", + "lookup.home_profile.carpet": "Alfombra", + "lookup.home_profile.vinyl": "Vinilo", + "lookup.home_profile.concrete": "Hormigón", + "lookup.home_profile.lawn": "Césped", + "lookup.home_profile.desert": "Desierto", + "lookup.home_profile.xeriscape": "Xerojardinería", + "lookup.home_profile.garden": "Jardín", + "lookup.home_profile.mixed": "Mixto", + "lookup.document_type.warranty": "Garantía", + "lookup.document_type.manual": "Manual de usuario", + "lookup.document_type.receipt": "Recibo/Factura", + "lookup.document_type.inspection": "Informe de inspección", + "lookup.document_type.permit": "Permiso", + "lookup.document_type.deed": "Escritura/Título", + "lookup.document_type.insurance": "Seguro", + "lookup.document_type.contract": "Contrato", + "lookup.document_type.photo": "Foto", + "lookup.document_type.other": "Otro", + "lookup.document_category.appliance": "Electrodoméstico", + "lookup.document_category.hvac": "Climatización", + "lookup.document_category.plumbing": "Fontanería", + "lookup.document_category.electrical": "Eléctrico", + "lookup.document_category.roofing": "Tejado", + "lookup.document_category.structural": "Estructural", + "lookup.document_category.landscaping": "Jardinería", + "lookup.document_category.general": "General", + "lookup.document_category.other": "Otro" } diff --git a/internal/i18n/translations/fr.json b/internal/i18n/translations/fr.json index 6a4561c..aabaccb 100644 --- a/internal/i18n/translations/fr.json +++ b/internal/i18n/translations/fr.json @@ -25,7 +25,6 @@ "error.google_signin_not_configured": "La connexion Google n'est pas configuree", "error.google_signin_failed": "Echec de la connexion Google", "error.invalid_google_token": "Jeton d'identite Google non valide", - "error.invalid_task_id": "ID de tache non valide", "error.invalid_residence_id": "ID de propriete non valide", "error.invalid_contractor_id": "ID de prestataire non valide", @@ -34,7 +33,6 @@ "error.invalid_user_id": "ID d'utilisateur non valide", "error.invalid_notification_id": "ID de notification non valide", "error.invalid_device_id": "ID d'appareil non valide", - "error.task_not_found": "Tache non trouvee", "error.residence_not_found": "Propriete non trouvee", "error.contractor_not_found": "Prestataire non trouve", @@ -43,7 +41,6 @@ "error.user_not_found": "Utilisateur non trouve", "error.share_code_invalid": "Code de partage non valide", "error.share_code_expired": "Le code de partage a expire", - "error.task_access_denied": "Vous n'avez pas acces a cette tache", "error.residence_access_denied": "Vous n'avez pas acces a cette propriete", "error.contractor_access_denied": "Vous n'avez pas acces a ce prestataire", @@ -52,10 +49,8 @@ "error.cannot_remove_owner": "Impossible de retirer le proprietaire", "error.user_already_member": "L'utilisateur est deja membre de cette propriete", "error.properties_limit_reached": "Vous avez atteint le nombre maximum de proprietes pour votre abonnement", - "error.task_already_cancelled": "La tache est deja annulee", "error.task_already_archived": "La tache est deja archivee", - "error.failed_to_parse_form": "Echec de l'analyse du formulaire", "error.task_id_required": "task_id est requis", "error.invalid_task_id_value": "task_id non valide", @@ -64,14 +59,12 @@ "error.invalid_residence_id_value": "residence_id non valide", "error.title_required": "Le titre est requis", "error.failed_to_upload_file": "Echec du telechargement du fichier", - "message.logged_out": "Deconnexion reussie", "message.email_verified": "Email verifie avec succes", "message.verification_email_sent": "Email de verification envoye", "message.password_reset_email_sent": "Si un compte existe avec cet email, un code de reinitialisation a ete envoye.", "message.reset_code_verified": "Code verifie avec succes", "message.password_reset_success": "Mot de passe reinitialise avec succes. Veuillez vous connecter avec votre nouveau mot de passe.", - "message.task_deleted": "Tache supprimee avec succes", "message.task_in_progress": "Tache marquee comme en cours", "message.task_cancelled": "Tache annulee", @@ -79,46 +72,35 @@ "message.task_archived": "Tache archivee", "message.task_unarchived": "Tache desarchivee", "message.completion_deleted": "Completion supprimee avec succes", - "message.residence_deleted": "Propriete supprimee avec succes", "message.user_removed": "Utilisateur retire de la propriete", "message.tasks_report_generated": "Rapport de taches genere avec succes", "message.tasks_report_sent": "Rapport de taches genere et envoye a {{.Email}}", "message.tasks_report_email_failed": "Rapport de taches genere mais l'email n'a pas pu etre envoye", - "message.contractor_deleted": "Prestataire supprime avec succes", - "message.document_deleted": "Document supprime avec succes", "message.document_activated": "Document active", "message.document_deactivated": "Document desactive", - "message.notification_marked_read": "Notification marquée comme lue", "message.all_notifications_marked_read": "Toutes les notifications marquées comme lues", "message.device_removed": "Appareil supprimé", - "message.subscription_upgraded": "Abonnement mis à niveau avec succès", "message.subscription_cancelled": "Abonnement annulé. Vous conserverez les avantages Pro jusqu'à la fin de votre période de facturation.", "message.subscription_restored": "Abonnement restauré avec succès", - "message.file_deleted": "Fichier supprimé avec succès", "message.static_data_refreshed": "Données statiques actualisées", - "error.notification_not_found": "Notification non trouvée", "error.invalid_platform": "Plateforme non valide", - "error.upgrade_trigger_not_found": "Déclencheur de mise à niveau non trouvé", "error.receipt_data_required": "receipt_data est requis pour iOS", "error.purchase_token_required": "purchase_token est requis pour Android", - "error.no_file_provided": "Aucun fichier fourni", - "error.failed_to_fetch_residence_types": "Échec de la récupération des types de propriété", "error.failed_to_fetch_task_categories": "Échec de la récupération des catégories de tâches", "error.failed_to_fetch_task_priorities": "Échec de la récupération des priorités de tâches", "error.failed_to_fetch_task_frequencies": "Échec de la récupération des fréquences de tâches", "error.failed_to_fetch_task_statuses": "Échec de la récupération des statuts de tâches", "error.failed_to_fetch_contractor_specialties": "Échec de la récupération des spécialités des prestataires", - "push.task_due_soon.title": "Tache Bientot Due", "push.task_due_soon.body": "{{.TaskTitle}} est due le {{.DueDate}}", "push.task_overdue.title": "Tache en Retard", @@ -129,44 +111,38 @@ "push.task_assigned.body": "{{.TaskTitle}} vous a ete assignee", "push.residence_shared.title": "Propriete Partagee", "push.residence_shared.body": "{{.UserName}} a partage {{.ResidenceName}} avec vous", - "email.welcome.subject": "Bienvenue sur honeyDue !", "email.verification.subject": "Verifiez Votre Email", "email.password_reset.subject": "Code de Reinitialisation de Mot de Passe", "email.tasks_report.subject": "Rapport de Taches pour {{.ResidenceName}}", - "lookup.residence_type.house": "Maison", "lookup.residence_type.apartment": "Appartement", - "lookup.residence_type.condo": "Copropriete", - "lookup.residence_type.townhouse": "Maison de Ville", - "lookup.residence_type.mobile_home": "Mobil-home", + "lookup.residence_type.condo": "Copropriété", + "lookup.residence_type.townhouse": "Maison de ville", + "lookup.residence_type.mobile_home": "Maison mobile", "lookup.residence_type.other": "Autre", - "lookup.task_category.plumbing": "Plomberie", - "lookup.task_category.electrical": "Electricite", - "lookup.task_category.hvac": "Climatisation", - "lookup.task_category.appliances": "Electromenager", - "lookup.task_category.exterior": "Exterieur", - "lookup.task_category.interior": "Interieur", + "lookup.task_category.electrical": "Électricité", + "lookup.task_category.hvac": "CVC", + "lookup.task_category.appliances": "Électroménager", + "lookup.task_category.exterior": "Extérieur", + "lookup.task_category.interior": "Intérieur", "lookup.task_category.landscaping": "Jardinage", - "lookup.task_category.safety": "Securite", + "lookup.task_category.safety": "Sécurité", "lookup.task_category.cleaning": "Nettoyage", - "lookup.task_category.pest_control": "Lutte Antiparasitaire", + "lookup.task_category.pest_control": "Lutte antiparasitaire", "lookup.task_category.seasonal": "Saisonnier", "lookup.task_category.other": "Autre", - "lookup.task_priority.low": "Basse", "lookup.task_priority.medium": "Moyenne", "lookup.task_priority.high": "Haute", "lookup.task_priority.urgent": "Urgente", - "lookup.task_status.pending": "En Attente", "lookup.task_status.in_progress": "En Cours", "lookup.task_status.completed": "Terminee", "lookup.task_status.cancelled": "Annulee", "lookup.task_status.archived": "Archivee", - - "lookup.task_frequency.once": "Une Fois", + "lookup.task_frequency.once": "Une fois", "lookup.task_frequency.daily": "Quotidien", "lookup.task_frequency.weekly": "Hebdomadaire", "lookup.task_frequency.biweekly": "Toutes les 2 Semaines", @@ -174,18 +150,98 @@ "lookup.task_frequency.quarterly": "Trimestriel", "lookup.task_frequency.semiannually": "Tous les 6 Mois", "lookup.task_frequency.annually": "Annuel", - "lookup.contractor_specialty.plumber": "Plombier", - "lookup.contractor_specialty.electrician": "Electricien", + "lookup.contractor_specialty.electrician": "Électricien", "lookup.contractor_specialty.hvac_technician": "Technicien CVC", "lookup.contractor_specialty.handyman": "Bricoleur", "lookup.contractor_specialty.landscaper": "Paysagiste", "lookup.contractor_specialty.roofer": "Couvreur", "lookup.contractor_specialty.painter": "Peintre", - "lookup.contractor_specialty.carpenter": "Menuisier", - "lookup.contractor_specialty.pest_control": "Desinsectisation", + "lookup.contractor_specialty.carpenter": "Charpentier", + "lookup.contractor_specialty.pest_control": "Lutte antiparasitaire", "lookup.contractor_specialty.cleaning": "Nettoyage", - "lookup.contractor_specialty.pool_service": "Service Piscine", - "lookup.contractor_specialty.general_contractor": "Entrepreneur General", - "lookup.contractor_specialty.other": "Autre" + "lookup.contractor_specialty.pool_service": "Service de piscine", + "lookup.contractor_specialty.general_contractor": "Entrepreneur général", + "lookup.contractor_specialty.other": "Autre", + "suggestion.reason.has_pool": "Votre logement a une piscine", + "suggestion.reason.has_sprinkler_system": "Votre logement a un système d'arrosage", + "suggestion.reason.has_septic": "Votre logement a une fosse septique", + "suggestion.reason.has_fireplace": "Votre logement a une cheminée", + "suggestion.reason.has_garage": "Votre logement a un garage", + "suggestion.reason.has_basement": "Votre logement a un sous-sol", + "suggestion.reason.has_attic": "Votre logement a des combles", + "suggestion.reason.heating_type": "Correspond à votre système de chauffage", + "suggestion.reason.cooling_type": "Correspond à votre système de climatisation", + "suggestion.reason.water_heater_type": "Correspond à votre chauffe-eau", + "suggestion.reason.roof_type": "Correspond à votre toiture", + "suggestion.reason.exterior_type": "Correspond à votre extérieur", + "suggestion.reason.flooring_primary": "Correspond à votre revêtement de sol", + "suggestion.reason.landscaping_type": "Correspond à votre aménagement paysager", + "suggestion.reason.property_type": "Recommandé pour votre type de logement", + "suggestion.reason.climate_region": "Recommandé pour votre climat", + "lookup.residence_type.duplex": "Duplex", + "lookup.residence_type.vacation_home": "Maison de vacances", + "lookup.task_category.general": "Général", + "lookup.task_frequency.bi_weekly": "Bimensuel", + "lookup.task_frequency.semi_annually": "Semestriel", + "lookup.task_frequency.custom": "Personnalisé", + "lookup.contractor_specialty.appliance_repair": "Réparation d'électroménager", + "lookup.contractor_specialty.cleaner": "Agent de nettoyage", + "lookup.contractor_specialty.locksmith": "Serrurier", + "lookup.home_profile.gas_furnace": "Fournaise au gaz", + "lookup.home_profile.electric_furnace": "Fournaise électrique", + "lookup.home_profile.heat_pump": "Pompe à chaleur", + "lookup.home_profile.boiler": "Chaudière", + "lookup.home_profile.radiant": "Rayonnant", + "lookup.home_profile.other": "Autre", + "lookup.home_profile.central_ac": "Climatisation centrale", + "lookup.home_profile.window_ac": "Climatiseur de fenêtre", + "lookup.home_profile.evaporative": "Évaporatif", + "lookup.home_profile.none": "Aucun", + "lookup.home_profile.tank_gas": "Réservoir (gaz)", + "lookup.home_profile.tank_electric": "Réservoir (électrique)", + "lookup.home_profile.tankless_gas": "Sans réservoir (gaz)", + "lookup.home_profile.tankless_electric": "Sans réservoir (électrique)", + "lookup.home_profile.solar": "Solaire", + "lookup.home_profile.asphalt_shingle": "Bardeau d'asphalte", + "lookup.home_profile.metal": "Métal", + "lookup.home_profile.tile": "Tuile", + "lookup.home_profile.slate": "Ardoise", + "lookup.home_profile.wood_shake": "Bardeau de bois", + "lookup.home_profile.flat": "Plat", + "lookup.home_profile.brick": "Brique", + "lookup.home_profile.vinyl_siding": "Revêtement vinyle", + "lookup.home_profile.wood_siding": "Revêtement bois", + "lookup.home_profile.stucco": "Stuc", + "lookup.home_profile.stone": "Pierre", + "lookup.home_profile.fiber_cement": "Fibrociment", + "lookup.home_profile.hardwood": "Bois franc", + "lookup.home_profile.laminate": "Stratifié", + "lookup.home_profile.carpet": "Moquette", + "lookup.home_profile.vinyl": "Vinyle", + "lookup.home_profile.concrete": "Béton", + "lookup.home_profile.lawn": "Pelouse", + "lookup.home_profile.desert": "Désert", + "lookup.home_profile.xeriscape": "Xéropaysagisme", + "lookup.home_profile.garden": "Jardin", + "lookup.home_profile.mixed": "Mixte", + "lookup.document_type.warranty": "Garantie", + "lookup.document_type.manual": "Manuel d'utilisation", + "lookup.document_type.receipt": "Reçu/Facture", + "lookup.document_type.inspection": "Rapport d'inspection", + "lookup.document_type.permit": "Permis", + "lookup.document_type.deed": "Acte/Titre", + "lookup.document_type.insurance": "Assurance", + "lookup.document_type.contract": "Contrat", + "lookup.document_type.photo": "Photo", + "lookup.document_type.other": "Autre", + "lookup.document_category.appliance": "Électroménager", + "lookup.document_category.hvac": "CVC", + "lookup.document_category.plumbing": "Plomberie", + "lookup.document_category.electrical": "Électricité", + "lookup.document_category.roofing": "Toiture", + "lookup.document_category.structural": "Structure", + "lookup.document_category.landscaping": "Aménagement paysager", + "lookup.document_category.general": "Général", + "lookup.document_category.other": "Autre" } diff --git a/internal/i18n/translations/it.json b/internal/i18n/translations/it.json index ad42c18..344873f 100644 --- a/internal/i18n/translations/it.json +++ b/internal/i18n/translations/it.json @@ -25,7 +25,6 @@ "error.google_signin_not_configured": "L'accesso con Google non è configurato", "error.google_signin_failed": "Accesso con Google fallito", "error.invalid_google_token": "Token di identità Google non valido", - "error.invalid_task_id": "ID attività non valido", "error.invalid_residence_id": "ID immobile non valido", "error.invalid_contractor_id": "ID fornitore non valido", @@ -34,7 +33,6 @@ "error.invalid_user_id": "ID utente non valido", "error.invalid_notification_id": "ID notifica non valido", "error.invalid_device_id": "ID dispositivo non valido", - "error.task_not_found": "Attività non trovata", "error.residence_not_found": "Immobile non trovato", "error.contractor_not_found": "Fornitore non trovato", @@ -43,7 +41,6 @@ "error.user_not_found": "Utente non trovato", "error.share_code_invalid": "Codice di condivisione non valido", "error.share_code_expired": "Il codice di condivisione è scaduto", - "error.task_access_denied": "Non hai accesso a questa attività", "error.residence_access_denied": "Non hai accesso a questo immobile", "error.contractor_access_denied": "Non hai accesso a questo fornitore", @@ -52,10 +49,8 @@ "error.cannot_remove_owner": "Impossibile rimuovere il proprietario dell'immobile", "error.user_already_member": "L'utente è già membro di questo immobile", "error.properties_limit_reached": "Hai raggiunto il numero massimo di immobili per il tuo abbonamento", - "error.task_already_cancelled": "L'attività è già stata annullata", "error.task_already_archived": "L'attività è già stata archiviata", - "error.failed_to_parse_form": "Impossibile analizzare il modulo multipart", "error.task_id_required": "task_id è obbligatorio", "error.invalid_task_id_value": "task_id non valido", @@ -64,14 +59,12 @@ "error.invalid_residence_id_value": "residence_id non valido", "error.title_required": "title è obbligatorio", "error.failed_to_upload_file": "Impossibile caricare il file", - "message.logged_out": "Disconnessione avvenuta con successo", "message.email_verified": "Email verificata con successo", "message.verification_email_sent": "Email di verifica inviata", "message.password_reset_email_sent": "Se esiste un account con quell'email, è stato inviato un codice di reimpostazione password.", "message.reset_code_verified": "Codice verificato con successo", "message.password_reset_success": "Password reimpostata con successo. Accedi con la tua nuova password.", - "message.task_deleted": "Attività eliminata con successo", "message.task_in_progress": "Attività contrassegnata come in corso", "message.task_cancelled": "Attività annullata", @@ -79,46 +72,35 @@ "message.task_archived": "Attività archiviata", "message.task_unarchived": "Attività ripristinata dall'archivio", "message.completion_deleted": "Completamento eliminato con successo", - "message.residence_deleted": "Immobile eliminato con successo", "message.user_removed": "Utente rimosso dall'immobile", "message.tasks_report_generated": "Report attività generato con successo", "message.tasks_report_sent": "Report attività generato e inviato a {{.Email}}", "message.tasks_report_email_failed": "Report attività generato ma l'email non è stata inviata", - "message.contractor_deleted": "Fornitore eliminato con successo", - "message.document_deleted": "Documento eliminato con successo", "message.document_activated": "Documento attivato", "message.document_deactivated": "Documento disattivato", - "message.notification_marked_read": "Notifica contrassegnata come letta", "message.all_notifications_marked_read": "Tutte le notifiche contrassegnate come lette", "message.device_removed": "Dispositivo rimosso", - "message.subscription_upgraded": "Abbonamento aggiornato con successo", "message.subscription_cancelled": "Abbonamento annullato. Manterrai i vantaggi Pro fino alla fine del periodo di fatturazione.", "message.subscription_restored": "Abbonamento ripristinato con successo", - "message.file_deleted": "File eliminato con successo", "message.static_data_refreshed": "Dati statici aggiornati", - "error.notification_not_found": "Notifica non trovata", "error.invalid_platform": "Piattaforma non valida", - "error.upgrade_trigger_not_found": "Trigger di aggiornamento non trovato", "error.receipt_data_required": "receipt_data è obbligatorio per iOS", "error.purchase_token_required": "purchase_token è obbligatorio per Android", - "error.no_file_provided": "Nessun file fornito", - "error.failed_to_fetch_residence_types": "Impossibile recuperare i tipi di immobile", "error.failed_to_fetch_task_categories": "Impossibile recuperare le categorie di attività", "error.failed_to_fetch_task_priorities": "Impossibile recuperare le priorità delle attività", "error.failed_to_fetch_task_frequencies": "Impossibile recuperare le frequenze delle attività", "error.failed_to_fetch_task_statuses": "Impossibile recuperare gli stati delle attività", "error.failed_to_fetch_contractor_specialties": "Impossibile recuperare le specializzazioni dei fornitori", - "push.task_due_soon.title": "Attività in Scadenza", "push.task_due_soon.body": "{{.TaskTitle}} scade {{.DueDate}}", "push.task_overdue.title": "Attività Scaduta", @@ -129,22 +111,19 @@ "push.task_assigned.body": "Ti è stata assegnata {{.TaskTitle}}", "push.residence_shared.title": "Immobile Condiviso", "push.residence_shared.body": "{{.UserName}} ha condiviso {{.ResidenceName}} con te", - "email.welcome.subject": "Benvenuto su honeyDue!", "email.verification.subject": "Verifica la Tua Email", "email.password_reset.subject": "Codice di Reimpostazione Password", "email.tasks_report.subject": "Report Attività per {{.ResidenceName}}", - "lookup.residence_type.house": "Casa", "lookup.residence_type.apartment": "Appartamento", "lookup.residence_type.condo": "Condominio", - "lookup.residence_type.townhouse": "Villetta a Schiera", - "lookup.residence_type.mobile_home": "Casa Mobile", + "lookup.residence_type.townhouse": "Villetta a schiera", + "lookup.residence_type.mobile_home": "Casa mobile", "lookup.residence_type.other": "Altro", - "lookup.task_category.plumbing": "Idraulica", - "lookup.task_category.electrical": "Elettricità", - "lookup.task_category.hvac": "Climatizzazione", + "lookup.task_category.electrical": "Elettrico", + "lookup.task_category.hvac": "HVAC", "lookup.task_category.appliances": "Elettrodomestici", "lookup.task_category.exterior": "Esterno", "lookup.task_category.interior": "Interno", @@ -154,38 +133,115 @@ "lookup.task_category.pest_control": "Disinfestazione", "lookup.task_category.seasonal": "Stagionale", "lookup.task_category.other": "Altro", - "lookup.task_priority.low": "Bassa", "lookup.task_priority.medium": "Media", "lookup.task_priority.high": "Alta", "lookup.task_priority.urgent": "Urgente", - "lookup.task_status.pending": "In Attesa", "lookup.task_status.in_progress": "In Corso", "lookup.task_status.completed": "Completata", "lookup.task_status.cancelled": "Annullata", "lookup.task_status.archived": "Archiviata", - - "lookup.task_frequency.once": "Una Volta", - "lookup.task_frequency.daily": "Giornaliera", + "lookup.task_frequency.once": "Una volta", + "lookup.task_frequency.daily": "Giornaliero", "lookup.task_frequency.weekly": "Settimanale", "lookup.task_frequency.biweekly": "Ogni 2 Settimane", "lookup.task_frequency.monthly": "Mensile", "lookup.task_frequency.quarterly": "Trimestrale", "lookup.task_frequency.semiannually": "Ogni 6 Mesi", "lookup.task_frequency.annually": "Annuale", - "lookup.contractor_specialty.plumber": "Idraulico", "lookup.contractor_specialty.electrician": "Elettricista", - "lookup.contractor_specialty.hvac_technician": "Tecnico Climatizzazione", + "lookup.contractor_specialty.hvac_technician": "Tecnico HVAC", "lookup.contractor_specialty.handyman": "Tuttofare", "lookup.contractor_specialty.landscaper": "Giardiniere", - "lookup.contractor_specialty.roofer": "Lattoniere", + "lookup.contractor_specialty.roofer": "Conciatetti", "lookup.contractor_specialty.painter": "Imbianchino", "lookup.contractor_specialty.carpenter": "Falegname", "lookup.contractor_specialty.pest_control": "Disinfestazione", "lookup.contractor_specialty.cleaning": "Pulizia", - "lookup.contractor_specialty.pool_service": "Manutenzione Piscine", - "lookup.contractor_specialty.general_contractor": "Impresa Generale", - "lookup.contractor_specialty.other": "Altro" + "lookup.contractor_specialty.pool_service": "Servizio piscina", + "lookup.contractor_specialty.general_contractor": "Imprenditore generale", + "lookup.contractor_specialty.other": "Altro", + "suggestion.reason.has_pool": "La tua casa ha una piscina", + "suggestion.reason.has_sprinkler_system": "La tua casa ha un impianto di irrigazione", + "suggestion.reason.has_septic": "La tua casa ha una fossa settica", + "suggestion.reason.has_fireplace": "La tua casa ha un camino", + "suggestion.reason.has_garage": "La tua casa ha un garage", + "suggestion.reason.has_basement": "La tua casa ha un seminterrato", + "suggestion.reason.has_attic": "La tua casa ha una soffitta", + "suggestion.reason.heating_type": "Corrisponde al tuo impianto di riscaldamento", + "suggestion.reason.cooling_type": "Corrisponde al tuo impianto di raffreddamento", + "suggestion.reason.water_heater_type": "Corrisponde al tuo scaldabagno", + "suggestion.reason.roof_type": "Corrisponde al tuo tetto", + "suggestion.reason.exterior_type": "Corrisponde al tuo esterno", + "suggestion.reason.flooring_primary": "Corrisponde alla tua pavimentazione", + "suggestion.reason.landscaping_type": "Corrisponde al tuo giardino", + "suggestion.reason.property_type": "Consigliato per il tuo tipo di immobile", + "suggestion.reason.climate_region": "Consigliato per il tuo clima", + "lookup.residence_type.duplex": "Bifamiliare", + "lookup.residence_type.vacation_home": "Casa vacanze", + "lookup.task_category.general": "Generale", + "lookup.task_frequency.bi_weekly": "Bisettimanale", + "lookup.task_frequency.semi_annually": "Semestrale", + "lookup.task_frequency.custom": "Personalizzato", + "lookup.contractor_specialty.appliance_repair": "Riparazione elettrodomestici", + "lookup.contractor_specialty.cleaner": "Addetto alle pulizie", + "lookup.contractor_specialty.locksmith": "Fabbro", + "lookup.home_profile.gas_furnace": "Caldaia a gas", + "lookup.home_profile.electric_furnace": "Caldaia elettrica", + "lookup.home_profile.heat_pump": "Pompa di calore", + "lookup.home_profile.boiler": "Caldaia", + "lookup.home_profile.radiant": "Radiante", + "lookup.home_profile.other": "Altro", + "lookup.home_profile.central_ac": "Climatizzatore centrale", + "lookup.home_profile.window_ac": "Climatizzatore a finestra", + "lookup.home_profile.evaporative": "Evaporativo", + "lookup.home_profile.none": "Nessuno", + "lookup.home_profile.tank_gas": "Serbatoio (gas)", + "lookup.home_profile.tank_electric": "Serbatoio (elettrico)", + "lookup.home_profile.tankless_gas": "Senza serbatoio (gas)", + "lookup.home_profile.tankless_electric": "Senza serbatoio (elettrico)", + "lookup.home_profile.solar": "Solare", + "lookup.home_profile.asphalt_shingle": "Tegola bituminosa", + "lookup.home_profile.metal": "Metallo", + "lookup.home_profile.tile": "Tegola", + "lookup.home_profile.slate": "Ardesia", + "lookup.home_profile.wood_shake": "Scandola di legno", + "lookup.home_profile.flat": "Piatto", + "lookup.home_profile.brick": "Mattone", + "lookup.home_profile.vinyl_siding": "Rivestimento in vinile", + "lookup.home_profile.wood_siding": "Rivestimento in legno", + "lookup.home_profile.stucco": "Stucco", + "lookup.home_profile.stone": "Pietra", + "lookup.home_profile.fiber_cement": "Fibrocemento", + "lookup.home_profile.hardwood": "Legno duro", + "lookup.home_profile.laminate": "Laminato", + "lookup.home_profile.carpet": "Moquette", + "lookup.home_profile.vinyl": "Vinile", + "lookup.home_profile.concrete": "Cemento", + "lookup.home_profile.lawn": "Prato", + "lookup.home_profile.desert": "Deserto", + "lookup.home_profile.xeriscape": "Xeriscaping", + "lookup.home_profile.garden": "Giardino", + "lookup.home_profile.mixed": "Misto", + "lookup.document_type.warranty": "Garanzia", + "lookup.document_type.manual": "Manuale d'uso", + "lookup.document_type.receipt": "Ricevuta/Fattura", + "lookup.document_type.inspection": "Rapporto di ispezione", + "lookup.document_type.permit": "Permesso", + "lookup.document_type.deed": "Atto/Titolo", + "lookup.document_type.insurance": "Assicurazione", + "lookup.document_type.contract": "Contratto", + "lookup.document_type.photo": "Foto", + "lookup.document_type.other": "Altro", + "lookup.document_category.appliance": "Elettrodomestico", + "lookup.document_category.hvac": "HVAC", + "lookup.document_category.plumbing": "Idraulica", + "lookup.document_category.electrical": "Elettrico", + "lookup.document_category.roofing": "Tetto", + "lookup.document_category.structural": "Strutturale", + "lookup.document_category.landscaping": "Giardinaggio", + "lookup.document_category.general": "Generale", + "lookup.document_category.other": "Altro" } diff --git a/internal/i18n/translations/ja.json b/internal/i18n/translations/ja.json index eabfa97..8244fa5 100644 --- a/internal/i18n/translations/ja.json +++ b/internal/i18n/translations/ja.json @@ -25,7 +25,6 @@ "error.google_signin_not_configured": "Google サインインが設定されていません", "error.google_signin_failed": "Google サインインに失敗しました", "error.invalid_google_token": "無効な Google ID トークンです", - "error.invalid_task_id": "無効なタスクIDです", "error.invalid_residence_id": "無効な物件IDです", "error.invalid_contractor_id": "無効な業者IDです", @@ -34,7 +33,6 @@ "error.invalid_user_id": "無効なユーザーIDです", "error.invalid_notification_id": "無効な通知IDです", "error.invalid_device_id": "無効なデバイスIDです", - "error.task_not_found": "タスクが見つかりません", "error.residence_not_found": "物件が見つかりません", "error.contractor_not_found": "業者が見つかりません", @@ -43,7 +41,6 @@ "error.user_not_found": "ユーザーが見つかりません", "error.share_code_invalid": "無効な共有コードです", "error.share_code_expired": "共有コードの有効期限が切れています", - "error.task_access_denied": "このタスクへのアクセス権がありません", "error.residence_access_denied": "この物件へのアクセス権がありません", "error.contractor_access_denied": "この業者へのアクセス権がありません", @@ -52,10 +49,8 @@ "error.cannot_remove_owner": "物件のオーナーを削除することはできません", "error.user_already_member": "このユーザーは既にこの物件のメンバーです", "error.properties_limit_reached": "サブスクリプションで許可されている物件の最大数に達しました", - "error.task_already_cancelled": "タスクは既にキャンセルされています", "error.task_already_archived": "タスクは既にアーカイブされています", - "error.failed_to_parse_form": "マルチパートフォームの解析に失敗しました", "error.task_id_required": "task_id は必須です", "error.invalid_task_id_value": "無効な task_id です", @@ -64,14 +59,12 @@ "error.invalid_residence_id_value": "無効な residence_id です", "error.title_required": "タイトルは必須です", "error.failed_to_upload_file": "ファイルのアップロードに失敗しました", - "message.logged_out": "ログアウトしました", "message.email_verified": "メールアドレスの認証が完了しました", "message.verification_email_sent": "認証メールを送信しました", "message.password_reset_email_sent": "該当するアカウントが存在する場合、パスワードリセットコードが送信されました。", "message.reset_code_verified": "コードの認証が完了しました", "message.password_reset_success": "パスワードのリセットが完了しました。新しいパスワードでログインしてください。", - "message.task_deleted": "タスクを削除しました", "message.task_in_progress": "タスクを進行中に設定しました", "message.task_cancelled": "タスクをキャンセルしました", @@ -79,46 +72,35 @@ "message.task_archived": "タスクをアーカイブしました", "message.task_unarchived": "タスクのアーカイブを解除しました", "message.completion_deleted": "完了記録を削除しました", - "message.residence_deleted": "物件を削除しました", "message.user_removed": "ユーザーを物件から削除しました", "message.tasks_report_generated": "タスクレポートを生成しました", "message.tasks_report_sent": "タスクレポートを生成し、{{.Email}} に送信しました", "message.tasks_report_email_failed": "タスクレポートは生成されましたが、メールの送信に失敗しました", - "message.contractor_deleted": "業者を削除しました", - "message.document_deleted": "書類を削除しました", "message.document_activated": "書類を有効化しました", "message.document_deactivated": "書類を無効化しました", - "message.notification_marked_read": "通知を既読にしました", "message.all_notifications_marked_read": "すべての通知を既読にしました", "message.device_removed": "デバイスを削除しました", - "message.subscription_upgraded": "サブスクリプションをアップグレードしました", "message.subscription_cancelled": "サブスクリプションをキャンセルしました。請求期間終了まで Pro 機能をご利用いただけます。", "message.subscription_restored": "サブスクリプションを復元しました", - "message.file_deleted": "ファイルを削除しました", "message.static_data_refreshed": "静的データを更新しました", - "error.notification_not_found": "通知が見つかりません", "error.invalid_platform": "無効なプラットフォームです", - "error.upgrade_trigger_not_found": "アップグレードトリガーが見つかりません", "error.receipt_data_required": "iOS の場合、receipt_data は必須です", "error.purchase_token_required": "Android の場合、purchase_token は必須です", - "error.no_file_provided": "ファイルが提供されていません", - "error.failed_to_fetch_residence_types": "物件タイプの取得に失敗しました", "error.failed_to_fetch_task_categories": "タスクカテゴリの取得に失敗しました", "error.failed_to_fetch_task_priorities": "タスク優先度の取得に失敗しました", "error.failed_to_fetch_task_frequencies": "タスク頻度の取得に失敗しました", "error.failed_to_fetch_task_statuses": "タスクステータスの取得に失敗しました", "error.failed_to_fetch_contractor_specialties": "業者専門分野の取得に失敗しました", - "push.task_due_soon.title": "タスクの期限が近づいています", "push.task_due_soon.body": "{{.TaskTitle}} の期限は {{.DueDate}} です", "push.task_overdue.title": "期限切れのタスク", @@ -129,19 +111,16 @@ "push.task_assigned.body": "{{.TaskTitle}} に割り当てられました", "push.residence_shared.title": "物件が共有されました", "push.residence_shared.body": "{{.UserName}} が {{.ResidenceName}} を共有しました", - "email.welcome.subject": "honeyDue へようこそ!", "email.verification.subject": "メールアドレスの認証", "email.password_reset.subject": "パスワードリセットコード", "email.tasks_report.subject": "{{.ResidenceName}} のタスクレポート", - "lookup.residence_type.house": "一戸建て", "lookup.residence_type.apartment": "アパート", - "lookup.residence_type.condo": "マンション", + "lookup.residence_type.condo": "分譲マンション", "lookup.residence_type.townhouse": "タウンハウス", - "lookup.residence_type.mobile_home": "移動式住宅", + "lookup.residence_type.mobile_home": "モバイルホーム", "lookup.residence_type.other": "その他", - "lookup.task_category.plumbing": "配管", "lookup.task_category.electrical": "電気", "lookup.task_category.hvac": "空調", @@ -154,19 +133,16 @@ "lookup.task_category.pest_control": "害虫駆除", "lookup.task_category.seasonal": "季節", "lookup.task_category.other": "その他", - "lookup.task_priority.low": "低", "lookup.task_priority.medium": "中", "lookup.task_priority.high": "高", "lookup.task_priority.urgent": "緊急", - "lookup.task_status.pending": "保留中", "lookup.task_status.in_progress": "進行中", "lookup.task_status.completed": "完了", "lookup.task_status.cancelled": "キャンセル", "lookup.task_status.archived": "アーカイブ", - - "lookup.task_frequency.once": "一度のみ", + "lookup.task_frequency.once": "1回", "lookup.task_frequency.daily": "毎日", "lookup.task_frequency.weekly": "毎週", "lookup.task_frequency.biweekly": "2週間ごと", @@ -174,18 +150,98 @@ "lookup.task_frequency.quarterly": "四半期ごと", "lookup.task_frequency.semiannually": "半年ごと", "lookup.task_frequency.annually": "毎年", - "lookup.contractor_specialty.plumber": "配管工", - "lookup.contractor_specialty.electrician": "電気工事士", - "lookup.contractor_specialty.hvac_technician": "空調技術者", + "lookup.contractor_specialty.electrician": "電気技師", + "lookup.contractor_specialty.hvac_technician": "空調技師", "lookup.contractor_specialty.handyman": "便利屋", "lookup.contractor_specialty.landscaper": "造園業者", "lookup.contractor_specialty.roofer": "屋根職人", - "lookup.contractor_specialty.painter": "塗装工", + "lookup.contractor_specialty.painter": "塗装業者", "lookup.contractor_specialty.carpenter": "大工", - "lookup.contractor_specialty.pest_control": "害虫駆除業者", + "lookup.contractor_specialty.pest_control": "害虫駆除", "lookup.contractor_specialty.cleaning": "清掃業者", "lookup.contractor_specialty.pool_service": "プールサービス", - "lookup.contractor_specialty.general_contractor": "総合建設業者", - "lookup.contractor_specialty.other": "その他" + "lookup.contractor_specialty.general_contractor": "総合請負業者", + "lookup.contractor_specialty.other": "その他", + "suggestion.reason.has_pool": "ご自宅にプールがあります", + "suggestion.reason.has_sprinkler_system": "ご自宅にスプリンクラーがあります", + "suggestion.reason.has_septic": "ご自宅に浄化槽があります", + "suggestion.reason.has_fireplace": "ご自宅に暖炉があります", + "suggestion.reason.has_garage": "ご自宅にガレージがあります", + "suggestion.reason.has_basement": "ご自宅に地下室があります", + "suggestion.reason.has_attic": "ご自宅に屋根裏があります", + "suggestion.reason.heating_type": "暖房設備に合っています", + "suggestion.reason.cooling_type": "冷房設備に合っています", + "suggestion.reason.water_heater_type": "給湯器に合っています", + "suggestion.reason.roof_type": "屋根に合っています", + "suggestion.reason.exterior_type": "外装に合っています", + "suggestion.reason.flooring_primary": "床材に合っています", + "suggestion.reason.landscaping_type": "造園に合っています", + "suggestion.reason.property_type": "ご自宅の種類におすすめです", + "suggestion.reason.climate_region": "お住まいの気候におすすめです", + "lookup.residence_type.duplex": "二世帯住宅", + "lookup.residence_type.vacation_home": "別荘", + "lookup.task_category.general": "一般", + "lookup.task_frequency.bi_weekly": "隔週", + "lookup.task_frequency.semi_annually": "半年ごと", + "lookup.task_frequency.custom": "カスタム", + "lookup.contractor_specialty.appliance_repair": "家電修理", + "lookup.contractor_specialty.cleaner": "清掃業者", + "lookup.contractor_specialty.locksmith": "錠前師", + "lookup.home_profile.gas_furnace": "ガス炉", + "lookup.home_profile.electric_furnace": "電気炉", + "lookup.home_profile.heat_pump": "ヒートポンプ", + "lookup.home_profile.boiler": "ボイラー", + "lookup.home_profile.radiant": "放射式", + "lookup.home_profile.other": "その他", + "lookup.home_profile.central_ac": "セントラルエアコン", + "lookup.home_profile.window_ac": "窓用エアコン", + "lookup.home_profile.evaporative": "気化式", + "lookup.home_profile.none": "なし", + "lookup.home_profile.tank_gas": "タンク式(ガス)", + "lookup.home_profile.tank_electric": "タンク式(電気)", + "lookup.home_profile.tankless_gas": "タンクレス(ガス)", + "lookup.home_profile.tankless_electric": "タンクレス(電気)", + "lookup.home_profile.solar": "ソーラー", + "lookup.home_profile.asphalt_shingle": "アスファルトシングル", + "lookup.home_profile.metal": "金属", + "lookup.home_profile.tile": "タイル", + "lookup.home_profile.slate": "スレート", + "lookup.home_profile.wood_shake": "木製シェイク", + "lookup.home_profile.flat": "平型", + "lookup.home_profile.brick": "レンガ", + "lookup.home_profile.vinyl_siding": "ビニールサイディング", + "lookup.home_profile.wood_siding": "木製サイディング", + "lookup.home_profile.stucco": "スタッコ", + "lookup.home_profile.stone": "石", + "lookup.home_profile.fiber_cement": "繊維強化セメント", + "lookup.home_profile.hardwood": "無垢材", + "lookup.home_profile.laminate": "ラミネート", + "lookup.home_profile.carpet": "カーペット", + "lookup.home_profile.vinyl": "ビニール", + "lookup.home_profile.concrete": "コンクリート", + "lookup.home_profile.lawn": "芝生", + "lookup.home_profile.desert": "砂漠", + "lookup.home_profile.xeriscape": "ゼリスケープ", + "lookup.home_profile.garden": "庭園", + "lookup.home_profile.mixed": "混合", + "lookup.document_type.warranty": "保証", + "lookup.document_type.manual": "ユーザーマニュアル", + "lookup.document_type.receipt": "領収書/請求書", + "lookup.document_type.inspection": "点検報告書", + "lookup.document_type.permit": "許可証", + "lookup.document_type.deed": "権利証/証書", + "lookup.document_type.insurance": "保険", + "lookup.document_type.contract": "契約", + "lookup.document_type.photo": "写真", + "lookup.document_type.other": "その他", + "lookup.document_category.appliance": "家電", + "lookup.document_category.hvac": "空調", + "lookup.document_category.plumbing": "配管", + "lookup.document_category.electrical": "電気", + "lookup.document_category.roofing": "屋根", + "lookup.document_category.structural": "構造", + "lookup.document_category.landscaping": "造園", + "lookup.document_category.general": "一般", + "lookup.document_category.other": "その他" } diff --git a/internal/i18n/translations/ko.json b/internal/i18n/translations/ko.json index c1629b8..6054826 100644 --- a/internal/i18n/translations/ko.json +++ b/internal/i18n/translations/ko.json @@ -25,7 +25,6 @@ "error.google_signin_not_configured": "Google 로그인이 설정되지 않았습니다", "error.google_signin_failed": "Google 로그인에 실패했습니다", "error.invalid_google_token": "유효하지 않은 Google 인증 토큰입니다", - "error.invalid_task_id": "유효하지 않은 작업 ID입니다", "error.invalid_residence_id": "유효하지 않은 주거지 ID입니다", "error.invalid_contractor_id": "유효하지 않은 계약업체 ID입니다", @@ -34,7 +33,6 @@ "error.invalid_user_id": "유효하지 않은 사용자 ID입니다", "error.invalid_notification_id": "유효하지 않은 알림 ID입니다", "error.invalid_device_id": "유효하지 않은 기기 ID입니다", - "error.task_not_found": "작업을 찾을 수 없습니다", "error.residence_not_found": "주거지를 찾을 수 없습니다", "error.contractor_not_found": "계약업체를 찾을 수 없습니다", @@ -43,7 +41,6 @@ "error.user_not_found": "사용자를 찾을 수 없습니다", "error.share_code_invalid": "유효하지 않은 공유 코드입니다", "error.share_code_expired": "공유 코드가 만료되었습니다", - "error.task_access_denied": "이 작업에 접근할 권한이 없습니다", "error.residence_access_denied": "이 주거지에 접근할 권한이 없습니다", "error.contractor_access_denied": "이 계약업체에 접근할 권한이 없습니다", @@ -52,10 +49,8 @@ "error.cannot_remove_owner": "주거지 소유자는 삭제할 수 없습니다", "error.user_already_member": "이미 이 주거지의 멤버입니다", "error.properties_limit_reached": "구독 플랜의 최대 주거지 수에 도달했습니다", - "error.task_already_cancelled": "이미 취소된 작업입니다", "error.task_already_archived": "이미 보관된 작업입니다", - "error.failed_to_parse_form": "멀티파트 폼 파싱에 실패했습니다", "error.task_id_required": "task_id가 필요합니다", "error.invalid_task_id_value": "유효하지 않은 task_id 값입니다", @@ -64,14 +59,12 @@ "error.invalid_residence_id_value": "유효하지 않은 residence_id 값입니다", "error.title_required": "제목이 필요합니다", "error.failed_to_upload_file": "파일 업로드에 실패했습니다", - "message.logged_out": "로그아웃되었습니다", "message.email_verified": "이메일이 인증되었습니다", "message.verification_email_sent": "인증 이메일이 발송되었습니다", "message.password_reset_email_sent": "해당 이메일로 등록된 계정이 있는 경우 비밀번호 재설정 코드가 발송되었습니다.", "message.reset_code_verified": "코드가 인증되었습니다", "message.password_reset_success": "비밀번호가 재설정되었습니다. 새 비밀번호로 로그인해주세요.", - "message.task_deleted": "작업이 삭제되었습니다", "message.task_in_progress": "작업이 진행 중으로 표시되었습니다", "message.task_cancelled": "작업이 취소되었습니다", @@ -79,46 +72,35 @@ "message.task_archived": "작업이 보관되었습니다", "message.task_unarchived": "작업 보관이 해제되었습니다", "message.completion_deleted": "완료 기록이 삭제되었습니다", - "message.residence_deleted": "주거지가 삭제되었습니다", "message.user_removed": "주거지에서 사용자가 제거되었습니다", "message.tasks_report_generated": "작업 보고서가 생성되었습니다", "message.tasks_report_sent": "작업 보고서가 생성되어 {{.Email}}로 발송되었습니다", "message.tasks_report_email_failed": "작업 보고서가 생성되었지만 이메일 발송에 실패했습니다", - "message.contractor_deleted": "계약업체가 삭제되었습니다", - "message.document_deleted": "문서가 삭제되었습니다", "message.document_activated": "문서가 활성화되었습니다", "message.document_deactivated": "문서가 비활성화되었습니다", - "message.notification_marked_read": "알림이 읽음으로 표시되었습니다", "message.all_notifications_marked_read": "모든 알림이 읽음으로 표시되었습니다", "message.device_removed": "기기가 제거되었습니다", - "message.subscription_upgraded": "구독이 업그레이드되었습니다", "message.subscription_cancelled": "구독이 취소되었습니다. 결제 기간이 종료될 때까지 Pro 혜택을 유지하실 수 있습니다.", "message.subscription_restored": "구독이 복원되었습니다", - "message.file_deleted": "파일이 삭제되었습니다", "message.static_data_refreshed": "정적 데이터가 새로고침되었습니다", - "error.notification_not_found": "알림을 찾을 수 없습니다", "error.invalid_platform": "유효하지 않은 플랫폼입니다", - "error.upgrade_trigger_not_found": "업그레이드 트리거를 찾을 수 없습니다", "error.receipt_data_required": "iOS의 경우 receipt_data가 필요합니다", "error.purchase_token_required": "Android의 경우 purchase_token이 필요합니다", - "error.no_file_provided": "파일이 제공되지 않았습니다", - "error.failed_to_fetch_residence_types": "주거지 유형을 가져오는데 실패했습니다", "error.failed_to_fetch_task_categories": "작업 카테고리를 가져오는데 실패했습니다", "error.failed_to_fetch_task_priorities": "작업 우선순위를 가져오는데 실패했습니다", "error.failed_to_fetch_task_frequencies": "작업 빈도를 가져오는데 실패했습니다", "error.failed_to_fetch_task_statuses": "작업 상태를 가져오는데 실패했습니다", "error.failed_to_fetch_contractor_specialties": "계약업체 전문 분야를 가져오는데 실패했습니다", - "push.task_due_soon.title": "작업 마감 임박", "push.task_due_soon.body": "{{.TaskTitle}}의 마감일은 {{.DueDate}}입니다", "push.task_overdue.title": "지연된 작업", @@ -129,23 +111,20 @@ "push.task_assigned.body": "{{.TaskTitle}}이(가) 할당되었습니다", "push.residence_shared.title": "주거지 공유", "push.residence_shared.body": "{{.UserName}}님이 {{.ResidenceName}}을(를) 공유했습니다", - "email.welcome.subject": "honeyDue에 오신 것을 환영합니다!", "email.verification.subject": "이메일 인증", "email.password_reset.subject": "비밀번호 재설정 코드", "email.tasks_report.subject": "{{.ResidenceName}} 작업 보고서", - - "lookup.residence_type.house": "단독주택", + "lookup.residence_type.house": "주택", "lookup.residence_type.apartment": "아파트", "lookup.residence_type.condo": "콘도", "lookup.residence_type.townhouse": "타운하우스", "lookup.residence_type.mobile_home": "이동식 주택", "lookup.residence_type.other": "기타", - "lookup.task_category.plumbing": "배관", "lookup.task_category.electrical": "전기", "lookup.task_category.hvac": "냉난방", - "lookup.task_category.appliances": "가전제품", + "lookup.task_category.appliances": "가전", "lookup.task_category.exterior": "외부", "lookup.task_category.interior": "내부", "lookup.task_category.landscaping": "조경", @@ -154,18 +133,15 @@ "lookup.task_category.pest_control": "해충 방제", "lookup.task_category.seasonal": "계절별", "lookup.task_category.other": "기타", - "lookup.task_priority.low": "낮음", "lookup.task_priority.medium": "보통", "lookup.task_priority.high": "높음", "lookup.task_priority.urgent": "긴급", - "lookup.task_status.pending": "대기 중", "lookup.task_status.in_progress": "진행 중", "lookup.task_status.completed": "완료", "lookup.task_status.cancelled": "취소됨", "lookup.task_status.archived": "보관됨", - "lookup.task_frequency.once": "한 번", "lookup.task_frequency.daily": "매일", "lookup.task_frequency.weekly": "매주", @@ -174,18 +150,98 @@ "lookup.task_frequency.quarterly": "분기별", "lookup.task_frequency.semiannually": "6개월마다", "lookup.task_frequency.annually": "매년", - "lookup.contractor_specialty.plumber": "배관공", "lookup.contractor_specialty.electrician": "전기 기사", "lookup.contractor_specialty.hvac_technician": "냉난방 기사", - "lookup.contractor_specialty.handyman": "편리공", + "lookup.contractor_specialty.handyman": "수리공", "lookup.contractor_specialty.landscaper": "조경사", - "lookup.contractor_specialty.roofer": "지붕공", - "lookup.contractor_specialty.painter": "도배공", + "lookup.contractor_specialty.roofer": "지붕 기술자", + "lookup.contractor_specialty.painter": "페인터", "lookup.contractor_specialty.carpenter": "목수", "lookup.contractor_specialty.pest_control": "해충 방제", "lookup.contractor_specialty.cleaning": "청소", - "lookup.contractor_specialty.pool_service": "수영장 관리", - "lookup.contractor_specialty.general_contractor": "종합 건설업체", - "lookup.contractor_specialty.other": "기타" + "lookup.contractor_specialty.pool_service": "수영장 서비스", + "lookup.contractor_specialty.general_contractor": "종합 건설업자", + "lookup.contractor_specialty.other": "기타", + "suggestion.reason.has_pool": "집에 수영장이 있습니다", + "suggestion.reason.has_sprinkler_system": "집에 스프링클러 시스템이 있습니다", + "suggestion.reason.has_septic": "집에 정화조가 있습니다", + "suggestion.reason.has_fireplace": "집에 벽난로가 있습니다", + "suggestion.reason.has_garage": "집에 차고가 있습니다", + "suggestion.reason.has_basement": "집에 지하실이 있습니다", + "suggestion.reason.has_attic": "집에 다락방이 있습니다", + "suggestion.reason.heating_type": "난방 시스템과 일치합니다", + "suggestion.reason.cooling_type": "냉방 시스템과 일치합니다", + "suggestion.reason.water_heater_type": "온수기와 일치합니다", + "suggestion.reason.roof_type": "지붕과 일치합니다", + "suggestion.reason.exterior_type": "외장과 일치합니다", + "suggestion.reason.flooring_primary": "바닥재와 일치합니다", + "suggestion.reason.landscaping_type": "조경과 일치합니다", + "suggestion.reason.property_type": "주택 유형에 추천됩니다", + "suggestion.reason.climate_region": "거주 지역 기후에 추천됩니다", + "lookup.residence_type.duplex": "듀플렉스", + "lookup.residence_type.vacation_home": "별장", + "lookup.task_category.general": "일반", + "lookup.task_frequency.bi_weekly": "격주", + "lookup.task_frequency.semi_annually": "반기별", + "lookup.task_frequency.custom": "사용자 지정", + "lookup.contractor_specialty.appliance_repair": "가전 수리", + "lookup.contractor_specialty.cleaner": "청소부", + "lookup.contractor_specialty.locksmith": "열쇠공", + "lookup.home_profile.gas_furnace": "가스 난로", + "lookup.home_profile.electric_furnace": "전기 난로", + "lookup.home_profile.heat_pump": "열펌프", + "lookup.home_profile.boiler": "보일러", + "lookup.home_profile.radiant": "복사식", + "lookup.home_profile.other": "기타", + "lookup.home_profile.central_ac": "중앙 에어컨", + "lookup.home_profile.window_ac": "창문형 에어컨", + "lookup.home_profile.evaporative": "증발식", + "lookup.home_profile.none": "없음", + "lookup.home_profile.tank_gas": "탱크식(가스)", + "lookup.home_profile.tank_electric": "탱크식(전기)", + "lookup.home_profile.tankless_gas": "탱크리스(가스)", + "lookup.home_profile.tankless_electric": "탱크리스(전기)", + "lookup.home_profile.solar": "태양광", + "lookup.home_profile.asphalt_shingle": "아스팔트 슁글", + "lookup.home_profile.metal": "금속", + "lookup.home_profile.tile": "타일", + "lookup.home_profile.slate": "슬레이트", + "lookup.home_profile.wood_shake": "목재 셰이크", + "lookup.home_profile.flat": "평지붕", + "lookup.home_profile.brick": "벽돌", + "lookup.home_profile.vinyl_siding": "비닐 사이딩", + "lookup.home_profile.wood_siding": "목재 사이딩", + "lookup.home_profile.stucco": "스투코", + "lookup.home_profile.stone": "석재", + "lookup.home_profile.fiber_cement": "섬유 시멘트", + "lookup.home_profile.hardwood": "원목", + "lookup.home_profile.laminate": "라미네이트", + "lookup.home_profile.carpet": "카펫", + "lookup.home_profile.vinyl": "비닐", + "lookup.home_profile.concrete": "콘크리트", + "lookup.home_profile.lawn": "잔디", + "lookup.home_profile.desert": "사막", + "lookup.home_profile.xeriscape": "제리스케이프", + "lookup.home_profile.garden": "정원", + "lookup.home_profile.mixed": "혼합", + "lookup.document_type.warranty": "보증", + "lookup.document_type.manual": "사용 설명서", + "lookup.document_type.receipt": "영수증/송장", + "lookup.document_type.inspection": "점검 보고서", + "lookup.document_type.permit": "허가증", + "lookup.document_type.deed": "증서/권리증", + "lookup.document_type.insurance": "보험", + "lookup.document_type.contract": "계약", + "lookup.document_type.photo": "사진", + "lookup.document_type.other": "기타", + "lookup.document_category.appliance": "가전", + "lookup.document_category.hvac": "냉난방", + "lookup.document_category.plumbing": "배관", + "lookup.document_category.electrical": "전기", + "lookup.document_category.roofing": "지붕", + "lookup.document_category.structural": "구조", + "lookup.document_category.landscaping": "조경", + "lookup.document_category.general": "일반", + "lookup.document_category.other": "기타" } diff --git a/internal/i18n/translations/nl.json b/internal/i18n/translations/nl.json index 7d72a4b..b4b2423 100644 --- a/internal/i18n/translations/nl.json +++ b/internal/i18n/translations/nl.json @@ -25,7 +25,6 @@ "error.google_signin_not_configured": "Google Sign In is niet geconfigureerd", "error.google_signin_failed": "Google Sign In mislukt", "error.invalid_google_token": "Ongeldig Google identiteitstoken", - "error.invalid_task_id": "Ongeldig taak-ID", "error.invalid_residence_id": "Ongeldig woning-ID", "error.invalid_contractor_id": "Ongeldig aannemer-ID", @@ -34,7 +33,6 @@ "error.invalid_user_id": "Ongeldig gebruikers-ID", "error.invalid_notification_id": "Ongeldig notificatie-ID", "error.invalid_device_id": "Ongeldig apparaat-ID", - "error.task_not_found": "Taak niet gevonden", "error.residence_not_found": "Woning niet gevonden", "error.contractor_not_found": "Aannemer niet gevonden", @@ -43,7 +41,6 @@ "error.user_not_found": "Gebruiker niet gevonden", "error.share_code_invalid": "Ongeldige deelcode", "error.share_code_expired": "Deelcode is verlopen", - "error.task_access_denied": "U heeft geen toegang tot deze taak", "error.residence_access_denied": "U heeft geen toegang tot deze woning", "error.contractor_access_denied": "U heeft geen toegang tot deze aannemer", @@ -52,10 +49,8 @@ "error.cannot_remove_owner": "Kan de woningeigenaar niet verwijderen", "error.user_already_member": "Gebruiker is al lid van deze woning", "error.properties_limit_reached": "U heeft het maximale aantal woningen voor uw abonnement bereikt", - "error.task_already_cancelled": "Taak is al geannuleerd", "error.task_already_archived": "Taak is al gearchiveerd", - "error.failed_to_parse_form": "Multipart formulier parsen mislukt", "error.task_id_required": "task_id is verplicht", "error.invalid_task_id_value": "Ongeldig task_id", @@ -64,14 +59,12 @@ "error.invalid_residence_id_value": "Ongeldig residence_id", "error.title_required": "titel is verplicht", "error.failed_to_upload_file": "Bestand uploaden mislukt", - "message.logged_out": "Succesvol uitgelogd", "message.email_verified": "E-mailadres succesvol geverifieerd", "message.verification_email_sent": "Verificatie e-mail verzonden", "message.password_reset_email_sent": "Als er een account met dat e-mailadres bestaat, is er een wachtwoord resetcode verzonden.", "message.reset_code_verified": "Code succesvol geverifieerd", "message.password_reset_success": "Wachtwoord succesvol gereset. Log in met uw nieuwe wachtwoord.", - "message.task_deleted": "Taak succesvol verwijderd", "message.task_in_progress": "Taak gemarkeerd als in uitvoering", "message.task_cancelled": "Taak geannuleerd", @@ -79,46 +72,35 @@ "message.task_archived": "Taak gearchiveerd", "message.task_unarchived": "Taak gearchiveerd ongedaan gemaakt", "message.completion_deleted": "Voltooiing succesvol verwijderd", - "message.residence_deleted": "Woning succesvol verwijderd", "message.user_removed": "Gebruiker verwijderd van woning", "message.tasks_report_generated": "Takenrapport succesvol gegenereerd", "message.tasks_report_sent": "Takenrapport gegenereerd en verzonden naar {{.Email}}", "message.tasks_report_email_failed": "Takenrapport gegenereerd maar e-mail kon niet worden verzonden", - "message.contractor_deleted": "Aannemer succesvol verwijderd", - "message.document_deleted": "Document succesvol verwijderd", "message.document_activated": "Document geactiveerd", "message.document_deactivated": "Document gedeactiveerd", - "message.notification_marked_read": "Notificatie gemarkeerd als gelezen", "message.all_notifications_marked_read": "Alle notificaties gemarkeerd als gelezen", "message.device_removed": "Apparaat verwijderd", - "message.subscription_upgraded": "Abonnement succesvol geüpgraded", "message.subscription_cancelled": "Abonnement geannuleerd. U behoudt Pro voordelen tot het einde van uw factureringsperiode.", "message.subscription_restored": "Abonnement succesvol hersteld", - "message.file_deleted": "Bestand succesvol verwijderd", "message.static_data_refreshed": "Statische gegevens vernieuwd", - "error.notification_not_found": "Notificatie niet gevonden", "error.invalid_platform": "Ongeldig platform", - "error.upgrade_trigger_not_found": "Upgrade trigger niet gevonden", "error.receipt_data_required": "receipt_data is verplicht voor iOS", "error.purchase_token_required": "purchase_token is verplicht voor Android", - "error.no_file_provided": "Geen bestand aangeleverd", - "error.failed_to_fetch_residence_types": "Woningtypes ophalen mislukt", "error.failed_to_fetch_task_categories": "Taakcategorieën ophalen mislukt", "error.failed_to_fetch_task_priorities": "Taakprioriteiten ophalen mislukt", "error.failed_to_fetch_task_frequencies": "Taakfrequenties ophalen mislukt", "error.failed_to_fetch_task_statuses": "Taakstatussen ophalen mislukt", "error.failed_to_fetch_contractor_specialties": "Aannemer specialiteiten ophalen mislukt", - "push.task_due_soon.title": "Taak Vervalt Binnenkort", "push.task_due_soon.body": "{{.TaskTitle}} vervalt {{.DueDate}}", "push.task_overdue.title": "Verlopen Taak", @@ -129,55 +111,48 @@ "push.task_assigned.body": "U bent toegewezen aan {{.TaskTitle}}", "push.residence_shared.title": "Woning Gedeeld", "push.residence_shared.body": "{{.UserName}} heeft {{.ResidenceName}} met u gedeeld", - "email.welcome.subject": "Welkom bij honeyDue!", "email.verification.subject": "Verifieer Uw E-mailadres", "email.password_reset.subject": "Wachtwoord Resetcode", "email.tasks_report.subject": "Takenrapport voor {{.ResidenceName}}", - "lookup.residence_type.house": "Huis", "lookup.residence_type.apartment": "Appartement", - "lookup.residence_type.condo": "Appartement met eigen grond", + "lookup.residence_type.condo": "Koopflat", "lookup.residence_type.townhouse": "Rijtjeshuis", "lookup.residence_type.mobile_home": "Stacaravan", - "lookup.residence_type.other": "Anders", - - "lookup.task_category.plumbing": "Loodgieterij", - "lookup.task_category.electrical": "Elektriciteit", - "lookup.task_category.hvac": "Verwarming en Ventilatie", + "lookup.residence_type.other": "Overig", + "lookup.task_category.plumbing": "Loodgieterswerk", + "lookup.task_category.electrical": "Elektrisch", + "lookup.task_category.hvac": "HVAC", "lookup.task_category.appliances": "Apparaten", - "lookup.task_category.exterior": "Buitenkant", - "lookup.task_category.interior": "Binnenkant", + "lookup.task_category.exterior": "Buiten", + "lookup.task_category.interior": "Binnen", "lookup.task_category.landscaping": "Tuinonderhoud", "lookup.task_category.safety": "Veiligheid", "lookup.task_category.cleaning": "Schoonmaak", "lookup.task_category.pest_control": "Ongediertebestrijding", "lookup.task_category.seasonal": "Seizoensgebonden", "lookup.task_category.other": "Anders", - "lookup.task_priority.low": "Laag", "lookup.task_priority.medium": "Gemiddeld", "lookup.task_priority.high": "Hoog", "lookup.task_priority.urgent": "Urgent", - "lookup.task_status.pending": "In afwachting", "lookup.task_status.in_progress": "In uitvoering", "lookup.task_status.completed": "Voltooid", "lookup.task_status.cancelled": "Geannuleerd", "lookup.task_status.archived": "Gearchiveerd", - "lookup.task_frequency.once": "Eenmalig", "lookup.task_frequency.daily": "Dagelijks", "lookup.task_frequency.weekly": "Wekelijks", "lookup.task_frequency.biweekly": "Om de 2 Weken", "lookup.task_frequency.monthly": "Maandelijks", - "lookup.task_frequency.quarterly": "Per Kwartaal", + "lookup.task_frequency.quarterly": "Per kwartaal", "lookup.task_frequency.semiannually": "Om de 6 Maanden", "lookup.task_frequency.annually": "Jaarlijks", - "lookup.contractor_specialty.plumber": "Loodgieter", "lookup.contractor_specialty.electrician": "Elektricien", - "lookup.contractor_specialty.hvac_technician": "HVAC Monteur", + "lookup.contractor_specialty.hvac_technician": "HVAC-technicus", "lookup.contractor_specialty.handyman": "Klusjesman", "lookup.contractor_specialty.landscaper": "Hovenier", "lookup.contractor_specialty.roofer": "Dakdekker", @@ -185,7 +160,88 @@ "lookup.contractor_specialty.carpenter": "Timmerman", "lookup.contractor_specialty.pest_control": "Ongediertebestrijding", "lookup.contractor_specialty.cleaning": "Schoonmaak", - "lookup.contractor_specialty.pool_service": "Zwembadonderhoud", - "lookup.contractor_specialty.general_contractor": "Algemeen Aannemer", - "lookup.contractor_specialty.other": "Anders" + "lookup.contractor_specialty.pool_service": "Zwembadservice", + "lookup.contractor_specialty.general_contractor": "Hoofdaannemer", + "lookup.contractor_specialty.other": "Anders", + "suggestion.reason.has_pool": "Je woning heeft een zwembad", + "suggestion.reason.has_sprinkler_system": "Je woning heeft een sproei-installatie", + "suggestion.reason.has_septic": "Je woning heeft een septische tank", + "suggestion.reason.has_fireplace": "Je woning heeft een open haard", + "suggestion.reason.has_garage": "Je woning heeft een garage", + "suggestion.reason.has_basement": "Je woning heeft een kelder", + "suggestion.reason.has_attic": "Je woning heeft een zolder", + "suggestion.reason.heating_type": "Past bij je verwarmingssysteem", + "suggestion.reason.cooling_type": "Past bij je koelsysteem", + "suggestion.reason.water_heater_type": "Past bij je boiler", + "suggestion.reason.roof_type": "Past bij je dak", + "suggestion.reason.exterior_type": "Past bij je gevel", + "suggestion.reason.flooring_primary": "Past bij je vloer", + "suggestion.reason.landscaping_type": "Past bij je tuin", + "suggestion.reason.property_type": "Aanbevolen voor je type woning", + "suggestion.reason.climate_region": "Aanbevolen voor je klimaat", + "lookup.residence_type.duplex": "Twee-onder-een-kap", + "lookup.residence_type.vacation_home": "Vakantiehuis", + "lookup.task_category.general": "Algemeen", + "lookup.task_frequency.bi_weekly": "Tweewekelijks", + "lookup.task_frequency.semi_annually": "Halfjaarlijks", + "lookup.task_frequency.custom": "Aangepast", + "lookup.contractor_specialty.appliance_repair": "Apparaatreparatie", + "lookup.contractor_specialty.cleaner": "Schoonmaker", + "lookup.contractor_specialty.locksmith": "Slotenmaker", + "lookup.home_profile.gas_furnace": "Gasketel", + "lookup.home_profile.electric_furnace": "Elektrische ketel", + "lookup.home_profile.heat_pump": "Warmtepomp", + "lookup.home_profile.boiler": "CV-ketel", + "lookup.home_profile.radiant": "Stralingsverwarming", + "lookup.home_profile.other": "Overig", + "lookup.home_profile.central_ac": "Centrale airco", + "lookup.home_profile.window_ac": "Raamairco", + "lookup.home_profile.evaporative": "Verdampings", + "lookup.home_profile.none": "Geen", + "lookup.home_profile.tank_gas": "Boiler (gas)", + "lookup.home_profile.tank_electric": "Boiler (elektrisch)", + "lookup.home_profile.tankless_gas": "Doorstroom (gas)", + "lookup.home_profile.tankless_electric": "Doorstroom (elektrisch)", + "lookup.home_profile.solar": "Zonne-energie", + "lookup.home_profile.asphalt_shingle": "Asfaltshingle", + "lookup.home_profile.metal": "Metaal", + "lookup.home_profile.tile": "Dakpan", + "lookup.home_profile.slate": "Leisteen", + "lookup.home_profile.wood_shake": "Houten shingle", + "lookup.home_profile.flat": "Plat", + "lookup.home_profile.brick": "Baksteen", + "lookup.home_profile.vinyl_siding": "Vinyl gevelbekleding", + "lookup.home_profile.wood_siding": "Houten gevelbekleding", + "lookup.home_profile.stucco": "Stucwerk", + "lookup.home_profile.stone": "Steen", + "lookup.home_profile.fiber_cement": "Vezelcement", + "lookup.home_profile.hardwood": "Hardhout", + "lookup.home_profile.laminate": "Laminaat", + "lookup.home_profile.carpet": "Tapijt", + "lookup.home_profile.vinyl": "Vinyl", + "lookup.home_profile.concrete": "Beton", + "lookup.home_profile.lawn": "Gazon", + "lookup.home_profile.desert": "Woestijn", + "lookup.home_profile.xeriscape": "Xeriscaping", + "lookup.home_profile.garden": "Tuin", + "lookup.home_profile.mixed": "Gemengd", + "lookup.document_type.warranty": "Garantie", + "lookup.document_type.manual": "Handleiding", + "lookup.document_type.receipt": "Bon/Factuur", + "lookup.document_type.inspection": "Inspectierapport", + "lookup.document_type.permit": "Vergunning", + "lookup.document_type.deed": "Akte/Eigendomsbewijs", + "lookup.document_type.insurance": "Verzekering", + "lookup.document_type.contract": "Contract", + "lookup.document_type.photo": "Foto", + "lookup.document_type.other": "Overig", + "lookup.document_category.appliance": "Apparaat", + "lookup.document_category.hvac": "HVAC", + "lookup.document_category.plumbing": "Loodgieterswerk", + "lookup.document_category.electrical": "Elektrisch", + "lookup.document_category.roofing": "Dak", + "lookup.document_category.structural": "Constructie", + "lookup.document_category.landscaping": "Tuinaanleg", + "lookup.document_category.general": "Algemeen", + "lookup.document_category.other": "Overig" } diff --git a/internal/i18n/translations/pt.json b/internal/i18n/translations/pt.json index 80f07ae..e8d6381 100644 --- a/internal/i18n/translations/pt.json +++ b/internal/i18n/translations/pt.json @@ -25,7 +25,6 @@ "error.google_signin_not_configured": "O login com Google nao esta configurado", "error.google_signin_failed": "Falha no login com Google", "error.invalid_google_token": "Token de identidade Google invalido", - "error.invalid_task_id": "ID da tarefa invalido", "error.invalid_residence_id": "ID da propriedade invalido", "error.invalid_contractor_id": "ID do prestador invalido", @@ -34,7 +33,6 @@ "error.invalid_user_id": "ID do usuario invalido", "error.invalid_notification_id": "ID da notificacao invalido", "error.invalid_device_id": "ID do dispositivo invalido", - "error.task_not_found": "Tarefa nao encontrada", "error.residence_not_found": "Propriedade nao encontrada", "error.contractor_not_found": "Prestador nao encontrado", @@ -43,7 +41,6 @@ "error.user_not_found": "Usuario nao encontrado", "error.share_code_invalid": "Codigo de compartilhamento invalido", "error.share_code_expired": "O codigo de compartilhamento expirou", - "error.task_access_denied": "Voce nao tem acesso a esta tarefa", "error.residence_access_denied": "Voce nao tem acesso a esta propriedade", "error.contractor_access_denied": "Voce nao tem acesso a este prestador", @@ -52,10 +49,8 @@ "error.cannot_remove_owner": "Nao e possivel remover o proprietario", "error.user_already_member": "O usuario ja e membro desta propriedade", "error.properties_limit_reached": "Voce atingiu o numero maximo de propriedades para sua assinatura", - "error.task_already_cancelled": "A tarefa ja esta cancelada", "error.task_already_archived": "A tarefa ja esta arquivada", - "error.failed_to_parse_form": "Falha ao analisar o formulario", "error.task_id_required": "task_id e obrigatorio", "error.invalid_task_id_value": "task_id invalido", @@ -64,14 +59,12 @@ "error.invalid_residence_id_value": "residence_id invalido", "error.title_required": "Titulo e obrigatorio", "error.failed_to_upload_file": "Falha ao enviar arquivo", - "message.logged_out": "Logout realizado com sucesso", "message.email_verified": "Email verificado com sucesso", "message.verification_email_sent": "Email de verificacao enviado", "message.password_reset_email_sent": "Se existir uma conta com este email, um codigo de redefinicao foi enviado.", "message.reset_code_verified": "Codigo verificado com sucesso", "message.password_reset_success": "Senha redefinida com sucesso. Por favor, faca login com sua nova senha.", - "message.task_deleted": "Tarefa excluida com sucesso", "message.task_in_progress": "Tarefa marcada como em andamento", "message.task_cancelled": "Tarefa cancelada", @@ -79,46 +72,35 @@ "message.task_archived": "Tarefa arquivada", "message.task_unarchived": "Tarefa desarquivada", "message.completion_deleted": "Conclusao excluida com sucesso", - "message.residence_deleted": "Propriedade excluida com sucesso", "message.user_removed": "Usuario removido da propriedade", "message.tasks_report_generated": "Relatorio de tarefas gerado com sucesso", "message.tasks_report_sent": "Relatorio de tarefas gerado e enviado para {{.Email}}", "message.tasks_report_email_failed": "Relatorio de tarefas gerado mas o email nao pode ser enviado", - "message.contractor_deleted": "Prestador excluido com sucesso", - "message.document_deleted": "Documento excluido com sucesso", "message.document_activated": "Documento ativado", "message.document_deactivated": "Documento desativado", - "message.notification_marked_read": "Notificação marcada como lida", "message.all_notifications_marked_read": "Todas as notificações marcadas como lidas", "message.device_removed": "Dispositivo removido", - "message.subscription_upgraded": "Assinatura atualizada com sucesso", "message.subscription_cancelled": "Assinatura cancelada. Você manterá os benefícios Pro até o final do seu período de faturamento.", "message.subscription_restored": "Assinatura restaurada com sucesso", - "message.file_deleted": "Arquivo excluído com sucesso", "message.static_data_refreshed": "Dados estáticos atualizados", - "error.notification_not_found": "Notificação não encontrada", "error.invalid_platform": "Plataforma inválida", - "error.upgrade_trigger_not_found": "Gatilho de atualização não encontrado", "error.receipt_data_required": "receipt_data é obrigatório para iOS", "error.purchase_token_required": "purchase_token é obrigatório para Android", - "error.no_file_provided": "Nenhum arquivo fornecido", - "error.failed_to_fetch_residence_types": "Falha ao buscar tipos de propriedade", "error.failed_to_fetch_task_categories": "Falha ao buscar categorias de tarefas", "error.failed_to_fetch_task_priorities": "Falha ao buscar prioridades de tarefas", "error.failed_to_fetch_task_frequencies": "Falha ao buscar frequências de tarefas", "error.failed_to_fetch_task_statuses": "Falha ao buscar status de tarefas", "error.failed_to_fetch_contractor_specialties": "Falha ao buscar especialidades de prestadores", - "push.task_due_soon.title": "Tarefa Proxima do Vencimento", "push.task_due_soon.body": "{{.TaskTitle}} vence em {{.DueDate}}", "push.task_overdue.title": "Tarefa Atrasada", @@ -129,63 +111,137 @@ "push.task_assigned.body": "{{.TaskTitle}} foi atribuida a voce", "push.residence_shared.title": "Propriedade Compartilhada", "push.residence_shared.body": "{{.UserName}} compartilhou {{.ResidenceName}} com voce", - "email.welcome.subject": "Bem-vindo ao honeyDue!", "email.verification.subject": "Verifique Seu Email", "email.password_reset.subject": "Codigo de Redefinicao de Senha", "email.tasks_report.subject": "Relatorio de Tarefas para {{.ResidenceName}}", - "lookup.residence_type.house": "Casa", "lookup.residence_type.apartment": "Apartamento", - "lookup.residence_type.condo": "Condominio", + "lookup.residence_type.condo": "Condomínio", "lookup.residence_type.townhouse": "Sobrado", - "lookup.residence_type.mobile_home": "Casa Movel", + "lookup.residence_type.mobile_home": "Casa móvel", "lookup.residence_type.other": "Outro", - "lookup.task_category.plumbing": "Encanamento", - "lookup.task_category.electrical": "Eletrica", - "lookup.task_category.hvac": "Climatizacao", - "lookup.task_category.appliances": "Eletrodomesticos", + "lookup.task_category.electrical": "Elétrica", + "lookup.task_category.hvac": "AVAC", + "lookup.task_category.appliances": "Eletrodomésticos", "lookup.task_category.exterior": "Exterior", "lookup.task_category.interior": "Interior", "lookup.task_category.landscaping": "Paisagismo", - "lookup.task_category.safety": "Seguranca", + "lookup.task_category.safety": "Segurança", "lookup.task_category.cleaning": "Limpeza", - "lookup.task_category.pest_control": "Controle de Pragas", + "lookup.task_category.pest_control": "Controle de pragas", "lookup.task_category.seasonal": "Sazonal", "lookup.task_category.other": "Outro", - "lookup.task_priority.low": "Baixa", - "lookup.task_priority.medium": "Media", + "lookup.task_priority.medium": "Média", "lookup.task_priority.high": "Alta", "lookup.task_priority.urgent": "Urgente", - "lookup.task_status.pending": "Pendente", "lookup.task_status.in_progress": "Em Andamento", "lookup.task_status.completed": "Concluida", "lookup.task_status.cancelled": "Cancelada", "lookup.task_status.archived": "Arquivada", - - "lookup.task_frequency.once": "Uma Vez", - "lookup.task_frequency.daily": "Diario", + "lookup.task_frequency.once": "Uma vez", + "lookup.task_frequency.daily": "Diário", "lookup.task_frequency.weekly": "Semanal", "lookup.task_frequency.biweekly": "Quinzenal", "lookup.task_frequency.monthly": "Mensal", "lookup.task_frequency.quarterly": "Trimestral", "lookup.task_frequency.semiannually": "Semestral", "lookup.task_frequency.annually": "Anual", - "lookup.contractor_specialty.plumber": "Encanador", "lookup.contractor_specialty.electrician": "Eletricista", - "lookup.contractor_specialty.hvac_technician": "Tecnico de Climatizacao", + "lookup.contractor_specialty.hvac_technician": "Técnico de AVAC", "lookup.contractor_specialty.handyman": "Faz-tudo", - "lookup.contractor_specialty.landscaper": "Paisagista", + "lookup.contractor_specialty.landscaper": "Jardineiro", "lookup.contractor_specialty.roofer": "Telhadista", "lookup.contractor_specialty.painter": "Pintor", "lookup.contractor_specialty.carpenter": "Carpinteiro", - "lookup.contractor_specialty.pest_control": "Controle de Pragas", + "lookup.contractor_specialty.pest_control": "Controle de pragas", "lookup.contractor_specialty.cleaning": "Limpeza", - "lookup.contractor_specialty.pool_service": "Servico de Piscina", - "lookup.contractor_specialty.general_contractor": "Empreiteiro Geral", - "lookup.contractor_specialty.other": "Outro" + "lookup.contractor_specialty.pool_service": "Serviço de piscina", + "lookup.contractor_specialty.general_contractor": "Empreiteiro geral", + "lookup.contractor_specialty.other": "Outro", + "suggestion.reason.has_pool": "Sua casa tem piscina", + "suggestion.reason.has_sprinkler_system": "Sua casa tem sistema de irrigação", + "suggestion.reason.has_septic": "Sua casa tem fossa séptica", + "suggestion.reason.has_fireplace": "Sua casa tem lareira", + "suggestion.reason.has_garage": "Sua casa tem garagem", + "suggestion.reason.has_basement": "Sua casa tem porão", + "suggestion.reason.has_attic": "Sua casa tem sótão", + "suggestion.reason.heating_type": "Combina com seu sistema de aquecimento", + "suggestion.reason.cooling_type": "Combina com seu sistema de refrigeração", + "suggestion.reason.water_heater_type": "Combina com seu aquecedor de água", + "suggestion.reason.roof_type": "Combina com seu telhado", + "suggestion.reason.exterior_type": "Combina com seu exterior", + "suggestion.reason.flooring_primary": "Combina com seu piso", + "suggestion.reason.landscaping_type": "Combina com seu paisagismo", + "suggestion.reason.property_type": "Recomendado para seu tipo de imóvel", + "suggestion.reason.climate_region": "Recomendado para seu clima", + "lookup.residence_type.duplex": "Duplex", + "lookup.residence_type.vacation_home": "Casa de férias", + "lookup.task_category.general": "Geral", + "lookup.task_frequency.bi_weekly": "Quinzenal", + "lookup.task_frequency.semi_annually": "Semestral", + "lookup.task_frequency.custom": "Personalizado", + "lookup.contractor_specialty.appliance_repair": "Reparo de eletrodomésticos", + "lookup.contractor_specialty.cleaner": "Faxineiro", + "lookup.contractor_specialty.locksmith": "Chaveiro", + "lookup.home_profile.gas_furnace": "Aquecedor a gás", + "lookup.home_profile.electric_furnace": "Aquecedor elétrico", + "lookup.home_profile.heat_pump": "Bomba de calor", + "lookup.home_profile.boiler": "Caldeira", + "lookup.home_profile.radiant": "Radiante", + "lookup.home_profile.other": "Outro", + "lookup.home_profile.central_ac": "AC central", + "lookup.home_profile.window_ac": "AC de janela", + "lookup.home_profile.evaporative": "Evaporativo", + "lookup.home_profile.none": "Nenhum", + "lookup.home_profile.tank_gas": "Tanque (gás)", + "lookup.home_profile.tank_electric": "Tanque (elétrico)", + "lookup.home_profile.tankless_gas": "Sem tanque (gás)", + "lookup.home_profile.tankless_electric": "Sem tanque (elétrico)", + "lookup.home_profile.solar": "Solar", + "lookup.home_profile.asphalt_shingle": "Telha asfáltica", + "lookup.home_profile.metal": "Metal", + "lookup.home_profile.tile": "Telha", + "lookup.home_profile.slate": "Ardósia", + "lookup.home_profile.wood_shake": "Telha de madeira", + "lookup.home_profile.flat": "Plano", + "lookup.home_profile.brick": "Tijolo", + "lookup.home_profile.vinyl_siding": "Revestimento de vinil", + "lookup.home_profile.wood_siding": "Revestimento de madeira", + "lookup.home_profile.stucco": "Estuque", + "lookup.home_profile.stone": "Pedra", + "lookup.home_profile.fiber_cement": "Cimento reforçado", + "lookup.home_profile.hardwood": "Madeira de lei", + "lookup.home_profile.laminate": "Laminado", + "lookup.home_profile.carpet": "Carpete", + "lookup.home_profile.vinyl": "Vinil", + "lookup.home_profile.concrete": "Concreto", + "lookup.home_profile.lawn": "Gramado", + "lookup.home_profile.desert": "Deserto", + "lookup.home_profile.xeriscape": "Xeropaisagismo", + "lookup.home_profile.garden": "Jardim", + "lookup.home_profile.mixed": "Misto", + "lookup.document_type.warranty": "Garantia", + "lookup.document_type.manual": "Manual do usuário", + "lookup.document_type.receipt": "Recibo/Fatura", + "lookup.document_type.inspection": "Relatório de inspeção", + "lookup.document_type.permit": "Licença", + "lookup.document_type.deed": "Escritura/Título", + "lookup.document_type.insurance": "Seguro", + "lookup.document_type.contract": "Contrato", + "lookup.document_type.photo": "Foto", + "lookup.document_type.other": "Outro", + "lookup.document_category.appliance": "Eletrodoméstico", + "lookup.document_category.hvac": "AVAC", + "lookup.document_category.plumbing": "Encanamento", + "lookup.document_category.electrical": "Elétrica", + "lookup.document_category.roofing": "Telhado", + "lookup.document_category.structural": "Estrutural", + "lookup.document_category.landscaping": "Paisagismo", + "lookup.document_category.general": "Geral", + "lookup.document_category.other": "Outro" } diff --git a/internal/i18n/translations/zh.json b/internal/i18n/translations/zh.json index b7047f0..36bb909 100644 --- a/internal/i18n/translations/zh.json +++ b/internal/i18n/translations/zh.json @@ -25,7 +25,6 @@ "error.google_signin_not_configured": "未配置 Google 登录", "error.google_signin_failed": "Google 登录失败", "error.invalid_google_token": "Google 身份令牌无效", - "error.invalid_task_id": "任务 ID 无效", "error.invalid_residence_id": "房产 ID 无效", "error.invalid_contractor_id": "承包商 ID 无效", @@ -34,7 +33,6 @@ "error.invalid_user_id": "用户 ID 无效", "error.invalid_notification_id": "通知 ID 无效", "error.invalid_device_id": "设备 ID 无效", - "error.task_not_found": "未找到任务", "error.residence_not_found": "未找到房产", "error.contractor_not_found": "未找到承包商", @@ -43,7 +41,6 @@ "error.user_not_found": "未找到用户", "error.share_code_invalid": "分享码无效", "error.share_code_expired": "分享码已过期", - "error.task_access_denied": "您无权访问此任务", "error.residence_access_denied": "您无权访问此房产", "error.contractor_access_denied": "您无权访问此承包商", @@ -52,10 +49,8 @@ "error.cannot_remove_owner": "无法移除房产所有者", "error.user_already_member": "用户已是此房产的成员", "error.properties_limit_reached": "您已达到订阅计划的房产数量上限", - "error.task_already_cancelled": "任务已取消", "error.task_already_archived": "任务已归档", - "error.failed_to_parse_form": "解析多部分表单失败", "error.task_id_required": "需要 task_id", "error.invalid_task_id_value": "task_id 无效", @@ -64,14 +59,12 @@ "error.invalid_residence_id_value": "residence_id 无效", "error.title_required": "需要标题", "error.failed_to_upload_file": "上传文件失败", - "message.logged_out": "已成功退出", "message.email_verified": "邮箱验证成功", "message.verification_email_sent": "验证邮件已发送", "message.password_reset_email_sent": "如果该邮箱存在账户,密码重置验证码已发送。", "message.reset_code_verified": "验证码验证成功", "message.password_reset_success": "密码重置成功,请使用新密码登录。", - "message.task_deleted": "任务删除成功", "message.task_in_progress": "任务已标记为进行中", "message.task_cancelled": "任务已取消", @@ -79,46 +72,35 @@ "message.task_archived": "任务已归档", "message.task_unarchived": "任务已取消归档", "message.completion_deleted": "完成记录删除成功", - "message.residence_deleted": "房产删除成功", "message.user_removed": "用户已从房产中移除", "message.tasks_report_generated": "任务报告生成成功", "message.tasks_report_sent": "任务报告已生成并发送至 {{.Email}}", "message.tasks_report_email_failed": "任务报告已生成但无法发送邮件", - "message.contractor_deleted": "承包商删除成功", - "message.document_deleted": "文档删除成功", "message.document_activated": "文档已激活", "message.document_deactivated": "文档已停用", - "message.notification_marked_read": "通知已标记为已读", "message.all_notifications_marked_read": "所有通知已标记为已读", "message.device_removed": "设备已移除", - "message.subscription_upgraded": "订阅升级成功", "message.subscription_cancelled": "订阅已取消。您将保留专业版权益至当前账单周期结束。", "message.subscription_restored": "订阅恢复成功", - "message.file_deleted": "文件删除成功", "message.static_data_refreshed": "静态数据已刷新", - "error.notification_not_found": "未找到通知", "error.invalid_platform": "平台无效", - "error.upgrade_trigger_not_found": "未找到升级触发器", "error.receipt_data_required": "iOS 需要 receipt_data", "error.purchase_token_required": "Android 需要 purchase_token", - "error.no_file_provided": "未提供文件", - "error.failed_to_fetch_residence_types": "获取房产类型失败", "error.failed_to_fetch_task_categories": "获取任务分类失败", "error.failed_to_fetch_task_priorities": "获取任务优先级失败", "error.failed_to_fetch_task_frequencies": "获取任务频率失败", "error.failed_to_fetch_task_statuses": "获取任务状态失败", "error.failed_to_fetch_contractor_specialties": "获取承包商专业类别失败", - "push.task_due_soon.title": "任务即将到期", "push.task_due_soon.body": "{{.TaskTitle}} 将于 {{.DueDate}} 到期", "push.task_overdue.title": "任务已逾期", @@ -129,19 +111,16 @@ "push.task_assigned.body": "您已被分配到 {{.TaskTitle}}", "push.residence_shared.title": "房产已分享", "push.residence_shared.body": "{{.UserName}} 与您分享了 {{.ResidenceName}}", - "email.welcome.subject": "欢迎使用 honeyDue!", "email.verification.subject": "验证您的邮箱", "email.password_reset.subject": "密码重置验证码", "email.tasks_report.subject": "{{.ResidenceName}} 的任务报告", - - "lookup.residence_type.house": "独立屋", + "lookup.residence_type.house": "独栋房屋", "lookup.residence_type.apartment": "公寓", "lookup.residence_type.condo": "共管公寓", "lookup.residence_type.townhouse": "联排别墅", "lookup.residence_type.mobile_home": "移动房屋", "lookup.residence_type.other": "其他", - "lookup.task_category.plumbing": "管道", "lookup.task_category.electrical": "电气", "lookup.task_category.hvac": "暖通空调", @@ -154,18 +133,15 @@ "lookup.task_category.pest_control": "害虫防治", "lookup.task_category.seasonal": "季节性", "lookup.task_category.other": "其他", - "lookup.task_priority.low": "低", "lookup.task_priority.medium": "中", "lookup.task_priority.high": "高", "lookup.task_priority.urgent": "紧急", - "lookup.task_status.pending": "待处理", "lookup.task_status.in_progress": "进行中", "lookup.task_status.completed": "已完成", "lookup.task_status.cancelled": "已取消", "lookup.task_status.archived": "已归档", - "lookup.task_frequency.once": "一次", "lookup.task_frequency.daily": "每天", "lookup.task_frequency.weekly": "每周", @@ -174,12 +150,11 @@ "lookup.task_frequency.quarterly": "每季度", "lookup.task_frequency.semiannually": "每半年", "lookup.task_frequency.annually": "每年", - - "lookup.contractor_specialty.plumber": "水管工", + "lookup.contractor_specialty.plumber": "管道工", "lookup.contractor_specialty.electrician": "电工", "lookup.contractor_specialty.hvac_technician": "暖通空调技师", "lookup.contractor_specialty.handyman": "杂工", - "lookup.contractor_specialty.landscaper": "园林工", + "lookup.contractor_specialty.landscaper": "园艺师", "lookup.contractor_specialty.roofer": "屋顶工", "lookup.contractor_specialty.painter": "油漆工", "lookup.contractor_specialty.carpenter": "木工", @@ -187,5 +162,86 @@ "lookup.contractor_specialty.cleaning": "清洁", "lookup.contractor_specialty.pool_service": "泳池服务", "lookup.contractor_specialty.general_contractor": "总承包商", - "lookup.contractor_specialty.other": "其他" + "lookup.contractor_specialty.other": "其他", + "suggestion.reason.has_pool": "您的住宅有游泳池", + "suggestion.reason.has_sprinkler_system": "您的住宅有喷灌系统", + "suggestion.reason.has_septic": "您的住宅有化粪池", + "suggestion.reason.has_fireplace": "您的住宅有壁炉", + "suggestion.reason.has_garage": "您的住宅有车库", + "suggestion.reason.has_basement": "您的住宅有地下室", + "suggestion.reason.has_attic": "您的住宅有阁楼", + "suggestion.reason.heating_type": "与您的供暖系统匹配", + "suggestion.reason.cooling_type": "与您的制冷系统匹配", + "suggestion.reason.water_heater_type": "与您的热水器匹配", + "suggestion.reason.roof_type": "与您的屋顶匹配", + "suggestion.reason.exterior_type": "与您的外墙匹配", + "suggestion.reason.flooring_primary": "与您的地板匹配", + "suggestion.reason.landscaping_type": "与您的庭院匹配", + "suggestion.reason.property_type": "为您的房产类型推荐", + "suggestion.reason.climate_region": "为您所在气候推荐", + "lookup.residence_type.duplex": "双拼住宅", + "lookup.residence_type.vacation_home": "度假屋", + "lookup.task_category.general": "通用", + "lookup.task_frequency.bi_weekly": "每两周", + "lookup.task_frequency.semi_annually": "每半年", + "lookup.task_frequency.custom": "自定义", + "lookup.contractor_specialty.appliance_repair": "家电维修", + "lookup.contractor_specialty.cleaner": "清洁工", + "lookup.contractor_specialty.locksmith": "锁匠", + "lookup.home_profile.gas_furnace": "燃气炉", + "lookup.home_profile.electric_furnace": "电炉", + "lookup.home_profile.heat_pump": "热泵", + "lookup.home_profile.boiler": "锅炉", + "lookup.home_profile.radiant": "辐射式", + "lookup.home_profile.other": "其他", + "lookup.home_profile.central_ac": "中央空调", + "lookup.home_profile.window_ac": "窗式空调", + "lookup.home_profile.evaporative": "蒸发式", + "lookup.home_profile.none": "无", + "lookup.home_profile.tank_gas": "储水式(燃气)", + "lookup.home_profile.tank_electric": "储水式(电)", + "lookup.home_profile.tankless_gas": "即热式(燃气)", + "lookup.home_profile.tankless_electric": "即热式(电)", + "lookup.home_profile.solar": "太阳能", + "lookup.home_profile.asphalt_shingle": "沥青瓦", + "lookup.home_profile.metal": "金属", + "lookup.home_profile.tile": "瓦片", + "lookup.home_profile.slate": "石板", + "lookup.home_profile.wood_shake": "木瓦", + "lookup.home_profile.flat": "平顶", + "lookup.home_profile.brick": "砖", + "lookup.home_profile.vinyl_siding": "乙烯基壁板", + "lookup.home_profile.wood_siding": "木壁板", + "lookup.home_profile.stucco": "灰泥", + "lookup.home_profile.stone": "石材", + "lookup.home_profile.fiber_cement": "纤维水泥", + "lookup.home_profile.hardwood": "硬木", + "lookup.home_profile.laminate": "复合地板", + "lookup.home_profile.carpet": "地毯", + "lookup.home_profile.vinyl": "乙烯基", + "lookup.home_profile.concrete": "混凝土", + "lookup.home_profile.lawn": "草坪", + "lookup.home_profile.desert": "沙漠", + "lookup.home_profile.xeriscape": "旱生园艺", + "lookup.home_profile.garden": "花园", + "lookup.home_profile.mixed": "混合", + "lookup.document_type.warranty": "保修", + "lookup.document_type.manual": "用户手册", + "lookup.document_type.receipt": "收据/发票", + "lookup.document_type.inspection": "检查报告", + "lookup.document_type.permit": "许可证", + "lookup.document_type.deed": "契据/产权", + "lookup.document_type.insurance": "保险", + "lookup.document_type.contract": "合同", + "lookup.document_type.photo": "照片", + "lookup.document_type.other": "其他", + "lookup.document_category.appliance": "家电", + "lookup.document_category.hvac": "暖通空调", + "lookup.document_category.plumbing": "管道", + "lookup.document_category.electrical": "电气", + "lookup.document_category.roofing": "屋顶", + "lookup.document_category.structural": "结构", + "lookup.document_category.landscaping": "园艺", + "lookup.document_category.general": "通用", + "lookup.document_category.other": "其他" } diff --git a/internal/services/cache_service.go b/internal/services/cache_service.go index 3c403c0..454821e 100644 --- a/internal/services/cache_service.go +++ b/internal/services/cache_service.go @@ -12,6 +12,7 @@ import ( "github.com/rs/zerolog/log" "github.com/treytartt/honeydue-api/internal/config" + "github.com/treytartt/honeydue-api/internal/i18n" ) // CacheService provides Redis caching functionality @@ -21,7 +22,7 @@ type CacheService struct { var ( cacheInstance *CacheService - cacheOnce sync.Once + cacheOnce sync.Once ) // NewCacheService creates a new cache service (thread-safe via sync.Once) @@ -133,7 +134,6 @@ func (c *CacheService) Close() error { return nil } - // Static data cache helpers const ( StaticDataKey = "static_data" @@ -191,9 +191,11 @@ func (c *CacheService) InvalidateAllLookups(ctx context.Context) error { LookupResidenceTypesKey, LookupSpecialtiesKey, LookupTaskTemplatesKey, - StaticDataKey, // Also invalidate the combined static data - SeededDataKey, // Invalidate unified seeded data - SeededDataETagKey, // Invalidate seeded data ETag + StaticDataKey, // Also invalidate the combined static data + } + // Per-locale seeded-data + ETag keys. + for _, lang := range i18n.SupportedLanguages { + keys = append(keys, seededDataKey(lang), seededDataETagKey(lang)) } return c.Delete(ctx, keys...) } @@ -289,50 +291,64 @@ func (c *CacheService) InvalidateTaskTemplates(ctx context.Context) error { return c.Delete(ctx, LookupTaskTemplatesKey, StaticDataKey) } -// Unified seeded data cache helpers +// Unified seeded data cache helpers. +// +// The seeded-data payload is localized (lookup display_name + home-profile +// option labels), so the cache and ETag are namespaced per locale. Mixing +// locales under one key would let the first request poison every other +// language and make the ETag meaningless across locales. const ( - SeededDataKey = "seeded_data" - SeededDataETagKey = "seeded_data:etag" - SeededDataTTL = 24 * time.Hour + seededDataPrefix = "seeded_data:" + SeededDataTTL = 24 * time.Hour ) -// CacheSeededData caches the unified seeded data and generates an ETag -func (c *CacheService) CacheSeededData(ctx context.Context, data interface{}) (string, error) { +func seededDataKey(locale string) string { return seededDataPrefix + locale } +func seededDataETagKey(locale string) string { return seededDataPrefix + locale + ":etag" } + +// CacheSeededData caches the unified seeded data for a locale and generates an +// ETag. The locale is folded into the ETag so a client switching languages +// always re-fetches rather than getting a stale 304. +func (c *CacheService) CacheSeededData(ctx context.Context, locale string, data interface{}) (string, error) { jsonData, err := json.Marshal(data) if err != nil { return "", fmt.Errorf("failed to marshal seeded data: %w", err) } - // Generate FNV-64a ETag from the JSON data (faster than MD5, non-cryptographic) + // FNV-64a ETag over locale + JSON (faster than MD5, non-cryptographic). h := fnv.New64a() + h.Write([]byte(locale)) + h.Write([]byte{0}) h.Write(jsonData) etag := fmt.Sprintf("\"%x\"", h.Sum64()) - // Store both the data and the ETag - if err := c.client.Set(ctx, SeededDataKey, jsonData, SeededDataTTL).Err(); err != nil { + if err := c.client.Set(ctx, seededDataKey(locale), jsonData, SeededDataTTL).Err(); err != nil { return "", fmt.Errorf("failed to cache seeded data: %w", err) } - - if err := c.client.Set(ctx, SeededDataETagKey, etag, SeededDataTTL).Err(); err != nil { + if err := c.client.Set(ctx, seededDataETagKey(locale), etag, SeededDataTTL).Err(); err != nil { return "", fmt.Errorf("failed to cache seeded data etag: %w", err) } - return etag, nil } -// GetCachedSeededData retrieves cached unified seeded data -func (c *CacheService) GetCachedSeededData(ctx context.Context, dest interface{}) error { - return c.Get(ctx, SeededDataKey, dest) +// GetCachedSeededData retrieves cached unified seeded data for a locale. +func (c *CacheService) GetCachedSeededData(ctx context.Context, locale string, dest interface{}) error { + return c.Get(ctx, seededDataKey(locale), dest) } -// GetSeededDataETag retrieves the cached ETag for seeded data -func (c *CacheService) GetSeededDataETag(ctx context.Context) (string, error) { - return c.GetString(ctx, SeededDataETagKey) +// GetSeededDataETag retrieves the cached ETag for a locale's seeded data. +func (c *CacheService) GetSeededDataETag(ctx context.Context, locale string) (string, error) { + return c.GetString(ctx, seededDataETagKey(locale)) } -// InvalidateSeededData removes cached seeded data and its ETag +// InvalidateSeededData removes cached seeded data and ETags for every +// supported locale (lookup data is locale-independent at the source, so a +// change must clear all language variants). func (c *CacheService) InvalidateSeededData(ctx context.Context) error { - return c.Delete(ctx, SeededDataKey, SeededDataETagKey) + keys := make([]string, 0, len(i18n.SupportedLanguages)*2) + for _, lang := range i18n.SupportedLanguages { + keys = append(keys, seededDataKey(lang), seededDataETagKey(lang)) + } + return c.Delete(ctx, keys...) } // === User → Residence-IDs cache === diff --git a/internal/services/lookup_i18n.go b/internal/services/lookup_i18n.go new file mode 100644 index 0000000..1867c0f --- /dev/null +++ b/internal/services/lookup_i18n.go @@ -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..". 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) + } +} diff --git a/internal/services/lookup_i18n_test.go b/internal/services/lookup_i18n_test.go new file mode 100644 index 0000000..36d17e2 --- /dev/null +++ b/internal/services/lookup_i18n_test.go @@ -0,0 +1,88 @@ +package services + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/treytartt/honeydue-api/internal/dto/responses" + "github.com/treytartt/honeydue-api/internal/i18n" +) + +func init() { + // Load the embedded translation bundle for the localization tests. + _ = i18n.Init() +} + +func TestLookupSlug(t *testing.T) { + cases := map[string]string{ + "Pest Control": "pest_control", + "Bi-Weekly": "bi_weekly", + "Semi-Annually": "semi_annually", + "Mobile Home": "mobile_home", + "HVAC": "hvac", + "tank_gas": "tank_gas", + "Tankless (Gas)": "tankless_gas", + " Trailing/Punct ": "trailing_punct", + } + for in, want := range cases { + assert.Equalf(t, want, lookupSlug(in), "slug(%q)", in) + } +} + +func TestLocalizeLookup_EnglishAndSpanish(t *testing.T) { + en := i18n.NewLocalizer("en") + es := i18n.NewLocalizer("es") + + // Known value: localizes per locale. + assert.Equal(t, "Plumbing", localizeLookup(en, lookupKindTaskCategory, "Plumbing")) + assert.Equal(t, "Fontanería", localizeLookup(es, lookupKindTaskCategory, "Plumbing")) + + // Frequency with separators (regression on the bi_weekly/semi_annually slug). + assert.Equal(t, "Bi-Weekly", localizeLookup(en, lookupKindTaskFrequency, "Bi-Weekly")) + assert.Equal(t, "Quincenal", localizeLookup(es, lookupKindTaskFrequency, "Bi-Weekly")) +} + +func TestLocalizeLookup_NilLocalizerFallsBackToEnglish(t *testing.T) { + assert.Equal(t, "House", localizeLookup(nil, lookupKindResidenceType, "House")) +} + +func TestLocalizeLookup_UnknownValueFallsBackToName(t *testing.T) { + // No translation key exists -> return the stable English name, never a key. + got := localizeLookup(i18n.NewLocalizer("es"), lookupKindTaskCategory, "Totally Unknown Value") + assert.Equal(t, "Totally Unknown Value", got) +} + +func TestBuildHomeProfileOptions(t *testing.T) { + opts := BuildHomeProfileOptions(i18n.NewLocalizer("es")) + + // All 7 fields present. + for _, field := range []string{"heating_type", "cooling_type", "water_heater_type", "roof_type", "exterior_type", "flooring_primary", "landscaping_type"} { + require.Containsf(t, opts, field, "missing field %s", field) + require.NotEmpty(t, opts[field]) + } + + // Values are stable; display_name is localized. + heating := opts["heating_type"] + assert.Equal(t, "gas_furnace", heating[0].Value) + assert.Equal(t, "Calefactor de gas", heating[0].DisplayName) +} + +func TestLocalizeLookups_FillsDisplayName(t *testing.T) { + cats := []responses.TaskCategoryResponse{{Name: "Plumbing"}, {Name: "HVAC"}} + prios := []responses.TaskPriorityResponse{{Name: "High"}} + freqs := []responses.TaskFrequencyResponse{{Name: "Monthly"}} + specs := []responses.ContractorSpecialtyResponse{{Name: "Plumber"}} + types := []responses.ResidenceTypeResponse{{Name: "House"}} + + LocalizeLookups(i18n.NewLocalizer("es"), types, cats, prios, freqs, specs) + + assert.Equal(t, "Fontanería", cats[0].DisplayName) + assert.Equal(t, "Plumbing", cats[0].Name) // name stays stable + assert.Equal(t, "Climatización", cats[1].DisplayName) + assert.Equal(t, "Alta", prios[0].DisplayName) + assert.Equal(t, "Mensual", freqs[0].DisplayName) + assert.Equal(t, "Fontanero", specs[0].DisplayName) + assert.Equal(t, "Casa", types[0].DisplayName) +} diff --git a/internal/services/suggestion_service.go b/internal/services/suggestion_service.go index 8971573..d7f2504 100644 --- a/internal/services/suggestion_service.go +++ b/internal/services/suggestion_service.go @@ -3,11 +3,14 @@ package services import ( "encoding/json" "sort" + "strings" + goi18n "github.com/nicksnyder/go-i18n/v2/i18n" "gorm.io/gorm" "github.com/treytartt/honeydue-api/internal/apperrors" "github.com/treytartt/honeydue-api/internal/dto/responses" + "github.com/treytartt/honeydue-api/internal/i18n" "github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/repositories" ) @@ -26,25 +29,59 @@ func NewSuggestionService(db *gorm.DB, residenceRepo *repositories.ResidenceRepo } } +// stringList is a condition value that may be encoded as either a single JSON +// string ("gas_furnace") or an array of allowed strings (["gas_furnace", +// "boiler"]). The seeded template catalog uses arrays of allowed values; older +// hand-written conditions used a scalar. Accepting both keeps every existing +// condition working — and a scalar `*string` (the previous type) silently +// failed to unmarshal the array form, collapsing every conditioned template to +// "universal". +type stringList []string + +// UnmarshalJSON accepts a string or an array of strings. +func (s *stringList) UnmarshalJSON(data []byte) error { + var arr []string + if err := json.Unmarshal(data, &arr); err == nil { + *s = arr + return nil + } + var one string + if err := json.Unmarshal(data, &one); err != nil { + return err + } + *s = stringList{one} + return nil +} + +// contains reports whether v is one of the allowed values. +func (s stringList) contains(v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + // templateConditions represents the parsed conditions JSON from a task template. // Every field is optional; a template with no conditions is "universal" and // receives a small base score. See scoreTemplate for how each field is used. type templateConditions struct { - HeatingType *string `json:"heating_type,omitempty"` - CoolingType *string `json:"cooling_type,omitempty"` - WaterHeaterType *string `json:"water_heater_type,omitempty"` - RoofType *string `json:"roof_type,omitempty"` - ExteriorType *string `json:"exterior_type,omitempty"` - FlooringPrimary *string `json:"flooring_primary,omitempty"` - LandscapingType *string `json:"landscaping_type,omitempty"` - HasPool *bool `json:"has_pool,omitempty"` - HasSprinkler *bool `json:"has_sprinkler_system,omitempty"` - HasSeptic *bool `json:"has_septic,omitempty"` - HasFireplace *bool `json:"has_fireplace,omitempty"` - HasGarage *bool `json:"has_garage,omitempty"` - HasBasement *bool `json:"has_basement,omitempty"` - HasAttic *bool `json:"has_attic,omitempty"` - PropertyType *string `json:"property_type,omitempty"` + HeatingType stringList `json:"heating_type,omitempty"` + CoolingType stringList `json:"cooling_type,omitempty"` + WaterHeaterType stringList `json:"water_heater_type,omitempty"` + RoofType stringList `json:"roof_type,omitempty"` + ExteriorType stringList `json:"exterior_type,omitempty"` + FlooringPrimary stringList `json:"flooring_primary,omitempty"` + LandscapingType stringList `json:"landscaping_type,omitempty"` + HasPool *bool `json:"has_pool,omitempty"` + HasSprinkler *bool `json:"has_sprinkler_system,omitempty"` + HasSeptic *bool `json:"has_septic,omitempty"` + HasFireplace *bool `json:"has_fireplace,omitempty"` + HasGarage *bool `json:"has_garage,omitempty"` + HasBasement *bool `json:"has_basement,omitempty"` + HasAttic *bool `json:"has_attic,omitempty"` + PropertyType stringList `json:"property_type,omitempty"` // ClimateRegionID replaces the old task_tasktemplate_regions join table. // Tag a template with the IECC zone ID it's relevant to (e.g. "Winterize // Sprinkler" → zone 5/6). Residence.PostalCode is mapped to a region at @@ -54,12 +91,12 @@ type templateConditions struct { // isEmpty returns true if no conditions are set func (c *templateConditions) isEmpty() bool { - return c.HeatingType == nil && c.CoolingType == nil && c.WaterHeaterType == nil && - c.RoofType == nil && c.ExteriorType == nil && c.FlooringPrimary == nil && - c.LandscapingType == nil && c.HasPool == nil && c.HasSprinkler == nil && + return len(c.HeatingType) == 0 && len(c.CoolingType) == 0 && len(c.WaterHeaterType) == 0 && + len(c.RoofType) == 0 && len(c.ExteriorType) == 0 && len(c.FlooringPrimary) == 0 && + len(c.LandscapingType) == 0 && c.HasPool == nil && c.HasSprinkler == nil && c.HasSeptic == nil && c.HasFireplace == nil && c.HasGarage == nil && c.HasBasement == nil && c.HasAttic == nil && - c.PropertyType == nil && c.ClimateRegionID == nil + len(c.PropertyType) == 0 && c.ClimateRegionID == nil } const ( @@ -75,8 +112,10 @@ const ( totalProfileFields = 15 // 14 home-profile fields + ZIP/region ) -// GetSuggestions returns task template suggestions scored against a residence's profile -func (s *SuggestionService) GetSuggestions(residenceID uint, userID uint) (*responses.TaskSuggestionsResponse, error) { +// GetSuggestions returns task template suggestions scored against a residence's +// profile. Match reasons are localized for display via the supplied localizer +// (nil falls back to English). +func (s *SuggestionService) GetSuggestions(residenceID uint, userID uint, localizer *goi18n.Localizer) (*responses.TaskSuggestionsResponse, error) { // Check access hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID) if err != nil { @@ -112,7 +151,7 @@ func (s *SuggestionService) GetSuggestions(residenceID uint, userID uint) (*resp suggestions = append(suggestions, responses.TaskSuggestionResponse{ Template: responses.NewTaskTemplateResponse(&templates[i]), RelevanceScore: score, - MatchReasons: reasons, + MatchReasons: localizeReasons(localizer, reasons), }) } @@ -135,6 +174,38 @@ func (s *SuggestionService) GetSuggestions(residenceID uint, userID uint) (*resp }, nil } +// localizeReasons converts the internal reason codes emitted by scoreTemplate +// into human-readable, localized strings for the API response. +// +// Codes come in two shapes: a bare feature code ("has_fireplace") and a +// "field:value" pair ("heating_type:gas_furnace"); for the latter we key off +// the field only (the percentage already conveys strength, and a per-enum-value +// catalog would be a much larger surface). The "universal" and "partial_profile" +// signals are internal scoring artifacts, not user-facing reasons, so they're +// dropped. Any code without a translation falls back to English so a raw key +// can never leak to the UI. +func localizeReasons(localizer *goi18n.Localizer, codes []string) []string { + out := make([]string, 0, len(codes)) + for _, code := range codes { + if code == "universal" || code == "partial_profile" { + continue + } + field := code + if i := strings.IndexByte(code, ':'); i >= 0 { + field = code[:i] + } + key := "suggestion.reason." + field + msg := i18n.T(localizer, key, nil) + if msg == key { + // Locale lacked the key — fall back to English so the user never + // sees a raw message id. + msg = i18n.T(i18n.NewLocalizer(i18n.DefaultLanguage), key, nil) + } + out = append(out, msg) + } + return out +} + // scoreTemplate scores a template against a residence profile. // Returns (score, matchReasons, shouldInclude). func (s *SuggestionService) scoreTemplate(tmpl *models.TaskTemplate, residence *models.Residence) (float64, []string, bool) { @@ -157,76 +228,62 @@ func (s *SuggestionService) scoreTemplate(tmpl *models.TaskTemplate, residence * reasons := make([]string, 0) conditionCount := 0 - // String field matches - if cond.HeatingType != nil { + // String field matches. Each condition is a set of allowed values; the + // residence matches when its value is one of them. A nil residence field is + // ignored (no penalty, no exclusion); a mismatch simply earns no bonus. + if len(cond.HeatingType) > 0 { conditionCount++ - if residence.HeatingType == nil { - // Field not set - ignore - } else if *residence.HeatingType == *cond.HeatingType { + if residence.HeatingType != nil && cond.HeatingType.contains(*residence.HeatingType) { score += stringMatchBonus - reasons = append(reasons, "heating_type:"+*cond.HeatingType) - } else { - // Mismatch - don't exclude, just don't reward + reasons = append(reasons, "heating_type:"+*residence.HeatingType) } } - if cond.CoolingType != nil { + if len(cond.CoolingType) > 0 { conditionCount++ - if residence.CoolingType == nil { - // ignore - } else if *residence.CoolingType == *cond.CoolingType { + if residence.CoolingType != nil && cond.CoolingType.contains(*residence.CoolingType) { score += stringMatchBonus - reasons = append(reasons, "cooling_type:"+*cond.CoolingType) + reasons = append(reasons, "cooling_type:"+*residence.CoolingType) } } - if cond.WaterHeaterType != nil { + if len(cond.WaterHeaterType) > 0 { conditionCount++ - if residence.WaterHeaterType == nil { - // ignore - } else if *residence.WaterHeaterType == *cond.WaterHeaterType { + if residence.WaterHeaterType != nil && cond.WaterHeaterType.contains(*residence.WaterHeaterType) { score += stringMatchBonus - reasons = append(reasons, "water_heater_type:"+*cond.WaterHeaterType) + reasons = append(reasons, "water_heater_type:"+*residence.WaterHeaterType) } } - if cond.RoofType != nil { + if len(cond.RoofType) > 0 { conditionCount++ - if residence.RoofType == nil { - // ignore - } else if *residence.RoofType == *cond.RoofType { + if residence.RoofType != nil && cond.RoofType.contains(*residence.RoofType) { score += stringMatchBonus - reasons = append(reasons, "roof_type:"+*cond.RoofType) + reasons = append(reasons, "roof_type:"+*residence.RoofType) } } - if cond.ExteriorType != nil { + if len(cond.ExteriorType) > 0 { conditionCount++ - if residence.ExteriorType == nil { - // ignore - } else if *residence.ExteriorType == *cond.ExteriorType { + if residence.ExteriorType != nil && cond.ExteriorType.contains(*residence.ExteriorType) { score += stringMatchBonus - reasons = append(reasons, "exterior_type:"+*cond.ExteriorType) + reasons = append(reasons, "exterior_type:"+*residence.ExteriorType) } } - if cond.FlooringPrimary != nil { + if len(cond.FlooringPrimary) > 0 { conditionCount++ - if residence.FlooringPrimary == nil { - // ignore - } else if *residence.FlooringPrimary == *cond.FlooringPrimary { + if residence.FlooringPrimary != nil && cond.FlooringPrimary.contains(*residence.FlooringPrimary) { score += stringMatchBonus - reasons = append(reasons, "flooring_primary:"+*cond.FlooringPrimary) + reasons = append(reasons, "flooring_primary:"+*residence.FlooringPrimary) } } - if cond.LandscapingType != nil { + if len(cond.LandscapingType) > 0 { conditionCount++ - if residence.LandscapingType == nil { - // ignore - } else if *residence.LandscapingType == *cond.LandscapingType { + if residence.LandscapingType != nil && cond.LandscapingType.contains(*residence.LandscapingType) { score += stringMatchBonus - reasons = append(reasons, "landscaping_type:"+*cond.LandscapingType) + reasons = append(reasons, "landscaping_type:"+*residence.LandscapingType) } } @@ -309,11 +366,11 @@ func (s *SuggestionService) scoreTemplate(tmpl *models.TaskTemplate, residence * } // Property type match - if cond.PropertyType != nil { + if len(cond.PropertyType) > 0 { conditionCount++ - if residence.PropertyType != nil && residence.PropertyType.Name == *cond.PropertyType { + if residence.PropertyType != nil && cond.PropertyType.contains(residence.PropertyType.Name) { score += propertyTypeBonus - reasons = append(reasons, "property_type:"+*cond.PropertyType) + reasons = append(reasons, "property_type:"+residence.PropertyType.Name) } } @@ -328,17 +385,22 @@ func (s *SuggestionService) scoreTemplate(tmpl *models.TaskTemplate, residence * } } - // Cap at 1.0 - if score > 1.0 { - score = 1.0 - } - - // If template has conditions but no matches and no reasons, still include with low score + // If template has conditions but none matched (residence fields unset or + // different), rank it just below universal — it's a conditioned template we + // couldn't confirm applies. if conditionCount > 0 && len(reasons) == 0 { return baseUniversalScore * 0.5, []string{"partial_profile"}, true } - return score, reasons, true + // A matched conditioned template must rank ABOVE a universal one. Anchor it + // at the universal baseline and add the accumulated match bonuses on top, + // so e.g. a single heating match (0.3 + 0.25) clearly beats universal (0.3). + final := baseUniversalScore + score + if final > 1.0 { + final = 1.0 + } + + return final, reasons, true } // CalculateProfileCompleteness returns how many of the 14 home profile fields are filled diff --git a/internal/services/suggestion_service_test.go b/internal/services/suggestion_service_test.go index ac0833e..b14426a 100644 --- a/internal/services/suggestion_service_test.go +++ b/internal/services/suggestion_service_test.go @@ -47,12 +47,12 @@ func TestSuggestionService_UniversalTemplate(t *testing.T) { // Create universal template (empty conditions) createTemplateWithConditions(t, service, "Change Air Filters", nil) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) assert.Equal(t, "Change Air Filters", resp.Suggestions[0].Template.Title) assert.Equal(t, baseUniversalScore, resp.Suggestions[0].RelevanceScore) - assert.Contains(t, resp.Suggestions[0].MatchReasons, "universal") + assert.Empty(t, resp.Suggestions[0].MatchReasons) // universal templates carry no display reason } func TestSuggestionService_HeatingTypeMatch(t *testing.T) { @@ -75,11 +75,47 @@ func TestSuggestionService_HeatingTypeMatch(t *testing.T) { "heating_type": "gas_furnace", }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) - assert.Equal(t, stringMatchBonus, resp.Suggestions[0].RelevanceScore) - assert.Contains(t, resp.Suggestions[0].MatchReasons, "heating_type:gas_furnace") + // Matched conditioned templates are anchored at the universal baseline and + // earn bonuses on top, so a match always ranks above a universal template. + assert.Equal(t, baseUniversalScore+stringMatchBonus, resp.Suggestions[0].RelevanceScore) + assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your heating system") +} + +// TestSuggestionService_StringConditionArrayForm is the regression test for the +// scorer bug where conditions encoded as an array of allowed values +// (`{"heating_type":["gas_furnace","boiler"]}`, the format used by the seeded +// template catalog) failed to unmarshal into a scalar *string field and the +// template silently collapsed to "universal". The residence's value must match +// when it is any member of the array. +func TestSuggestionService_StringConditionArrayForm(t *testing.T) { + service := setupSuggestionService(t) + user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password") + + heatingType := "boiler" // second value in the allowed list + residence := &models.Residence{ + OwnerID: user.ID, + Name: "Boiler House", + IsActive: true, + IsPrimary: true, + HeatingType: &heatingType, + } + require.NoError(t, service.db.Create(residence).Error) + + // Array-of-allowed-values form, exactly as the seed catalog stores it. + createTemplateWithConditions(t, service, "Test Gas Shutoffs", map[string]interface{}{ + "heating_type": []string{"gas_furnace", "boiler"}, + }) + + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) + require.NoError(t, err) + require.Len(t, resp.Suggestions, 1) + // Must MATCH (not fall back to universal) and rank above the universal baseline. + assert.Equal(t, baseUniversalScore+stringMatchBonus, resp.Suggestions[0].RelevanceScore) + assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your heating system") + assert.Len(t, resp.Suggestions[0].MatchReasons, 1) } func TestSuggestionService_ExcludedWhenPoolRequiredButFalse(t *testing.T) { @@ -94,7 +130,7 @@ func TestSuggestionService_ExcludedWhenPoolRequiredButFalse(t *testing.T) { "has_pool": true, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) assert.Len(t, resp.Suggestions, 0) // Should be excluded } @@ -111,11 +147,11 @@ func TestSuggestionService_NilFieldIgnored(t *testing.T) { "heating_type": "gas_furnace", }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) // Should be included (not excluded) but with low partial score - assert.Contains(t, resp.Suggestions[0].MatchReasons, "partial_profile") + assert.Empty(t, resp.Suggestions[0].MatchReasons) // conditioned-but-unmatched carries no display reason } func TestSuggestionService_ProfileCompleteness(t *testing.T) { @@ -140,7 +176,7 @@ func TestSuggestionService_ProfileCompleteness(t *testing.T) { // Create at least one template so we get a response createTemplateWithConditions(t, service, "Universal Task", nil) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) // 4 fields filled out of 15 (home-profile fields + ZIP/region) expectedCompleteness := 4.0 / float64(totalProfileFields) @@ -176,7 +212,7 @@ func TestSuggestionService_SortedByScoreDescending(t *testing.T) { "has_pool": true, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 3) @@ -193,7 +229,7 @@ func TestSuggestionService_AccessDenied(t *testing.T) { residence := testutil.CreateTestResidence(t, service.db, owner.ID, "Private House") - _, err := service.GetSuggestions(residence.ID, stranger.ID) + _, err := service.GetSuggestions(residence.ID, stranger.ID, nil) require.Error(t, err) testutil.AssertAppErrorCode(t, err, 403) } @@ -222,12 +258,13 @@ func TestSuggestionService_MultipleConditionsAllMustMatch(t *testing.T) { "heating_type": "gas_furnace", }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) - // All three conditions matched - expectedScore := boolMatchBonus + boolMatchBonus + stringMatchBonus // 0.3 + 0.3 + 0.25 = 0.85 + // All three conditions matched. Anchored baseline (0.3) + 0.3 + 0.3 + 0.25 + // = 1.15, capped at 1.0. + expectedScore := 1.0 assert.InDelta(t, expectedScore, resp.Suggestions[0].RelevanceScore, 0.01) assert.Len(t, resp.Suggestions[0].MatchReasons, 3) } @@ -248,7 +285,7 @@ func TestSuggestionService_MalformedConditions(t *testing.T) { err := service.db.Create(tmpl).Error require.NoError(t, err) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) // Should be treated as universal @@ -270,7 +307,7 @@ func TestSuggestionService_NullConditions(t *testing.T) { err := service.db.Create(tmpl).Error require.NoError(t, err) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) assert.Equal(t, baseUniversalScore, resp.Suggestions[0].RelevanceScore) @@ -304,10 +341,10 @@ func TestSuggestionService_PropertyTypeMatch(t *testing.T) { "property_type": "House", }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) - assert.Contains(t, resp.Suggestions[0].MatchReasons, "property_type:House") + assert.Contains(t, resp.Suggestions[0].MatchReasons, "Recommended for your property type") } // === CalculateProfileCompleteness with fully filled profile === @@ -392,7 +429,7 @@ func TestSuggestionService_ScoreCappedAtOne(t *testing.T) { "has_garage": true, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) assert.LessOrEqual(t, resp.Suggestions[0].RelevanceScore, 1.0) @@ -409,7 +446,7 @@ func TestSuggestionService_InactiveTemplateExcluded(t *testing.T) { err := service.db.Exec("INSERT INTO task_tasktemplate (title, is_active, conditions, created_at, updated_at) VALUES ('Inactive Task', false, '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)").Error require.NoError(t, err) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) assert.Len(t, resp.Suggestions, 0) } @@ -425,7 +462,7 @@ func TestSuggestionService_ExcludedWhenSprinklerRequired(t *testing.T) { "has_sprinkler_system": true, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) assert.Len(t, resp.Suggestions, 0) } @@ -441,7 +478,7 @@ func TestSuggestionService_ExcludedWhenSepticRequired(t *testing.T) { "has_septic": true, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) assert.Len(t, resp.Suggestions, 0) } @@ -455,7 +492,7 @@ func TestSuggestionService_ExcludedWhenFireplaceRequired(t *testing.T) { "has_fireplace": true, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) assert.Len(t, resp.Suggestions, 0) } @@ -469,7 +506,7 @@ func TestSuggestionService_ExcludedWhenGarageRequired(t *testing.T) { "has_garage": true, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) assert.Len(t, resp.Suggestions, 0) } @@ -483,7 +520,7 @@ func TestSuggestionService_ExcludedWhenBasementRequired(t *testing.T) { "has_basement": true, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) assert.Len(t, resp.Suggestions, 0) } @@ -497,7 +534,7 @@ func TestSuggestionService_ExcludedWhenAtticRequired(t *testing.T) { "has_attic": true, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) assert.Len(t, resp.Suggestions, 0) } @@ -523,10 +560,10 @@ func TestSuggestionService_CoolingTypeMatch(t *testing.T) { "cooling_type": "central_ac", }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) - assert.Contains(t, resp.Suggestions[0].MatchReasons, "cooling_type:central_ac") + assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your cooling system") } func TestSuggestionService_WaterHeaterTypeMatch(t *testing.T) { @@ -548,10 +585,10 @@ func TestSuggestionService_WaterHeaterTypeMatch(t *testing.T) { "water_heater_type": "tank_gas", }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) - assert.Contains(t, resp.Suggestions[0].MatchReasons, "water_heater_type:tank_gas") + assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your water heater") } func TestSuggestionService_ExteriorTypeMatch(t *testing.T) { @@ -573,10 +610,10 @@ func TestSuggestionService_ExteriorTypeMatch(t *testing.T) { "exterior_type": "brick", }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) - assert.Contains(t, resp.Suggestions[0].MatchReasons, "exterior_type:brick") + assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your exterior") } func TestSuggestionService_FlooringPrimaryMatch(t *testing.T) { @@ -598,10 +635,10 @@ func TestSuggestionService_FlooringPrimaryMatch(t *testing.T) { "flooring_primary": "hardwood", }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) - assert.Contains(t, resp.Suggestions[0].MatchReasons, "flooring_primary:hardwood") + assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your flooring") } func TestSuggestionService_LandscapingTypeMatch(t *testing.T) { @@ -623,10 +660,10 @@ func TestSuggestionService_LandscapingTypeMatch(t *testing.T) { "landscaping_type": "lawn", }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) - assert.Contains(t, resp.Suggestions[0].MatchReasons, "landscaping_type:lawn") + assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your landscaping") } func TestSuggestionService_RoofTypeMatch(t *testing.T) { @@ -648,10 +685,10 @@ func TestSuggestionService_RoofTypeMatch(t *testing.T) { "roof_type": "asphalt_shingle", }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) - assert.Contains(t, resp.Suggestions[0].MatchReasons, "roof_type:asphalt_shingle") + assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your roof") } // === Mismatch on string field — no score for that field === @@ -676,11 +713,11 @@ func TestSuggestionService_HeatingTypeMismatch(t *testing.T) { "heating_type": "gas_furnace", }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) // Should still be included but with partial_profile (no match, no exclude) - assert.Contains(t, resp.Suggestions[0].MatchReasons, "partial_profile") + assert.Empty(t, resp.Suggestions[0].MatchReasons) // conditioned-but-unmatched carries no display reason } // === templateConditions.isEmpty === @@ -689,16 +726,14 @@ func TestTemplateConditions_IsEmpty(t *testing.T) { cond := &templateConditions{} assert.True(t, cond.isEmpty()) - ht := "gas" - cond2 := &templateConditions{HeatingType: &ht} + cond2 := &templateConditions{HeatingType: stringList{"gas"}} assert.False(t, cond2.isEmpty()) pool := true cond3 := &templateConditions{HasPool: &pool} assert.False(t, cond3.isEmpty()) - pt := "House" - cond4 := &templateConditions{PropertyType: &pt} + cond4 := &templateConditions{PropertyType: stringList{"House"}} assert.False(t, cond4.isEmpty()) var regionID uint = 5 @@ -727,11 +762,11 @@ func TestSuggestionService_ClimateRegionMatch(t *testing.T) { "climate_region_id": 5, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) - assert.InDelta(t, climateRegionBonus, resp.Suggestions[0].RelevanceScore, 0.001) - assert.Contains(t, resp.Suggestions[0].MatchReasons, "climate_region") + assert.InDelta(t, baseUniversalScore+climateRegionBonus, resp.Suggestions[0].RelevanceScore, 0.001) + assert.Contains(t, resp.Suggestions[0].MatchReasons, "Recommended for your climate") } func TestSuggestionService_ClimateRegionMismatch(t *testing.T) { @@ -753,11 +788,11 @@ func TestSuggestionService_ClimateRegionMismatch(t *testing.T) { "climate_region_id": 6, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) // Still included — mismatch doesn't exclude assert.InDelta(t, baseUniversalScore*0.5, resp.Suggestions[0].RelevanceScore, 0.001) - assert.Contains(t, resp.Suggestions[0].MatchReasons, "partial_profile") + assert.Empty(t, resp.Suggestions[0].MatchReasons) // conditioned-but-unmatched carries no display reason } func TestSuggestionService_ClimateRegionIgnoredWhenNoZip(t *testing.T) { @@ -779,7 +814,7 @@ func TestSuggestionService_ClimateRegionIgnoredWhenNoZip(t *testing.T) { "climate_region_id": 5, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) // Still included, just no bonus assert.InDelta(t, baseUniversalScore*0.5, resp.Suggestions[0].RelevanceScore, 0.001) @@ -802,11 +837,11 @@ func TestSuggestionService_ClimateRegionUnknownZip(t *testing.T) { "climate_region_id": 5, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) // Unknown ZIP → 0 region → no match, but no crash - assert.Contains(t, resp.Suggestions[0].MatchReasons, "partial_profile") + assert.Empty(t, resp.Suggestions[0].MatchReasons) // conditioned-but-unmatched carries no display reason } func TestSuggestionService_ClimateRegionStacksWithOtherConditions(t *testing.T) { @@ -829,11 +864,11 @@ func TestSuggestionService_ClimateRegionStacksWithOtherConditions(t *testing.T) "climate_region_id": 5, }) - resp, err := service.GetSuggestions(residence.ID, user.ID) + resp, err := service.GetSuggestions(residence.ID, user.ID, nil) require.NoError(t, err) require.Len(t, resp.Suggestions, 1) // Both bonuses should apply: stringMatchBonus + climateRegionBonus - assert.InDelta(t, stringMatchBonus+climateRegionBonus, resp.Suggestions[0].RelevanceScore, 0.001) - assert.Contains(t, resp.Suggestions[0].MatchReasons, "heating_type:gas_furnace") - assert.Contains(t, resp.Suggestions[0].MatchReasons, "climate_region") + assert.InDelta(t, baseUniversalScore+stringMatchBonus+climateRegionBonus, resp.Suggestions[0].RelevanceScore, 0.001) + assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your heating system") + assert.Contains(t, resp.Suggestions[0].MatchReasons, "Recommended for your climate") }