From c96def9dea319a97510135d89a5a85d5c40bb700 Mon Sep 17 00:00:00 2001 From: Invis1ble_ <1434045601@qq.com> Date: Sun, 15 Mar 2026 12:41:15 +0800 Subject: [PATCH 1/4] add i18n provider and Chinese localization --- dist-electron/main.cjs | 824 ++++++++++++++++++++++--- dist-electron/preload.cjs | 49 +- electron/main.ts | 102 +-- electron/preload.ts | 7 +- package.json | 8 +- src/App.tsx | 27 +- src/components/AppUsageTable.tsx | 22 +- src/components/DatePicker.tsx | 27 +- src/components/NotificationSummary.tsx | 8 +- src/components/SuggestionPanel.tsx | 7 +- src/i18n/I18nProvider.tsx | 81 +++ src/i18n/core.ts | 148 +++++ src/i18n/index.ts | 3 + src/i18n/locales/en-US.ts | 318 ++++++++++ src/i18n/locales/zh-CN.ts | 318 ++++++++++ src/i18n/types.ts | 7 + src/main.tsx | 5 +- src/pages/Apps.tsx | 86 +-- src/pages/Dashboard.tsx | 98 +-- src/pages/Insights.tsx | 72 +-- src/pages/Notifications.tsx | 67 +- src/pages/Settings.tsx | 112 ++-- src/services/usageService.ts | 2 + src/types/models.ts | 1 + src/utils/analytics.ts | 34 +- 25 files changed, 2023 insertions(+), 410 deletions(-) create mode 100644 src/i18n/I18nProvider.tsx create mode 100644 src/i18n/core.ts create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/locales/en-US.ts create mode 100644 src/i18n/locales/zh-CN.ts create mode 100644 src/i18n/types.ts diff --git a/dist-electron/main.cjs b/dist-electron/main.cjs index b382cc3..d7a73a1 100644 --- a/dist-electron/main.cjs +++ b/dist-electron/main.cjs @@ -22,7 +22,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge )); // electron/main.ts -var electron = __toESM(require("electron"), 1); +var import_electron3 = require("electron"); var import_node_path = __toESM(require("node:path"), 1); var fs3 = __toESM(require("node:fs"), 1); @@ -773,9 +773,678 @@ var createUsageTracker = () => { }; }; +// src/i18n/locales/en-US.ts +var enUS = { + common: { + today: "Today", + yesterday: "Yesterday", + none: "None", + noData: "No data", + notAvailable: "N/A", + loading: "Loading...", + live: "Live", + focused: "Focused", + tracking: "Tracking", + unknown: "Unknown" + }, + locales: { + "zh-CN": "\u7B80\u4F53\u4E2D\u6587", + "en-US": "English" + }, + nav: { + dashboard: "Dashboard", + insights: "Insights", + apps: "Apps", + notifications: "Notifications", + settings: "Settings" + }, + sidebar: { + focusScore: "Focus score" + }, + themes: { + dark: { + name: "Dark", + description: "Easy on the eyes, perfect for night" + }, + light: { + name: "Light", + description: "Clean and bright for daytime" + }, + tokyo: { + name: "Tokyo", + description: "Cyberpunk vibes with purple accents" + }, + skin: { + name: "Skin", + description: "Warm and soft aesthetic" + } + }, + dashboard: { + greeting: { + morning: "Good morning", + afternoon: "Good afternoon", + evening: "Good evening" + }, + subtitle: "Your screen time for today", + cards: { + screenTime: "Screen Time", + totalToday: "Total today", + dailyAverage: "Daily Avg", + acrossDays: "Across {count} days", + noHistory: "No history yet", + topCategory: "Top Category", + notifications: "Notifications", + topNotificationApp: "Top: {name}" + }, + charts: { + dailyUsageTitle: "Daily usage", + dailyUsageSubtitle: "Minutes per day across all apps", + dailyUsageEmpty: "Start using apps to see your usage data", + categoriesTitle: "Categories", + categoriesSubtitle: "Time by category today", + categoriesEmpty: "No category data yet", + usageDataset: "Usage (minutes)", + minutesDataset: "Minutes" + }, + sections: { + topAppsToday: "Top Apps Today", + activeApps: "Active apps", + activeAppsSubtitle: "Currently open windows", + activeAppsEmpty: "No apps with visible windows detected", + currentFocus: "Current focus", + appsUsedToday: "Apps used today", + daysRecorded: "Days recorded", + noActiveApp: "No active app", + otherApps: "Other apps" + }, + trend: { + collecting: "Collecting data", + newData: "New data", + up: "Trending up", + down: "Trending down", + stable: "Stable" + } + }, + insights: { + title: "Insights", + subtitle: "Deep dive into your screen time patterns", + cards: { + focusScore: "Focus Score", + focusScoreSub: "Based on productive app usage", + appsUsed: "Apps Used", + topApp: "Top app", + topAppSub: "Most used by minutes", + totalTracked: "Total tracked", + totalTrackedSub: "All time screen time" + }, + charts: { + weeklyTrendTitle: "Weekly trend", + weeklyTrendSubtitle: "Screen time over the last 7 days", + weeklyTrendEmpty: "Start using apps to see trends", + categoryBreakdownTitle: "Category breakdown", + categoryBreakdownSubtitle: "How you spend your screen time", + categoryBreakdownEmpty: "No category data yet", + minutesDataset: "Minutes" + }, + quickStats: { + title: "Quick stats", + daysTracked: "Days tracked", + appsUsed: "Apps used", + dailyAverage: "Daily average", + topCategory: "Top category", + peakDay: "Peak day" + } + }, + apps: { + title: "Apps", + subtitle: "Screen time for {date}", + dateView: "View:", + stats: { + totalTime: "Total Time", + appsUsed: "Apps Used", + topCategory: "Top Category" + }, + openApps: { + title: "Open apps", + subtitle: "Apps with visible windows", + empty: "No apps with visible windows detected yet." + }, + backgroundApps: { + title: "Background processes", + subtitle: "Running without visible windows", + empty: "No background processes detected.", + processCount: "{count} process(es)" + }, + timeLimits: { + title: "Active Time Limits", + subtitle: "{count} app(s) with limits", + usage: "{used} / {limit} min", + exceeded: "Exceeded", + setLimit: "Set time limit", + edit: "Edit", + remove: "Remove", + save: "Save", + cancel: "Cancel", + limit: "Limit:", + minutesPlaceholder: "Minutes", + perDayUnit: "min/day" + }, + controls: { + searchPlaceholder: "Search apps...", + sortBy: "Sort by:", + time: "Time", + name: "Name", + category: "Category" + }, + empty: { + search: "No apps match your search", + date: "No apps tracked for {date}" + }, + cards: { + percentageOfTotal: "{count}% of total" + } + }, + notifications: { + title: "Notifications", + subtitle: "Track notification activity from your apps today", + status: { + disabledTitle: "Notification Logs Disabled", + disabledMessage: "Windows notification logs are disabled. Enable them in Event Viewer or run as Administrator.", + errorTitle: "Error Reading Logs", + errorMessage: "Failed to read notification logs.", + errorWithDetails: "Failed to read notification logs: {detail}" + }, + stats: { + totalToday: "Total Today", + apps: "Apps", + avgPerApp: "Avg per App", + topSender: "Top Sender" + }, + breakdown: { + title: "Breakdown by App", + subtitle: "Notifications received today per application", + countBadge: "{count} apps", + app: "Application", + count: "Count", + emptyTitle: "No notifications tracked yet", + emptySubtitle: "Notifications from your apps will appear here as they come in.", + emptyActionSubtitle: "Fix the issue above to start tracking notifications.", + otherApps: "Other Apps" + }, + tips: { + title: "Reduce Distractions", + subtitle: "Tips for managing notification overload", + focusAssistTitle: "Enable Focus Assist", + focusAssistDesc: "Use Windows Focus Assist to silence notifications during work hours. Access it from the Action Center or Settings.", + appNotificationsTitle: "Configure App Notifications", + appNotificationsDesc: "Go to Windows Settings > System > Notifications to customize which apps can send you notifications.", + timeLimitsTitle: "Set Time Limits", + timeLimitsDesc: "Use the Apps page to set daily time limits for distracting applications. You will receive a notification when limits are exceeded." + } + }, + settings: { + title: "Settings", + subtitle: "Customize your ScreenForge experience", + loadingSubtitle: "Loading...", + sections: { + appearance: "Appearance", + appearanceDesc: "Choose your preferred theme", + language: "Language", + languageDesc: "Choose your interface language", + behavior: "Behavior", + behaviorDesc: "Control how ScreenForge runs", + timeLimits: "Time Limits", + timeLimitsDesc: "Control app usage notifications", + data: "Data", + dataDesc: "Manage your tracked data", + about: "About" + }, + behavior: { + startWithWindows: "Start with Windows", + startWithWindowsDesc: "Launch ScreenForge when you log in", + minimizeToTray: "Minimize to tray", + minimizeToTrayDesc: "Keep running in background when closed" + }, + timeLimits: { + notifications: "Time limit notifications", + notificationsDesc: "Get notified when you exceed app time limits", + activeLimits: "Active limits", + activeLimitsDesc: "You have {count} app(s) with time limits. Manage them on the Apps page." + }, + data: { + clearAll: "Clear all data", + clearAllDesc: "Remove all tracked usage history", + clearButton: "Clear data", + clearConfirm: "Are you sure? This will delete all your usage data." + }, + about: { + version: "Version", + platform: "Platform", + builtWith: "Built with", + windows: "Windows", + techStack: "Electron + React" + } + }, + tables: { + topApps: "Top apps", + usageToday: "Usage today", + app: "App", + category: "Category", + time: "Time", + notifications: "Notifications", + empty: "No app data yet for today", + notificationToday: "Today", + notificationEmpty: "No notifications yet" + }, + datePicker: { + previousMonth: "Previous month", + nextMonth: "Next month", + dataAvailable: "Data available", + dataRange: "Data from {start} to {end}" + }, + suggestions: { + title: "Suggestions", + subtitle: "Based on your recent activity", + welcome: { + title: "Welcome to ScreenForge!", + detail: "Keep the app running to track your screen time automatically." + }, + entertainment: { + title: "High entertainment usage", + detail: "Consider setting time limits for entertainment apps to boost productivity." + }, + social: { + title: "Social apps taking over", + detail: "Try scheduling specific times for checking social media." + }, + productive: { + title: "Great focus!", + detail: "You're spending most of your time on productive tasks. Keep it up!" + }, + balance: { + title: "Balanced usage", + detail: "Your screen time is well distributed across different activities." + }, + breaks: { + title: "Remember to take breaks", + detail: "Use the 20-20-20 rule: every 20 minutes, look at something 20 feet away for 20 seconds." + } + }, + native: { + timeLimitReachedTitle: "Time Limit Reached", + timeLimitReachedBody: "You've used {appName} for {usedMinutes} minutes today. Your limit is {limitMinutes} minutes.", + trayTooltip: "ScreenForge - Screen Time Tracker", + trayShow: "Show ScreenForge", + trayQuit: "Quit" + }, + categories: { + Productivity: "Productivity", + Education: "Education", + Communication: "Communication", + Utilities: "Utilities", + Browsers: "Browsers", + Entertainment: "Entertainment", + Social: "Social", + System: "System", + Other: "Other", + Unknown: "Unknown" + } +}; + +// src/i18n/locales/zh-CN.ts +var zhCN = { + common: { + today: "\u4ECA\u5929", + yesterday: "\u6628\u5929", + none: "\u65E0", + noData: "\u6682\u65E0\u6570\u636E", + notAvailable: "\u6682\u65E0", + loading: "\u52A0\u8F7D\u4E2D...", + live: "\u5B9E\u65F6", + focused: "\u5F53\u524D\u7126\u70B9", + tracking: "\u8FFD\u8E2A\u4E2D", + unknown: "\u672A\u77E5" + }, + locales: { + "zh-CN": "\u7B80\u4F53\u4E2D\u6587", + "en-US": "English" + }, + nav: { + dashboard: "\u4EEA\u8868\u76D8", + insights: "\u6D1E\u5BDF", + apps: "\u5E94\u7528", + notifications: "\u901A\u77E5", + settings: "\u8BBE\u7F6E" + }, + sidebar: { + focusScore: "\u4E13\u6CE8\u5206" + }, + themes: { + dark: { + name: "\u6DF1\u8272", + description: "\u66F4\u62A4\u773C\uFF0C\u9002\u5408\u591C\u95F4\u4F7F\u7528" + }, + light: { + name: "\u6D45\u8272", + description: "\u6E05\u723D\u660E\u4EAE\uFF0C\u9002\u5408\u767D\u5929\u4F7F\u7528" + }, + tokyo: { + name: "\u4E1C\u4EAC", + description: "\u5E26\u6709\u9713\u8679\u611F\u7684\u672A\u6765\u98CE\u914D\u8272" + }, + skin: { + name: "\u6696\u80A4", + description: "\u6E29\u6696\u67D4\u548C\u7684\u754C\u9762\u98CE\u683C" + } + }, + dashboard: { + greeting: { + morning: "\u65E9\u4E0A\u597D", + afternoon: "\u4E0B\u5348\u597D", + evening: "\u665A\u4E0A\u597D" + }, + subtitle: "\u67E5\u770B\u4F60\u4ECA\u5929\u7684\u5C4F\u5E55\u4F7F\u7528\u60C5\u51B5", + cards: { + screenTime: "\u5C4F\u5E55\u65F6\u957F", + totalToday: "\u4ECA\u65E5\u603B\u8BA1", + dailyAverage: "\u65E5\u5747", + acrossDays: "\u8FD1 {count} \u5929\u5E73\u5747", + noHistory: "\u6682\u65E0\u5386\u53F2\u8BB0\u5F55", + topCategory: "\u6700\u9AD8\u5206\u7C7B", + notifications: "\u901A\u77E5\u6570", + topNotificationApp: "\u6700\u9AD8\uFF1A{name}" + }, + charts: { + dailyUsageTitle: "\u6BCF\u65E5\u4F7F\u7528\u8D8B\u52BF", + dailyUsageSubtitle: "\u6240\u6709\u5E94\u7528\u6BCF\u5929\u7684\u4F7F\u7528\u5206\u949F\u6570", + dailyUsageEmpty: "\u5F00\u59CB\u4F7F\u7528\u5E94\u7528\u540E\uFF0C\u8FD9\u91CC\u4F1A\u663E\u793A\u4F60\u7684\u4F7F\u7528\u6570\u636E", + categoriesTitle: "\u5206\u7C7B\u5206\u5E03", + categoriesSubtitle: "\u4ECA\u5929\u5404\u5206\u7C7B\u7684\u4F7F\u7528\u65F6\u957F", + categoriesEmpty: "\u6682\u65E0\u5206\u7C7B\u6570\u636E", + usageDataset: "\u4F7F\u7528\u65F6\u957F\uFF08\u5206\u949F\uFF09", + minutesDataset: "\u5206\u949F" + }, + sections: { + topAppsToday: "\u4ECA\u65E5\u5E38\u7528\u5E94\u7528", + activeApps: "\u6D3B\u8DC3\u5E94\u7528", + activeAppsSubtitle: "\u5F53\u524D\u5DF2\u6253\u5F00\u7684\u7A97\u53E3", + activeAppsEmpty: "\u6682\u672A\u68C0\u6D4B\u5230\u53EF\u89C1\u7A97\u53E3\u5E94\u7528", + currentFocus: "\u5F53\u524D\u4E13\u6CE8\u5E94\u7528", + appsUsedToday: "\u4ECA\u65E5\u4F7F\u7528\u5E94\u7528\u6570", + daysRecorded: "\u8BB0\u5F55\u5929\u6570", + noActiveApp: "\u5F53\u524D\u6CA1\u6709\u6D3B\u8DC3\u5E94\u7528", + otherApps: "\u5176\u4ED6\u5E94\u7528" + }, + trend: { + collecting: "\u6B63\u5728\u6536\u96C6\u6570\u636E", + newData: "\u521A\u5F00\u59CB\u8BB0\u5F55", + up: "\u5448\u4E0A\u5347\u8D8B\u52BF", + down: "\u5448\u4E0B\u964D\u8D8B\u52BF", + stable: "\u57FA\u672C\u7A33\u5B9A" + } + }, + insights: { + title: "\u6D1E\u5BDF", + subtitle: "\u66F4\u6DF1\u5165\u5730\u4E86\u89E3\u4F60\u7684\u5C4F\u5E55\u4F7F\u7528\u6A21\u5F0F", + cards: { + focusScore: "\u4E13\u6CE8\u5206", + focusScoreSub: "\u57FA\u4E8E\u9AD8\u6548\u5E94\u7528\u4F7F\u7528\u60C5\u51B5\u8BA1\u7B97", + appsUsed: "\u4F7F\u7528\u5E94\u7528\u6570", + topApp: "\u6700\u5E38\u7528\u5E94\u7528", + topAppSub: "\u6309\u5206\u949F\u6570\u7EDF\u8BA1", + totalTracked: "\u7D2F\u8BA1\u8BB0\u5F55", + totalTrackedSub: "\u5168\u90E8\u5386\u53F2\u5C4F\u5E55\u65F6\u957F" + }, + charts: { + weeklyTrendTitle: "\u8FD1\u4E00\u5468\u8D8B\u52BF", + weeklyTrendSubtitle: "\u8FC7\u53BB 7 \u5929\u7684\u5C4F\u5E55\u4F7F\u7528\u65F6\u957F", + weeklyTrendEmpty: "\u5F00\u59CB\u4F7F\u7528\u5E94\u7528\u540E\uFF0C\u8FD9\u91CC\u4F1A\u663E\u793A\u8D8B\u52BF", + categoryBreakdownTitle: "\u5206\u7C7B\u5360\u6BD4", + categoryBreakdownSubtitle: "\u4F60\u7684\u5C4F\u5E55\u65F6\u95F4\u4E3B\u8981\u82B1\u5728\u54EA\u91CC", + categoryBreakdownEmpty: "\u6682\u65E0\u5206\u7C7B\u6570\u636E", + minutesDataset: "\u5206\u949F" + }, + quickStats: { + title: "\u901F\u89C8\u7EDF\u8BA1", + daysTracked: "\u8BB0\u5F55\u5929\u6570", + appsUsed: "\u4F7F\u7528\u5E94\u7528\u6570", + dailyAverage: "\u65E5\u5747\u65F6\u957F", + topCategory: "\u6700\u9AD8\u5206\u7C7B", + peakDay: "\u5CF0\u503C\u65E5\u671F" + } + }, + apps: { + title: "\u5E94\u7528", + subtitle: "{date} \u7684\u5C4F\u5E55\u4F7F\u7528\u60C5\u51B5", + dateView: "\u67E5\u770B\uFF1A", + stats: { + totalTime: "\u603B\u65F6\u957F", + appsUsed: "\u4F7F\u7528\u5E94\u7528\u6570", + topCategory: "\u6700\u9AD8\u5206\u7C7B" + }, + openApps: { + title: "\u5DF2\u6253\u5F00\u5E94\u7528", + subtitle: "\u6709\u53EF\u89C1\u7A97\u53E3\u7684\u5E94\u7528", + empty: "\u6682\u672A\u68C0\u6D4B\u5230\u6709\u53EF\u89C1\u7A97\u53E3\u7684\u5E94\u7528\u3002" + }, + backgroundApps: { + title: "\u540E\u53F0\u8FDB\u7A0B", + subtitle: "\u6B63\u5728\u8FD0\u884C\u4F46\u6CA1\u6709\u53EF\u89C1\u7A97\u53E3", + empty: "\u672A\u68C0\u6D4B\u5230\u540E\u53F0\u8FDB\u7A0B\u3002", + processCount: "{count} \u4E2A\u8FDB\u7A0B" + }, + timeLimits: { + title: "\u5DF2\u542F\u7528\u65F6\u957F\u9650\u5236", + subtitle: "\u5171 {count} \u4E2A\u5E94\u7528\u8BBE\u7F6E\u4E86\u9650\u989D", + usage: "{used} / {limit} \u5206\u949F", + exceeded: "\u5DF2\u8D85\u9650", + setLimit: "\u8BBE\u7F6E\u65F6\u957F\u9650\u5236", + edit: "\u7F16\u8F91", + remove: "\u79FB\u9664", + save: "\u4FDD\u5B58", + cancel: "\u53D6\u6D88", + limit: "\u9650\u5236\uFF1A", + minutesPlaceholder: "\u5206\u949F", + perDayUnit: "\u5206\u949F/\u5929" + }, + controls: { + searchPlaceholder: "\u641C\u7D22\u5E94\u7528...", + sortBy: "\u6392\u5E8F\u65B9\u5F0F\uFF1A", + time: "\u65F6\u957F", + name: "\u540D\u79F0", + category: "\u5206\u7C7B" + }, + empty: { + search: "\u6CA1\u6709\u5339\u914D\u641C\u7D22\u6761\u4EF6\u7684\u5E94\u7528", + date: "{date} \u6682\u65E0\u5E94\u7528\u4F7F\u7528\u8BB0\u5F55" + }, + cards: { + percentageOfTotal: "\u5360\u603B\u65F6\u957F {count}%" + } + }, + notifications: { + title: "\u901A\u77E5", + subtitle: "\u8DDF\u8E2A\u4ECA\u5929\u6765\u81EA\u5404\u5E94\u7528\u7684\u901A\u77E5\u6D3B\u52A8", + status: { + disabledTitle: "\u901A\u77E5\u65E5\u5FD7\u672A\u542F\u7528", + disabledMessage: "Windows \u901A\u77E5\u65E5\u5FD7\u5F53\u524D\u5DF2\u5173\u95ED\uFF0C\u8BF7\u5728\u4E8B\u4EF6\u67E5\u770B\u5668\u4E2D\u542F\u7528\uFF0C\u6216\u5C1D\u8BD5\u4EE5\u7BA1\u7406\u5458\u8EAB\u4EFD\u8FD0\u884C\u3002", + errorTitle: "\u8BFB\u53D6\u65E5\u5FD7\u5931\u8D25", + errorMessage: "\u8BFB\u53D6\u901A\u77E5\u65E5\u5FD7\u5931\u8D25\u3002", + errorWithDetails: "\u8BFB\u53D6\u901A\u77E5\u65E5\u5FD7\u5931\u8D25\uFF1A{detail}" + }, + stats: { + totalToday: "\u4ECA\u65E5\u603B\u6570", + apps: "\u5E94\u7528\u6570", + avgPerApp: "\u5355\u5E94\u7528\u5E73\u5747", + topSender: "\u6700\u9AD8\u6765\u6E90" + }, + breakdown: { + title: "\u6309\u5E94\u7528\u62C6\u5206", + subtitle: "\u4ECA\u5929\u6BCF\u4E2A\u5E94\u7528\u6536\u5230\u7684\u901A\u77E5\u6570", + countBadge: "{count} \u4E2A\u5E94\u7528", + app: "\u5E94\u7528", + count: "\u6570\u91CF", + emptyTitle: "\u8FD8\u6CA1\u6709\u8BB0\u5F55\u5230\u901A\u77E5", + emptySubtitle: "\u6765\u81EA\u5E94\u7528\u7684\u901A\u77E5\u4F1A\u5728\u6536\u5230\u540E\u663E\u793A\u5728\u8FD9\u91CC\u3002", + emptyActionSubtitle: "\u5148\u89E3\u51B3\u4E0A\u65B9\u95EE\u9898\uFF0C\u4E4B\u540E\u5373\u53EF\u5F00\u59CB\u8DDF\u8E2A\u901A\u77E5\u3002", + otherApps: "\u5176\u4ED6\u5E94\u7528" + }, + tips: { + title: "\u51CF\u5C11\u5E72\u6270", + subtitle: "\u7BA1\u7406\u901A\u77E5\u8FC7\u8F7D\u7684\u5C0F\u5EFA\u8BAE", + focusAssistTitle: "\u5F00\u542F\u4E13\u6CE8\u52A9\u624B", + focusAssistDesc: "\u4F7F\u7528 Windows \u4E13\u6CE8\u52A9\u624B\u5728\u5DE5\u4F5C\u65F6\u6BB5\u9759\u97F3\u901A\u77E5\uFF0C\u53EF\u5728\u64CD\u4F5C\u4E2D\u5FC3\u6216\u8BBE\u7F6E\u4E2D\u5F00\u542F\u3002", + appNotificationsTitle: "\u914D\u7F6E\u5E94\u7528\u901A\u77E5", + appNotificationsDesc: "\u524D\u5F80 Windows \u8BBE\u7F6E > \u7CFB\u7EDF > \u901A\u77E5\uFF0C\u81EA\u5B9A\u4E49\u54EA\u4E9B\u5E94\u7528\u53EF\u4EE5\u5411\u4F60\u53D1\u9001\u901A\u77E5\u3002", + timeLimitsTitle: "\u8BBE\u7F6E\u65F6\u957F\u9650\u5236", + timeLimitsDesc: "\u4F60\u53EF\u4EE5\u5728\u201C\u5E94\u7528\u201D\u9875\u9762\u4E3A\u5BB9\u6613\u5206\u5FC3\u7684\u5E94\u7528\u8BBE\u7F6E\u6BCF\u65E5\u9650\u5236\uFF0C\u8D85\u9650\u540E\u4F1A\u6536\u5230\u63D0\u9192\u3002" + } + }, + settings: { + title: "\u8BBE\u7F6E", + subtitle: "\u81EA\u5B9A\u4E49\u4F60\u7684 ScreenForge \u4F7F\u7528\u4F53\u9A8C", + loadingSubtitle: "\u52A0\u8F7D\u4E2D...", + sections: { + appearance: "\u5916\u89C2", + appearanceDesc: "\u9009\u62E9\u4F60\u559C\u6B22\u7684\u4E3B\u9898", + language: "\u8BED\u8A00", + languageDesc: "\u9009\u62E9\u754C\u9762\u663E\u793A\u8BED\u8A00", + behavior: "\u884C\u4E3A", + behaviorDesc: "\u63A7\u5236 ScreenForge \u7684\u8FD0\u884C\u65B9\u5F0F", + timeLimits: "\u65F6\u957F\u9650\u5236", + timeLimitsDesc: "\u63A7\u5236\u5E94\u7528\u4F7F\u7528\u63D0\u9192", + data: "\u6570\u636E", + dataDesc: "\u7BA1\u7406\u5DF2\u8BB0\u5F55\u7684\u6570\u636E", + about: "\u5173\u4E8E" + }, + behavior: { + startWithWindows: "\u5F00\u673A\u542F\u52A8", + startWithWindowsDesc: "\u767B\u5F55 Windows \u540E\u81EA\u52A8\u542F\u52A8 ScreenForge", + minimizeToTray: "\u6700\u5C0F\u5316\u5230\u6258\u76D8", + minimizeToTrayDesc: "\u5173\u95ED\u7A97\u53E3\u540E\u7EE7\u7EED\u5728\u540E\u53F0\u8FD0\u884C" + }, + timeLimits: { + notifications: "\u65F6\u957F\u8D85\u9650\u63D0\u9192", + notificationsDesc: "\u5F53\u5E94\u7528\u4F7F\u7528\u8D85\u51FA\u9650\u5236\u65F6\u53D1\u9001\u63D0\u9192", + activeLimits: "\u5F53\u524D\u9650\u5236", + activeLimitsDesc: "\u4F60\u5DF2\u4E3A {count} \u4E2A\u5E94\u7528\u8BBE\u7F6E\u65F6\u957F\u9650\u5236\uFF0C\u53EF\u524D\u5F80\u201C\u5E94\u7528\u201D\u9875\u9762\u7BA1\u7406\u3002" + }, + data: { + clearAll: "\u6E05\u7A7A\u6240\u6709\u6570\u636E", + clearAllDesc: "\u5220\u9664\u6240\u6709\u5DF2\u8BB0\u5F55\u7684\u4F7F\u7528\u5386\u53F2", + clearButton: "\u6E05\u7A7A\u6570\u636E", + clearConfirm: "\u786E\u5B9A\u5417\uFF1F\u8FD9\u5C06\u5220\u9664\u4F60\u6240\u6709\u7684\u4F7F\u7528\u6570\u636E\u3002" + }, + about: { + version: "\u7248\u672C", + platform: "\u5E73\u53F0", + builtWith: "\u6280\u672F\u6808", + windows: "Windows", + techStack: "Electron + React" + } + }, + tables: { + topApps: "\u5E38\u7528\u5E94\u7528", + usageToday: "\u4ECA\u65E5\u4F7F\u7528\u60C5\u51B5", + app: "\u5E94\u7528", + category: "\u5206\u7C7B", + time: "\u65F6\u957F", + notifications: "\u901A\u77E5", + empty: "\u4ECA\u5929\u8FD8\u6CA1\u6709\u5E94\u7528\u6570\u636E", + notificationToday: "\u4ECA\u5929", + notificationEmpty: "\u8FD8\u6CA1\u6709\u901A\u77E5" + }, + datePicker: { + previousMonth: "\u4E0A\u4E2A\u6708", + nextMonth: "\u4E0B\u4E2A\u6708", + dataAvailable: "\u6709\u6570\u636E\u8BB0\u5F55", + dataRange: "\u6570\u636E\u8303\u56F4\uFF1A{start} \u81F3 {end}" + }, + suggestions: { + title: "\u5EFA\u8BAE", + subtitle: "\u57FA\u4E8E\u4F60\u6700\u8FD1\u7684\u6D3B\u52A8\u751F\u6210", + welcome: { + title: "\u6B22\u8FCE\u4F7F\u7528 ScreenForge\uFF01", + detail: "\u4FDD\u6301\u5E94\u7528\u5728\u540E\u53F0\u8FD0\u884C\uFF0C\u5373\u53EF\u81EA\u52A8\u8BB0\u5F55\u4F60\u7684\u5C4F\u5E55\u65F6\u957F\u3002" + }, + entertainment: { + title: "\u5A31\u4E50\u5E94\u7528\u4F7F\u7528\u504F\u9AD8", + detail: "\u53EF\u4EE5\u8003\u8651\u4E3A\u5A31\u4E50\u7C7B\u5E94\u7528\u8BBE\u7F6E\u65F6\u957F\u9650\u5236\uFF0C\u63D0\u5347\u4E13\u6CE8\u5EA6\u3002" + }, + social: { + title: "\u793E\u4EA4\u5E94\u7528\u5360\u6BD4\u504F\u9AD8", + detail: "\u8BD5\u7740\u7ED9\u67E5\u770B\u793E\u4EA4\u5A92\u4F53\u5B89\u6392\u56FA\u5B9A\u65F6\u95F4\uFF0C\u51CF\u5C11\u9891\u7E41\u6253\u65AD\u3002" + }, + productive: { + title: "\u4E13\u6CE8\u72B6\u6001\u4E0D\u9519\uFF01", + detail: "\u4F60\u5927\u90E8\u5206\u65F6\u95F4\u90FD\u82B1\u5728\u9AD8\u6548\u4EFB\u52A1\u4E0A\uFF0C\u7EE7\u7EED\u4FDD\u6301\u3002" + }, + balance: { + title: "\u4F7F\u7528\u5206\u5E03\u8F83\u5747\u8861", + detail: "\u4F60\u7684\u5C4F\u5E55\u65F6\u95F4\u5728\u4E0D\u540C\u6D3B\u52A8\u4E4B\u95F4\u5206\u914D\u5F97\u6BD4\u8F83\u5E73\u8861\u3002" + }, + breaks: { + title: "\u8BB0\u5F97\u9002\u5F53\u4F11\u606F", + detail: "\u8BD5\u8BD5 20-20-20 \u6CD5\u5219\uFF1A\u6BCF 20 \u5206\u949F\uFF0C\u770B\u5411 20 \u82F1\u5C3A\u5916\u7684\u7269\u4F53 20 \u79D2\u3002" + } + }, + native: { + timeLimitReachedTitle: "\u5DF2\u8FBE\u5230\u65F6\u957F\u9650\u5236", + timeLimitReachedBody: "\u4ECA\u5929\u4F60\u5DF2\u4F7F\u7528 {appName} {usedMinutes} \u5206\u949F\uFF0C\u8D85\u8FC7\u8BBE\u5B9A\u9650\u5236 {limitMinutes} \u5206\u949F\u3002", + trayTooltip: "ScreenForge - \u5C4F\u5E55\u65F6\u95F4\u8FFD\u8E2A\u5668", + trayShow: "\u663E\u793A ScreenForge", + trayQuit: "\u9000\u51FA" + }, + categories: { + Productivity: "\u751F\u4EA7\u529B", + Education: "\u6559\u80B2", + Communication: "\u6C9F\u901A", + Utilities: "\u5DE5\u5177", + Browsers: "\u6D4F\u89C8\u5668", + Entertainment: "\u5A31\u4E50", + Social: "\u793E\u4EA4", + System: "\u7CFB\u7EDF", + Other: "\u5176\u4ED6", + Unknown: "\u672A\u77E5" + } +}; + +// src/i18n/types.ts +var supportedLocales = ["zh-CN", "en-US"]; + +// src/i18n/core.ts +var dictionaries = { + "zh-CN": zhCN, + "en-US": enUS +}; +var defaultLocale = "zh-CN"; +var normalizeLocale = (value) => { + if (!value) return defaultLocale; + if (supportedLocales.includes(value)) return value; + if (value.toLowerCase().startsWith("zh")) return "zh-CN"; + if (value.toLowerCase().startsWith("en")) return "en-US"; + return defaultLocale; +}; +var getNestedValue = (tree, path4) => { + const value = path4.split(".").reduce((current, segment) => { + if (!current || typeof current === "string") { + return void 0; + } + return current[segment]; + }, tree); + return typeof value === "string" ? value : void 0; +}; +var interpolate = (template, params) => { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_match, key) => String(params[key] ?? "")); +}; +var translate = (locale, key, params) => { + const resolvedLocale = normalizeLocale(locale); + const template = getNestedValue(dictionaries[resolvedLocale], key) ?? getNestedValue(dictionaries[defaultLocale], key) ?? key; + return interpolate(template, params); +}; + // electron/main.ts -var { app: app3, BrowserWindow, Tray, Menu, nativeImage, Notification } = electron; -var { ipcMain } = electron; var isDev = Boolean(process.env.VITE_DEV_SERVER_URL); var usageTracker = createUsageTracker(); var notificationTracker = createNotificationTracker(); @@ -807,9 +1476,11 @@ var settings = { minimizeToTray: true, startWithWindows: false, timeLimits: [], - timeLimitNotificationsEnabled: true + timeLimitNotificationsEnabled: true, + language: defaultLocale }; var shownAlerts = []; +var mt = (key, params) => translate(settings.language, key, params); var getTodayDateString3 = () => { const now = /* @__PURE__ */ new Date(); const year = now.getFullYear(); @@ -818,11 +1489,11 @@ var getTodayDateString3 = () => { return `${year}-${month}-${day}`; }; var getSettingsPath = () => { - const userDataPath = app3.getPath("userData"); + const userDataPath = import_electron3.app.getPath("userData"); return import_node_path.default.join(userDataPath, "settings.json"); }; var getAlertsPath = () => { - const userDataPath = app3.getPath("userData"); + const userDataPath = import_electron3.app.getPath("userData"); return import_node_path.default.join(userDataPath, "alerts.json"); }; var loadSettings = () => { @@ -835,7 +1506,8 @@ var loadSettings = () => { minimizeToTray: loaded.minimizeToTray ?? true, startWithWindows: loaded.startWithWindows ?? false, timeLimits: loaded.timeLimits ?? [], - timeLimitNotificationsEnabled: loaded.timeLimitNotificationsEnabled ?? true + timeLimitNotificationsEnabled: loaded.timeLimitNotificationsEnabled ?? true, + language: normalizeLocale(loaded.language) }; } } catch { @@ -899,9 +1571,13 @@ var checkTimeLimits = () => { if (!alreadyNotified) { const appInfo = appLookup.get(limit.appId); const appName = appInfo?.name ?? limit.appId; - const notification = new Notification({ - title: "Time Limit Reached", - body: `You've used ${appName} for ${usedMinutes} minutes today. Your limit is ${limit.limitMinutes} minutes.`, + const notification = new import_electron3.Notification({ + title: mt("native.timeLimitReachedTitle"), + body: mt("native.timeLimitReachedBody", { + appName, + usedMinutes, + limitMinutes: limit.limitMinutes + }), icon: void 0, silent: false }); @@ -930,8 +1606,8 @@ var generateSuggestions = () => { if (snapshot.usageEntries.length === 0) { suggestions.push({ id: "welcome", - title: "Welcome to ScreenForge!", - detail: "Keep the app running to track your screen time automatically." + title: mt("suggestions.welcome.title"), + detail: mt("suggestions.welcome.detail") }); return suggestions; } @@ -948,40 +1624,64 @@ var generateSuggestions = () => { if (entertainmentMinutes > 0 && entertainmentMinutes / totalMinutes > 0.3) { suggestions.push({ id: "entertainment", - title: "High entertainment usage", - detail: "Consider setting time limits for entertainment apps to boost productivity." + title: mt("suggestions.entertainment.title"), + detail: mt("suggestions.entertainment.detail") }); } const socialMinutes = categoryMinutes.get("Social") ?? 0; if (socialMinutes > 0 && socialMinutes / totalMinutes > 0.2) { suggestions.push({ id: "social", - title: "Social apps taking over", - detail: "Try scheduling specific times for checking social media." + title: mt("suggestions.social.title"), + detail: mt("suggestions.social.detail") }); } const productiveMinutes = (categoryMinutes.get("Productivity") ?? 0) + (categoryMinutes.get("Education") ?? 0); if (productiveMinutes > 0 && productiveMinutes / totalMinutes > 0.5) { suggestions.push({ id: "productive", - title: "Great focus!", - detail: "You're spending most of your time on productive tasks. Keep it up!" + title: mt("suggestions.productive.title"), + detail: mt("suggestions.productive.detail") }); } if (suggestions.length === 0) { suggestions.push({ id: "balance", - title: "Balanced usage", - detail: "Your screen time is well distributed across different activities." + title: mt("suggestions.balance.title"), + detail: mt("suggestions.balance.detail") }); } suggestions.push({ id: "breaks", - title: "Remember to take breaks", - detail: "Use the 20-20-20 rule: every 20 minutes, look at something 20 feet away for 20 seconds." + title: mt("suggestions.breaks.title"), + detail: mt("suggestions.breaks.detail") }); return suggestions; }; +var buildTrayMenu = () => import_electron3.Menu.buildFromTemplate([ + { + label: mt("native.trayShow"), + click: () => { + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + } + } + }, + { type: "separator" }, + { + label: mt("native.trayQuit"), + click: () => { + isQuitting = true; + import_electron3.app.quit(); + } + } +]); +var updateTrayMenu = () => { + if (!tray) return; + tray.setToolTip(mt("native.trayTooltip")); + tray.setContextMenu(buildTrayMenu()); +}; var createTray = () => { const size = 16; const canvas = Buffer.alloc(size * size * 4); @@ -1026,29 +1726,9 @@ var createTray = () => { for (let x = 7; x <= 11; x++) { setPixel(x, 8, accentColor); } - const trayIcon = nativeImage.createFromBuffer(canvas, { width: size, height: size }); - tray = new Tray(trayIcon); - tray.setToolTip("ScreenForge - Screen Time Tracker"); - const contextMenu = Menu.buildFromTemplate([ - { - label: "Show ScreenForge", - click: () => { - if (mainWindow) { - mainWindow.show(); - mainWindow.focus(); - } - } - }, - { type: "separator" }, - { - label: "Quit", - click: () => { - isQuitting = true; - app3.quit(); - } - } - ]); - tray.setContextMenu(contextMenu); + const trayIcon = import_electron3.nativeImage.createFromBuffer(canvas, { width: size, height: size }); + tray = new import_electron3.Tray(trayIcon); + updateTrayMenu(); tray.on("double-click", () => { if (mainWindow) { mainWindow.show(); @@ -1116,11 +1796,11 @@ var createAppIcon = () => { setPixel(x, 16, accentColor); setPixel(x, 17, accentColor); } - return nativeImage.createFromBuffer(canvas, { width: size, height: size }); + return import_electron3.nativeImage.createFromBuffer(canvas, { width: size, height: size }); }; var createWindow = async () => { const appIcon = createAppIcon(); - mainWindow = new BrowserWindow({ + mainWindow = new import_electron3.BrowserWindow({ width: 1240, height: 820, minWidth: 980, @@ -1173,33 +1853,33 @@ var createWindow = async () => { await mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL); mainWindow.webContents.openDevTools({ mode: "detach" }); } else { - const indexPath = import_node_path.default.join(app3.getAppPath(), "dist", "index.html"); + const indexPath = import_node_path.default.join(import_electron3.app.getAppPath(), "dist", "index.html"); await mainWindow.loadFile(indexPath); } }; var setAutoLaunch = (enable) => { if (process.platform !== "win32") return; - app3.setLoginItemSettings({ + import_electron3.app.setLoginItemSettings({ openAtLogin: enable, - path: app3.getPath("exe") + path: import_electron3.app.getPath("exe") }); }; -app3.whenReady().then(() => { +import_electron3.app.whenReady().then(() => { loadSettings(); loadAlerts(); - ipcMain.handle("usage:snapshot", () => usageTracker.getSnapshot()); - ipcMain.handle("usage:clear", () => { + import_electron3.ipcMain.handle("usage:snapshot", () => usageTracker.getSnapshot()); + import_electron3.ipcMain.handle("usage:clear", () => { usageTracker.clearData(); return usageTracker.getSnapshot(); }); - ipcMain.handle("theme:set", (_event, theme) => { + import_electron3.ipcMain.handle("theme:set", (_event, theme) => { applyThemeToWindow(theme); return true; }); - ipcMain.handle("suggestions:list", () => generateSuggestions()); - ipcMain.handle("notifications:summary", () => notificationTracker.getSummary()); - ipcMain.handle("settings:get", () => settings); - ipcMain.handle("settings:set", (_event, newSettings) => { + import_electron3.ipcMain.handle("suggestions:list", () => generateSuggestions()); + import_electron3.ipcMain.handle("notifications:summary", () => notificationTracker.getSummary()); + import_electron3.ipcMain.handle("settings:get", () => settings); + import_electron3.ipcMain.handle("settings:set", (_event, newSettings) => { if (typeof newSettings.minimizeToTray === "boolean") { settings.minimizeToTray = newSettings.minimizeToTray; } @@ -1213,54 +1893,58 @@ app3.whenReady().then(() => { if (typeof newSettings.timeLimitNotificationsEnabled === "boolean") { settings.timeLimitNotificationsEnabled = newSettings.timeLimitNotificationsEnabled; } + if (newSettings.language) { + settings.language = normalizeLocale(newSettings.language); + updateTrayMenu(); + } saveSettings(); return settings; }); - ipcMain.handle("timelimits:get", () => settings.timeLimits); - ipcMain.handle("timelimits:set", (_event, limits) => { + import_electron3.ipcMain.handle("timelimits:get", () => settings.timeLimits); + import_electron3.ipcMain.handle("timelimits:set", (_event, limits) => { settings.timeLimits = limits; saveSettings(); return settings.timeLimits; }); - ipcMain.handle("timelimits:add", (_event, limit) => { + import_electron3.ipcMain.handle("timelimits:add", (_event, limit) => { settings.timeLimits = settings.timeLimits.filter((l) => l.appId !== limit.appId); settings.timeLimits.push(limit); saveSettings(); return settings.timeLimits; }); - ipcMain.handle("timelimits:remove", (_event, appId) => { + import_electron3.ipcMain.handle("timelimits:remove", (_event, appId) => { settings.timeLimits = settings.timeLimits.filter((l) => l.appId !== appId); saveSettings(); return settings.timeLimits; }); - ipcMain.handle("timelimits:alerts", () => shownAlerts); + import_electron3.ipcMain.handle("timelimits:alerts", () => shownAlerts); createTray(); createWindow(); const timeLimitInterval = setInterval(checkTimeLimits, 3e4); setTimeout(checkTimeLimits, 5e3); - app3.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { + import_electron3.app.on("activate", () => { + if (import_electron3.BrowserWindow.getAllWindows().length === 0) { createWindow(); } else if (mainWindow) { mainWindow.show(); } }); - app3.on("will-quit", () => { + import_electron3.app.on("will-quit", () => { clearInterval(timeLimitInterval); }); }); -app3.on("before-quit", () => { +import_electron3.app.on("before-quit", () => { isQuitting = true; }); -app3.on("window-all-closed", () => { +import_electron3.app.on("window-all-closed", () => { if (process.platform === "darwin") { } else if (!settings.minimizeToTray) { usageTracker.dispose(); notificationTracker.dispose(); - app3.quit(); + import_electron3.app.quit(); } }); -app3.on("will-quit", () => { +import_electron3.app.on("will-quit", () => { usageTracker.dispose(); notificationTracker.dispose(); if (tray) { diff --git a/dist-electron/preload.cjs b/dist-electron/preload.cjs index 6582d25..6f3d621 100644 --- a/dist-electron/preload.cjs +++ b/dist-electron/preload.cjs @@ -1,8 +1,6 @@ -var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; -var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { @@ -12,25 +10,16 @@ var __copyProps = (to, from, except, desc) => { } return to; }; -var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( - // If the importer is in node compatibility mode or this is not an ESM - // file that has been converted to a CommonJS file using a Babel- - // compatible transform (i.e. "__esModule" has not been set), then set - // "default" to the CommonJS "module.exports" for node compatibility. - isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, - mod -)); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // electron/preload.ts var preload_exports = {}; module.exports = __toCommonJS(preload_exports); -var electron = __toESM(require("electron"), 1); -var { contextBridge, ipcRenderer } = electron; +var import_electron = require("electron"); var api = { getUsageSnapshot: async () => { try { - const snapshot = await ipcRenderer.invoke("usage:snapshot"); + const snapshot = await import_electron.ipcRenderer.invoke("usage:snapshot"); return { generatedAt: (/* @__PURE__ */ new Date()).toISOString(), ...snapshot }; } catch { return { @@ -44,14 +33,14 @@ var api = { }, getSuggestionFeed: async () => { try { - return await ipcRenderer.invoke("suggestions:list"); + return await import_electron.ipcRenderer.invoke("suggestions:list"); } catch { return []; } }, clearUsageData: async () => { try { - return await ipcRenderer.invoke("usage:clear"); + return await import_electron.ipcRenderer.invoke("usage:clear"); } catch { return { generatedAt: (/* @__PURE__ */ new Date()).toISOString(), @@ -64,7 +53,7 @@ var api = { }, getNotificationSummary: async () => { try { - return await ipcRenderer.invoke("notifications:summary"); + return await import_electron.ipcRenderer.invoke("notifications:summary"); } catch { return { total: 0, @@ -75,7 +64,7 @@ var api = { }, setTheme: async (theme) => { try { - return await ipcRenderer.invoke("theme:set", theme); + return await import_electron.ipcRenderer.invoke("theme:set", theme); } catch { return false; } @@ -83,60 +72,62 @@ var api = { // Settings getSettings: async () => { try { - return await ipcRenderer.invoke("settings:get"); + return await import_electron.ipcRenderer.invoke("settings:get"); } catch { return { minimizeToTray: true, startWithWindows: false, timeLimits: [], - timeLimitNotificationsEnabled: true + timeLimitNotificationsEnabled: true, + language: "zh-CN" }; } }, setSettings: async (settings) => { try { - return await ipcRenderer.invoke("settings:set", settings); + return await import_electron.ipcRenderer.invoke("settings:set", settings); } catch { return { minimizeToTray: true, startWithWindows: false, timeLimits: [], - timeLimitNotificationsEnabled: true + timeLimitNotificationsEnabled: true, + language: "zh-CN" }; } }, // Time limits getTimeLimits: async () => { try { - return await ipcRenderer.invoke("timelimits:get"); + return await import_electron.ipcRenderer.invoke("timelimits:get"); } catch { return []; } }, setTimeLimits: async (limits) => { try { - return await ipcRenderer.invoke("timelimits:set", limits); + return await import_electron.ipcRenderer.invoke("timelimits:set", limits); } catch { return []; } }, addTimeLimit: async (limit) => { try { - return await ipcRenderer.invoke("timelimits:add", limit); + return await import_electron.ipcRenderer.invoke("timelimits:add", limit); } catch { return []; } }, removeTimeLimit: async (appId) => { try { - return await ipcRenderer.invoke("timelimits:remove", appId); + return await import_electron.ipcRenderer.invoke("timelimits:remove", appId); } catch { return []; } }, getTimeLimitAlerts: async () => { try { - return await ipcRenderer.invoke("timelimits:alerts"); + return await import_electron.ipcRenderer.invoke("timelimits:alerts"); } catch { return []; } @@ -146,10 +137,10 @@ var api = { const handler = (_event, data) => { callback(data); }; - ipcRenderer.on("time-limit-exceeded", handler); + import_electron.ipcRenderer.on("time-limit-exceeded", handler); return () => { - ipcRenderer.removeListener("time-limit-exceeded", handler); + import_electron.ipcRenderer.removeListener("time-limit-exceeded", handler); }; } }; -contextBridge.exposeInMainWorld("screenforge", api); +import_electron.contextBridge.exposeInMainWorld("screenforge", api); diff --git a/electron/main.ts b/electron/main.ts index f21c4da..cfb9825 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,19 +1,17 @@ -import * as electron from 'electron' +import { app, BrowserWindow, Tray, Menu, nativeImage, Notification, ipcMain } from 'electron' import path from 'node:path' import * as fs from 'node:fs' import { createNotificationTracker } from './notifications' import { createUsageTracker } from './telemetry' - -const { app, BrowserWindow, Tray, Menu, nativeImage, Notification } = electron -const { ipcMain } = electron +import { defaultLocale, normalizeLocale, translate, type LocaleCode } from '../src/i18n/core' const isDev = Boolean(process.env.VITE_DEV_SERVER_URL) const usageTracker = createUsageTracker() const notificationTracker = createNotificationTracker() -let mainWindow: electron.BrowserWindow | null = null -let tray: electron.Tray | null = null +let mainWindow: BrowserWindow | null = null +let tray: Tray | null = null let isQuitting = false const ZOOM_STEP = 0.1 @@ -57,6 +55,7 @@ interface AppSettings { startWithWindows: boolean timeLimits: AppTimeLimit[] timeLimitNotificationsEnabled: boolean + language: LocaleCode } // Track which alerts have been shown today to avoid spam @@ -71,11 +70,15 @@ let settings: AppSettings = { startWithWindows: false, timeLimits: [], timeLimitNotificationsEnabled: true, + language: defaultLocale, } let shownAlerts: TimeLimitAlert[] = [] // Get today's date in Windows local timezone +const mt = (key: string, params?: Record) => + translate(settings.language, key, params) + const getTodayDateString = (): string => { const now = new Date() const year = now.getFullYear() @@ -105,6 +108,7 @@ const loadSettings = () => { startWithWindows: loaded.startWithWindows ?? false, timeLimits: loaded.timeLimits ?? [], timeLimitNotificationsEnabled: loaded.timeLimitNotificationsEnabled ?? true, + language: normalizeLocale(loaded.language), } } } catch { @@ -188,8 +192,12 @@ const checkTimeLimits = () => { // Show notification const notification = new Notification({ - title: 'Time Limit Reached', - body: `You've used ${appName} for ${usedMinutes} minutes today. Your limit is ${limit.limitMinutes} minutes.`, + title: mt('native.timeLimitReachedTitle'), + body: mt('native.timeLimitReachedBody', { + appName, + usedMinutes, + limitMinutes: limit.limitMinutes, + }), icon: undefined, silent: false, }) @@ -226,8 +234,8 @@ const generateSuggestions = () => { if (snapshot.usageEntries.length === 0) { suggestions.push({ id: 'welcome', - title: 'Welcome to ScreenForge!', - detail: 'Keep the app running to track your screen time automatically.', + title: mt('suggestions.welcome.title'), + detail: mt('suggestions.welcome.detail'), }) return suggestions } @@ -250,8 +258,8 @@ const generateSuggestions = () => { if (entertainmentMinutes > 0 && entertainmentMinutes / totalMinutes > 0.3) { suggestions.push({ id: 'entertainment', - title: 'High entertainment usage', - detail: 'Consider setting time limits for entertainment apps to boost productivity.', + title: mt('suggestions.entertainment.title'), + detail: mt('suggestions.entertainment.detail'), }) } @@ -260,8 +268,8 @@ const generateSuggestions = () => { if (socialMinutes > 0 && socialMinutes / totalMinutes > 0.2) { suggestions.push({ id: 'social', - title: 'Social apps taking over', - detail: 'Try scheduling specific times for checking social media.', + title: mt('suggestions.social.title'), + detail: mt('suggestions.social.detail'), }) } @@ -270,8 +278,8 @@ const generateSuggestions = () => { if (productiveMinutes > 0 && productiveMinutes / totalMinutes > 0.5) { suggestions.push({ id: 'productive', - title: 'Great focus!', - detail: 'You\'re spending most of your time on productive tasks. Keep it up!', + title: mt('suggestions.productive.title'), + detail: mt('suggestions.productive.detail'), }) } @@ -279,20 +287,46 @@ const generateSuggestions = () => { if (suggestions.length === 0) { suggestions.push({ id: 'balance', - title: 'Balanced usage', - detail: 'Your screen time is well distributed across different activities.', + title: mt('suggestions.balance.title'), + detail: mt('suggestions.balance.detail'), }) } suggestions.push({ id: 'breaks', - title: 'Remember to take breaks', - detail: 'Use the 20-20-20 rule: every 20 minutes, look at something 20 feet away for 20 seconds.', + title: mt('suggestions.breaks.title'), + detail: mt('suggestions.breaks.detail'), }) return suggestions } +const buildTrayMenu = () => Menu.buildFromTemplate([ + { + label: mt('native.trayShow'), + click: () => { + if (mainWindow) { + mainWindow.show() + mainWindow.focus() + } + }, + }, + { type: 'separator' }, + { + label: mt('native.trayQuit'), + click: () => { + isQuitting = true + app.quit() + }, + }, +]) + +const updateTrayMenu = () => { + if (!tray) return + tray.setToolTip(mt('native.trayTooltip')) + tray.setContextMenu(buildTrayMenu()) +} + const createTray = () => { // Create the ScreenForge tray icon programmatically // 16x16 icon with monitor shape in blue accent color @@ -346,29 +380,7 @@ const createTray = () => { const trayIcon = nativeImage.createFromBuffer(canvas, { width: size, height: size }) tray = new Tray(trayIcon) - tray.setToolTip('ScreenForge - Screen Time Tracker') - - const contextMenu = Menu.buildFromTemplate([ - { - label: 'Show ScreenForge', - click: () => { - if (mainWindow) { - mainWindow.show() - mainWindow.focus() - } - }, - }, - { type: 'separator' }, - { - label: 'Quit', - click: () => { - isQuitting = true - app.quit() - }, - }, - ]) - - tray.setContextMenu(contextMenu) + updateTrayMenu() // Double-click to show window tray.on('double-click', () => { @@ -564,6 +576,10 @@ app.whenReady().then(() => { if (typeof newSettings.timeLimitNotificationsEnabled === 'boolean') { settings.timeLimitNotificationsEnabled = newSettings.timeLimitNotificationsEnabled } + if (newSettings.language) { + settings.language = normalizeLocale(newSettings.language) + updateTrayMenu() + } saveSettings() return settings }) diff --git a/electron/preload.ts b/electron/preload.ts index 6585401..019c5ba 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,6 +1,4 @@ -import * as electron from 'electron' - -const { contextBridge, ipcRenderer } = electron +import { contextBridge, ipcRenderer } from 'electron' // Time limit interface interface AppTimeLimit { @@ -15,6 +13,7 @@ interface AppSettings { startWithWindows: boolean timeLimits: AppTimeLimit[] timeLimitNotificationsEnabled: boolean + language: 'zh-CN' | 'en-US' } const api = { @@ -80,6 +79,7 @@ const api = { startWithWindows: false, timeLimits: [], timeLimitNotificationsEnabled: true, + language: 'zh-CN', } } }, @@ -92,6 +92,7 @@ const api = { startWithWindows: false, timeLimits: [], timeLimitNotificationsEnabled: true, + language: 'zh-CN', } } }, diff --git a/package.json b/package.json index e39b42f..413cbf8 100644 --- a/package.json +++ b/package.json @@ -16,15 +16,17 @@ "type": "module", "main": "dist-electron/main.cjs", "scripts": { - "dev": "concurrently \"vite\" \"npm:electron:dev\"", - "electron:dev": "npm run electron:build && wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron dist-electron/main.cjs", + "dev": "concurrently \"vite --strictPort\" \"npm:electron:dev\"", + "electron:dev": "npm run electron:build && wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 cmd /c \"set ELECTRON_RUN_AS_NODE=&& electron .\"", + "dev:debug": "concurrently \"vite --strictPort\" \"npm:electron:dev:debug\"", + "electron:dev:debug": "npm run electron:build && wait-on tcp:5173 && cross-env ELECTRON_ENABLE_LOGGING=true VITE_DEV_SERVER_URL=http://localhost:5173 cmd /c \"set ELECTRON_RUN_AS_NODE=&& electron --inspect=9229 .\"", "generate:icon": "node scripts/generate-icon.mjs", "prebuild": "npm run generate:icon", "build": "tsc -b && vite build && npm run electron:build", "electron:build": "esbuild electron/main.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist-electron/main.cjs && esbuild electron/preload.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist-electron/preload.cjs", "lint": "eslint .", "preview": "vite preview", - "electron:start": "electron dist-electron/main.cjs", + "electron:start": "cmd /c \"set ELECTRON_RUN_AS_NODE=&& electron .\"", "dist": "npm run build && electron-builder --win nsis", "dist:portable": "npm run build && electron-builder --win portable" }, diff --git a/src/App.tsx b/src/App.tsx index 46d5e8f..808ead2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,11 +3,13 @@ import './App.css' import type { ThemeName, PageName, SuggestionItem, NotificationSummary } from './types/models' import type { UsageSnapshot } from './services/usageService' import { + fetchSettings, fetchNotificationSummary, fetchSuggestions, fetchUsageSnapshot, } from './services/usageService' import { calculateFocusScore } from './utils/analytics' +import { useI18n } from './i18n/I18nProvider' import Dashboard from './pages/Dashboard' import Insights from './pages/Insights' import Apps from './pages/Apps' @@ -16,15 +18,8 @@ import Settings from './pages/Settings' const themes: ThemeName[] = ['dark', 'light', 'tokyo', 'skin'] -const navItems: { id: PageName; label: string }[] = [ - { id: 'dashboard', label: 'Dashboard' }, - { id: 'insights', label: 'Insights' }, - { id: 'apps', label: 'Apps' }, - { id: 'notifications', label: 'Notifications' }, - { id: 'settings', label: 'Settings' }, -] - const App = () => { + const { t, setLocale, translateThemeName } = useI18n() const [theme, setTheme] = useState(() => { const saved = localStorage.getItem('screenforge-theme') return (saved as ThemeName) || 'dark' @@ -34,6 +29,14 @@ const App = () => { const [suggestions, setSuggestions] = useState([]) const [notificationSummary, setNotificationSummary] = useState(null) + const navItems: { id: PageName; label: string }[] = [ + { id: 'dashboard', label: t('nav.dashboard') }, + { id: 'insights', label: t('nav.insights') }, + { id: 'apps', label: t('nav.apps') }, + { id: 'notifications', label: t('nav.notifications') }, + { id: 'settings', label: t('nav.settings') }, + ] + useEffect(() => { document.documentElement.setAttribute('data-theme', theme) localStorage.setItem('screenforge-theme', theme) @@ -44,15 +47,17 @@ const App = () => { let interval: number | undefined const load = async () => { - const [usageSnapshot, suggestionFeed, notificationFeed] = await Promise.all([ + const [usageSnapshot, suggestionFeed, notificationFeed, settings] = await Promise.all([ fetchUsageSnapshot(), fetchSuggestions(), fetchNotificationSummary(), + fetchSettings(), ]) setSnapshot(usageSnapshot) setSuggestions(suggestionFeed) setNotificationSummary(notificationFeed) + setLocale(settings.language) } load() @@ -123,7 +128,7 @@ const App = () => { ))}
-
Focus score
+
{t('sidebar.focusScore')}
{focusScore}
@@ -137,7 +142,7 @@ const App = () => { className={`theme-pill ${theme === option ? 'theme-pill--active' : ''}`} onClick={() => setTheme(option)} > - {option} + {translateThemeName(option)} ))} diff --git a/src/components/AppUsageTable.tsx b/src/components/AppUsageTable.tsx index 5e8ef70..1f78744 100644 --- a/src/components/AppUsageTable.tsx +++ b/src/components/AppUsageTable.tsx @@ -1,5 +1,5 @@ import type { AppInfo } from '../types/models' -import { formatMinutes, formatSeconds } from '../utils/analytics' +import { useI18n } from '../i18n/I18nProvider' import './AppUsageTable.css' interface AppUsageRow { @@ -15,24 +15,26 @@ interface AppUsageTableProps { subtitle?: string } -const AppUsageTable = ({ rows, title = 'Top apps', subtitle = 'Usage today' }: AppUsageTableProps) => { +const AppUsageTable = ({ rows, title, subtitle }: AppUsageTableProps) => { + const { formatMinutes, formatSeconds, t, translateCategory } = useI18n() + return (
-

{title}

-

{subtitle}

+

{title || t('tables.topApps')}

+

{subtitle || t('tables.usageToday')}

- App - Category - Time - Notifications + {t('tables.app')} + {t('tables.category')} + {t('tables.time')} + {t('tables.notifications')}
{rows.length === 0 ? ( -
No app data yet for today
+
{t('tables.empty')}
) : ( rows.map((row) => (
@@ -40,7 +42,7 @@ const AppUsageTable = ({ rows, title = 'Top apps', subtitle = 'Usage today' }: A {row.app.name} - {row.app.category} + {translateCategory(row.app.category)} {row.seconds !== undefined ? formatSeconds(row.seconds) : formatMinutes(row.minutes)} {row.notifications}
diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx index c9ea4ec..6b85442 100644 --- a/src/components/DatePicker.tsx +++ b/src/components/DatePicker.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useMemo } from 'react' -import { getTodayDateString, formatDateLabel } from '../utils/analytics' +import { getTodayDateString } from '../utils/analytics' +import { useI18n } from '../i18n/I18nProvider' import './DatePicker.css' interface DatePickerProps { @@ -9,6 +10,7 @@ interface DatePickerProps { } const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) => { + const { formatDate, formatDateLabel, formatMonthLabel, t } = useI18n() const [isOpen, setIsOpen] = useState(false) const [viewMonth, setViewMonth] = useState(() => { // Start with the selected date's month @@ -115,7 +117,15 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) setIsOpen(false) } - const monthLabel = viewMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) + const monthLabel = formatMonthLabel(viewMonth) + const weekdayLabels = useMemo(() => { + const sunday = new Date('2024-03-03T00:00:00') + return Array.from({ length: 7 }, (_, index) => { + const date = new Date(sunday) + date.setDate(sunday.getDate() + index) + return formatDate(date, { weekday: 'short' }) + }) + }, [formatDate]) return (
@@ -140,7 +150,7 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) className="datepicker__nav" onClick={goToPrevMonth} type="button" - aria-label="Previous month" + aria-label={t('datePicker.previousMonth')} > @@ -151,7 +161,7 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) className="datepicker__nav" onClick={goToNextMonth} type="button" - aria-label="Next month" + aria-label={t('datePicker.nextMonth')} > @@ -160,7 +170,7 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps)
- {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => ( + {weekdayLabels.map(day => (
{day}
))}
@@ -190,11 +200,14 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps)
- Data available + {t('datePicker.dataAvailable')}
- Data from {formatDateLabel(dataRange.earliest)} to {formatDateLabel(dataRange.latest)} + {t('datePicker.dataRange', { + start: formatDateLabel(dataRange.earliest), + end: formatDateLabel(dataRange.latest), + })}
diff --git a/src/components/NotificationSummary.tsx b/src/components/NotificationSummary.tsx index 5a0e072..634e202 100644 --- a/src/components/NotificationSummary.tsx +++ b/src/components/NotificationSummary.tsx @@ -1,4 +1,5 @@ import type { AppInfo } from '../types/models' +import { useI18n } from '../i18n/I18nProvider' import './NotificationSummary.css' interface NotificationRow { @@ -12,21 +13,22 @@ interface NotificationSummaryProps { } const NotificationSummary = ({ total, rows }: NotificationSummaryProps) => { + const { t } = useI18n() const maxCount = rows.length > 0 ? Math.max(...rows.map(r => r.notifications)) : 1 return (
-

Notifications

+

{t('nav.notifications')}

{total}
- Today + {t('tables.notificationToday')}
{rows.length === 0 ? ( -
No notifications yet
+
{t('tables.notificationEmpty')}
) : ( rows.map((row) => { const percentage = (row.notifications / maxCount) * 100 diff --git a/src/components/SuggestionPanel.tsx b/src/components/SuggestionPanel.tsx index 3732d19..044d794 100644 --- a/src/components/SuggestionPanel.tsx +++ b/src/components/SuggestionPanel.tsx @@ -1,4 +1,5 @@ import type { SuggestionItem } from '../types/models' +import { useI18n } from '../i18n/I18nProvider' import './SuggestionPanel.css' interface SuggestionPanelProps { @@ -6,11 +7,13 @@ interface SuggestionPanelProps { } const SuggestionPanel = ({ items }: SuggestionPanelProps) => { + const { t } = useI18n() + return (
-

Suggestions

-

Based on your recent activity

+

{t('suggestions.title')}

+

{t('suggestions.subtitle')}

{items.map((item) => ( diff --git a/src/i18n/I18nProvider.tsx b/src/i18n/I18nProvider.tsx new file mode 100644 index 0000000..7bac28b --- /dev/null +++ b/src/i18n/I18nProvider.tsx @@ -0,0 +1,81 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import type { ThemeName } from '../types/models' +import { + defaultLocale, + formatDate, + formatDurationMinutes, + formatDurationSeconds, + formatMonthLabel, + formatRelativeDateLabel, + getLocaleOptions, + normalizeLocale, + translate, + translateCategoryLabel, + translateThemeDescription, + translateThemeName, + type LocaleCode, +} from './core' + +const LOCALE_STORAGE_KEY = 'screenforge-locale' + +interface I18nContextValue { + locale: LocaleCode + setLocale: (locale: LocaleCode) => void + t: (key: string, params?: Record) => string + formatDate: (value: Date | string, options?: Intl.DateTimeFormatOptions) => string + formatDateLabel: (dateString: string) => string + formatMonthLabel: (date: Date) => string + formatMinutes: (minutes: number) => string + formatSeconds: (seconds: number) => string + translateCategory: (category: string) => string + translateThemeName: (theme: ThemeName) => string + translateThemeDescription: (theme: ThemeName) => string + localeOptions: Array<{ code: LocaleCode; label: string }> +} + +const I18nContext = createContext(null) + +const getInitialLocale = (): LocaleCode => { + if (typeof window === 'undefined') return defaultLocale + + const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY) + if (stored) return normalizeLocale(stored) + + return normalizeLocale(window.navigator.language) +} + +export const I18nProvider = ({ children }: { children: ReactNode }) => { + const [locale, setLocaleState] = useState(getInitialLocale) + + useEffect(() => { + window.localStorage.setItem(LOCALE_STORAGE_KEY, locale) + }, [locale]) + + const value = useMemo(() => ({ + locale, + setLocale: (nextLocale) => setLocaleState(normalizeLocale(nextLocale)), + t: (key, params) => translate(locale, key, params), + formatDate: (value, options) => formatDate(locale, value, options), + formatDateLabel: (dateString) => formatRelativeDateLabel(locale, dateString), + formatMonthLabel: (date) => formatMonthLabel(locale, date), + formatMinutes: (minutes) => formatDurationMinutes(locale, minutes), + formatSeconds: (seconds) => formatDurationSeconds(locale, seconds), + translateCategory: (category) => translateCategoryLabel(locale, category), + translateThemeName: (theme) => translateThemeName(locale, theme), + translateThemeDescription: (theme) => translateThemeDescription(locale, theme), + localeOptions: getLocaleOptions(), + }), [locale]) + + return {children} +} + +export const useI18n = () => { + const context = useContext(I18nContext) + if (!context) { + throw new Error('useI18n must be used within an I18nProvider') + } + return context +} + +export type { LocaleCode } diff --git a/src/i18n/core.ts b/src/i18n/core.ts new file mode 100644 index 0000000..af6cf0a --- /dev/null +++ b/src/i18n/core.ts @@ -0,0 +1,148 @@ +import type { ThemeName } from '../types/models' +import { enUS } from './locales/en-US' +import { zhCN } from './locales/zh-CN' +import { supportedLocales, type LocaleCode, type TranslationTree } from './types' + +const dictionaries: Record = { + 'zh-CN': zhCN, + 'en-US': enUS, +} + +export const defaultLocale: LocaleCode = 'zh-CN' + +export const normalizeLocale = (value?: string | null): LocaleCode => { + if (!value) return defaultLocale + if (supportedLocales.includes(value as LocaleCode)) return value as LocaleCode + if (value.toLowerCase().startsWith('zh')) return 'zh-CN' + if (value.toLowerCase().startsWith('en')) return 'en-US' + return defaultLocale +} + +const getNestedValue = (tree: TranslationTree, path: string): string | undefined => { + const value = path.split('.').reduce((current, segment) => { + if (!current || typeof current === 'string') { + return undefined + } + return current[segment] + }, tree) + + return typeof value === 'string' ? value : undefined +} + +const interpolate = (template: string, params?: Record) => { + if (!params) return template + return template.replace(/\{(\w+)\}/g, (_match, key) => String(params[key] ?? '')) +} + +export const translate = (locale: LocaleCode, key: string, params?: Record) => { + const resolvedLocale = normalizeLocale(locale) + const template = + getNestedValue(dictionaries[resolvedLocale], key) ?? + getNestedValue(dictionaries[defaultLocale], key) ?? + key + + return interpolate(template, params) +} + +export const getLocaleOptions = (): Array<{ code: LocaleCode; label: string }> => + supportedLocales.map((code) => ({ + code, + label: translate(code, `locales.${code}`), + })) + +export const translateThemeName = (locale: LocaleCode, theme: ThemeName) => + translate(locale, `themes.${theme}.name`) + +export const translateThemeDescription = (locale: LocaleCode, theme: ThemeName) => + translate(locale, `themes.${theme}.description`) + +export const translateCategoryLabel = (locale: LocaleCode, category: string) => { + const resolvedLocale = normalizeLocale(locale) + const localeTree = dictionaries[resolvedLocale].categories as TranslationTree + const defaultTree = dictionaries[defaultLocale].categories as TranslationTree + const translated = + (localeTree[category] as string | undefined) ?? + (defaultTree[category] as string | undefined) + + return translated ?? category +} + +export const formatDate = ( + locale: LocaleCode, + value: Date | string, + options?: Intl.DateTimeFormatOptions, +) => { + const date = value instanceof Date ? value : new Date(value) + return new Intl.DateTimeFormat(normalizeLocale(locale), options).format(date) +} + +const getDateString = (daysOffset = 0) => { + const now = new Date() + now.setDate(now.getDate() + daysOffset) + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export const formatRelativeDateLabel = (locale: LocaleCode, dateString: string) => { + const today = getDateString(0) + const yesterday = getDateString(-1) + + if (dateString === today) return translate(locale, 'common.today') + if (dateString === yesterday) return translate(locale, 'common.yesterday') + + return formatDate(locale, `${dateString}T00:00:00`, { + weekday: 'short', + month: 'short', + day: 'numeric', + }) +} + +export const formatMonthLabel = (locale: LocaleCode, date: Date) => + formatDate(locale, date, { year: 'numeric', month: 'long' }) + +export const formatDurationMinutes = (locale: LocaleCode, minutes: number) => { + const resolvedLocale = normalizeLocale(locale) + + if (resolvedLocale === 'zh-CN') { + if (minutes === 0) return '0分' + if (minutes < 1) return '<1分' + const hours = Math.floor(minutes / 60) + const mins = Math.round(minutes % 60) + if (hours === 0) return `${mins}分` + return `${hours}小时 ${mins}分` + } + + if (minutes === 0) return '0m' + if (minutes < 1) return '<1m' + const hours = Math.floor(minutes / 60) + const mins = Math.round(minutes % 60) + if (hours === 0) return `${mins}m` + return `${hours}h ${mins}m` +} + +export const formatDurationSeconds = (locale: LocaleCode, totalSeconds: number) => { + const resolvedLocale = normalizeLocale(locale) + + if (resolvedLocale === 'zh-CN') { + if (totalSeconds < 60) return `${Math.floor(totalSeconds)}秒` + const minutes = Math.floor(totalSeconds / 60) + const seconds = Math.floor(totalSeconds % 60) + if (minutes < 60) return `${minutes}分 ${seconds}秒` + const hours = Math.floor(minutes / 60) + const mins = minutes % 60 + return `${hours}小时 ${mins}分` + } + + if (totalSeconds < 60) return `${Math.floor(totalSeconds)}s` + const minutes = Math.floor(totalSeconds / 60) + const seconds = Math.floor(totalSeconds % 60) + if (minutes < 60) return `${minutes}m ${seconds}s` + const hours = Math.floor(minutes / 60) + const mins = minutes % 60 + return `${hours}h ${mins}m` +} + +export { supportedLocales } +export type { LocaleCode, TranslationTree } diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..a78bfc4 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,3 @@ +export * from './core' +export * from './I18nProvider' +export * from './types' diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts new file mode 100644 index 0000000..b32a97a --- /dev/null +++ b/src/i18n/locales/en-US.ts @@ -0,0 +1,318 @@ +import type { TranslationTree } from '../types' + +export const enUS = { + common: { + today: 'Today', + yesterday: 'Yesterday', + none: 'None', + noData: 'No data', + notAvailable: 'N/A', + loading: 'Loading...', + live: 'Live', + focused: 'Focused', + tracking: 'Tracking', + unknown: 'Unknown', + }, + locales: { + 'zh-CN': '简体中文', + 'en-US': 'English', + }, + nav: { + dashboard: 'Dashboard', + insights: 'Insights', + apps: 'Apps', + notifications: 'Notifications', + settings: 'Settings', + }, + sidebar: { + focusScore: 'Focus score', + }, + themes: { + dark: { + name: 'Dark', + description: 'Easy on the eyes, perfect for night', + }, + light: { + name: 'Light', + description: 'Clean and bright for daytime', + }, + tokyo: { + name: 'Tokyo', + description: 'Cyberpunk vibes with purple accents', + }, + skin: { + name: 'Skin', + description: 'Warm and soft aesthetic', + }, + }, + dashboard: { + greeting: { + morning: 'Good morning', + afternoon: 'Good afternoon', + evening: 'Good evening', + }, + subtitle: 'Your screen time for today', + cards: { + screenTime: 'Screen Time', + totalToday: 'Total today', + dailyAverage: 'Daily Avg', + acrossDays: 'Across {count} days', + noHistory: 'No history yet', + topCategory: 'Top Category', + notifications: 'Notifications', + topNotificationApp: 'Top: {name}', + }, + charts: { + dailyUsageTitle: 'Daily usage', + dailyUsageSubtitle: 'Minutes per day across all apps', + dailyUsageEmpty: 'Start using apps to see your usage data', + categoriesTitle: 'Categories', + categoriesSubtitle: 'Time by category today', + categoriesEmpty: 'No category data yet', + usageDataset: 'Usage (minutes)', + minutesDataset: 'Minutes', + }, + sections: { + topAppsToday: 'Top Apps Today', + activeApps: 'Active apps', + activeAppsSubtitle: 'Currently open windows', + activeAppsEmpty: 'No apps with visible windows detected', + currentFocus: 'Current focus', + appsUsedToday: 'Apps used today', + daysRecorded: 'Days recorded', + noActiveApp: 'No active app', + otherApps: 'Other apps', + }, + trend: { + collecting: 'Collecting data', + newData: 'New data', + up: 'Trending up', + down: 'Trending down', + stable: 'Stable', + }, + }, + insights: { + title: 'Insights', + subtitle: 'Deep dive into your screen time patterns', + cards: { + focusScore: 'Focus Score', + focusScoreSub: 'Based on productive app usage', + appsUsed: 'Apps Used', + topApp: 'Top app', + topAppSub: 'Most used by minutes', + totalTracked: 'Total tracked', + totalTrackedSub: 'All time screen time', + }, + charts: { + weeklyTrendTitle: 'Weekly trend', + weeklyTrendSubtitle: 'Screen time over the last 7 days', + weeklyTrendEmpty: 'Start using apps to see trends', + categoryBreakdownTitle: 'Category breakdown', + categoryBreakdownSubtitle: 'How you spend your screen time', + categoryBreakdownEmpty: 'No category data yet', + minutesDataset: 'Minutes', + }, + quickStats: { + title: 'Quick stats', + daysTracked: 'Days tracked', + appsUsed: 'Apps used', + dailyAverage: 'Daily average', + topCategory: 'Top category', + peakDay: 'Peak day', + }, + }, + apps: { + title: 'Apps', + subtitle: 'Screen time for {date}', + dateView: 'View:', + stats: { + totalTime: 'Total Time', + appsUsed: 'Apps Used', + topCategory: 'Top Category', + }, + openApps: { + title: 'Open apps', + subtitle: 'Apps with visible windows', + empty: 'No apps with visible windows detected yet.', + }, + backgroundApps: { + title: 'Background processes', + subtitle: 'Running without visible windows', + empty: 'No background processes detected.', + processCount: '{count} process(es)', + }, + timeLimits: { + title: 'Active Time Limits', + subtitle: '{count} app(s) with limits', + usage: '{used} / {limit} min', + exceeded: 'Exceeded', + setLimit: 'Set time limit', + edit: 'Edit', + remove: 'Remove', + save: 'Save', + cancel: 'Cancel', + limit: 'Limit:', + minutesPlaceholder: 'Minutes', + perDayUnit: 'min/day', + }, + controls: { + searchPlaceholder: 'Search apps...', + sortBy: 'Sort by:', + time: 'Time', + name: 'Name', + category: 'Category', + }, + empty: { + search: 'No apps match your search', + date: 'No apps tracked for {date}', + }, + cards: { + percentageOfTotal: '{count}% of total', + }, + }, + notifications: { + title: 'Notifications', + subtitle: 'Track notification activity from your apps today', + status: { + disabledTitle: 'Notification Logs Disabled', + disabledMessage: 'Windows notification logs are disabled. Enable them in Event Viewer or run as Administrator.', + errorTitle: 'Error Reading Logs', + errorMessage: 'Failed to read notification logs.', + errorWithDetails: 'Failed to read notification logs: {detail}', + }, + stats: { + totalToday: 'Total Today', + apps: 'Apps', + avgPerApp: 'Avg per App', + topSender: 'Top Sender', + }, + breakdown: { + title: 'Breakdown by App', + subtitle: 'Notifications received today per application', + countBadge: '{count} apps', + app: 'Application', + count: 'Count', + emptyTitle: 'No notifications tracked yet', + emptySubtitle: 'Notifications from your apps will appear here as they come in.', + emptyActionSubtitle: 'Fix the issue above to start tracking notifications.', + otherApps: 'Other Apps', + }, + tips: { + title: 'Reduce Distractions', + subtitle: 'Tips for managing notification overload', + focusAssistTitle: 'Enable Focus Assist', + focusAssistDesc: 'Use Windows Focus Assist to silence notifications during work hours. Access it from the Action Center or Settings.', + appNotificationsTitle: 'Configure App Notifications', + appNotificationsDesc: 'Go to Windows Settings > System > Notifications to customize which apps can send you notifications.', + timeLimitsTitle: 'Set Time Limits', + timeLimitsDesc: 'Use the Apps page to set daily time limits for distracting applications. You will receive a notification when limits are exceeded.', + }, + }, + settings: { + title: 'Settings', + subtitle: 'Customize your ScreenForge experience', + loadingSubtitle: 'Loading...', + sections: { + appearance: 'Appearance', + appearanceDesc: 'Choose your preferred theme', + language: 'Language', + languageDesc: 'Choose your interface language', + behavior: 'Behavior', + behaviorDesc: 'Control how ScreenForge runs', + timeLimits: 'Time Limits', + timeLimitsDesc: 'Control app usage notifications', + data: 'Data', + dataDesc: 'Manage your tracked data', + about: 'About', + }, + behavior: { + startWithWindows: 'Start with Windows', + startWithWindowsDesc: 'Launch ScreenForge when you log in', + minimizeToTray: 'Minimize to tray', + minimizeToTrayDesc: 'Keep running in background when closed', + }, + timeLimits: { + notifications: 'Time limit notifications', + notificationsDesc: 'Get notified when you exceed app time limits', + activeLimits: 'Active limits', + activeLimitsDesc: 'You have {count} app(s) with time limits. Manage them on the Apps page.', + }, + data: { + clearAll: 'Clear all data', + clearAllDesc: 'Remove all tracked usage history', + clearButton: 'Clear data', + clearConfirm: 'Are you sure? This will delete all your usage data.', + }, + about: { + version: 'Version', + platform: 'Platform', + builtWith: 'Built with', + windows: 'Windows', + techStack: 'Electron + React', + }, + }, + tables: { + topApps: 'Top apps', + usageToday: 'Usage today', + app: 'App', + category: 'Category', + time: 'Time', + notifications: 'Notifications', + empty: 'No app data yet for today', + notificationToday: 'Today', + notificationEmpty: 'No notifications yet', + }, + datePicker: { + previousMonth: 'Previous month', + nextMonth: 'Next month', + dataAvailable: 'Data available', + dataRange: 'Data from {start} to {end}', + }, + suggestions: { + title: 'Suggestions', + subtitle: 'Based on your recent activity', + welcome: { + title: 'Welcome to ScreenForge!', + detail: 'Keep the app running to track your screen time automatically.', + }, + entertainment: { + title: 'High entertainment usage', + detail: 'Consider setting time limits for entertainment apps to boost productivity.', + }, + social: { + title: 'Social apps taking over', + detail: 'Try scheduling specific times for checking social media.', + }, + productive: { + title: 'Great focus!', + detail: "You're spending most of your time on productive tasks. Keep it up!", + }, + balance: { + title: 'Balanced usage', + detail: 'Your screen time is well distributed across different activities.', + }, + breaks: { + title: 'Remember to take breaks', + detail: 'Use the 20-20-20 rule: every 20 minutes, look at something 20 feet away for 20 seconds.', + }, + }, + native: { + timeLimitReachedTitle: 'Time Limit Reached', + timeLimitReachedBody: "You've used {appName} for {usedMinutes} minutes today. Your limit is {limitMinutes} minutes.", + trayTooltip: 'ScreenForge - Screen Time Tracker', + trayShow: 'Show ScreenForge', + trayQuit: 'Quit', + }, + categories: { + Productivity: 'Productivity', + Education: 'Education', + Communication: 'Communication', + Utilities: 'Utilities', + Browsers: 'Browsers', + Entertainment: 'Entertainment', + Social: 'Social', + System: 'System', + Other: 'Other', + Unknown: 'Unknown', + }, +} as const satisfies TranslationTree diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts new file mode 100644 index 0000000..60eb845 --- /dev/null +++ b/src/i18n/locales/zh-CN.ts @@ -0,0 +1,318 @@ +import type { TranslationTree } from '../types' + +export const zhCN = { + common: { + today: '今天', + yesterday: '昨天', + none: '无', + noData: '暂无数据', + notAvailable: '暂无', + loading: '加载中...', + live: '实时', + focused: '当前焦点', + tracking: '追踪中', + unknown: '未知', + }, + locales: { + 'zh-CN': '简体中文', + 'en-US': 'English', + }, + nav: { + dashboard: '仪表盘', + insights: '洞察', + apps: '应用', + notifications: '通知', + settings: '设置', + }, + sidebar: { + focusScore: '专注分', + }, + themes: { + dark: { + name: '深色', + description: '更护眼,适合夜间使用', + }, + light: { + name: '浅色', + description: '清爽明亮,适合白天使用', + }, + tokyo: { + name: '东京', + description: '带有霓虹感的未来风配色', + }, + skin: { + name: '暖肤', + description: '温暖柔和的界面风格', + }, + }, + dashboard: { + greeting: { + morning: '早上好', + afternoon: '下午好', + evening: '晚上好', + }, + subtitle: '查看你今天的屏幕使用情况', + cards: { + screenTime: '屏幕时长', + totalToday: '今日总计', + dailyAverage: '日均', + acrossDays: '近 {count} 天平均', + noHistory: '暂无历史记录', + topCategory: '最高分类', + notifications: '通知数', + topNotificationApp: '最高:{name}', + }, + charts: { + dailyUsageTitle: '每日使用趋势', + dailyUsageSubtitle: '所有应用每天的使用分钟数', + dailyUsageEmpty: '开始使用应用后,这里会显示你的使用数据', + categoriesTitle: '分类分布', + categoriesSubtitle: '今天各分类的使用时长', + categoriesEmpty: '暂无分类数据', + usageDataset: '使用时长(分钟)', + minutesDataset: '分钟', + }, + sections: { + topAppsToday: '今日常用应用', + activeApps: '活跃应用', + activeAppsSubtitle: '当前已打开的窗口', + activeAppsEmpty: '暂未检测到可见窗口应用', + currentFocus: '当前专注应用', + appsUsedToday: '今日使用应用数', + daysRecorded: '记录天数', + noActiveApp: '当前没有活跃应用', + otherApps: '其他应用', + }, + trend: { + collecting: '正在收集数据', + newData: '刚开始记录', + up: '呈上升趋势', + down: '呈下降趋势', + stable: '基本稳定', + }, + }, + insights: { + title: '洞察', + subtitle: '更深入地了解你的屏幕使用模式', + cards: { + focusScore: '专注分', + focusScoreSub: '基于高效应用使用情况计算', + appsUsed: '使用应用数', + topApp: '最常用应用', + topAppSub: '按分钟数统计', + totalTracked: '累计记录', + totalTrackedSub: '全部历史屏幕时长', + }, + charts: { + weeklyTrendTitle: '近一周趋势', + weeklyTrendSubtitle: '过去 7 天的屏幕使用时长', + weeklyTrendEmpty: '开始使用应用后,这里会显示趋势', + categoryBreakdownTitle: '分类占比', + categoryBreakdownSubtitle: '你的屏幕时间主要花在哪里', + categoryBreakdownEmpty: '暂无分类数据', + minutesDataset: '分钟', + }, + quickStats: { + title: '速览统计', + daysTracked: '记录天数', + appsUsed: '使用应用数', + dailyAverage: '日均时长', + topCategory: '最高分类', + peakDay: '峰值日期', + }, + }, + apps: { + title: '应用', + subtitle: '{date} 的屏幕使用情况', + dateView: '查看:', + stats: { + totalTime: '总时长', + appsUsed: '使用应用数', + topCategory: '最高分类', + }, + openApps: { + title: '已打开应用', + subtitle: '有可见窗口的应用', + empty: '暂未检测到有可见窗口的应用。', + }, + backgroundApps: { + title: '后台进程', + subtitle: '正在运行但没有可见窗口', + empty: '未检测到后台进程。', + processCount: '{count} 个进程', + }, + timeLimits: { + title: '已启用时长限制', + subtitle: '共 {count} 个应用设置了限额', + usage: '{used} / {limit} 分钟', + exceeded: '已超限', + setLimit: '设置时长限制', + edit: '编辑', + remove: '移除', + save: '保存', + cancel: '取消', + limit: '限制:', + minutesPlaceholder: '分钟', + perDayUnit: '分钟/天', + }, + controls: { + searchPlaceholder: '搜索应用...', + sortBy: '排序方式:', + time: '时长', + name: '名称', + category: '分类', + }, + empty: { + search: '没有匹配搜索条件的应用', + date: '{date} 暂无应用使用记录', + }, + cards: { + percentageOfTotal: '占总时长 {count}%', + }, + }, + notifications: { + title: '通知', + subtitle: '跟踪今天来自各应用的通知活动', + status: { + disabledTitle: '通知日志未启用', + disabledMessage: 'Windows 通知日志当前已关闭,请在事件查看器中启用,或尝试以管理员身份运行。', + errorTitle: '读取日志失败', + errorMessage: '读取通知日志失败。', + errorWithDetails: '读取通知日志失败:{detail}', + }, + stats: { + totalToday: '今日总数', + apps: '应用数', + avgPerApp: '单应用平均', + topSender: '最高来源', + }, + breakdown: { + title: '按应用拆分', + subtitle: '今天每个应用收到的通知数', + countBadge: '{count} 个应用', + app: '应用', + count: '数量', + emptyTitle: '还没有记录到通知', + emptySubtitle: '来自应用的通知会在收到后显示在这里。', + emptyActionSubtitle: '先解决上方问题,之后即可开始跟踪通知。', + otherApps: '其他应用', + }, + tips: { + title: '减少干扰', + subtitle: '管理通知过载的小建议', + focusAssistTitle: '开启专注助手', + focusAssistDesc: '使用 Windows 专注助手在工作时段静音通知,可在操作中心或设置中开启。', + appNotificationsTitle: '配置应用通知', + appNotificationsDesc: '前往 Windows 设置 > 系统 > 通知,自定义哪些应用可以向你发送通知。', + timeLimitsTitle: '设置时长限制', + timeLimitsDesc: '你可以在“应用”页面为容易分心的应用设置每日限制,超限后会收到提醒。', + }, + }, + settings: { + title: '设置', + subtitle: '自定义你的 ScreenForge 使用体验', + loadingSubtitle: '加载中...', + sections: { + appearance: '外观', + appearanceDesc: '选择你喜欢的主题', + language: '语言', + languageDesc: '选择界面显示语言', + behavior: '行为', + behaviorDesc: '控制 ScreenForge 的运行方式', + timeLimits: '时长限制', + timeLimitsDesc: '控制应用使用提醒', + data: '数据', + dataDesc: '管理已记录的数据', + about: '关于', + }, + behavior: { + startWithWindows: '开机启动', + startWithWindowsDesc: '登录 Windows 后自动启动 ScreenForge', + minimizeToTray: '最小化到托盘', + minimizeToTrayDesc: '关闭窗口后继续在后台运行', + }, + timeLimits: { + notifications: '时长超限提醒', + notificationsDesc: '当应用使用超出限制时发送提醒', + activeLimits: '当前限制', + activeLimitsDesc: '你已为 {count} 个应用设置时长限制,可前往“应用”页面管理。', + }, + data: { + clearAll: '清空所有数据', + clearAllDesc: '删除所有已记录的使用历史', + clearButton: '清空数据', + clearConfirm: '确定吗?这将删除你所有的使用数据。', + }, + about: { + version: '版本', + platform: '平台', + builtWith: '技术栈', + windows: 'Windows', + techStack: 'Electron + React', + }, + }, + tables: { + topApps: '常用应用', + usageToday: '今日使用情况', + app: '应用', + category: '分类', + time: '时长', + notifications: '通知', + empty: '今天还没有应用数据', + notificationToday: '今天', + notificationEmpty: '还没有通知', + }, + datePicker: { + previousMonth: '上个月', + nextMonth: '下个月', + dataAvailable: '有数据记录', + dataRange: '数据范围:{start} 至 {end}', + }, + suggestions: { + title: '建议', + subtitle: '基于你最近的活动生成', + welcome: { + title: '欢迎使用 ScreenForge!', + detail: '保持应用在后台运行,即可自动记录你的屏幕时长。', + }, + entertainment: { + title: '娱乐应用使用偏高', + detail: '可以考虑为娱乐类应用设置时长限制,提升专注度。', + }, + social: { + title: '社交应用占比偏高', + detail: '试着给查看社交媒体安排固定时间,减少频繁打断。', + }, + productive: { + title: '专注状态不错!', + detail: '你大部分时间都花在高效任务上,继续保持。', + }, + balance: { + title: '使用分布较均衡', + detail: '你的屏幕时间在不同活动之间分配得比较平衡。', + }, + breaks: { + title: '记得适当休息', + detail: '试试 20-20-20 法则:每 20 分钟,看向 20 英尺外的物体 20 秒。', + }, + }, + native: { + timeLimitReachedTitle: '已达到时长限制', + timeLimitReachedBody: '今天你已使用 {appName} {usedMinutes} 分钟,超过设定限制 {limitMinutes} 分钟。', + trayTooltip: 'ScreenForge - 屏幕时间追踪器', + trayShow: '显示 ScreenForge', + trayQuit: '退出', + }, + categories: { + Productivity: '生产力', + Education: '教育', + Communication: '沟通', + Utilities: '工具', + Browsers: '浏览器', + Entertainment: '娱乐', + Social: '社交', + System: '系统', + Other: '其他', + Unknown: '未知', + }, +} as const satisfies TranslationTree diff --git a/src/i18n/types.ts b/src/i18n/types.ts new file mode 100644 index 0000000..400f95d --- /dev/null +++ b/src/i18n/types.ts @@ -0,0 +1,7 @@ +export const supportedLocales = ['zh-CN', 'en-US'] as const + +export type LocaleCode = (typeof supportedLocales)[number] + +export type TranslationTree = { + [key: string]: string | TranslationTree +} diff --git a/src/main.tsx b/src/main.tsx index 702dbaa..0c4594f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client' import '@fontsource/inter/index.css' import './index.css' import App from './App.tsx' +import { I18nProvider } from './i18n/I18nProvider.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/src/pages/Apps.tsx b/src/pages/Apps.tsx index 958a4ed..05ac042 100644 --- a/src/pages/Apps.tsx +++ b/src/pages/Apps.tsx @@ -1,13 +1,12 @@ import { useMemo, useState, useEffect } from 'react' import type { UsageSnapshot } from '../services/usageService' import type { AppInfo, AppTimeLimit } from '../types/models' +import { useI18n } from '../i18n/I18nProvider' import { - formatSeconds, getAppTotals, getEntriesForDate, getAvailableDates, getTodayDateString, - formatDateLabel, getCategoryTotals, } from '../utils/analytics' import { fetchTimeLimits, addTimeLimit, removeTimeLimit } from '../services/usageService' @@ -21,6 +20,7 @@ interface AppsProps { type SortBy = 'time' | 'name' | 'category' const Apps = ({ snapshot }: AppsProps) => { + const { t, formatSeconds, formatDateLabel, translateCategory } = useI18n() const [sortBy, setSortBy] = useState('time') const [search, setSearch] = useState('') const [timeLimits, setTimeLimits] = useState([]) @@ -97,7 +97,10 @@ const Apps = ({ snapshot }: AppsProps) => { if (search) { const q = search.toLowerCase() sorted = sorted.filter( - (a) => a.app.name.toLowerCase().includes(q) || a.app.category.toLowerCase().includes(q) + (a) => + a.app.name.toLowerCase().includes(q) || + a.app.category.toLowerCase().includes(q) || + translateCategory(a.app.category).toLowerCase().includes(q) ) } @@ -107,7 +110,7 @@ const Apps = ({ snapshot }: AppsProps) => { categoryTotals: catTotals, todayUsageByApp: todayUsage, } - }, [snapshot, sortBy, search, selectedDate]) + }, [snapshot, sortBy, search, selectedDate, translateCategory]) const handleSetLimit = async (appId: string) => { const minutes = parseInt(limitInputValue, 10) @@ -142,9 +145,9 @@ const Apps = ({ snapshot }: AppsProps) => { <>
-
Apps
+
{t('apps.title')}
- Screen time for {formatDateLabel(selectedDate)} + {t('apps.subtitle', { date: formatDateLabel(selectedDate) })}
@@ -152,7 +155,7 @@ const Apps = ({ snapshot }: AppsProps) => { {/* Date Selector & Stats */}
- View: + {t('apps.dateView')} {
{formatSeconds(totalSeconds)} - Total Time + {t('apps.stats.totalTime')}
{appList.length} - Apps Used + {t('apps.stats.appsUsed')}
- {categoryTotals[0]?.category ?? 'None'} - Top Category + {categoryTotals[0] ? translateCategory(categoryTotals[0].category) : t('common.none')} + {t('apps.stats.topCategory')}
@@ -179,11 +182,11 @@ const Apps = ({ snapshot }: AppsProps) => { <>
-
Open apps
-
Apps with visible windows
+
{t('apps.openApps.title')}
+
{t('apps.openApps.subtitle')}
{openApps.length === 0 ? ( -
No apps with visible windows detected yet.
+
{t('apps.openApps.empty')}
) : (
{openApps.map((p) => ( @@ -197,7 +200,7 @@ const Apps = ({ snapshot }: AppsProps) => { /> {p.appInfo?.name ?? p.process} {p.appId === snapshot?.activeAppId && ( - focused + {t('common.focused')} )}
))} @@ -207,18 +210,18 @@ const Apps = ({ snapshot }: AppsProps) => {
-
Background processes
-
Running without visible windows
+
{t('apps.backgroundApps.title')}
+
{t('apps.backgroundApps.subtitle')}
{backgroundApps.length === 0 ? ( -
No background processes detected.
+
{t('apps.backgroundApps.empty')}
) : (
{backgroundApps.slice(0, 16).map((p) => (
{p.appInfo?.name ?? p.process} - {p.count} {p.count === 1 ? 'process' : 'processes'} + {t('apps.backgroundApps.processCount', { count: p.count })}
))} @@ -232,9 +235,9 @@ const Apps = ({ snapshot }: AppsProps) => { {timeLimits.length > 0 && isToday && (
-
Active Time Limits
+
{t('apps.timeLimits.title')}
- {timeLimits.length} app{timeLimits.length !== 1 ? 's' : ''} with limits + {t('apps.timeLimits.subtitle', { count: timeLimits.length })}
@@ -255,7 +258,7 @@ const Apps = ({ snapshot }: AppsProps) => { /> {app?.name ?? limit.appId} - {usedMinutes}m / {limit.limitMinutes}m + {t('apps.timeLimits.usage', { used: usedMinutes, limit: limit.limitMinutes })}
{ }} />
- {isExceeded && exceeded} + {isExceeded && {t('apps.timeLimits.exceeded')}}
) })} @@ -278,20 +281,20 @@ const Apps = ({ snapshot }: AppsProps) => { setSearch(e.target.value)} />
- Sort by: + {t('apps.controls.sortBy')}
@@ -300,8 +303,8 @@ const Apps = ({ snapshot }: AppsProps) => { {appList.length === 0 ? (
{search - ? 'No apps match your search' - : `No apps tracked for ${formatDateLabel(selectedDate)}`} + ? t('apps.empty.search') + : t('apps.empty.date', { date: formatDateLabel(selectedDate) })}
) : ( appList.map(({ app, seconds }) => { @@ -369,6 +372,7 @@ const AppCard = ({ onCancelEdit, onRemoveLimit, }: AppCardProps) => { + const { t, formatSeconds, translateCategory } = useI18n() const isExceeded = limit && todayMinutes >= limit.limitMinutes const limitProgress = limit ? Math.min(100, Math.round((todayMinutes / limit.limitMinutes) * 100)) : 0 @@ -378,12 +382,12 @@ const AppCard = ({
{app.name}
-
{app.category}
+
{translateCategory(app.category)}
{formatSeconds(seconds)} - {percentage}% of total + {t('apps.cards.percentageOfTotal', { count: percentage })}
@@ -397,7 +401,7 @@ const AppCard = ({ onLimitInputChange(e.target.value)} onKeyDown={(e) => { @@ -407,16 +411,16 @@ const AppCard = ({ autoFocus min={1} /> - min/day - - + {t('apps.timeLimits.perDayUnit')} + +
) : limit ? (
- Limit: + {t('apps.timeLimits.limit')} - {todayMinutes}m / {limit.limitMinutes}m + {t('apps.timeLimits.usage', { used: todayMinutes, limit: limit.limitMinutes })}
@@ -429,13 +433,13 @@ const AppCard = ({ />
- - + +
) : ( )}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 16736b0..861945e 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -17,9 +17,8 @@ import NotificationSummary from '../components/NotificationSummary' import SuggestionPanel from '../components/SuggestionPanel' import type { ThemeName, SuggestionItem, NotificationSummary as NotifSummaryType, AppInfo } from '../types/models' import type { UsageSnapshot } from '../services/usageService' +import { useI18n } from '../i18n/I18nProvider' import { - formatMinutes, - formatSeconds, getAppTotals, getCategoryTotals, getDailyTotals, @@ -110,6 +109,7 @@ const buildFallbackApp = (appId: string): AppInfo => { } const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: DashboardProps) => { + const { t, formatMinutes, formatSeconds, formatDate, translateCategory } = useI18n() const { dailyTotals, todaySeconds, @@ -181,8 +181,8 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo }, [snapshot, notificationSummary]) const activeAppName = snapshot?.activeAppId - ? snapshot.apps.find((app) => app.id === snapshot.activeAppId)?.name ?? 'Other apps' - : 'No active app' + ? snapshot.apps.find((app) => app.id === snapshot.activeAppId)?.name ?? t('dashboard.sections.otherApps') + : t('dashboard.sections.noActiveApp') // Get currently running apps with windows (active apps) const activeApps = useMemo(() => { @@ -198,7 +198,7 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo }, [snapshot]) const chartLabels = dailyTotals.map((entry) => - new Date(entry.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + formatDate(`${entry.date}T00:00:00`, { month: 'short', day: 'numeric' }), ) const axisColor = @@ -210,7 +210,7 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo labels: chartLabels, datasets: [ { - label: 'Usage (minutes)', + label: t('dashboard.charts.usageDataset'), data: dailyTotals.map((entry) => entry.minutes), borderColor: 'rgba(79, 139, 255, 0.9)', backgroundColor: 'rgba(79, 139, 255, 0.15)', @@ -222,10 +222,10 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo } const categoryChartData = { - labels: todayCategoryTotals.map((row) => row.category), + labels: todayCategoryTotals.map((row) => translateCategory(row.category)), datasets: [ { - label: 'Minutes', + label: t('dashboard.charts.minutesDataset'), data: todayCategoryTotals.map((row) => row.minutes), backgroundColor: ['#4f8bff', '#8c7dff', '#2ed47a', '#ff8b6a', '#f7b955'], borderRadius: 12, @@ -237,31 +237,35 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo <>
-
{getGreeting()}
-
Your screen time for today
+
{getGreeting(t)}
+
{t('dashboard.subtitle')}
0 ? `Across ${dailyTotals.length} days` : 'No history yet'} + sub={dailyTotals.length > 0 + ? t('dashboard.cards.acrossDays', { count: dailyTotals.length }) + : t('dashboard.cards.noHistory')} />
@@ -270,10 +274,10 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo
-

Daily usage

-

Minutes per day across all apps

+

{t('dashboard.charts.dailyUsageTitle')}

+

{t('dashboard.charts.dailyUsageSubtitle')}

- {getTrendLabel(dailyTotals)} + {getTrendLabel(dailyTotals, t)}
{dailyTotals.length > 0 ? ( ) : ( -
Start using apps to see your usage data
+
{t('dashboard.charts.dailyUsageEmpty')}
)}
-

Categories

-

Time by category today

+

{t('dashboard.charts.categoriesTitle')}

+

{t('dashboard.charts.categoriesSubtitle')}

- Today + {t('common.today')}
{todayCategoryTotals.length > 0 ? ( ) : ( -
No category data yet
+
{t('dashboard.charts.categoriesEmpty')}
)}
- + {notificationSummary && }
-

Active apps

-

Currently open windows

+

{t('dashboard.sections.activeApps')}

+

{t('dashboard.sections.activeAppsSubtitle')}

{activeApps.length === 0 ? ( -
No apps with visible windows detected
+
{t('dashboard.sections.activeAppsEmpty')}
) : (
{activeApps.map((app) => ( @@ -346,7 +350,7 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo {app.appInfo?.name ?? app.process} {app.appId === snapshot?.activeAppId && ( - focused + {t('common.focused')} )}
))} @@ -357,21 +361,21 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo
- Tracking + {t('common.tracking')}
- Live + {t('common.live')}
- Current focus + {t('dashboard.sections.currentFocus')} {activeAppName}
- Apps used today + {t('dashboard.sections.appsUsedToday')} {todayAppsCount}
- Days recorded + {t('dashboard.sections.daysRecorded')} {dailyTotals.length}
@@ -381,24 +385,24 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo ) } -const getGreeting = () => { +const getGreeting = (t: (key: string) => string) => { const hour = new Date().getHours() - if (hour < 12) return 'Good morning' - if (hour < 18) return 'Good afternoon' - return 'Good evening' + if (hour < 12) return t('dashboard.greeting.morning') + if (hour < 18) return t('dashboard.greeting.afternoon') + return t('dashboard.greeting.evening') } -const getTrendLabel = (dailyTotals: { minutes: number }[]) => { - if (dailyTotals.length < 2) return 'Collecting data' +const getTrendLabel = (dailyTotals: { minutes: number }[], t: (key: string) => string) => { + if (dailyTotals.length < 2) return t('dashboard.trend.collecting') const recent = dailyTotals.slice(-3) const older = dailyTotals.slice(-6, -3) - if (older.length === 0) return 'New data' + if (older.length === 0) return t('dashboard.trend.newData') const recentAvg = recent.reduce((s, e) => s + e.minutes, 0) / recent.length const olderAvg = older.reduce((s, e) => s + e.minutes, 0) / older.length const diff = ((recentAvg - olderAvg) / Math.max(olderAvg, 1)) * 100 - if (diff > 10) return 'Trending up' - if (diff < -10) return 'Trending down' - return 'Stable' + if (diff > 10) return t('dashboard.trend.up') + if (diff < -10) return t('dashboard.trend.down') + return t('dashboard.trend.stable') } export default Dashboard diff --git a/src/pages/Insights.tsx b/src/pages/Insights.tsx index 6cb1b80..d4ca74d 100644 --- a/src/pages/Insights.tsx +++ b/src/pages/Insights.tsx @@ -12,7 +12,8 @@ import { import { Doughnut, Line } from 'react-chartjs-2' import type { UsageSnapshot } from '../services/usageService' import type { ThemeName } from '../types/models' -import { formatMinutes, getDailyTotals, getCategoryTotals, getAppTotals, calculateFocusScore, getTodayEntries } from '../utils/analytics' +import { useI18n } from '../i18n/I18nProvider' +import { getDailyTotals, getCategoryTotals, getAppTotals, calculateFocusScore, getTodayEntries } from '../utils/analytics' import './Insights.css' ChartJS.register(ArcElement, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend) @@ -23,6 +24,7 @@ interface InsightsProps { } const Insights = ({ snapshot, theme }: InsightsProps) => { + const { t, formatMinutes, formatDate, translateCategory } = useI18n() const { dailyTotals, categoryTotals, @@ -40,9 +42,9 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { appTotals: [], focusScore: 0, todayAppsCount: 0, - topAppName: 'N/A', - topCategoryName: 'N/A', - peakDayLabel: 'N/A', + topAppName: t('common.notAvailable'), + topCategoryName: t('common.notAvailable'), + peakDayLabel: t('common.notAvailable'), } } @@ -67,11 +69,11 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { appTotals: apps, focusScore: score, todayAppsCount: todayAppTotals.length, - topAppName: topApp?.app.name ?? 'N/A', - topCategoryName: topCategory?.category ?? 'N/A', - peakDayLabel: peakDay ? new Date(peakDay.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'N/A', + topAppName: topApp?.app.name ?? t('common.notAvailable'), + topCategoryName: topCategory ? translateCategory(topCategory.category) : t('common.notAvailable'), + peakDayLabel: peakDay ? formatDate(`${peakDay.date}T00:00:00`, { month: 'short', day: 'numeric' }) : t('common.notAvailable'), } - }, [snapshot]) + }, [snapshot, formatDate, t, translateCategory]) const style = typeof window !== 'undefined' ? getComputedStyle(document.documentElement) : null const axisColor = style?.getPropertyValue('--text-muted').trim() || 'rgba(255,255,255,0.6)' @@ -84,10 +86,10 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { const weeklyData = useMemo(() => { const last7 = dailyTotals.slice(-7) return { - labels: last7.map((d) => new Date(d.date).toLocaleDateString('en-US', { weekday: 'short' })), + labels: last7.map((d) => formatDate(`${d.date}T00:00:00`, { weekday: 'short' })), datasets: [ { - label: 'Minutes', + label: t('insights.charts.minutesDataset'), data: last7.map((d) => d.minutes), borderColor: accentColor, backgroundColor: 'transparent', @@ -96,10 +98,10 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { }, ], } - }, [dailyTotals, accentColor]) + }, [dailyTotals, accentColor, formatDate, t]) const categoryDonutData = useMemo(() => ({ - labels: categoryTotals.map((c) => c.category), + labels: categoryTotals.map((c) => translateCategory(c.category)), datasets: [ { data: categoryTotals.map((c) => c.minutes), @@ -107,7 +109,7 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { borderWidth: 0, }, ], - }), [categoryTotals]) + }), [categoryTotals, translateCategory]) const totalTime = dailyTotals.reduce((s, d) => s + d.minutes, 0) const avgDaily = dailyTotals.length > 0 ? Math.round(totalTime / dailyTotals.length) : 0 @@ -116,31 +118,31 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { <>
-
Insights
-
Deep dive into your screen time patterns
+
{t('insights.title')}
+
{t('insights.subtitle')}
-
Focus Score
+
{t('insights.cards.focusScore')}
{focusScore}
-
Based on productive app usage
+
{t('insights.cards.focusScoreSub')}
-
Apps Used
+
{t('insights.cards.appsUsed')}
{todayAppsCount}
-
Today
+
{t('common.today')}
-
Top app
+
{t('insights.cards.topApp')}
{topAppName}
-
Most used by minutes
+
{t('insights.cards.topAppSub')}
-
Total tracked
+
{t('insights.cards.totalTracked')}
{formatMinutes(totalTime)}
-
All time screen time
+
{t('insights.cards.totalTrackedSub')}
@@ -148,8 +150,8 @@ const Insights = ({ snapshot, theme }: InsightsProps) => {
-

Weekly trend

-

Screen time over the last 7 days

+

{t('insights.charts.weeklyTrendTitle')}

+

{t('insights.charts.weeklyTrendSubtitle')}

{dailyTotals.length > 0 ? ( @@ -165,15 +167,15 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { }} /> ) : ( -
Start using apps to see trends
+
{t('insights.charts.weeklyTrendEmpty')}
)}
-

Category breakdown

-

How you spend your screen time

+

{t('insights.charts.categoryBreakdownTitle')}

+

{t('insights.charts.categoryBreakdownSubtitle')}

{categoryTotals.length > 0 ? ( @@ -190,33 +192,33 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { />
) : ( -
No category data yet
+
{t('insights.charts.categoryBreakdownEmpty')}
)}
-

Quick stats

+

{t('insights.quickStats.title')}

- Days tracked + {t('insights.quickStats.daysTracked')} {dailyTotals.length}
- Apps used + {t('insights.quickStats.appsUsed')} {appTotals.length}
- Daily average + {t('insights.quickStats.dailyAverage')} {formatMinutes(avgDaily)}
- Top category + {t('insights.quickStats.topCategory')} {topCategoryName}
- Peak day + {t('insights.quickStats.peakDay')} {peakDayLabel}
diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index 5cf8a46..8aaa666 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import type { UsageSnapshot } from '../services/usageService' import type { NotificationSummary as NotifSummaryType } from '../types/models' +import { useI18n } from '../i18n/I18nProvider' import './Notifications.css' interface NotificationsProps { @@ -9,6 +10,7 @@ interface NotificationsProps { } const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => { + const { t, translateCategory } = useI18n() const { appNotifications, totalNotifications, avgPerApp, topSender, statusInfo } = useMemo(() => { if (!snapshot || !notificationSummary) { return { @@ -16,7 +18,7 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => totalNotifications: 0, avgPerApp: 0, topSender: null, - statusInfo: { status: 'loading' as const, message: 'Loading...' } + statusInfo: { status: 'loading' as const, message: t('common.loading') } } } @@ -32,7 +34,7 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => // For entries without app info, create a placeholder const processedEntries = entries.map(e => ({ - app: e.app ?? { id: e.appId, name: e.appId === 'other' ? 'Other Apps' : e.appId, category: 'Unknown', color: '#6b7280' }, + app: e.app ?? { id: e.appId, name: e.appId === 'other' ? t('notifications.breakdown.otherApps') : e.appId, category: 'Unknown', color: '#6b7280' }, count: e.count, })) @@ -44,12 +46,14 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => if (notificationSummary.status === 'no-logs') { statusInfo = { status: 'no-logs', - message: 'Windows notification logs are disabled. Enable them in Event Viewer or run as Administrator.', + message: t('notifications.status.disabledMessage'), } } else if (notificationSummary.status === 'error') { statusInfo = { status: 'error', - message: notificationSummary.errorMessage ?? 'Failed to read notification logs.', + message: notificationSummary.errorMessage + ? t('notifications.status.errorWithDetails', { detail: notificationSummary.errorMessage }) + : t('notifications.status.errorMessage'), } } else { statusInfo = { status: 'ok', message: '' } @@ -62,7 +66,7 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => topSender: top, statusInfo, } - }, [snapshot, notificationSummary]) + }, [snapshot, notificationSummary, t]) const maxCount = appNotifications.length > 0 ? appNotifications[0].count : 1 @@ -70,8 +74,8 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => <>
-
Notifications
-
Track notification activity from your apps today
+
{t('notifications.title')}
+
{t('notifications.subtitle')}
@@ -80,7 +84,7 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) =>
- {statusInfo.status === 'no-logs' ? 'Notification Logs Disabled' : 'Error Reading Logs'} + {statusInfo.status === 'no-logs' ? t('notifications.status.disabledTitle') : t('notifications.status.errorTitle')} {statusInfo.message}
@@ -91,24 +95,24 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) =>
{totalNotifications} - Total Today + {t('notifications.stats.totalToday')}
{appNotifications.length} - Apps + {t('notifications.stats.apps')}
{avgPerApp} - Avg per App + {t('notifications.stats.avgPerApp')}
- {topSender?.app.name ?? 'None'} + {topSender?.app.name ?? t('common.none')} - Top Sender + {t('notifications.stats.topSender')}
@@ -116,28 +120,28 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) =>
-

Breakdown by App

-

Notifications received today per application

+

{t('notifications.breakdown.title')}

+

{t('notifications.breakdown.subtitle')}

{appNotifications.length > 0 && ( - {appNotifications.length} apps + {t('notifications.breakdown.countBadge', { count: appNotifications.length })} )}
{appNotifications.length === 0 ? (
-
No notifications tracked yet
+
{t('notifications.breakdown.emptyTitle')}
{statusInfo.status === 'ok' - ? 'Notifications from your apps will appear here as they come in.' - : 'Fix the issue above to start tracking notifications.'} + ? t('notifications.breakdown.emptySubtitle') + : t('notifications.breakdown.emptyActionSubtitle')}
) : (
- Application - Count + {t('notifications.breakdown.app')} + {t('notifications.breakdown.count')}
{appNotifications.map(({ app, count }) => { const percentage = Math.round((count / totalNotifications) * 100) @@ -147,7 +151,7 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) =>
{app.name} - {app.category} + {translateCategory(app.category)}
@@ -172,34 +176,31 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => {/* Tips Section */}
-

Reduce Distractions

-

Tips for managing notification overload

+

{t('notifications.tips.title')}

+

{t('notifications.tips.subtitle')}

- Enable Focus Assist + {t('notifications.tips.focusAssistTitle')}

- Use Windows Focus Assist to silence notifications during work hours. - Access it from the Action Center or Settings. + {t('notifications.tips.focusAssistDesc')}

- Configure App Notifications + {t('notifications.tips.appNotificationsTitle')}

- Go to Windows Settings > System > Notifications to customize - which apps can send you notifications. + {t('notifications.tips.appNotificationsDesc')}

- Set Time Limits + {t('notifications.tips.timeLimitsTitle')}

- Use the Apps page to set daily time limits for distracting applications. - You will receive a notification when limits are exceeded. + {t('notifications.tips.timeLimitsDesc')}

diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index d91b10e..0ae840b 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { clearUsageData, fetchSettings, updateSettings, fetchTimeLimits } from '../services/usageService' import type { ThemeName, AppSettings, AppTimeLimit } from '../types/models' +import { useI18n, type LocaleCode } from '../i18n/I18nProvider' import './Settings.css' interface SettingsProps { @@ -8,14 +9,8 @@ interface SettingsProps { onThemeChange: (theme: ThemeName) => void } -const themes: { id: ThemeName; name: string; description: string }[] = [ - { id: 'dark', name: 'Dark', description: 'Easy on the eyes, perfect for night' }, - { id: 'light', name: 'Light', description: 'Clean and bright for daytime' }, - { id: 'tokyo', name: 'Tokyo', description: 'Cyberpunk vibes with purple accents' }, - { id: 'skin', name: 'Skin', description: 'Warm and soft aesthetic' }, -] - const Settings = ({ theme, onThemeChange }: SettingsProps) => { + const { locale, setLocale, localeOptions, t, translateThemeName, translateThemeDescription } = useI18n() const [startWithWindows, setStartWithWindows] = useState(false) const [minimizeToTray, setMinimizeToTray] = useState(true) const [timeLimitNotificationsEnabled, setTimeLimitNotificationsEnabled] = useState(true) @@ -34,6 +29,7 @@ const Settings = ({ theme, onThemeChange }: SettingsProps) => { setMinimizeToTray(settings.minimizeToTray) setTimeLimitNotificationsEnabled(settings.timeLimitNotificationsEnabled) setTimeLimits(limits) + setLocale(settings.language) } catch { // Fall back to defaults } finally { @@ -54,13 +50,22 @@ const Settings = ({ theme, onThemeChange }: SettingsProps) => { } } + const handleLanguageChange = async (nextLanguage: LocaleCode) => { + setLocale(nextLanguage) + try { + await updateSettings({ language: nextLanguage }) + } catch { + // Ignore errors and keep local UI language + } + } + if (loading) { return ( <>
-
Settings
-
Loading...
+
{t('settings.title')}
+
{t('settings.loadingSubtitle')}
@@ -71,25 +76,43 @@ const Settings = ({ theme, onThemeChange }: SettingsProps) => { <>
-
Settings
-
Customize your ScreenForge experience
+
{t('settings.title')}
+
{t('settings.subtitle')}
-

Appearance

-

Choose your preferred theme

+

{t('settings.sections.appearance')}

+

{t('settings.sections.appearanceDesc')}

+
+ {(['dark', 'light', 'tokyo', 'skin'] as ThemeName[]).map((themeOption) => ( + + ))} +
+
+ +
+

{t('settings.sections.language')}

+

{t('settings.sections.languageDesc')}

- {themes.map((t) => ( + {localeOptions.map((option) => ( ))} @@ -97,13 +120,13 @@ const Settings = ({ theme, onThemeChange }: SettingsProps) => {
-

Behavior

-

Control how ScreenForge runs

+

{t('settings.sections.behavior')}

+

{t('settings.sections.behaviorDesc')}

-

Time Limits

-

Control app usage notifications

+

{t('settings.sections.timeLimits')}

+

{t('settings.sections.timeLimitsDesc')}

-

Data

-

Manage your tracked data

+

{t('settings.sections.data')}

+

{t('settings.sections.dataDesc')}

-
Clear all data
-
Remove all tracked usage history
+
{t('settings.data.clearAll')}
+
{t('settings.data.clearAllDesc')}
-

About

+

{t('settings.sections.about')}

- Version + {t('settings.about.version')} 1.0.0
- Platform - Windows + {t('settings.about.platform')} + {t('settings.about.windows')}
- Built with - Electron + React + {t('settings.about.builtWith')} + {t('settings.about.techStack')}
diff --git a/src/services/usageService.ts b/src/services/usageService.ts index 283586e..acd1408 100644 --- a/src/services/usageService.ts +++ b/src/services/usageService.ts @@ -66,6 +66,7 @@ export const fetchSettings = async (): Promise => { startWithWindows: false, timeLimits: [], timeLimitNotificationsEnabled: true, + language: 'zh-CN', } } @@ -79,6 +80,7 @@ export const updateSettings = async (settings: Partial): Promise { @@ -20,36 +21,15 @@ export const getDateString = (daysOffset: number = 0): string => { } // Format a date string for display -export const formatDateLabel = (dateString: string): string => { - const today = getTodayDateString() - const yesterday = getDateString(-1) - - if (dateString === today) return 'Today' - if (dateString === yesterday) return 'Yesterday' - - const date = new Date(dateString + 'T00:00:00') - return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) -} +export const formatDateLabel = (dateString: string, locale: LocaleCode = defaultLocale): string => + formatRelativeDateLabel(locale, dateString) -export const formatMinutes = (minutes: number) => { - if (minutes === 0) return '0m' - if (minutes < 1) return '<1m' - const hours = Math.floor(minutes / 60) - const mins = Math.round(minutes % 60) - if (hours === 0) return `${mins}m` - return `${hours}h ${mins}m` -} +export const formatMinutes = (minutes: number, locale: LocaleCode = defaultLocale) => + formatDurationMinutes(locale, minutes) // Format seconds with more granularity for real-time display -export const formatSeconds = (totalSeconds: number) => { - if (totalSeconds < 60) return `${Math.floor(totalSeconds)}s` - const minutes = Math.floor(totalSeconds / 60) - const seconds = Math.floor(totalSeconds % 60) - if (minutes < 60) return `${minutes}m ${seconds}s` - const hours = Math.floor(minutes / 60) - const mins = minutes % 60 - return `${hours}h ${mins}m` -} +export const formatSeconds = (totalSeconds: number, locale: LocaleCode = defaultLocale) => + formatDurationSeconds(locale, totalSeconds) export const getTotalMinutes = (entries: UsageEntry[]) => entries.reduce((sum, entry) => sum + entry.minutes, 0) From 57f001d3ce589f6b332d6a5b187bce1d874f0782 Mon Sep 17 00:00:00 2001 From: Invis1ble_ <1434045601@qq.com> Date: Sun, 15 Mar 2026 12:55:04 +0800 Subject: [PATCH 2/4] fix lints --- dist-electron/main.cjs | 4 ++-- electron/notifications.ts | 4 ++-- electron/preload.ts | 3 ++- eslint.config.js | 14 ++++++++++++- .../src/app/faq/FAQContent.tsx | 1 - screenforge-landing/src/components/Header.tsx | 6 +++--- .../src/components/ThemeToggle.tsx | 10 ++++------ src/App.tsx | 8 +++----- src/components/DatePicker.tsx | 7 +++---- src/i18n/I18nProvider.tsx | 9 ++++++--- src/pages/Apps.tsx | 20 +++++++++---------- src/pages/Settings.tsx | 2 +- 12 files changed, 49 insertions(+), 39 deletions(-) diff --git a/dist-electron/main.cjs b/dist-electron/main.cjs index d7a73a1..f63731e 100644 --- a/dist-electron/main.cjs +++ b/dist-electron/main.cjs @@ -261,7 +261,7 @@ if ($unique.Count -eq 0) { } `; try { - const { stdout, stderr } = await execFileAsync("powershell", [ + const { stdout } = await execFileAsync("powershell", [ "-NoProfile", "-ExecutionPolicy", "Bypass", @@ -288,7 +288,7 @@ if ($unique.Count -eq 0) { } }; var createNotificationTracker = () => { - let persistedData = loadPersistedData(); + const persistedData = loadPersistedData(); let lastError; let saveTimeout = null; const scheduleSave = () => { diff --git a/electron/notifications.ts b/electron/notifications.ts index b607221..6f84986 100644 --- a/electron/notifications.ts +++ b/electron/notifications.ts @@ -261,7 +261,7 @@ if ($unique.Count -eq 0) { ` try { - const { stdout, stderr } = await execFileAsync('powershell', [ + const { stdout } = await execFileAsync('powershell', [ '-NoProfile', '-ExecutionPolicy', 'Bypass', @@ -291,7 +291,7 @@ if ($unique.Count -eq 0) { } export const createNotificationTracker = () => { - let persistedData = loadPersistedData() + const persistedData = loadPersistedData() let lastError: string | undefined let saveTimeout: NodeJS.Timeout | null = null diff --git a/electron/preload.ts b/electron/preload.ts index 019c5ba..6ac4037 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron' +import type { IpcRendererEvent } from 'electron' // Time limit interface interface AppTimeLimit { @@ -134,7 +135,7 @@ const api = { }, // Event listeners onTimeLimitExceeded: (callback: (data: { appId: string; appName: string; usedMinutes: number; limitMinutes: number }) => void) => { - const handler = (_event: electron.IpcRendererEvent, data: { appId: string; appName: string; usedMinutes: number; limitMinutes: number }) => { + const handler = (_event: IpcRendererEvent, data: { appId: string; appName: string; usedMinutes: number; limitMinutes: number }) => { callback(data) } ipcRenderer.on('time-limit-exceeded', handler) diff --git a/eslint.config.js b/eslint.config.js index 5e6b472..012aa9e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,13 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores([ + 'dist', + 'dist-electron', + 'release', + 'screenforge-landing/.next', + 'screenforge-landing/out', + ]), { files: ['**/*.{ts,tsx}'], extends: [ @@ -20,4 +26,10 @@ export default defineConfig([ globals: globals.browser, }, }, + { + files: ['src/i18n/I18nProvider.tsx', 'screenforge-landing/src/app/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, ]) diff --git a/screenforge-landing/src/app/faq/FAQContent.tsx b/screenforge-landing/src/app/faq/FAQContent.tsx index 19da0db..a10064e 100644 --- a/screenforge-landing/src/app/faq/FAQContent.tsx +++ b/screenforge-landing/src/app/faq/FAQContent.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { motion } from 'framer-motion' -import Image from 'next/image' import Header from '@/components/Header' import Footer from '@/components/Footer' diff --git a/screenforge-landing/src/components/Header.tsx b/screenforge-landing/src/components/Header.tsx index 923d469..f08bb6b 100644 --- a/screenforge-landing/src/components/Header.tsx +++ b/screenforge-landing/src/components/Header.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useEffect, useState, useSyncExternalStore } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { useTheme } from 'next-themes' import Image from 'next/image' @@ -12,16 +12,16 @@ const navLinks = [ ] const GITHUB_REPO = 'raghav3615/screenforge' +const subscribe = () => () => {} export default function Header() { const [scrolled, setScrolled] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [stars, setStars] = useState(null) const { theme, setTheme } = useTheme() - const [mounted, setMounted] = useState(false) + const mounted = useSyncExternalStore(subscribe, () => true, () => false) useEffect(() => { - setMounted(true) const handleScroll = () => { setScrolled(window.scrollY > 20) } diff --git a/screenforge-landing/src/components/ThemeToggle.tsx b/screenforge-landing/src/components/ThemeToggle.tsx index 82de46c..06b574b 100644 --- a/screenforge-landing/src/components/ThemeToggle.tsx +++ b/screenforge-landing/src/components/ThemeToggle.tsx @@ -1,16 +1,14 @@ "use client"; import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; import { cn } from "@/lib/utils"; +const subscribe = () => () => {}; + export function ThemeToggle() { const { theme, setTheme } = useTheme(); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); + const mounted = useSyncExternalStore(subscribe, () => true, () => false); if (!mounted) { return ( diff --git a/src/App.tsx b/src/App.tsx index 808ead2..4a9dd20 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,8 +44,6 @@ const App = () => { }, [theme]) useEffect(() => { - let interval: number | undefined - const load = async () => { const [usageSnapshot, suggestionFeed, notificationFeed, settings] = await Promise.all([ fetchUsageSnapshot(), @@ -61,15 +59,15 @@ const App = () => { } load() - interval = window.setInterval(() => { + const intervalId = window.setInterval(() => { fetchUsageSnapshot().then(setSnapshot) fetchNotificationSummary().then(setNotificationSummary) }, 5000) return () => { - if (interval) window.clearInterval(interval) + window.clearInterval(intervalId) } - }, []) + }, [setLocale]) // Calculate focus score using shared utility const focusScore = snapshot diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx index 6b85442..e290ff4 100644 --- a/src/components/DatePicker.tsx +++ b/src/components/DatePicker.tsx @@ -45,8 +45,7 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) } }, [availableDates]) - // Check if a date has data - const hasData = (dateStr: string) => availableDates.includes(dateStr) + const availableDateSet = useMemo(() => new Set(availableDates), [availableDates]) // Get days for the current view month const calendarDays = useMemo(() => { @@ -84,7 +83,7 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) date: d, dateStr, isCurrentMonth: d.getMonth() === month, - hasData: hasData(dateStr), + hasData: availableDateSet.has(dateStr), isToday: dateStr === today, isSelected: dateStr === selectedDate, isFuture: d > todayDate, @@ -92,7 +91,7 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) } return days - }, [viewMonth, availableDates, selectedDate]) + }, [availableDateSet, selectedDate, viewMonth]) const goToPrevMonth = () => { setViewMonth(prev => { diff --git a/src/i18n/I18nProvider.tsx b/src/i18n/I18nProvider.tsx index 7bac28b..ba2fedb 100644 --- a/src/i18n/I18nProvider.tsx +++ b/src/i18n/I18nProvider.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useMemo, useState } from 'react' +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { ThemeName } from '../types/models' import { @@ -47,6 +47,9 @@ const getInitialLocale = (): LocaleCode => { export const I18nProvider = ({ children }: { children: ReactNode }) => { const [locale, setLocaleState] = useState(getInitialLocale) + const setLocale = useCallback((nextLocale: LocaleCode) => { + setLocaleState(normalizeLocale(nextLocale)) + }, []) useEffect(() => { window.localStorage.setItem(LOCALE_STORAGE_KEY, locale) @@ -54,7 +57,7 @@ export const I18nProvider = ({ children }: { children: ReactNode }) => { const value = useMemo(() => ({ locale, - setLocale: (nextLocale) => setLocaleState(normalizeLocale(nextLocale)), + setLocale, t: (key, params) => translate(locale, key, params), formatDate: (value, options) => formatDate(locale, value, options), formatDateLabel: (dateString) => formatRelativeDateLabel(locale, dateString), @@ -65,7 +68,7 @@ export const I18nProvider = ({ children }: { children: ReactNode }) => { translateThemeName: (theme) => translateThemeName(locale, theme), translateThemeDescription: (theme) => translateThemeDescription(locale, theme), localeOptions: getLocaleOptions(), - }), [locale]) + }), [locale, setLocale]) return {children} } diff --git a/src/pages/Apps.tsx b/src/pages/Apps.tsx index 05ac042..8273c77 100644 --- a/src/pages/Apps.tsx +++ b/src/pages/Apps.tsx @@ -45,11 +45,11 @@ const Apps = ({ snapshot }: AppsProps) => { return dates.slice(0, 14) // Last 14 days max }, [snapshot]) - // Ensure selected date is valid - useEffect(() => { - if (!availableDates.includes(selectedDate)) { - setSelectedDate(availableDates[0] || getTodayDateString()) + const activeSelectedDate = useMemo(() => { + if (availableDates.includes(selectedDate)) { + return selectedDate } + return availableDates[0] || getTodayDateString() }, [availableDates, selectedDate]) const runningNow = useMemo(() => { @@ -73,7 +73,7 @@ const Apps = ({ snapshot }: AppsProps) => { } // Filter entries for selected date - const dateEntries = getEntriesForDate(snapshot.usageEntries, selectedDate) + const dateEntries = getEntriesForDate(snapshot.usageEntries, activeSelectedDate) const appTotals = getAppTotals(dateEntries, snapshot.apps) const catTotals = getCategoryTotals(dateEntries, snapshot.apps) const totalSec = appTotals.reduce((s, a) => s + a.seconds, 0) @@ -110,7 +110,7 @@ const Apps = ({ snapshot }: AppsProps) => { categoryTotals: catTotals, todayUsageByApp: todayUsage, } - }, [snapshot, sortBy, search, selectedDate, translateCategory]) + }, [activeSelectedDate, snapshot, sortBy, search, translateCategory]) const handleSetLimit = async (appId: string) => { const minutes = parseInt(limitInputValue, 10) @@ -139,7 +139,7 @@ const Apps = ({ snapshot }: AppsProps) => { return timeLimits.find((l) => l.appId === appId) } - const isToday = selectedDate === getTodayDateString() + const isToday = activeSelectedDate === getTodayDateString() return ( <> @@ -147,7 +147,7 @@ const Apps = ({ snapshot }: AppsProps) => {
{t('apps.title')}
- {t('apps.subtitle', { date: formatDateLabel(selectedDate) })} + {t('apps.subtitle', { date: formatDateLabel(activeSelectedDate) })}
@@ -157,7 +157,7 @@ const Apps = ({ snapshot }: AppsProps) => {
{t('apps.dateView')} @@ -304,7 +304,7 @@ const Apps = ({ snapshot }: AppsProps) => {
{search ? t('apps.empty.search') - : t('apps.empty.date', { date: formatDateLabel(selectedDate) })} + : t('apps.empty.date', { date: formatDateLabel(activeSelectedDate) })}
) : ( appList.map(({ app, seconds }) => { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 0ae840b..10fbed2 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -37,7 +37,7 @@ const Settings = ({ theme, onThemeChange }: SettingsProps) => { } } loadSettings() - }, []) + }, [setLocale]) const handleSettingChange = async (key: keyof AppSettings, value: boolean) => { try { From 2fa57e9eee83e68ea5f7bcf25e6395b70805d33f Mon Sep 17 00:00:00 2001 From: Invis1ble_ <1434045601@qq.com> Date: Wed, 1 Apr 2026 12:51:21 +0800 Subject: [PATCH 3/4] non-preset apps category and more preset apps categorized, and fix non-preset apps history missing bug --- dist-electron/main.cjs | 459 ++++++++++++++++++++++++++++------- dist-electron/preload.cjs | 8 +- electron/main.ts | 64 ++++- electron/preload.ts | 6 + electron/telemetry.ts | 367 +++++++++++++++++++++------- src/i18n/locales/en-US.ts | 8 + src/i18n/locales/zh-CN.ts | 8 + src/pages/Apps.css | 66 +++++ src/pages/Apps.tsx | 139 ++++++++++- src/pages/Settings.tsx | 2 +- src/services/usageService.ts | 4 + src/types/models.ts | 2 + src/utils/categoryColor.ts | 32 +++ 13 files changed, 986 insertions(+), 179 deletions(-) create mode 100644 src/utils/categoryColor.ts diff --git a/dist-electron/main.cjs b/dist-electron/main.cjs index f63731e..a304603 100644 --- a/dist-electron/main.cjs +++ b/dist-electron/main.cjs @@ -381,96 +381,291 @@ var import_electron2 = require("electron"); var fs2 = __toESM(require("node:fs"), 1); var path2 = __toESM(require("node:path"), 1); var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile); -var appCatalog = [ - { id: "code", name: "VS Code", category: "Productivity", color: "#35a7ff" }, - // Browsers - { id: "msedge", name: "Microsoft Edge", category: "Browsers", color: "#4f8bff" }, - { id: "chrome", name: "Google Chrome", category: "Browsers", color: "#f7b955" }, - { id: "firefox", name: "Firefox", category: "Browsers", color: "#ff6611" }, - { id: "zen", name: "Zen Browser", category: "Browsers", color: "#8b5cf6" }, - { id: "brave", name: "Brave", category: "Browsers", color: "#fb542b" }, - { id: "opera", name: "Opera", category: "Browsers", color: "#ff1b2d" }, - { id: "vivaldi", name: "Vivaldi", category: "Browsers", color: "#ef3939" }, - { id: "arc", name: "Arc", category: "Browsers", color: "#5e5ce6" }, - { id: "discord", name: "Discord", category: "Social", color: "#8c7dff" }, - { id: "spotify", name: "Spotify", category: "Entertainment", color: "#2ed47a" }, - { id: "steam", name: "Steam", category: "Entertainment", color: "#ff8b6a" }, - { id: "teams", name: "Microsoft Teams", category: "Communication", color: "#5b7cfa" }, - { id: "outlook", name: "Outlook", category: "Communication", color: "#2f6fff" }, - { id: "explorer", name: "File Explorer", category: "Utilities", color: "#9aa0ff" }, - { id: "notepad", name: "Notepad", category: "Productivity", color: "#a0c4ff" }, - { id: "terminal", name: "Terminal", category: "Productivity", color: "#4ec9b0" }, - { id: "slack", name: "Slack", category: "Communication", color: "#e91e63" }, - { id: "zoom", name: "Zoom", category: "Communication", color: "#2d8cff" }, - { id: "notion", name: "Notion", category: "Productivity", color: "#1f1f1f" }, - { id: "word", name: "Microsoft Word", category: "Productivity", color: "#2b579a" }, - { id: "excel", name: "Microsoft Excel", category: "Productivity", color: "#217346" }, - { id: "powerpoint", name: "PowerPoint", category: "Productivity", color: "#d24726" }, - { id: "vlc", name: "VLC", category: "Entertainment", color: "#ff8c00" }, - { id: "obs", name: "OBS Studio", category: "Productivity", color: "#302e2e" }, - { id: "figma", name: "Figma", category: "Productivity", color: "#f24e1e" }, - { id: "photoshop", name: "Photoshop", category: "Productivity", color: "#31a8ff" }, - { id: "premiere", name: "Premiere Pro", category: "Productivity", color: "#9999ff" }, - { id: "blender", name: "Blender", category: "Productivity", color: "#f5792a" }, - { id: "vscode-insiders", name: "VS Code Insiders", category: "Productivity", color: "#24bfa5" }, - { id: "cursor", name: "Cursor", category: "Productivity", color: "#00d4ff" }, - { id: "whatsapp", name: "WhatsApp", category: "Social", color: "#25d366" }, - { id: "telegram", name: "Telegram", category: "Social", color: "#0088cc" }, - { id: "youtube", name: "YouTube", category: "Entertainment", color: "#ff0000" }, - { id: "netflix", name: "Netflix", category: "Entertainment", color: "#e50914" }, - { id: "github", name: "GitHub Desktop", category: "Productivity", color: "#6e5494" }, - { id: "postman", name: "Postman", category: "Productivity", color: "#ff6c37" }, - { id: "rider", name: "JetBrains Rider", category: "Productivity", color: "#c90f5e" }, - { id: "intellij", name: "IntelliJ IDEA", category: "Productivity", color: "#fe315d" }, - { id: "webstorm", name: "WebStorm", category: "Productivity", color: "#07c3f2" } +var appCatalogGroups = [ + { + category: "Productivity", + apps: [ + { id: "code", name: "VS Code", color: "#35a7ff" }, + { id: "vscode-insiders", name: "VS Code Insiders", color: "#24bfa5" }, + { id: "visualstudio", name: "Visual Studio", color: "#7c3aed" }, + { id: "cursor", name: "Cursor", color: "#00d4ff" }, + { id: "pycharm", name: "PyCharm", color: "#22c55e" }, + { id: "notion", name: "Notion", color: "#1f1f1f" }, + { id: "notepad", name: "Notepad", color: "#a0c4ff" }, + { id: "notepad3", name: "Notepad3", color: "#64748b" }, + { id: "terminal", name: "Terminal", color: "#4ec9b0" }, + { id: "gitbash", name: "Git Bash", color: "#f97316" }, + { id: "word", name: "Microsoft Word", color: "#2b579a" }, + { id: "excel", name: "Microsoft Excel", color: "#217346" }, + { id: "powerpoint", name: "PowerPoint", color: "#d24726" }, + { id: "autocad", name: "AutoCAD", color: "#ef4444" }, + { id: "figma", name: "Figma", color: "#f24e1e" }, + { id: "photoshop", name: "Photoshop", color: "#31a8ff" }, + { id: "lightroom", name: "Adobe Lightroom", color: "#0ea5e9" }, + { id: "aftereffects", name: "After Effects", color: "#6366f1" }, + { id: "audition", name: "Adobe Audition", color: "#8b5cf6" }, + { id: "premiere", name: "Premiere Pro", color: "#9999ff" }, + { id: "blender", name: "Blender", color: "#f5792a" }, + { id: "obs", name: "OBS Studio", color: "#302e2e" }, + { id: "fontforge", name: "FontForge", color: "#64748b" }, + { id: "github", name: "GitHub Desktop", color: "#6e5494" }, + { id: "postman", name: "Postman", color: "#ff6c37" }, + { id: "rider", name: "JetBrains Rider", color: "#c90f5e" }, + { id: "intellij", name: "IntelliJ IDEA", color: "#fe315d" }, + { id: "webstorm", name: "WebStorm", color: "#07c3f2" }, + { id: "wps", name: "WPS Office", color: "#ef4444" }, + { id: "typora", name: "Typora", color: "#64748b" }, + { id: "xmind", name: "XMind", color: "#f97316" } + ] + }, + { + category: "Browsers", + apps: [ + { id: "msedge", name: "Microsoft Edge", color: "#4f8bff" }, + { id: "chrome", name: "Google Chrome", color: "#f7b955" }, + { id: "firefox", name: "Firefox", color: "#ff6611" }, + { id: "zen", name: "Zen Browser", color: "#8b5cf6" }, + { id: "brave", name: "Brave", color: "#fb542b" }, + { id: "opera", name: "Opera", color: "#ff1b2d" }, + { id: "vivaldi", name: "Vivaldi", color: "#ef3939" }, + { id: "arc", name: "Arc", color: "#5e5ce6" }, + { id: "qqbrowser", name: "QQ Browser", color: "#3b82f6" }, + { id: "se360", name: "360 Secure Browser", color: "#22c55e" }, + { id: "chrome360", name: "360 Extreme Browser", color: "#16a34a" }, + { id: "sogou", name: "Sogou Explorer", color: "#f59e0b" } + ] + }, + { + category: "Communication", + apps: [ + { id: "teams", name: "Microsoft Teams", color: "#5b7cfa" }, + { id: "outlook", name: "Outlook", color: "#2f6fff" }, + { id: "slack", name: "Slack", color: "#e91e63" }, + { id: "zoom", name: "Zoom", color: "#2d8cff" }, + { id: "wechat", name: "WeChat", color: "#22c55e" }, + { id: "qq", name: "QQ", color: "#06b6d4" }, + { id: "dingtalk", name: "DingTalk", color: "#1677ff" }, + { id: "feishu", name: "Feishu", color: "#14b8a6" }, + { id: "wecom", name: "WeCom", color: "#0284c7" }, + { id: "kook", name: "KOOK", color: "#22c55e" }, + { id: "teamspeak", name: "TeamSpeak", color: "#3b82f6" } + ] + }, + { + category: "Social", + apps: [ + { id: "discord", name: "Discord", color: "#8c7dff" }, + { id: "whatsapp", name: "WhatsApp", color: "#25d366" }, + { id: "telegram", name: "Telegram", color: "#0088cc" } + ] + }, + { + category: "Entertainment", + apps: [ + { id: "spotify", name: "Spotify", color: "#2ed47a" }, + { id: "vlc", name: "VLC", color: "#ff8c00" }, + { id: "mpv", name: "mpv", color: "#7c3aed" }, + { id: "youtube", name: "YouTube", color: "#ff0000" }, + { id: "netflix", name: "Netflix", color: "#e50914" }, + { id: "bilibili", name: "Bilibili", color: "#f472b6" }, + { id: "douyin", name: "Douyin", color: "#111827" }, + { id: "iqiyi", name: "iQIYI", color: "#16a34a" }, + { id: "tencentvideo", name: "Tencent Video", color: "#10b981" }, + { id: "youku", name: "Youku", color: "#ef4444" }, + { id: "cloudmusic", name: "NetEase Cloud Music", color: "#dc2626" }, + { id: "qqmusic", name: "QQ Music", color: "#22c55e" } + ] + }, + { + category: "Utilities", + apps: [ + { id: "explorer", name: "File Explorer", color: "#9aa0ff" }, + { id: "localsend", name: "LocalSend", color: "#06b6d4" }, + { id: "todesk", name: "ToDesk", color: "#0ea5e9" }, + { id: "moonlight", name: "Moonlight", color: "#38bdf8" }, + { id: "everything", name: "Everything", color: "#f59e0b" }, + { id: "bandizip", name: "Bandizip", color: "#3b82f6" }, + { id: "idm", name: "Internet Download Manager", color: "#1d4ed8" }, + { id: "fdm", name: "Free Download Manager", color: "#0284c7" }, + { id: "treesize", name: "TreeSize", color: "#16a34a" }, + { id: "wisecare365", name: "Wise Care 365", color: "#2563eb" }, + { id: "watttoolkit", name: "Watt Toolkit", color: "#06b6d4" }, + { id: "steamtools", name: "SteamTools", color: "#334155" }, + { id: "msi-afterburner", name: "MSI Afterburner", color: "#ef4444" }, + { id: "handbrake", name: "HandBrake", color: "#22c55e" }, + { id: "clash-verge", name: "Clash Verge", color: "#a855f7" }, + { id: "xshell", name: "Xshell", color: "#f97316" }, + { id: "xftp", name: "Xftp", color: "#fb923c" }, + { id: "baidunetdisk", name: "Baidu Netdisk", color: "#2563eb" }, + { id: "xunlei", name: "Xunlei", color: "#eab308" } + ] + }, + { + category: "Games", + apps: [ + { id: "steam", name: "Steam", color: "#ff8b6a" }, + { id: "epicgames", name: "Epic Games Launcher", color: "#111827" }, + { id: "ubisoftconnect", name: "Ubisoft Connect", color: "#2563eb" }, + { id: "eadesktop", name: "EA App", color: "#f97316" }, + { id: "rockstar", name: "Rockstar Games Launcher", color: "#facc15" }, + { id: "pcl", name: "PCL Launcher", color: "#0ea5e9" }, + { id: "perfectworld", name: "Perfect World Platform", color: "#64748b" }, + { id: "fivee", name: "5E Platform", color: "#7c3aed" }, + { id: "leigod", name: "Leigod Booster", color: "#ef4444" }, + { id: "uugame", name: "UU Booster", color: "#0284c7" }, + { id: "balatro", name: "Balatro", color: "#f97316" }, + { id: "stardewvalley", name: "Stardew Valley", color: "#84cc16" }, + { id: "osu", name: "osu!", color: "#ec4899" }, + { id: "deltaforce", name: "Delta Force", color: "#10b981" }, + { id: "forzahorizon5", name: "Forza Horizon 5", color: "#f59e0b" }, + { id: "rainbowsix", name: "Tom Clancy's Rainbow Six Siege", color: "#2563eb" }, + { id: "warthunder", name: "War Thunder", color: "#dc2626" }, + { id: "readyornot", name: "Ready or Not", color: "#374151" }, + { id: "houseflipper2", name: "House Flipper 2", color: "#0ea5e9" }, + { id: "eurotruck2", name: "Euro Truck Simulator 2", color: "#475569" }, + { id: "coffeetalk", name: "Coffee Talk", color: "#a16207" } + ] + } ]; +var appCatalog = appCatalogGroups.flatMap( + ({ category, apps }) => apps.map((app4) => ({ ...app4, category })) +); var processPatterns = [ + // Productivity { pattern: /^code$/i, appId: "code" }, { pattern: /^code - insiders$/i, appId: "vscode-insiders" }, + { pattern: /^devenv$/i, appId: "visualstudio" }, { pattern: /^cursor$/i, appId: "cursor" }, - // Browsers - { pattern: /^msedge$/i, appId: "msedge" }, - { pattern: /^chrome$/i, appId: "chrome" }, - { pattern: /^firefox$/i, appId: "firefox" }, - { pattern: /^zen$/i, appId: "zen" }, - { pattern: /^brave$/i, appId: "brave" }, - { pattern: /^opera$/i, appId: "opera" }, - { pattern: /^vivaldi$/i, appId: "vivaldi" }, - { pattern: /^arc$/i, appId: "arc" }, - { pattern: /^discord$/i, appId: "discord" }, - { pattern: /^spotify$/i, appId: "spotify" }, - { pattern: /^steam$/i, appId: "steam" }, - { pattern: /^teams$/i, appId: "teams" }, - { pattern: /^ms-teams$/i, appId: "teams" }, - { pattern: /^outlook$/i, appId: "outlook" }, - { pattern: /^explorer$/i, appId: "explorer" }, + { pattern: /^pycharm64$/i, appId: "pycharm" }, + { pattern: /^pycharm$/i, appId: "pycharm" }, { pattern: /^notepad$/i, appId: "notepad" }, + { pattern: /^notepad3$/i, appId: "notepad3" }, { pattern: /^notepad\+\+$/i, appId: "notepad" }, { pattern: /^windowsterminal$/i, appId: "terminal" }, { pattern: /^wt$/i, appId: "terminal" }, { pattern: /^powershell$/i, appId: "terminal" }, { pattern: /^cmd$/i, appId: "terminal" }, - { pattern: /^slack$/i, appId: "slack" }, - { pattern: /^zoom$/i, appId: "zoom" }, + { pattern: /^git-bash$/i, appId: "gitbash" }, { pattern: /^notion$/i, appId: "notion" }, { pattern: /^winword$/i, appId: "word" }, { pattern: /^excel$/i, appId: "excel" }, { pattern: /^powerpnt$/i, appId: "powerpoint" }, - { pattern: /^vlc$/i, appId: "vlc" }, + { pattern: /^acad$/i, appId: "autocad" }, { pattern: /^obs64$/i, appId: "obs" }, { pattern: /^obs$/i, appId: "obs" }, { pattern: /^figma$/i, appId: "figma" }, { pattern: /^photoshop$/i, appId: "photoshop" }, + { pattern: /^lightroomclassic$/i, appId: "lightroom" }, + { pattern: /^afterfx$/i, appId: "aftereffects" }, + { pattern: /^adobe audition/i, appId: "audition" }, + { pattern: /^audition$/i, appId: "audition" }, { pattern: /^premiere/i, appId: "premiere" }, { pattern: /^blender$/i, appId: "blender" }, - { pattern: /^whatsapp\.root$/i, appId: "whatsapp" }, - { pattern: /^whatsapp$/i, appId: "whatsapp" }, - { pattern: /^telegram$/i, appId: "telegram" }, + { pattern: /^fontforge$/i, appId: "fontforge" }, { pattern: /^githubdesktop$/i, appId: "github" }, { pattern: /^postman$/i, appId: "postman" }, { pattern: /^rider64$/i, appId: "rider" }, { pattern: /^idea64$/i, appId: "intellij" }, - { pattern: /^webstorm64$/i, appId: "webstorm" } + { pattern: /^webstorm64$/i, appId: "webstorm" }, + { pattern: /^wps$/i, appId: "wps" }, + { pattern: /^et$/i, appId: "wps" }, + { pattern: /^wpp$/i, appId: "wps" }, + { pattern: /^typora$/i, appId: "typora" }, + { pattern: /^xmind$/i, appId: "xmind" }, + // Browsers + { pattern: /^msedge$/i, appId: "msedge" }, + { pattern: /^chrome$/i, appId: "chrome" }, + { pattern: /^firefox$/i, appId: "firefox" }, + { pattern: /^zen$/i, appId: "zen" }, + { pattern: /^brave$/i, appId: "brave" }, + { pattern: /^opera$/i, appId: "opera" }, + { pattern: /^vivaldi$/i, appId: "vivaldi" }, + { pattern: /^arc$/i, appId: "arc" }, + { pattern: /^qqbrowser$/i, appId: "qqbrowser" }, + { pattern: /^360se$/i, appId: "se360" }, + { pattern: /^360chrome$/i, appId: "chrome360" }, + { pattern: /^sogouexplorer$/i, appId: "sogou" }, + // Communication + { pattern: /^teams$/i, appId: "teams" }, + { pattern: /^ms-teams$/i, appId: "teams" }, + { pattern: /^outlook$/i, appId: "outlook" }, + { pattern: /^slack$/i, appId: "slack" }, + { pattern: /^zoom$/i, appId: "zoom" }, + { pattern: /^wechat$/i, appId: "wechat" }, + { pattern: /^qq$/i, appId: "qq" }, + { pattern: /^dingtalk$/i, appId: "dingtalk" }, + { pattern: /^feishu$/i, appId: "feishu" }, + { pattern: /^lark$/i, appId: "feishu" }, + { pattern: /^wxwork$/i, appId: "wecom" }, + { pattern: /^wecom$/i, appId: "wecom" }, + { pattern: /^kook$/i, appId: "kook" }, + { pattern: /^ts3client$/i, appId: "teamspeak" }, + { pattern: /^teamspeak$/i, appId: "teamspeak" }, + // Social + { pattern: /^discord$/i, appId: "discord" }, + { pattern: /^whatsapp\.root$/i, appId: "whatsapp" }, + { pattern: /^whatsapp$/i, appId: "whatsapp" }, + { pattern: /^telegram$/i, appId: "telegram" }, + // Entertainment + { pattern: /^spotify$/i, appId: "spotify" }, + { pattern: /^vlc$/i, appId: "vlc" }, + { pattern: /^mpv$/i, appId: "mpv" }, + { pattern: /^bilibili$/i, appId: "bilibili" }, + { pattern: /^douyin$/i, appId: "douyin" }, + { pattern: /^iqiyi$/i, appId: "iqiyi" }, + { pattern: /^qqlive$/i, appId: "tencentvideo" }, + { pattern: /^youku$/i, appId: "youku" }, + { pattern: /^cloudmusic$/i, appId: "cloudmusic" }, + { pattern: /^qqmusic$/i, appId: "qqmusic" }, + // Utilities + { pattern: /^explorer$/i, appId: "explorer" }, + { pattern: /^localsend$/i, appId: "localsend" }, + { pattern: /^todesk$/i, appId: "todesk" }, + { pattern: /^moonlight$/i, appId: "moonlight" }, + { pattern: /^everything$/i, appId: "everything" }, + { pattern: /^bandizip$/i, appId: "bandizip" }, + { pattern: /^idman$/i, appId: "idm" }, + { pattern: /^fdm$/i, appId: "fdm" }, + { pattern: /^treesize$/i, appId: "treesize" }, + { pattern: /^wisecare365$/i, appId: "wisecare365" }, + { pattern: /^watttoolkit$/i, appId: "watttoolkit" }, + { pattern: /^steamtools$/i, appId: "steamtools" }, + { pattern: /^msiafterburner$/i, appId: "msi-afterburner" }, + { pattern: /^handbrake$/i, appId: "handbrake" }, + { pattern: /^clash-verge$/i, appId: "clash-verge" }, + { pattern: /^clashverge$/i, appId: "clash-verge" }, + { pattern: /^xshell$/i, appId: "xshell" }, + { pattern: /^xftp$/i, appId: "xftp" }, + { pattern: /^baidunetdisk$/i, appId: "baidunetdisk" }, + { pattern: /^xunlei$/i, appId: "xunlei" }, + // Games + { pattern: /^steam$/i, appId: "steam" }, + { pattern: /^steamwebhelper$/i, appId: "steam" }, + { pattern: /^epicgameslauncher$/i, appId: "epicgames" }, + { pattern: /^ubisoftconnect$/i, appId: "ubisoftconnect" }, + { pattern: /^eadesktop$/i, appId: "eadesktop" }, + { pattern: /^rockstar.*launcher$/i, appId: "rockstar" }, + { pattern: /^pcl2$/i, appId: "pcl" }, + { pattern: /^pcl$/i, appId: "pcl" }, + { pattern: /^perfectworld.*platform$/i, appId: "perfectworld" }, + { pattern: /^5eclient$/i, appId: "fivee" }, + { pattern: /^leigod$/i, appId: "leigod" }, + { pattern: /^uu$/i, appId: "uugame" }, + { pattern: /^uugamebooster$/i, appId: "uugame" }, + { pattern: /^balatro$/i, appId: "balatro" }, + { pattern: /^stardew valley$/i, appId: "stardewvalley" }, + { pattern: /^stardewvalley$/i, appId: "stardewvalley" }, + { pattern: /^osu!?$/i, appId: "osu" }, + { pattern: /^delta.*force/i, appId: "deltaforce" }, + { pattern: /^forzahorizon5$/i, appId: "forzahorizon5" }, + { pattern: /^rainbowsix$/i, appId: "rainbowsix" }, + { pattern: /^rainbow.*six/i, appId: "rainbowsix" }, + { pattern: /^aces$/i, appId: "warthunder" }, + { pattern: /^warthunder$/i, appId: "warthunder" }, + { pattern: /^readyornot/i, appId: "readyornot" }, + { pattern: /^houseflipper2$/i, appId: "houseflipper2" }, + { pattern: /^eurotrucks2$/i, appId: "eurotruck2" }, + { pattern: /^coffeetalk$/i, appId: "coffeetalk" } ]; var unknownApp = { id: "other", @@ -647,6 +842,16 @@ var createUsageTracker = () => { appLookup.set(app4.id, app4); } appLookup.set(unknownApp.id, unknownApp); + const ensureDynamicAppInfo = (appId, nameHint) => { + if (!appId || appLookup.has(appId)) return; + const rawName = nameHint ?? (appId.startsWith("proc:") ? appId.slice(5) : appId); + appLookup.set(appId, { + id: appId, + name: toDisplayName(rawName), + category: "Other", + color: "#6b7280" + }); + }; const totals = loadPersistedData2(); let interval = null; let saveInterval = null; @@ -670,14 +875,7 @@ var createUsageTracker = () => { const mapped = mapProcessToAppId(active.process); if (mapped && appLookup.has(mapped)) return mapped; const dynamicId = `proc:${active.process.toLowerCase()}`; - if (!appLookup.has(dynamicId)) { - appLookup.set(dynamicId, { - id: dynamicId, - name: toDisplayName(active.process), - category: "Other", - color: "#6b7280" - }); - } + ensureDynamicAppInfo(dynamicId, active.process); return dynamicId; }; const resolveAppIdForRunningApps = (processName) => { @@ -689,14 +887,7 @@ var createUsageTracker = () => { const mapped = mapProcessToAppId(processName); if (mapped && appLookup.has(mapped)) return mapped; const dynamicId = `proc:${processName.toLowerCase()}`; - if (!appLookup.has(dynamicId)) { - appLookup.set(dynamicId, { - id: dynamicId, - name: toDisplayName(processName), - category: "Other", - color: "#6b7280" - }); - } + ensureDynamicAppInfo(dynamicId, processName); return dynamicId; }; const refreshRunningApps = async () => { @@ -738,6 +929,7 @@ var createUsageTracker = () => { const date = key.slice(0, firstColonIndex); const appId = key.slice(firstColonIndex + 1); if (!appId) continue; + ensureDynamicAppInfo(appId); usedAppIds.add(appId); entries.push({ date, @@ -936,6 +1128,13 @@ var enUS = { name: "Name", category: "Category" }, + categoryManager: { + title: "Custom App Categories", + subtitle: "Assign categories to non-preset apps and add your own categories", + newCategoryPlaceholder: "Enter a new category...", + addCategory: "Add Category", + empty: "No non-preset apps available yet" + }, empty: { search: "No apps match your search", date: "No apps tracked for {date}" @@ -1084,6 +1283,7 @@ var enUS = { Utilities: "Utilities", Browsers: "Browsers", Entertainment: "Entertainment", + Games: "Games", Social: "Social", System: "System", Other: "Other", @@ -1254,6 +1454,13 @@ var zhCN = { name: "\u540D\u79F0", category: "\u5206\u7C7B" }, + categoryManager: { + title: "\u81EA\u5B9A\u4E49\u5E94\u7528\u5206\u7C7B", + subtitle: "\u4E3A\u975E\u9884\u8BBE\u5E94\u7528\u6307\u5B9A\u5206\u7C7B\u5E76\u6DFB\u52A0\u81EA\u5B9A\u4E49\u5206\u7C7B", + newCategoryPlaceholder: "\u8F93\u5165\u65B0\u5206\u7C7B\u540D\u79F0...", + addCategory: "\u6DFB\u52A0\u5206\u7C7B", + empty: "\u6682\u65E0\u975E\u9884\u8BBE\u5E94\u7528\u53EF\u5206\u7C7B" + }, empty: { search: "\u6CA1\u6709\u5339\u914D\u641C\u7D22\u6761\u4EF6\u7684\u5E94\u7528", date: "{date} \u6682\u65E0\u5E94\u7528\u4F7F\u7528\u8BB0\u5F55" @@ -1402,6 +1609,7 @@ var zhCN = { Utilities: "\u5DE5\u5177", Browsers: "\u6D4F\u89C8\u5668", Entertainment: "\u5A31\u4E50", + Games: "\u6E38\u620F", Social: "\u793E\u4EA4", System: "\u7CFB\u7EDF", Other: "\u5176\u4ED6", @@ -1444,6 +1652,36 @@ var translate = (locale, key, params) => { return interpolate(template, params); }; +// src/utils/categoryColor.ts +var CATEGORY_COLORS = { + Productivity: "#3b82f6", + Education: "#0ea5e9", + Communication: "#06b6d4", + Utilities: "#8b5cf6", + Browsers: "#f59e0b", + Entertainment: "#22c55e", + Games: "#ef4444", + Social: "#ec4899", + System: "#64748b", + Other: "#6b7280", + Unknown: "#6b7280" +}; +var hashString = (value) => { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +}; +var getCategoryColor = (category) => { + const normalized = category.trim(); + const preset = CATEGORY_COLORS[normalized]; + if (preset) return preset; + const hue = hashString(normalized || "Other") % 360; + return `hsl(${hue}, 65%, 55%)`; +}; + // electron/main.ts var isDev = Boolean(process.env.VITE_DEV_SERVER_URL); var usageTracker = createUsageTracker(); @@ -1477,7 +1715,9 @@ var settings = { startWithWindows: false, timeLimits: [], timeLimitNotificationsEnabled: true, - language: defaultLocale + language: defaultLocale, + customCategories: [], + customAppCategories: {} }; var shownAlerts = []; var mt = (key, params) => translate(settings.language, key, params); @@ -1488,6 +1728,47 @@ var getTodayDateString3 = () => { const day = String(now.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; }; +var normalizeCategory = (value) => value.trim(); +var sanitizeCustomCategories = (value) => { + if (!Array.isArray(value)) return []; + const deduped = /* @__PURE__ */ new Set(); + for (const item of value) { + if (typeof item !== "string") continue; + const normalized = normalizeCategory(item); + if (!normalized) continue; + deduped.add(normalized); + } + return Array.from(deduped); +}; +var sanitizeCustomAppCategories = (value) => { + if (!value || typeof value !== "object") return {}; + const sanitized = {}; + for (const [appId, categoryRaw] of Object.entries(value)) { + if (!appId.trim()) continue; + if (typeof categoryRaw !== "string") continue; + const normalizedCategory = normalizeCategory(categoryRaw); + if (!normalizedCategory) continue; + sanitized[appId] = normalizedCategory; + } + return sanitized; +}; +var applyCustomCategories = (snapshot) => { + if (!snapshot.apps.length) return snapshot; + const map = settings.customAppCategories; + if (!map || Object.keys(map).length === 0) return snapshot; + return { + ...snapshot, + apps: snapshot.apps.map((appInfo) => { + const customCategory = map[appInfo.id]; + if (!customCategory) return appInfo; + return { + ...appInfo, + category: customCategory, + color: getCategoryColor(customCategory) + }; + }) + }; +}; var getSettingsPath = () => { const userDataPath = import_electron3.app.getPath("userData"); return import_node_path.default.join(userDataPath, "settings.json"); @@ -1507,7 +1788,9 @@ var loadSettings = () => { startWithWindows: loaded.startWithWindows ?? false, timeLimits: loaded.timeLimits ?? [], timeLimitNotificationsEnabled: loaded.timeLimitNotificationsEnabled ?? true, - language: normalizeLocale(loaded.language) + language: normalizeLocale(loaded.language), + customCategories: sanitizeCustomCategories(loaded.customCategories), + customAppCategories: sanitizeCustomAppCategories(loaded.customAppCategories) }; } } catch { @@ -1867,10 +2150,10 @@ var setAutoLaunch = (enable) => { import_electron3.app.whenReady().then(() => { loadSettings(); loadAlerts(); - import_electron3.ipcMain.handle("usage:snapshot", () => usageTracker.getSnapshot()); + import_electron3.ipcMain.handle("usage:snapshot", () => applyCustomCategories(usageTracker.getSnapshot())); import_electron3.ipcMain.handle("usage:clear", () => { usageTracker.clearData(); - return usageTracker.getSnapshot(); + return applyCustomCategories(usageTracker.getSnapshot()); }); import_electron3.ipcMain.handle("theme:set", (_event, theme) => { applyThemeToWindow(theme); @@ -1897,6 +2180,12 @@ import_electron3.app.whenReady().then(() => { settings.language = normalizeLocale(newSettings.language); updateTrayMenu(); } + if (newSettings.customCategories !== void 0) { + settings.customCategories = sanitizeCustomCategories(newSettings.customCategories); + } + if (newSettings.customAppCategories !== void 0) { + settings.customAppCategories = sanitizeCustomAppCategories(newSettings.customAppCategories); + } saveSettings(); return settings; }); diff --git a/dist-electron/preload.cjs b/dist-electron/preload.cjs index 6f3d621..e2a1081 100644 --- a/dist-electron/preload.cjs +++ b/dist-electron/preload.cjs @@ -79,7 +79,9 @@ var api = { startWithWindows: false, timeLimits: [], timeLimitNotificationsEnabled: true, - language: "zh-CN" + language: "zh-CN", + customCategories: [], + customAppCategories: {} }; } }, @@ -92,7 +94,9 @@ var api = { startWithWindows: false, timeLimits: [], timeLimitNotificationsEnabled: true, - language: "zh-CN" + language: "zh-CN", + customCategories: [], + customAppCategories: {} }; } }, diff --git a/electron/main.ts b/electron/main.ts index cfb9825..d070de3 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,6 +4,7 @@ import * as fs from 'node:fs' import { createNotificationTracker } from './notifications' import { createUsageTracker } from './telemetry' import { defaultLocale, normalizeLocale, translate, type LocaleCode } from '../src/i18n/core' +import { getCategoryColor } from '../src/utils/categoryColor' const isDev = Boolean(process.env.VITE_DEV_SERVER_URL) @@ -56,6 +57,8 @@ interface AppSettings { timeLimits: AppTimeLimit[] timeLimitNotificationsEnabled: boolean language: LocaleCode + customCategories: string[] + customAppCategories: Record } // Track which alerts have been shown today to avoid spam @@ -71,6 +74,8 @@ let settings: AppSettings = { timeLimits: [], timeLimitNotificationsEnabled: true, language: defaultLocale, + customCategories: [], + customAppCategories: {}, } let shownAlerts: TimeLimitAlert[] = [] @@ -87,6 +92,53 @@ const getTodayDateString = (): string => { return `${year}-${month}-${day}` } +type UsageSnapshot = ReturnType + +const normalizeCategory = (value: string) => value.trim() + +const sanitizeCustomCategories = (value: unknown): string[] => { + if (!Array.isArray(value)) return [] + const deduped = new Set() + for (const item of value) { + if (typeof item !== 'string') continue + const normalized = normalizeCategory(item) + if (!normalized) continue + deduped.add(normalized) + } + return Array.from(deduped) +} + +const sanitizeCustomAppCategories = (value: unknown): Record => { + if (!value || typeof value !== 'object') return {} + const sanitized: Record = {} + for (const [appId, categoryRaw] of Object.entries(value)) { + if (!appId.trim()) continue + if (typeof categoryRaw !== 'string') continue + const normalizedCategory = normalizeCategory(categoryRaw) + if (!normalizedCategory) continue + sanitized[appId] = normalizedCategory + } + return sanitized +} + +const applyCustomCategories = (snapshot: UsageSnapshot): UsageSnapshot => { + if (!snapshot.apps.length) return snapshot + const map = settings.customAppCategories + if (!map || Object.keys(map).length === 0) return snapshot + return { + ...snapshot, + apps: snapshot.apps.map((appInfo) => { + const customCategory = map[appInfo.id] + if (!customCategory) return appInfo + return { + ...appInfo, + category: customCategory, + color: getCategoryColor(customCategory), + } + }), + } +} + const getSettingsPath = () => { const userDataPath = app.getPath('userData') return path.join(userDataPath, 'settings.json') @@ -109,6 +161,8 @@ const loadSettings = () => { timeLimits: loaded.timeLimits ?? [], timeLimitNotificationsEnabled: loaded.timeLimitNotificationsEnabled ?? true, language: normalizeLocale(loaded.language), + customCategories: sanitizeCustomCategories(loaded.customCategories), + customAppCategories: sanitizeCustomAppCategories(loaded.customAppCategories), } } } catch { @@ -548,10 +602,10 @@ app.whenReady().then(() => { loadAlerts() // IPC Handlers - ipcMain.handle('usage:snapshot', () => usageTracker.getSnapshot()) + ipcMain.handle('usage:snapshot', () => applyCustomCategories(usageTracker.getSnapshot())) ipcMain.handle('usage:clear', () => { usageTracker.clearData() - return usageTracker.getSnapshot() + return applyCustomCategories(usageTracker.getSnapshot()) }) ipcMain.handle('theme:set', (_event, theme: ThemeName) => { applyThemeToWindow(theme) @@ -580,6 +634,12 @@ app.whenReady().then(() => { settings.language = normalizeLocale(newSettings.language) updateTrayMenu() } + if (newSettings.customCategories !== undefined) { + settings.customCategories = sanitizeCustomCategories(newSettings.customCategories) + } + if (newSettings.customAppCategories !== undefined) { + settings.customAppCategories = sanitizeCustomAppCategories(newSettings.customAppCategories) + } saveSettings() return settings }) diff --git a/electron/preload.ts b/electron/preload.ts index 6ac4037..17df2e8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -15,6 +15,8 @@ interface AppSettings { timeLimits: AppTimeLimit[] timeLimitNotificationsEnabled: boolean language: 'zh-CN' | 'en-US' + customCategories: string[] + customAppCategories: Record } const api = { @@ -81,6 +83,8 @@ const api = { timeLimits: [], timeLimitNotificationsEnabled: true, language: 'zh-CN', + customCategories: [], + customAppCategories: {}, } } }, @@ -94,6 +98,8 @@ const api = { timeLimits: [], timeLimitNotificationsEnabled: true, language: 'zh-CN', + customCategories: [], + customAppCategories: {}, } } }, diff --git a/electron/telemetry.ts b/electron/telemetry.ts index c1be369..6af5647 100644 --- a/electron/telemetry.ts +++ b/electron/telemetry.ts @@ -47,99 +47,306 @@ export interface RunningAppSummary { hasWindow: boolean } -// Extended app catalog with more common Windows applications -const appCatalog: AppInfo[] = [ - { id: 'code', name: 'VS Code', category: 'Productivity', color: '#35a7ff' }, - // Browsers - { id: 'msedge', name: 'Microsoft Edge', category: 'Browsers', color: '#4f8bff' }, - { id: 'chrome', name: 'Google Chrome', category: 'Browsers', color: '#f7b955' }, - { id: 'firefox', name: 'Firefox', category: 'Browsers', color: '#ff6611' }, - { id: 'zen', name: 'Zen Browser', category: 'Browsers', color: '#8b5cf6' }, - { id: 'brave', name: 'Brave', category: 'Browsers', color: '#fb542b' }, - { id: 'opera', name: 'Opera', category: 'Browsers', color: '#ff1b2d' }, - { id: 'vivaldi', name: 'Vivaldi', category: 'Browsers', color: '#ef3939' }, - { id: 'arc', name: 'Arc', category: 'Browsers', color: '#5e5ce6' }, - { id: 'discord', name: 'Discord', category: 'Social', color: '#8c7dff' }, - { id: 'spotify', name: 'Spotify', category: 'Entertainment', color: '#2ed47a' }, - { id: 'steam', name: 'Steam', category: 'Entertainment', color: '#ff8b6a' }, - { id: 'teams', name: 'Microsoft Teams', category: 'Communication', color: '#5b7cfa' }, - { id: 'outlook', name: 'Outlook', category: 'Communication', color: '#2f6fff' }, - { id: 'explorer', name: 'File Explorer', category: 'Utilities', color: '#9aa0ff' }, - { id: 'notepad', name: 'Notepad', category: 'Productivity', color: '#a0c4ff' }, - { id: 'terminal', name: 'Terminal', category: 'Productivity', color: '#4ec9b0' }, - { id: 'slack', name: 'Slack', category: 'Communication', color: '#e91e63' }, - { id: 'zoom', name: 'Zoom', category: 'Communication', color: '#2d8cff' }, - { id: 'notion', name: 'Notion', category: 'Productivity', color: '#1f1f1f' }, - { id: 'word', name: 'Microsoft Word', category: 'Productivity', color: '#2b579a' }, - { id: 'excel', name: 'Microsoft Excel', category: 'Productivity', color: '#217346' }, - { id: 'powerpoint', name: 'PowerPoint', category: 'Productivity', color: '#d24726' }, - { id: 'vlc', name: 'VLC', category: 'Entertainment', color: '#ff8c00' }, - { id: 'obs', name: 'OBS Studio', category: 'Productivity', color: '#302e2e' }, - { id: 'figma', name: 'Figma', category: 'Productivity', color: '#f24e1e' }, - { id: 'photoshop', name: 'Photoshop', category: 'Productivity', color: '#31a8ff' }, - { id: 'premiere', name: 'Premiere Pro', category: 'Productivity', color: '#9999ff' }, - { id: 'blender', name: 'Blender', category: 'Productivity', color: '#f5792a' }, - { id: 'vscode-insiders', name: 'VS Code Insiders', category: 'Productivity', color: '#24bfa5' }, - { id: 'cursor', name: 'Cursor', category: 'Productivity', color: '#00d4ff' }, - { id: 'whatsapp', name: 'WhatsApp', category: 'Social', color: '#25d366' }, - { id: 'telegram', name: 'Telegram', category: 'Social', color: '#0088cc' }, - { id: 'youtube', name: 'YouTube', category: 'Entertainment', color: '#ff0000' }, - { id: 'netflix', name: 'Netflix', category: 'Entertainment', color: '#e50914' }, - { id: 'github', name: 'GitHub Desktop', category: 'Productivity', color: '#6e5494' }, - { id: 'postman', name: 'Postman', category: 'Productivity', color: '#ff6c37' }, - { id: 'rider', name: 'JetBrains Rider', category: 'Productivity', color: '#c90f5e' }, - { id: 'intellij', name: 'IntelliJ IDEA', category: 'Productivity', color: '#fe315d' }, - { id: 'webstorm', name: 'WebStorm', category: 'Productivity', color: '#07c3f2' }, +type CatalogGroup = { + category: AppInfo['category'] + apps: Array> +} + +// Extended app catalog grouped by category +const appCatalogGroups: CatalogGroup[] = [ + { + category: 'Productivity', + apps: [ + { id: 'code', name: 'VS Code', color: '#35a7ff' }, + { id: 'vscode-insiders', name: 'VS Code Insiders', color: '#24bfa5' }, + { id: 'visualstudio', name: 'Visual Studio', color: '#7c3aed' }, + { id: 'cursor', name: 'Cursor', color: '#00d4ff' }, + { id: 'pycharm', name: 'PyCharm', color: '#22c55e' }, + { id: 'notion', name: 'Notion', color: '#1f1f1f' }, + { id: 'notepad', name: 'Notepad', color: '#a0c4ff' }, + { id: 'notepad3', name: 'Notepad3', color: '#64748b' }, + { id: 'terminal', name: 'Terminal', color: '#4ec9b0' }, + { id: 'gitbash', name: 'Git Bash', color: '#f97316' }, + { id: 'word', name: 'Microsoft Word', color: '#2b579a' }, + { id: 'excel', name: 'Microsoft Excel', color: '#217346' }, + { id: 'powerpoint', name: 'PowerPoint', color: '#d24726' }, + { id: 'autocad', name: 'AutoCAD', color: '#ef4444' }, + { id: 'figma', name: 'Figma', color: '#f24e1e' }, + { id: 'photoshop', name: 'Photoshop', color: '#31a8ff' }, + { id: 'lightroom', name: 'Adobe Lightroom', color: '#0ea5e9' }, + { id: 'aftereffects', name: 'After Effects', color: '#6366f1' }, + { id: 'audition', name: 'Adobe Audition', color: '#8b5cf6' }, + { id: 'premiere', name: 'Premiere Pro', color: '#9999ff' }, + { id: 'blender', name: 'Blender', color: '#f5792a' }, + { id: 'obs', name: 'OBS Studio', color: '#302e2e' }, + { id: 'fontforge', name: 'FontForge', color: '#64748b' }, + { id: 'github', name: 'GitHub Desktop', color: '#6e5494' }, + { id: 'postman', name: 'Postman', color: '#ff6c37' }, + { id: 'rider', name: 'JetBrains Rider', color: '#c90f5e' }, + { id: 'intellij', name: 'IntelliJ IDEA', color: '#fe315d' }, + { id: 'webstorm', name: 'WebStorm', color: '#07c3f2' }, + { id: 'wps', name: 'WPS Office', color: '#ef4444' }, + { id: 'typora', name: 'Typora', color: '#64748b' }, + { id: 'xmind', name: 'XMind', color: '#f97316' }, + ], + }, + { + category: 'Browsers', + apps: [ + { id: 'msedge', name: 'Microsoft Edge', color: '#4f8bff' }, + { id: 'chrome', name: 'Google Chrome', color: '#f7b955' }, + { id: 'firefox', name: 'Firefox', color: '#ff6611' }, + { id: 'zen', name: 'Zen Browser', color: '#8b5cf6' }, + { id: 'brave', name: 'Brave', color: '#fb542b' }, + { id: 'opera', name: 'Opera', color: '#ff1b2d' }, + { id: 'vivaldi', name: 'Vivaldi', color: '#ef3939' }, + { id: 'arc', name: 'Arc', color: '#5e5ce6' }, + { id: 'qqbrowser', name: 'QQ Browser', color: '#3b82f6' }, + { id: 'se360', name: '360 Secure Browser', color: '#22c55e' }, + { id: 'chrome360', name: '360 Extreme Browser', color: '#16a34a' }, + { id: 'sogou', name: 'Sogou Explorer', color: '#f59e0b' }, + ], + }, + { + category: 'Communication', + apps: [ + { id: 'teams', name: 'Microsoft Teams', color: '#5b7cfa' }, + { id: 'outlook', name: 'Outlook', color: '#2f6fff' }, + { id: 'slack', name: 'Slack', color: '#e91e63' }, + { id: 'zoom', name: 'Zoom', color: '#2d8cff' }, + { id: 'wechat', name: 'WeChat', color: '#22c55e' }, + { id: 'qq', name: 'QQ', color: '#06b6d4' }, + { id: 'dingtalk', name: 'DingTalk', color: '#1677ff' }, + { id: 'feishu', name: 'Feishu', color: '#14b8a6' }, + { id: 'wecom', name: 'WeCom', color: '#0284c7' }, + { id: 'kook', name: 'KOOK', color: '#22c55e' }, + { id: 'teamspeak', name: 'TeamSpeak', color: '#3b82f6' }, + ], + }, + { + category: 'Social', + apps: [ + { id: 'discord', name: 'Discord', color: '#8c7dff' }, + { id: 'whatsapp', name: 'WhatsApp', color: '#25d366' }, + { id: 'telegram', name: 'Telegram', color: '#0088cc' }, + ], + }, + { + category: 'Entertainment', + apps: [ + { id: 'spotify', name: 'Spotify', color: '#2ed47a' }, + { id: 'vlc', name: 'VLC', color: '#ff8c00' }, + { id: 'mpv', name: 'mpv', color: '#7c3aed' }, + { id: 'youtube', name: 'YouTube', color: '#ff0000' }, + { id: 'netflix', name: 'Netflix', color: '#e50914' }, + { id: 'bilibili', name: 'Bilibili', color: '#f472b6' }, + { id: 'douyin', name: 'Douyin', color: '#111827' }, + { id: 'iqiyi', name: 'iQIYI', color: '#16a34a' }, + { id: 'tencentvideo', name: 'Tencent Video', color: '#10b981' }, + { id: 'youku', name: 'Youku', color: '#ef4444' }, + { id: 'cloudmusic', name: 'NetEase Cloud Music', color: '#dc2626' }, + { id: 'qqmusic', name: 'QQ Music', color: '#22c55e' }, + ], + }, + { + category: 'Utilities', + apps: [ + { id: 'explorer', name: 'File Explorer', color: '#9aa0ff' }, + { id: 'localsend', name: 'LocalSend', color: '#06b6d4' }, + { id: 'todesk', name: 'ToDesk', color: '#0ea5e9' }, + { id: 'moonlight', name: 'Moonlight', color: '#38bdf8' }, + { id: 'everything', name: 'Everything', color: '#f59e0b' }, + { id: 'bandizip', name: 'Bandizip', color: '#3b82f6' }, + { id: 'idm', name: 'Internet Download Manager', color: '#1d4ed8' }, + { id: 'fdm', name: 'Free Download Manager', color: '#0284c7' }, + { id: 'treesize', name: 'TreeSize', color: '#16a34a' }, + { id: 'wisecare365', name: 'Wise Care 365', color: '#2563eb' }, + { id: 'watttoolkit', name: 'Watt Toolkit', color: '#06b6d4' }, + { id: 'steamtools', name: 'SteamTools', color: '#334155' }, + { id: 'msi-afterburner', name: 'MSI Afterburner', color: '#ef4444' }, + { id: 'handbrake', name: 'HandBrake', color: '#22c55e' }, + { id: 'clash-verge', name: 'Clash Verge', color: '#a855f7' }, + { id: 'xshell', name: 'Xshell', color: '#f97316' }, + { id: 'xftp', name: 'Xftp', color: '#fb923c' }, + { id: 'baidunetdisk', name: 'Baidu Netdisk', color: '#2563eb' }, + { id: 'xunlei', name: 'Xunlei', color: '#eab308' }, + ], + }, + { + category: 'Games', + apps: [ + { id: 'steam', name: 'Steam', color: '#ff8b6a' }, + { id: 'epicgames', name: 'Epic Games Launcher', color: '#111827' }, + { id: 'ubisoftconnect', name: 'Ubisoft Connect', color: '#2563eb' }, + { id: 'eadesktop', name: 'EA App', color: '#f97316' }, + { id: 'rockstar', name: 'Rockstar Games Launcher', color: '#facc15' }, + { id: 'pcl', name: 'PCL Launcher', color: '#0ea5e9' }, + { id: 'perfectworld', name: 'Perfect World Platform', color: '#64748b' }, + { id: 'fivee', name: '5E Platform', color: '#7c3aed' }, + { id: 'leigod', name: 'Leigod Booster', color: '#ef4444' }, + { id: 'uugame', name: 'UU Booster', color: '#0284c7' }, + { id: 'balatro', name: 'Balatro', color: '#f97316' }, + { id: 'stardewvalley', name: 'Stardew Valley', color: '#84cc16' }, + { id: 'osu', name: 'osu!', color: '#ec4899' }, + { id: 'deltaforce', name: 'Delta Force', color: '#10b981' }, + { id: 'forzahorizon5', name: 'Forza Horizon 5', color: '#f59e0b' }, + { id: 'rainbowsix', name: "Tom Clancy's Rainbow Six Siege", color: '#2563eb' }, + { id: 'warthunder', name: 'War Thunder', color: '#dc2626' }, + { id: 'readyornot', name: 'Ready or Not', color: '#374151' }, + { id: 'houseflipper2', name: 'House Flipper 2', color: '#0ea5e9' }, + { id: 'eurotruck2', name: 'Euro Truck Simulator 2', color: '#475569' }, + { id: 'coffeetalk', name: 'Coffee Talk', color: '#a16207' }, + ], + }, ] +const appCatalog: AppInfo[] = appCatalogGroups.flatMap(({ category, apps }) => + apps.map((app) => ({ ...app, category })) +) + // Map process names to app IDs (case-insensitive matching) const processPatterns: Array<{ pattern: RegExp; appId: string }> = [ + // Productivity { pattern: /^code$/i, appId: 'code' }, { pattern: /^code - insiders$/i, appId: 'vscode-insiders' }, + { pattern: /^devenv$/i, appId: 'visualstudio' }, { pattern: /^cursor$/i, appId: 'cursor' }, - // Browsers - { pattern: /^msedge$/i, appId: 'msedge' }, - { pattern: /^chrome$/i, appId: 'chrome' }, - { pattern: /^firefox$/i, appId: 'firefox' }, - { pattern: /^zen$/i, appId: 'zen' }, - { pattern: /^brave$/i, appId: 'brave' }, - { pattern: /^opera$/i, appId: 'opera' }, - { pattern: /^vivaldi$/i, appId: 'vivaldi' }, - { pattern: /^arc$/i, appId: 'arc' }, - { pattern: /^discord$/i, appId: 'discord' }, - { pattern: /^spotify$/i, appId: 'spotify' }, - { pattern: /^steam$/i, appId: 'steam' }, - { pattern: /^teams$/i, appId: 'teams' }, - { pattern: /^ms-teams$/i, appId: 'teams' }, - { pattern: /^outlook$/i, appId: 'outlook' }, - { pattern: /^explorer$/i, appId: 'explorer' }, + { pattern: /^pycharm64$/i, appId: 'pycharm' }, + { pattern: /^pycharm$/i, appId: 'pycharm' }, { pattern: /^notepad$/i, appId: 'notepad' }, + { pattern: /^notepad3$/i, appId: 'notepad3' }, { pattern: /^notepad\+\+$/i, appId: 'notepad' }, { pattern: /^windowsterminal$/i, appId: 'terminal' }, { pattern: /^wt$/i, appId: 'terminal' }, { pattern: /^powershell$/i, appId: 'terminal' }, { pattern: /^cmd$/i, appId: 'terminal' }, - { pattern: /^slack$/i, appId: 'slack' }, - { pattern: /^zoom$/i, appId: 'zoom' }, + { pattern: /^git-bash$/i, appId: 'gitbash' }, { pattern: /^notion$/i, appId: 'notion' }, { pattern: /^winword$/i, appId: 'word' }, { pattern: /^excel$/i, appId: 'excel' }, { pattern: /^powerpnt$/i, appId: 'powerpoint' }, - { pattern: /^vlc$/i, appId: 'vlc' }, + { pattern: /^acad$/i, appId: 'autocad' }, { pattern: /^obs64$/i, appId: 'obs' }, { pattern: /^obs$/i, appId: 'obs' }, { pattern: /^figma$/i, appId: 'figma' }, { pattern: /^photoshop$/i, appId: 'photoshop' }, + { pattern: /^lightroomclassic$/i, appId: 'lightroom' }, + { pattern: /^afterfx$/i, appId: 'aftereffects' }, + { pattern: /^adobe audition/i, appId: 'audition' }, + { pattern: /^audition$/i, appId: 'audition' }, { pattern: /^premiere/i, appId: 'premiere' }, { pattern: /^blender$/i, appId: 'blender' }, - { pattern: /^whatsapp\.root$/i, appId: 'whatsapp' }, - { pattern: /^whatsapp$/i, appId: 'whatsapp' }, - { pattern: /^telegram$/i, appId: 'telegram' }, + { pattern: /^fontforge$/i, appId: 'fontforge' }, { pattern: /^githubdesktop$/i, appId: 'github' }, { pattern: /^postman$/i, appId: 'postman' }, { pattern: /^rider64$/i, appId: 'rider' }, { pattern: /^idea64$/i, appId: 'intellij' }, { pattern: /^webstorm64$/i, appId: 'webstorm' }, + { pattern: /^wps$/i, appId: 'wps' }, + { pattern: /^et$/i, appId: 'wps' }, + { pattern: /^wpp$/i, appId: 'wps' }, + { pattern: /^typora$/i, appId: 'typora' }, + { pattern: /^xmind$/i, appId: 'xmind' }, + + // Browsers + { pattern: /^msedge$/i, appId: 'msedge' }, + { pattern: /^chrome$/i, appId: 'chrome' }, + { pattern: /^firefox$/i, appId: 'firefox' }, + { pattern: /^zen$/i, appId: 'zen' }, + { pattern: /^brave$/i, appId: 'brave' }, + { pattern: /^opera$/i, appId: 'opera' }, + { pattern: /^vivaldi$/i, appId: 'vivaldi' }, + { pattern: /^arc$/i, appId: 'arc' }, + { pattern: /^qqbrowser$/i, appId: 'qqbrowser' }, + { pattern: /^360se$/i, appId: 'se360' }, + { pattern: /^360chrome$/i, appId: 'chrome360' }, + { pattern: /^sogouexplorer$/i, appId: 'sogou' }, + + // Communication + { pattern: /^teams$/i, appId: 'teams' }, + { pattern: /^ms-teams$/i, appId: 'teams' }, + { pattern: /^outlook$/i, appId: 'outlook' }, + { pattern: /^slack$/i, appId: 'slack' }, + { pattern: /^zoom$/i, appId: 'zoom' }, + { pattern: /^wechat$/i, appId: 'wechat' }, + { pattern: /^qq$/i, appId: 'qq' }, + { pattern: /^dingtalk$/i, appId: 'dingtalk' }, + { pattern: /^feishu$/i, appId: 'feishu' }, + { pattern: /^lark$/i, appId: 'feishu' }, + { pattern: /^wxwork$/i, appId: 'wecom' }, + { pattern: /^wecom$/i, appId: 'wecom' }, + { pattern: /^kook$/i, appId: 'kook' }, + { pattern: /^ts3client$/i, appId: 'teamspeak' }, + { pattern: /^teamspeak$/i, appId: 'teamspeak' }, + + // Social + { pattern: /^discord$/i, appId: 'discord' }, + { pattern: /^whatsapp\.root$/i, appId: 'whatsapp' }, + { pattern: /^whatsapp$/i, appId: 'whatsapp' }, + { pattern: /^telegram$/i, appId: 'telegram' }, + + // Entertainment + { pattern: /^spotify$/i, appId: 'spotify' }, + { pattern: /^vlc$/i, appId: 'vlc' }, + { pattern: /^mpv$/i, appId: 'mpv' }, + { pattern: /^bilibili$/i, appId: 'bilibili' }, + { pattern: /^douyin$/i, appId: 'douyin' }, + { pattern: /^iqiyi$/i, appId: 'iqiyi' }, + { pattern: /^qqlive$/i, appId: 'tencentvideo' }, + { pattern: /^youku$/i, appId: 'youku' }, + { pattern: /^cloudmusic$/i, appId: 'cloudmusic' }, + { pattern: /^qqmusic$/i, appId: 'qqmusic' }, + + // Utilities + { pattern: /^explorer$/i, appId: 'explorer' }, + { pattern: /^localsend$/i, appId: 'localsend' }, + { pattern: /^todesk$/i, appId: 'todesk' }, + { pattern: /^moonlight$/i, appId: 'moonlight' }, + { pattern: /^everything$/i, appId: 'everything' }, + { pattern: /^bandizip$/i, appId: 'bandizip' }, + { pattern: /^idman$/i, appId: 'idm' }, + { pattern: /^fdm$/i, appId: 'fdm' }, + { pattern: /^treesize$/i, appId: 'treesize' }, + { pattern: /^wisecare365$/i, appId: 'wisecare365' }, + { pattern: /^watttoolkit$/i, appId: 'watttoolkit' }, + { pattern: /^steamtools$/i, appId: 'steamtools' }, + { pattern: /^msiafterburner$/i, appId: 'msi-afterburner' }, + { pattern: /^handbrake$/i, appId: 'handbrake' }, + { pattern: /^clash-verge$/i, appId: 'clash-verge' }, + { pattern: /^clashverge$/i, appId: 'clash-verge' }, + { pattern: /^xshell$/i, appId: 'xshell' }, + { pattern: /^xftp$/i, appId: 'xftp' }, + { pattern: /^baidunetdisk$/i, appId: 'baidunetdisk' }, + { pattern: /^xunlei$/i, appId: 'xunlei' }, + + // Games + { pattern: /^steam$/i, appId: 'steam' }, + { pattern: /^steamwebhelper$/i, appId: 'steam' }, + { pattern: /^epicgameslauncher$/i, appId: 'epicgames' }, + { pattern: /^ubisoftconnect$/i, appId: 'ubisoftconnect' }, + { pattern: /^eadesktop$/i, appId: 'eadesktop' }, + { pattern: /^rockstar.*launcher$/i, appId: 'rockstar' }, + { pattern: /^pcl2$/i, appId: 'pcl' }, + { pattern: /^pcl$/i, appId: 'pcl' }, + { pattern: /^perfectworld.*platform$/i, appId: 'perfectworld' }, + { pattern: /^5eclient$/i, appId: 'fivee' }, + { pattern: /^leigod$/i, appId: 'leigod' }, + { pattern: /^uu$/i, appId: 'uugame' }, + { pattern: /^uugamebooster$/i, appId: 'uugame' }, + { pattern: /^balatro$/i, appId: 'balatro' }, + { pattern: /^stardew valley$/i, appId: 'stardewvalley' }, + { pattern: /^stardewvalley$/i, appId: 'stardewvalley' }, + { pattern: /^osu!?$/i, appId: 'osu' }, + { pattern: /^delta.*force/i, appId: 'deltaforce' }, + { pattern: /^forzahorizon5$/i, appId: 'forzahorizon5' }, + { pattern: /^rainbowsix$/i, appId: 'rainbowsix' }, + { pattern: /^rainbow.*six/i, appId: 'rainbowsix' }, + { pattern: /^aces$/i, appId: 'warthunder' }, + { pattern: /^warthunder$/i, appId: 'warthunder' }, + { pattern: /^readyornot/i, appId: 'readyornot' }, + { pattern: /^houseflipper2$/i, appId: 'houseflipper2' }, + { pattern: /^eurotrucks2$/i, appId: 'eurotruck2' }, + { pattern: /^coffeetalk$/i, appId: 'coffeetalk' }, ] const unknownApp: AppInfo = { @@ -346,6 +553,17 @@ export const createUsageTracker = (): UsageTracker => { } appLookup.set(unknownApp.id, unknownApp) + const ensureDynamicAppInfo = (appId: string, nameHint?: string) => { + if (!appId || appLookup.has(appId)) return + const rawName = nameHint ?? (appId.startsWith('proc:') ? appId.slice(5) : appId) + appLookup.set(appId, { + id: appId, + name: toDisplayName(rawName), + category: 'Other', + color: '#6b7280', + }) + } + // Load persisted data const totals = loadPersistedData() @@ -377,14 +595,7 @@ export const createUsageTracker = (): UsageTracker => { const mapped = mapProcessToAppId(active.process) if (mapped && appLookup.has(mapped)) return mapped const dynamicId = `proc:${active.process.toLowerCase()}` - if (!appLookup.has(dynamicId)) { - appLookup.set(dynamicId, { - id: dynamicId, - name: toDisplayName(active.process), - category: 'Other', - color: '#6b7280', - }) - } + ensureDynamicAppInfo(dynamicId, active.process) return dynamicId } @@ -400,14 +611,7 @@ export const createUsageTracker = (): UsageTracker => { const mapped = mapProcessToAppId(processName) if (mapped && appLookup.has(mapped)) return mapped const dynamicId = `proc:${processName.toLowerCase()}` - if (!appLookup.has(dynamicId)) { - appLookup.set(dynamicId, { - id: dynamicId, - name: toDisplayName(processName), - category: 'Other', - color: '#6b7280', - }) - } + ensureDynamicAppInfo(dynamicId, processName) return dynamicId } @@ -468,6 +672,7 @@ export const createUsageTracker = (): UsageTracker => { const date = key.slice(0, firstColonIndex) const appId = key.slice(firstColonIndex + 1) if (!appId) continue + ensureDynamicAppInfo(appId) usedAppIds.add(appId) entries.push({ date, diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index b32a97a..9f133ed 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -162,6 +162,13 @@ export const enUS = { name: 'Name', category: 'Category', }, + categoryManager: { + title: 'Custom App Categories', + subtitle: 'Assign categories to non-preset apps and add your own categories', + newCategoryPlaceholder: 'Enter a new category...', + addCategory: 'Add Category', + empty: 'No non-preset apps available yet', + }, empty: { search: 'No apps match your search', date: 'No apps tracked for {date}', @@ -310,6 +317,7 @@ export const enUS = { Utilities: 'Utilities', Browsers: 'Browsers', Entertainment: 'Entertainment', + Games: 'Games', Social: 'Social', System: 'System', Other: 'Other', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 60eb845..4bddc85 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -162,6 +162,13 @@ export const zhCN = { name: '名称', category: '分类', }, + categoryManager: { + title: '自定义应用分类', + subtitle: '为非预设应用指定分类并添加自定义分类', + newCategoryPlaceholder: '输入新分类名称...', + addCategory: '添加分类', + empty: '暂无非预设应用可分类', + }, empty: { search: '没有匹配搜索条件的应用', date: '{date} 暂无应用使用记录', @@ -310,6 +317,7 @@ export const zhCN = { Utilities: '工具', Browsers: '浏览器', Entertainment: '娱乐', + Games: '游戏', Social: '社交', System: '系统', Other: '其他', diff --git a/src/pages/Apps.css b/src/pages/Apps.css index e2dc66d..7854d1c 100644 --- a/src/pages/Apps.css +++ b/src/pages/Apps.css @@ -56,6 +56,45 @@ flex-wrap: wrap; } +.apps-category-add { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.apps-category-add__input { + min-width: 180px; + max-width: 220px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--card-border); + background: var(--panel-bg); + color: var(--text-primary); + font-size: 13px; +} + +.apps-category-add__input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--ring); +} + +.apps-category-add__button { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--accent); + background: var(--accent); + color: #ffffff; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.apps-category-add__button:hover { + background: var(--accent-hover); +} + .running-now { padding: 16px; border-radius: 12px; @@ -427,6 +466,33 @@ transition: width 0.3s ease; } +.app-card__category-editor { + display: flex; + align-items: center; + gap: 8px; +} + +.app-card__category-label { + font-size: 12px; + color: var(--text-muted); +} + +.app-card__category-select { + flex: 1; + min-width: 120px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--card-border); + background: var(--panel-bg); + color: var(--text-primary); + font-size: 12px; +} + +.app-card__category-select:focus { + outline: none; + border-color: var(--accent); +} + /* Time Limit Section in App Card */ .app-card__limit { padding-top: 12px; diff --git a/src/pages/Apps.tsx b/src/pages/Apps.tsx index 8273c77..e2221a0 100644 --- a/src/pages/Apps.tsx +++ b/src/pages/Apps.tsx @@ -9,7 +9,8 @@ import { getTodayDateString, getCategoryTotals, } from '../utils/analytics' -import { fetchTimeLimits, addTimeLimit, removeTimeLimit } from '../services/usageService' +import { getCategoryColor } from '../utils/categoryColor' +import { fetchTimeLimits, addTimeLimit, removeTimeLimit, fetchSettings, updateSettings } from '../services/usageService' import DatePicker from '../components/DatePicker' import './Apps.css' @@ -27,10 +28,17 @@ const Apps = ({ snapshot }: AppsProps) => { const [editingLimit, setEditingLimit] = useState(null) const [limitInputValue, setLimitInputValue] = useState('') const [selectedDate, setSelectedDate] = useState(getTodayDateString()) + const [customCategories, setCustomCategories] = useState([]) + const [customAppCategories, setCustomAppCategories] = useState>({}) + const [newCategoryInput, setNewCategoryInput] = useState('') // Load time limits on mount useEffect(() => { - fetchTimeLimits().then(setTimeLimits) + Promise.all([fetchTimeLimits(), fetchSettings()]).then(([limits, settings]) => { + setTimeLimits(limits) + setCustomCategories(settings.customCategories ?? []) + setCustomAppCategories(settings.customAppCategories ?? {}) + }) }, []) // Available dates for selection @@ -52,9 +60,27 @@ const Apps = ({ snapshot }: AppsProps) => { return availableDates[0] || getTodayDateString() }, [availableDates, selectedDate]) + const effectiveApps = useMemo(() => { + const apps = snapshot?.apps ?? [] + return apps.map((app) => { + const customCategory = customAppCategories[app.id] + if (!customCategory) return app + return { + ...app, + category: customCategory, + color: getCategoryColor(customCategory), + } + }) + }, [snapshot, customAppCategories]) + + const originalCategoryByAppId = useMemo( + () => new Map((snapshot?.apps ?? []).map((app) => [app.id, app.category])), + [snapshot] + ) + const runningNow = useMemo(() => { const items = snapshot?.runningApps ?? [] - const appLookup = new Map(snapshot?.apps.map((a) => [a.id, a]) ?? []) + const appLookup = new Map(effectiveApps.map((a) => [a.id, a])) return [...items] .map((p) => ({ ...p, @@ -62,7 +88,7 @@ const Apps = ({ snapshot }: AppsProps) => { })) .sort((a, b) => (b.hasWindow ? 1 : 0) - (a.hasWindow ? 1 : 0) || b.count - a.count) .slice(0, 30) - }, [snapshot]) + }, [snapshot, effectiveApps]) const openApps = useMemo(() => runningNow.filter((p) => p.hasWindow), [runningNow]) const backgroundApps = useMemo(() => runningNow.filter((p) => !p.hasWindow), [runningNow]) @@ -74,8 +100,8 @@ const Apps = ({ snapshot }: AppsProps) => { // Filter entries for selected date const dateEntries = getEntriesForDate(snapshot.usageEntries, activeSelectedDate) - const appTotals = getAppTotals(dateEntries, snapshot.apps) - const catTotals = getCategoryTotals(dateEntries, snapshot.apps) + const appTotals = getAppTotals(dateEntries, effectiveApps) + const catTotals = getCategoryTotals(dateEntries, effectiveApps) const totalSec = appTotals.reduce((s, a) => s + a.seconds, 0) // Calculate today's usage for time limit progress (always today) @@ -110,7 +136,66 @@ const Apps = ({ snapshot }: AppsProps) => { categoryTotals: catTotals, todayUsageByApp: todayUsage, } - }, [activeSelectedDate, snapshot, sortBy, search, translateCategory]) + }, [activeSelectedDate, snapshot, effectiveApps, sortBy, search, translateCategory]) + + const categoryOptions = useMemo(() => { + const defaults = [ + 'Productivity', + 'Education', + 'Communication', + 'Utilities', + 'Browsers', + 'Entertainment', + 'Games', + 'Social', + 'System', + 'Other', + ] + const categorySet = new Set(defaults) + for (const app of effectiveApps) { + categorySet.add(app.category) + } + for (const category of customCategories) { + categorySet.add(category) + } + return Array.from(categorySet) + }, [effectiveApps, customCategories]) + + const handleAddCustomCategory = async () => { + const nextCategory = newCategoryInput.trim() + if (!nextCategory) return + + const exists = customCategories.some((category) => category.toLowerCase() === nextCategory.toLowerCase()) + if (exists) { + setNewCategoryInput('') + return + } + + const updated = await updateSettings({ + customCategories: [...customCategories, nextCategory], + }) + setCustomCategories(updated.customCategories ?? []) + setCustomAppCategories(updated.customAppCategories ?? {}) + setNewCategoryInput('') + } + + const handleAppCategoryChange = async (appId: string, nextCategory: string) => { + const selected = nextCategory.trim() + const originalCategory = originalCategoryByAppId.get(appId) ?? 'Other' + const nextMappings = { ...customAppCategories } + + if (!selected || selected === originalCategory) { + delete nextMappings[appId] + } else { + nextMappings[appId] = selected + } + + const updated = await updateSettings({ + customAppCategories: nextMappings, + }) + setCustomCategories(updated.customCategories ?? []) + setCustomAppCategories(updated.customAppCategories ?? {}) + } const handleSetLimit = async (appId: string) => { const minutes = parseInt(limitInputValue, 10) @@ -242,7 +327,7 @@ const Apps = ({ snapshot }: AppsProps) => {
{timeLimits.map((limit) => { - const app = snapshot?.apps.find((a) => a.id === limit.appId) + const app = effectiveApps.find((a) => a.id === limit.appId) const usedMinutes = todayUsageByApp.get(limit.appId) ?? 0 const percentUsed = Math.min(100, Math.round((usedMinutes / limit.limitMinutes) * 100)) const isExceeded = usedMinutes >= limit.limitMinutes @@ -285,6 +370,23 @@ const Apps = ({ snapshot }: AppsProps) => { value={search} onChange={(e) => setSearch(e.target.value)} /> +
+ setNewCategoryInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + void handleAddCustomCategory() + } + }} + /> + +
{t('apps.controls.sortBy')} -
{t('apps.controls.sortBy')}