Skip to content
12 changes: 9 additions & 3 deletions openless-all/app/src-tauri/src/commands/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActivityDay> {
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()
}

Expand Down
16 changes: 10 additions & 6 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3934,12 +3934,16 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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}");
}

Expand Down
142 changes: 133 additions & 9 deletions openless-all/app/src-tauri/src/persistence/activity.rs
Original file line number Diff line number Diff line change
@@ -1,33 +1,78 @@
//! 每日听写活动计数(`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<StoredDay> 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<BTreeMap<String, u32>>,
cache: Mutex<BTreeMap<String, DayStats>>,
}

impl ActivityStore {
pub fn load() -> Result<Self> {
let dir = data_dir()?;
ensure_dir(&dir)?;
let path = dir.join(ACTIVITY_FILE);
let cache: BTreeMap<String, u32> = read_or_default(&path)?;
let stored: BTreeMap<String, StoredDay> = read_or_default(&path)?;
let cache = stored
.into_iter()
.map(|(date, day)| (date, day.into()))
.collect();
Ok(Self {
path,
cache: Mutex::new(cache),
Expand All @@ -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(),
Expand All @@ -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<String, StoredDay> = serde_json::from_slice(json).unwrap();
let parsed: BTreeMap<String, DayStats> =
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<String, DayStats> = 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<String, StoredDay> = serde_json::from_slice(&bytes).unwrap();
let parsed: BTreeMap<String, DayStats> =
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<String, StoredDay> = serde_json::from_slice(json).unwrap();
let parsed: BTreeMap<String, DayStats> =
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<String, StoredDay> = serde_json::from_slice(json).unwrap();
let parsed: BTreeMap<String, DayStats> =
stored.into_iter().map(|(k, v)| (k, v.into())).collect();

assert_eq!(
parsed["2026-08-04"],
DayStats {
count: 2,
chars: 0,
duration_ms: 0
}
);
}
}
10 changes: 9 additions & 1 deletion openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
17 changes: 15 additions & 2 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
17 changes: 15 additions & 2 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}} を押して最初の録音を始めましょう。',
Expand Down
17 changes: 15 additions & 2 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}} 를 눌러 첫 녹음을 시작하세요.',
Expand Down
17 changes: 15 additions & 2 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}} 开始第一次录音。',
Expand Down
17 changes: 15 additions & 2 deletions openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}} 開始第一次錄音。',
Expand Down
Loading
Loading