From 94c917c2dc0c7b39408bd5b6f032d84ea475f385 Mon Sep 17 00:00:00 2001 From: jhihjian Date: Thu, 11 Jun 2026 16:32:56 +0800 Subject: [PATCH 01/11] feat: add lightweight i18n core --- src/i18n/index.ts | 78 +++++++++++++++++++++++++++++++++++++ src/i18n/locales/en.ts | 30 ++++++++++++++ src/i18n/locales/zh-CN.ts | 27 +++++++++++++ test/unit/i18n.test.node.ts | 54 +++++++++++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/locales/en.ts create mode 100644 src/i18n/locales/zh-CN.ts create mode 100644 test/unit/i18n.test.node.ts diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 00000000..7b75bb25 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,78 @@ +import Vue from 'vue'; + +import en from './locales/en'; +import zhCN from './locales/zh-CN'; + +export type SupportedLocale = 'en' | 'zh-CN'; +export type TranslationParams = Record; + +type TranslationValue = string | TranslationTree; +type TranslationTree = { + [key: string]: TranslationValue; +}; + +export const DEFAULT_LOCALE: SupportedLocale = 'en'; +export const SUPPORTED_LOCALES: Array<{ code: SupportedLocale; label: string }> = [ + { code: 'en', label: 'English' }, + { code: 'zh-CN', label: '简体中文' }, +]; + +const messages: Record = { + en, + 'zh-CN': zhCN, +}; + +const state = Vue.observable<{ locale: SupportedLocale }>({ + locale: DEFAULT_LOCALE, +}); + +function resolveKey(locale: SupportedLocale, key: string): string | undefined { + const value = key.split('.').reduce((current, part) => { + if (!current || typeof current === 'string') { + return undefined; + } + + return current[part]; + }, messages[locale]); + + return typeof value === 'string' ? value : undefined; +} + +export function isSupportedLocale(locale: string): locale is SupportedLocale { + return SUPPORTED_LOCALES.some(supportedLocale => supportedLocale.code === locale); +} + +export function setLocale(locale: SupportedLocale): void { + state.locale = locale; +} + +export function getLocale(): SupportedLocale { + return state.locale; +} + +export function interpolate(message: string, params: TranslationParams = {}): string { + return message.replace(/\{([^{}]+)\}/g, (placeholder, name: string) => { + const value = params[name]; + + return value === undefined || value === null ? placeholder : String(value); + }); +} + +export function translate( + key: string, + locale: SupportedLocale = state.locale, + params?: TranslationParams +): string { + const message = resolveKey(locale, key) ?? resolveKey(DEFAULT_LOCALE, key) ?? key; + + return interpolate(message, params); +} + +export function t(key: string, params?: TranslationParams): string { + return translate(key, state.locale, params); +} + +export function installI18n(vue: typeof Vue): void { + vue.prototype.$t = t; + vue.prototype.$translate = translate; +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts new file mode 100644 index 00000000..b93a790f --- /dev/null +++ b/src/i18n/locales/en.ts @@ -0,0 +1,30 @@ +const en = { + common: { + loading: 'Loading...', + error: 'Error', + save: 'Save', + cancel: 'Cancel', + close: 'Close', + }, + test: { + onlyEnglish: 'Only English', + }, + nav: { + activity: 'Activity', + timeline: 'Timeline', + buckets: 'Buckets', + settings: 'Settings', + }, + activity: { + title: 'Activity', + hostLabel: 'Host: {host}', + noData: 'No activity data found', + }, + settings: { + title: 'Settings', + language: 'Language', + theme: 'Theme', + }, +}; + +export default en; diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts new file mode 100644 index 00000000..b81ef6bf --- /dev/null +++ b/src/i18n/locales/zh-CN.ts @@ -0,0 +1,27 @@ +const zhCN = { + common: { + loading: '加载中...', + error: '错误', + save: '保存', + cancel: '取消', + close: '关闭', + }, + nav: { + activity: '活动', + timeline: '时间线', + buckets: '存储桶', + settings: '设置', + }, + activity: { + title: '活动', + hostLabel: '主机:{host}', + noData: '未找到活动数据', + }, + settings: { + title: '设置', + language: '语言', + theme: '主题', + }, +}; + +export default zhCN; diff --git a/test/unit/i18n.test.node.ts b/test/unit/i18n.test.node.ts new file mode 100644 index 00000000..d88c4eb5 --- /dev/null +++ b/test/unit/i18n.test.node.ts @@ -0,0 +1,54 @@ +import { + DEFAULT_LOCALE, + SUPPORTED_LOCALES, + interpolate, + setLocale, + t, + translate, +} from '~/i18n'; + +describe('i18n', () => { + test('uses English as the default locale', () => { + expect(DEFAULT_LOCALE).toBe('en'); + expect(SUPPORTED_LOCALES).toContainEqual({ + code: 'en', + label: 'English', + }); + }); + + test('translates English keys', () => { + expect(t('common.loading')).toBe('Loading...'); + expect(translate('nav.activity', 'en')).toBe('Activity'); + }); + + test('translates zh-CN keys', () => { + expect(translate('common.loading', 'zh-CN')).toBe('加载中...'); + expect(translate('nav.activity', 'zh-CN')).toBe('活动'); + }); + + test('falls back to English when zh-CN key is missing', () => { + expect(translate('test.onlyEnglish', 'zh-CN')).toBe('Only English'); + }); + + test('returns the key when no locale contains the key', () => { + expect(translate('missing.key', 'zh-CN')).toBe('missing.key'); + }); + + test('interpolates named params', () => { + expect(translate('activity.hostLabel', 'en', { host: 'laptop' })).toBe('Host: laptop'); + expect(translate('activity.hostLabel', 'zh-CN', { host: 'laptop' })).toBe('主机:laptop'); + }); + + test('t follows the active locale', () => { + setLocale('zh-CN'); + expect(t('nav.activity')).toBe('活动'); + setLocale('en'); + expect(t('nav.activity')).toBe('Activity'); + }); + + test('keeps placeholders for missing params', () => { + expect(interpolate('Found {count} events in {seconds} seconds', { count: 5 })).toBe( + 'Found 5 events in {seconds} seconds' + ); + }); +}); From 03e6458ac976e2ead0e8b2d797801b8ceb253b9b Mon Sep 17 00:00:00 2001 From: jhihjian Date: Thu, 11 Jun 2026 16:45:47 +0800 Subject: [PATCH 02/11] fix: align i18n core with localization spec --- src/i18n/index.ts | 9 ++--- src/i18n/locales/en.ts | 73 ++++++++++++++++++++++++++++++++++++- src/i18n/locales/zh-CN.ts | 70 ++++++++++++++++++++++++++++++++++- test/unit/i18n.test.node.ts | 27 ++++++++++++++ 4 files changed, 172 insertions(+), 7 deletions(-) diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 7b75bb25..a7233040 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -42,8 +42,8 @@ export function isSupportedLocale(locale: string): locale is SupportedLocale { return SUPPORTED_LOCALES.some(supportedLocale => supportedLocale.code === locale); } -export function setLocale(locale: SupportedLocale): void { - state.locale = locale; +export function setLocale(locale: string | null | undefined): void { + state.locale = locale && isSupportedLocale(locale) ? locale : DEFAULT_LOCALE; } export function getLocale(): SupportedLocale { @@ -72,7 +72,6 @@ export function t(key: string, params?: TranslationParams): string { return translate(key, state.locale, params); } -export function installI18n(vue: typeof Vue): void { - vue.prototype.$t = t; - vue.prototype.$translate = translate; +export function installI18n(): void { + Vue.prototype.$t = t; } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b93a790f..d12ac0eb 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1,10 +1,27 @@ const en = { common: { loading: 'Loading...', - error: 'Error', + noData: 'No data', save: 'Save', cancel: 'Cancel', + confirm: 'Confirm', + delete: 'Delete', + edit: 'Edit', close: 'Close', + open: 'Open', + refresh: 'Refresh', + filters: 'Filters', + options: 'Options', + search: 'Search', + query: 'Query', + generate: 'Generate', + import: 'Import', + export: 'Export', + danger: 'Danger!', + warning: 'Warning', + error: 'Error', + all: 'All', + uncategorized: 'Uncategorized', }, test: { onlyEnglish: 'Only English', @@ -12,18 +29,72 @@ const en = { nav: { activity: 'Activity', timeline: 'Timeline', + stopwatch: 'Stopwatch', + tools: 'Tools', + search: 'Search', + trends: 'Trends', + report: 'Report', + alerts: 'Alerts', + timespiral: 'Timespiral', + query: 'Query', + graph: 'Graph', + rawData: 'Raw Data', buckets: 'Buckets', settings: 'Settings', + loading: 'Loading...', + noActivityReports: 'No activity reports available', + missingWatchers: 'Make sure you have both an AFK and window watcher running', }, activity: { title: 'Activity', + titleFor: 'for', + host: 'Host:', + timeActive: 'Time active:', + queryRange: 'Query range:', + filters: 'Filters', + refresh: 'Refresh', + excludeAfk: 'Exclude AFK time', + excludeAfkHelp: "Filter away time where the AFK watcher didn't detect any input.", + countAudible: 'Count audible browser tab as active', + countAudibleHelp: + 'If the active window is an audible browser tab, count as active. Requires a browser watcher.', + includeStopwatch: 'Include manually logged events (stopwatch)', + includeStopwatchNote: 'WIP, breaks aw-server-rust badly. Only shown in devmode.', + showCategory: 'Show category', + newView: 'New view', + loadDemoData: 'Load demo data', hostLabel: 'Host: {host}', + last7Days: 'last 7 days', + last30Days: 'last 30 days', + periodDay: 'day', + periodWeek: 'week', + periodMonth: 'month', + periodYear: 'year', + period7Days: '7 days', + period30Days: '30 days', + invalidFormInput: 'Invalid form input: {errors}', + idNotUnique: 'ID is not unique', + missingId: 'Missing ID', + missingName: 'Missing name', noData: 'No activity data found', }, settings: { title: 'Settings', + languageTitle: 'Language', + languageDescription: 'Choose the display language for ActivityWatch.', language: 'Language', + themeTitle: 'Theme', + themeLight: 'Light', + themeDark: 'Dark', + themeDescription: + 'Change color theme of the application (you need to change categories colors manually to be suitable with dark mode).', theme: 'Theme', + landingPageTitle: 'Landing page', + landingPageHome: 'Home', + landingPageActivity: 'Activity ({hostname})', + landingPageTimeline: 'Timeline', + landingPageDescription: + 'The page to open when opening ActivityWatch, or clicking the logo in the top menu.', }, }; diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index b81ef6bf..f69917cf 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -1,26 +1,94 @@ const zhCN = { common: { loading: '加载中...', - error: '错误', + noData: '无数据', save: '保存', cancel: '取消', + confirm: '确认', + delete: '删除', + edit: '编辑', close: '关闭', + open: '打开', + refresh: '刷新', + filters: '筛选', + options: '选项', + search: '搜索', + query: '查询', + generate: '生成', + import: '导入', + export: '导出', + danger: '危险操作!', + warning: '警告', + error: '错误', + all: '全部', + uncategorized: '未分类', }, nav: { activity: '活动', timeline: '时间线', + stopwatch: '秒表', + tools: '工具', + search: '搜索', + trends: '趋势', + report: '报告', + alerts: '提醒', + timespiral: '时间螺旋', + query: '查询', + graph: '图谱', + rawData: '原始数据', buckets: '存储桶', settings: '设置', + loading: '加载中...', + noActivityReports: '暂无活动报告', + missingWatchers: '请确认 AFK watcher 和 window watcher 都在运行', }, activity: { title: '活动', + titleFor: ':', + host: '主机:', + timeActive: '活跃时间:', + queryRange: '查询范围:', + filters: '筛选', + refresh: '刷新', + excludeAfk: '排除离开时间', + excludeAfkHelp: '过滤 AFK watcher 未检测到输入的时间。', + countAudible: '将有声音的浏览器标签页计为活跃', + countAudibleHelp: '当当前窗口是有声音的浏览器标签页时,将其计为活跃。需要浏览器 watcher。', + includeStopwatch: '包含手动记录事件(秒表)', + includeStopwatchNote: '开发中的功能,对 aw-server-rust 影响较大。仅在开发模式显示。', + showCategory: '显示分类', + newView: '新建视图', + loadDemoData: '加载演示数据', hostLabel: '主机:{host}', + last7Days: '最近 7 天', + last30Days: '最近 30 天', + periodDay: '日', + periodWeek: '周', + periodMonth: '月', + periodYear: '年', + period7Days: '7 天', + period30Days: '30 天', + invalidFormInput: '表单输入无效:{errors}', + idNotUnique: 'ID 已存在', + missingId: '缺少 ID', + missingName: '缺少名称', noData: '未找到活动数据', }, settings: { title: '设置', + languageTitle: '语言', + languageDescription: '选择 ActivityWatch 的界面显示语言。', language: '语言', + themeTitle: '主题', + themeLight: '浅色', + themeDark: '深色', + themeDescription: '切换应用颜色主题;深色模式下的分类颜色需要手动调整。', theme: '主题', + landingPageTitle: '打开页面', + landingPageHome: '首页', + landingPageActivity: '活动({hostname})', + landingPageTimeline: '时间线', + landingPageDescription: '打开 ActivityWatch 或点击顶部菜单 logo 时进入的页面。', }, }; diff --git a/test/unit/i18n.test.node.ts b/test/unit/i18n.test.node.ts index d88c4eb5..d8a88a9a 100644 --- a/test/unit/i18n.test.node.ts +++ b/test/unit/i18n.test.node.ts @@ -1,11 +1,14 @@ import { DEFAULT_LOCALE, SUPPORTED_LOCALES, + getLocale, + installI18n, interpolate, setLocale, t, translate, } from '~/i18n'; +import Vue from 'vue'; describe('i18n', () => { test('uses English as the default locale', () => { @@ -46,6 +49,30 @@ describe('i18n', () => { expect(t('nav.activity')).toBe('Activity'); }); + test('falls back to the default locale for invalid locale input', () => { + setLocale('zh-CN'); + + setLocale('fr-FR'); + expect(getLocale()).toBe(DEFAULT_LOCALE); + expect(t('nav.activity')).toBe('Activity'); + + setLocale('zh-CN'); + setLocale(null); + expect(getLocale()).toBe(DEFAULT_LOCALE); + + setLocale('zh-CN'); + setLocale(undefined); + expect(getLocale()).toBe(DEFAULT_LOCALE); + }); + + test('installs $t on Vue without requiring a Vue argument', () => { + delete Vue.prototype.$t; + + installI18n(); + + expect(Vue.prototype.$t('nav.activity')).toBe('Activity'); + }); + test('keeps placeholders for missing params', () => { expect(interpolate('Found {count} events in {seconds} seconds', { count: 5 })).toBe( 'Found 5 events in {seconds} seconds' From 6f85b6eb98ce7e268c0b3979c701a3b7eac9ad16 Mon Sep 17 00:00:00 2001 From: jhihjian Date: Thu, 11 Jun 2026 17:01:51 +0800 Subject: [PATCH 03/11] fix: harden i18n locale handling --- src/i18n/index.ts | 16 ++++-- src/i18n/locales/zh-CN.ts | 8 ++- test/unit/i18n.test.node.ts | 98 +++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/src/i18n/index.ts b/src/i18n/index.ts index a7233040..b23c99d3 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -5,6 +5,10 @@ import zhCN from './locales/zh-CN'; export type SupportedLocale = 'en' | 'zh-CN'; export type TranslationParams = Record; +export type SupportedLocaleOption = Readonly<{ + code: SupportedLocale; + label: string; +}>; type TranslationValue = string | TranslationTree; type TranslationTree = { @@ -12,10 +16,10 @@ type TranslationTree = { }; export const DEFAULT_LOCALE: SupportedLocale = 'en'; -export const SUPPORTED_LOCALES: Array<{ code: SupportedLocale; label: string }> = [ - { code: 'en', label: 'English' }, - { code: 'zh-CN', label: '简体中文' }, -]; +export const SUPPORTED_LOCALES: ReadonlyArray = Object.freeze([ + Object.freeze({ code: 'en', label: 'English' }), + Object.freeze({ code: 'zh-CN', label: '简体中文' }), +]); const messages: Record = { en, @@ -52,6 +56,10 @@ export function getLocale(): SupportedLocale { export function interpolate(message: string, params: TranslationParams = {}): string { return message.replace(/\{([^{}]+)\}/g, (placeholder, name: string) => { + if (!Object.prototype.hasOwnProperty.call(params, name)) { + return placeholder; + } + const value = params[name]; return value === undefined || value === null ? placeholder : String(value); diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index f69917cf..b6ae8063 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -1,3 +1,9 @@ +import type en from './en'; + +type DeepPartial = { + [K in keyof T]?: T[K] extends object ? DeepPartial : T[K]; +}; + const zhCN = { common: { loading: '加载中...', @@ -90,6 +96,6 @@ const zhCN = { landingPageTimeline: '时间线', landingPageDescription: '打开 ActivityWatch 或点击顶部菜单 logo 时进入的页面。', }, -}; +} satisfies DeepPartial; export default zhCN; diff --git a/test/unit/i18n.test.node.ts b/test/unit/i18n.test.node.ts index d8a88a9a..bbebe0eb 100644 --- a/test/unit/i18n.test.node.ts +++ b/test/unit/i18n.test.node.ts @@ -6,11 +6,56 @@ import { interpolate, setLocale, t, + TranslationParams, translate, } from '~/i18n'; +import en from '~/i18n/locales/en'; +import zhCN from '~/i18n/locales/zh-CN'; import Vue from 'vue'; +type LocaleValue = string | LocaleTree; +type LocaleTree = { + [key: string]: LocaleValue; +}; + +function findExtraLocaleKeys(canonical: LocaleTree, candidate: LocaleTree, prefix = ''): string[] { + return Object.keys(candidate).flatMap(key => { + const path = prefix ? `${prefix}.${key}` : key; + + if (!Object.prototype.hasOwnProperty.call(canonical, key)) { + return [path]; + } + + const canonicalValue = canonical[key]; + const candidateValue = candidate[key]; + + if (typeof canonicalValue === 'string') { + return typeof candidateValue === 'string' ? [] : [path]; + } + + return typeof candidateValue === 'string' + ? [] + : findExtraLocaleKeys(canonicalValue, candidateValue, path); + }); +} + describe('i18n', () => { + const originalVueT = Vue.prototype.$t; + + beforeEach(() => { + setLocale(DEFAULT_LOCALE); + }); + + afterEach(() => { + setLocale(DEFAULT_LOCALE); + + if (originalVueT === undefined) { + delete Vue.prototype.$t; + } else { + Vue.prototype.$t = originalVueT; + } + }); + test('uses English as the default locale', () => { expect(DEFAULT_LOCALE).toBe('en'); expect(SUPPORTED_LOCALES).toContainEqual({ @@ -19,6 +64,27 @@ describe('i18n', () => { }); }); + test('exposes supported locales as an immutable iterable list', () => { + expect(Array.from(SUPPORTED_LOCALES)).toEqual( + expect.arrayContaining([ + { code: 'en', label: 'English' }, + { code: 'zh-CN', label: '简体中文' }, + ]) + ); + expect(Object.isFrozen(SUPPORTED_LOCALES)).toBe(true); + expect(SUPPORTED_LOCALES.every(locale => Object.isFrozen(locale))).toBe(true); + expect(() => { + (SUPPORTED_LOCALES as unknown as Array<{ code: string; label: string }>).push({ + code: 'fr-FR', + label: 'French', + }); + }).toThrow(); + }); + + test.each([['zh-CN', zhCN]])('%s does not define keys outside the English schema', (_, locale) => { + expect(findExtraLocaleKeys(en, locale)).toEqual([]); + }); + test('translates English keys', () => { expect(t('common.loading')).toBe('Loading...'); expect(translate('nav.activity', 'en')).toBe('Activity'); @@ -73,9 +139,41 @@ describe('i18n', () => { expect(Vue.prototype.$t('nav.activity')).toBe('Activity'); }); + test('updates Vue instance translations when the active locale changes', async () => { + installI18n(); + + const vm = new Vue({ + computed: { + activityLabel(this: Vue & { $t: typeof t }) { + return this.$t('nav.activity'); + }, + }, + }) as Vue & { activityLabel: string }; + + expect(vm.activityLabel).toBe('Activity'); + + setLocale('zh-CN'); + await Vue.nextTick(); + + expect(vm.activityLabel).toBe('活动'); + + vm.$destroy(); + }); + test('keeps placeholders for missing params', () => { expect(interpolate('Found {count} events in {seconds} seconds', { count: 5 })).toBe( 'Found 5 events in {seconds} seconds' ); }); + + test('keeps placeholders for inherited or nullish params', () => { + const inheritedParams = Object.create({ name: 'inherited' }) as TranslationParams; + const explicitParams = Object.create(null) as TranslationParams; + explicitParams['constructor'] = 'explicit'; + + expect(interpolate('Missing {constructor}', {})).toBe('Missing {constructor}'); + expect(interpolate('Missing {name}', inheritedParams)).toBe('Missing {name}'); + expect(interpolate('Missing {name}', { name: null })).toBe('Missing {name}'); + expect(interpolate('Found {constructor}', explicitParams)).toBe('Found explicit'); + }); }); From 6603b24e0bb0844ab33c35b0e9e33b3e21ebfbf1 Mon Sep 17 00:00:00 2001 From: jhihjian Date: Thu, 11 Jun 2026 17:09:08 +0800 Subject: [PATCH 04/11] fix: guard i18n key lookup --- src/i18n/index.ts | 4 ++++ test/unit/i18n.test.node.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/i18n/index.ts b/src/i18n/index.ts index b23c99d3..ce487604 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -36,6 +36,10 @@ function resolveKey(locale: SupportedLocale, key: string): string | undefined { return undefined; } + if (!Object.prototype.hasOwnProperty.call(current, part)) { + return undefined; + } + return current[part]; }, messages[locale]); diff --git a/test/unit/i18n.test.node.ts b/test/unit/i18n.test.node.ts index bbebe0eb..cb07acee 100644 --- a/test/unit/i18n.test.node.ts +++ b/test/unit/i18n.test.node.ts @@ -103,6 +103,10 @@ describe('i18n', () => { expect(translate('missing.key', 'zh-CN')).toBe('missing.key'); }); + test('does not resolve missing keys through the object prototype chain', () => { + expect(translate('constructor.name', 'en')).toBe('constructor.name'); + }); + test('interpolates named params', () => { expect(translate('activity.hostLabel', 'en', { host: 'laptop' })).toBe('Host: laptop'); expect(translate('activity.hostLabel', 'zh-CN', { host: 'laptop' })).toBe('主机:laptop'); From 9f643a37d2b9a74ee16511072c6ec1bf6b96fd36 Mon Sep 17 00:00:00 2001 From: jhihjian Date: Thu, 11 Jun 2026 17:17:15 +0800 Subject: [PATCH 05/11] feat: persist display language setting --- src/App.vue | 11 ++ src/globals.d.ts | 18 ++- src/main.js | 3 + src/stores/settings.ts | 12 ++ test/unit/store/settings.test.node.ts | 191 +++++++++++++++++++++++++- 5 files changed, 226 insertions(+), 9 deletions(-) diff --git a/src/App.vue b/src/App.vue index 09ae113e..baea8f5e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,6 +16,7 @@ div#wrapper(v-if="loaded") import { useSettingsStore } from '~/stores/settings'; import { useServerStore } from '~/stores/server'; import { detectPreferredTheme } from '~/util/theme'; +import { setLocale } from '~/i18n'; // if vite is used, you can import css file as module //import darkCssUrl from '../static/dark.css?url'; //import darkCssContent from '../static/dark.css?inline'; @@ -33,12 +34,22 @@ export default { fullContainer() { return this.$route.meta.fullContainer; }, + language() { + return useSettingsStore().language; + }, + }, + + watch: { + language(language: string) { + setLocale(language); + }, }, async beforeCreate() { // Get Theme From LocalStorage const settingsStore = useSettingsStore(); await settingsStore.ensureLoaded(); + setLocale(settingsStore.language); const theme = settingsStore.theme; const detectedTheme = theme === 'auto' ? detectPreferredTheme() : theme; diff --git a/src/globals.d.ts b/src/globals.d.ts index 29b22efe..d153572e 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -1,7 +1,19 @@ // We will disable the no-shadow eslint rule for the entire file: /* eslint-disable no-shadow */ +import type { TranslationParams } from '~/i18n'; + // Constants set at compile time -declare const PRODUCTION: boolean; -declare const AW_SERVER_URL: string; -declare const COMMIT_HASH: string; +declare global { + const PRODUCTION: boolean; + const AW_SERVER_URL: string; + const COMMIT_HASH: string; +} + +declare module 'vue/types/vue' { + interface Vue { + $t(key: string, params?: TranslationParams): string; + } +} + +export {}; diff --git a/src/main.js b/src/main.js index 54dcca01..5401d743 100644 --- a/src/main.js +++ b/src/main.js @@ -22,6 +22,9 @@ import './style/style.scss'; // Loads all the filters import './util/filters.js'; +import { installI18n } from './i18n'; +installI18n(); + // Sets up the routing and the base app (using vue-router) import router from './route.js'; diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 34f39def..5403af87 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -6,6 +6,7 @@ import { SavedQuery } from '~/util/savedQueries'; import { View, defaultViews } from '~/stores/views'; import type { PrivacyFilterRule } from '~/util/privacyFilters'; import { isEqual } from 'lodash'; +import { DEFAULT_LOCALE, isSupportedLocale, type SupportedLocale } from '~/i18n'; function jsonEq(a: any, b: any) { const jsonA = JSON.parse(JSON.stringify(a)); @@ -32,6 +33,7 @@ interface State { useColorFallback: boolean; landingpage: string; theme: 'light' | 'dark' | 'auto'; + language: SupportedLocale; newReleaseCheckData: Record; userSatisfactionPollData: { @@ -79,6 +81,7 @@ export const useSettingsStore = defineStore('settings', { landingpage: '/home', theme: 'auto', + language: 'en', newReleaseCheckData: { isEnabled: true, @@ -179,6 +182,8 @@ export const useSettingsStore = defineStore('settings', { parsed = parsed.map(cleanCategory); } storage[key] = parsed; + } else if (key == 'language' && !isSupportedLocale(raw)) { + storage[key] = DEFAULT_LOCALE; } else if (raw === 'true' || raw === 'false') { storage[key] = raw === 'true'; } else { @@ -188,6 +193,13 @@ export const useSettingsStore = defineStore('settings', { console.error('failed to parse', key, raw, e); } } + if ( + storage.language !== undefined && + (typeof storage.language !== 'string' || !isSupportedLocale(storage.language)) + ) { + storage.language = DEFAULT_LOCALE; + } + this.$patch({ ...storage, _loaded: true }); // Since `requestTimeout` is used to initialize the client, we need to set it again diff --git a/test/unit/store/settings.test.node.ts b/test/unit/store/settings.test.node.ts index ff1f32fc..2ceb2ec3 100644 --- a/test/unit/store/settings.test.node.ts +++ b/test/unit/store/settings.test.node.ts @@ -1,17 +1,98 @@ import { setActivePinia, createPinia } from 'pinia'; - import { useSettingsStore } from '~/stores/settings'; +import { DEFAULT_LOCALE, getLocale, setLocale } from '~/i18n'; -describe('settings store', () => { - setActivePinia(createPinia()); - const settingsStore = useSettingsStore(); +const App = require('~/App.vue').default; + +const postMock = jest.fn(); +const getSettingsMock = jest.fn(); +let consoleLogSpy: jest.SpyInstance; + +jest.mock('~/util/awclient', () => ({ + getClient: () => ({ + get_settings: getSettingsMock, + req: { + defaults: { + timeout: 0, + }, + post: postMock, + }, + }), +})); + +type MemoryStorage = Storage & { + store: Record; +}; +function createLocalStorage(items: Record = {}): MemoryStorage { + const storage = { + store: { ...items }, + get length() { + return Object.keys(this.store).length; + }, + clear() { + this.store = {}; + }, + getItem(key: string) { + return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null; + }, + key(index: number) { + return Object.keys(this.store)[index] ?? null; + }, + removeItem(key: string) { + delete this.store[key]; + }, + setItem(key: string, value: string) { + this.store[key] = String(value); + }, + }; + + return new Proxy(storage, { + get(target, property) { + if (typeof property === 'string' && property in target.store) { + return target.store[property]; + } + + return target[property]; + }, + ownKeys(target) { + return Reflect.ownKeys(target.store); + }, + getOwnPropertyDescriptor(target, property) { + if (typeof property === 'string' && property in target.store) { + return { + configurable: true, + enumerable: true, + value: target.store[property], + }; + } + + return undefined; + }, + }) as MemoryStorage; +} + +describe('settings store', () => { beforeEach(() => { - settingsStore.$reset(); - jest.restoreAllMocks(); + setActivePinia(createPinia()); + getSettingsMock.mockResolvedValue({}); + postMock.mockResolvedValue({}); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + Object.defineProperty(global, 'localStorage', { + configurable: true, + value: createLocalStorage(), + }); + setLocale(DEFAULT_LOCALE); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + jest.clearAllMocks(); + setLocale(DEFAULT_LOCALE); }); test('ensureLoaded coalesces concurrent loads', async () => { + const settingsStore = useSettingsStore(); let resolveLoad!: () => void; const loadMock = jest.spyOn(settingsStore, 'load').mockImplementation( () => @@ -35,6 +116,7 @@ describe('settings store', () => { }); test('update waits for settings to load before patching state', async () => { + const settingsStore = useSettingsStore(); const savedQueries = [ { id: 'daily-coding-time', @@ -63,4 +145,101 @@ describe('settings store', () => { expect(steps).toEqual(['ensureLoaded', 'save']); expect(settingsStore.saved_queries).toEqual(savedQueries); }); + + test('defaults to English', () => { + const settingsStore = useSettingsStore(); + expect(settingsStore.language).toBe('en'); + }); + + test('can update language in store state', () => { + const settingsStore = useSettingsStore(); + settingsStore.$patch({ language: 'zh-CN' }); + expect(settingsStore.language).toBe('zh-CN'); + }); + + test('loads supported language from server settings', async () => { + getSettingsMock.mockResolvedValue({ language: 'zh-CN' }); + const settingsStore = useSettingsStore(); + + await settingsStore.load(); + + expect(settingsStore.language).toBe('zh-CN'); + }); + + test('falls back to default language when server settings contain an unsupported language', async () => { + getSettingsMock.mockResolvedValue({ language: 'fr-FR' }); + const settingsStore = useSettingsStore(); + + await settingsStore.load(); + + expect(settingsStore.language).toBe(DEFAULT_LOCALE); + }); + + test.each(['true', 'false'])( + 'falls back to default language when server settings contain boolean-like language %s', + async language => { + getSettingsMock.mockResolvedValue({ language }); + const settingsStore = useSettingsStore(); + + await settingsStore.load(); + + expect(settingsStore.language).toBe(DEFAULT_LOCALE); + } + ); + + test('falls back to default language when localStorage contains an unsupported language', async () => { + getSettingsMock.mockResolvedValue({}); + Object.defineProperty(global, 'localStorage', { + configurable: true, + value: createLocalStorage({ language: 'fr-FR' }), + }); + const settingsStore = useSettingsStore(); + + await settingsStore.load(); + + expect(settingsStore.language).toBe(DEFAULT_LOCALE); + }); + + test.each(['true', 'false'])( + 'falls back to default language when localStorage contains boolean-like language %s', + async language => { + getSettingsMock.mockResolvedValue({}); + Object.defineProperty(global, 'localStorage', { + configurable: true, + value: createLocalStorage({ language }), + }); + const settingsStore = useSettingsStore(); + + await settingsStore.load(); + + expect(settingsStore.language).toBe(DEFAULT_LOCALE); + } + ); + + test('saves language through the backend settings endpoint', async () => { + getSettingsMock.mockResolvedValue({}); + const settingsStore = useSettingsStore(); + settingsStore.$patch({ + _loaded: true, + language: 'zh-CN', + }); + + await settingsStore.save(); + + expect(postMock).toHaveBeenCalledWith('/0/settings/language', 'zh-CN', { + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + test('App language watcher syncs the active locale', () => { + const watcher = (App as any).watch.language; + + watcher('zh-CN'); + expect(getLocale()).toBe('zh-CN'); + + watcher('en'); + expect(getLocale()).toBe('en'); + }); }); From 7509601633a2330acbfadfe2ddab0ca94137d626 Mon Sep 17 00:00:00 2001 From: jhihjian Date: Thu, 11 Jun 2026 18:59:52 +0800 Subject: [PATCH 06/11] feat: add language selector to settings --- src/views/settings/LanguageSettings.vue | 41 +++++++++++++++++++ src/views/settings/Settings.vue | 3 ++ test/unit/LanguageSettings.test.js | 53 +++++++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 src/views/settings/LanguageSettings.vue create mode 100644 test/unit/LanguageSettings.test.js diff --git a/src/views/settings/LanguageSettings.vue b/src/views/settings/LanguageSettings.vue new file mode 100644 index 00000000..a95017aa --- /dev/null +++ b/src/views/settings/LanguageSettings.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/views/settings/Settings.vue b/src/views/settings/Settings.vue index ad66f197..516614ff 100644 --- a/src/views/settings/Settings.vue +++ b/src/views/settings/Settings.vue @@ -32,6 +32,7 @@ import ReleaseNotificationSettings from '~/views/settings/ReleaseNotificationSet import UncategorizedHintSettings from '~/views/settings/UncategorizedHintSettings.vue'; import CategorizationSettings from '~/views/settings/CategorizationSettings.vue'; import LandingPageSettings from '~/views/settings/LandingPageSettings.vue'; +import LanguageSettings from '~/views/settings/LanguageSettings.vue'; import DeveloperSettings from '~/views/settings/DeveloperSettings.vue'; import Theme from '~/views/settings/Theme.vue'; import ColorSettings from '~/views/settings/ColorSettings.vue'; @@ -54,6 +55,7 @@ export default { UncategorizedHintSettings, CategorizationSettings, LandingPageSettings, + LanguageSettings, Theme, ColorSettings, DeveloperSettings, @@ -91,6 +93,7 @@ export default { { name: 'DaystartSettings' }, { name: 'TimelineDurationSettings' }, { name: 'LandingPageSettings' }, + { name: 'LanguageSettings' }, { name: 'UncategorizedHintSettings' }, // Release-notification check folded in here so it doesn't // need its own one-setting "Updates" panel. diff --git a/test/unit/LanguageSettings.test.js b/test/unit/LanguageSettings.test.js new file mode 100644 index 00000000..10939c74 --- /dev/null +++ b/test/unit/LanguageSettings.test.js @@ -0,0 +1,53 @@ +import { shallowMount } from '@vue/test-utils'; +import { createTestingPinia } from '@pinia/testing'; +import LanguageSettings from '~/views/settings/LanguageSettings.vue'; +import { getLocale, installI18n, setLocale } from '~/i18n'; +import { useSettingsStore } from '~/stores/settings'; + +installI18n(); + +describe('LanguageSettings', () => { + beforeEach(() => { + setLocale('en'); + }); + + function mountLanguageSettings() { + return shallowMount(LanguageSettings, { + global: { + plugins: [ + createTestingPinia({ + initialState: { + settings: { + _loaded: true, + }, + }, + stubActions: true, + }), + ], + }, + stubs: { + 'b-select': { + template: '', + }, + }, + }); + } + + test('renders language options', () => { + const wrapper = mountLanguageSettings(); + + expect(wrapper.text()).toContain('Language'); + expect(wrapper.text()).toContain('English'); + expect(wrapper.text()).toContain('简体中文'); + }); + + test('updates the display language when a locale is selected', async () => { + const wrapper = mountLanguageSettings(); + const settingsStore = useSettingsStore(); + + await wrapper.find('select').setValue('zh-CN'); + + expect(settingsStore.update).toHaveBeenCalledWith({ language: 'zh-CN' }); + expect(getLocale()).toBe('zh-CN'); + }); +}); From 1dade1f3bd1f4e1a968179baf8a1f243b2481ff2 Mon Sep 17 00:00:00 2001 From: jhihjian Date: Thu, 11 Jun 2026 19:26:14 +0800 Subject: [PATCH 07/11] feat: localize navigation and common UI --- src/components/Footer.vue | 17 ++-- src/components/Header.vue | 36 ++++---- src/components/InputTimeInterval.vue | 23 +++-- src/components/NewReleaseNotification.vue | 14 +-- src/components/UncategorizedNotification.vue | 35 ++++--- src/i18n/locales/en.ts | 97 ++++++++++++++++++++ src/i18n/locales/zh-CN.ts | 93 +++++++++++++++++++ src/views/Home.vue | 73 ++++++++------- 8 files changed, 302 insertions(+), 86 deletions(-) diff --git a/src/components/Footer.vue b/src/components/Footer.vue index 8ad67c61..6a28cc95 100644 --- a/src/components/Footer.vue +++ b/src/components/Footer.vue @@ -1,30 +1,31 @@ diff --git a/src/visualizations/VisTimeline.vue b/src/visualizations/VisTimeline.vue index 90501f76..f9c83c40 100644 --- a/src/visualizations/VisTimeline.vue +++ b/src/visualizations/VisTimeline.vue @@ -3,7 +3,7 @@ div#visualization div.small.text-muted.my-2(v-if="bucketsFromEither.length != 1") - i Buckets with no events in the queried range will be hidden. + i {{ $t('visualizationStatus.hiddenBuckets') }} div(v-if="editingEvent") EventEditor(:event="editingEvent" :bucket_id="editingEventBucket") @@ -60,6 +60,7 @@ import { getCategoryColorFromEvent, getTitleAttr } from '../util/color'; import { getSwimlane } from '../util/swimlane.js'; import { IEvent } from '../util/interfaces'; import { formatTimelineBucketLabelHtml, shortenBucketLabel } from '../util/timelineLabels'; +import { getLocale } from '~/i18n'; import { Timeline } from 'vis-timeline/esnext'; import 'vis-timeline/styles/vis-timeline-graph2d.css'; @@ -122,6 +123,9 @@ export default { }; }, computed: { + currentLocale() { + return getLocale(); + }, bucketsFromEither() { if (this.buckets) { return this.buckets; @@ -186,6 +190,9 @@ export default { this.update(); }, + currentLocale() { + this.update(); + }, }, mounted() { this.$nextTick(() => { @@ -271,8 +278,8 @@ export default { // edit flow. Persist the dismissal via localStorage so the user // doesn't see it every session. if (!this.editRefreshHintDismissed()) { - this.$bvToast.toast('Your edit is saved. Refresh the timeline to see it reflected.', { - title: 'Heads up', + this.$bvToast.toast(this.$t('visualizationStatus.refreshRequiredAfterEditToast'), { + title: this.$t('visualizationStatus.headsUp'), variant: 'info', autoHideDelay: 6000, solid: true, @@ -282,7 +289,11 @@ export default { isAlertWarningShown = true; } } else { - alert('selected multiple items: ' + JSON.stringify(properties.items)); + alert( + this.$t('visualizationStatus.selectedMultipleItems', { + items: JSON.stringify(properties.items), + }) + ); } }, abbreviateBucketName(bucketId: string): string { @@ -388,7 +399,10 @@ export default { if (groups.length > 0 && items.length > 0) { if (this.queriedInterval && this.showQueriedInterval) { const duration = this.queriedInterval[1].diff(this.queriedInterval[0], 'seconds'); - groups.push({ id: String(groups.length), content: 'queried interval' }); + groups.push({ + id: String(groups.length), + content: this.$t('visualizationStatus.queriedInterval'), + }); items.push({ id: String(items.length + 1), group: groups.length - 1, @@ -400,7 +414,7 @@ export default { data: { title: 'test' }, } ), - content: 'query', + content: this.$t('visualizationStatus.query'), start: this.queriedInterval[0], end: this.queriedInterval[1], style: 'background-color: #aaa; height: 10px', diff --git a/src/visualizations/periodusage.ts b/src/visualizations/periodusage.ts index eaebb547..100ab209 100644 --- a/src/visualizations/periodusage.ts +++ b/src/visualizations/periodusage.ts @@ -3,6 +3,7 @@ import _ from 'lodash'; import moment from 'moment'; import { seconds_to_duration, get_hour_offset } from '../util/time.ts'; +import { t } from '~/i18n'; function create(svg_elem: SVGElement) { // Clear element @@ -34,7 +35,7 @@ function update(svg_elem: SVGElement, usage_arr, onPeriodClicked) { // No apps, sets status to "No data" if (usage_arr.length <= 0) { - set_status(svg_elem, 'No data'); + set_status(svg_elem, t('visualizationStatus.noData')); return; } svg_elem.innerHTML = ''; @@ -84,7 +85,7 @@ function update(svg_elem: SVGElement, usage_arr, onPeriodClicked) { .append('text') .attr('x', x + 1.5 * width + '%') .attr('y', '30') - .text('Today'); + .text(t('visualizationStatus.today')); } const rect = svg diff --git a/src/visualizations/summary.ts b/src/visualizations/summary.ts index f0f3dc5a..75ac26bc 100644 --- a/src/visualizations/summary.ts +++ b/src/visualizations/summary.ts @@ -8,6 +8,7 @@ import { useCategoryStore } from '~/stores/categories'; import { getCategoryColorFromString } from '~/util/color'; import { seconds_to_duration } from '~/util/time'; import { IEvent } from '~/util/interfaces'; +import { t } from '~/i18n'; const textColor = '#333'; @@ -49,7 +50,7 @@ interface Entry { function update(container: HTMLElement, apps: Entry[]) { // No apps, sets status to "No data" if (apps.length <= 0) { - set_status(container, 'No data'); + set_status(container, t('visualizationStatus.noData')); return container; } diff --git a/src/visualizations/timeline-simple.ts b/src/visualizations/timeline-simple.ts index 1e6ce6b9..78693ecf 100644 --- a/src/visualizations/timeline-simple.ts +++ b/src/visualizations/timeline-simple.ts @@ -6,6 +6,7 @@ const _ = require('lodash'); const moment = require('moment'); import { getTitleAttr, getColorFromString } from '../util/color'; +import { t } from '~/i18n'; const time = require('../util/time'); @@ -36,7 +37,7 @@ function update(svg_el, events, event_type: string) { timeline.selectAll('*').remove(); if (events.length <= 0) { - set_status(svg_el, 'No data'); + set_status(svg_el, t('visualizationStatus.noData')); return; } @@ -86,7 +87,8 @@ function update(svg_el, events, event_type: string) { .text( timestamp.format() + '\n' + - 'Duration: ' + + t('visualizationStatus.duration') + + ': ' + time.seconds_to_duration(e.duration) + '\n' + JSON.stringify(e.data) diff --git a/src/visualizations/timeline.ts b/src/visualizations/timeline.ts index 81b4f9e8..33458ea4 100644 --- a/src/visualizations/timeline.ts +++ b/src/visualizations/timeline.ts @@ -8,6 +8,7 @@ import _ from 'lodash'; import moment from 'moment'; import { getColorFromString } from '../util/color'; +import { t as translate } from '~/i18n'; import { seconds_to_duration } from '../util/time'; import { IEvent } from '../util/interfaces'; @@ -78,7 +79,7 @@ function update( d3.select(container.querySelector('.titleinfo-container')).html(null); if (events.length <= 0) { - set_status(container, 'No data'); + set_status(container, translate('visualizationStatus.noData')); return container; } diff --git a/test/unit/store/categories.test.node.ts b/test/unit/store/categories.test.node.ts index 323c4a52..05df80b0 100644 --- a/test/unit/store/categories.test.node.ts +++ b/test/unit/store/categories.test.node.ts @@ -1,6 +1,7 @@ import { isEqual } from 'lodash'; import { setActivePinia, createPinia } from 'pinia'; +import { setLocale } from '~/i18n'; import { useCategoryStore } from '~/stores/categories'; import { createMissingParents, defaultCategories, Category } from '~/util/classes'; @@ -9,6 +10,7 @@ describe('categories store', () => { const categoryStore = useCategoryStore(); beforeEach(() => { + setLocale('en'); categoryStore.clearAll(); }); @@ -45,6 +47,18 @@ describe('categories store', () => { expect(categoryStore.all_categories).toHaveLength(1); }); + test('translates category select metadata options without changing category names', () => { + categoryStore.load([{ name: ['Test'], rule: { type: 'none' } }]); + + setLocale('zh-CN'); + + expect(categoryStore.category_select(true).slice(0, 3)).toEqual([ + { text: '全部', value: null }, + { text: '未分类', value: ['Uncategorized'] }, + { text: 'Test', value: ['Test'] }, + ]); + }); + test('get category hierarchy', () => { categoryStore.restoreDefaultClasses(); const hier = categoryStore.classes_hierarchy; diff --git a/test/unit/tooltip.test.js b/test/unit/tooltip.test.js new file mode 100644 index 00000000..36642a81 --- /dev/null +++ b/test/unit/tooltip.test.js @@ -0,0 +1,39 @@ +import { buildTooltip } from '~/util/tooltip'; + +function buildWebTooltip(url) { + return buildTooltip( + { type: 'web.tab.current' }, + { + timestamp: '2024-01-01T12:00:00Z', + duration: 60, + data: { + title: 'Example', + url, + }, + } + ); +} + +function parseTooltip(html) { + const container = document.createElement('div'); + container.innerHTML = html; + return container; +} + +describe('buildTooltip', () => { + test('does not allow web URLs to inject attributes', () => { + const tooltip = parseTooltip(buildWebTooltip('https://example.com/" onclick="alert(1)')); + const anchor = tooltip.querySelector('a'); + + expect(anchor).not.toBeNull(); + expect(anchor.getAttribute('onclick')).toBeNull(); + }); + + test('does not render javascript URLs as clickable links', () => { + const tooltip = parseTooltip(buildWebTooltip('javascript:alert(1)')); + const anchor = tooltip.querySelector('a'); + + expect(anchor).toBeNull(); + expect(tooltip.textContent).toContain('javascript:alert(1)'); + }); +}); From 341aaa3b0756b5a791c9a648827ade02fe9c5253 Mon Sep 17 00:00:00 2001 From: jhihjian Date: Thu, 11 Jun 2026 21:59:33 +0800 Subject: [PATCH 11/11] feat: localize remaining interface text --- src/components/BucketMerge.vue | 34 ++-- src/components/BucketValidate.vue | 30 +-- src/components/CategoryEditModal.vue | 43 ++--- src/components/CategoryEditTree.vue | 6 +- src/components/DevOnly.vue | 4 +- src/components/ErrorBoundary.vue | 2 +- src/components/EventEditor.vue | 24 +-- src/components/StopwatchEntry.vue | 14 +- src/components/UserSatisfactionPoll.vue | 28 +-- src/i18n/locales/en.ts | 232 +++++++++++++++++++++++- src/i18n/locales/zh-CN.ts | 219 +++++++++++++++++++++- src/views/Alerts.vue | 33 ++-- src/views/Buckets.vue | 5 +- src/views/Dev.vue | 6 +- src/views/Graph.vue | 31 ++-- src/views/NotFound.vue | 4 +- src/views/Timeline.vue | 4 +- src/views/TimespiralView.vue | 10 +- src/views/Trends.vue | 67 ++++--- src/visualizations/Calendar.vue | 12 +- src/visualizations/CategoryTree.vue | 2 +- src/visualizations/EventList.vue | 10 +- src/visualizations/Score.vue | 18 +- 23 files changed, 645 insertions(+), 193 deletions(-) diff --git a/src/components/BucketMerge.vue b/src/components/BucketMerge.vue index 12bd5035..acadbb2e 100644 --- a/src/components/BucketMerge.vue +++ b/src/components/BucketMerge.vue @@ -1,43 +1,45 @@ diff --git a/src/components/BucketValidate.vue b/src/components/BucketValidate.vue index b817a0bc..b4269ea2 100644 --- a/src/components/BucketValidate.vue +++ b/src/components/BucketValidate.vue @@ -1,18 +1,18 @@