Write more code in Rust
This commit is contained in:
113
src-tauri/Cargo.lock
generated
113
src-tauri/Cargo.lock
generated
@@ -775,13 +775,34 @@ dependencies = [
|
|||||||
"crypto-common",
|
"crypto-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys 0.4.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs-sys",
|
"dirs-sys 0.5.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"option-ext",
|
||||||
|
"redox_users 0.4.6",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -792,7 +813,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users 0.5.2",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2976,6 +2997,17 @@ dependencies = [
|
|||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.16",
|
||||||
|
"libredox",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -3727,12 +3759,15 @@ name = "tauclock"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"dirs 5.0.1",
|
||||||
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3744,7 +3779,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"dunce",
|
"dunce",
|
||||||
"embed_plist",
|
"embed_plist",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
@@ -3794,7 +3829,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"cargo_toml",
|
"cargo_toml",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"glob",
|
"glob",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
@@ -4339,7 +4374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b"
|
checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"libappindicator",
|
"libappindicator",
|
||||||
"muda",
|
"muda",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -4923,6 +4958,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.48.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -4965,6 +5009,21 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.42.2",
|
"windows_x86_64_msvc 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.48.5",
|
||||||
|
"windows_aarch64_msvc 0.48.5",
|
||||||
|
"windows_i686_gnu 0.48.5",
|
||||||
|
"windows_i686_msvc 0.48.5",
|
||||||
|
"windows_x86_64_gnu 0.48.5",
|
||||||
|
"windows_x86_64_gnullvm 0.48.5",
|
||||||
|
"windows_x86_64_msvc 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5022,6 +5081,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5040,6 +5105,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5058,6 +5129,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5088,6 +5165,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5106,6 +5189,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5124,6 +5213,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5142,6 +5237,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -5204,7 +5305,7 @@ dependencies = [
|
|||||||
"block2",
|
"block2",
|
||||||
"cookie",
|
"cookie",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"dpi",
|
"dpi",
|
||||||
"dunce",
|
"dunce",
|
||||||
"gdkx11",
|
"gdkx11",
|
||||||
|
|||||||
@@ -24,4 +24,6 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
chrono = "0.4.42"
|
chrono = "0.4.42"
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
dirs = "5"
|
||||||
|
log = "0.4"
|
||||||
|
|||||||
837
src-tauri/src/state.rs
Normal file
837
src-tauri/src/state.rs
Normal file
@@ -0,0 +1,837 @@
|
|||||||
|
use chrono::{
|
||||||
|
DateTime, Datelike, Days, Local, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone,
|
||||||
|
Timelike, Utc, Weekday,
|
||||||
|
};
|
||||||
|
use log::{debug, error, info, trace, warn};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::Mutex,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const STATUS_DURATION_MS: i64 = 4000;
|
||||||
|
const RINGING_DURATION_MS: i64 = 6000;
|
||||||
|
const TIMER_MIN_MS: u64 = 1000;
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
inner: Mutex<StateInner>,
|
||||||
|
storage_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(storage_path: PathBuf) -> Self {
|
||||||
|
let mut inner = StateInner::default();
|
||||||
|
if let Some(list) = load_alarms(&storage_path) {
|
||||||
|
let now = Local::now();
|
||||||
|
inner.alarms = list
|
||||||
|
.into_iter()
|
||||||
|
.map(|alarm| Alarm {
|
||||||
|
id: alarm.id,
|
||||||
|
hour: alarm.hour,
|
||||||
|
minute: alarm.minute,
|
||||||
|
label: alarm.label,
|
||||||
|
active: alarm.active,
|
||||||
|
next_trigger: resolve_next_trigger(alarm.hour, alarm.minute, &now),
|
||||||
|
ringing_until: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
inner: Mutex::new(inner),
|
||||||
|
storage_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> FrontendState {
|
||||||
|
trace!("Building snapshot for frontend");
|
||||||
|
self.apply(|_, _, _| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tick(&self) -> FrontendState {
|
||||||
|
trace!("Processing tick request");
|
||||||
|
self.apply(|_, _, _| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_alarm(&self, time: String, label: Option<String>) -> FrontendState {
|
||||||
|
self.apply(|inner, now_ms, now_local| {
|
||||||
|
inner.create_alarm(&time, label.as_deref(), now_ms, now_local);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_alarm(&self, id: String) -> FrontendState {
|
||||||
|
self.apply(|inner, _now_ms, now_local| {
|
||||||
|
inner.toggle_alarm(&id, now_local);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_alarm(&self, id: String) -> FrontendState {
|
||||||
|
self.apply(|inner, now_ms, _| {
|
||||||
|
inner.delete_alarm(&id, now_ms);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_timer(&self, input: Option<String>, force_parse: bool) -> FrontendState {
|
||||||
|
self.apply(|inner, now_ms, _| {
|
||||||
|
inner.toggle_timer(input.as_deref(), force_parse, now_ms);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_timer(&self) -> FrontendState {
|
||||||
|
self.apply(|inner, _, _| inner.reset_timer())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_stopwatch(&self) -> FrontendState {
|
||||||
|
self.apply(|inner, now_ms, _| inner.toggle_stopwatch(now_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_lap(&self) -> FrontendState {
|
||||||
|
self.apply(|inner, now_ms, _| inner.add_lap(now_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_stopwatch(&self) -> FrontendState {
|
||||||
|
self.apply(|inner, _, _| inner.reset_stopwatch())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply<F>(&self, mutator: F) -> FrontendState
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut StateInner, i64, &DateTime<Local>),
|
||||||
|
{
|
||||||
|
let now_local = Local::now();
|
||||||
|
let now_ms = now_local.timestamp_millis();
|
||||||
|
let mut guard = self.inner.lock().expect("state mutex poisoned");
|
||||||
|
trace!("Running state mutation (now_ms={})", now_ms);
|
||||||
|
mutator(&mut guard, now_ms, &now_local);
|
||||||
|
guard.refresh(now_ms, &now_local);
|
||||||
|
let snapshot = guard.to_frontend(&now_local);
|
||||||
|
let persisted = if guard.alarms_dirty {
|
||||||
|
Some(
|
||||||
|
guard
|
||||||
|
.alarms
|
||||||
|
.iter()
|
||||||
|
.map(PersistedAlarm::from)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
guard.alarms_dirty = false;
|
||||||
|
drop(guard);
|
||||||
|
if let Some(list) = persisted {
|
||||||
|
debug!("Persisting {} alarms to disk", list.len());
|
||||||
|
self.save_alarms(&list);
|
||||||
|
}
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_alarms(&self, alarms: &[PersistedAlarm]) {
|
||||||
|
if let Some(parent) = self.storage_path.parent() {
|
||||||
|
if let Err(error) = fs::create_dir_all(parent) {
|
||||||
|
error!(
|
||||||
|
"Failed to create alarm storage directory {}: {error}",
|
||||||
|
parent.display()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let payload = AlarmStore {
|
||||||
|
alarms: alarms.to_vec(),
|
||||||
|
};
|
||||||
|
match serde_json::to_string_pretty(&payload) {
|
||||||
|
Ok(json) => {
|
||||||
|
if let Err(error) = fs::write(&self.storage_path, json) {
|
||||||
|
error!(
|
||||||
|
"Failed to persist alarms to {}: {error}",
|
||||||
|
self.storage_path.display()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
"Persisted {} alarms to {}",
|
||||||
|
payload.alarms.len(),
|
||||||
|
self.storage_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
error!("Failed to serialize alarms: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct StateInner {
|
||||||
|
alarms: Vec<Alarm>,
|
||||||
|
timer: TimerState,
|
||||||
|
stopwatch: StopwatchState,
|
||||||
|
status: Option<StatusMessage>,
|
||||||
|
play_chime: bool,
|
||||||
|
alarms_dirty: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StateInner {
|
||||||
|
fn refresh(&mut self, now_ms: i64, now_local: &DateTime<Local>) {
|
||||||
|
self.process_alarms(now_ms, now_local);
|
||||||
|
self.update_timer(now_ms);
|
||||||
|
self.expire_status(now_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_frontend(&mut self, now_local: &DateTime<Local>) -> FrontendState {
|
||||||
|
let now_ms = now_local.timestamp_millis();
|
||||||
|
let now_utc = now_local.with_timezone(&Utc);
|
||||||
|
let clock = ClockState {
|
||||||
|
local_time: format_time(now_local.hour(), now_local.minute(), now_local.second()),
|
||||||
|
local_date: format_date(now_local.weekday(), now_local.month(), now_local.day()),
|
||||||
|
utc_time: format_time(now_utc.hour(), now_utc.minute(), now_utc.second()),
|
||||||
|
utc_date: format_date(now_utc.weekday(), now_utc.month(), now_utc.day()),
|
||||||
|
};
|
||||||
|
let alarms = self.build_alarm_view(now_ms);
|
||||||
|
let timer = self.build_timer_view();
|
||||||
|
let stopwatch = self.build_stopwatch_view(now_ms);
|
||||||
|
let status = self.status.as_ref().map(|message| message.text.clone());
|
||||||
|
let should_chime = std::mem::take(&mut self.play_chime);
|
||||||
|
FrontendState {
|
||||||
|
clock,
|
||||||
|
alarms,
|
||||||
|
timer,
|
||||||
|
stopwatch,
|
||||||
|
status,
|
||||||
|
should_chime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_alarm(
|
||||||
|
&mut self,
|
||||||
|
raw_time: &str,
|
||||||
|
label: Option<&str>,
|
||||||
|
now_ms: i64,
|
||||||
|
now_local: &DateTime<Local>,
|
||||||
|
) {
|
||||||
|
let Some((hour, minute)) = parse_alarm_time(raw_time) else {
|
||||||
|
warn!("Invalid alarm time input: {}", raw_time);
|
||||||
|
self.push_status("時刻の形式が正しくありません", now_ms);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let label_text = label
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "アラーム".to_string());
|
||||||
|
let alarm = Alarm {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
label: label_text.clone(),
|
||||||
|
active: true,
|
||||||
|
next_trigger: resolve_next_trigger(hour, minute, now_local),
|
||||||
|
ringing_until: None,
|
||||||
|
};
|
||||||
|
self.alarms.push(alarm);
|
||||||
|
self.alarms_dirty = true;
|
||||||
|
info!(
|
||||||
|
"Alarm created: '{}' scheduled for {:02}:{:02}",
|
||||||
|
label_text, hour, minute
|
||||||
|
);
|
||||||
|
self.push_status(&format!("アラーム追加: {}", label_text), now_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_alarm(&mut self, id: &str, now_local: &DateTime<Local>) {
|
||||||
|
if let Some(alarm) = self.alarms.iter_mut().find(|alarm| alarm.id == id) {
|
||||||
|
alarm.active = !alarm.active;
|
||||||
|
alarm.ringing_until = None;
|
||||||
|
alarm.next_trigger = resolve_next_trigger(alarm.hour, alarm.minute, now_local);
|
||||||
|
self.alarms_dirty = true;
|
||||||
|
info!(
|
||||||
|
"Alarm {} toggled -> {}",
|
||||||
|
id,
|
||||||
|
if alarm.active { "active" } else { "inactive" }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warn!("Requested toggle for unknown alarm id {}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_alarm(&mut self, id: &str, now_ms: i64) {
|
||||||
|
if let Some(index) = self.alarms.iter().position(|alarm| alarm.id == id) {
|
||||||
|
let removed = self.alarms.remove(index);
|
||||||
|
self.alarms_dirty = true;
|
||||||
|
info!("Alarm {} deleted", removed.id);
|
||||||
|
self.push_status(&format!("アラーム削除: {}", removed.label), now_ms);
|
||||||
|
} else {
|
||||||
|
warn!("Requested delete for unknown alarm id {}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_timer(&mut self, input: Option<&str>, force_parse: bool, now_ms: i64) {
|
||||||
|
if self.timer.running {
|
||||||
|
if let Some(target) = self.timer.target_epoch_ms {
|
||||||
|
self.timer.remaining_ms = target.saturating_sub(now_ms) as u64;
|
||||||
|
}
|
||||||
|
self.timer.running = false;
|
||||||
|
self.timer.target_epoch_ms = None;
|
||||||
|
info!("Timer paused with {}ms remaining", self.timer.remaining_ms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_input = input.unwrap_or("").trim();
|
||||||
|
let needs_parse = force_parse
|
||||||
|
|| self.timer.initial_ms == 0
|
||||||
|
|| (!next_input.is_empty() && Some(next_input) != self.timer.last_input.as_deref());
|
||||||
|
|
||||||
|
if needs_parse {
|
||||||
|
if next_input.is_empty() {
|
||||||
|
warn!("Timer start requested without duration input");
|
||||||
|
self.push_status("タイマーの時間を入力してください", now_ms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(duration) = parse_duration(next_input) else {
|
||||||
|
warn!("Failed to parse timer duration from '{}'", next_input);
|
||||||
|
self.push_status("時間の形式が正しくありません", now_ms);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if duration < TIMER_MIN_MS {
|
||||||
|
warn!("Timer duration below minimum: {}ms", duration);
|
||||||
|
self.push_status("1秒以上の時間を入力してください", now_ms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.timer.initial_ms = duration;
|
||||||
|
self.timer.remaining_ms = duration;
|
||||||
|
self.timer.last_input = Some(next_input.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.timer.remaining_ms == 0 {
|
||||||
|
self.timer.remaining_ms = self.timer.initial_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.timer.remaining_ms == 0 {
|
||||||
|
self.push_status("1秒以上の時間を入力してください", now_ms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.timer.running = true;
|
||||||
|
self.timer.target_epoch_ms = Some(now_ms + self.timer.remaining_ms as i64);
|
||||||
|
info!(
|
||||||
|
"Timer started for {}ms (target epoch {})",
|
||||||
|
self.timer.remaining_ms,
|
||||||
|
self.timer.target_epoch_ms.unwrap_or(now_ms)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_timer(&mut self) {
|
||||||
|
self.timer.running = false;
|
||||||
|
self.timer.target_epoch_ms = None;
|
||||||
|
self.timer.remaining_ms = self.timer.initial_ms;
|
||||||
|
info!("Timer reset to {}ms", self.timer.initial_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_stopwatch(&mut self, now_ms: i64) {
|
||||||
|
if self.stopwatch.running {
|
||||||
|
if let Some(start) = self.stopwatch.start_epoch_ms {
|
||||||
|
let delta = now_ms.saturating_sub(start);
|
||||||
|
self.stopwatch.elapsed_before_ms += delta as u64;
|
||||||
|
}
|
||||||
|
self.stopwatch.running = false;
|
||||||
|
self.stopwatch.start_epoch_ms = None;
|
||||||
|
info!("Stopwatch paused at {}ms", self.stopwatch.elapsed_before_ms);
|
||||||
|
} else {
|
||||||
|
self.stopwatch.running = true;
|
||||||
|
self.stopwatch.start_epoch_ms = Some(now_ms);
|
||||||
|
info!("Stopwatch started");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_lap(&mut self, now_ms: i64) {
|
||||||
|
let elapsed = self.stopwatch_elapsed(now_ms);
|
||||||
|
if elapsed == 0 {
|
||||||
|
debug!("Lap ignored because stopwatch has not started");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let previous_total = self
|
||||||
|
.stopwatch
|
||||||
|
.laps
|
||||||
|
.last()
|
||||||
|
.map(|lap| lap.total_ms)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let entry = LapEntry {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
total_ms: elapsed,
|
||||||
|
delta_ms: elapsed.saturating_sub(previous_total),
|
||||||
|
};
|
||||||
|
self.stopwatch.laps.push(entry);
|
||||||
|
debug!(
|
||||||
|
"Lap recorded: {} (total {}ms)",
|
||||||
|
self.stopwatch
|
||||||
|
.laps
|
||||||
|
.last()
|
||||||
|
.map(|lap| lap.id.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
elapsed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_stopwatch(&mut self) {
|
||||||
|
self.stopwatch.running = false;
|
||||||
|
self.stopwatch.start_epoch_ms = None;
|
||||||
|
self.stopwatch.elapsed_before_ms = 0;
|
||||||
|
self.stopwatch.laps.clear();
|
||||||
|
info!("Stopwatch reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_alarms(&mut self, now_ms: i64, now_local: &DateTime<Local>) {
|
||||||
|
let mut triggered_labels = Vec::new();
|
||||||
|
for alarm in &mut self.alarms {
|
||||||
|
if let Some(until) = alarm.ringing_until {
|
||||||
|
if until <= now_ms {
|
||||||
|
alarm.ringing_until = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !alarm.active {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if now_ms >= alarm.next_trigger {
|
||||||
|
alarm.ringing_until = Some(now_ms + RINGING_DURATION_MS);
|
||||||
|
alarm.next_trigger = resolve_next_trigger(alarm.hour, alarm.minute, now_local);
|
||||||
|
triggered_labels.push(alarm.label.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !triggered_labels.is_empty() {
|
||||||
|
self.play_chime = true;
|
||||||
|
self.alarms_dirty = true;
|
||||||
|
info!(
|
||||||
|
"Triggered {} alarm(s): {:?}",
|
||||||
|
triggered_labels.len(),
|
||||||
|
triggered_labels
|
||||||
|
);
|
||||||
|
for label in triggered_labels {
|
||||||
|
self.push_status(&format!("アラーム: {}", label), now_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_timer(&mut self, now_ms: i64) {
|
||||||
|
if self.timer.running {
|
||||||
|
if let Some(target) = self.timer.target_epoch_ms {
|
||||||
|
if now_ms >= target {
|
||||||
|
self.timer.running = false;
|
||||||
|
self.timer.target_epoch_ms = None;
|
||||||
|
self.timer.remaining_ms = 0;
|
||||||
|
self.play_chime = true;
|
||||||
|
info!("Timer completed");
|
||||||
|
self.push_status("タイマー終了", now_ms);
|
||||||
|
} else {
|
||||||
|
self.timer.remaining_ms = target.saturating_sub(now_ms) as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expire_status(&mut self, now_ms: i64) {
|
||||||
|
if let Some(status) = &self.status {
|
||||||
|
if status.expires_at <= now_ms {
|
||||||
|
self.status = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_alarm_view(&self, now_ms: i64) -> AlarmSection {
|
||||||
|
let mut sorted = self.alarms.iter().collect::<Vec<_>>();
|
||||||
|
sorted.sort_by_key(|alarm| alarm.next_trigger);
|
||||||
|
let items: Vec<AlarmView> = sorted
|
||||||
|
.into_iter()
|
||||||
|
.map(|alarm| AlarmView {
|
||||||
|
id: alarm.id.clone(),
|
||||||
|
label: alarm.label.clone(),
|
||||||
|
time_label: format_time_label(alarm.hour, alarm.minute),
|
||||||
|
active: alarm.active,
|
||||||
|
ringing: alarm
|
||||||
|
.ringing_until
|
||||||
|
.map(|until| until > now_ms)
|
||||||
|
.unwrap_or(false),
|
||||||
|
next_relative: format_relative(alarm.next_trigger, now_ms),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let active_alarms: Vec<&Alarm> = self.alarms.iter().filter(|alarm| alarm.active).collect();
|
||||||
|
let next_summary = if active_alarms.is_empty() {
|
||||||
|
"アクティブなアラームはありません".to_string()
|
||||||
|
} else {
|
||||||
|
let soonest = active_alarms
|
||||||
|
.into_iter()
|
||||||
|
.min_by_key(|alarm| alarm.next_trigger)
|
||||||
|
.expect("active alarm available");
|
||||||
|
format!(
|
||||||
|
"次のアラーム: {} ({}) - {}",
|
||||||
|
format_time_label(soonest.hour, soonest.minute),
|
||||||
|
soonest.label,
|
||||||
|
format_relative(soonest.next_trigger, now_ms)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
AlarmSection {
|
||||||
|
items,
|
||||||
|
next_summary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_timer_view(&self) -> TimerView {
|
||||||
|
let display_ms = if self.timer.running {
|
||||||
|
self.timer.remaining_ms
|
||||||
|
} else if self.timer.remaining_ms > 0 {
|
||||||
|
self.timer.remaining_ms
|
||||||
|
} else {
|
||||||
|
self.timer.initial_ms
|
||||||
|
};
|
||||||
|
let toggle_label = if self.timer.running {
|
||||||
|
"一時停止"
|
||||||
|
} else if self.timer.remaining_ms > 0 && self.timer.remaining_ms < self.timer.initial_ms {
|
||||||
|
"再開"
|
||||||
|
} else {
|
||||||
|
"スタート"
|
||||||
|
};
|
||||||
|
let status_text = if self.timer.running {
|
||||||
|
Some("カウント中".to_string())
|
||||||
|
} else if self.timer.remaining_ms > 0 && self.timer.remaining_ms < self.timer.initial_ms {
|
||||||
|
Some("一時停止".to_string())
|
||||||
|
} else if !self.timer.running && self.timer.initial_ms > 0 && self.timer.remaining_ms == 0 {
|
||||||
|
Some("完了".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
TimerView {
|
||||||
|
display: format_duration(display_ms),
|
||||||
|
toggle_label: toggle_label.to_string(),
|
||||||
|
status_text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_stopwatch_view(&self, now_ms: i64) -> StopwatchView {
|
||||||
|
let elapsed = self.stopwatch_elapsed(now_ms);
|
||||||
|
let toggle_label = if self.stopwatch.running {
|
||||||
|
"停止"
|
||||||
|
} else {
|
||||||
|
"スタート"
|
||||||
|
};
|
||||||
|
let laps = build_lap_views(&self.stopwatch.laps);
|
||||||
|
StopwatchView {
|
||||||
|
display: format_stopwatch(elapsed),
|
||||||
|
toggle_label: toggle_label.to_string(),
|
||||||
|
laps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stopwatch_elapsed(&self, now_ms: i64) -> u64 {
|
||||||
|
self.stopwatch.elapsed_before_ms
|
||||||
|
+ if self.stopwatch.running {
|
||||||
|
now_ms.saturating_sub(self.stopwatch.start_epoch_ms.unwrap_or(now_ms)) as u64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_status(&mut self, message: &str, now_ms: i64) {
|
||||||
|
self.status = Some(StatusMessage {
|
||||||
|
text: message.to_string(),
|
||||||
|
expires_at: now_ms + STATUS_DURATION_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
struct Alarm {
|
||||||
|
id: String,
|
||||||
|
hour: u8,
|
||||||
|
minute: u8,
|
||||||
|
label: String,
|
||||||
|
active: bool,
|
||||||
|
next_trigger: i64,
|
||||||
|
ringing_until: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct TimerState {
|
||||||
|
initial_ms: u64,
|
||||||
|
remaining_ms: u64,
|
||||||
|
running: bool,
|
||||||
|
target_epoch_ms: Option<i64>,
|
||||||
|
last_input: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct StopwatchState {
|
||||||
|
running: bool,
|
||||||
|
start_epoch_ms: Option<i64>,
|
||||||
|
elapsed_before_ms: u64,
|
||||||
|
laps: Vec<LapEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct LapEntry {
|
||||||
|
id: String,
|
||||||
|
total_ms: u64,
|
||||||
|
delta_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusMessage {
|
||||||
|
text: String,
|
||||||
|
expires_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct FrontendState {
|
||||||
|
pub clock: ClockState,
|
||||||
|
pub alarms: AlarmSection,
|
||||||
|
pub timer: TimerView,
|
||||||
|
pub stopwatch: StopwatchView,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub should_chime: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ClockState {
|
||||||
|
pub local_time: String,
|
||||||
|
pub local_date: String,
|
||||||
|
pub utc_time: String,
|
||||||
|
pub utc_date: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AlarmSection {
|
||||||
|
pub items: Vec<AlarmView>,
|
||||||
|
pub next_summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AlarmView {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub time_label: String,
|
||||||
|
pub active: bool,
|
||||||
|
pub ringing: bool,
|
||||||
|
pub next_relative: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TimerView {
|
||||||
|
pub display: String,
|
||||||
|
pub toggle_label: String,
|
||||||
|
pub status_text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct StopwatchView {
|
||||||
|
pub display: String,
|
||||||
|
pub toggle_label: String,
|
||||||
|
pub laps: Vec<LapView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct LapView {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub total: String,
|
||||||
|
pub delta: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
struct PersistedAlarm {
|
||||||
|
id: String,
|
||||||
|
hour: u8,
|
||||||
|
minute: u8,
|
||||||
|
label: String,
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
|
struct AlarmStore {
|
||||||
|
alarms: Vec<PersistedAlarm>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Alarm> for PersistedAlarm {
|
||||||
|
fn from(value: &Alarm) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id.clone(),
|
||||||
|
hour: value.hour,
|
||||||
|
minute: value.minute,
|
||||||
|
label: value.label.clone(),
|
||||||
|
active: value.active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_alarms(path: &Path) -> Option<Vec<PersistedAlarm>> {
|
||||||
|
match fs::read(path) {
|
||||||
|
Ok(bytes) => match serde_json::from_slice::<AlarmStore>(&bytes) {
|
||||||
|
Ok(store) => {
|
||||||
|
debug!("Loaded {} persisted alarms", store.alarms.len());
|
||||||
|
Some(store.alarms)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Failed to parse alarm store at {}: {error}", path.display());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(error) => {
|
||||||
|
debug!(
|
||||||
|
"No persisted alarms available at {} ({error})",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_time(hour: u32, minute: u32, second: u32) -> String {
|
||||||
|
format!("{:02}:{:02}:{:02}", hour, minute, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_date(weekday: Weekday, month: u32, day: u32) -> String {
|
||||||
|
let weekday_label = match weekday {
|
||||||
|
Weekday::Mon => "月",
|
||||||
|
Weekday::Tue => "火",
|
||||||
|
Weekday::Wed => "水",
|
||||||
|
Weekday::Thu => "木",
|
||||||
|
Weekday::Fri => "金",
|
||||||
|
Weekday::Sat => "土",
|
||||||
|
Weekday::Sun => "日",
|
||||||
|
};
|
||||||
|
format!("{} {}月{:02}日", weekday_label, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_time_label(hour: u8, minute: u8) -> String {
|
||||||
|
format!("{:02}:{:02}", hour, minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_relative(target_ms: i64, now_ms: i64) -> String {
|
||||||
|
let diff = target_ms - now_ms;
|
||||||
|
if diff <= 0 {
|
||||||
|
return "まもなく".to_string();
|
||||||
|
}
|
||||||
|
if diff <= 60_000 {
|
||||||
|
return "まもなく".to_string();
|
||||||
|
}
|
||||||
|
let minutes = diff / 60_000;
|
||||||
|
if minutes < 60 {
|
||||||
|
return format!("{}分後", minutes);
|
||||||
|
}
|
||||||
|
let hours = minutes / 60;
|
||||||
|
let mins = minutes % 60;
|
||||||
|
format!("{}時間{}分後", hours, mins)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_duration(ms: u64) -> String {
|
||||||
|
let total_seconds = (ms / 1000) as u64;
|
||||||
|
let minutes = total_seconds / 60;
|
||||||
|
let seconds = total_seconds % 60;
|
||||||
|
let hundredths = (ms % 1000) / 10;
|
||||||
|
if minutes >= 60 {
|
||||||
|
let hours = minutes / 60;
|
||||||
|
let rem_minutes = minutes % 60;
|
||||||
|
format!("{:02}:{:02}:{:02}", hours, rem_minutes, seconds)
|
||||||
|
} else {
|
||||||
|
format!("{:02}:{:02}.{:02}", minutes, seconds, hundredths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_stopwatch(ms: u64) -> String {
|
||||||
|
let total_seconds = ms / 1000;
|
||||||
|
let minutes = total_seconds / 60;
|
||||||
|
let seconds = total_seconds % 60;
|
||||||
|
let millis = ms % 1000;
|
||||||
|
format!("{:02}:{:02}.{:03}", minutes, seconds, millis)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_lap_views(entries: &[LapEntry]) -> Vec<LapView> {
|
||||||
|
if entries.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let total = entries.len();
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, lap)| LapView {
|
||||||
|
id: lap.id.clone(),
|
||||||
|
label: format!("Lap {}", total - index),
|
||||||
|
total: format!("合計 {}", format_stopwatch(lap.total_ms)),
|
||||||
|
delta: format!("+{}", format_stopwatch(lap.delta_ms)),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_alarm_time(value: &str) -> Option<(u8, u8)> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
let mut parts = trimmed.split(':');
|
||||||
|
let hour = parts.next()?.parse::<u8>().ok()?;
|
||||||
|
let minute = parts.next()?.parse::<u8>().ok()?;
|
||||||
|
if parts.next().is_some() || hour > 23 || minute > 59 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((hour, minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_duration(value: &str) -> Option<u64> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if trimmed.chars().all(|ch| ch.is_ascii_digit()) {
|
||||||
|
return trimmed.parse::<u64>().ok().map(|seconds| seconds * 1000);
|
||||||
|
}
|
||||||
|
if trimmed.contains(':') {
|
||||||
|
let mut parts = trimmed.split(':');
|
||||||
|
let minutes = parts.next()?.parse::<u64>().ok()?;
|
||||||
|
let seconds = parts.next()?.parse::<u64>().ok()?;
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some((minutes * 60 + seconds) * 1000);
|
||||||
|
}
|
||||||
|
parse_unit_duration(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_unit_duration(value: &str) -> Option<u64> {
|
||||||
|
let mut total_ms = 0u64;
|
||||||
|
for chunk in value.split_whitespace() {
|
||||||
|
if chunk.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let split_index = chunk.find(|c: char| !c.is_ascii_digit())?;
|
||||||
|
let (number_part, unit_part) = chunk.split_at(split_index);
|
||||||
|
if number_part.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let amount = number_part.parse::<u64>().ok()?;
|
||||||
|
let unit = unit_part.chars().next()?.to_ascii_lowercase();
|
||||||
|
match unit {
|
||||||
|
'h' => total_ms += amount * 3_600_000,
|
||||||
|
'm' => total_ms += amount * 60_000,
|
||||||
|
's' => total_ms += amount * 1000,
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total_ms > 0 {
|
||||||
|
Some(total_ms)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_next_trigger(hour: u8, minute: u8, now_local: &DateTime<Local>) -> i64 {
|
||||||
|
let naive_time = NaiveTime::from_hms_opt(hour as u32, minute as u32, 0)
|
||||||
|
.unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap());
|
||||||
|
let mut candidate = combine_local(now_local.date_naive(), naive_time);
|
||||||
|
if candidate.timestamp_millis() <= now_local.timestamp_millis() {
|
||||||
|
let next_day = now_local.date_naive() + Days::new(1);
|
||||||
|
candidate = combine_local(next_day, naive_time);
|
||||||
|
}
|
||||||
|
candidate.timestamp_millis()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn combine_local(date: NaiveDate, time: NaiveTime) -> DateTime<Local> {
|
||||||
|
let naive = NaiveDateTime::new(date, time);
|
||||||
|
match Local.from_local_datetime(&naive) {
|
||||||
|
LocalResult::Single(value) => value,
|
||||||
|
LocalResult::Ambiguous(first, _second) => first,
|
||||||
|
LocalResult::None => {
|
||||||
|
DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc).with_timezone(&Local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user