ArkTS + HarmonyOS 专注统计:七日归组与跨日口径(连载04/06)【共创稿事节】
打开 FocusGarden(专注花园)的“专注记录”,能看到每次完成专注的时间和任务标签,但如果想回答一个更日常的问题——“过去七天,哪几天真正完成了专注?”——原来的历史列表就显得不够直观。把最近七条记录写成“最近七天”也会得出错误结论:某天完成了三次,另一天一次没有,列表依旧可能只有七条。本篇从会话源数据计算每日汇总,刻意不再引入另一套可变的累计计数。

前文的计时器依旧使用 fullSec、remainingSec、running 和 deadlineMs 纠正倒计时;会话落盘仍放在 Preferences 文件 focusgarden_timer_v2 的 session_bundle_v3 键。SessionEntry 里的 id 负责去重,startedAtMs、endedAtMs 记录时刻,plannedSec 是用户原先设定的计划秒数,label 则是本次专注所属任务。这些字段没有“真实有效学习时长”之类的属性,所以统计必须遵守原始语义,不能把计划分钟直接宣传为实际专注分钟。
一、先定统计口径,再开始画柱状图
“今天完成一次”到底按什么时间算?一段专注可能在 23:50 开始,次日 00:15 结束。如果按开始日统计,跨日会话落在前一天;按结束日则落在第二天。两者都可用于不同产品,但同一张图不能来回切换口径。FocusGarden 的目标是展示哪天完成了专注,因此选择 endedAtMs 所在设备本地日历日作为归属日。这是一条产品约定,不能简单等同于服务器所在地日期或用户跨时区旅行后的历史原始日历日。
时间窗口定义为包含今天在内的最近七个日历日期。不能用 Date.now() - 7 * 24 * 60 * 60 * 1000 来创建七个标签:跨越夏令时切换时,一个本地日不一定恰好是 24 小时。我们用 new Date(year, month, day - offset) 生成每天的本地零点,由 JavaScript 日期对象处理月份、闰年和日期跨界。然后用 getFullYear()、getMonth()、getDate() 组合不带时分秒的日期键 YYYY-MM-DD,保证“11月3日”和“11月13日”不会碰巧匹配。
统计结果只从已完成的 entries: SessionEntry[] 派生,而不重新计算 completedSessions:第三篇之前的计时次数可能没有对应历史条目,而且保留的历史上限为 100 条。某天没有记录,在图中显示零次;没有历史数据时,显示“本机暂无可统计会话”,不拿总完成数去填补空白。这个区别决定了图表是否可信。
二、把日期计算从页面布局中抽离
先增加一个不依赖 ArkUI 的纯计算文件 entry/src/main/ets/common/DailySummary.ets。它接收既有 SessionEntry 数组,输出一组稳定的 DailyStat,便于单独输入测试数据验证跨月和跨日。没有复杂新系统接口,也不需要日历权限;这里的 Date、Map 和 Array 都是 ArkTS 可使用的基本语言能力。
// entry/src/main/ets/common/DailySummary.ets
import { SessionEntry } from './SessionModel';
export interface DailyStat {
dayKey: string;
display: string;
count: number;
plannedMinutes: number;
}
export function localDayKey(ms: number): string {
const d: Date = new Date(ms);
const y: string = String(d.getFullYear());
const m: string = String(d.getMonth() + 1).padStart(2, '0');
const day: string = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function sevenDays(entries: SessionEntry[], nowMs: number): DailyStat[] {
const today: Date = new Date(nowMs);
const result: DailyStat[] = [];
for (let offset: number = 6; offset >= 0; offset--) {
const day: Date = new Date(today.getFullYear(),
today.getMonth(), today.getDate() - offset);
const dayKey: string = localDayKey(day.getTime());
const matches: SessionEntry[] = entries.filter((e: SessionEntry) =>
Number.isFinite(e.endedAtMs) && localDayKey(e.endedAtMs) === dayKey);
const sumSeconds: number = matches.reduce(
(sum: number, e: SessionEntry) => sum + Math.max(0, e.plannedSec), 0);
result.push({ dayKey, display: `${day.getMonth() + 1}/${day.getDate()}`,
count: matches.length, plannedMinutes: Math.round(sumSeconds / 60) });
}
return result;
}
这里把日期比较写成明确的字符串相等,虽然每次筛选都要遍历数组,但本地历史最多保留 100 条,一次展示只有七个日历日,此时优先保证可读性更合适。如果未来记录增长到数万条,可以先用 Map<string, DailyStat> 做一次索引,再一次填充七日结果;那时还应使用数据库存储,而不是继续扩张 Preferences 中的 JSON 数组。
plannedMinutes 采用所有已完成会话的 plannedSec 相加后四舍五入;这是完成会话的计划时长总和,不是后台真实运行的精确时长。因为计时器使用截止时刻而非定时器触发次数计算剩余秒数,完成逻辑可以在重新回到前台时补结算,但当前模型不记录暂停期间每个活动片段的有效时长。以后如需统计净专注时间,应在会话模型中增加实际累计字段,再设计迁移策略。
三、让七天统计跟随既有记录,而不是另存一份报表
为了避免“历史列表已经更新但图表还是昨天的数据”,页面不再维护 dailyStats 作为另一个可写入的 @State 数组。它是一个计算属性,只读取第三篇维护的 @State entries。用户切换到“七日统计”时页面重新取当前时间计算,计时结算后 entries 更新也会进入同一条数据路径。
// entry/src/main/ets/pages/Index.ets:加入统计页面的成员
// import { sevenDays, DailyStat } from '../common/DailySummary';
@State private showStats: boolean = false;
@State private todayAnchor: number = Date.now();
private get dailyStats(): DailyStat[] {
return sevenDays(this.entries, this.todayAnchor);
}
private get maxDayCount(): number {
return Math.max(1, ...this.dailyStats.map((d: DailyStat) => d.count));
}
private openStats(): void {
this.todayAnchor = Date.now();
this.showStats = true;
this.showHistory = false;
}
todayAnchor 看上去只是一个数字,实际上解决了夜间一直打开页面的日期滚动问题:组件在跨过午夜后并不必然自动知道日期已经变化,必须在可控时机更新基准。此处进入统计页时刷新;如果要让统计页跨零点自动翻日,可在页面可见期间安排轻量刷新,并在离开页面时清除计时器。不要为了每日一次的日期更新让不可见页面长期运行高频定时器。

柱形高度直接从 count / maxDayCount 映射,视觉化的是完成次数;文字保留具体数字,不能只让颜色承载结论。0 次 的日子显示短横线,非零日显示等比例竖柱。maxDayCount 至少为 1,可以避免七天全是零时出现除零异常。整体风格保留 FocusGarden 的浅绿植物感,却不把一排柱子画成生长高度暗示精确时长。
// 将下列片段放入原 build() 的主 Column 中
Row({ space: 10 }) {
Button('计时').onClick(() => { this.showStats = false; this.showHistory = false; })
Button('专注记录').onClick(() => { this.showStats = false; this.showHistory = true; })
Button('七日统计').onClick(() => this.openStats())
}
if (this.showStats) {
Text('最近七日 · 按完成日期').fontSize(24).fontWeight(FontWeight.Bold)
Text(`可统计记录 ${this.entries.length} 条`)
.fontSize(14).fontColor('#607967')
Row({ space: 8 }) {
ForEach(this.dailyStats, (day: DailyStat) => {
Column({ space: 8 }) {
Text(`${day.count}次`).fontSize(14)
Column() {
Blank().height(Math.max(6, day.count / this.maxDayCount * 160))
.width(22).backgroundColor(day.count > 0 ? '#4BAF83' : '#E0EAE3')
.borderRadius(6)
}.height(170).justifyContent(FlexAlign.End)
Text(day.display).fontSize(11).fontColor('#6C8374')
}.layoutWeight(1)
}, (day: DailyStat) => day.dayKey)
}.width('100%')
Text('柱高表示完成次数;计划分钟不等于有效专注时长')
.fontSize(12).fontColor('#69786D')
}
布局中的 160 是柱高设计值,单位为界面布局中的 vp,并非对性能或用户行为的测量结果。这里的 Blank 仅作为便于说明的填充块,正式项目可以换成自定义组件,增加选中日期、屏幕阅读器描述和窄屏横向滚动。七个小列在小屏幕里要特别检查文字是否压缩;如果文字过密,可以减少次级标签、保留完整数字或允许水平滑动,不要用无法辨认的微小字体来换取视觉整齐。
四、设计三组反例测试,守住统计解释的一致性
第一组验证跨日:构造一条前一天 23:50 开始、今天 00:15 完成的 SessionEntry,预期统计归到今天。第二组验证跨月:让今天是一个月的第一天,近七日标签应自然包含前月日期,且跨年时年份键仍正确。第三组验证重复与零值:给同一个 id 传入两条测试记录,数据层本应去重;图表会如实把两个对象算成两次,因而统计层不能成为修复会话重复的替代品。要回到第三篇的唯一完成入口保证 id 只追加一次。

示例记录可以使用“阅读”和“练字”两类任务标签,令昨天两次、今天一次,其余日期无记录;图中应显示昨天两次、今天一次,计划分钟分别按记录原始时长相加。这里的示例数字用于检查计算是否符合预期,不能在平台文章里写成真实用户的使用数据。改变设备时区后,历史时刻 endedAtMs 不变,但重新计算得到的本地日可能变化;如果产品需要跨设备固定日历归属,就应在完成时保存明确的时区标识或完成地的日历日期,并说明修改时区后的展示策略。
持久化方面,只读计算不增加新的 Preferences 写入。源数据继续由第三篇的 saveBundle() 在会话完成时写入 session_bundle_v3。由于 entries 目前最多保留最近 100 条,所以七日“0次”只表示本机保存的这批记录内为零,不能据此推出用户历史上完全没有专注过。尤其是完成过很多次的老用户,completedSessions 与 entries.length 不相等是合理现象。正式分析体系若需要全量历史和多端一致性,应该设计独立的数据同步与更适合查询的存储模型。
五、把可靠统计建立在可靠原始记录上
从代码维护角度看,本篇新增的真正资产不是七根柱子,而是有文档说明、可独立输入测试的 sevenDays()。它接受同一组已完成会话,明确按完成时刻归入本地日,输出可复核的日期键、完成次数和计划分钟。UI 只负责展示,不在页面渲染时修改历史,也不重新写入另一份累计值。官方 Preferences 参考 适用于这里保留的轻量本地快照;前后台切换的刷新边界可以参考 UIAbility 生命周期,避免在不可见阶段运行无谓的工作。
下一篇 FocusGarden 将面对更复杂的任务:用户导出历史、迁移格式和处理异常数据时,该怎样保持 id 稳定并防止统计口径改变。先把完成的定义写清楚,后续页面、图表乃至导出功能才会说同一种语言。
更多推荐


所有评论(0)