From 0b5799416bb5024e2c815e10311fe8705ff49969 Mon Sep 17 00:00:00 2001 From: Toofan Mohammadi Date: Mon, 9 Nov 2026 09:16:39 +0330 Subject: [PATCH 01/10] feat(i18n): add Persian (fa) locale and RTL support --- frontend/src/lib/locales/de/auth.js | 6 ++++++ frontend/src/lib/locales/de/supplemental.js | 1 + frontend/src/lib/locales/en/auth.js | 6 ++++++ frontend/src/lib/locales/en/ui.js | 1 + frontend/src/lib/locales/fa/auth.js | 6 ++++++ frontend/src/lib/locales/fa/ui.js | 1 + frontend/src/lib/locales/fr/auth.js | 6 ++++++ frontend/src/lib/locales/fr/ui.js | 1 + frontend/src/lib/locales/ru/auth.js | 6 ++++++ frontend/src/lib/locales/ru/ui.js | 1 + frontend/src/lib/locales/zh-CN/auth.js | 6 ++++++ frontend/src/lib/locales/zh-CN/supplemental.js | 1 + internal/i18n/locale.go | 3 ++- 13 files changed, 44 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/locales/de/auth.js b/frontend/src/lib/locales/de/auth.js index 53198f836..9fd45f6d1 100644 --- a/frontend/src/lib/locales/de/auth.js +++ b/frontend/src/lib/locales/de/auth.js @@ -107,6 +107,12 @@ export default { regionalSettingsDesc: 'Konfigurieren Sie Ihre Zeitzone und Sprachpräferenzen', timezoneHint: 'Wird für die Anzeige von Datum und Uhrzeit in Ihrer lokalen Zeitzone verwendet', languageHint: 'Ihre bevorzugte Sprache für die Anwendungsoberfläche', + calendarAutomatic: 'Automatisch', + calendarSaveFailed: 'Sprache und Zeitzone wurden gespeichert, der Kalender jedoch nicht.', + calendar: 'Kalender', + calendarHint: 'Kalender zur Anzeige von Daten wählen', + gregorian: 'Gregorianisch', + persian: 'Persisch (Jalali)', saveSettings: 'Einstellungen speichern', settingsSaved: 'Einstellungen erfolgreich gespeichert', notificationsTab: 'Benachrichtigungen', diff --git a/frontend/src/lib/locales/de/supplemental.js b/frontend/src/lib/locales/de/supplemental.js index 522750d21..7db5c803a 100644 --- a/frontend/src/lib/locales/de/supplemental.js +++ b/frontend/src/lib/locales/de/supplemental.js @@ -406,6 +406,7 @@ export default { } }, "recurrence": { + gregorianRuleHint: 'Monatliche und jährliche Wiederholungen verwenden den gregorianischen Kalender.', "title": "Wiederholung", "description": "Regeln für wiederkehrende Aufgaben verwalten", "frequency": "Häufigkeit", diff --git a/frontend/src/lib/locales/en/auth.js b/frontend/src/lib/locales/en/auth.js index 7257b5da6..cd79333b9 100644 --- a/frontend/src/lib/locales/en/auth.js +++ b/frontend/src/lib/locales/en/auth.js @@ -123,6 +123,12 @@ export default { timezoneHint: 'Used for displaying dates and times in your local timezone', language: 'Language', languageHint: 'Your preferred language for the application interface', + calendarAutomatic: 'Automatic', + calendarSaveFailed: 'Language and timezone were saved, but the calendar could not be saved.', + calendar: 'Calendar', + calendarHint: 'Choose the calendar used to display dates', + gregorian: 'Gregorian', + persian: 'Persian (Jalali)', saveSettings: 'Save Settings', settingsSaved: 'Settings saved successfully', notificationsTab: 'Notifications', diff --git a/frontend/src/lib/locales/en/ui.js b/frontend/src/lib/locales/en/ui.js index a0f5969bc..ee980cb7b 100644 --- a/frontend/src/lib/locales/en/ui.js +++ b/frontend/src/lib/locales/en/ui.js @@ -567,6 +567,7 @@ export default { }, recurrence: { + gregorianRuleHint: 'Monthly and yearly recurrence rules use the Gregorian calendar.', title: 'Recurrence', description: 'Manage recurring task rules', frequency: 'Frequency', diff --git a/frontend/src/lib/locales/fa/auth.js b/frontend/src/lib/locales/fa/auth.js index c5a36abb9..a44db5093 100644 --- a/frontend/src/lib/locales/fa/auth.js +++ b/frontend/src/lib/locales/fa/auth.js @@ -109,6 +109,12 @@ export default { "timezone": "منطقه زمانی", "timezoneHint": "برای نمایش تاریخ و زمان در منطقه زمانی محلی شما استفاده می شود", "language": "زبان", + "calendar": "تقویم", + "calendarHint": "تقویم نمایش تاریخ را انتخاب کنید", + "calendarAutomatic": "خودکار", + "calendarSaveFailed": "زبان و منطقه زمانی ذخیره شد، اما تقویم ذخیره نشد.", + "gregorian": "میلادی", + "persian": "هجری شمسی", "languageHint": "زبان دلخواه شما برای رابط برنامه", "saveSettings": "تنظیمات را ذخیره کنید", "settingsSaved": "تنظیمات با موفقیت ذخیره شد", diff --git a/frontend/src/lib/locales/fa/ui.js b/frontend/src/lib/locales/fa/ui.js index 901d861dc..b6adf02bc 100644 --- a/frontend/src/lib/locales/fa/ui.js +++ b/frontend/src/lib/locales/fa/ui.js @@ -550,6 +550,7 @@ export default { } }, "recurrence": { + "gregorianRuleHint": "تکرار ماهانه و سالانه بر اساس تقویم میلادی است.", "title": "عود", "description": "قوانین وظایف تکراری را مدیریت کنید", "frequency": "فرکانس", diff --git a/frontend/src/lib/locales/fr/auth.js b/frontend/src/lib/locales/fr/auth.js index 1769e1314..fe8c793ae 100644 --- a/frontend/src/lib/locales/fr/auth.js +++ b/frontend/src/lib/locales/fr/auth.js @@ -123,6 +123,12 @@ export default { timezoneHint: 'Utilisé pour afficher les dates et heures dans votre fuseau horaire local', language: 'Langue', languageHint: 'Votre langue préférée pour l’interface de l’application', + calendarAutomatic: 'Automatique', + calendarSaveFailed: 'La langue et le fuseau horaire ont été enregistrés, mais pas le calendrier.', + calendar: 'Calendrier', + calendarHint: 'Choisissez le calendrier utilisé pour afficher les dates', + gregorian: 'Grégorien', + persian: 'Persan (Jalali)', saveSettings: 'Enregistrer les paramètres', settingsSaved: 'Paramètres enregistrés avec succès', notificationsTab: 'Notifications', diff --git a/frontend/src/lib/locales/fr/ui.js b/frontend/src/lib/locales/fr/ui.js index 331250c41..6d85e1b69 100644 --- a/frontend/src/lib/locales/fr/ui.js +++ b/frontend/src/lib/locales/fr/ui.js @@ -559,6 +559,7 @@ export default { }, recurrence: { + gregorianRuleHint: 'Les répétitions mensuelles et annuelles utilisent le calendrier grégorien.', title: 'Récurrence', description: 'Gérer les règles de tâches récurrentes', frequency: 'Fréquence', diff --git a/frontend/src/lib/locales/ru/auth.js b/frontend/src/lib/locales/ru/auth.js index 3fb53e3fe..a1c85c7f5 100644 --- a/frontend/src/lib/locales/ru/auth.js +++ b/frontend/src/lib/locales/ru/auth.js @@ -119,6 +119,12 @@ export default { timezoneHint: 'Используется для отображения дат и времени в вашем часовом поясе', language: 'Язык', languageHint: 'Предпочитаемый язык интерфейса', + calendarAutomatic: 'Автоматически', + calendarSaveFailed: 'Язык и часовой пояс сохранены, но календарь сохранить не удалось.', + calendar: 'Календарь', + calendarHint: 'Выберите календарь для отображения дат', + gregorian: 'Григорианский', + persian: 'Персидский (Джалали)', saveSettings: 'Сохранить настройки', settingsSaved: 'Настройки сохранены', notificationsTab: 'Уведомления', diff --git a/frontend/src/lib/locales/ru/ui.js b/frontend/src/lib/locales/ru/ui.js index 6f4ca56f7..615b565b0 100644 --- a/frontend/src/lib/locales/ru/ui.js +++ b/frontend/src/lib/locales/ru/ui.js @@ -483,6 +483,7 @@ export default { }, recurrence: { + gregorianRuleHint: 'Ежемесячные и ежегодные повторения используют григорианский календарь.', title: 'Повторение', description: 'Управление правилами повторяющихся задач', frequency: 'Периодичность', diff --git a/frontend/src/lib/locales/zh-CN/auth.js b/frontend/src/lib/locales/zh-CN/auth.js index c57109b16..43a411ce5 100644 --- a/frontend/src/lib/locales/zh-CN/auth.js +++ b/frontend/src/lib/locales/zh-CN/auth.js @@ -111,6 +111,12 @@ export default { timezoneHint: '用于以您的本地时区显示日期和时间', language: '语言', languageHint: '您偏好的应用程序界面语言', + calendarAutomatic: '自动', + calendarSaveFailed: '语言和时区已保存,但日历保存失败。', + calendar: '日历', + calendarHint: '选择用于显示日期的日历', + gregorian: '公历', + persian: '波斯历 (Jalali)', saveSettings: '保存设置', settingsSaved: '设置保存成功', notificationsTab: '通知', diff --git a/frontend/src/lib/locales/zh-CN/supplemental.js b/frontend/src/lib/locales/zh-CN/supplemental.js index 54ce4ccc3..fe6710f09 100644 --- a/frontend/src/lib/locales/zh-CN/supplemental.js +++ b/frontend/src/lib/locales/zh-CN/supplemental.js @@ -432,6 +432,7 @@ export default { } }, "recurrence": { + "gregorianRuleHint": "每月和每年的重复规则使用公历。", "title": "重复", "description": "管理重复任务规则", "frequency": "频率", diff --git a/internal/i18n/locale.go b/internal/i18n/locale.go index 68ab3c50c..054901a79 100644 --- a/internal/i18n/locale.go +++ b/internal/i18n/locale.go @@ -97,5 +97,6 @@ func NegotiateAcceptLanguage(header string) (string, bool) { // IsRTL reports whether the locale reads right to left. func IsRTL(locale string) bool { - return Normalize(locale) == "ar" + normalized := Normalize(locale) + return normalized == "ar" || normalized == "fa" } From 1d696f098818db6c7431f65f16df677e157b0886 Mon Sep 17 00:00:00 2001 From: Toofan Mohammadi Date: Mon, 9 Nov 2026 09:17:05 +0330 Subject: [PATCH 02/10] feat(ui): persist calendar preference in user preferences --- .gitignore | 3 + api/openapi-v2.json | 15 ++ internal/models/user.go | 1 + .../repository/user_preferences_repository.go | 29 ++-- .../restapi/v2/calendar_preferences_test.go | 62 +++++++ internal/restapi/v2/preferences.go | 12 +- .../services/calendar_preferences_test.go | 105 ++++++++++++ internal/services/user_preferences_service.go | 162 ++++++++++-------- 8 files changed, 303 insertions(+), 86 deletions(-) create mode 100644 internal/restapi/v2/calendar_preferences_test.go create mode 100644 internal/services/calendar_preferences_test.go diff --git a/.gitignore b/.gitignore index 446be127d..cfb750ea3 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ cx *.spec.ts # Public regression coverage for the locale runtime ships with the implementation. +!frontend/src/lib/stores/calendar.spec.js +!internal/services/calendar_preferences_test.go +!internal/restapi/v2/calendar_preferences_test.go !frontend/src/lib/stores/i18n-utils.spec.js !frontend/src/lib/mobile/mobile-i18n.spec.js !frontend/scripts/hardcoded-i18n-ast.spec.js diff --git a/api/openapi-v2.json b/api/openapi-v2.json index 17d0ac5f2..b400adff5 100644 --- a/api/openapi-v2.json +++ b/api/openapi-v2.json @@ -31390,6 +31390,10 @@ "data": { "additionalProperties": false, "properties": { + "calendar": { + "description": "The calendar value.", + "type": "string" + }, "color_mode": { "description": "The color mode value.", "type": "string" @@ -31552,6 +31556,7 @@ } }, "required": [ + "calendar", "color_mode", "comment_sort_order", "due_date_reminder_days", @@ -59695,6 +59700,11 @@ "PatchUsersMePreferencesPayload": { "additionalProperties": false, "properties": { + "calendar": { + "description": "The calendar value.", + "nullable": true, + "type": "string" + }, "color_mode": { "description": "The color mode value.", "nullable": true, @@ -59771,6 +59781,10 @@ "data": { "additionalProperties": false, "properties": { + "calendar": { + "description": "The calendar value.", + "type": "string" + }, "color_mode": { "description": "The color mode value.", "type": "string" @@ -59933,6 +59947,7 @@ } }, "required": [ + "calendar", "color_mode", "comment_sort_order", "due_date_reminder_days", diff --git a/internal/models/user.go b/internal/models/user.go index 9bee433bd..1b7f141c2 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -113,6 +113,7 @@ type UserPreferences struct { // UserPreferencesData represents the parsed preferences JSON structure type UserPreferencesData struct { + Calendar string `json:"calendar,omitempty"` ColorMode string `json:"color_mode,omitempty"` // "light", "dark", or "system" ThemeID *int `json:"theme_id,omitempty"` DashboardLayout *UserDashboardLayout `json:"dashboard_layout,omitempty"` diff --git a/internal/repository/user_preferences_repository.go b/internal/repository/user_preferences_repository.go index 09395b2cc..665ee6c41 100644 --- a/internal/repository/user_preferences_repository.go +++ b/internal/repository/user_preferences_repository.go @@ -72,25 +72,18 @@ func (r *UserPreferencesRepository) GetJSON(userID int) (string, error) { return prefs, nil } -// UpsertJSON creates or updates the raw preferences JSON for a user. -func (r *UserPreferencesRepository) UpsertJSON(userID int, prefs string, now time.Time) error { - res, err := r.db.ExecWrite( - "UPDATE user_preferences SET preferences = ?, updated_at = ? WHERE user_id = ?", - prefs, now, userID, - ) - if err != nil { - return fmt.Errorf("update user preferences %d: %w", userID, err) - } - if affected, _ := res.RowsAffected(); affected > 0 { - return nil +// CompareAndSwapJSON updates a preference document if it has not changed. +func (r *UserPreferencesRepository) CompareAndSwapJSON(userID int, old *string, next string, now time.Time) (bool, error) { + var result sql.Result + var err error + if old == nil { + result, err = r.db.ExecWrite("INSERT INTO user_preferences (user_id, preferences, created_at, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(user_id) DO NOTHING", userID, next, now, now) + } else { + result, err = r.db.ExecWrite("UPDATE user_preferences SET preferences = ?, updated_at = ? WHERE user_id = ? AND preferences = ?", next, now, userID, *old) } - - _, err = r.db.ExecWrite( - "INSERT INTO user_preferences (user_id, preferences, created_at, updated_at) VALUES (?, ?, ?, ?)", - userID, prefs, now, now, - ) if err != nil { - return fmt.Errorf("insert user preferences %d: %w", userID, err) + return false, err } - return nil + count, err := result.RowsAffected() + return count > 0, err } diff --git a/internal/restapi/v2/calendar_preferences_test.go b/internal/restapi/v2/calendar_preferences_test.go new file mode 100644 index 000000000..a1889fa30 --- /dev/null +++ b/internal/restapi/v2/calendar_preferences_test.go @@ -0,0 +1,62 @@ +package v2 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "windshift/internal/contextkeys" + "windshift/internal/models" + "windshift/internal/services" +) + +type calendarPreferencesStub struct{ patch services.UserPreferencesPatch } + +func (s *calendarPreferencesStub) GetSnapshot(int) (services.UserPreferencesSnapshot, error) { + return services.UserPreferencesSnapshot{Calendar: "auto"}, nil +} +func (s *calendarPreferencesStub) UpdateSnapshot(_ int, patch services.UserPreferencesPatch) (services.UserPreferencesSnapshot, error) { + s.patch = patch + if patch.CalendarSet && patch.Calendar != "auto" && patch.Calendar != "gregory" && patch.Calendar != "persian" { + return services.UserPreferencesSnapshot{}, services.ErrInvalidCalendar + } + return services.UserPreferencesSnapshot{Calendar: patch.Calendar}, nil +} +func TestCalendarPreferencePatchContract(t *testing.T) { + cases := []struct { + body string + set bool + value string + status int + }{ + {"{}", false, "", 0}, + {"{\"calendar\":null}", true, "auto", 0}, + {"{\"calendar\":\"persian\"}", true, "persian", 0}, + {"{\"calendar\":\"invalid\"}", true, "invalid", http.StatusBadRequest}, + } + for _, tc := range cases { + t.Run(tc.body, func(t *testing.T) { + var input preferencesPatchRequest + if err := json.Unmarshal([]byte(tc.body), &input); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPatch, "/users/me/preferences", nil) + req = req.WithContext(context.WithValue(req.Context(), contextkeys.User, &models.User{ID: 7})) + stub := &calendarPreferencesStub{} + _, err := updatePreferences(stub)(req, input) + if tc.status == 0 && err != nil { + t.Fatal(err) + } + if tc.status != 0 { + e, ok := err.(*Error) + if !ok || e.Status != tc.status { + t.Fatalf("expected status %d, got %v", tc.status, err) + } + } + if stub.patch.CalendarSet != tc.set || stub.patch.Calendar != tc.value { + t.Fatalf("unexpected patch: %+v", stub.patch) + } + }) + } +} diff --git a/internal/restapi/v2/preferences.go b/internal/restapi/v2/preferences.go index 96caaa44a..7c9bbd8e4 100644 --- a/internal/restapi/v2/preferences.go +++ b/internal/restapi/v2/preferences.go @@ -16,6 +16,7 @@ func registerPreferenceRoutes(builder *routeBuilder, preferences preferencesAppl } type preferencesDTO struct { + Calendar string `json:"calendar"` ColorMode string `json:"color_mode"` ThemeID *int `json:"theme_id"` Theme *models.Theme `json:"theme"` @@ -37,6 +38,7 @@ type tuiPreferencesPatch struct { } type preferencesPatchRequest struct { + Calendar Optional[string] `json:"calendar"` ColorMode Optional[string] `json:"color_mode"` ThemeID Optional[int] `json:"theme_id"` TUI Optional[tuiPreferencesPatch] `json:"tui"` @@ -73,9 +75,13 @@ func updatePreferences(preferences preferencesApplication) jsonOperation[prefere return preferencesDTO{}, newError(http.StatusBadRequest, "invalid_request", "color_mode cannot be null") } patch := services.UserPreferencesPatch{ + CalendarSet: input.Calendar.Set, Calendar: input.Calendar.Value, ColorModeSet: input.ColorMode.Set, ColorMode: input.ColorMode.Value, ThemeIDSet: input.ThemeID.Set, } + if input.Calendar.Null { + patch.Calendar = "auto" + } if input.ThemeID.Set && !input.ThemeID.Null { patch.ThemeID = &input.ThemeID.Value } @@ -141,7 +147,7 @@ func preferencesFromSnapshot(snapshot services.UserPreferencesSnapshot) preferen savedFilters = map[string]any{} } return preferencesDTO{ - ColorMode: snapshot.ColorMode, ThemeID: snapshot.ThemeID, + Calendar: snapshot.Calendar, ColorMode: snapshot.ColorMode, ThemeID: snapshot.ThemeID, Theme: snapshot.Theme, TUI: snapshot.TUI, ViewState: viewState, SavedFilters: savedFilters, DueDateRemindersEnabled: snapshot.DueDateRemindersEnabled, @@ -152,6 +158,10 @@ func preferencesFromSnapshot(snapshot services.UserPreferencesSnapshot) preferen func preferenceMutationError(err error) error { switch { + case errors.Is(err, repository.ErrConcurrentUpdate): + return newError(http.StatusConflict, "conflict", "Preferences changed concurrently; retry") + case errors.Is(err, services.ErrInvalidCalendar): + return newError(http.StatusBadRequest, "invalid_request", err.Error()) case errors.Is(err, services.ErrInvalidColorMode): return newError(http.StatusBadRequest, "invalid_request", "color_mode must be light, dark, or system") case errors.Is(err, services.ErrInvalidReminderDays): diff --git a/internal/services/calendar_preferences_test.go b/internal/services/calendar_preferences_test.go new file mode 100644 index 000000000..26c491390 --- /dev/null +++ b/internal/services/calendar_preferences_test.go @@ -0,0 +1,105 @@ +package services + +import ( + "errors" + "fmt" + "path/filepath" + "sync/atomic" + "testing" + "time" + "windshift/internal/database" + "windshift/internal/models" + "windshift/internal/repository" +) + +func TestCalendarPreferenceDefaults(t *testing.T) { + for _, tc := range []struct{ input, want string }{{"", "auto"}, {"unknown", "auto"}, {"gregory", "gregory"}, {"persian", "persian"}} { + if got := normalizeCalendar(tc.input); got != tc.want { + t.Errorf("normalizeCalendar(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestCalendarPreferenceConcurrentFirstWrites(t *testing.T) { + db, err := database.NewSQLiteDBWithPoolSizes(filepath.Join(t.TempDir(), "prefs.db"), 2, 1) + if err != nil { + t.Fatal(err) + } + defer db.Close() + _, err = db.ExecWrite("CREATE TABLE user_preferences (user_id INTEGER UNIQUE, preferences TEXT NOT NULL, created_at DATETIME, updated_at DATETIME)") + if err != nil { + t.Fatal(err) + } + service := &UserPreferencesService{prefsRepo: repository.NewUserPreferencesRepository(db)} + ready := make(chan struct{}, 2) + release := make(chan struct{}) + errors := make(chan error, 2) + var calls atomic.Int32 + update := func(fn func(*models.UserPreferencesData)) { + errors <- service.updateData(7, func(p *models.UserPreferencesData) error { + if calls.Add(1) <= 2 { + ready <- struct{}{} + <-release + } + fn(p) + return nil + }) + } + go update(func(p *models.UserPreferencesData) { p.Calendar = "persian" }) + go update(func(p *models.UserPreferencesData) { p.ColorMode = "dark" }) + for range 2 { + select { + case <-ready: + case err := <-errors: + close(release) + t.Fatalf("preparing concurrent write: %v", err) + case <-time.After(10 * time.Second): + close(release) + t.Fatal("timed out preparing concurrent writes") + } + } + close(release) + for range 2 { + if err := <-errors; err != nil { + t.Fatal(err) + } + } + prefs, err := service.loadData(7) + if err != nil { + t.Fatal(err) + } + if prefs.Calendar != "persian" || prefs.ColorMode != "dark" { + t.Fatalf("concurrent updates lost: %+v", prefs) + } +} + +func TestCalendarPreferenceConflictAfterRetries(t *testing.T) { + db, err := database.NewSQLiteDBWithPoolSizes(filepath.Join(t.TempDir(), "prefs.db"), 2, 1) + if err != nil { + t.Fatal(err) + } + defer db.Close() + _, err = db.ExecWrite("CREATE TABLE user_preferences (user_id INTEGER UNIQUE, preferences TEXT NOT NULL, created_at DATETIME, updated_at DATETIME)") + if err != nil { + t.Fatal(err) + } + repo := repository.NewUserPreferencesRepository(db) + if ok, err := repo.CompareAndSwapJSON(7, nil, "{}", time.Now()); !ok || err != nil { + t.Fatalf("seed: %v %v", ok, err) + } + service := &UserPreferencesService{prefsRepo: repo} + calls := 0 + err = service.updateData(7, func(p *models.UserPreferencesData) error { + calls++ + old, err := repo.GetJSON(7) + if err != nil { + return err + } + _, err = repo.CompareAndSwapJSON(7, &old, fmt.Sprintf("{\"color_mode\":\"writer%d\"}", calls), time.Now()) + p.Calendar = "persian" + return err + }) + if !errors.Is(err, repository.ErrConcurrentUpdate) || calls != 3 { + t.Fatalf("got %v after %d calls", err, calls) + } +} diff --git a/internal/services/user_preferences_service.go b/internal/services/user_preferences_service.go index 9ccfa0a15..d85f8ca39 100644 --- a/internal/services/user_preferences_service.go +++ b/internal/services/user_preferences_service.go @@ -13,6 +13,7 @@ import ( // ErrInvalidColorMode is returned when a preference update contains an unsupported color mode. var ErrInvalidColorMode = errors.New("invalid color mode") +var ErrInvalidCalendar = errors.New("calendar must be auto, gregory, or persian") // ErrPreferencesTooLarge is returned when a preferences update would store more // than the bounded per-user JSON blob. @@ -44,6 +45,7 @@ const ( const maxPreferencesBytes = 1 << 20 // 1 MiB type UserPreferencesSnapshot struct { + Calendar string ColorMode string ThemeID *int Theme *models.Theme @@ -60,6 +62,8 @@ type UserPreferencesSnapshot struct { } type UserPreferencesPatch struct { + CalendarSet bool + Calendar string ColorModeSet bool ColorMode string ThemeIDSet bool @@ -123,15 +127,46 @@ func (s *UserPreferencesService) loadData(userID int) (models.UserPreferencesDat return prefs, nil } -func (s *UserPreferencesService) saveData(userID int, prefs models.UserPreferencesData) error { - prefsBytes, err := json.Marshal(prefs) - if err != nil { - return err +func (s *UserPreferencesService) updateData(userID int, update func(*models.UserPreferencesData) error) error { + for attempt := 0; attempt < 3; attempt++ { + raw, err := s.prefsRepo.GetJSON(userID) + var old *string + if err == nil { + old = &raw + } else if !errors.Is(err, repository.ErrNotFound) { + return err + } + var prefs models.UserPreferencesData + if raw != "" { + if err := json.Unmarshal([]byte(raw), &prefs); err != nil { + return err + } + } + if err := update(&prefs); err != nil { + return err + } + data, err := json.Marshal(prefs) + if err != nil { + return err + } + if len(data) > maxPreferencesBytes { + return ErrPreferencesTooLarge + } + saved, err := s.prefsRepo.CompareAndSwapJSON(userID, old, string(data), time.Now()) + if err != nil { + return err + } + if saved { + return nil + } } - if len(prefsBytes) > maxPreferencesBytes { - return ErrPreferencesTooLarge + return repository.ErrConcurrentUpdate +} +func normalizeCalendar(value string) string { + if value == "gregory" || value == "persian" { + return value } - return s.prefsRepo.UpsertJSON(userID, string(prefsBytes), time.Now()) + return "auto" } func (s *UserPreferencesService) GetSnapshot(userID int) (UserPreferencesSnapshot, error) { @@ -160,7 +195,7 @@ func (s *UserPreferencesService) GetSnapshot(userID int) (UserPreferencesSnapsho return UserPreferencesSnapshot{}, err } return UserPreferencesSnapshot{ - ColorMode: colorMode, ThemeID: prefs.ThemeID, + Calendar: normalizeCalendar(prefs.Calendar), ColorMode: colorMode, ThemeID: prefs.ThemeID, Theme: theme, TUI: tui, ViewState: prefs.ViewState, SavedFilters: prefs.SavedFilters, @@ -178,59 +213,64 @@ func normalizeCommentSortOrder(order string) string { } func (s *UserPreferencesService) UpdateSnapshot(userID int, patch UserPreferencesPatch) (UserPreferencesSnapshot, error) { - prefs, err := s.loadData(userID) - if err != nil { - return UserPreferencesSnapshot{}, err - } - if patch.DueDateReminderDaysSet && (patch.DueDateReminderDays < MinReminderDays || patch.DueDateReminderDays > MaxReminderDays) { - return UserPreferencesSnapshot{}, ErrInvalidReminderDays - } - if patch.CommentSortOrderSet && patch.CommentSortOrder != CommentSortOldest && patch.CommentSortOrder != CommentSortNewest { - return UserPreferencesSnapshot{}, ErrInvalidCommentSortOrder + if patch.CalendarSet && patch.Calendar != "auto" && patch.Calendar != "gregory" && patch.Calendar != "persian" { + return UserPreferencesSnapshot{}, ErrInvalidCalendar } - if patch.CommentSortOrderSet { - prefs.CommentSortOrder = patch.CommentSortOrder - } - if patch.ColorModeSet { - if patch.ColorMode != "light" && patch.ColorMode != "dark" && patch.ColorMode != "system" { - return UserPreferencesSnapshot{}, ErrInvalidColorMode + err := s.updateData(userID, func(prefs *models.UserPreferencesData) error { + if patch.CalendarSet { + prefs.Calendar = patch.Calendar } - prefs.ColorMode = patch.ColorMode - } - if patch.ThemeIDSet { - if patch.ThemeID != nil { - if _, err := s.themeRepo.GetByID(*patch.ThemeID); err != nil { - return UserPreferencesSnapshot{}, err + if patch.DueDateReminderDaysSet && (patch.DueDateReminderDays < MinReminderDays || patch.DueDateReminderDays > MaxReminderDays) { + return ErrInvalidReminderDays + } + if patch.CommentSortOrderSet && patch.CommentSortOrder != CommentSortOldest && patch.CommentSortOrder != CommentSortNewest { + return ErrInvalidCommentSortOrder + } + if patch.CommentSortOrderSet { + prefs.CommentSortOrder = patch.CommentSortOrder + } + if patch.ColorModeSet { + if patch.ColorMode != "light" && patch.ColorMode != "dark" && patch.ColorMode != "system" { + return ErrInvalidColorMode } + prefs.ColorMode = patch.ColorMode } - prefs.ThemeID = patch.ThemeID - } - if patch.TUIThemeSet || patch.TUISplitRatioSet || patch.LastWorkspaceIDSet { - tui := models.UserTUIPreferences{} - if prefs.TUI != nil { - tui = *prefs.TUI + if patch.ThemeIDSet { + if patch.ThemeID != nil { + if _, err := s.themeRepo.GetByID(*patch.ThemeID); err != nil { + return err + } + } + prefs.ThemeID = patch.ThemeID } - if patch.TUIThemeSet { - tui.Theme = patch.TUITheme + if patch.TUIThemeSet || patch.TUISplitRatioSet || patch.LastWorkspaceIDSet { + tui := models.UserTUIPreferences{} + if prefs.TUI != nil { + tui = *prefs.TUI + } + if patch.TUIThemeSet { + tui.Theme = patch.TUITheme + } + if patch.TUISplitRatioSet { + tui.SplitRatio = patch.TUISplitRatio + } + if patch.LastWorkspaceIDSet { + tui.LastWorkspaceID = patch.LastWorkspaceID + } + prefs.TUI = &tui } - if patch.TUISplitRatioSet { - tui.SplitRatio = patch.TUISplitRatio + if prefs.TUI != nil { + normalizeTUIPreferences(userID, s.permissionService, prefs.TUI) } - if patch.LastWorkspaceIDSet { - tui.LastWorkspaceID = patch.LastWorkspaceID + if patch.ViewStateSet { + prefs.ViewState = mergeViewState(prefs.ViewState, patch.ViewState) } - prefs.TUI = &tui - } - if prefs.TUI != nil { - normalizeTUIPreferences(userID, s.permissionService, prefs.TUI) - } - if patch.ViewStateSet { - prefs.ViewState = mergeViewState(prefs.ViewState, patch.ViewState) - } - if patch.SavedFiltersSet { - prefs.SavedFilters = mergeSavedFilters(prefs.SavedFilters, patch.SavedFilters) - } - if err := s.saveData(userID, prefs); err != nil { + if patch.SavedFiltersSet { + prefs.SavedFilters = mergeSavedFilters(prefs.SavedFilters, patch.SavedFilters) + } + return nil + }) + if err != nil { return UserPreferencesSnapshot{}, err } if patch.DueDateRemindersEnabledSet || patch.DueDateReminderDaysSet { @@ -359,15 +399,9 @@ func (s *UserPreferencesService) GetDashboardLayoutSnapshot(userID int) (models. // UpdateDashboardLayout stores the user's dashboard layout without clobbering other preferences. func (s *UserPreferencesService) UpdateDashboardLayout(userID int, layout models.UserDashboardLayout) error { - prefs, err := s.loadData(userID) - if err != nil { - return err - } - prefs.DashboardLayout = &layout - return s.saveData(userID, prefs) + return s.updateData(userID, func(prefs *models.UserPreferencesData) error { prefs.DashboardLayout = &layout; return nil }) } -// GetTUI returns the user's SSH TUI preferences (zero value when unset). func (s *UserPreferencesService) GetTUI(userID int) (models.UserTUIPreferences, error) { prefs, err := s.loadData(userID) if err != nil { @@ -391,13 +425,7 @@ func (s *UserPreferencesService) GetTUI(userID int) (models.UserTUIPreferences, // or odd preference should never break the TUI. func (s *UserPreferencesService) UpdateTUI(userID int, tui models.UserTUIPreferences) error { normalizeTUIPreferences(userID, s.permissionService, &tui) - - prefs, err := s.loadData(userID) - if err != nil { - return err - } - prefs.TUI = &tui - return s.saveData(userID, prefs) + return s.updateData(userID, func(prefs *models.UserPreferencesData) error { prefs.TUI = &tui; return nil }) } func normalizeTUIPreferences(userID int, permissions *PermissionService, tui *models.UserTUIPreferences) { From 983bfbf63f4374c9bf008877c655e83fd1834154 Mon Sep 17 00:00:00 2001 From: Toofan Mohammadi Date: Mon, 9 Nov 2026 09:44:31 +0330 Subject: [PATCH 03/10] feat(ui): add calendar-aware date formatting and Jalali picker # Conflicts: # frontend/src/lib/pickers/CalendarDateField.svelte # frontend/src/lib/pickers/DatePicker.svelte --- .../src/lib/pickers/CalendarDateField.svelte | 63 ++++- frontend/src/lib/pickers/CalendarGrid.svelte | 27 ++- frontend/src/lib/pickers/DatePicker.svelte | 60 +++-- frontend/src/lib/stores/calendar.spec.js | 227 ++++++++++++++++++ frontend/src/lib/stores/calendar.svelte.js | 16 ++ frontend/src/lib/utils/dateFormatter.js | 22 +- 6 files changed, 389 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/stores/calendar.spec.js create mode 100644 frontend/src/lib/stores/calendar.svelte.js diff --git a/frontend/src/lib/pickers/CalendarDateField.svelte b/frontend/src/lib/pickers/CalendarDateField.svelte index 32eefbfab..f9e9829e2 100644 --- a/frontend/src/lib/pickers/CalendarDateField.svelte +++ b/frontend/src/lib/pickers/CalendarDateField.svelte @@ -7,11 +7,13 @@ * Emits Gregorian `YYYY-MM-DD` strings. */ import { createPopover, melt } from '@melt-ui/svelte'; - import { DateFormatter, parseDate, today } from '@internationalized/date'; + import { DateFormatter, parseDate } from '@internationalized/date'; import Calendar from '@lucide/svelte/icons/calendar'; import X from '@lucide/svelte/icons/x'; import { untrack } from 'svelte'; import { t } from '../stores/i18n.svelte.js'; + import { formatDate } from '../utils/dateFormatter.js'; + import { serverNow } from '../utils/serverClock.js'; import { cn } from '../utils/cn.js'; import { controlSizeClasses } from '../utils/controlSizes.js'; import CalendarGrid from './CalendarGrid.svelte'; @@ -22,8 +24,21 @@ locale = 'fa', min = undefined, max = undefined, + step = undefined, disabled = false, readonly = false, + required = false, + autofocus = false, + style = '', + title = undefined, + ariaDescribedby = undefined, + name = undefined, + inputRef = $bindable(null), + onblur = undefined, + onfocus = undefined, + onkeydown = undefined, + onclick = undefined, + onkeyup = undefined, clearable = true, inline = false, placeholder = undefined, @@ -36,10 +51,13 @@ onchange = undefined } = $props(); + const descriptionId = $props.id(); + const displayFormatter = untrack( () => new DateFormatter(locale, { calendar: calendarId, + timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' @@ -73,7 +91,20 @@ ) ); + function canCommit(nextValue) { + if (disabled || readonly || (nextValue && ((min && nextValue < min) || (max && nextValue > max)))) return false; + const dayStep = Number(step); + return !(nextValue && dayStep > 0 && (Date.parse(nextValue) - Date.parse(min || '1970-01-01')) / 86400000 % dayStep !== 0); + } + + let validationMessage = $state(''); + $effect(() => { + void [value, readonly, disabled, min, max, step, required]; + validationMessage = ''; + }); function commit(nextValue) { + if (!canCommit(nextValue)) return; + validationMessage = ''; value = nextValue; onchange?.(nextValue); $open = false; @@ -89,6 +120,15 @@ {#if label} {label} {/if} + {#if name || required} + { event.preventDefault(); validationMessage = event.currentTarget.validationMessage; inputRef?.focus(); }} /> + {/if} + {#if required} + {t('validation.required')} + {/if} + {#if validationMessage} + {validationMessage} + {/if}
{#if inline}
{:else} + - {#if clearable && value} + {#if clearable && value && !readonly && !disabled} {:else} +{/if} +
item.label} /> + + + item.value} + getLabel={(item) => item.label} + /> +
diff --git a/frontend/src/lib/stores/collectionPreferences.svelte.js b/frontend/src/lib/stores/collectionPreferences.svelte.js index 951272e9c..52716f76f 100644 --- a/frontend/src/lib/stores/collectionPreferences.svelte.js +++ b/frontend/src/lib/stores/collectionPreferences.svelte.js @@ -1,4 +1,5 @@ import { userPreferences } from '../api/users.js'; +import { setCalendarPreference } from './calendar.svelte.js'; // Per-user collection view configuration and other top-level user // preferences, stored in user_preferences so they follow the user across @@ -21,6 +22,7 @@ class CollectionPreferencesStore { /** @type {'oldest' | 'newest'} preferred comment thread order */ commentSortOrder = $state('oldest'); loaded = $state(false); + loadError = $state(false); #userId = null; #loadPromise = null; @@ -43,6 +45,8 @@ class CollectionPreferencesStore { if (this.loaded) return Promise.resolve(); if (this.#loadPromise) return this.#loadPromise; + this.loadError = false; + const generation = this.#generation; const promise = userPreferences .get({ timeout }) @@ -50,10 +54,13 @@ class CollectionPreferencesStore { if (generation !== this.#generation) return; this.viewState = prefs?.view_state ?? {}; this.savedFilters = prefs?.saved_filters ?? {}; + setCalendarPreference(prefs?.calendar); this.commentSortOrder = prefs?.comment_sort_order === 'newest' ? 'newest' : 'oldest'; this.loaded = true; }) .catch((error) => { + if (generation !== this.#generation) return; + this.loadError = true; console.warn('[collectionPreferences] failed to load preferences', error); }) .finally(() => { @@ -66,10 +73,12 @@ class CollectionPreferencesStore { /** Clear the cache on logout or user change. */ reset() { this.#generation += 1; + setCalendarPreference('auto'); this.viewState = {}; this.savedFilters = {}; this.commentSortOrder = 'oldest'; this.loaded = false; + this.loadError = false; this.#userId = null; this.#loadPromise = null; this.#dirtyViewScopes.clear(); From c71dd17619df01bb5ac097edfaacfdf6f86de256 Mon Sep 17 00:00:00 2001 From: Toofan Mohammadi Date: Mon, 9 Nov 2026 09:17:50 +0330 Subject: [PATCH 05/10] feat(ui): route date inputs through calendar-aware picker --- frontend/src/lib/components/Input.svelte | 13 +++++++++---- frontend/src/lib/editors/RecurrenceEditor.svelte | 9 ++++++++- .../src/lib/features/agents/AgentCatalog.svelte | 3 ++- .../src/lib/features/items/ItemIncidentPanel.svelte | 3 ++- .../src/lib/features/items/ZammadItemPanel.svelte | 2 +- .../src/lib/features/testing/TestTemplates.svelte | 3 ++- .../src/lib/features/time/WeeklyCalendar.svelte | 7 +++---- frontend/src/lib/openui/querySpec.js | 6 ++++-- frontend/src/lib/portal/PortalKBArticle.svelte | 3 ++- frontend/src/lib/settings/RecurrenceDetail.svelte | 3 ++- 10 files changed, 35 insertions(+), 17 deletions(-) diff --git a/frontend/src/lib/components/Input.svelte b/frontend/src/lib/components/Input.svelte index be55e4dbc..5a91b8fd2 100644 --- a/frontend/src/lib/components/Input.svelte +++ b/frontend/src/lib/components/Input.svelte @@ -115,21 +115,26 @@ {name} {min} {max} + {step} {disabled} {required} + {autofocus} readonly={readonly} {placeholder} {size} class={className} {dataTestid} {ariaLabel} + {ariaDescribedby} + {style} + {title} + {onclick} + {onkeyup} {onkeydown} {onblur} {onfocus} - onchange={(next) => { - // Mirror the native input shape: consumers read either target.value or - // currentTarget.value. - const synthetic = { target: { value: next }, currentTarget: { value: next } }; + onchange={(next, event) => { + const synthetic = event || { target: { value: next }, currentTarget: { value: next } }; oninput?.(synthetic); onchange?.(synthetic); }} diff --git a/frontend/src/lib/editors/RecurrenceEditor.svelte b/frontend/src/lib/editors/RecurrenceEditor.svelte index 692a62f5e..f313a93ce 100644 --- a/frontend/src/lib/editors/RecurrenceEditor.svelte +++ b/frontend/src/lib/editors/RecurrenceEditor.svelte @@ -1,5 +1,8 @@ diff --git a/frontend/src/lib/features/items/ZammadItemPanel.svelte b/frontend/src/lib/features/items/ZammadItemPanel.svelte index 73cb81278..5c2cfc6d6 100644 --- a/frontend/src/lib/features/items/ZammadItemPanel.svelte +++ b/frontend/src/lib/features/items/ZammadItemPanel.svelte @@ -734,7 +734,7 @@
{link.connection_name}
{t('zammad.group')}: {link.group_name || t('zammad.unknown')}
{t('zammad.owner')}: {link.owner_name || t('zammad.unassignedOwner')}
-
{link.last_synced_at ? t('zammad.lastSynced', { time: new Date(link.last_synced_at).toLocaleString() }) : t('zammad.notSynced')}
+
{link.last_synced_at ? t('zammad.lastSynced', { time: formatDateTimeLocale(link.last_synced_at, timezone) }) : t('zammad.notSynced')}
diff --git a/frontend/src/lib/features/testing/TestTemplates.svelte b/frontend/src/lib/features/testing/TestTemplates.svelte index 4eb9df920..9c8edc722 100644 --- a/frontend/src/lib/features/testing/TestTemplates.svelte +++ b/frontend/src/lib/features/testing/TestTemplates.svelte @@ -8,6 +8,7 @@ import { errorToast } from '../../stores/toasts.svelte.js'; import IconFiles from '@tabler/icons-svelte-runes/icons/files'; import { escapeHtml } from '../../utils/sanitize.ts'; + import { formatAuthenticatedDateTime } from '../../utils/authenticatedDateFormatter.js'; import Button from '../../components/Button.svelte'; import Input from '../../components/Input.svelte'; import Select from '../../components/Select.svelte'; @@ -65,7 +66,7 @@ { key: 'created_at', label: t('common.created'), - render: (template) => template.created_at ? new Date(template.created_at).toLocaleString() : '-' + render: (template) => template.created_at ? formatAuthenticatedDateTime(template.created_at) : '-' }, { key: 'actions', label: t('common.actions'), width: 'w-20', align: 'text-right' } ]); diff --git a/frontend/src/lib/features/time/WeeklyCalendar.svelte b/frontend/src/lib/features/time/WeeklyCalendar.svelte index d45c6f328..b3f323c3a 100644 --- a/frontend/src/lib/features/time/WeeklyCalendar.svelte +++ b/frontend/src/lib/features/time/WeeklyCalendar.svelte @@ -31,7 +31,7 @@ // Get current date and week let currentDate = $state(new Date()); let currentWeekStart = $state(new Date()); - let weekDays = $state([]); + let weekDays = $derived(generateWeekDays(currentWeekStart)); let workItems = $state({}); // Store scheduled work items for each day let showTasksSidebar = $state(false); let assignedWorkItems = $state([]); // Real work items assigned to user @@ -304,9 +304,9 @@ days.push({ date: new Date(date), dayName: formatDateWithOptions(date, { weekday: 'short' }), - dayNumber: date.getDate(), + dayNumber: formatDateWithOptions(date, { day: 'numeric' }), isToday: isToday(date), - isCurrentMonth: date.getMonth() === currentDate.getMonth(), + isCurrentMonth: formatDateWithOptions(date, { year: 'numeric', month: 'numeric' }) === formatDateWithOptions(currentDate, { year: 'numeric', month: 'numeric' }), dateKey: formatDateKey(date) }); } @@ -343,7 +343,6 @@ const newWeekStart = new Date(currentWeekStart); newWeekStart.setDate(currentWeekStart.getDate() + (direction * 7)); currentWeekStart = newWeekStart; - weekDays = generateWeekDays(currentWeekStart); loadScheduledItems(); } diff --git a/frontend/src/lib/openui/querySpec.js b/frontend/src/lib/openui/querySpec.js index c208e6478..356aa30b0 100644 --- a/frontend/src/lib/openui/querySpec.js +++ b/frontend/src/lib/openui/querySpec.js @@ -5,6 +5,8 @@ // internal/customwidgets: a slot carries only a CQL string, a sort, a limit, // and (for assets) a set ID. Scope is never part of the spec. +import { formatDateSimple } from '../utils/dateFormatter.js'; + export const DEFAULT_LIMIT = 100; export const MAX_LIMIT = 1000; @@ -115,10 +117,10 @@ export function fieldValue(row, path) { /** Format a value for display in a table cell. */ export function formatValue(value) { if (value === null || value === undefined) return '—'; - if (value instanceof Date) return value.toLocaleDateString(); + if (value instanceof Date) return formatDateSimple(value); if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) { const date = new Date(value); - if (!Number.isNaN(date.getTime())) return date.toLocaleDateString(); + if (!Number.isNaN(date.getTime())) return formatDateSimple(date); } if (typeof value === 'boolean') return value ? 'Yes' : 'No'; return String(value); diff --git a/frontend/src/lib/portal/PortalKBArticle.svelte b/frontend/src/lib/portal/PortalKBArticle.svelte index 53f356e19..380d8ede8 100644 --- a/frontend/src/lib/portal/PortalKBArticle.svelte +++ b/frontend/src/lib/portal/PortalKBArticle.svelte @@ -5,6 +5,7 @@ import { portal } from '../api/portal.js'; import { navigate } from '../router.js'; import { portalCustomizationStore as portalStore } from '../stores/portal.svelte.js'; + import { formatDateWithOptions } from '../utils/dateFormatter.js'; import StateDisplay from '../components/StateDisplay.svelte'; import LazyMilkdownEditor from '../editors/LazyMilkdownEditor.svelte'; @@ -87,7 +88,7 @@ {#if page.updated_at}

Last updated - {new Date(page.updated_at).toLocaleDateString(undefined, { + {formatDateWithOptions(new Date(page.updated_at), { year: 'numeric', month: 'long', day: 'numeric', diff --git a/frontend/src/lib/settings/RecurrenceDetail.svelte b/frontend/src/lib/settings/RecurrenceDetail.svelte index c4d477802..c13d453e3 100644 --- a/frontend/src/lib/settings/RecurrenceDetail.svelte +++ b/frontend/src/lib/settings/RecurrenceDetail.svelte @@ -1,6 +1,7 @@