☰
Pomotroid 计时器时长键盘输入:从整分钟滑块到 MM:SS 精确设置的完整改造指南
2026/10/13 6:00:38 网站建设 项目流程

【免费下载链接】pomotroid

:tomato: Simple and visually-pleasing Pomodoro timer

项目地址:https://gitcode.com/gh_mirrors/po/pomotroid
点击查看免费下载

Pomotroid 是一款基于 Tauri(Rust + Svelte)的番茄钟应用。本篇技术指南围绕其Settings → Timer设置区的一次关键改造展开:将原先只能按整分钟步进的时长滑块,升级为支持MM:SS键盘输入的精确设置能力(timer-duration-keyboard-entry),并同步完成数据库存储从「分钟精度」到「秒精度」的无损迁移、滑块与输入框的双向同步、空闲态计时表盘即时刷新,以及统计视图的分钟取整修正。读完本文,你将掌握这套改造从需求到数据库迁移、前端交互与验证测试的完整实现脉络。

本文核心设计文档位于仓库的 2026-03-03-timer-settings-keyboard-entry/proposal.md,配套规格、设计文档与任务清单分别为同目录下的 specs/timer-duration-keyboard-entry/spec.md、specs/settings/spec.md、design.md 与 tasks.md。

一、改造背景:为什么滑块不够用

改造前的Settings → Timer区域,每个时长(Focus / Short Break / Long Break)旁边只显示一个静态时间徽章(如25:00),用户只能通过 1 分钟步进的 range slider 调整时长。这意味着:

  • 无法设置25:30、5:45这类带秒的精确时长;
  • 所有值都以整分钟为单位写入数据库,底层虽然已经存在以秒为单位的字段,却永远只能收到整分钟的值。

这正是 proposal 中Why一节描述的核心痛点:时长滑块只支持整分钟增量,把用户的番茄节奏强行限制在 1 分钟步长上。键盘输入MM:SS格式让用户对节奏拥有完全控制权,而不是被迫接受一分钟的步进。

二、需求与能力变更全景

proposal 的What Changes列出了本次改造的完整范围:

变更项说明
可编辑徽章每个滑块标签(Focus、Short Break、Long Break)旁的时长徽章变为可编辑输入框,接受MM:SS格式
交互方式点击徽章进入编辑态;按 Enter 或失焦(blur)即提交值
合法范围1:00(60 秒)~ 90:00(5400 秒),范围外数值在提交时被钳制(clamp)
秒级精度接受任意MM:SS值(如5:39),不再局限于整分钟
存储改造计时器时长设置改为以整秒存入数据库,而非整分钟
数据迁移新增数据库迁移,把既有分钟值转换为秒值

新增能力(New Capabilities)

  • timer-duration-keyboard-entry:在 Timer 设置区用内联可编辑的MM:SS输入框替代静态时间徽章,具备校验、钳制、Enter/失焦提交行为。

修改能力(Modified Capabilities)

  • settings:计时器时长的数据库键从分钟分辨率整数(time_work_mins、time_short_break_mins、time_long_break_mins)改为秒分辨率整数(time_work_secs、time_short_break_secs、time_long_break_secs),需要为存量用户提供迁移。

影响面(Impact)

  • TimerSection.svelte:新增可编辑徽章组件;滑块oninput继续以秒保存;
  • settings/mod.rs:数据库键名变更;Settings结构体字段类型不变(本就以秒为单位);
  • db/migrations.rs:新迁移将既有time_work_mins等行转换为新键下的秒值;
  • ipc/index.ts:数据库键常量更新;
  • types.ts:无需类型变更,字段本就命名为*_secs(见 types.ts 第 21-23 行)。

三、存储层改造:分钟键 → 秒键的数据库迁移

3.1 迁移决策与备选方案

design.md 的Decisions记录了三种被否决的备选方案:

  1. 保留*_mins键、存十进制分钟(如"5.65")——键名含义混乱,且有浮点解析风险;
  2. 新旧键并存、加载时回退——加载逻辑变得含糊,出现两个事实来源;
  3. 同键名原地更新——time_work_mins里存"339"会极其费解。

