diff --git a/docs/images/pr-716/cumulative-dark.png b/docs/images/pr-716/cumulative-dark.png new file mode 100644 index 00000000..50c14b89 Binary files /dev/null and b/docs/images/pr-716/cumulative-dark.png differ diff --git a/docs/images/pr-716/heatmap-dark.png b/docs/images/pr-716/heatmap-dark.png new file mode 100644 index 00000000..04c1ceb8 Binary files /dev/null and b/docs/images/pr-716/heatmap-dark.png differ diff --git a/docs/images/pr-716/heatmap-light.png b/docs/images/pr-716/heatmap-light.png new file mode 100644 index 00000000..6c22de23 Binary files /dev/null and b/docs/images/pr-716/heatmap-light.png differ diff --git a/docs/images/pr-716/hover-dark.png b/docs/images/pr-716/hover-dark.png new file mode 100644 index 00000000..424a277b Binary files /dev/null and b/docs/images/pr-716/hover-dark.png differ diff --git a/docs/images/pr-716/settings-toggle.png b/docs/images/pr-716/settings-toggle.png new file mode 100644 index 00000000..f03fcd28 Binary files /dev/null and b/docs/images/pr-716/settings-toggle.png differ diff --git a/docs/images/pr-716/weekly-dark.png b/docs/images/pr-716/weekly-dark.png new file mode 100644 index 00000000..ae19a181 Binary files /dev/null and b/docs/images/pr-716/weekly-dark.png differ diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 3348ee0e..2d98d0b6 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -729,6 +729,9 @@ pub struct UserPreferences { /// UI theme: follow OS, force light, or force dark. Frontend applies via data-ol-theme. #[serde(default)] pub theme_mode: ThemeMode, + /// Whether the Overview page shows the annual activity heatmap. Default true. + #[serde(default = "default_true")] + pub show_overview_activity_heatmap: bool, /// 流式输入:润色 SSE 一边到达一边逐字模拟键盘事件输出到当前焦点。开启后用户感知到 /// 的处理时延显著降低(润色 LLM 第一个 token 即开始落字)。 /// @@ -953,6 +956,8 @@ struct UserPreferencesWire { #[serde(default)] theme_mode: ThemeMode, #[serde(default = "default_true")] + show_overview_activity_heatmap: bool, + #[serde(default = "default_true")] streaming_insert: bool, #[serde(default)] streaming_insert_default_migrated: bool, @@ -1045,6 +1050,7 @@ impl Default for UserPreferencesWire { polish_context_window_minutes: prefs.polish_context_window_minutes, start_minimized: prefs.start_minimized, theme_mode: prefs.theme_mode, + show_overview_activity_heatmap: prefs.show_overview_activity_heatmap, streaming_insert: prefs.streaming_insert, streaming_insert_default_migrated: prefs.streaming_insert_default_migrated, streaming_insert_save_clipboard: prefs.streaming_insert_save_clipboard, @@ -1152,6 +1158,7 @@ impl<'de> Deserialize<'de> for UserPreferences { polish_context_window_minutes: wire.polish_context_window_minutes, start_minimized: wire.start_minimized, theme_mode: wire.theme_mode, + show_overview_activity_heatmap: wire.show_overview_activity_heatmap, streaming_insert, streaming_insert_default_migrated: true, streaming_insert_save_clipboard: wire.streaming_insert_save_clipboard, @@ -1887,6 +1894,7 @@ impl Default for UserPreferences { polish_context_window_minutes: default_polish_context_window_minutes(), start_minimized: false, theme_mode: ThemeMode::default(), + show_overview_activity_heatmap: true, streaming_insert: true, streaming_insert_default_migrated: true, streaming_insert_save_clipboard: true, diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index fcf71ca3..8abe2bd6 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -277,9 +277,25 @@ export const en: typeof zhCN = { metricNoData: 'No data', historyLoadError: 'History load failed', metricTotal: 'Total records', - metricTotalTrend: 'Local archive (max 200)', + metricTotalTrend: 'Local archive', weekTitle: 'Last 7 days', weekUnit: 'count / day', + activityTitle: 'Annual activity', + activityModeDaily: 'Daily', + activityMode: { + daily: 'Daily', + weekly: 'Weekly', + cumulative: 'Total', + }, + activityUnit: 'Past year · {{days}} active days', + activityEmpty: 'No history in the past year.', + activityTooltip: '{{date}} · {{count}} records · {{chars}} chars', + activitySummaryDaily: '{{date}} used {{count}} records / {{chars}} chars', + activitySummaryWeekly: 'Week of {{start}} used {{count}} records / {{chars}} chars', + activitySummaryCumulative: 'Through week of {{start}} used {{count}} records / {{chars}} chars', + activityLegendLow: 'Less', + activityLegendHigh: 'More', + monthNames: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], recentTitle: 'Recent transcripts', recentAll: 'View all →', recentEmpty: 'No records yet. Press {{trigger}} to start your first recording.', @@ -966,6 +982,8 @@ export const en: typeof zhCN = { system: 'Follow system', light: 'Light', dark: 'Dark', + overviewActivityLabel: 'Show overview activity heatmap', + overviewActivityDesc: 'Show the daily / weekly / cumulative activity heatmap at the bottom of Overview. Turning this off does not delete history.', }, remoteInput: { title: 'Remote Input', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 257bfb7f..fbb5da44 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -279,9 +279,25 @@ export const ja: typeof zhCN = { metricNoData: 'データなし', historyLoadError: '履歴の読み込みに失敗', metricTotal: '累計記録', - metricTotalTrend: 'ローカル保存(上限 200)', + metricTotalTrend: 'ローカル保存', weekTitle: '直近 7 日', weekUnit: '件 / 日', + activityTitle: '年間アクティビティ', + activityModeDaily: '毎日', + activityMode: { + daily: '毎日', + weekly: '毎週', + cumulative: '累計', + }, + activityUnit: '過去 1 年 · {{days}} 日に記録', + activityEmpty: '過去 1 年の履歴はまだありません。', + activityTooltip: '{{date}} · {{count}} 件 · {{chars}} 文字', + activitySummaryDaily: '{{date}} に {{count}} 件 / {{chars}} 文字', + activitySummaryWeekly: '{{start}} の週に {{count}} 件 / {{chars}} 文字', + activitySummaryCumulative: '{{start}} の週まで累計 {{count}} 件 / {{chars}} 文字', + activityLegendLow: '少', + activityLegendHigh: '多', + monthNames: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'], recentTitle: '最近の認識', recentAll: 'すべて表示 →', recentEmpty: '記録がありません。{{trigger}} を押して最初の録音を始めましょう。', @@ -934,6 +950,8 @@ export const ja: typeof zhCN = { system: 'システムに従う', light: 'ライト', dark: 'ダーク', + overviewActivityLabel: '概要の活動ヒートマップを表示', + overviewActivityDesc: '今日の概要の下部に日別 / 週別 / 累計の活動ヒートマップを表示します。オフにしても履歴は削除されません。', }, remoteInput: { title: 'リモート入力', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 395f92e1..79dfd1e5 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -279,9 +279,25 @@ export const ko: typeof zhCN = { metricNoData: '데이터 없음', historyLoadError: '기록 로드 실패', metricTotal: '누적 기록', - metricTotalTrend: '로컬 보관(상한 200)', + metricTotalTrend: '로컬 보관', weekTitle: '최근 7일', weekUnit: '건/일', + activityTitle: '연간 활동', + activityModeDaily: '매일', + activityMode: { + daily: '매일', + weekly: '매주', + cumulative: '누적', + }, + activityUnit: '지난 1년 · {{days}}일 기록 있음', + activityEmpty: '지난 1년 동안 기록이 없습니다.', + activityTooltip: '{{date}} · {{count}}건 · {{chars}}자', + activitySummaryDaily: '{{date}} 사용 {{count}}건 / {{chars}}자', + activitySummaryWeekly: '{{start}} 주간 사용 {{count}}건 / {{chars}}자', + activitySummaryCumulative: '{{start}} 주까지 누적 사용 {{count}}건 / {{chars}}자', + activityLegendLow: '적음', + activityLegendHigh: '많음', + monthNames: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'], recentTitle: '최근 인식', recentAll: '전체 보기 →', recentEmpty: '아직 기록이 없습니다. {{trigger}} 를 눌러 첫 녹음을 시작하세요.', @@ -934,6 +950,8 @@ export const ko: typeof zhCN = { system: '시스템 따르기', light: '라이트', dark: '다크', + overviewActivityLabel: '개요 활동 히트맵 표시', + overviewActivityDesc: '오늘의 개요 하단에 일별 / 주별 / 누적 활동 히트맵을 표시합니다. 꺼도 기록은 삭제되지 않습니다.', }, remoteInput: { title: '원격 입력', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 3147b416..2f99e241 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -275,9 +275,25 @@ export const zhCN = { metricNoData: '暂无数据', historyLoadError: '历史读取失败', metricTotal: '累计记录', - metricTotalTrend: '本机存档 (上限 200)', + metricTotalTrend: '本机存档', weekTitle: '近 7 天', weekUnit: '条数 / 天', + activityTitle: '年度活动', + activityModeDaily: '每日', + activityMode: { + daily: '每日', + weekly: '每周', + cumulative: '累计', + }, + activityUnit: '过去一年 · {{days}} 天有记录', + activityEmpty: '过去一年还没有历史记录。', + activityTooltip: '{{date}} · {{count}} 条 · {{chars}} 字', + activitySummaryDaily: '{{date}} 使用了 {{count}} 条 / {{chars}} 字', + activitySummaryWeekly: '{{start}} 当周使用了 {{count}} 条 / {{chars}} 字', + activitySummaryCumulative: '截至 {{start}} 当周累计使用了 {{count}} 条 / {{chars}} 字', + activityLegendLow: '少', + activityLegendHigh: '多', + monthNames: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'], recentTitle: '最近识别', recentAll: '全部记录 →', recentEmpty: '还没有记录。按 {{trigger}} 开始第一次录音。', @@ -964,6 +980,8 @@ export const zhCN = { system: '跟随系统', light: '浅色', dark: '深色', + overviewActivityLabel: '显示概况活动热图', + overviewActivityDesc: '在今日概况底部显示每日 / 每周 / 累计活动热图;关闭不会删除历史记录。', }, remoteInput: { title: '远程输入', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 2fe58825..bf1806d9 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -277,9 +277,25 @@ export const zhTW: typeof zhCN = { metricNoData: '暫無數據', historyLoadError: '歷史讀取失敗', metricTotal: '累計記錄', - metricTotalTrend: '本機存檔 (上限 200)', + metricTotalTrend: '本機存檔', weekTitle: '近 7 天', weekUnit: '條數 / 天', + activityTitle: '年度活動', + activityModeDaily: '每日', + activityMode: { + daily: '每日', + weekly: '每週', + cumulative: '累計', + }, + activityUnit: '過去一年 · {{days}} 天有記錄', + activityEmpty: '過去一年還沒有歷史記錄。', + activityTooltip: '{{date}} · {{count}} 條 · {{chars}} 字', + activitySummaryDaily: '{{date}} 使用了 {{count}} 條 / {{chars}} 字', + activitySummaryWeekly: '{{start}} 當週使用了 {{count}} 條 / {{chars}} 字', + activitySummaryCumulative: '截至 {{start}} 當週累計使用了 {{count}} 條 / {{chars}} 字', + activityLegendLow: '少', + activityLegendHigh: '多', + monthNames: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'], recentTitle: '最近識別', recentAll: '全部記錄 →', recentEmpty: '還沒有記錄。按 {{trigger}} 開始第一次錄音。', @@ -932,6 +948,8 @@ export const zhTW: typeof zhCN = { system: '跟隨系統', light: '淺色', dark: '深色', + overviewActivityLabel: '顯示概況活動熱圖', + overviewActivityDesc: '在今日概況底部顯示每日 / 每週 / 累計活動熱圖;關閉不會刪除歷史記錄。', }, remoteInput: { title: '遠端輸入', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 402f8cc8..cb6f1da2 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -84,6 +84,7 @@ export let mockSettings: UserPreferences = { polishContextWindowMinutes: 5, startMinimized: false, themeMode: "system", + showOverviewActivityHeatmap: true, updateChannel: "stable", streamingInsert: true, streamingInsertDefaultMigrated: true, @@ -431,23 +432,40 @@ export const mockMicrophoneDevices: MicrophoneDevice[] = [ { name: "USB Microphone", isDefault: false }, ] -export const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ - id: `mock-${i}`, - createdAt: new Date().toISOString(), - rawTranscript: h.preview, - finalText: h.preview, - mode: "structured", - stylePackId: "builtin.structured", - translationActive: false, - polishSource: null, - appBundleId: null, - appName: "VS Code", - insertStatus: "inserted", - errorCode: null, - durationMs: 600, - dictionaryEntryCount: 28, - hasAudioRecording: null, -})) +const mockHistoryOffsets = [0, 0, 0, 1, 1, 2, 3, 3, 4, 5, 6, 15, 32, 54, 91, 130, 178, 230, 292, 340] +const mockHistoryModes: PolishMode[] = ["structured", "light", "raw", "formal"] +const mockHistoryApps = ["VS Code", "Obsidian", "Chrome", "微信", "Word"] + +function mockCreatedAt(dayOffset: number, index: number): string { + const source = OL_DATA.history[index % OL_DATA.history.length] + const [hours = 12, minutes = 0] = source.time.split(":").map(Number) + const date = new Date() + date.setDate(date.getDate() - dayOffset) + date.setHours(hours, minutes, 0, 0) + return date.toISOString() +} + +export const mockHistory: DictationSession[] = mockHistoryOffsets.map((dayOffset, i) => { + const h = OL_DATA.history[i % OL_DATA.history.length] + const mode = mockHistoryModes[i % mockHistoryModes.length] + return { + id: `mock-${dayOffset}-${i}`, + createdAt: mockCreatedAt(dayOffset, i), + rawTranscript: h.preview, + finalText: h.preview, + mode, + stylePackId: `builtin.${mode}`, + translationActive: false, + polishSource: null, + appBundleId: null, + appName: mockHistoryApps[i % mockHistoryApps.length], + insertStatus: "inserted", + errorCode: null, + durationMs: 9000 + (i % 6) * 1800, + dictionaryEntryCount: 12 + (i % 5) * 4, + hasAudioRecording: null, + } +}) export const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({ id: `vocab-${i}`, diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index d049a4a5..f39d049d 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -71,6 +71,7 @@ const previousPrefs: UserPreferences = { polishContextWindowMinutes: 5, startMinimized: false, themeMode: 'system', + showOverviewActivityHeatmap: true, updateChannel: 'stable', streamingInsert: true, streamingInsertDefaultMigrated: true, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index fe8c8baa..8801df87 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -334,6 +334,8 @@ export interface UserPreferences { startMinimized: boolean; /** UI theme preference: follow OS, light, or dark. */ themeMode: ThemeMode; + /** Show the annual activity heatmap on the Overview page. Default true. */ + showOverviewActivityHeatmap: boolean; /** 自动更新渠道。'stable'(默认)= plugin-updater 仅检查正式版; * 'beta' = Settings → About 出现手动下载 Beta 的入口。 */ updateChannel: UpdateChannel; diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 6e9d77ff..5593b35a 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -1,6 +1,6 @@ // Overview.tsx — 真实指标,从 listHistory + getCredentials 派生。 -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { formatComboLabel } from '../lib/hotkey'; @@ -52,6 +52,10 @@ const LLM_NAME_KEY_BY_ID: Record = { custom: 'custom', }; +type ActivityMode = 'daily' | 'weekly' | 'cumulative'; + +const ACTIVITY_MODES: ActivityMode[] = ['daily', 'weekly', 'cumulative']; + export function Overview({ onOpenHistory }: OverviewProps) { const { t } = useTranslation(); const mobile = useMobileLayout(); @@ -67,6 +71,7 @@ export function Overview({ onOpenHistory }: OverviewProps) { volcengineConfigured: false, arkConfigured: false, }); + const [activityMode, setActivityMode] = useState('daily'); const { prefs } = useHotkeySettings(); const credentialsRequestSeq = useRef(0); @@ -159,6 +164,17 @@ export function Overview({ onOpenHistory }: OverviewProps) { return buckets; }, [history]); + const monthNames = useMemo( + () => t('overview.monthNames', { returnObjects: true }) as string[], + [t], + ); + + const yearlyActivity = useMemo( + () => buildYearlyActivity(history, monthNames, activityMode), + [history, monthNames, activityMode], + ); + const showActivityHeatmap = prefs?.showOverviewActivityHeatmap !== false; + const asrProviderId = creds.activeAsrProvider || 'volcengine'; const llmProviderId = creds.activeLlmProvider || 'ark'; const asrNameKey = ASR_NAME_KEY_BY_ID[asrProviderId]; @@ -189,18 +205,16 @@ export function Overview({ onOpenHistory }: OverviewProps) { /> -
+
0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} />
- {/* 底部一行 = flex:1 撑满剩余高度(父 wrapper 是 display:flex/column)。 - 只有「最近识别」内部允许滚动;其他卡片按内容自然高度,不破裂底部圆角。 - issue #243 follow-up:去掉外层 overflow 后底部圆角被裁的视觉问题。 */} -
- + {/* 近 7 天和最近识别固定一个可读高度;最近识别内部滚动,避免和年度活动互相遮挡。 */} +
+
{t('overview.weekTitle')} {t('overview.weekUnit')} @@ -212,12 +226,15 @@ export function Overview({ onOpenHistory }: OverviewProps) { ) : ( )} -
- {weekDayLabels(t('overview.weekDays', { returnObjects: true }) as string[]).map((d, i) => {d})} +
+
- +
{t('overview.recentTitle')} {t('overview.recentAll')} @@ -243,6 +260,15 @@ export function Overview({ onOpenHistory }: OverviewProps) {
+ + {showActivityHeatmap && ( + + )} ); } @@ -318,27 +344,423 @@ function Metric({ icon, label, value, trend, accent }: MetricProps) { function WeekChart({ data }: { data: number[] }) { const max = Math.max(...data, 1); + const mid = Math.ceil(max / 2); + return ( +
+
+ {max} + {mid} + 0 +
+
+
+ ); +} + +interface DayActivity { + key: string; + date: Date; + rawCount: number; + rawChars: number; + count: number; + chars: number; + level: number; + inRange: boolean; +} + +interface WeekActivity { + key: string; + startDate: Date; + endDate: Date; + count: number; + chars: number; + cumulativeCount: number; + cumulativeChars: number; + value: number; + valueChars: number; + level: number; + inRange: boolean; +} + +interface MonthLabel { + weekIndex: number; + label: string; +} + +interface YearlyActivity { + cells: DayActivity[]; + weeks: number; + weekColumns: number; + activeDays: number; + maxCount: number; + maxWeekValue: number; + weekBars: WeekActivity[]; + monthLabels: MonthLabel[]; + weekMonthLabels: MonthLabel[]; +} + +function ActivityHeatmapCard({ + activity, + mode, + onModeChange, + historyError, +}: { + activity: YearlyActivity; + mode: ActivityMode; + onModeChange: (mode: ActivityMode) => void; + historyError: boolean; +}) { + const { t } = useTranslation(); + const mobile = useMobileLayout(); + const cardRef = useRef(null); + const [hoveredKey, setHoveredKey] = useState(null); + const [hoverPoint, setHoverPoint] = useState<{ x: number; y: number; align: 'left' | 'center' | 'right' } | null>(null); + const cellSize = 12; + const cellGap = 4; + const dayGridWidth = activity.weeks * cellSize + Math.max(0, activity.weeks - 1) * cellGap; + const weekGridWidth = activity.weekColumns * cellSize + Math.max(0, activity.weekColumns - 1) * cellGap; + const hoveredDay = mode === 'daily' && hoveredKey + ? activity.cells.find(cell => cell.key === hoveredKey) ?? null + : null; + const hoveredWeek = mode !== 'daily' && hoveredKey + ? activity.weekBars.find(week => week.key === hoveredKey) ?? null + : null; + const summaryArgs = hoveredDay + ? activityDaySummaryArgs(hoveredDay) + : hoveredWeek && mode !== 'daily' + ? activityWeekSummaryArgs(mode, hoveredWeek) + : null; + const summaryText = summaryArgs ? t(summaryArgs.key, summaryArgs.options) : null; + const showTooltip = Boolean(summaryText && hoverPoint); + const handleHover = useCallback((key: string, event: PointerEvent) => { + const cardRect = cardRef.current?.getBoundingClientRect(); + const targetRect = event.currentTarget.getBoundingClientRect(); + if (!cardRect) return; + const x = targetRect.left + targetRect.width / 2 - cardRect.left; + const y = targetRect.top - cardRect.top; + const align = x < 140 ? 'left' : x > cardRect.width - 140 ? 'right' : 'center'; + setHoveredKey(key); + setHoverPoint({ x, y, align }); + }, []); + const clearHover = useCallback(() => { + setHoveredKey(null); + setHoverPoint(null); + }, []); + + return ( + +
+
+
+ {t('overview.activityTitle')} +
+
+ {ACTIVITY_MODES.map(activityMode => { + const selected = activityMode === mode; + return ( + + ); + })} +
+
+ + {historyError ? ( +
{t('overview.historyLoadError')}
+ ) : activity.activeDays === 0 ? ( +
{t('overview.activityEmpty')}
+ ) : mode === 'daily' ? ( + + ) : ( + + )} + {showTooltip && hoverPoint && ( +
+ {summaryText} +
+ )} + + ); +} + +function ActivityDailyGrid({ + mode, + activity, + cellSize, + cellGap, + minGridWidth, + hoveredKey, + onHover, + onLeave, +}: { + mode: ActivityMode; + activity: YearlyActivity; + cellSize: number; + cellGap: number; + minGridWidth: number; + hoveredKey: string | null; + onHover: (key: string, event: PointerEvent) => void; + onLeave: () => void; +}) { + const { t } = useTranslation(); return ( -
- {data.map((v, i) => { - const isToday = i === 6; - return ( -
-
{v}
-
+
+
+
+
+ {activity.cells.map(cell => { + const selected = hoveredKey === cell.key; + const cellSummary = activityDaySummaryArgs(cell); + const weekIndex = Math.floor(differenceInDays(cell.date, activity.cells[0]?.date ?? cell.date) / 7); + return ( +
{ + if (cell.inRange) onHover(cell.key, event); + }} + onPointerMove={(event) => { + if (cell.inRange) onHover(cell.key, event); + }} + style={{ + width: cellSize, + height: cellSize, + borderRadius: 4, + background: activityColor(cell.level), + opacity: cell.inRange ? 1 : 0, + boxShadow: cell.inRange + ? selected + ? '0 0 0 1.5px color-mix(in srgb, var(--ol-ink) 42%, transparent) inset' + : '0 0 0 0.5px color-mix(in srgb, var(--ol-ink) 10%, transparent) inset' + : 'none', + cursor: cell.inRange ? 'default' : 'auto', + transform: 'scale(1)', + animation: cell.inRange ? `ol-activity-cell-in 0.22s var(--ol-motion-soft) ${Math.min(Math.max(0, weekIndex) * 5, 260)}ms both` : undefined, + transition: 'background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick), box-shadow 0.12s var(--ol-motion-quick)', + }} + /> + ); + })}
- ); - })} + +
+
+
+ ); +} + +function ActivityWeekGrid({ + activity, + mode, + cellSize, + cellGap, + minGridWidth, + hoveredKey, + onHover, + onLeave, +}: { + activity: YearlyActivity; + mode: Exclude; + cellSize: number; + cellGap: number; + minGridWidth: number; + hoveredKey: string | null; + onHover: (key: string, event: PointerEvent) => void; + onLeave: () => void; +}) { + const { t } = useTranslation(); + + return ( +
+
+
+
+ {activity.weekBars.flatMap((week, weekIndex) => { + const summary = activityWeekSummaryArgs(mode, week); + const filledCells = activityDiscreteCells(week.value, activity.maxWeekValue); + return Array.from({ length: 7 }).map((_, rowIndex) => { + const selected = hoveredKey === week.key; + const filled = rowIndex >= 7 - filledCells; + return ( +
{ + if (week.inRange) onHover(week.key, event); + }} + onPointerMove={(event) => { + if (week.inRange) onHover(week.key, event); + }} + style={{ + width: cellSize, + height: cellSize, + borderRadius: 4, + background: filled ? activityColor(week.level) : activityColor(0), + opacity: week.inRange ? 1 : 0, + boxShadow: week.inRange + ? selected + ? '0 0 0 1.5px color-mix(in srgb, var(--ol-ink) 42%, transparent) inset' + : '0 0 0 0.5px color-mix(in srgb, var(--ol-ink) 10%, transparent) inset' + : 'none', + cursor: week.inRange ? 'default' : 'auto', + transform: 'scale(1)', + animation: week.inRange ? `ol-activity-cell-in 0.22s var(--ol-motion-soft) ${Math.min(weekIndex * 5, 260)}ms both` : undefined, + transition: 'background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick), box-shadow 0.12s var(--ol-motion-quick)', + }} + /> + ); + }); + })} +
+ +
+
+
+ ); +} + +function ActivityMonthLabels({ + labels, + weeks, + cellSize, + cellGap, +}: { + labels: MonthLabel[]; + weeks: number; + cellSize: number; + cellGap: number; +}) { + return ( +
+ {labels.map(label => ( + + {label.label} + + ))}
); } @@ -363,6 +785,223 @@ function RecentRow({ session, modeLabel }: { session: DictationSession; modeLabe ); } +function buildYearlyActivity(history: DictationSession[], monthNames: string[], mode: ActivityMode): YearlyActivity { + const today = startOfLocalDay(new Date()); + const rangeStart = addDays(today, -364); + const gridStart = rangeStart; + const weeks = Math.ceil((differenceInDays(today, gridStart) + 1) / 7); + const weekGridStart = startOfWeek(rangeStart); + const weekColumns = Math.ceil((differenceInDays(today, weekGridStart) + 1) / 7); + const byDay = new Map(); + + history.forEach(session => { + const date = startOfLocalDay(new Date(session.createdAt)); + if (isNaN(date.getTime()) || date < rangeStart || date > today) return; + const key = localDateKey(date); + const current = byDay.get(key) ?? { count: 0, chars: 0 }; + current.count += 1; + current.chars += session.finalText.length; + byDay.set(key, current); + }); + + const rawCells: Array> = []; + for (let i = 0; i < weeks * 7; i += 1) { + const date = addDays(gridStart, i); + const inRange = date >= rangeStart && date <= today; + const key = localDateKey(date); + const stats = inRange ? byDay.get(key) : undefined; + rawCells.push({ + key, + date, + rawCount: stats?.count ?? 0, + rawChars: stats?.chars ?? 0, + inRange, + }); + } + + let cumulativeCount = 0; + let cumulativeChars = 0; + const metricCells = rawCells.map(cell => ({ + ...cell, + count: cell.inRange ? cell.rawCount : 0, + chars: cell.inRange ? cell.rawChars : 0, + })); + + const maxCount = Math.max(...metricCells.filter(cell => cell.inRange).map(cell => cell.count), 0); + const cells = metricCells.map(cell => ({ + ...cell, + level: cell.inRange ? activityLevel(cell.count, maxCount) : 0, + })); + + const rawWeeks: Array> = []; + for (let weekIndex = 0; weekIndex < weekColumns; weekIndex += 1) { + const startDate = addDays(weekGridStart, weekIndex * 7); + const endDate = addDays(startDate, 6); + const inRange = endDate >= rangeStart && startDate <= today; + let stats = { count: 0, chars: 0 }; + for (let offset = 0; offset < 7; offset += 1) { + const date = addDays(startDate, offset); + if (date < rangeStart || date > today) continue; + const dayStats = byDay.get(localDateKey(date)); + if (!dayStats) continue; + stats = { count: stats.count + dayStats.count, chars: stats.chars + dayStats.chars }; + } + if (inRange) { + cumulativeCount += stats.count; + cumulativeChars += stats.chars; + } + const value = mode === 'cumulative' ? cumulativeCount : stats.count; + const valueChars = mode === 'cumulative' ? cumulativeChars : stats.chars; + rawWeeks.push({ + key: localDateKey(startDate), + startDate, + endDate: endDate > today ? today : endDate, + count: inRange ? stats.count : 0, + chars: inRange ? stats.chars : 0, + cumulativeCount: inRange ? cumulativeCount : 0, + cumulativeChars: inRange ? cumulativeChars : 0, + value: inRange ? value : 0, + valueChars: inRange ? valueChars : 0, + inRange, + }); + } + + const maxWeekValue = Math.max(...rawWeeks.filter(week => week.inRange).map(week => week.value), 0); + const weekBars = rawWeeks.map(week => ({ + ...week, + level: week.inRange ? activityLevel(week.value, maxWeekValue) : 0, + })); + + const monthLabels: MonthLabel[] = []; + let lastMonth = -1; + for (let weekIndex = 0; weekIndex < weeks; weekIndex += 1) { + const weekCells = cells.slice(weekIndex * 7, weekIndex * 7 + 7).filter(cell => cell.inRange); + const candidate = weekCells.find(cell => cell.date.getDate() === 1); + if (!candidate) continue; + const month = candidate.date.getMonth(); + if (month === lastMonth) continue; + lastMonth = month; + monthLabels.push({ + weekIndex, + label: monthNames[month] ?? String(month + 1), + }); + } + const weekMonthLabels: MonthLabel[] = []; + let lastWeekMonth = -1; + for (let weekIndex = 0; weekIndex < weekColumns; weekIndex += 1) { + let candidate: Date | null = null; + const weekStart = addDays(weekGridStart, weekIndex * 7); + for (let offset = 0; offset < 7; offset += 1) { + const date = addDays(weekStart, offset); + if (date >= rangeStart && date <= today && date.getDate() === 1) { + candidate = date; + break; + } + } + if (!candidate) continue; + const month = candidate.getMonth(); + if (month === lastWeekMonth) continue; + lastWeekMonth = month; + weekMonthLabels.push({ + weekIndex, + label: monthNames[month] ?? String(month + 1), + }); + } + + return { + cells, + weeks, + weekColumns, + activeDays: rawCells.filter(cell => cell.inRange && cell.rawCount > 0).length, + maxCount, + maxWeekValue, + weekBars, + monthLabels, + weekMonthLabels, + }; +} + +function activityLevel(count: number, maxCount: number): number { + if (count <= 0 || maxCount <= 0) return 0; + const ratio = count / maxCount; + if (ratio <= 0.25) return 1; + if (ratio <= 0.5) return 2; + if (ratio <= 0.75) return 3; + return 4; +} + +function activityDiscreteCells(value: number, maxValue: number): number { + if (value <= 0 || maxValue <= 0) return 0; + return Math.min(7, Math.max(1, Math.ceil((value / maxValue) * 7))); +} + +function activityColor(level: number): string { + const base = 'var(--ol-blue)'; + switch (level) { + case 1: + return `color-mix(in srgb, ${base} 26%, var(--ol-surface))`; + case 2: + return `color-mix(in srgb, ${base} 44%, var(--ol-surface))`; + case 3: + return `color-mix(in srgb, ${base} 68%, var(--ol-surface))`; + case 4: + return base; + default: + return 'color-mix(in srgb, var(--ol-ink) 8%, var(--ol-surface))'; + } +} + +function activityDaySummaryArgs(cell: DayActivity): { key: string; options: Record } { + const date = formatHeatmapMonthDay(cell.date); + return { + key: 'overview.activitySummaryDaily', + options: { date, count: cell.rawCount.toLocaleString(), chars: cell.rawChars.toLocaleString() }, + }; +} + +function activityWeekSummaryArgs(mode: Exclude, week: WeekActivity): { key: string; options: Record } { + const start = formatHeatmapDisplayDate(week.startDate); + if (mode === 'weekly') { + return { + key: 'overview.activitySummaryWeekly', + options: { start, count: week.count.toLocaleString(), chars: week.chars.toLocaleString() }, + }; + } + return { + key: 'overview.activitySummaryCumulative', + options: { start, count: week.cumulativeCount.toLocaleString(), chars: week.cumulativeChars.toLocaleString() }, + }; +} + +function startOfLocalDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function startOfWeek(date: Date): Date { + return addDays(startOfLocalDay(date), -((date.getDay() + 6) % 7)); +} + +function addDays(date: Date, days: number): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days); +} + +function differenceInDays(a: Date, b: Date): number { + return Math.round((startOfLocalDay(a).getTime() - startOfLocalDay(b).getTime()) / 86400000); +} + +function localDateKey(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +function formatHeatmapDisplayDate(date: Date): string { + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); +} + +function formatHeatmapMonthDay(date: Date): string { + return date.toLocaleDateString(undefined, { month: 'long', day: 'numeric' }); +} + function formatTime(iso: string): string { const d = new Date(iso); if (isNaN(d.getTime())) return iso; diff --git a/openless-all/app/src/pages/settings/ThemeSection.tsx b/openless-all/app/src/pages/settings/ThemeSection.tsx index f27075a0..2fd9a6ea 100644 --- a/openless-all/app/src/pages/settings/ThemeSection.tsx +++ b/openless-all/app/src/pages/settings/ThemeSection.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { SelectLite } from '../../components/ui/SelectLite'; import { Card } from '../_atoms'; -import { SettingRow } from './shared'; +import { SettingRow, Toggle } from './shared'; import { readThemePreference, setThemePreference, @@ -53,6 +53,19 @@ export function ThemeSection() { style={{ maxWidth: 220, minWidth: 200 }} /> + {prefs && ( + +
+ void updatePrefs({ ...prefs, showOverviewActivityHeatmap: next })} + /> + + {t('settings.theme.overviewActivityDesc')} + +
+
+ )} ); } diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index dc08e742..319f0814 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -122,6 +122,11 @@ a { color: inherit; text-decoration: none; } to { transform: rotate(360deg); } } +@keyframes ol-activity-cell-in { + from { opacity: 0; } + to { opacity: 1; } +} + @keyframes ol-modal-drawer-in { from { opacity: 0;