feat(overview): 「近 7 天」脱离历史 200 条上限,并支持近 7/30 天 × 条数/字数/时长切换 (#909)

* feat(activity): 每日活动汇总记录字数与时长

activity.json 此前每天只存一个条数。概览页想给出「近 30 天说了多少字」
这类周期指标时,只能回头从 list_history() 现算 —— 而历史有 200 条硬上限,
日均上百次的用户两三天就把上周挤没了,算出来的字数与时长必然偏低。

把单日值从裸数字扩成 {count, chars, durationMs}:
- chars 口径 = 最终插入文本的 Unicode 字符数,与历史详情页的「N 字」一致;
- durationMs 口径 = 录音时长,不含识别/润色耗时,与详情页「录音 x.x 秒」同源。
两个口径都刻意跟已有 UI 对齐,避免同一个数在两处对不上。

磁盘格式用 untagged enum 兼容旧文件:老用户的 activity.json 全是裸数字,
读不回来的话年度热力图会一次性清空(会被当成数据丢失)。旧日期没有字数/
时长,读回 0 —— 诚实缺省,好过整天丢掉条数。四个单测钉住这个契约:纯旧
格式、纯新格式、新旧混排(升级当天必然出现)、以及缺字段的对象。

存储代价可忽略:只存聚合数字、不存文本,一天一行,两年上限 731 行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(overview): 「近 7 天」改为周期 × 指标可切换,并脱离历史 200 条上限

两件事,同一张卡:

1. 修数据源。「近 7 天」此前从 list_history() 现算,而历史有 200 条硬上限。
   实测(日均约 88 次听写的真实数据):history.json 正好卡在 200 条,只装得下
   两天多,于是近 7 天里前 4 天全画成 0 —— 而同一页的年度热力图上那 4 天分别是
   40 / 118 / 108 / 37,亮着的。同一个页面两块数据自相矛盾。改读 activity 后
   两者同源,柱子和格子对得上。

2. 加周期与指标切换。卡片标题位变成「近 7 天 / 近 30 天」,右侧原来的「条数 /
   天」变成「条数 / 字数 / 时长」。月度字数是用户实际想看的数(跟按月计费的
   竞品比较时要用),此前完全看不到。

卡片顶部显示周期总计(大字)+ 日均:想知道「这个月总共说了多少字」是要一个数,
不是在 30 根柱子里目测求和。30 天模式下柱子只有几像素宽,逐柱数字会糊成一片,
改为 title 悬浮显示、横轴只标首/中/末三个日期。

聚合逻辑抽成 lib/activityMetrics.ts 并带单测:窗口长度恒定、缺失日期补 0、
跨月边界、老数据无 chars/durationMs 时不产生 NaN(NaN 会把柱状图的 max 算坏)。
日期键一律用本地年月日拼,不能用 toISOString() —— 后者按 UTC 切日,东八区凌晨
的会话会算到前一天,与后端 chrono::Local 写的键对不上。

顺带:活动数据的 IPC 不再按 mobile 跳过。热力图在移动端仍不渲染(issue #861),
但周期指标卡是要渲染的,跳过 IPC 会让它在移动端永远空。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(overview): 概览页支持 ⌘R / Ctrl+R 刷新

历史页早就有这个键(History.tsx),概览页没有 —— 想看到新数据只能切到别的
页再切回来触发重挂载。⌘R 是「重新加载」的通用直觉,两个数据页应该一致。

一次刷新本页全部三份数据:历史、活动、凭据。preventDefault 拦掉 webview
默认的整页 reload,只重发 IPC,不让整个前端重挂载。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(history): 详情页字数按 Unicode 码点统计,与概览页/后端口径一致

finalText.length 按 UTF-16 码元计数,emoji / CJK 扩展 B 等增补平面字符
会被双算;后端 activity 的 chars 用 polished.chars().count()(Unicode 标量),
概览页「字数」指标也以此为准。改用 Array.from(...).length 后三处同口径。

* fix(overview): 今日字数按 Unicode 码点统计,与历史详情/后端口径统一

概览页顶部「今日字数」此前用 finalText.length 按 UTF-16 码元计数,emoji / CJK 扩展 B 等增补平面字符会被双算;历史详情页(commit 70464a19)与后端 activity 的 chars 已是 Unicode 标量口径。抽成 lib/unicode.ts 的 countCodePoints 让两处前端共用,消除第三处口径漂移。

* fix(overview): 历史与活动刷新加请求序号防竞态

⌘R 一次触发历史、活动、凭据三份刷新,历史与活动此前没有 credentialsRequestSeq 那样的防竞态:快速连按可能让较早的慢响应后到并覆盖新数据。沿用同一模式,请求前自增序号、响应校验一致才 setState。

* style(overview): 分段切换器按钮光标改 pointer

SegmentedToggle 是可点击按钮,与 _atoms 中 Btn 的 cursor: pointer 交互约定保持一致(此前为 default,看起来像不可点)。

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-08-05 23:44:32 +08:00
1 parent 115680cd4c
commit 27198b9b2f
17 files changed
+737 -98

No files matched your search

@@ -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()
}
@@ -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}");
}
@@ -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<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 {
@@ -27,7 +68,11 @@ impl ActivityStore {
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),
@@ -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<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
}
);
}
}
+9 -1
View File
@@ -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)]
+15 -2
View File
@@ -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.',
+15 -2
View File
@@ -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}} を押して最初の録音を始めましょう。',
+15 -2
View File
@@ -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}} 를 눌러 첫 녹음을 시작하세요.',
+15 -2
View File
@@ -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}} 开始第一次录音。',
+15 -2
View File
@@ -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}} 開始第一次錄音。',
@@ -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}`);
@@ -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<string, ActivityDay>();
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 };
}
+14 -1
View File
@@ -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
})()
+4
View File
@@ -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 {
+18
View File
@@ -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()',
);
+11
View File
@@ -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;
}
+4 -1
View File
@@ -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() {
<span>{t('history.stepInsert')}</span>
<span style={{ color: 'var(--ol-ink-2)' }}>
{item.appName && <><b>{item.appName}</b>{' · '}</>}
{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 })}</>
)}
+287 -67
View File
@@ -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<ActivityDay[] | null>(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<ActivityPeriod>(7);
const [metric, setMetric] = useState<ActivityMetric>('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) {
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1fr 1.4fr', gap: 12, flex: mobile ? undefined : 1, minHeight: mobile ? undefined : 0 }}>
{/* overflow:hidden:窗口过小时这一行 flex:1 会被压到比内容还矮,柱状图(固定高)
原本会溢出卡片圆角外(issue #782)。裁进卡片内,与右侧「最近识别」卡片一致。 */}
<Card padding={18} style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>{t('overview.weekTitle')}</span>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>{t('overview.weekUnit')}</span>
</div>
{historyError ? (
<div style={{ height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
{t('overview.historyLoadError')}
</div>
) : (
<WeekChart data={weekly} />
)}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: 'var(--ol-ink-4)', marginTop: 8 }}>
{weekDayLabels(t('overview.weekDays', { returnObjects: true }) as string[]).map((d, i) => <span key={i}>{d}</span>)}
</div>
</Card>
<PeriodMetricsCard
series={series}
period={period}
metric={metric}
onPeriodChange={setPeriod}
onMetricChange={setMetric}
loadError={activityError}
/>
<Card padding={0} style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
<div style={{ padding: '14px 18px', borderBottom: '0.5px solid var(--ol-line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
@@ -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<T extends string | number>({
value,
options,
onChange,
ariaLabel,
}: {
value: T;
options: Array<{ value: T; label: string }>;
onChange: (next: T) => void;
ariaLabel: string;
}) {
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8, height: 100 }}>
{data.map((v, i) => {
const isToday = i === 6;
<div
role="group"
aria-label={ariaLabel}
style={{
display: 'flex',
gap: 2,
padding: 2,
borderRadius: 8,
background: 'var(--ol-surface-2)',
border: '0.5px solid var(--ol-line)',
}}
>
{options.map(option => {
const selected = option.value === value;
return (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<div style={{ fontSize: 9.5, color: isToday ? 'var(--ol-blue)' : 'var(--ol-ink-4)', fontWeight: isToday ? 600 : 400 }}>{v}</div>
<div
style={{
width: '100%',
height: `${(v / max) * 80}px`,
minHeight: 2,
borderRadius: 4,
background: isToday ? 'var(--ol-blue)' : 'var(--ol-ink-4)',
opacity: v === 0 ? 0.15 : isToday ? 1 : 0.85,
transition: 'height 0.18s var(--ol-motion-soft), opacity 0.18s var(--ol-motion-soft)',
}}
/>
</div>
<button
key={String(option.value)}
type="button"
aria-pressed={selected}
onClick={() => onChange(option.value)}
style={{
padding: '3px 9px',
fontSize: 11,
fontWeight: selected ? 600 : 500,
border: 0,
borderRadius: 6,
background: selected ? 'var(--ol-blue)' : 'transparent',
color: selected ? '#fff' : 'var(--ol-ink-3)',
cursor: 'pointer',
fontFamily: 'inherit',
whiteSpace: 'nowrap',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
}}
>
{option.label}
</button>
);
})}
</div>
);
}
/**
* 周期指标卡:近 7 天 / 近 30 天 × 条数 / 字数 / 时长。
*
* 卡片顶部显示周期总计(大字)+ 日均,柱状图在下 —— 用户关心的「这个月总共说了多少字」
* 是一个数,不是要在 30 根柱子里目测求和。
*/
function PeriodMetricsCard({
series,
period,
metric,
onPeriodChange,
onMetricChange,
loadError,
}: {
series: ReturnType<typeof buildPeriodSeries>;
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 (
<Card padding={18} style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
{/* flexWrap:卡片在 1fr 列里较窄,两组切换器放不下时换行而不是压扁按钮。 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
<SegmentedToggle
value={period}
options={periodOptions}
onChange={onPeriodChange}
ariaLabel={t('overview.period.ariaLabel')}
/>
<SegmentedToggle
value={metric}
options={metricOptions}
onChange={onMetricChange}
ariaLabel={t('overview.metricName.ariaLabel')}
/>
</div>
{loadError ? (
<div style={{ height: 132, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
{t('overview.activityLoadError')}
</div>
) : (
<>
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 26, fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ol-ink)', lineHeight: 1.1 }}>
{formatMetricValue(series.total, metric, t)}
</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 5 }}>
{t('overview.period.dailyAverage', {
value: formatMetricValue(series.dailyAverage, metric, t),
})}
</div>
</div>
<PeriodChart series={series} metric={metric} />
</>
)}
</Card>
);
}
/** 柱状图。7 天时每根柱子上标数值;30 天时柱子只有几像素宽,标了会糊成一片,
* 改用 title 悬浮显示,并只在两端和中间标日期。 */
function PeriodChart({
series,
metric,
}: {
series: ReturnType<typeof buildPeriodSeries>;
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 (
<>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: dense ? 2 : 8, height: 100 }}>
{buckets.map((bucket, i) => {
const isToday = i === lastIndex;
return (
<div
key={bucket.date}
title={`${bucket.date} · ${formatMetricValue(bucket.value, metric, t)}`}
style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}
>
{!dense && (
<div style={{ fontSize: 9.5, color: isToday ? 'var(--ol-blue)' : 'var(--ol-ink-4)', fontWeight: isToday ? 600 : 400 }}>
{formatMetricValue(bucket.value, metric, t)}
</div>
)}
<div
style={{
width: '100%',
height: `${(bucket.value / max) * 80}px`,
minHeight: 2,
borderRadius: dense ? 2 : 4,
background: isToday ? 'var(--ol-blue)' : 'var(--ol-ink-4)',
opacity: bucket.value === 0 ? 0.15 : isToday ? 1 : 0.85,
transition: 'height 0.18s var(--ol-motion-soft), opacity 0.18s var(--ol-motion-soft)',
}}
/>
</div>
);
})}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: 'var(--ol-ink-4)', marginTop: 8 }}>
{dense
? [0, midIndex, lastIndex].map(i => <span key={i}>{shortDateLabel(buckets[i].date)}</span>)
: buckets.map(bucket => <span key={bucket.date}>{weekDayLabel(bucket.date, t('overview.weekDays', { returnObjects: true }) as string[])}</span>)}
</div>
</>
);
}
/** `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<typeof useTranslation>['t'],
): string {
if (metric === 'duration') return formatLongDuration(value, t);
return Math.round(value).toLocaleString();
}
/** 周期总时长可能是几十小时,不能沿用只处理秒/分的 formatDuration。 */
function formatLongDuration(ms: number, t: ReturnType<typeof useTranslation>['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<PolishMode, string> }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
@@ -473,12 +702,3 @@ function formatDuration(ms: number, t: ReturnType<typeof useTranslation>['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;
}