最终决策是:新增 MIGRATION_2 读取既有*_mins行、乘以 60 后写入*_secs行,再删除旧的*_mins行,让数据库键名与Settings结构体字段名一一对应,消除此前「字段是秒、存储是分」的阻抗失配。

3.2 MIGRATION_2 的完整 SQL

design.md 的 Migration Plan 给出了可直接落库的 SQL,且该 SQL 已原样实现于 db/migrations.rs:

INSERT OR IGNORE INTO settings (key, value) SELECT 'time_work_secs', CAST(CAST(value AS INTEGER) * 60 AS TEXT) FROM settings WHERE key = 'time_work_mins'; INSERT OR IGNORE INTO settings (key, value) SELECT 'time_short_break_secs', CAST(CAST(value AS INTEGER) * 60 AS TEXT) FROM settings WHERE key = 'time_short_break_mins'; INSERT OR IGNORE INTO settings (key, value) SELECT 'time_long_break_secs', CAST(CAST(value AS INTEGER) * 60 AS TEXT) FROM settings WHERE key = 'time_long_break_mins'; DELETE FROM settings WHERE key IN ('time_work_mins', 'time_short_break_mins', 'time_long_break_mins'); INSERT INTO schema_version VALUES (2);

要点解读:

  • INSERT OR IGNORE保证幂等性——即便旧键存在重复情况也不会覆盖已有数据;
  • CAST(value AS INTEGER) * 60把分钟值转为秒值,再CAST(... AS TEXT)写回 TEXT 类型的 SQLite 值列;
  • DELETE FROM settings WHERE key IN (...)删除全部旧键;
  • 最后写入schema_version = 2记录迁移版本。

在 migrations.rs 的 run() 中,通过版本判断if version < 2 { ... }触发该迁移,且每个迁移都包裹在BEGIN; ... COMMIT;事务中,部分失败时数据库保持不变。

3.3 默认值:以秒为单位播种

settings/defaults.rs 中,默认值从分钟键改为秒键:

("time_work_secs", "1500"), // 25:00 ("time_short_break_secs", "300"), // 5:00 ("time_long_break_secs", "900"), // 15:00

3.4 加载逻辑:去掉* 60

此前 Rust 的load()函数在读取分钟值时要做* 60换算;改造后 settings/mod.rs 的 load() 直接从*_secs键读取,不再有任何换算:

// DB stores seconds directly (since MIGRATION_2). time_work_secs: parse_u32(&map, "time_work_secs", d.time_work_secs), time_short_break_secs: parse_u32(&map, "time_short_break_secs", d.time_short_break_secs), time_long_break_secs: parse_u32(&map, "time_long_break_secs", d.time_long_break_secs),

Settings结构体中的time_work_secs、time_short_break_secs、time_long_break_secs三个字段本就是u32秒类型(见 settings/mod.rs),改造后字段语义与存储语义完全一致。缺失键或解析失败时回退到Settings::default()(默认 25/5/15 分钟),保证降级安全。

3.5 迁移验收场景

specs/settings/spec.md 定义了迁移的验收标准:

  • 存量用户无损迁移:升级前time_work_mins = "30",升级后time_work_secs应为"1800"且旧键被删除;
  • 新装默认播种:全新安装应包含time_work_secs = "1500"、time_short_break_secs = "300"、time_long_break_secs = "900",且不含任何*_mins键;
  • 亚分钟值跨重启持久化:设置 Focus 为 5:39(339 秒)并重启后,数据库time_work_secs应为"339";
  • 重置恢复秒级默认:触发 Reset All Settings 后表中应为time_work_secs = "1500",不含time_work_mins;
  • 旧键彻底清除:迁移后SELECT key FROM settings WHERE key LIKE '%_mins'应返回零行。

这些场景均有 Rust 测试对应实现:save_and_reload_sub_minute_time(settings/mod.rs)验证 339 秒的存取,migration_2_converts_mins_to_secs_and_removes_old_keys(settings/mod.rs)手工构造 v1 数据库并断言 30→1800、7→420、20→1200 的转换与旧键清零,reset_defaults_restores_all_settings(settings/mod.rs)验证重置后回到 25×60。

