diff --git a/frontend/scripts/check-i18n.js b/frontend/scripts/check-i18n.js index 06b5d8f23..ebe704855 100644 --- a/frontend/scripts/check-i18n.js +++ b/frontend/scripts/check-i18n.js @@ -131,6 +131,12 @@ async function extractSourceKeys() { for (const match of matches) { keys.add(match[1]); } + const metadataMatches = content.matchAll( + /\b(?:name|description|label|title|subtitle)Key:\s*['"]([a-zA-Z][a-zA-Z0-9_.]+)['"]/g + ); + for (const match of metadataMatches) { + keys.add(match[1]); + } } return keys; diff --git a/frontend/src/lib/layout/DashboardCustomizationSidebar.svelte b/frontend/src/lib/layout/DashboardCustomizationSidebar.svelte index 78027d114..8cb0834ad 100644 --- a/frontend/src/lib/layout/DashboardCustomizationSidebar.svelte +++ b/frontend/src/lib/layout/DashboardCustomizationSidebar.svelte @@ -29,25 +29,26 @@ const categories = [ { id: dashboardWidgetCategories.ACTIVITY, - name: 'Activity', + nameKey: 'dashboard.customization.activity.name', icon: Clock, - description: 'Briefings, activity streams, and notifications', + descriptionKey: 'dashboard.customization.activity.description', }, { id: dashboardWidgetCategories.WORK, - name: 'Work', + nameKey: 'dashboard.customization.work.name', icon: CheckSquare, - description: 'Items, milestones, and things assigned to you', + descriptionKey: 'dashboard.customization.work.description', }, { id: dashboardWidgetCategories.NAVIGATION, - name: 'Navigation', + nameKey: 'dashboard.customization.navigation.name', icon: Compass, - description: 'Quick access to workspaces', + descriptionKey: 'dashboard.customization.navigation.description', }, ]; let currentWidgets = $derived(getDashboardWidgetsByCategory(activeCategory)); + let currentCategory = $derived(categories.find((category) => category.id === activeCategory)); function handleKeydown(event) { if (event.key === 'Escape' && isOpen) { @@ -90,7 +91,8 @@ if (!isActive) e.currentTarget.style.cssText = 'color: var(--ds-text-subtle);'; }} onclick={() => (activeCategory = category.id)} - title={category.name} + title={t(category.nameKey)} + aria-label={t(category.nameKey)} > @@ -100,8 +102,8 @@
c.id === activeCategory)?.name || 'Widgets'} - subtitle={categories.find((c) => c.id === activeCategory)?.description || ''} + title={currentCategory ? t(currentCategory.nameKey) : t('dashboard.customization.widgets')} + subtitle={currentCategory ? t(currentCategory.descriptionKey) : ''} onClose={() => (isOpen = false)} /> @@ -130,14 +132,14 @@
-

{widget.name}

- {widget.description} +

{t(widget.nameKey)}

+ {t(widget.descriptionKey)}
- {widget.category} + {t(`dashboard.customization.${widget.category}.name`)} {t('widgets.defaultWidth', { @@ -163,7 +165,8 @@ style="background-color: var(--ds-background-neutral); border: 1px solid var(--ds-border);" >

- Tip: Drag widgets from here into any section on your dashboard. + {t('dashboard.customization.tipLabel')}: + {t('dashboard.customization.tip')}

diff --git a/frontend/src/lib/locales/ar/dashboard.js b/frontend/src/lib/locales/ar/dashboard.js new file mode 100644 index 000000000..04ce72a2a --- /dev/null +++ b/frontend/src/lib/locales/ar/dashboard.js @@ -0,0 +1,118 @@ +export default { + dashboard: { + salutation: { + withName: '{salutation}، {name}!', + withoutName: '{salutation}!', + }, + sections: { + yourDay: { + title: 'يومك', + subtitle: 'نظرة سريعة على ما يحتاج إلى انتباهك', + }, + work: { + title: 'العمل', + subtitle: 'قائمتك الشخصية والعناصر المسندة إليك', + }, + workspaces: { + title: 'مساحات العمل', + subtitle: 'تابع من حيث توقفت', + }, + }, + widgetCatalog: { + dailyBriefing: { + name: 'الموجز اليومي', + description: 'ملخص مولد بالذكاء الاصطناعي لما يهمك اليوم', + }, + yourActivity: { + name: 'نشاطك', + description: 'العناصر التي عرضتها أو عدلتها أو علقت عليها مؤخراً', + }, + whatsNew: { + name: 'ما الجديد', + description: 'أحدث الإشعارات والتحديثات غير المقروءة', + }, + personalTasks: { + name: 'المهام الشخصية', + description: 'عناصر من قائمة مهامك الشخصية', + }, + savedSearch: { + name: 'بحث محفوظ', + description: 'عرض عناصر العمل من مجموعة محفوظة', + }, + assignedToMe: { + name: 'مسند إليّ', + description: 'العناصر المفتوحة المسندة إليك في جميع مساحات العمل', + }, + watchedItems: { + name: 'العناصر المراقبة', + description: 'العناصر التي تتابعها', + }, + upcomingMilestones: { + name: 'المعالم القادمة', + description: 'المعالم ذات التواريخ المستهدفة القريبة', + }, + recentWorkspaces: { + name: 'مساحات العمل الأخيرة', + description: 'مساحات العمل التي زرتها مؤخراً', + }, + quickAccess: { + name: 'الوصول السريع', + description: 'روابط سريعة إلى مساحات العمل المتاحة لك', + }, + }, + customization: { + widgets: 'الأدوات', + activity: { + name: 'النشاط', + description: 'الموجزات وتدفقات النشاط والإشعارات', + }, + work: { + name: 'العمل', + description: 'العناصر والمعالم والمهام المسندة إليك', + }, + navigation: { + name: 'التنقل', + description: 'وصول سريع إلى مساحات العمل', + }, + tipLabel: 'تلميح', + tip: 'اسحب الأدوات من هنا إلى أي قسم في لوحة المعلومات.', + }, + editor: { + newSection: 'قسم جديد', + deleteSectionConfirm: 'هل تريد حذف هذا القسم؟ ستتم إزالة جميع الأدوات الموجودة فيه.', + doneEditing: 'إنهاء التحرير', + customize: 'تخصيص', + editModeDescription: 'وضع التحرير: أضف الأقسام والأدوات أو أعد تسميتها أو ترتيبها أو احذفها', + addSection: 'إضافة قسم', + sectionLabel: 'قسم لوحة المعلومات', + sectionTitlePlaceholder: 'عنوان القسم', + sectionSubtitlePlaceholder: 'العنوان الفرعي (اختياري)', + renameSection: 'إعادة تسمية القسم', + deleteSection: 'حذف القسم', + unknownWidgetType: 'نوع أداة غير معروف: {type}', + noWidgets: 'لا توجد أدوات في هذا القسم بعد', + addWidgetsHint: 'اختر تخصيص لإضافة أدوات', + noSections: 'لم يتم إعداد أي أقسام', + addSectionsHint: 'اختر تحرير لإضافة أقسام إلى لوحة المعلومات', + }, + states: { + assignedLoadError: 'تعذر تحميل العناصر المسندة إليك', + assignedEmpty: 'لا يوجد شيء مسند إليك حالياً', + personalTasksLoadError: 'تعذر تحميل مهامك الشخصية', + personalTasksEmpty: 'قائمة مهامك الشخصية فارغة', + dailyBriefingUnavailable: 'موجزك اليومي غير متاح حالياً. يعتمد على تكامل للذكاء الاصطناعي. إذا أعددت تكاملاً للتو، فحاول مرة أخرى بعد قليل.', + updatedAt: 'تم التحديث في {time}', + priorityLabel: 'الأولوية: {priority}', + noWorkspaces: 'لا توجد مساحات عمل بعد', + createWorkspace: 'إنشاء مساحة', + workspaceAvatarAlt: 'الصورة الرمزية لـ {name}', + visited: 'تمت الزيارة {time}', + noUpcomingMilestones: 'لا توجد معالم قادمة', + milestoneProgress: 'تم إنجاز {done} من {total}', + daysOverdue: 'متأخر {days} أيام', + daysLeft: 'متبقي {days} أيام', + watchedItemsEmpty: 'أنت لا تراقب أي عناصر', + workspaceWithId: 'مساحة العمل {id}', + }, + }, +}; diff --git a/frontend/src/lib/locales/ar/index.js b/frontend/src/lib/locales/ar/index.js index 19ae66317..4faeae034 100644 --- a/frontend/src/lib/locales/ar/index.js +++ b/frontend/src/lib/locales/ar/index.js @@ -21,6 +21,7 @@ import workspace from './workspace.js'; import pages from './pages.js'; import teams from './teams.js'; import supplemental from './supplemental.js'; +import dashboard from './dashboard.js'; export default createLocale({ common, @@ -40,4 +41,5 @@ export default createLocale({ pages, teams, supplemental, + dashboard, }); diff --git a/frontend/src/lib/locales/de/dashboard.js b/frontend/src/lib/locales/de/dashboard.js new file mode 100644 index 000000000..d40147b3f --- /dev/null +++ b/frontend/src/lib/locales/de/dashboard.js @@ -0,0 +1,118 @@ +export default { + dashboard: { + salutation: { + withName: '{salutation}, {name}!', + withoutName: '{salutation}!', + }, + sections: { + yourDay: { + title: 'Ihr Tag', + subtitle: 'Ein schneller Überblick darüber, was Ihre Aufmerksamkeit braucht', + }, + work: { + title: 'Arbeit', + subtitle: 'Ihre persönliche Liste und die Ihnen zugewiesenen Einträge', + }, + workspaces: { + title: 'Arbeitsbereiche', + subtitle: 'Direkt weitermachen', + }, + }, + widgetCatalog: { + dailyBriefing: { + name: 'Täglicher Überblick', + description: 'KI-generierte Zusammenfassung dessen, was heute für Sie wichtig ist', + }, + yourActivity: { + name: 'Ihre Aktivität', + description: 'Kürzlich angesehene, bearbeitete oder kommentierte Einträge', + }, + whatsNew: { + name: 'Neuigkeiten', + description: 'Neueste Benachrichtigungen und ungelesene Aktualisierungen', + }, + personalTasks: { + name: 'Persönliche Aufgaben', + description: 'Einträge aus Ihrer persönlichen Aufgabenliste', + }, + savedSearch: { + name: 'Gespeicherte Suche', + description: 'Einträge aus einer gespeicherten Sammlung anzeigen', + }, + assignedToMe: { + name: 'Mir zugewiesen', + description: 'Ihnen zugewiesene offene Einträge aus allen Arbeitsbereichen', + }, + watchedItems: { + name: 'Beobachtete Einträge', + description: 'Einträge, denen Sie folgen', + }, + upcomingMilestones: { + name: 'Anstehende Meilensteine', + description: 'Meilensteine mit nahendem Zieldatum', + }, + recentWorkspaces: { + name: 'Letzte Arbeitsbereiche', + description: 'Kürzlich besuchte Arbeitsbereiche', + }, + quickAccess: { + name: 'Schnellzugriff', + description: 'Direkte Verknüpfungen zu erreichbaren Arbeitsbereichen', + }, + }, + customization: { + widgets: 'Widgets', + activity: { + name: 'Aktivität', + description: 'Überblicke, Aktivitätsverläufe und Benachrichtigungen', + }, + work: { + name: 'Arbeit', + description: 'Einträge, Meilensteine und Ihnen zugewiesene Aufgaben', + }, + navigation: { + name: 'Navigation', + description: 'Schnellzugriff auf Arbeitsbereiche', + }, + tipLabel: 'Tipp', + tip: 'Ziehen Sie Widgets von hier in einen beliebigen Bereich Ihres Dashboards.', + }, + editor: { + newSection: 'Neuer Bereich', + deleteSectionConfirm: 'Diesen Bereich löschen? Alle darin enthaltenen Widgets werden entfernt.', + doneEditing: 'Bearbeitung beenden', + customize: 'Anpassen', + editModeDescription: 'Bearbeitungsmodus: Bereiche und Widgets hinzufügen, umbenennen, neu anordnen oder löschen', + addSection: 'Bereich hinzufügen', + sectionLabel: 'Dashboard-Bereich', + sectionTitlePlaceholder: 'Bereichstitel', + sectionSubtitlePlaceholder: 'Untertitel (optional)', + renameSection: 'Bereich umbenennen', + deleteSection: 'Bereich löschen', + unknownWidgetType: 'Unbekannter Widget-Typ: {type}', + noWidgets: 'Dieser Bereich enthält noch keine Widgets', + addWidgetsHint: 'Wählen Sie Anpassen, um Widgets hinzuzufügen', + noSections: 'Keine Bereiche konfiguriert', + addSectionsHint: 'Wählen Sie Bearbeiten, um Bereiche zum Dashboard hinzuzufügen', + }, + states: { + assignedLoadError: 'Die Ihnen zugewiesenen Einträge konnten nicht geladen werden', + assignedEmpty: 'Ihnen ist derzeit nichts zugewiesen', + personalTasksLoadError: 'Ihre persönlichen Aufgaben konnten nicht geladen werden', + personalTasksEmpty: 'Ihre persönliche Aufgabenliste ist leer', + dailyBriefingUnavailable: 'Ihr täglicher Überblick ist derzeit nicht verfügbar. Er benötigt eine KI-Integration. Falls Sie gerade eine eingerichtet haben, versuchen Sie es in Kürze erneut.', + updatedAt: 'Aktualisiert: {time}', + priorityLabel: 'Priorität: {priority}', + noWorkspaces: 'Noch keine Arbeitsbereiche', + createWorkspace: 'Arbeitsbereich erstellen', + workspaceAvatarAlt: 'Avatar von {name}', + visited: 'besucht {time}', + noUpcomingMilestones: 'Keine anstehenden Meilensteine', + milestoneProgress: '{done} von {total} erledigt', + daysOverdue: '{days} Tage überfällig', + daysLeft: 'Noch {days} Tage', + watchedItemsEmpty: 'Sie beobachten derzeit keine Einträge', + workspaceWithId: 'Arbeitsbereich {id}', + }, + }, +}; diff --git a/frontend/src/lib/locales/de/index.js b/frontend/src/lib/locales/de/index.js index 809106aec..18317899a 100644 --- a/frontend/src/lib/locales/de/index.js +++ b/frontend/src/lib/locales/de/index.js @@ -23,6 +23,7 @@ import pages from './pages.js'; import supplemental from './supplemental.js'; import quality from './quality.js'; import review from './review.js'; +import dashboard from './dashboard.js'; export default createLocale({ admin, @@ -44,4 +45,5 @@ export default createLocale({ supplemental, quality, review, + dashboard, }); diff --git a/frontend/src/lib/locales/en/dashboard.js b/frontend/src/lib/locales/en/dashboard.js new file mode 100644 index 000000000..0963d6295 --- /dev/null +++ b/frontend/src/lib/locales/en/dashboard.js @@ -0,0 +1,118 @@ +export default { + dashboard: { + salutation: { + withName: '{salutation}, {name}!', + withoutName: '{salutation}!', + }, + sections: { + yourDay: { + title: 'Your Day', + subtitle: 'A quick read on what needs your attention', + }, + work: { + title: 'Work', + subtitle: 'Your personal list and items assigned to you', + }, + workspaces: { + title: 'Workspaces', + subtitle: 'Jump back in', + }, + }, + widgetCatalog: { + dailyBriefing: { + name: 'Daily Briefing', + description: 'AI-generated summary of what matters to you today', + }, + yourActivity: { + name: 'Your Activity', + description: 'Items you recently viewed, edited, or commented on', + }, + whatsNew: { + name: "What's New", + description: 'Latest notifications and unread updates', + }, + personalTasks: { + name: 'Personal Tasks', + description: 'Items from your personal todo list', + }, + savedSearch: { + name: 'Saved Search', + description: 'Display work items from a saved collection', + }, + assignedToMe: { + name: 'Assigned to Me', + description: 'Open items assigned to you across all workspaces', + }, + watchedItems: { + name: 'Watched Items', + description: 'Items you are following', + }, + upcomingMilestones: { + name: 'Upcoming Milestones', + description: 'Milestones with approaching target dates', + }, + recentWorkspaces: { + name: 'Recent Workspaces', + description: 'Workspaces you recently visited', + }, + quickAccess: { + name: 'Quick Access', + description: 'Quick links to workspaces you can reach', + }, + }, + customization: { + widgets: 'Widgets', + activity: { + name: 'Activity', + description: 'Briefings, activity streams, and notifications', + }, + work: { + name: 'Work', + description: 'Items, milestones, and things assigned to you', + }, + navigation: { + name: 'Navigation', + description: 'Quick access to workspaces', + }, + tipLabel: 'Tip', + tip: 'Drag widgets from here into any section on your dashboard.', + }, + editor: { + newSection: 'New Section', + deleteSectionConfirm: 'Delete this section? All widgets in this section will be removed.', + doneEditing: 'Done Editing', + customize: 'Customize', + editModeDescription: 'Edit mode: add, rename, reorder, or delete sections and widgets', + addSection: 'Add Section', + sectionLabel: 'Dashboard section', + sectionTitlePlaceholder: 'Section title', + sectionSubtitlePlaceholder: 'Subtitle (optional)', + renameSection: 'Rename section', + deleteSection: 'Delete section', + unknownWidgetType: 'Unknown widget type: {type}', + noWidgets: 'No widgets in this section yet', + addWidgetsHint: 'Select Customize to add widgets', + noSections: 'No sections configured', + addSectionsHint: 'Select Edit to add sections to your dashboard', + }, + states: { + assignedLoadError: "Couldn't load your assigned items", + assignedEmpty: 'Nothing assigned to you right now', + personalTasksLoadError: "Couldn't load your personal tasks", + personalTasksEmpty: 'Your personal todo list is empty', + dailyBriefingUnavailable: "Your daily briefing isn't available right now. It relies on an AI integration. If you've just set one up, check back in a bit.", + updatedAt: 'Updated {time}', + priorityLabel: 'Priority: {priority}', + noWorkspaces: 'No workspaces yet', + createWorkspace: 'Create one', + workspaceAvatarAlt: '{name} avatar', + visited: 'visited {time}', + noUpcomingMilestones: 'No upcoming milestones', + milestoneProgress: '{done} of {total} done', + daysOverdue: '{days} days overdue', + daysLeft: '{days} days left', + watchedItemsEmpty: "You aren't watching any items", + workspaceWithId: 'Workspace {id}', + }, + }, +}; diff --git a/frontend/src/lib/locales/en/index.js b/frontend/src/lib/locales/en/index.js index eac42aba1..8fe81d6d8 100644 --- a/frontend/src/lib/locales/en/index.js +++ b/frontend/src/lib/locales/en/index.js @@ -20,6 +20,7 @@ import ui from './ui.js'; import workflows from './workflows.js'; import workspace from './workspace.js'; import pages from './pages.js'; +import dashboard from './dashboard.js'; export default createLocale({ common, @@ -38,4 +39,5 @@ export default createLocale({ analytics, teams, pages, + dashboard, }); diff --git a/frontend/src/lib/locales/es/dashboard.js b/frontend/src/lib/locales/es/dashboard.js new file mode 100644 index 000000000..ad5997d2b --- /dev/null +++ b/frontend/src/lib/locales/es/dashboard.js @@ -0,0 +1,118 @@ +export default { + dashboard: { + salutation: { + withName: '¡{salutation}, {name}!', + withoutName: '¡{salutation}!', + }, + sections: { + yourDay: { + title: 'Tu día', + subtitle: 'Un resumen rápido de lo que necesita tu atención', + }, + work: { + title: 'Trabajo', + subtitle: 'Tu lista personal y los elementos que tienes asignados', + }, + workspaces: { + title: 'Espacios de trabajo', + subtitle: 'Retoma el trabajo', + }, + }, + widgetCatalog: { + dailyBriefing: { + name: 'Resumen diario', + description: 'Resumen generado por IA de lo que te importa hoy', + }, + yourActivity: { + name: 'Tu actividad', + description: 'Elementos que has visto, editado o comentado recientemente', + }, + whatsNew: { + name: 'Novedades', + description: 'Últimas notificaciones y actualizaciones sin leer', + }, + personalTasks: { + name: 'Tareas personales', + description: 'Elementos de tu lista personal de tareas', + }, + savedSearch: { + name: 'Búsqueda guardada', + description: 'Muestra elementos de una colección guardada', + }, + assignedToMe: { + name: 'Asignados a mí', + description: 'Elementos abiertos que tienes asignados en todos los espacios de trabajo', + }, + watchedItems: { + name: 'Elementos observados', + description: 'Elementos que sigues', + }, + upcomingMilestones: { + name: 'Próximos hitos', + description: 'Hitos cuya fecha objetivo se acerca', + }, + recentWorkspaces: { + name: 'Espacios de trabajo recientes', + description: 'Espacios de trabajo que has visitado recientemente', + }, + quickAccess: { + name: 'Acceso rápido', + description: 'Enlaces rápidos a los espacios de trabajo disponibles', + }, + }, + customization: { + widgets: 'Widgets', + activity: { + name: 'Actividad', + description: 'Resúmenes, flujos de actividad y notificaciones', + }, + work: { + name: 'Trabajo', + description: 'Elementos, hitos y tareas que tienes asignados', + }, + navigation: { + name: 'Navegación', + description: 'Acceso rápido a los espacios de trabajo', + }, + tipLabel: 'Consejo', + tip: 'Arrastra widgets desde aquí a cualquier sección de tu panel.', + }, + editor: { + newSection: 'Nueva sección', + deleteSectionConfirm: '¿Eliminar esta sección? Se quitarán todos los widgets que contiene.', + doneEditing: 'Terminar edición', + customize: 'Personalizar', + editModeDescription: 'Modo de edición: añade, cambia el nombre, reordena o elimina secciones y widgets', + addSection: 'Añadir sección', + sectionLabel: 'Sección del panel', + sectionTitlePlaceholder: 'Título de la sección', + sectionSubtitlePlaceholder: 'Subtítulo (opcional)', + renameSection: 'Cambiar nombre de la sección', + deleteSection: 'Eliminar sección', + unknownWidgetType: 'Tipo de widget desconocido: {type}', + noWidgets: 'Esta sección aún no tiene widgets', + addWidgetsHint: 'Selecciona Personalizar para añadir widgets', + noSections: 'No hay secciones configuradas', + addSectionsHint: 'Selecciona Editar para añadir secciones al panel', + }, + states: { + assignedLoadError: 'No se pudieron cargar los elementos que tienes asignados', + assignedEmpty: 'No tienes nada asignado en este momento', + personalTasksLoadError: 'No se pudieron cargar tus tareas personales', + personalTasksEmpty: 'Tu lista personal de tareas está vacía', + dailyBriefingUnavailable: 'Tu resumen diario no está disponible en este momento. Necesita una integración de IA. Si acabas de configurar una, vuelve a intentarlo en unos instantes.', + updatedAt: 'Actualizado {time}', + priorityLabel: 'Prioridad: {priority}', + noWorkspaces: 'Aún no hay espacios de trabajo', + createWorkspace: 'Crear uno', + workspaceAvatarAlt: 'Avatar de {name}', + visited: 'visitado {time}', + noUpcomingMilestones: 'No hay próximos hitos', + milestoneProgress: '{done} de {total} completados', + daysOverdue: '{days} días de retraso', + daysLeft: 'Quedan {days} días', + watchedItemsEmpty: 'No estás observando ningún elemento', + workspaceWithId: 'Espacio de trabajo {id}', + }, + }, +}; diff --git a/frontend/src/lib/locales/es/index.js b/frontend/src/lib/locales/es/index.js index 543044c69..0924d6e98 100644 --- a/frontend/src/lib/locales/es/index.js +++ b/frontend/src/lib/locales/es/index.js @@ -22,6 +22,7 @@ import pages from './pages.js'; import supplemental from './supplemental.js'; import quality from './quality.js'; import review from './review.js'; +import dashboard from './dashboard.js'; export default createLocale({ common, @@ -43,4 +44,5 @@ export default createLocale({ supplemental, quality, review, + dashboard, }); diff --git a/frontend/src/lib/locales/pt-BR/dashboard.js b/frontend/src/lib/locales/pt-BR/dashboard.js new file mode 100644 index 000000000..88c0d8915 --- /dev/null +++ b/frontend/src/lib/locales/pt-BR/dashboard.js @@ -0,0 +1,118 @@ +export default { + dashboard: { + salutation: { + withName: '{salutation}, {name}!', + withoutName: '{salutation}!', + }, + sections: { + yourDay: { + title: 'Seu dia', + subtitle: 'Uma visão rápida do que precisa da sua atenção', + }, + work: { + title: 'Trabalho', + subtitle: 'Sua lista pessoal e os itens atribuídos a você', + }, + workspaces: { + title: 'Espaços de trabalho', + subtitle: 'Continue de onde parou', + }, + }, + widgetCatalog: { + dailyBriefing: { + name: 'Resumo diário', + description: 'Resumo gerado por IA do que importa para você hoje', + }, + yourActivity: { + name: 'Sua atividade', + description: 'Itens que você viu, editou ou comentou recentemente', + }, + whatsNew: { + name: 'Novidades', + description: 'Notificações recentes e atualizações não lidas', + }, + personalTasks: { + name: 'Tarefas pessoais', + description: 'Itens da sua lista pessoal de tarefas', + }, + savedSearch: { + name: 'Pesquisa salva', + description: 'Exibe itens de uma coleção salva', + }, + assignedToMe: { + name: 'Atribuídos a mim', + description: 'Itens abertos atribuídos a você em todos os espaços de trabalho', + }, + watchedItems: { + name: 'Itens observados', + description: 'Itens que você acompanha', + }, + upcomingMilestones: { + name: 'Próximos marcos', + description: 'Marcos com datas-alvo próximas', + }, + recentWorkspaces: { + name: 'Espaços de trabalho recentes', + description: 'Espaços de trabalho visitados recentemente', + }, + quickAccess: { + name: 'Acesso rápido', + description: 'Links rápidos para os espaços de trabalho disponíveis', + }, + }, + customization: { + widgets: 'Widgets', + activity: { + name: 'Atividade', + description: 'Resumos, fluxos de atividade e notificações', + }, + work: { + name: 'Trabalho', + description: 'Itens, marcos e tarefas atribuídos a você', + }, + navigation: { + name: 'Navegação', + description: 'Acesso rápido aos espaços de trabalho', + }, + tipLabel: 'Dica', + tip: 'Arraste widgets daqui para qualquer seção do seu painel.', + }, + editor: { + newSection: 'Nova seção', + deleteSectionConfirm: 'Excluir esta seção? Todos os widgets nela serão removidos.', + doneEditing: 'Concluir edição', + customize: 'Personalizar', + editModeDescription: 'Modo de edição: adicione, renomeie, reordene ou exclua seções e widgets', + addSection: 'Adicionar seção', + sectionLabel: 'Seção do painel', + sectionTitlePlaceholder: 'Título da seção', + sectionSubtitlePlaceholder: 'Subtítulo (opcional)', + renameSection: 'Renomear seção', + deleteSection: 'Excluir seção', + unknownWidgetType: 'Tipo de widget desconhecido: {type}', + noWidgets: 'Ainda não há widgets nesta seção', + addWidgetsHint: 'Selecione Personalizar para adicionar widgets', + noSections: 'Nenhuma seção configurada', + addSectionsHint: 'Selecione Editar para adicionar seções ao painel', + }, + states: { + assignedLoadError: 'Não foi possível carregar os itens atribuídos a você', + assignedEmpty: 'Nada foi atribuído a você no momento', + personalTasksLoadError: 'Não foi possível carregar suas tarefas pessoais', + personalTasksEmpty: 'Sua lista pessoal de tarefas está vazia', + dailyBriefingUnavailable: 'Seu resumo diário não está disponível no momento. Ele depende de uma integração de IA. Se você acabou de configurar uma, tente novamente em instantes.', + updatedAt: 'Atualizado em {time}', + priorityLabel: 'Prioridade: {priority}', + noWorkspaces: 'Ainda não há espaços de trabalho', + createWorkspace: 'Criar um', + workspaceAvatarAlt: 'Avatar de {name}', + visited: 'visitado {time}', + noUpcomingMilestones: 'Nenhum marco próximo', + milestoneProgress: '{done} de {total} concluídos', + daysOverdue: '{days} dias de atraso', + daysLeft: 'Faltam {days} dias', + watchedItemsEmpty: 'Você não está observando nenhum item', + workspaceWithId: 'Espaço de trabalho {id}', + }, + }, +}; diff --git a/frontend/src/lib/locales/pt-BR/index.js b/frontend/src/lib/locales/pt-BR/index.js index 0bff9ba2d..c947c9530 100644 --- a/frontend/src/lib/locales/pt-BR/index.js +++ b/frontend/src/lib/locales/pt-BR/index.js @@ -21,6 +21,7 @@ import teams from './teams.js'; import pages from './pages.js'; import supplemental from './supplemental.js'; import quality from './quality.js'; +import dashboard from './dashboard.js'; export default createLocale({ common, @@ -41,4 +42,5 @@ export default createLocale({ teams, supplemental, quality, + dashboard, }); diff --git a/frontend/src/lib/locales/zh-CN/dashboard.js b/frontend/src/lib/locales/zh-CN/dashboard.js new file mode 100644 index 000000000..5666da873 --- /dev/null +++ b/frontend/src/lib/locales/zh-CN/dashboard.js @@ -0,0 +1,118 @@ +export default { + dashboard: { + salutation: { + withName: '{salutation},{name}!', + withoutName: '{salutation}!', + }, + sections: { + yourDay: { + title: '你的一天', + subtitle: '快速了解需要你关注的事项', + }, + work: { + title: '工作', + subtitle: '你的个人列表和分配给你的事项', + }, + workspaces: { + title: '工作区', + subtitle: '继续之前的工作', + }, + }, + widgetCatalog: { + dailyBriefing: { + name: '每日简报', + description: '由 AI 生成的今日重点摘要', + }, + yourActivity: { + name: '你的活动', + description: '你最近查看、编辑或评论的事项', + }, + whatsNew: { + name: '最新动态', + description: '最新通知和未读更新', + }, + personalTasks: { + name: '个人任务', + description: '个人待办列表中的事项', + }, + savedSearch: { + name: '已保存的搜索', + description: '显示已保存集合中的工作事项', + }, + assignedToMe: { + name: '分配给我的', + description: '所有工作区中分配给你的未完成事项', + }, + watchedItems: { + name: '关注的事项', + description: '你正在关注的事项', + }, + upcomingMilestones: { + name: '即将到来的里程碑', + description: '目标日期临近的里程碑', + }, + recentWorkspaces: { + name: '最近的工作区', + description: '你最近访问过的工作区', + }, + quickAccess: { + name: '快速访问', + description: '快速打开你有权访问的工作区', + }, + }, + customization: { + widgets: '小部件', + activity: { + name: '活动', + description: '简报、活动记录和通知', + }, + work: { + name: '工作', + description: '事项、里程碑和分配给你的任务', + }, + navigation: { + name: '导航', + description: '快速访问工作区', + }, + tipLabel: '提示', + tip: '将小部件从这里拖到面板中的任意分区。', + }, + editor: { + newSection: '新建分区', + deleteSectionConfirm: '要删除此分区吗?其中的所有小部件都将被移除。', + doneEditing: '完成编辑', + customize: '自定义', + editModeDescription: '编辑模式:添加、重命名、重新排序或删除分区和小部件', + addSection: '添加分区', + sectionLabel: '面板分区', + sectionTitlePlaceholder: '分区标题', + sectionSubtitlePlaceholder: '副标题(可选)', + renameSection: '重命名分区', + deleteSection: '删除分区', + unknownWidgetType: '未知的小部件类型:{type}', + noWidgets: '此分区中还没有小部件', + addWidgetsHint: '选择“自定义”以添加小部件', + noSections: '尚未配置分区', + addSectionsHint: '选择“编辑”以向面板添加分区', + }, + states: { + assignedLoadError: '无法加载分配给你的事项', + assignedEmpty: '目前没有分配给你的事项', + personalTasksLoadError: '无法加载你的个人任务', + personalTasksEmpty: '你的个人待办列表为空', + dailyBriefingUnavailable: '你的每日简报目前不可用。它依赖 AI 集成。如果你刚完成设置,请稍后再试。', + updatedAt: '更新于 {time}', + priorityLabel: '优先级:{priority}', + noWorkspaces: '还没有工作区', + createWorkspace: '创建一个', + workspaceAvatarAlt: '{name} 的头像', + visited: '访问于 {time}', + noUpcomingMilestones: '没有即将到来的里程碑', + milestoneProgress: '已完成 {done}/{total}', + daysOverdue: '逾期 {days} 天', + daysLeft: '剩余 {days} 天', + watchedItemsEmpty: '你尚未关注任何事项', + workspaceWithId: '工作区 {id}', + }, + }, +}; diff --git a/frontend/src/lib/locales/zh-CN/index.js b/frontend/src/lib/locales/zh-CN/index.js index b6c6cf97e..1bf658744 100644 --- a/frontend/src/lib/locales/zh-CN/index.js +++ b/frontend/src/lib/locales/zh-CN/index.js @@ -17,6 +17,7 @@ import teams from './teams.js'; import pages from './pages.js'; import supplemental from './supplemental.js'; import quality from './quality.js'; +import dashboard from './dashboard.js'; export default createLocale({ common, @@ -37,4 +38,5 @@ export default createLocale({ teams, supplemental, quality, + dashboard, }); diff --git a/frontend/src/lib/pages/DashboardOnboarding.svelte b/frontend/src/lib/pages/DashboardOnboarding.svelte index 16527c8ca..82254de20 100644 --- a/frontend/src/lib/pages/DashboardOnboarding.svelte +++ b/frontend/src/lib/pages/DashboardOnboarding.svelte @@ -36,6 +36,14 @@ // Derived: determine which step is active (first incomplete step, admin path only) let activeStep = $derived(workspaceCount === 0 ? 1 : (itemCount === 0 ? 2 : 0)); + let welcomeText = $derived( + userName + ? t('dashboard.salutation.withName', { + salutation: t('onboarding.welcomeTo'), + name: userName, + }) + : t('dashboard.salutation.withoutName', { salutation: t('onboarding.welcomeTo') }) + ); onMount(() => { // Check if user has dismissed the onboarding @@ -121,7 +129,7 @@

- {t('onboarding.welcomeTo')}, {userName}! + {welcomeText}

{#if canCreateWorkspaces} diff --git a/frontend/src/lib/pages/Homepage.svelte b/frontend/src/lib/pages/Homepage.svelte index f06df5f03..aa67b5212 100644 --- a/frontend/src/lib/pages/Homepage.svelte +++ b/frontend/src/lib/pages/Homepage.svelte @@ -21,6 +21,8 @@ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { + getDashboardSectionDisplay, + getDashboardSectionSaveValues, getDashboardWidgetMetadata, } from '../services/dashboardWidgetRegistry.js'; @@ -40,6 +42,15 @@ import SavedSearchWidget from '../widgets/dashboard/SavedSearchWidget.svelte'; let greeting = $derived(homepageStore.greeting); + let greetingText = $derived(greeting ? t(greeting) : ''); + let userName = $derived(authStore.currentUser?.first_name || ''); + let personalizedGreeting = $derived( + greetingText + ? userName + ? t('dashboard.salutation.withName', { salutation: greetingText, name: userName }) + : t('dashboard.salutation.withoutName', { salutation: greetingText }) + : '' + ); let currentDate = $derived(homepageStore.currentDate); let totalWorkspaceCount = $derived(homepageStore.totalWorkspaceCount); let totalItemCount = $derived(homepageStore.totalItemCount); @@ -114,7 +125,7 @@ } async function addSection() { - const created = homepageStore.addSection('New Section', ''); + const created = homepageStore.addSection(t('dashboard.editor.newSection'), ''); editingSectionId = created.id; editingSectionTitle = created.title; editingSectionSubtitle = created.subtitle; @@ -133,18 +144,24 @@ } function startEditingSection(section) { + const display = getDashboardSectionDisplay(section, t); editingSectionId = section.id; - editingSectionTitle = section.title; - editingSectionSubtitle = section.subtitle || ''; + editingSectionTitle = display.title; + editingSectionSubtitle = display.subtitle || ''; isNewSection = false; } function saveSection() { if (!editingSectionId) return; - homepageStore.updateSection(editingSectionId, { + const draft = { title: editingSectionTitle, subtitle: editingSectionSubtitle, - }); + }; + const section = sections.find((candidate) => candidate.id === editingSectionId); + homepageStore.updateSection( + editingSectionId, + section ? getDashboardSectionSaveValues(section, draft, t) : draft + ); editingSectionId = null; isNewSection = false; } @@ -170,7 +187,7 @@ async function handleDeleteSection(sectionId) { const confirmed = await confirm({ title: t('common.delete'), - message: 'Delete this section? All widgets in this section will be removed.', + message: t('dashboard.editor.deleteSectionConfirm'), confirmText: t('common.delete'), cancelText: t('common.cancel'), variant: 'danger', @@ -263,7 +280,8 @@ } function getWidgetTitle(type) { - return getDashboardWidgetMetadata(type)?.name || type; + const metadata = getDashboardWidgetMetadata(type); + return metadata ? t(metadata.nameKey) : type; } function getSectionWidgets(sectionId) { @@ -287,7 +305,7 @@

- {greeting}, {authStore.currentUser?.first_name || 'there'}! + {personalizedGreeting} {currentDate}
@@ -298,14 +316,14 @@ onclick={toggleEditMode} dataTestid="dashboard-edit-toggle" > - {isEditMode ? 'Done Editing' : 'Edit'} + {isEditMode ? t('dashboard.editor.doneEditing') : t('common.edit')}
@@ -317,7 +335,7 @@
- Edit mode: add, rename, reorder, or delete sections and widgets + {t('dashboard.editor.editModeDescription')}
{/if} @@ -363,10 +381,11 @@
{#each sections as section, sectionIndex (section.id)} {@const sectionWidgets = getSectionWidgets(section.id)} + {@const sectionDisplay = getDashboardSectionDisplay(section, t)}
@@ -377,22 +396,22 @@ type="text" bind:value={editingSectionTitle} class="text-lg font-semibold" - placeholder="Section title" + placeholder={t('dashboard.editor.sectionTitlePlaceholder')} onkeydown={handleSectionEditKeydown} dataTestid="dashboard-section-title-input" />
{:else} @@ -401,9 +420,9 @@ id={`dashboard-section-heading-${section.id}`} class="text-lg font-semibold" style="color: var(--ds-text);" - >{section.title} - {#if section.subtitle} -

{section.subtitle}

+ >{sectionDisplay.title} + {#if sectionDisplay.subtitle} +

{sectionDisplay.subtitle}

{/if}
{#if isEditMode} @@ -437,7 +456,8 @@ class="p-2 rounded transition-colors hover:bg-[var(--ds-background-neutral-hovered)]" style="color: var(--ds-text-subtle);" onclick={() => startEditingSection(section)} - title="Rename section" + title={t('dashboard.editor.renameSection')} + aria-label={t('dashboard.editor.renameSection')} > @@ -446,7 +466,8 @@ class="p-2 rounded transition-colors hover:bg-[var(--ds-background-neutral-hovered)]" style="color: var(--ds-text-subtle);" onclick={() => handleDeleteSection(section.id)} - title="Delete section" + title={t('dashboard.editor.deleteSection')} + aria-label={t('dashboard.editor.deleteSection')} > @@ -511,7 +532,7 @@ /> {:else}
- Unknown widget type: {widget.type} + {t('dashboard.editor.unknownWidgetType', { type: widget.type })}
{/if} @@ -519,8 +540,8 @@ {:else}
-

No widgets in this section yet

-

Click "Customize" to add widgets

+

{t('dashboard.editor.noWidgets')}

+

{t('dashboard.editor.addWidgetsHint')}

{/if} @@ -530,8 +551,8 @@ {#if sections.length === 0}
-

No sections configured

-

Click "Edit" to add sections to your dashboard

+

{t('dashboard.editor.noSections')}

+

{t('dashboard.editor.addSectionsHint')}

{/if} diff --git a/frontend/src/lib/services/dashboardWidgetRegistry.js b/frontend/src/lib/services/dashboardWidgetRegistry.js index cab833051..ab591c25e 100644 --- a/frontend/src/lib/services/dashboardWidgetRegistry.js +++ b/frontend/src/lib/services/dashboardWidgetRegistry.js @@ -14,8 +14,8 @@ export const dashboardWidgetRegistry = [ // Activity & news { type: 'daily-briefing', - name: 'Daily Briefing', - description: 'AI-generated summary of what matters to you today', + nameKey: 'dashboard.widgetCatalog.dailyBriefing.name', + descriptionKey: 'dashboard.widgetCatalog.dailyBriefing.description', category: dashboardWidgetCategories.ACTIVITY, icon: 'Sparkles', defaultWidth: 12, @@ -23,8 +23,8 @@ export const dashboardWidgetRegistry = [ }, { type: 'your-activity', - name: 'Your Activity', - description: 'Items you recently viewed, edited, or commented on', + nameKey: 'dashboard.widgetCatalog.yourActivity.name', + descriptionKey: 'dashboard.widgetCatalog.yourActivity.description', category: dashboardWidgetCategories.ACTIVITY, icon: 'Clock', defaultWidth: 8, @@ -32,8 +32,8 @@ export const dashboardWidgetRegistry = [ }, { type: 'whats-new', - name: "What's New", - description: 'Latest notifications and unread updates', + nameKey: 'dashboard.widgetCatalog.whatsNew.name', + descriptionKey: 'dashboard.widgetCatalog.whatsNew.description', category: dashboardWidgetCategories.ACTIVITY, icon: 'Bell', defaultWidth: 4, @@ -43,8 +43,8 @@ export const dashboardWidgetRegistry = [ // Work items { type: 'personal-tasks', - name: 'Personal Tasks', - description: 'Items from your personal todo list', + nameKey: 'dashboard.widgetCatalog.personalTasks.name', + descriptionKey: 'dashboard.widgetCatalog.personalTasks.description', category: dashboardWidgetCategories.WORK, icon: 'ListChecks', defaultWidth: 6, @@ -52,8 +52,8 @@ export const dashboardWidgetRegistry = [ }, { type: 'saved-search', - name: 'Saved Search', - description: 'Display work items from a saved collection', + nameKey: 'dashboard.widgetCatalog.savedSearch.name', + descriptionKey: 'dashboard.widgetCatalog.savedSearch.description', category: dashboardWidgetCategories.WORK, icon: 'Search', defaultWidth: 6, @@ -61,8 +61,8 @@ export const dashboardWidgetRegistry = [ }, { type: 'assigned-to-me', - name: 'Assigned to Me', - description: 'Open items assigned to you across all workspaces', + nameKey: 'dashboard.widgetCatalog.assignedToMe.name', + descriptionKey: 'dashboard.widgetCatalog.assignedToMe.description', category: dashboardWidgetCategories.WORK, icon: 'CheckSquare', defaultWidth: 6, @@ -70,8 +70,8 @@ export const dashboardWidgetRegistry = [ }, { type: 'watched-items', - name: 'Watched Items', - description: 'Items you are following', + nameKey: 'dashboard.widgetCatalog.watchedItems.name', + descriptionKey: 'dashboard.widgetCatalog.watchedItems.description', category: dashboardWidgetCategories.WORK, icon: 'Eye', defaultWidth: 4, @@ -79,8 +79,8 @@ export const dashboardWidgetRegistry = [ }, { type: 'upcoming-milestones', - name: 'Upcoming Milestones', - description: 'Milestones with approaching target dates', + nameKey: 'dashboard.widgetCatalog.upcomingMilestones.name', + descriptionKey: 'dashboard.widgetCatalog.upcomingMilestones.description', category: dashboardWidgetCategories.WORK, icon: 'Target', defaultWidth: 12, @@ -90,8 +90,8 @@ export const dashboardWidgetRegistry = [ // Navigation { type: 'recent-workspaces', - name: 'Recent Workspaces', - description: 'Workspaces you recently visited', + nameKey: 'dashboard.widgetCatalog.recentWorkspaces.name', + descriptionKey: 'dashboard.widgetCatalog.recentWorkspaces.description', category: dashboardWidgetCategories.NAVIGATION, icon: 'Briefcase', defaultWidth: 8, @@ -99,8 +99,8 @@ export const dashboardWidgetRegistry = [ }, { type: 'quick-access', - name: 'Quick Access', - description: 'Quick links to workspaces you can reach', + nameKey: 'dashboard.widgetCatalog.quickAccess.name', + descriptionKey: 'dashboard.widgetCatalog.quickAccess.description', category: dashboardWidgetCategories.NAVIGATION, icon: 'Grip', defaultWidth: 4, @@ -126,33 +126,73 @@ export function getDashboardWidgetMinWidth(type) { return widget?.minWidth ?? 3; } +export const defaultDashboardSections = { + 'default-your-day': { + title: 'Your Day', + subtitle: 'A quick read on what needs your attention', + titleKey: 'dashboard.sections.yourDay.title', + subtitleKey: 'dashboard.sections.yourDay.subtitle', + }, + 'default-work': { + title: 'Work', + subtitle: 'Your personal list and items assigned to you', + titleKey: 'dashboard.sections.work.title', + subtitleKey: 'dashboard.sections.work.subtitle', + }, + 'default-workspaces': { + title: 'Workspaces', + subtitle: 'Jump back in', + titleKey: 'dashboard.sections.workspaces.title', + subtitleKey: 'dashboard.sections.workspaces.subtitle', + }, +}; + +/** + * Translate untouched built-in section headings while preserving user edits. + */ +export function getDashboardSectionDisplay(section, translate) { + const defaults = defaultDashboardSections[section.id]; + if (!defaults) return { title: section.title, subtitle: section.subtitle }; + return { + title: section.title === defaults.title ? translate(defaults.titleKey) : section.title, + subtitle: + section.subtitle === defaults.subtitle ? translate(defaults.subtitleKey) : section.subtitle, + }; +} + +/** + * Preserve canonical default values when a localized section editor is saved + * without changing its translated display text. + */ +export function getDashboardSectionSaveValues(section, draft, translate) { + const display = getDashboardSectionDisplay(section, translate); + return { + title: draft.title === display.title ? section.title : draft.title, + subtitle: draft.subtitle === (display.subtitle || '') ? section.subtitle : draft.subtitle, + }; +} + /** * Build the default three-section layout shown to users who have never * customized their dashboard (or whose saved layout is empty). */ export function buildDefaultDashboardLayout() { + const section = (id, displayOrder, widgetIds) => ({ + id, + title: defaultDashboardSections[id].title, + subtitle: defaultDashboardSections[id].subtitle, + display_order: displayOrder, + widget_ids: widgetIds, + }); + const sections = [ - { - id: 'default-your-day', - title: 'Your Day', - subtitle: 'A quick read on what needs your attention', - display_order: 0, - widget_ids: ['default-daily-briefing', 'default-your-activity', 'default-whats-new'], - }, - { - id: 'default-work', - title: 'Work', - subtitle: 'Your personal list and items assigned to you', - display_order: 1, - widget_ids: ['default-personal-tasks', 'default-assigned-to-me'], - }, - { - id: 'default-workspaces', - title: 'Workspaces', - subtitle: 'Jump back in', - display_order: 2, - widget_ids: ['default-recent-workspaces', 'default-quick-access'], - }, + section('default-your-day', 0, [ + 'default-daily-briefing', + 'default-your-activity', + 'default-whats-new', + ]), + section('default-work', 1, ['default-personal-tasks', 'default-assigned-to-me']), + section('default-workspaces', 2, ['default-recent-workspaces', 'default-quick-access']), ]; const widget = (id, type, sectionId, position, width) => ({ diff --git a/frontend/src/lib/stores/homepageStore.svelte.js b/frontend/src/lib/stores/homepageStore.svelte.js index 05ced8e78..761cf0b88 100644 --- a/frontend/src/lib/stores/homepageStore.svelte.js +++ b/frontend/src/lib/stores/homepageStore.svelte.js @@ -14,7 +14,10 @@ import { getDashboardWidgetDefaultWidth, getDashboardWidgetMinWidth, } from '../services/dashboardWidgetRegistry.js'; -import { formatDateSimple, formatDateWithOptions } from '../utils/dateFormatter.js'; +import { + formatDateWithOptions, + formatRelativeTime as formatRelativeTimeValue, +} from '../utils/dateFormatter.js'; import { t } from './i18n.svelte.js'; import { errorToast } from './toasts.svelte.js'; @@ -187,7 +190,7 @@ class HomepageStore { // === Section management === - addSection(title = 'New Section', subtitle = '') { + addSection(title = t('dashboard.editor.newSection'), subtitle = '') { const newSection = { id: crypto.randomUUID(), title, @@ -393,13 +396,13 @@ class HomepageStore { // Determine greeting based on time of day if (hour >= 5 && hour < 12) { - this.greeting = 'Good morning'; + this.greeting = 'dashboard.goodMorning'; } else if (hour >= 12 && hour < 18) { - this.greeting = 'Good afternoon'; + this.greeting = 'dashboard.goodAfternoon'; } else if (hour >= 18 && hour < 22) { - this.greeting = 'Good evening'; + this.greeting = 'dashboard.goodEvening'; } else { - this.greeting = 'Good night'; + this.greeting = 'dashboard.goodNight'; } // Format current date @@ -447,21 +450,7 @@ class HomepageStore { * Format relative time. */ formatRelativeTime(timestamp) { - if (!timestamp) return 'Unknown'; - - const now = new Date(); - const then = new Date(timestamp); - const diffMs = now.getTime() - then.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) return 'Just now'; - if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`; - if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; - if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`; - - return formatDateSimple(then); + return timestamp ? formatRelativeTimeValue(timestamp) : t('common.unknown'); } /** diff --git a/frontend/src/lib/stores/i18n-utils.spec.js b/frontend/src/lib/stores/i18n-utils.spec.js new file mode 100644 index 000000000..d5b759837 --- /dev/null +++ b/frontend/src/lib/stores/i18n-utils.spec.js @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + buildDefaultDashboardLayout, + getDashboardSectionDisplay, + getDashboardSectionSaveValues, +} from '../services/dashboardWidgetRegistry.js'; +import { formatRelativeTime } from '../utils/dateFormatter.js'; +import { i18n } from './i18n.svelte.js'; + +describe('dashboard section localization', () => { + it('translates untouched default sections', () => { + const [section] = buildDefaultDashboardLayout().sections; + const translate = (key) => `translated:${key}`; + + expect(getDashboardSectionDisplay(section, translate)).toEqual({ + title: 'translated:dashboard.sections.yourDay.title', + subtitle: 'translated:dashboard.sections.yourDay.subtitle', + }); + }); + + it('preserves customized section text', () => { + const section = { + ...buildDefaultDashboardLayout().sections[0], + title: 'My focus', + subtitle: 'What matters now', + }; + + expect(getDashboardSectionDisplay(section, () => 'translated')).toEqual({ + title: 'My focus', + subtitle: 'What matters now', + }); + }); + + it('does not persist localized default text when an unchanged section is saved', () => { + const [section] = buildDefaultDashboardLayout().sections; + const german = (key) => + ({ + 'dashboard.sections.yourDay.title': 'Dein Tag', + 'dashboard.sections.yourDay.subtitle': + 'Ein kurzer Blick auf alles, was Ihre Aufmerksamkeit braucht', + })[key]; + const english = (key) => + ({ + 'dashboard.sections.yourDay.title': 'Your Day', + 'dashboard.sections.yourDay.subtitle': 'A quick read on what needs your attention', + })[key]; + const displayedInGerman = getDashboardSectionDisplay(section, german); + + const savedValues = getDashboardSectionSaveValues(section, displayedInGerman, german); + const savedSection = { ...section, ...savedValues }; + + expect(savedValues).toEqual({ + title: 'Your Day', + subtitle: 'A quick read on what needs your attention', + }); + expect(getDashboardSectionDisplay(savedSection, english)).toEqual({ + title: 'Your Day', + subtitle: 'A quick read on what needs your attention', + }); + expect(getDashboardSectionDisplay(savedSection, german)).toEqual(displayedInGerman); + }); + + it('persists actual edits made to localized section text', () => { + const [section] = buildDefaultDashboardLayout().sections; + const translate = (key) => `translated:${key}`; + + expect( + getDashboardSectionSaveValues( + section, + { title: 'Mein Fokus', subtitle: 'Heute wichtig' }, + translate + ) + ).toEqual({ + title: 'Mein Fokus', + subtitle: 'Heute wichtig', + }); + }); + + it('preserves edits for custom sections', () => { + const section = { + id: 'custom-section', + title: 'Eigener Bereich', + subtitle: 'Meine Übersicht', + }; + + expect( + getDashboardSectionSaveValues( + section, + { title: 'Neuer Bereich', subtitle: 'Neue Übersicht' }, + () => 'translated' + ) + ).toEqual({ + title: 'Neuer Bereich', + subtitle: 'Neue Übersicht', + }); + }); +}); + +describe('locale-aware relative time', () => { + afterEach(async () => { + vi.useRealTimers(); + await i18n.setLocale('en'); + }); + + it('uses the active application locale', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-28T12:00:00Z')); + await i18n.setLocale('de'); + + expect(formatRelativeTime('2026-08-28T11:39:00Z')).toBe('vor 21 Minuten'); + }); + + it('keeps the English fallback behavior', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-28T12:00:00Z')); + await i18n.setLocale('en'); + + expect(formatRelativeTime('2026-08-28T11:39:00Z')).toBe('21 minutes ago'); + }); + + it('describes elapsed weeks and months instead of calendar periods', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-28T12:00:00Z')); + await i18n.setLocale('en'); + + expect(formatRelativeTime('2026-08-20T12:00:00Z')).toBe('1 week ago'); + expect(formatRelativeTime('2026-07-14T12:00:00Z')).toBe('1 month ago'); + expect(formatRelativeTime('2026-08-27T02:00:00Z')).toBe('1 day ago'); + expect(formatRelativeTime('2026-09-05T12:00:00Z')).toBe('in 1 week'); + }); +}); diff --git a/frontend/src/lib/utils/dateFormatter.js b/frontend/src/lib/utils/dateFormatter.js index 2141c48eb..dc2554a58 100644 --- a/frontend/src/lib/utils/dateFormatter.js +++ b/frontend/src/lib/utils/dateFormatter.js @@ -261,27 +261,21 @@ export function formatRelativeTime(dateString) { if (!dateString) return ''; try { const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return ''; const now = serverNow(); - const diffMs = now.getTime() - date.getTime(); - const diffSecs = Math.floor(diffMs / 1000); - const diffMins = Math.floor(diffSecs / 60); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffSecs < 60) return 'just now'; - if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`; - if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; - if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`; - if (diffDays < 30) { - const weeks = Math.floor(diffDays / 7); - return `${weeks} week${weeks !== 1 ? 's' : ''} ago`; - } - if (diffDays < 365) { - const months = Math.floor(diffDays / 30); - return `${months} month${months !== 1 ? 's' : ''} ago`; - } - const years = Math.floor(diffDays / 365); - return `${years} year${years !== 1 ? 's' : ''} ago`; + const diffMs = date.getTime() - now.getTime(); + const absoluteMs = Math.abs(diffMs); + const formatter = new Intl.RelativeTimeFormat(getAppLocale(), { numeric: 'always' }); + const immediateFormatter = new Intl.RelativeTimeFormat(getAppLocale(), { numeric: 'auto' }); + const valueFor = (unitMs) => Math.sign(diffMs) * Math.floor(absoluteMs / unitMs); + + if (absoluteMs < 60_000) return immediateFormatter.format(0, 'second'); + if (absoluteMs < 3_600_000) return formatter.format(valueFor(60_000), 'minute'); + if (absoluteMs < 86_400_000) return formatter.format(valueFor(3_600_000), 'hour'); + if (absoluteMs < 604_800_000) return formatter.format(valueFor(86_400_000), 'day'); + if (absoluteMs < 2_592_000_000) return formatter.format(valueFor(604_800_000), 'week'); + if (absoluteMs < 31_536_000_000) return formatter.format(valueFor(2_592_000_000), 'month'); + return formatter.format(valueFor(31_536_000_000), 'year'); } catch (error) { console.error('Error formatting relative time:', error); return ''; diff --git a/frontend/src/lib/widgets/dashboard/AssignedToMeWidget.svelte b/frontend/src/lib/widgets/dashboard/AssignedToMeWidget.svelte index fe480e95d..625886c7f 100644 --- a/frontend/src/lib/widgets/dashboard/AssignedToMeWidget.svelte +++ b/frontend/src/lib/widgets/dashboard/AssignedToMeWidget.svelte @@ -1,6 +1,7 @@