From a0262b97b3ee9e56240491c1959de432cb83a228 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 14 Jul 2026 23:03:14 +0800 Subject: [PATCH] fix: localize overview activity labels --- frontend/e2e/ux-today.spec.js | 26 ++++++- frontend/src/lib/i18n/catalogs/en.js | 36 +++++++++ frontend/src/lib/i18n/catalogs/ru.js | 36 +++++++++ frontend/src/lib/shell/TodaySurface.svelte | 88 ++++++++++++---------- 4 files changed, 145 insertions(+), 41 deletions(-) diff --git a/frontend/e2e/ux-today.spec.js b/frontend/e2e/ux-today.spec.js index 7104ae7..450d5bf 100644 --- a/frontend/e2e/ux-today.spec.js +++ b/frontend/e2e/ux-today.spec.js @@ -248,7 +248,7 @@ test.describe('UX Overview workspace flow', () => { await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3'); const attention = overview.locator('[data-overview-section="attention"]'); await expect(attention).toContainText('Possible journal entry'); - await expect(attention).toContainText('Workspace: Project · 10 min · 2 activities'); + await expect(attention).toContainText('Deal: Project · 10 min · 2 activities'); await attention.locator('.overview-attention-row', { hasText: 'Possible journal entry' }).getByRole('button', { name: 'Review candidate' }).click(); await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true'); await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Workspace: Project'); @@ -295,4 +295,28 @@ test.describe('UX Overview workspace flow', () => { await expect(recent).toContainText('Added journal entry "Write project summary"'); await expect(recent).not.toContainText('Changed file "draft.md"'); }); + + test('Overview localizes activity labels without exposing internal event names', async ({ page }) => { + await page.evaluate(async () => { + await window.go.api.App.WritePluginSettings('verstak.activity', { + 'events:workspace:Project': [{ + activityId: 'overview-russian-note', + occurredAt: '2026-06-30T08:25:00.000Z', + type: 'note.saved', + title: 'Локализация', + summary: 'Project/Notes/Localization.md', + workspaceRootPath: 'Project', + }], + }); + }); + await page.locator('[data-settings-menu-button]').click(); + await page.locator('[data-settings-language="ru"]').click(); + + const overview = page.locator('[data-overview-root]'); + await overview.locator('[data-overview-action="refresh"]').click(); + const recent = overview.locator('[data-overview-section="recent"]'); + await expect(recent).toContainText('Изменена заметка «Локализация»'); + await expect(recent).not.toContainText('note.saved'); + await expect(recent).not.toContainText('Edited note'); + }); }); diff --git a/frontend/src/lib/i18n/catalogs/en.js b/frontend/src/lib/i18n/catalogs/en.js index 4c25967..9c59457 100644 --- a/frontend/src/lib/i18n/catalogs/en.js +++ b/frontend/src/lib/i18n/catalogs/en.js @@ -236,6 +236,42 @@ export default { 'overview.recentChangesHint': 'Latest meaningful activity in this Deal.', 'overview.event.workspaceOpened': 'Opened Deal', 'overview.event.activity': 'Deal activity', + 'overview.event.noteEdited': 'Edited note "{name}"', + 'overview.event.noteOpened': 'Opened note "{name}"', + 'overview.event.noteCreated': 'Created note "{name}"', + 'overview.event.fileOpened': 'Opened file "{name}"', + 'overview.event.fileChanged': 'Changed file "{name}"', + 'overview.event.fileCreated': 'Created file "{name}"', + 'overview.event.fileRemoved': 'Removed file "{name}"', + 'overview.event.capturePage': 'Captured page "{name}"', + 'overview.event.captureSelection': 'Captured selection "{name}"', + 'overview.event.captureLink': 'Captured link "{name}"', + 'overview.event.captureFile': 'Captured file "{name}"', + 'overview.event.captureConverted': 'Converted capture "{name}"', + 'overview.event.capture': 'Captured {kind} "{title}"', + 'overview.event.journalAdded': 'Added journal entry "{name}"', + 'overview.event.workSessionDetected': 'Work session detected', + 'overview.event.todo': 'Todo "{title}"', + 'overview.event.reviewCapture': 'Review capture "{title}"', + 'overview.event.continueJournal': 'Continue journal entry "{title}"', + 'overview.event.possibleJournalEntry': 'Possible journal entry', + 'overview.captureKind.page': 'page', + 'overview.captureKind.selection': 'selection', + 'overview.captureKind.link': 'link', + 'overview.captureKind.file': 'file', + 'overview.captureKind.item': 'item', + 'overview.todo.reminderDue': 'Reminder due', + 'overview.todo.overdue': 'Overdue', + 'overview.todo.dueSoon': 'Due soon', + 'overview.todo.due': 'Due {date}', + 'overview.candidateMeta': 'Deal: {deal} · {minutes} min · {activities} activities', + 'overview.minutes': '{count} min', + 'overview.browserCapture': 'Browser capture', + 'overview.untitledTodo': 'Untitled todo', + 'overview.untitledCapture': 'Untitled capture', + 'overview.untitledJournal': 'Journal entry', + 'overview.unknownDeal': 'Unknown Deal', + 'overview.item': 'item', 'overview.overviewNote': 'Deal overview note', 'overview.recentFilter': 'Recent changes filter', 'overview.loadingRecent': 'Loading recent changes...', diff --git a/frontend/src/lib/i18n/catalogs/ru.js b/frontend/src/lib/i18n/catalogs/ru.js index 6bbc758..19803e9 100644 --- a/frontend/src/lib/i18n/catalogs/ru.js +++ b/frontend/src/lib/i18n/catalogs/ru.js @@ -236,6 +236,42 @@ export default { 'overview.recentChangesHint': 'Последняя важная активность в этом Деле.', 'overview.event.workspaceOpened': 'Открыто Дело', 'overview.event.activity': 'Активность Дела', + 'overview.event.noteEdited': 'Изменена заметка «{name}»', + 'overview.event.noteOpened': 'Открыта заметка «{name}»', + 'overview.event.noteCreated': 'Создана заметка «{name}»', + 'overview.event.fileOpened': 'Открыт файл «{name}»', + 'overview.event.fileChanged': 'Изменён файл «{name}»', + 'overview.event.fileCreated': 'Создан файл «{name}»', + 'overview.event.fileRemoved': 'Удалён файл «{name}»', + 'overview.event.capturePage': 'Сохранена страница «{name}»', + 'overview.event.captureSelection': 'Сохранено выделение «{name}»', + 'overview.event.captureLink': 'Сохранена ссылка «{name}»', + 'overview.event.captureFile': 'Сохранён файл «{name}»', + 'overview.event.captureConverted': 'Сохранённое преобразовано: «{name}»', + 'overview.event.capture': 'Сохранено: {kind} «{title}»', + 'overview.event.journalAdded': 'Добавлена запись в журнал «{name}»', + 'overview.event.workSessionDetected': 'Обнаружена рабочая сессия', + 'overview.event.todo': 'Задача «{title}»', + 'overview.event.reviewCapture': 'Разобрать сохранённое «{title}»', + 'overview.event.continueJournal': 'Продолжить запись журнала «{title}»', + 'overview.event.possibleJournalEntry': 'Возможная запись в журнал', + 'overview.captureKind.page': 'страница', + 'overview.captureKind.selection': 'выделение', + 'overview.captureKind.link': 'ссылка', + 'overview.captureKind.file': 'файл', + 'overview.captureKind.item': 'элемент', + 'overview.todo.reminderDue': 'Пора напомнить', + 'overview.todo.overdue': 'Просрочено', + 'overview.todo.dueSoon': 'Скоро срок', + 'overview.todo.due': 'Срок: {date}', + 'overview.candidateMeta': 'Дело: {deal} · {minutes} мин. · активностей: {activities}', + 'overview.minutes': '{count} мин.', + 'overview.browserCapture': 'Сохранённое из браузера', + 'overview.untitledTodo': 'Задача без названия', + 'overview.untitledCapture': 'Сохранённое без названия', + 'overview.untitledJournal': 'Запись журнала', + 'overview.unknownDeal': 'Неизвестное Дело', + 'overview.item': 'элемент', 'overview.overviewNote': 'Обзорная заметка Дела', 'overview.recentFilter': 'Фильтр недавних изменений', 'overview.loadingRecent': 'Загрузка недавних изменений...', diff --git a/frontend/src/lib/shell/TodaySurface.svelte b/frontend/src/lib/shell/TodaySurface.svelte index 8219262..d60598f 100644 --- a/frontend/src/lib/shell/TodaySurface.svelte +++ b/frontend/src/lib/shell/TodaySurface.svelte @@ -195,7 +195,7 @@ } function todoTitle(item) { - return String(item?.title || item?.name || 'Untitled todo').trim() || 'Untitled todo'; + return String(item?.title || item?.name || tr('overview.untitledTodo')).trim() || tr('overview.untitledTodo'); } function todoAttentionState(item) { @@ -203,9 +203,9 @@ const now = Date.now(); const dueAt = todoDateMs(item?.dueAt); const reminderAt = todoDateMs(item?.reminderAt); - if (reminderAt && reminderAt <= now) return 'Reminder due'; - if (dueAt && dueAt < now) return 'Overdue'; - if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return 'Due soon'; + if (reminderAt && reminderAt <= now) return tr('overview.todo.reminderDue'); + if (dueAt && dueAt < now) return tr('overview.todo.overdue'); + if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return tr('overview.todo.dueSoon'); return ''; } @@ -238,17 +238,12 @@ return fileName(path).replace(/\.(md|markdown|txt)$/i, '').replace(/_/g, ' ') || 'Untitled'; } - function quoted(value) { - const clean = String(value || '').trim(); - return clean ? `"${clean}"` : ''; - } - function entityName(item) { const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}; const path = payload.path || payload.notePath || item?.path || item?.relativePath || looksLikePath(item?.summary); const title = String(item?.title || payload.title || '').trim(); if (path && (!title || /^(saved note|file opened|file changed|activity event)$/i.test(title))) return titleFromPath(path); - return title || titleFromPath(path) || String(item?.summary || item?.activityId || 'item'); + return title || titleFromPath(path) || String(item?.summary || item?.activityId || tr('overview.item')); } function looksLikePath(value) { @@ -258,11 +253,11 @@ } function captureTitle(capture) { - return capture?.title || capture?.fileName || capture?.url || capture?.captureId || 'Untitled capture'; + return capture?.title || capture?.fileName || capture?.url || capture?.captureId || tr('overview.untitledCapture'); } function journalTitle(entry) { - return entry?.title || entry?.summary || entry?.date || entry?.entryId || 'Journal entry'; + return entry?.title || entry?.summary || entry?.date || entry?.entryId || tr('overview.untitledJournal'); } function itemTimeLabel(item) { @@ -312,24 +307,33 @@ function activityTitle(item) { const type = String(item?.type || '').toLowerCase(); const name = entityName(item); - if (type === 'note.saved' || type === 'note.edited') return `Edited note ${quoted(name)}`; - if (type === 'note.opened') return `Opened note ${quoted(name)}`; - if (type === 'note.created') return `Created note ${quoted(name)}`; - if (type === 'file.opened') return `Opened file ${quoted(name)}`; - if (type === 'file.changed') return `Changed file ${quoted(name)}`; - if (type === 'file.created') return `Created file ${quoted(name)}`; - if (type === 'file.deleted' || type === 'file.trashed') return `Removed file ${quoted(name)}`; - if (type === 'browser.capture.page') return `Captured page ${quoted(name)}`; - if (type === 'browser.capture.selection') return `Captured selection ${quoted(name)}`; - if (type === 'browser.capture.link') return `Captured link ${quoted(name)}`; - if (type === 'browser.capture.file') return `Captured file ${quoted(name)}`; - if (type === 'browser.capture.converted') return `Converted capture ${quoted(name)}`; - if (type === 'journal.entry.added' || type === 'worklog.entry.added') return `Added journal entry ${quoted(name)}`; - if (type === 'action.started') return 'Work session detected'; + if (type === 'note.saved' || type === 'note.edited') return tr('overview.event.noteEdited', { name }); + if (type === 'note.opened') return tr('overview.event.noteOpened', { name }); + if (type === 'note.created') return tr('overview.event.noteCreated', { name }); + if (type === 'file.opened') return tr('overview.event.fileOpened', { name }); + if (type === 'file.changed') return tr('overview.event.fileChanged', { name }); + if (type === 'file.created') return tr('overview.event.fileCreated', { name }); + if (type === 'file.deleted' || type === 'file.trashed') return tr('overview.event.fileRemoved', { name }); + if (type === 'browser.capture.page') return tr('overview.event.capturePage', { name }); + if (type === 'browser.capture.selection') return tr('overview.event.captureSelection', { name }); + if (type === 'browser.capture.link') return tr('overview.event.captureLink', { name }); + if (type === 'browser.capture.file') return tr('overview.event.captureFile', { name }); + if (type === 'browser.capture.converted') return tr('overview.event.captureConverted', { name }); + if (type === 'journal.entry.added' || type === 'worklog.entry.added') return tr('overview.event.journalAdded', { name }); + if (type === 'action.started') return tr('overview.event.workSessionDetected'); if (type === 'workspace.opened') return tr('overview.event.workspaceOpened'); return item?.title || item?.summary || tr('overview.event.activity'); } + function captureKindLabel(capture) { + const kind = String(capture?.kind || '').toLowerCase(); + if (kind === 'page') return tr('overview.captureKind.page'); + if (kind === 'selection') return tr('overview.captureKind.selection'); + if (kind === 'link') return tr('overview.captureKind.link'); + if (kind === 'file') return tr('overview.captureKind.file'); + return tr('overview.captureKind.item'); + } + function actionForCategory(category) { if (category === 'notes') return { kind: 'notes', label: tr('overview.openNotes') }; if (category === 'files') return { kind: 'files', label: tr('overview.openFiles') }; @@ -348,7 +352,7 @@ id: item.activityId || `${item.type}:${timeValue(item)}`, category, title: activityTitle(item), - meta: `${itemTimeLabel(item)}${item.sourcePluginId ? ' · ' + item.sourcePluginId.replace('verstak.', '') : ''}`, + meta: itemTimeLabel(item), time: timeValue(item), absolute: absoluteTime(timeValue(item)), actionKind: action.kind, @@ -358,8 +362,8 @@ const captureItems = captureRows.map(item => ({ id: item.captureId || `capture:${timeValue(item)}`, category: 'captures', - title: `Captured ${item.kind || 'item'} ${quoted(captureTitle(item))}`, - meta: `${itemTimeLabel(item)} · ${item.domain || item.url || 'Browser capture'}`, + title: tr('overview.event.capture', { kind: captureKindLabel(item), title: captureTitle(item) }), + meta: `${itemTimeLabel(item)} · ${item.domain || item.url || tr('overview.browserCapture')}`, time: timeValue(item), absolute: absoluteTime(timeValue(item)), actionKind: 'browser-inbox', @@ -368,8 +372,8 @@ const journalItems = journalRows.map(item => ({ id: item.entryId || `journal:${timeValue(item)}`, category: 'journal', - title: `Added journal entry ${quoted(journalTitle(item))}`, - meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + item.minutes + ' min' : ''}`, + title: tr('overview.event.journalAdded', { name: journalTitle(item) }), + meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + tr('overview.minutes', { count: item.minutes }) : ''}`, time: timeValue(item), absolute: absoluteTime(timeValue(item)), actionKind: 'journal', @@ -409,8 +413,8 @@ const todoCandidates = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).map(item => ({ id: item.id || `todo:${todoTitle(item)}`, category: 'todo', - title: `Todo ${quoted(todoTitle(item))}`, - meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`, + title: tr('overview.event.todo', { title: todoTitle(item) }), + meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`, time: todoTimeValue(item), absolute: absoluteTime(todoTimeValue(item)), actionKind: 'todo', @@ -419,8 +423,8 @@ const captureCandidates = sortByTime(captureRows).map(item => ({ id: item.captureId || `capture:${timeValue(item)}`, category: 'captures', - title: `Review capture ${quoted(captureTitle(item))}`, - meta: `${itemTimeLabel(item)} · ${item.domain || item.kind || 'Browser capture'}`, + title: tr('overview.event.reviewCapture', { title: captureTitle(item) }), + meta: `${itemTimeLabel(item)} · ${item.domain || captureKindLabel(item) || tr('overview.browserCapture')}`, time: timeValue(item), absolute: absoluteTime(timeValue(item)), actionKind: 'browser-inbox', @@ -435,7 +439,7 @@ const journalCandidates = sortByTime(journalRows).map(item => ({ id: item.entryId || `journal:${timeValue(item)}`, category: 'journal', - title: `Continue journal entry ${quoted(journalTitle(item))}`, + title: tr('overview.event.continueJournal', { title: journalTitle(item) }), meta: itemTimeLabel(item), time: timeValue(item), absolute: absoluteTime(timeValue(item)), @@ -449,14 +453,18 @@ const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({ id: item.captureId || `capture:${timeValue(item)}`, title: captureTitle(item), - meta: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`, + meta: `${captureKindLabel(item)} · ${itemTimeLabel(item)}`, actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox'), })); const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({ id: item.candidateId || `work-session:${timeValue(item)}`, - title: 'Possible journal entry', - meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`, + title: tr('overview.event.possibleJournalEntry'), + meta: tr('overview.candidateMeta', { + deal: item.workspaceRootPath || workspaceRootPath || tr('overview.unknownDeal'), + minutes: item.estimatedMinutes || 0, + activities: item.activityCount || (item.activityIds || []).length || 0, + }), actionKind: 'journal', actionLabel: tr('overview.reviewCandidate'), toolRequest: { type: 'work-session-candidate', candidate: item }, @@ -464,7 +472,7 @@ const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({ id: item.id || `todo:${todoTitle(item)}`, title: todoTitle(item), - meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`, + meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`, actionKind: 'todo', actionLabel: tr('overview.openTodos'), }));