四、前端交互:可编辑 MM:SS 徽章

4.1 交互规格

specs/timer-duration-keyboard-entry/spec.md 明确定义了可编辑徽章的行为:

  • 展示:设置窗口打开时,每个计时器徽章以MM:SS显示存储时长(1500 s →25:00,339 s →5:39);
  • 进入编辑态:点击徽章,输入框获得焦点且文本被全选,便于直接覆盖输入;
  • Enter 提交:输入5:39按 Enter,保存为 339 秒并回显5:39;
  • 裸整数:输入25视为整分钟,保存 1500 秒并回显25:00;
  • 低于下限:解析结果不足 60 秒(如0:30)钳制为 60 秒(1:00);
  • 高于上限:解析结果超过 5400 秒(如91:00)钳制为 5400 秒(90:00);
  • 非法输入:无法解析(如abc、::)时保持原值并回显旧值;
  • 失焦提交:编辑后未按 Enter 直接点击他处,行为与按 Enter 完全一致(解析、钳制、保存);
  • Tab 提交:按 Tab 提交并移动焦点到下一个可聚焦元素。

design.md 的Decisions 3进一步明确了提交时接受的两种格式:

  • MM:SS——规范形式(如5:39、25:00、1:05);
  • 裸整数M或MM——按整分钟解释(如25→25:00)。

秒分量钳制到0–59,分钟分量保证MM * 60 + SS落在 60–5400 之间。任何不匹配上述模式或解析后越界的值,都会吸附到最近的合法边界,提交后输入框回显为格式化后的MM:SS。

4.2 源码实现:解析、格式化与提交

TimerSection.svelte 完整实现了这套交互,核心是三个函数与两套边界常量:

const MIN_SECS = 60; // 1:00 const MAX_SECS = 5400; // 90:00 /** Parse MM:SS or bare integer minutes. Returns total seconds, or null on failure. */ function parseMMSS(input: string): number | null { const trimmed = input.trim(); const colonIdx = trimmed.indexOf(':'); if (colonIdx === -1) { const mins = parseInt(trimmed, 10); if (isNaN(mins) || trimmed === '') return null; return mins * 60; } const mm = parseInt(trimmed.slice(0, colonIdx), 10); const ss = parseInt(trimmed.slice(colonIdx + 1), 10); if (isNaN(mm) || isNaN(ss) || ss < 0 || ss > 59) return null; return mm * 60 + ss; } /** Format total seconds as M:SS or MM:SS. */ function formatMMSS(totalSecs: number): string { const mins = Math.floor(totalSecs / 60); const secs = totalSecs % 60; return `${mins}:${String(secs).padStart(2, '0')}`; } /** Commit an edited badge value: parse, clamp, save. Reverts on invalid input. */ async function commitBadge(raw, currentSecs, dbKey, el) { if (raw === null) { el.value = formatMMSS(currentSecs); return; } const parsed = parseMMSS(raw); if (parsed === null) { el.value = formatMMSS(currentSecs); return; } const clamped = Math.max(MIN_SECS, Math.min(MAX_SECS, parsed)); await handleChange(dbKey, clamped); el.value = formatMMSS(clamped); }

行为细节:

  • 聚焦全选:onfocus中把当前值写入编辑态并把select()全选文本;
  • Enter / Escape:onkeydown中 Enter 走commitBadge后blur();Escape 则放弃编辑、回显格式化旧值并失焦(规格外的贴心兜底);
  • 失焦提交:onblur调用commitBadge;
  • 钳制提交:Math.max(MIN_SECS, Math.min(MAX_SECS, parsed))把结果约束在 1:00–90:00;
  • 保存链路:handleChange调用 ipc/index.ts 的 setSetting,即invoke('settings_set', { key, value }),Rust 侧 commands.rs 的 settings_set 完成写库、重载并广播事件。

4.3 样式:视觉上与静态徽章一致

