diff --git a/openless-all/app/src-tauri/src/commands/history.rs b/openless-all/app/src-tauri/src/commands/history.rs index 95c2fed23..3e4b0feed 100644 --- a/openless-all/app/src-tauri/src/commands/history.rs +++ b/openless-all/app/src-tauri/src/commands/history.rs @@ -16,15 +16,21 @@ pub fn clear_history(coord: CoordinatorState<'_>) -> Result<(), String> { coord.history().clear().map_err(|e| e.to_string()) } -/// 每日活动计数(日期升序),概览页年度热力图的数据源。与历史内容 / 保留策略解耦: -/// 清空历史不影响它,全年格子照亮。 +/// 每日活动汇总(日期升序),概览页年度热力图与「近 7 天 / 近 30 天」指标的数据源。 +/// 与历史内容 / 保留策略解耦:清空历史不影响它,全年格子照亮,周期统计也不会被 +/// 历史 200 条上限截断。 #[tauri::command] pub fn get_activity_stats(coord: CoordinatorState<'_>) -> Vec { coord .activity() .snapshot() .into_iter() - .map(|(date, count)| ActivityDay { date, count }) + .map(|(date, stats)| ActivityDay { + date, + count: stats.count, + chars: stats.chars, + duration_ms: stats.duration_ms, + }) .collect() } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 672e5d834..6d3e41a99 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -3934,12 +3934,16 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { ) { log::error!("[coord] history append failed: {e}"); } - // 活动计数(概览页热力图数据源):只有成功完成的听写才点亮格子——转录失败 / - // 错误收尾的两处 append 不计。写失败不阻断主流程。 - if let Err(e) = inner - .activity - .bump(&chrono::Local::now().format("%Y-%m-%d").to_string()) - { + // 活动汇总(概览页热力图 + 近 7 天 / 近 30 天指标的数据源):只有成功完成的听写 + // 才点亮格子——转录失败 / 错误收尾的两处 append 不计。写失败不阻断主流程。 + // + // 字数口径与历史详情页的「N 字」一致(最终插入文本的 Unicode 字符数);时长口径 + // 是录音时长,不含识别/润色耗时——与详情页「录音 x.x 秒」同源,避免两处对不上。 + if let Err(e) = inner.activity.bump( + &chrono::Local::now().format("%Y-%m-%d").to_string(), + polished.chars().count() as u64, + raw.duration_ms, + ) { log::warn!("[coord] activity bump failed: {e}"); } diff --git a/openless-all/app/src-tauri/src/persistence/activity.rs b/openless-all/app/src-tauri/src/persistence/activity.rs index 35c9aa858..a50ce5292 100644 --- a/openless-all/app/src-tauri/src/persistence/activity.rs +++ b/openless-all/app/src-tauri/src/persistence/activity.rs @@ -1,25 +1,66 @@ -//! 每日听写活动计数(`date(YYYY-MM-DD) → count`),供概览页年度活动热力图使用。 +//! 每日听写活动汇总(`date(YYYY-MM-DD) → {count, chars, duration_ms}`),供概览页的 +//! 年度热力图与「近 7 天 / 近 30 天」统计使用。 //! //! 与历史内容存储完全解耦:不含任何转写文本,也不受历史保留策略 / 条数上限影响 //! —— 清理历史不会抹掉活动足迹,热力图因此能覆盖全年而无需放开历史上限 //! (取代 PR #716 里「为热力图把历史改为无限保留」的方案)。 //! 写入时按保留窗口(两年)裁剪最早的日期,文件天然有界。 +//! +//! 只存聚合数字、不存文本,所以「多记两个字段」的隐私与体积代价可忽略:一天一行, +//! 两年上限 731 行。 use std::collections::BTreeMap; use std::path::PathBuf; use anyhow::Result; use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; use super::{atomic_write, data_dir, ensure_dir, read_or_default}; const ACTIVITY_FILE: &str = "activity.json"; -/// 保留最近两年(含闰年余量)的日计数,超窗的最早日期在写入时移除。 +/// 保留最近两年(含闰年余量)的日汇总,超窗的最早日期在写入时移除。 const ACTIVITY_RETENTION_DAYS: usize = 731; +/// 单日汇总。字段都是纯计数,不含任何文本。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DayStats { + pub count: u32, + #[serde(default)] + pub chars: u64, + #[serde(default)] + pub duration_ms: u64, +} + +/// 磁盘表示。旧版本的 activity.json 每天只写一个裸数字(`{"2026-08-01": 5}`), +/// 升级后必须原样读回来 —— 否则老用户的年度热力图会一次性清空。 +/// 旧格式没有字数/时长,读回后为 0:这些天在新指标里显示为 0 是诚实的(数据当时没记), +/// 比整段丢掉条数要好。写入一律用新的对象格式。 +#[derive(Deserialize)] +#[serde(untagged)] +enum StoredDay { + /// 旧格式:只有条数。 + CountOnly(u32), + /// 新格式。 + Full(DayStats), +} + +impl From for DayStats { + fn from(stored: StoredDay) -> Self { + match stored { + StoredDay::CountOnly(count) => DayStats { + count, + ..Default::default() + }, + StoredDay::Full(stats) => stats, + } + } +} + pub struct ActivityStore { path: PathBuf, - cache: Mutex>, + cache: Mutex>, } impl ActivityStore { @@ -27,7 +68,11 @@ impl ActivityStore { let dir = data_dir()?; ensure_dir(&dir)?; let path = dir.join(ACTIVITY_FILE); - let cache: BTreeMap = read_or_default(&path)?; + let stored: BTreeMap = read_or_default(&path)?; + let cache = stored + .into_iter() + .map(|(date, day)| (date, day.into())) + .collect(); Ok(Self { path, cache: Mutex::new(cache), @@ -44,9 +89,14 @@ impl ActivityStore { } /// 记录一次活动。`date` 为本地日期 `YYYY-MM-DD`(BTreeMap 按字典序即按日期序)。 - pub fn bump(&self, date: &str) -> Result<()> { + /// `chars` = 本次最终插入文本的字符数,`duration_ms` = 本次录音时长。 + /// 累加用 saturating:单日理论上不可能溢出,但计数器溢出 panic 不值得赌。 + pub fn bump(&self, date: &str, chars: u64, duration_ms: u64) -> Result<()> { let mut cache = self.cache.lock(); - *cache.entry(date.to_string()).or_insert(0) += 1; + let entry = cache.entry(date.to_string()).or_default(); + entry.count = entry.count.saturating_add(1); + entry.chars = entry.chars.saturating_add(chars); + entry.duration_ms = entry.duration_ms.saturating_add(duration_ms); while cache.len() > ACTIVITY_RETENTION_DAYS { let oldest = match cache.keys().next() { Some(key) => key.clone(), @@ -58,12 +108,86 @@ impl ActivityStore { atomic_write(&self.path, &bytes) } - /// 全量快照(日期升序),前端聚合成热力图。 - pub fn snapshot(&self) -> Vec<(String, u32)> { + /// 全量快照(日期升序),前端聚合成热力图与周期指标。 + pub fn snapshot(&self) -> Vec<(String, DayStats)> { self.cache .lock() .iter() - .map(|(date, count)| (date.clone(), *count)) + .map(|(date, stats)| (date.clone(), *stats)) .collect() } } + +#[cfg(test)] +mod tests { + use super::{DayStats, StoredDay}; + use std::collections::BTreeMap; + + /// 老用户升级后 activity.json 仍是「日期 → 裸数字」。必须原样读回条数, + /// 否则年度热力图一次性清空(用户会当成数据丢失)。 + #[test] + fn legacy_count_only_entries_survive_the_upgrade() { + let json = br#"{"2026-08-01": 5, "2026-08-02": 12}"#; + let stored: BTreeMap = serde_json::from_slice(json).unwrap(); + let parsed: BTreeMap = + stored.into_iter().map(|(k, v)| (k, v.into())).collect(); + + assert_eq!(parsed["2026-08-01"].count, 5); + assert_eq!(parsed["2026-08-02"].count, 12); + // 旧格式没记过字数/时长,读回 0 —— 诚实缺省,好过整天丢掉。 + assert_eq!(parsed["2026-08-01"].chars, 0); + assert_eq!(parsed["2026-08-01"].duration_ms, 0); + } + + #[test] + fn new_object_entries_round_trip() { + let original: BTreeMap = BTreeMap::from([( + "2026-08-03".to_string(), + DayStats { + count: 7, + chars: 4210, + duration_ms: 96_000, + }, + )]); + let bytes = serde_json::to_vec(&original).unwrap(); + let stored: BTreeMap = serde_json::from_slice(&bytes).unwrap(); + let parsed: BTreeMap = + stored.into_iter().map(|(k, v)| (k, v.into())).collect(); + + assert_eq!(parsed, original); + } + + /// 两种格式混在同一个文件里也要能读:升级当天写入会把当天变成对象格式, + /// 而更早的日期仍是裸数字。 + #[test] + fn mixed_legacy_and_new_entries_parse_together() { + let json = br#"{"2026-08-01": 5, "2026-08-02": {"count": 3, "chars": 900, "durationMs": 12000}}"#; + let stored: BTreeMap = serde_json::from_slice(json).unwrap(); + let parsed: BTreeMap = + stored.into_iter().map(|(k, v)| (k, v.into())).collect(); + + assert_eq!(parsed["2026-08-01"].count, 5); + assert_eq!(parsed["2026-08-01"].chars, 0); + assert_eq!(parsed["2026-08-02"].count, 3); + assert_eq!(parsed["2026-08-02"].chars, 900); + assert_eq!(parsed["2026-08-02"].duration_ms, 12_000); + } + + /// 缺字段的对象(比如手工编辑过的文件)按 0 补齐,不整份读失败。 + #[test] + fn object_entries_tolerate_missing_optional_fields() { + let json = br#"{"2026-08-04": {"count": 2}}"#; + let stored: BTreeMap = serde_json::from_slice(json).unwrap(); + let parsed: BTreeMap = + stored.into_iter().map(|(k, v)| (k, v.into())).collect(); + + assert_eq!( + parsed["2026-08-04"], + DayStats { + count: 2, + chars: 0, + duration_ms: 0 + } + ); + } +} diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index b8536cf9d..f312d3ac1 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -145,12 +145,20 @@ pub enum SelectionPolishOutputMode { PreviewConfirm, } -/// 概览页年度活动热力图的单日计数(date = 本地日期 YYYY-MM-DD)。 +/// 概览页活动统计的单日汇总(date = 本地日期 YYYY-MM-DD)。 +/// +/// 年度热力图只用 `count`;`chars` / `duration_ms` 供「近 7 天 / 近 30 天」的 +/// 字数与时长指标使用——这两个指标此前从 `list_history()` 现算,会被历史 200 条 +/// 上限截断(说得多的用户几天就把上周挤没了)。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ActivityDay { pub date: String, pub count: u32, + /// 当日最终插入文本的总字符数(按 Unicode 字符计,与历史详情页的「N 字」同口径)。 + pub chars: u64, + /// 当日录音总时长(毫秒)。口径 = 每次会话的录音时长,不含识别/润色耗时。 + pub duration_ms: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 7a658cf05..5359acdb4 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -324,10 +324,23 @@ export const en: typeof zhCN = { historyLoadError: 'History load failed', metricTotal: 'Total records', metricTotalTrend: 'Local archive (max 200)', - weekTitle: 'Last 7 days', activityTitle: 'Annual activity', activityCount: '{{count}} dictation(s)', - weekUnit: 'count / day', + activityLoadError: 'Activity data load failed', + period: { + ariaLabel: 'Reporting period', + last7Days: 'Last 7 days', + last30Days: 'Last 30 days', + dailyAverage: '{{value}} / day', + minutes: '{{value}} min', + hoursMinutes: '{{hours}} h {{minutes}} min', + }, + metricName: { + ariaLabel: 'Metric', + count: 'Count', + chars: 'Characters', + duration: 'Duration', + }, recentTitle: 'Recent transcripts', recentAll: 'View all →', recentEmpty: 'No records yet. Press {{trigger}} to start your first recording.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 013b16e7e..d45e90b61 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -326,10 +326,23 @@ export const ja: typeof zhCN = { historyLoadError: '履歴の読み込みに失敗', metricTotal: '累計記録', metricTotalTrend: 'ローカル保存(上限 200)', - weekTitle: '直近 7 日', activityTitle: '年間アクティビティ', activityCount: '{{count}} 回の入力', - weekUnit: '件 / 日', + activityLoadError: 'アクティビティの読み込みに失敗', + period: { + ariaLabel: '集計期間', + last7Days: '直近 7 日', + last30Days: '直近 30 日', + dailyAverage: '1 日平均 {{value}}', + minutes: '{{value}} 分', + hoursMinutes: '{{hours}} 時間 {{minutes}} 分', + }, + metricName: { + ariaLabel: '指標', + count: '件数', + chars: '文字数', + duration: '時間', + }, recentTitle: '最近の認識', recentAll: 'すべて表示 →', recentEmpty: '記録がありません。{{trigger}} を押して最初の録音を始めましょう。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index ed2d5a591..17b36cdd1 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -326,10 +326,23 @@ export const ko: typeof zhCN = { historyLoadError: '기록 로드 실패', metricTotal: '누적 기록', metricTotalTrend: '로컬 보관(상한 200)', - weekTitle: '최근 7일', activityTitle: '연간 활동', activityCount: '{{count}}회 받아쓰기', - weekUnit: '건/일', + activityLoadError: '활동 데이터 로드 실패', + period: { + ariaLabel: '집계 기간', + last7Days: '최근 7일', + last30Days: '최근 30일', + dailyAverage: '일평균 {{value}}', + minutes: '{{value}}분', + hoursMinutes: '{{hours}}시간 {{minutes}}분', + }, + metricName: { + ariaLabel: '지표', + count: '건수', + chars: '글자 수', + duration: '시간', + }, recentTitle: '최근 인식', recentAll: '전체 보기 →', recentEmpty: '아직 기록이 없습니다. {{trigger}} 를 눌러 첫 녹음을 시작하세요.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 77574093f..c04220494 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -322,10 +322,23 @@ export const zhCN = { historyLoadError: '历史读取失败', metricTotal: '累计记录', metricTotalTrend: '本机存档 (上限 200)', - weekTitle: '近 7 天', activityTitle: '年度活动', activityCount: '{{count}} 次听写', - weekUnit: '条数 / 天', + activityLoadError: '活动数据读取失败', + period: { + ariaLabel: '统计周期', + last7Days: '近 7 天', + last30Days: '近 30 天', + dailyAverage: '日均 {{value}}', + minutes: '{{value}} 分钟', + hoursMinutes: '{{hours}} 小时 {{minutes}} 分', + }, + metricName: { + ariaLabel: '统计指标', + count: '条数', + chars: '字数', + duration: '时长', + }, recentTitle: '最近识别', recentAll: '全部记录 →', recentEmpty: '还没有记录。按 {{trigger}} 开始第一次录音。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 846e1fe62..7a4d759c1 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -324,10 +324,23 @@ export const zhTW: typeof zhCN = { historyLoadError: '歷史讀取失敗', metricTotal: '累計記錄', metricTotalTrend: '本機存檔 (上限 200)', - weekTitle: '近 7 天', activityTitle: '年度活動', activityCount: '{{count}} 次聽寫', - weekUnit: '條數 / 天', + activityLoadError: '活動數據讀取失敗', + period: { + ariaLabel: '統計週期', + last7Days: '近 7 天', + last30Days: '近 30 天', + dailyAverage: '日均 {{value}}', + minutes: '{{value}} 分鐘', + hoursMinutes: '{{hours}} 小時 {{minutes}} 分', + }, + metricName: { + ariaLabel: '統計指標', + count: '條數', + chars: '字數', + duration: '時長', + }, recentTitle: '最近識別', recentAll: '全部記錄 →', recentEmpty: '還沒有記錄。按 {{trigger}} 開始第一次錄音。', diff --git a/openless-all/app/src/lib/activityMetrics.test.ts b/openless-all/app/src/lib/activityMetrics.test.ts new file mode 100644 index 000000000..5b105792a --- /dev/null +++ b/openless-all/app/src/lib/activityMetrics.test.ts @@ -0,0 +1,86 @@ +import { buildPeriodSeries, localDateKey } from './activityMetrics'; +import type { ActivityDay } from './types'; + +function assert(condition: boolean, message: string) { + if (!condition) throw new Error(message); +} + +function day(date: string, count: number, chars: number, durationMs: number): ActivityDay { + return { date, count, chars, durationMs }; +} + +// 本地日期键必须按本地年月日拼。东八区凌晨用 toISOString() 会切到前一天, +// 与后端 chrono::Local 写入的键对不上,整段数据会读成 0。 +const localMidnight = new Date(2026, 7, 4, 0, 30, 0); +assert( + localDateKey(localMidnight) === '2026-08-04', + `local date key should follow local calendar day, got ${localDateKey(localMidnight)}`, +); + +const today = new Date(2026, 7, 4, 12, 0, 0); // 2026-08-04 +const activity: ActivityDay[] = [ + day('2026-07-29', 40, 4000, 400_000), + day('2026-07-30', 118, 11_800, 1_180_000), + day('2026-08-02', 44, 4400, 440_000), + day('2026-08-04', 32, 3200, 320_000), +]; + +// 7 天窗口:长度恒为 7、按日期升序、最后一个是今天、缺失日期补 0。 +const week = buildPeriodSeries(activity, 7, 'count', today); +assert(week.buckets.length === 7, `7-day window should have 7 buckets, got ${week.buckets.length}`); +assert( + week.buckets[0].date === '2026-07-29' && week.buckets[6].date === '2026-08-04', + `window should span 07-29..08-04, got ${week.buckets[0].date}..${week.buckets[6].date}`, +); +assert( + week.buckets[2].date === '2026-07-31' && week.buckets[2].value === 0, + 'a date with no activity should be a zero bucket, not a gap', +); +assert(week.total === 40 + 118 + 44 + 32, `7-day count total wrong: ${week.total}`); +assert( + Math.abs(week.dailyAverage - 234 / 7) < 1e-9, + `daily average should divide by the whole period, got ${week.dailyAverage}`, +); + +// 指标切换读的是不同字段,窗口逻辑不变。 +const weekChars = buildPeriodSeries(activity, 7, 'chars', today); +assert(weekChars.total === 4000 + 11_800 + 4400 + 3200, `7-day chars total wrong: ${weekChars.total}`); +const weekDuration = buildPeriodSeries(activity, 7, 'duration', today); +assert( + weekDuration.total === 400_000 + 1_180_000 + 440_000 + 320_000, + `7-day duration total wrong: ${weekDuration.total}`, +); + +// 30 天窗口把更早的日期也纳进来(这里 07-29 起的都在窗口内),长度恒为 30。 +const month = buildPeriodSeries(activity, 30, 'count', today); +assert(month.buckets.length === 30, `30-day window should have 30 buckets, got ${month.buckets.length}`); +assert( + month.buckets[29].date === '2026-08-04' && month.buckets[0].date === '2026-07-06', + `30-day window should span 07-06..08-04, got ${month.buckets[0].date}..${month.buckets[29].date}`, +); +assert(month.total === 234, `30-day count total wrong: ${month.total}`); + +// 升级前写入的老数据只有 count,没有 chars / durationMs。字数/时长按 0 读, +// 不能 NaN —— NaN 会把整个柱状图的 max 算坏。 +const legacy: ActivityDay[] = [{ date: '2026-08-03', count: 156 } as ActivityDay]; +const legacyChars = buildPeriodSeries(legacy, 7, 'chars', today); +assert(legacyChars.total === 0, `legacy entries should read as 0 chars, got ${legacyChars.total}`); +assert( + Number.isFinite(legacyChars.dailyAverage), + 'legacy entries must not produce NaN averages', +); +const legacyCount = buildPeriodSeries(legacy, 7, 'count', today); +assert(legacyCount.total === 156, 'legacy entries should still report their count'); + +// 空数据集不炸,全 0。 +const empty = buildPeriodSeries([], 7, 'count', today); +assert(empty.buckets.length === 7 && empty.total === 0 && empty.dailyAverage === 0, 'empty activity should yield a zeroed series'); + +// 跨月边界:窗口要正确回退到上个月,不能在 1 号截断。 +const firstOfMonth = new Date(2026, 7, 1, 9, 0, 0); // 2026-08-01 +const crossMonth = buildPeriodSeries(activity, 7, 'count', firstOfMonth); +assert( + crossMonth.buckets[0].date === '2026-07-26' && crossMonth.buckets[6].date === '2026-08-01', + `window should cross the month boundary, got ${crossMonth.buckets[0].date}..${crossMonth.buckets[6].date}`, +); +assert(crossMonth.total === 40 + 118, `cross-month total wrong: ${crossMonth.total}`); diff --git a/openless-all/app/src/lib/activityMetrics.ts b/openless-all/app/src/lib/activityMetrics.ts new file mode 100644 index 000000000..8d1e1f397 --- /dev/null +++ b/openless-all/app/src/lib/activityMetrics.ts @@ -0,0 +1,77 @@ +// 概览页「近 7 天 / 近 30 天」周期指标的聚合。 +// +// 数据源是 activity 存储(date → {count, chars, durationMs}),**不是** listHistory(): +// 历史受 200 条上限约束,日均上百次的用户几天就把上周挤没了,按历史现算会把没数据的 +// 那几天画成 0(明明年度热力图上是亮的)。activity 保留两年且只存聚合数字。 + +import type { ActivityDay } from './types'; + +export const ACTIVITY_PERIODS = [7, 30] as const; +export type ActivityPeriod = (typeof ACTIVITY_PERIODS)[number]; + +export const ACTIVITY_METRICS = ['count', 'chars', 'duration'] as const; +export type ActivityMetric = (typeof ACTIVITY_METRICS)[number]; + +export interface ActivityBucket { + /** 本地日期 YYYY-MM-DD,与后端 chrono::Local 写入的键同格式。 */ + date: string; + value: number; +} + +export interface PeriodSeries { + /** 长度恒等于 days,按日期升序,最后一个是今天。缺数据的日期补 0。 */ + buckets: ActivityBucket[]; + total: number; + /** 周期内日均值。分母是整个周期(含没说话的日子),不是「有记录的天数」。 */ + dailyAverage: number; +} + +/** 本地日期键。必须用本地年月日拼,不能用 toISOString()——后者按 UTC 切日, + * 东八区凌晨的会话会被算到前一天,与后端 chrono::Local 的键对不上。 */ +export 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 readMetric(day: ActivityDay, metric: ActivityMetric): number { + switch (metric) { + case 'count': + return day.count; + case 'chars': + return day.chars ?? 0; + case 'duration': + return day.durationMs ?? 0; + } +} + +/** + * 把活动快照裁成「今天往前数 days 天」的连续序列。 + * + * 老数据(升级前写入的裸数字)没有 chars / durationMs,读回是 0:这些天在字数/时长 + * 指标里显示为 0 是诚实的——当时确实没记,不该凭历史现算去伪造一个受 200 条上限 + * 影响的数字。条数指标不受影响,全程可用。 + */ +export function buildPeriodSeries( + activity: readonly ActivityDay[], + days: number, + metric: ActivityMetric, + today: Date = new Date(), +): PeriodSeries { + const byDate = new Map(); + for (const day of activity) byDate.set(day.date, day); + + const buckets: ActivityBucket[] = []; + let total = 0; + for (let offset = days - 1; offset >= 0; offset--) { + const date = new Date(today); + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() - offset); + const key = localDateKey(date); + const day = byDate.get(key); + const value = day ? readMetric(day, metric) : 0; + total += value; + buckets.push({ date: key, value }); + } + + return { buckets, total, dailyAverage: days > 0 ? total / days : 0 }; +} diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 9a0645c15..ad8e3318f 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -787,7 +787,20 @@ export const mockActivityDays: ActivityDay[] = (() => { if (seed < 0.55) continue const count = Math.max(1, Math.round(seed * 22) - 8) const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` - days.push({ date: iso, count }) + // 字数 / 时长按每条 ~120 字、~9 秒的量级派生,让周期指标卡在浏览器 dev 下 + // 也有可看的数据。最早的 30 天故意只给 count(不给 chars/durationMs), + // 模拟升级前写入的老数据,验证「老日期在字数/时长指标里显示 0」不会崩。 + const legacy = i > 334 + days.push( + legacy + ? { date: iso, count } + : { + date: iso, + count, + chars: count * (90 + Math.round(seed * 70)), + durationMs: count * (6000 + Math.round(seed * 7000)), + }, + ) } return days })() diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 9af16fbcf..548bcbd54 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -30,6 +30,10 @@ export type InsertStatus = 'inserted' | 'pasteSent' | 'copiedFallback' | 'failed export interface ActivityDay { date: string; count: number; + /** 当日最终插入文本的总字符数。升级前写入的日期没有这个字段(读作 0)。 */ + chars?: number; + /** 当日录音总时长(毫秒)。升级前写入的日期没有这个字段(读作 0)。 */ + durationMs?: number; } export interface DictationSession { diff --git a/openless-all/app/src/lib/unicode.test.ts b/openless-all/app/src/lib/unicode.test.ts new file mode 100644 index 000000000..2822f4b1b --- /dev/null +++ b/openless-all/app/src/lib/unicode.test.ts @@ -0,0 +1,18 @@ +import { countCodePoints } from './unicode'; + +function assert(condition: boolean, message: string) { + if (!condition) throw new Error(message); +} + +// 口径与后端 Rust `polished.chars().count()`(Unicode 标量值)一致: +// ASCII / CJK 按字计,emoji 与 CJK 扩展 B 等增补平面字符不得被 UTF-16 码元双算。 +assert(countCodePoints('') === 0, 'empty string should count 0'); +assert(countCodePoints('hello') === 5, 'ASCII code points'); +assert(countCodePoints('你好,世界') === 5, 'CJK code points'); +assert(countCodePoints('😀') === 1, 'emoji surrogate pair must count as 1, not 2'); +assert(countCodePoints('😀a') === 2, 'emoji + ASCII'); +assert(countCodePoints('𠮷') === 1, 'CJK Extension B (surrogate pair) must count as 1'); +assert( + countCodePoints('e\u0301') === 2, + 'combining marks count per code point, matching Rust chars()', +); diff --git a/openless-all/app/src/lib/unicode.ts b/openless-all/app/src/lib/unicode.ts new file mode 100644 index 000000000..3e8d3d7b5 --- /dev/null +++ b/openless-all/app/src/lib/unicode.ts @@ -0,0 +1,11 @@ +// 按 Unicode 码点(标量值)计数字符数。 +// +// `String.prototype.length` 按 UTF-16 码元计数,emoji / CJK 扩展 B 等增补平面字符 +// 会被双算;后端 Rust `polished.chars().count()` 按 Unicode 标量值计数,两者必须对齐, +// 否则概览页「字数」指标、历史详情页「N 字」与后端 activity 聚合会各说各话。 +// `Array.from(text).length` 按码点切分(对合法 UTF-16 文本即等于标量值数),与 Rust +// `chars()` 同口径。注意这是码点数、不是字素簇数——组合字符(如 e + U+0301) +// 会计成 2 个,与后端一致。 +export function countCodePoints(text: string): number { + return Array.from(text).length; +} diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 2bfa18c95..282479161 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -10,6 +10,7 @@ import { formatComboLabel } from '../lib/hotkey'; import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording, retranscribeRecording, isTauri } from '../lib/ipc'; import { useMobileLayout } from '../lib/useMobileLayout'; import type { DictationSession, PolishMode } from '../lib/types'; +import { countCodePoints } from '../lib/unicode'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; import { chipSelectedStyle } from './settings/shared'; @@ -473,7 +474,9 @@ export function History() { {t('history.stepInsert')} {item.appName && <>{item.appName}{' · '}} - {t('history.chars', { count: item.finalText.length })} + {/* 按 Unicode 码点计(emoji / CJK 扩展 B 等增补平面字符不按 UTF-16 码元双算), + 与后端 `polished.chars().count()` 及概览页「字数」口径一致。 */} + {t('history.chars', { count: countCodePoints(item.finalText) })} {item.dictionaryEntryCount != null && item.dictionaryEntryCount > 0 && ( <>{' · '}{t('history.vocabHits', { count: item.dictionaryEntryCount })} )} diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index e0a12313b..587463539 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -7,6 +7,14 @@ import { formatComboLabel } from '../lib/hotkey'; import { getActivityStats, getCredentials, listHistory } from '../lib/ipc'; import { Heatmap } from '../components/Heatmap'; import { useMobileLayout } from '../lib/useMobileLayout'; +import { countCodePoints } from '../lib/unicode'; +import { + ACTIVITY_METRICS, + ACTIVITY_PERIODS, + buildPeriodSeries, + type ActivityMetric, + type ActivityPeriod, +} from '../lib/activityMetrics'; import type { ActivityDay, CredentialsStatus, DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; @@ -64,29 +72,51 @@ export function Overview({ onOpenHistory }: OverviewProps) { }); const { prefs } = useHotkeySettings(); const credentialsRequestSeq = useRef(0); + const historyRequestSeq = useRef(0); + const activityRequestSeq = useRef(0); const refreshHistory = useCallback(() => { + const requestSeq = historyRequestSeq.current + 1; + historyRequestSeq.current = requestSeq; setHistoryError(false); listHistory() - .then(setHistory) + .then(entries => { + if (requestSeq !== historyRequestSeq.current) return; + setHistory(entries); + }) .catch(error => { + if (requestSeq !== historyRequestSeq.current) return; console.error('[overview] failed to load history', error); setHistoryError(true); }); }, []); - // 年度活动热力图数据(独立于历史内容存储,清空历史不影响)。加载失败仅隐藏卡片。 - // 移动端跳过 IPC 与渲染(issue #861):热力图横向宽度固定,窄屏易溢出并拖慢 WebView。 + // 活动数据(独立于历史内容存储,清空历史不影响):年度热力图 + 近 7/30 天指标共用。 + // 加载失败仅隐藏对应卡片。 + // + // 热力图在移动端不渲染(issue #861:横向宽度固定,窄屏易溢出并拖慢 WebView),但 + // 周期指标卡是要渲染的,所以 IPC 不能再按 mobile 跳过 —— 否则移动端周期卡永远空。 const [activity, setActivity] = useState(null); - useEffect(() => { - if (mobile) return; + const [activityError, setActivityError] = useState(false); + const refreshActivity = useCallback(() => { + const requestSeq = activityRequestSeq.current + 1; + activityRequestSeq.current = requestSeq; + setActivityError(false); getActivityStats() - .then(setActivity) + .then(stats => { + if (requestSeq !== activityRequestSeq.current) return; + setActivity(stats); + }) .catch(error => { + if (requestSeq !== activityRequestSeq.current) return; console.error('[overview] failed to load activity stats', error); setActivity(null); + setActivityError(true); }); - }, [mobile]); + }, []); + useEffect(() => { + refreshActivity(); + }, [refreshActivity]); const refreshCredentials = useCallback(() => { const requestSeq = credentialsRequestSeq.current + 1; @@ -111,7 +141,27 @@ export function Overview({ onOpenHistory }: OverviewProps) { useEffect(() => { refreshCredentials(); - }, [refreshCredentials, prefs?.activeAsrProvider, prefs?.activeLlmProvider]); + }, [refreshCredentials, prefs?.activeLlmProvider, prefs?.activeAsrProvider]); + + // ⌘R / Ctrl+R 重新拉取本页的三份数据(历史、活动、凭据),与历史页同键同语义。 + // preventDefault 拦掉 webview 默认的整页 reload,避免整个前端重挂载。 + // 此前概览页没有刷新入口,用户只能切到别的页再切回来才能看到新数据。 + const refreshAll = useCallback(() => { + refreshHistory(); + refreshActivity(); + refreshCredentials(); + }, [refreshHistory, refreshActivity, refreshCredentials]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && (e.key === 'r' || e.key === 'R')) { + e.preventDefault(); + refreshAll(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [refreshAll]); // 凭据被保存后重新拉取状态(issue #532 / #573:在 Settings 中填写/更新凭据 // 但不切换提供商时,上面的 useEffect 不会重跑,导致概览页的状态仍停留在「未配置」)。 @@ -145,27 +195,24 @@ export function Overview({ onOpenHistory }: OverviewProps) { const today = new Date(); today.setHours(0, 0, 0, 0); const todays = history.filter(s => new Date(s.createdAt) >= today); - const charsToday = todays.reduce((acc, s) => acc + s.finalText.length, 0); + const charsToday = todays.reduce((acc, s) => acc + countCodePoints(s.finalText), 0); const segmentsToday = todays.length; const totalDurationMs = todays.reduce((acc, s) => acc + (s.durationMs ?? 0), 0); const avgLatencyMs = segmentsToday > 0 ? totalDurationMs / segmentsToday : 0; return { charsToday, segmentsToday, totalDurationMs, avgLatencyMs }; }, [history]); - // 周历:过去 7 天每天的条数 - const weekly = useMemo(() => { - const buckets = Array(7).fill(0); - const today = new Date(); - today.setHours(0, 0, 0, 0); - history.forEach(s => { - const d = new Date(s.createdAt); - const diff = Math.floor((today.getTime() - d.setHours(0, 0, 0, 0)) / 86400000); - if (diff >= 0 && diff < 7) { - buckets[6 - diff] += 1; - } - }); - return buckets; - }, [history]); + // 周期指标:近 7 天 / 近 30 天 × 条数 / 字数 / 时长。 + // + // 数据源必须是 activity 而不是 history —— history 有 200 条硬上限,日均上百次的用户 + // 两三天就把上周挤没了,按历史现算会把没数据的那几天画成 0(而同一页的年度热力图 + // 上那几天明明是亮的,两块数据自相矛盾)。 + const [period, setPeriod] = useState(7); + const [metric, setMetric] = useState('count'); + const series = useMemo( + () => buildPeriodSeries(activity ?? [], period, metric), + [activity, period, metric], + ); const asrProviderId = creds.activeAsrProvider || 'volcengine'; const llmProviderId = creds.activeLlmProvider || 'ark'; @@ -221,22 +268,14 @@ export function Overview({ onOpenHistory }: OverviewProps) {
{/* overflow:hidden:窗口过小时这一行 flex:1 会被压到比内容还矮,柱状图(固定高) 原本会溢出卡片圆角外(issue #782)。裁进卡片内,与右侧「最近识别」卡片一致。 */} - -
- {t('overview.weekTitle')} - {t('overview.weekUnit')} -
- {historyError ? ( -
- {t('overview.historyLoadError')} -
- ) : ( - - )} -
- {weekDayLabels(t('overview.weekDays', { returnObjects: true }) as string[]).map((d, i) => {d})} -
-
+
@@ -384,33 +423,223 @@ function Metric({ icon, label, value, trend, accent }: MetricProps) { ); } -function WeekChart({ data }: { data: number[] }) { - const max = Math.max(...data, 1); +/** 分段切换器(周期 / 指标共用)。窄,一行放得下两组。 */ +function SegmentedToggle({ + value, + options, + onChange, + ariaLabel, +}: { + value: T; + options: Array<{ value: T; label: string }>; + onChange: (next: T) => void; + ariaLabel: string; +}) { return ( -
- {data.map((v, i) => { - const isToday = i === 6; +
+ {options.map(option => { + const selected = option.value === value; return ( -
-
{v}
-
-
+ ); })}
); } +/** + * 周期指标卡:近 7 天 / 近 30 天 × 条数 / 字数 / 时长。 + * + * 卡片顶部显示周期总计(大字)+ 日均,柱状图在下 —— 用户关心的「这个月总共说了多少字」 + * 是一个数,不是要在 30 根柱子里目测求和。 + */ +function PeriodMetricsCard({ + series, + period, + metric, + onPeriodChange, + onMetricChange, + loadError, +}: { + series: ReturnType; + period: ActivityPeriod; + metric: ActivityMetric; + onPeriodChange: (next: ActivityPeriod) => void; + onMetricChange: (next: ActivityMetric) => void; + loadError: boolean; +}) { + const { t } = useTranslation(); + const periodOptions = ACTIVITY_PERIODS.map(days => ({ + value: days, + label: t(`overview.period.last${days}Days`), + })); + const metricOptions = ACTIVITY_METRICS.map(id => ({ + value: id, + label: t(`overview.metricName.${id}`), + })); + + return ( + + {/* flexWrap:卡片在 1fr 列里较窄,两组切换器放不下时换行而不是压扁按钮。 */} +
+ + +
+ + {loadError ? ( +
+ {t('overview.activityLoadError')} +
+ ) : ( + <> +
+
+ {formatMetricValue(series.total, metric, t)} +
+
+ {t('overview.period.dailyAverage', { + value: formatMetricValue(series.dailyAverage, metric, t), + })} +
+
+ + + )} +
+ ); +} + +/** 柱状图。7 天时每根柱子上标数值;30 天时柱子只有几像素宽,标了会糊成一片, + * 改用 title 悬浮显示,并只在两端和中间标日期。 */ +function PeriodChart({ + series, + metric, +}: { + series: ReturnType; + metric: ActivityMetric; +}) { + const { t } = useTranslation(); + const { buckets } = series; + const max = Math.max(...buckets.map(b => b.value), 1); + const dense = buckets.length > 7; + const lastIndex = buckets.length - 1; + const midIndex = Math.floor(lastIndex / 2); + + return ( + <> +
+ {buckets.map((bucket, i) => { + const isToday = i === lastIndex; + return ( +
+ {!dense && ( +
+ {formatMetricValue(bucket.value, metric, t)} +
+ )} +
+
+ ); + })} +
+
+ {dense + ? [0, midIndex, lastIndex].map(i => {shortDateLabel(buckets[i].date)}) + : buckets.map(bucket => {weekDayLabel(bucket.date, t('overview.weekDays', { returnObjects: true }) as string[])})} +
+ + ); +} + +/** `YYYY-MM-DD` → `M/D`。日期键是后端按本地日历写的,直接切字符串即可, + * 不要 new Date(key) —— 那会按 UTC 解析再转回本地,跨时区会差一天。 */ +function shortDateLabel(dateKey: string): string { + const [, month, day] = dateKey.split('-'); + return `${Number(month)}/${Number(day)}`; +} + +function weekDayLabel(dateKey: string, names: string[]): string { + const [year, month, day] = dateKey.split('-').map(Number); + return names[new Date(year, month - 1, day).getDay()]; +} + +/** 条数/字数按整数千分位显示;时长转成人类可读的时/分/秒。 */ +function formatMetricValue( + value: number, + metric: ActivityMetric, + t: ReturnType['t'], +): string { + if (metric === 'duration') return formatLongDuration(value, t); + return Math.round(value).toLocaleString(); +} + +/** 周期总时长可能是几十小时,不能沿用只处理秒/分的 formatDuration。 */ +function formatLongDuration(ms: number, t: ReturnType['t']): string { + if (ms <= 0) return '0'; + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return t('common.durationSeconds', { value: totalSeconds }); + const totalMinutes = Math.floor(totalSeconds / 60); + if (totalMinutes < 60) return t('overview.period.minutes', { value: totalMinutes }); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return t('overview.period.hoursMinutes', { hours, minutes }); +} + function RecentRow({ session, modeLabel }: { session: DictationSession; modeLabel: Record }) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); @@ -473,12 +702,3 @@ function formatDuration(ms: number, t: ReturnType['t']): if (sec < 60) return t('common.durationSeconds', { value: sec.toFixed(1) }); return `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`; } - -function weekDayLabels(names: string[]): string[] { - const today = new Date().getDay(); - const out: string[] = []; - for (let i = 6; i >= 0; i--) { - out.push(names[(today - i + 7) % 7]); - } - return out; -}