use chrono::{ DateTime, Datelike, Days, Local, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc, }; use log::{debug, error, info, trace, warn}; use serde::{Deserialize, Serialize}; use std::{ fs, path::{Path, PathBuf}, sync::Mutex, }; use uuid::Uuid; /// Duration (ms) an informational status message remains visible. const STATUS_DURATION_MS: i64 = 4000; /// Amount of time (ms) an alarm is considered to be "ringing" once triggered. const RINGING_DURATION_MS: i64 = 6000; /// Minimum timer duration accepted from the UI to avoid accidental zero timers. const TIMER_MIN_MS: u64 = 1000; /// Shared state container held by Tauri. It wraps a mutex-protected /// [`StateInner`] and is responsible for persistence plus providing immutable /// snapshots to the frontend. pub struct AppState { inner: Mutex, storage_path: PathBuf, } impl AppState { /// Constructs a new state container and eagerly loads persisted alarms. 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, } } /// Returns a `FrontendState` without mutating anything. Primarily used when /// the UI first boots. pub fn snapshot(&self) -> FrontendState { trace!("Building snapshot for frontend"); self.apply(|_, _, _| {}) } /// Executes the housekeeping refresh logic and returns an updated snapshot. pub fn tick(&self) -> FrontendState { trace!("Processing tick request"); self.apply(|_, _, _| {}) } /// Creates a new alarm entry using the provided time and label. 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); }) } /// Toggles an existing alarm's active state. pub fn toggle_alarm(&self, id: String) -> FrontendState { self.apply(|inner, _now_ms, now_local| { inner.toggle_alarm(&id, now_local); }) } /// Deletes an alarm permanently. pub fn delete_alarm(&self, id: String) -> FrontendState { self.apply(|inner, now_ms, _| { inner.delete_alarm(&id, now_ms); }) } /// Starts or pauses the timer depending on the current state. 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); }) } /// Resets the timer to its initial duration. pub fn reset_timer(&self) -> FrontendState { self.apply(|inner, _, _| inner.reset_timer()) } /// Starts or pauses the stopwatch. pub fn toggle_stopwatch(&self) -> FrontendState { self.apply(|inner, now_ms, _| inner.toggle_stopwatch(now_ms)) } /// Records a lap for the stopwatch if running. pub fn add_lap(&self) -> FrontendState { self.apply(|inner, now_ms, _| inner.add_lap(now_ms)) } /// Resets the stopwatch to zero elapsed time and clears laps. pub fn reset_stopwatch(&self) -> FrontendState { self.apply(|inner, _, _| inner.reset_stopwatch()) } /// Executes a state mutation closure while handling locking, refresh logic, /// snapshot generation, and persistence side effects. 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 } /// Persists the alarm list to disk, creating directories as needed. 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)] /// Mutable inner state containing alarms, timers, and transient UI messaging. struct StateInner { alarms: Vec, timer: TimerState, stopwatch: StopwatchState, status: Option, play_chime: bool, alarms_dirty: bool, } impl StateInner { /// Executes periodic tasks that should run on every mutation or tick. 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); } /// Builds a full [`FrontendState`] representation of the current data. 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.year(), 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.year(), 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, } } /// Adds a new alarm and schedules its next trigger time. 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); } /// Toggles an alarm's active state and recalculates its next trigger. 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); } } /// Removes an alarm completely. 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); } } /// Handles timer start/pause logic, parsing inputs when necessary. 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) ); } /// Restores the timer to its initial duration without starting it. 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); } /// Starts or pauses the stopwatch while accounting for elapsed time. 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"); } } /// Records a lap entry capturing both the total elapsed time and the delta /// since the previous lap. 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 ); } /// Stops the stopwatch and clears any lap history. 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"); } /// Iterates configured alarms, triggering those scheduled for the current /// tick and scheduling their next activation. 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); } } } /// Updates timer countdown bookkeeping and fires completion status when the /// clock hits zero. 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; } } } } /// Expires the active status message once its TTL elapses. fn expire_status(&mut self, now_ms: i64) { if let Some(status) = &self.status { if status.expires_at <= now_ms { self.status = None; } } } /// Builds the alarm section view model that the frontend expects. 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, } } /// Builds the timer view model by formatting durations and button labels. 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, } } /// Builds the stopwatch view model including lap breakdowns. 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, } } /// Returns the total stopwatch elapsed milliseconds, optionally including /// the in-flight span if the stopwatch is currently running. 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 } } /// Stores a transient status message that the UI can render for users. 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)] /// Comprehensive state payload consumed by the frontend. pub struct FrontendState { pub clock: ClockState, pub alarms: AlarmSection, pub timer: TimerView, pub stopwatch: StopwatchView, pub status: Option, pub should_chime: bool, } #[derive(Serialize)] /// Clock label strings rendered in the UI. pub struct ClockState { pub local_time: String, pub local_date: String, pub utc_time: String, pub utc_date: String, } #[derive(Serialize)] /// Aggregated alarm listing plus small summary for the next trigger. pub struct AlarmSection { pub items: Vec, pub next_summary: String, } #[derive(Serialize)] /// Individual alarm entry displayed in the frontend list. 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)] /// Timer labels consumed by the UI controls. pub struct TimerView { pub display: String, pub toggle_label: String, pub status_text: Option, } #[derive(Serialize)] /// Stopwatch value for the UI including lap breakdowns. pub struct StopwatchView { pub display: String, pub toggle_label: String, pub laps: Vec, } #[derive(Serialize)] /// Single lap renderable information. pub struct LapView { pub id: String, pub label: String, pub total: String, pub delta: String, } #[derive(Serialize, Deserialize, Clone)] /// Minimal alarm representation stored on disk. struct PersistedAlarm { id: String, hour: u8, minute: u8, label: String, active: bool, } #[derive(Serialize, Deserialize, Default)] /// Wrapper used when serializing alarm lists to JSON. 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, } } } /// Attempts to load the alarm store JSON file from disk. 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 } } } /// Formats a timestamp as HH:MM:SS. fn format_time(hour: u32, minute: u32, second: u32) -> String { format!("{:02}:{:02}:{:02}", hour, minute, second) } /// Formats a date as YYYY-MM-DD. fn format_date(year: i32, month: u32, day: u32) -> String { format!("{year:04}-{month:02}-{day:02}") } /// Formats an alarm display label. fn format_time_label(hour: u8, minute: u8) -> String { format!("{:02}:{:02}", hour, minute) } /// Produces a human-friendly relative time string describing when an alarm /// will next trigger. 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) } /// Formats a duration for timer display, switching to HH:MM:SS when needed. 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) } } /// Formats stopwatch durations with millisecond precision. 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) } /// Converts raw lap entries into renderable view data. 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() } /// Parses HH:MM strings emitted by the UI. 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)) } /// Parses timer duration input supporting seconds, MM:SS, or `1h 30m` formats. 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) } /// Helper that parses unit-suffixed duration tokens like `10m 5s`. 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 } } /// Calculates the next trigger timestamp in milliseconds for an alarm. 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() } /// Safely combines a date and time within the Local timezone, falling back to /// UTC if the local conversion is ambiguous or invalid. 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) } } }