改造后的输入框被刻意设计得与旧静态徽章视觉一致:等宽字体、var(--color-hover)背景、圆角 3px、width: 5ch、透明边框,仅在聚焦时切换边框与背景色(见 TimerSection.svelte 样式段),并在编辑时保留光标。Rounds 行仍使用静态徽章(.slider-value--static),因为它不需要键盘输入。

五、滑块与徽章的双向同步

5.1 规格要求

  • 滑块 → 徽章:拖动滑块仍按 1 分钟粒度工作,移动滑块更新徽章显示并以整分钟×60 秒保存;
  • 徽章 → 滑块:编辑徽章后,滑块拇指移动到最近整分钟。

5.2 同步算法

design.md 的Decisions 4给出两条规则:

  • Slider move → badge updates:按最近整分钟显示,向数据库保存「整分钟 × 60」秒;
  • Badge edit → slider updates:滑块位置为Math.round(totalSeconds / 60)(最近整分钟的可视位置),数据库保存精确总秒数。

由于滑块oninput与徽章提交各自独立触发,不存在双向循环风险。spec 中的验收场景:

  • 拖动 Focus 滑块到 30 → 徽章显示30:00,保存 1800 秒;
  • 输入5:39→ 滑块拇指移到位置 6(最近整分钟),保存 339 秒;
  • 输入25:00→ 滑块停在位置 25,保存 1500 秒。

5.3 源码印证

在 TimerSection.svelte 中,滑块位置由存储秒数派生:

let workMins = $derived(Math.round($settings.time_work_secs / 60)); let shortMins = $derived(Math.round($settings.time_short_break_secs / 60)); let longMins = $derived(Math.round($settings.time_long_break_secs / 60));

滑块配置为min="1" max="90" step="1",其oninput直接以秒提交:

oninput={(e) => handleChange('time_work_secs', (e.target as HTMLInputElement).valueAsNumber * 60)}

徽章提交后,settingsstore 更新,$derived自动把滑块拇指摆到Math.round(secs / 60)的位置。两条链路最终都汇入同一个setSettingIPC 调用,由 Rust 侧统一落库。

六、空闲态表盘即时刷新:timer:reset 事件

6.1 需求背景

当计时器处于空闲态(未运行、无已耗进度)时修改时长设置,主窗口的计时表盘应立即显示新的总时长,无需用户手动重置计时器。spec 定义了两条场景:

  • 空闲时改时长:从 25:00 改为 5:39,主窗口表盘立即显示完整的 5:39 圆环,无需在主窗口做任何操作;
  • 运行中改时长:当前倒计时不受影响地走完,新时长从下一轮开始生效。

6.2 实现机制

这条链路横跨前后端:

  1. 前端setSetting→ Rustsettings_set(commands.rs);
  2. timer.apply_settings(new_settings.clone())同步计时引擎;
  3. 无论计时器处于何种状态,都app.emit("timer:reset", &timer.get_snapshot())广播更新后的快照——代码注释明确说明:即使正在运行,timer:reset处理器只调用timerState.set(),不会打断倒计时,下一次timer:tick会在 1 秒内自动对齐total_secs。

计时引擎侧的 timer/mod.rs 的 apply_settings 是关键:它更新内存中的设置副本,并且仅在空闲态(!s.is_running && s.elapsed_secs == 0)才调用self.reconfigure()重配引擎;运行或暂停中则保留当前倒计时,新时长在下一轮或手动重置后生效——这与 spec 的「运行中改时长下一轮生效」完全一致。前端的onTimerReset监听(见 ipc/index.ts)接收该事件并更新表盘。

七、统计视图修正:专注时长四舍五入到分钟

7.1 问题与决策

秒级时长引入后,统计视图的「每日专注时长」不能再简单地截断取整。spec 明确:每日专注时长应四舍五入到最近分钟(而非截断)。339 秒(5:39)的会话应计为 6 分钟,而不是 5 分钟。验收场景:

  • 当日完成工作会话合计 339 秒(5:39)→ 显示6m;
  • 合计 324 秒(5:24)→ 显示5m(余数 24 < 30 向下舍入);
  • 恰好 1500 秒(25:00)→ 显示25m(整数分钟边界不变)。

7.2 源码实现

