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}
{:else}
+