diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 1153fdeae..a9625f19b 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -60,6 +60,9 @@ jobs: - name: Run Svelte typecheck run: npm run typecheck + - name: Run frontend tests + run: npm run test:run + build: name: Build runs-on: ubuntu-latest diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index dad1c9a8f..0876c2feb 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -62,6 +62,9 @@ jobs: - name: Run Go lint and architecture guards run: make lint + - name: Run preference and calendar tests + run: go test ./internal/services ./internal/repository ./internal/restapi/v2 ./internal/i18n + govulncheck: name: Go Vulnerability Check runs-on: ubuntu-latest 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/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..99167ed5f 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/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..9a202868b 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/pl/auth.js b/frontend/src/lib/locales/pl/auth.js index 963d185e0..e16e50e1b 100644 --- a/frontend/src/lib/locales/pl/auth.js +++ b/frontend/src/lib/locales/pl/auth.js @@ -125,6 +125,12 @@ export default { timezoneHint: 'Używana do wyświetlania dat i godzin w Twojej lokalnej strefie czasowej', language: 'Język', languageHint: 'Preferowany język interfejsu aplikacji', + calendar: 'Kalendarz', + calendarHint: 'Wybierz kalendarz używany do wyświetlania dat', + calendarAutomatic: 'Automatyczny', + calendarSaveFailed: 'Język i strefa czasowa zostały zapisane, ale kalendarza nie udało się zapisać.', + gregorian: 'Gregoriański', + persian: 'Perski (Jalali)', saveSettings: 'Zapisz ustawienia', settingsSaved: 'Ustawienia zapisane pomyślnie', notificationsTab: 'Powiadomienia', diff --git a/frontend/src/lib/locales/pl/ui.js b/frontend/src/lib/locales/pl/ui.js index fd28056eb..1498d6d2c 100644 --- a/frontend/src/lib/locales/pl/ui.js +++ b/frontend/src/lib/locales/pl/ui.js @@ -575,6 +575,7 @@ export default { }, recurrence: { + gregorianRuleHint: 'Miesięczne i roczne reguły powtarzania korzystają z kalendarza gregoriańskiego.', title: 'Powtarzanie', description: 'Zarządzaj regułami powtarzających się zadań', frequency: 'Częstotliwość', 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/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/pickers/CalendarDateField.svelte b/frontend/src/lib/pickers/CalendarDateField.svelte index 32eefbfab..49320a84b 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} -
{$headingValue}
+
{calendarHeading}
{:else} +{/if} +
item.label} /> + + + item.value} + getLabel={(item) => item.label} + /> +
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 @@