db/queries.rs 的 get_daily_stats() 中,focus_mins由截断改为四舍五入:

focus_mins: ((focus_secs + 30) / 60) as u32,

(focus_secs + 30) / 60是典型的整数四舍五入技巧:余数 ≥ 30 秒时进位,< 30 秒时舍去。对应测试focus_mins_rounds_to_nearest_minute(queries.rs)用内存数据库分别断言 339 s → 6 min、324 s → 5 min、1500 s → 25 min 三种边界。

八、风险与权衡(来自 design.md)

design.md 的Risks / Trade-offs记录了三条值得注意的取舍:

  1. 重置默认值的既有测试:原测试通过读取time_work_mins断言重置后time_work_secs == 25 * 60。迁移后测试必须改用time_work_secs作为播种与断言键——这是顺理成章的更新(已在 settings/mod.rs 落实);
  2. 迁移不可逆:如果用户降级到迁移前的版本,应用会因缺少*_mins键而回退到Settings::default()。默认值本身合理,因此是安全的,但用户自定义值会丢失;对桌面应用可接受,已在迁移注释中说明;
  3. MM:SS 解析边界情况(如1:60、0:30、90:01):统一由钳制处理,不需要用户可见的错误状态。

另外,design.md 的Non-Goals明确划定了边界:不做亚分钟精度的滑块(滑块保持 1 分钟步进,精细输入交给键盘);不改变计时引擎消费秒的方式(引擎本就在秒级工作);暂不对MM:SS格式做本地化。

九、改造任务清单与验证路径

tasks.md 把整套改造拆解为 4 个模块、9 项验证,全部已勾选完成:

模块关键动作
1. DB 迁移(Rust)新增MIGRATION_2;run()加if version < 2;defaults.rs 改秒值;load()去* 60;更新并新增迁移测试
2. 前端 IPC 更新TimerSection.svelte的handleChange键名改为*_secs;滑块oninput传valueAsNumber * 60
3. 可编辑徽章组件parseMMSS/formatMMSS工具函数;静态<span>换<input type="text">;oncommit(Enter/Tab/blur)逻辑;聚焦全选;视觉一致性样式
4. 统计取整修正(Rust)get_daily_stats()改((focus_secs + 30) / 60);新增 339/324 秒取整测试

验证手段包括cargo test --manifest-path src-tauri/Cargo.toml(Rust 全量测试)、npm run check(svelte-check 零错误),以及 9 项手工冒烟测试:输入5:39重启后计时器从 5:39 开始、拖到 30 徽章显示30:00、0:10钳制为1:00、abc回退旧值、全新安装默认 25:00/5:00/15:00、空闲态改时长表盘即时更新、完成 5:39 会话后统计显示6m。

十、实战要点速查

  • 输入格式:MM:SS或裸整数分钟(25=25:00),提交时机为 Enter / Tab / 失焦;
  • 合法范围:60 秒(1:00)~ 5400 秒(90:00),越界自动钳制;无法解析则回退旧值;
  • 存储单位:数据库一律存整秒,键名为time_work_secs/time_short_break_secs/time_long_break_secs,默认值 1500 / 300 / 900;
  • 迁移版本:schema_version记录在案,MIGRATION_2 幂等且事务包裹,降级时缺失键回退默认值;
  • 同步规则:滑块按整分钟步进、以秒保存;徽章精确到秒、滑块取Math.round(secs / 60);
  • 统计口径:专注分钟数采用(focus_secs + 30) / 60四舍五入。

对于希望在自己的 Tauri 应用里实现「滑块粗调 + 键盘精调 + 秒级持久化」同类能力的开发者,这套 proposal → spec → design → tasks → 源码与测试的完整链路本身就是一份可复用的工程范本。

【免费下载链接】pomotroid

:tomato: Simple and visually-pleasing Pomodoro timer

项目地址:https://gitcode.com/gh_mirrors/po/pomotroid
点击查看免费下载

相关推荐

上一篇:【热门开源项目下载】<WanJuan3.0>
下一篇:DeepTutor安装与本地部署指南:4行命令跑通AI学习助手,第一次就生成互动教材

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询