【免费下载链接】pomotroid
:tomato: Simple and visually-pleasing Pomodoro timer
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记录了三种被否决的备选方案:
- 保留
*_mins键、存十进制分钟(如"5.65")——键名含义混乱,且有浮点解析风险; - 新旧键并存、加载时回退——加载逻辑变得含糊,出现两个事实来源;
- 同键名原地更新——
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:003.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 实现机制
这条链路横跨前后端:
- 前端
setSetting→ Rustsettings_set(commands.rs); timer.apply_settings(new_settings.clone())同步计时引擎;- 无论计时器处于何种状态,都
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记录了三条值得注意的取舍:
- 重置默认值的既有测试:原测试通过读取
time_work_mins断言重置后time_work_secs == 25 * 60。迁移后测试必须改用time_work_secs作为播种与断言键——这是顺理成章的更新(已在 settings/mod.rs 落实); - 迁移不可逆:如果用户降级到迁移前的版本,应用会因缺少
*_mins键而回退到Settings::default()。默认值本身合理,因此是安全的,但用户自定义值会丢失;对桌面应用可接受,已在迁移注释中说明; - 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
相关推荐
Pomotroid 计时器时长键盘输入(MM:SS)改造:从分钟级滑块到秒级精度存储的完整实现
Pomotroid 计时器时长键盘输入(MM:SS)改造:从分钟级滑块到秒级精度存储的完整实现 本技术指南以 Pomotroid 开源仓库中「计时器设置键盘输入
material-components-android 时间选择器(MaterialTimePicker)开发实战:从拨盘到键盘输入的完整指南
material components android 时间选择器(MaterialTimePicker)开发实战:从拨盘到键盘输入的完整指南 Time pic
UI组件移动开发设计系统LoveDOS游戏开发实战:打造经典像素风格的平台跳跃游戏
LoveDOS游戏开发实战:打造经典像素风格的平台跳跃游戏 LoveDOS是一款基于Lua的2D DOS游戏开发框架,让开发者能够轻松创建复古风格的像素游戏。本
游戏开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考