diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 042561a..f109bfc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -775,13 +775,34 @@ dependencies = [ "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]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" 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]] @@ -792,7 +813,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2976,6 +2997,17 @@ dependencies = [ "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]] name = "redox_users" version = "0.5.2" @@ -3727,12 +3759,15 @@ name = "tauclock" version = "0.1.0" dependencies = [ "chrono", + "dirs 5.0.1", + "log", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-log", "tauri-plugin-opener", + "uuid", ] [[package]] @@ -3744,7 +3779,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3794,7 +3829,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4339,7 +4374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -4923,6 +4958,15 @@ dependencies = [ "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]] name = "windows-sys" version = "0.59.0" @@ -4965,6 +5009,21 @@ dependencies = [ "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]] name = "windows-targets" version = "0.52.6" @@ -5022,6 +5081,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5040,6 +5105,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5058,6 +5129,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5088,6 +5165,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5106,6 +5189,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5124,6 +5213,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5142,6 +5237,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5204,7 +5305,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 967a8bc..9843ed8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,4 +24,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = "0.4.42" tauri-plugin-log = "2" - +uuid = { version = "1", features = ["v4"] } +dirs = "5" +log = "0.4" diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs new file mode 100644 index 0000000..ea6cd48 --- /dev/null +++ b/src-tauri/src/state.rs @@ -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, + 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) -> 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, 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(&self, mutator: F) -> FrontendState + where + F: FnOnce(&mut StateInner, i64, &DateTime), + { + 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::>(), + ) + } 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, + timer: TimerState, + stopwatch: StopwatchState, + status: Option, + play_chime: bool, + alarms_dirty: bool, +} + +impl StateInner { + fn refresh(&mut self, now_ms: i64, now_local: &DateTime) { + 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) -> 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, + ) { + 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) { + 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) { + 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::>(); + sorted.sort_by_key(|alarm| alarm.next_trigger); + let items: Vec = 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, +} + +#[derive(Default)] +struct TimerState { + initial_ms: u64, + remaining_ms: u64, + running: bool, + target_epoch_ms: Option, + last_input: Option, +} + +#[derive(Default)] +struct StopwatchState { + running: bool, + start_epoch_ms: Option, + elapsed_before_ms: u64, + laps: Vec, +} + +#[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, + 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, + 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, +} + +#[derive(Serialize)] +pub struct StopwatchView { + pub display: String, + pub toggle_label: String, + pub laps: Vec, +} + +#[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, +} + +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> { + match fs::read(path) { + Ok(bytes) => match serde_json::from_slice::(&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 { + 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::().ok()?; + let minute = parts.next()?.parse::().ok()?; + if parts.next().is_some() || hour > 23 || minute > 59 { + return None; + } + Some((hour, minute)) +} + +fn parse_duration(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.chars().all(|ch| ch.is_ascii_digit()) { + return trimmed.parse::().ok().map(|seconds| seconds * 1000); + } + if trimmed.contains(':') { + let mut parts = trimmed.split(':'); + let minutes = parts.next()?.parse::().ok()?; + let seconds = parts.next()?.parse::().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 { + 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::().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) -> 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 { + let naive = NaiveDateTime::new(date, time); + match Local.from_local_datetime(&naive) { + LocalResult::Single(value) => value, + LocalResult::Ambiguous(first, _second) => first, + LocalResult::None => { + DateTime::::from_naive_utc_and_offset(naive, Utc).with_timezone(&Local) + } + } +}