Documentation
This commit is contained in:
@@ -1,3 +1,7 @@
|
|||||||
|
//! Backend bridge for the Tauclock application. This module binds the shared
|
||||||
|
//! state container to the Tauri command system, configures plugins, and exposes
|
||||||
|
//! convenience helpers for logging and persistence.
|
||||||
|
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
@@ -5,18 +9,24 @@ use state::{AppState, FrontendState};
|
|||||||
use std::{path::PathBuf, str::FromStr};
|
use std::{path::PathBuf, str::FromStr};
|
||||||
use tauri_plugin_log::{Target, TargetKind};
|
use tauri_plugin_log::{Target, TargetKind};
|
||||||
|
|
||||||
|
/// Returns the current frontend snapshot without mutating any state. Used for
|
||||||
|
/// the initial UI bootstrap.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_state(app_state: tauri::State<AppState>) -> FrontendState {
|
fn get_state(app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
log::debug!("get_state invoked");
|
log::debug!("get_state invoked");
|
||||||
app_state.snapshot()
|
app_state.snapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lightweight tick endpoint that allows the frontend to poll for changes such
|
||||||
|
/// as timer countdowns and stopwatch progress without issuing full commands.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn tick(app_state: tauri::State<AppState>) -> FrontendState {
|
fn tick(app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
log::trace!("tick invoked");
|
log::trace!("tick invoked");
|
||||||
app_state.tick()
|
app_state.tick()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a new alarm entry for the provided time/label combination and
|
||||||
|
/// returns the refreshed snapshot so the UI can re-render immediately.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn create_alarm(
|
fn create_alarm(
|
||||||
time: String,
|
time: String,
|
||||||
@@ -35,18 +45,22 @@ fn create_alarm(
|
|||||||
app_state.create_alarm(time, label)
|
app_state.create_alarm(time, label)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enables or disables the specified alarm without deleting it.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn toggle_alarm(id: String, app_state: tauri::State<AppState>) -> FrontendState {
|
fn toggle_alarm(id: String, app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
log::info!("toggle_alarm invoked (id={id})", id = id);
|
log::info!("toggle_alarm invoked (id={id})", id = id);
|
||||||
app_state.toggle_alarm(id)
|
app_state.toggle_alarm(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Permanently removes the referenced alarm and emits a new snapshot.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn delete_alarm(id: String, app_state: tauri::State<AppState>) -> FrontendState {
|
fn delete_alarm(id: String, app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
log::info!("delete_alarm invoked (id={id})", id = id);
|
log::info!("delete_alarm invoked (id={id})", id = id);
|
||||||
app_state.delete_alarm(id)
|
app_state.delete_alarm(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts, pauses, or reconfigures the countdown timer depending on the
|
||||||
|
/// requested input, optionally forcing the duration string to be reparsed.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn toggle_timer(
|
fn toggle_timer(
|
||||||
input: Option<String>,
|
input: Option<String>,
|
||||||
@@ -65,30 +79,36 @@ fn toggle_timer(
|
|||||||
app_state.toggle_timer(input, force_parse)
|
app_state.toggle_timer(input, force_parse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resets the timer to its initial duration and stops any active countdown.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn reset_timer(app_state: tauri::State<AppState>) -> FrontendState {
|
fn reset_timer(app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
log::debug!("reset_timer invoked");
|
log::debug!("reset_timer invoked");
|
||||||
app_state.reset_timer()
|
app_state.reset_timer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts or pauses the stopwatch while retaining any accumulated elapsed time.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn toggle_stopwatch(app_state: tauri::State<AppState>) -> FrontendState {
|
fn toggle_stopwatch(app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
log::info!("toggle_stopwatch invoked");
|
log::info!("toggle_stopwatch invoked");
|
||||||
app_state.toggle_stopwatch()
|
app_state.toggle_stopwatch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records a lap entry at the stopwatch's current elapsed time.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn add_lap(app_state: tauri::State<AppState>) -> FrontendState {
|
fn add_lap(app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
log::debug!("add_lap invoked");
|
log::debug!("add_lap invoked");
|
||||||
app_state.add_lap()
|
app_state.add_lap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears the stopwatch, deleting all laps and resetting the elapsed counter.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn reset_stopwatch(app_state: tauri::State<AppState>) -> FrontendState {
|
fn reset_stopwatch(app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
log::debug!("reset_stopwatch invoked");
|
log::debug!("reset_stopwatch invoked");
|
||||||
app_state.reset_stopwatch()
|
app_state.reset_stopwatch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initializes the Tauri runtime, configures plugins, and wires shared
|
||||||
|
/// application state into every window.
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let context = tauri::generate_context!();
|
let context = tauri::generate_context!();
|
||||||
@@ -130,6 +150,8 @@ pub fn run() {
|
|||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determines the log level from environment variables, falling back to a
|
||||||
|
/// debug build aware default.
|
||||||
fn resolve_log_level() -> LevelFilter {
|
fn resolve_log_level() -> LevelFilter {
|
||||||
let env_value = std::env::var("TAUCLOCK_LOG_LEVEL")
|
let env_value = std::env::var("TAUCLOCK_LOG_LEVEL")
|
||||||
.or_else(|_| std::env::var("TAUCLOCK_LOG"))
|
.or_else(|_| std::env::var("TAUCLOCK_LOG"))
|
||||||
@@ -151,6 +173,8 @@ fn resolve_log_level() -> LevelFilter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves the per-user storage file, ensuring persisted alarms live under an
|
||||||
|
/// identifier-scoped directory.
|
||||||
fn resolve_storage_path(config: &tauri::Config) -> PathBuf {
|
fn resolve_storage_path(config: &tauri::Config) -> PathBuf {
|
||||||
let mut dir = dirs::data_dir()
|
let mut dir = dirs::data_dir()
|
||||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||||
|
|||||||
@@ -11,16 +11,23 @@ use std::{
|
|||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Duration (ms) an informational status message remains visible.
|
||||||
const STATUS_DURATION_MS: i64 = 4000;
|
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;
|
const RINGING_DURATION_MS: i64 = 6000;
|
||||||
|
/// Minimum timer duration accepted from the UI to avoid accidental zero timers.
|
||||||
const TIMER_MIN_MS: u64 = 1000;
|
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 {
|
pub struct AppState {
|
||||||
inner: Mutex<StateInner>,
|
inner: Mutex<StateInner>,
|
||||||
storage_path: PathBuf,
|
storage_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
/// Constructs a new state container and eagerly loads persisted alarms.
|
||||||
pub fn new(storage_path: PathBuf) -> Self {
|
pub fn new(storage_path: PathBuf) -> Self {
|
||||||
let mut inner = StateInner::default();
|
let mut inner = StateInner::default();
|
||||||
if let Some(list) = load_alarms(&storage_path) {
|
if let Some(list) = load_alarms(&storage_path) {
|
||||||
@@ -44,56 +51,69 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a `FrontendState` without mutating anything. Primarily used when
|
||||||
|
/// the UI first boots.
|
||||||
pub fn snapshot(&self) -> FrontendState {
|
pub fn snapshot(&self) -> FrontendState {
|
||||||
trace!("Building snapshot for frontend");
|
trace!("Building snapshot for frontend");
|
||||||
self.apply(|_, _, _| {})
|
self.apply(|_, _, _| {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Executes the housekeeping refresh logic and returns an updated snapshot.
|
||||||
pub fn tick(&self) -> FrontendState {
|
pub fn tick(&self) -> FrontendState {
|
||||||
trace!("Processing tick request");
|
trace!("Processing tick request");
|
||||||
self.apply(|_, _, _| {})
|
self.apply(|_, _, _| {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a new alarm entry using the provided time and label.
|
||||||
pub fn create_alarm(&self, time: String, label: Option<String>) -> FrontendState {
|
pub fn create_alarm(&self, time: String, label: Option<String>) -> FrontendState {
|
||||||
self.apply(|inner, now_ms, now_local| {
|
self.apply(|inner, now_ms, now_local| {
|
||||||
inner.create_alarm(&time, label.as_deref(), 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 {
|
pub fn toggle_alarm(&self, id: String) -> FrontendState {
|
||||||
self.apply(|inner, _now_ms, now_local| {
|
self.apply(|inner, _now_ms, now_local| {
|
||||||
inner.toggle_alarm(&id, now_local);
|
inner.toggle_alarm(&id, now_local);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes an alarm permanently.
|
||||||
pub fn delete_alarm(&self, id: String) -> FrontendState {
|
pub fn delete_alarm(&self, id: String) -> FrontendState {
|
||||||
self.apply(|inner, now_ms, _| {
|
self.apply(|inner, now_ms, _| {
|
||||||
inner.delete_alarm(&id, 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<String>, force_parse: bool) -> FrontendState {
|
pub fn toggle_timer(&self, input: Option<String>, force_parse: bool) -> FrontendState {
|
||||||
self.apply(|inner, now_ms, _| {
|
self.apply(|inner, now_ms, _| {
|
||||||
inner.toggle_timer(input.as_deref(), force_parse, 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 {
|
pub fn reset_timer(&self) -> FrontendState {
|
||||||
self.apply(|inner, _, _| inner.reset_timer())
|
self.apply(|inner, _, _| inner.reset_timer())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts or pauses the stopwatch.
|
||||||
pub fn toggle_stopwatch(&self) -> FrontendState {
|
pub fn toggle_stopwatch(&self) -> FrontendState {
|
||||||
self.apply(|inner, now_ms, _| inner.toggle_stopwatch(now_ms))
|
self.apply(|inner, now_ms, _| inner.toggle_stopwatch(now_ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records a lap for the stopwatch if running.
|
||||||
pub fn add_lap(&self) -> FrontendState {
|
pub fn add_lap(&self) -> FrontendState {
|
||||||
self.apply(|inner, now_ms, _| inner.add_lap(now_ms))
|
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 {
|
pub fn reset_stopwatch(&self) -> FrontendState {
|
||||||
self.apply(|inner, _, _| inner.reset_stopwatch())
|
self.apply(|inner, _, _| inner.reset_stopwatch())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Executes a state mutation closure while handling locking, refresh logic,
|
||||||
|
/// snapshot generation, and persistence side effects.
|
||||||
fn apply<F>(&self, mutator: F) -> FrontendState
|
fn apply<F>(&self, mutator: F) -> FrontendState
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut StateInner, i64, &DateTime<Local>),
|
F: FnOnce(&mut StateInner, i64, &DateTime<Local>),
|
||||||
@@ -125,6 +145,7 @@ impl AppState {
|
|||||||
snapshot
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists the alarm list to disk, creating directories as needed.
|
||||||
fn save_alarms(&self, alarms: &[PersistedAlarm]) {
|
fn save_alarms(&self, alarms: &[PersistedAlarm]) {
|
||||||
if let Some(parent) = self.storage_path.parent() {
|
if let Some(parent) = self.storage_path.parent() {
|
||||||
if let Err(error) = fs::create_dir_all(parent) {
|
if let Err(error) = fs::create_dir_all(parent) {
|
||||||
@@ -161,6 +182,7 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
/// Mutable inner state containing alarms, timers, and transient UI messaging.
|
||||||
struct StateInner {
|
struct StateInner {
|
||||||
alarms: Vec<Alarm>,
|
alarms: Vec<Alarm>,
|
||||||
timer: TimerState,
|
timer: TimerState,
|
||||||
@@ -171,12 +193,14 @@ struct StateInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StateInner {
|
impl StateInner {
|
||||||
|
/// Executes periodic tasks that should run on every mutation or tick.
|
||||||
fn refresh(&mut self, now_ms: i64, now_local: &DateTime<Local>) {
|
fn refresh(&mut self, now_ms: i64, now_local: &DateTime<Local>) {
|
||||||
self.process_alarms(now_ms, now_local);
|
self.process_alarms(now_ms, now_local);
|
||||||
self.update_timer(now_ms);
|
self.update_timer(now_ms);
|
||||||
self.expire_status(now_ms);
|
self.expire_status(now_ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds a full [`FrontendState`] representation of the current data.
|
||||||
fn to_frontend(&mut self, now_local: &DateTime<Local>) -> FrontendState {
|
fn to_frontend(&mut self, now_local: &DateTime<Local>) -> FrontendState {
|
||||||
let now_ms = now_local.timestamp_millis();
|
let now_ms = now_local.timestamp_millis();
|
||||||
let now_utc = now_local.with_timezone(&Utc);
|
let now_utc = now_local.with_timezone(&Utc);
|
||||||
@@ -201,6 +225,7 @@ impl StateInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds a new alarm and schedules its next trigger time.
|
||||||
fn create_alarm(
|
fn create_alarm(
|
||||||
&mut self,
|
&mut self,
|
||||||
raw_time: &str,
|
raw_time: &str,
|
||||||
@@ -235,6 +260,7 @@ impl StateInner {
|
|||||||
self.push_status(&format!("アラーム追加: {}", label_text), now_ms);
|
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<Local>) {
|
fn toggle_alarm(&mut self, id: &str, now_local: &DateTime<Local>) {
|
||||||
if let Some(alarm) = self.alarms.iter_mut().find(|alarm| alarm.id == id) {
|
if let Some(alarm) = self.alarms.iter_mut().find(|alarm| alarm.id == id) {
|
||||||
alarm.active = !alarm.active;
|
alarm.active = !alarm.active;
|
||||||
@@ -251,6 +277,7 @@ impl StateInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes an alarm completely.
|
||||||
fn delete_alarm(&mut self, id: &str, now_ms: i64) {
|
fn delete_alarm(&mut self, id: &str, now_ms: i64) {
|
||||||
if let Some(index) = self.alarms.iter().position(|alarm| alarm.id == id) {
|
if let Some(index) = self.alarms.iter().position(|alarm| alarm.id == id) {
|
||||||
let removed = self.alarms.remove(index);
|
let removed = self.alarms.remove(index);
|
||||||
@@ -262,6 +289,7 @@ impl StateInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles timer start/pause logic, parsing inputs when necessary.
|
||||||
fn toggle_timer(&mut self, input: Option<&str>, force_parse: bool, now_ms: i64) {
|
fn toggle_timer(&mut self, input: Option<&str>, force_parse: bool, now_ms: i64) {
|
||||||
if self.timer.running {
|
if self.timer.running {
|
||||||
if let Some(target) = self.timer.target_epoch_ms {
|
if let Some(target) = self.timer.target_epoch_ms {
|
||||||
@@ -317,6 +345,7 @@ impl StateInner {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restores the timer to its initial duration without starting it.
|
||||||
fn reset_timer(&mut self) {
|
fn reset_timer(&mut self) {
|
||||||
self.timer.running = false;
|
self.timer.running = false;
|
||||||
self.timer.target_epoch_ms = None;
|
self.timer.target_epoch_ms = None;
|
||||||
@@ -324,6 +353,7 @@ impl StateInner {
|
|||||||
info!("Timer reset to {}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) {
|
fn toggle_stopwatch(&mut self, now_ms: i64) {
|
||||||
if self.stopwatch.running {
|
if self.stopwatch.running {
|
||||||
if let Some(start) = self.stopwatch.start_epoch_ms {
|
if let Some(start) = self.stopwatch.start_epoch_ms {
|
||||||
@@ -340,6 +370,8 @@ impl StateInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
fn add_lap(&mut self, now_ms: i64) {
|
||||||
let elapsed = self.stopwatch_elapsed(now_ms);
|
let elapsed = self.stopwatch_elapsed(now_ms);
|
||||||
if elapsed == 0 {
|
if elapsed == 0 {
|
||||||
@@ -369,6 +401,7 @@ impl StateInner {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stops the stopwatch and clears any lap history.
|
||||||
fn reset_stopwatch(&mut self) {
|
fn reset_stopwatch(&mut self) {
|
||||||
self.stopwatch.running = false;
|
self.stopwatch.running = false;
|
||||||
self.stopwatch.start_epoch_ms = None;
|
self.stopwatch.start_epoch_ms = None;
|
||||||
@@ -377,6 +410,8 @@ impl StateInner {
|
|||||||
info!("Stopwatch reset");
|
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<Local>) {
|
fn process_alarms(&mut self, now_ms: i64, now_local: &DateTime<Local>) {
|
||||||
let mut triggered_labels = Vec::new();
|
let mut triggered_labels = Vec::new();
|
||||||
for alarm in &mut self.alarms {
|
for alarm in &mut self.alarms {
|
||||||
@@ -408,6 +443,8 @@ impl StateInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates timer countdown bookkeeping and fires completion status when the
|
||||||
|
/// clock hits zero.
|
||||||
fn update_timer(&mut self, now_ms: i64) {
|
fn update_timer(&mut self, now_ms: i64) {
|
||||||
if self.timer.running {
|
if self.timer.running {
|
||||||
if let Some(target) = self.timer.target_epoch_ms {
|
if let Some(target) = self.timer.target_epoch_ms {
|
||||||
@@ -425,6 +462,7 @@ impl StateInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Expires the active status message once its TTL elapses.
|
||||||
fn expire_status(&mut self, now_ms: i64) {
|
fn expire_status(&mut self, now_ms: i64) {
|
||||||
if let Some(status) = &self.status {
|
if let Some(status) = &self.status {
|
||||||
if status.expires_at <= now_ms {
|
if status.expires_at <= now_ms {
|
||||||
@@ -433,6 +471,7 @@ impl StateInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the alarm section view model that the frontend expects.
|
||||||
fn build_alarm_view(&self, now_ms: i64) -> AlarmSection {
|
fn build_alarm_view(&self, now_ms: i64) -> AlarmSection {
|
||||||
let mut sorted = self.alarms.iter().collect::<Vec<_>>();
|
let mut sorted = self.alarms.iter().collect::<Vec<_>>();
|
||||||
sorted.sort_by_key(|alarm| alarm.next_trigger);
|
sorted.sort_by_key(|alarm| alarm.next_trigger);
|
||||||
@@ -471,6 +510,7 @@ impl StateInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the timer view model by formatting durations and button labels.
|
||||||
fn build_timer_view(&self) -> TimerView {
|
fn build_timer_view(&self) -> TimerView {
|
||||||
let display_ms = if self.timer.running {
|
let display_ms = if self.timer.running {
|
||||||
self.timer.remaining_ms
|
self.timer.remaining_ms
|
||||||
@@ -502,6 +542,7 @@ impl StateInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the stopwatch view model including lap breakdowns.
|
||||||
fn build_stopwatch_view(&self, now_ms: i64) -> StopwatchView {
|
fn build_stopwatch_view(&self, now_ms: i64) -> StopwatchView {
|
||||||
let elapsed = self.stopwatch_elapsed(now_ms);
|
let elapsed = self.stopwatch_elapsed(now_ms);
|
||||||
let toggle_label = if self.stopwatch.running {
|
let toggle_label = if self.stopwatch.running {
|
||||||
@@ -517,6 +558,8 @@ impl StateInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
fn stopwatch_elapsed(&self, now_ms: i64) -> u64 {
|
||||||
self.stopwatch.elapsed_before_ms
|
self.stopwatch.elapsed_before_ms
|
||||||
+ if self.stopwatch.running {
|
+ if self.stopwatch.running {
|
||||||
@@ -526,6 +569,7 @@ impl StateInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stores a transient status message that the UI can render for users.
|
||||||
fn push_status(&mut self, message: &str, now_ms: i64) {
|
fn push_status(&mut self, message: &str, now_ms: i64) {
|
||||||
self.status = Some(StatusMessage {
|
self.status = Some(StatusMessage {
|
||||||
text: message.to_string(),
|
text: message.to_string(),
|
||||||
@@ -575,6 +619,7 @@ struct StatusMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
/// Comprehensive state payload consumed by the frontend.
|
||||||
pub struct FrontendState {
|
pub struct FrontendState {
|
||||||
pub clock: ClockState,
|
pub clock: ClockState,
|
||||||
pub alarms: AlarmSection,
|
pub alarms: AlarmSection,
|
||||||
@@ -585,6 +630,7 @@ pub struct FrontendState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
/// Clock label strings rendered in the UI.
|
||||||
pub struct ClockState {
|
pub struct ClockState {
|
||||||
pub local_time: String,
|
pub local_time: String,
|
||||||
pub local_date: String,
|
pub local_date: String,
|
||||||
@@ -593,12 +639,14 @@ pub struct ClockState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
/// Aggregated alarm listing plus small summary for the next trigger.
|
||||||
pub struct AlarmSection {
|
pub struct AlarmSection {
|
||||||
pub items: Vec<AlarmView>,
|
pub items: Vec<AlarmView>,
|
||||||
pub next_summary: String,
|
pub next_summary: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
/// Individual alarm entry displayed in the frontend list.
|
||||||
pub struct AlarmView {
|
pub struct AlarmView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -609,6 +657,7 @@ pub struct AlarmView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
/// Timer labels consumed by the UI controls.
|
||||||
pub struct TimerView {
|
pub struct TimerView {
|
||||||
pub display: String,
|
pub display: String,
|
||||||
pub toggle_label: String,
|
pub toggle_label: String,
|
||||||
@@ -616,6 +665,7 @@ pub struct TimerView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
/// Stopwatch value for the UI including lap breakdowns.
|
||||||
pub struct StopwatchView {
|
pub struct StopwatchView {
|
||||||
pub display: String,
|
pub display: String,
|
||||||
pub toggle_label: String,
|
pub toggle_label: String,
|
||||||
@@ -623,6 +673,7 @@ pub struct StopwatchView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
/// Single lap renderable information.
|
||||||
pub struct LapView {
|
pub struct LapView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -631,6 +682,7 @@ pub struct LapView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
/// Minimal alarm representation stored on disk.
|
||||||
struct PersistedAlarm {
|
struct PersistedAlarm {
|
||||||
id: String,
|
id: String,
|
||||||
hour: u8,
|
hour: u8,
|
||||||
@@ -640,6 +692,7 @@ struct PersistedAlarm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
|
/// Wrapper used when serializing alarm lists to JSON.
|
||||||
struct AlarmStore {
|
struct AlarmStore {
|
||||||
alarms: Vec<PersistedAlarm>,
|
alarms: Vec<PersistedAlarm>,
|
||||||
}
|
}
|
||||||
@@ -656,6 +709,7 @@ impl From<&Alarm> for PersistedAlarm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attempts to load the alarm store JSON file from disk.
|
||||||
fn load_alarms(path: &Path) -> Option<Vec<PersistedAlarm>> {
|
fn load_alarms(path: &Path) -> Option<Vec<PersistedAlarm>> {
|
||||||
match fs::read(path) {
|
match fs::read(path) {
|
||||||
Ok(bytes) => match serde_json::from_slice::<AlarmStore>(&bytes) {
|
Ok(bytes) => match serde_json::from_slice::<AlarmStore>(&bytes) {
|
||||||
@@ -678,18 +732,23 @@ fn load_alarms(path: &Path) -> Option<Vec<PersistedAlarm>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats a timestamp as HH:MM:SS.
|
||||||
fn format_time(hour: u32, minute: u32, second: u32) -> String {
|
fn format_time(hour: u32, minute: u32, second: u32) -> String {
|
||||||
format!("{:02}:{:02}:{:02}", hour, minute, second)
|
format!("{:02}:{:02}:{:02}", hour, minute, second)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats a date as YYYY-MM-DD.
|
||||||
fn format_date(year: i32, month: u32, day: u32) -> String {
|
fn format_date(year: i32, month: u32, day: u32) -> String {
|
||||||
format!("{year:04}-{month:02}-{day:02}")
|
format!("{year:04}-{month:02}-{day:02}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats an alarm display label.
|
||||||
fn format_time_label(hour: u8, minute: u8) -> String {
|
fn format_time_label(hour: u8, minute: u8) -> String {
|
||||||
format!("{:02}:{:02}", hour, minute)
|
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 {
|
fn format_relative(target_ms: i64, now_ms: i64) -> String {
|
||||||
let diff = target_ms - now_ms;
|
let diff = target_ms - now_ms;
|
||||||
if diff <= 0 {
|
if diff <= 0 {
|
||||||
@@ -707,6 +766,7 @@ fn format_relative(target_ms: i64, now_ms: i64) -> String {
|
|||||||
format!("{}時間{}分後", hours, mins)
|
format!("{}時間{}分後", hours, mins)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats a duration for timer display, switching to HH:MM:SS when needed.
|
||||||
fn format_duration(ms: u64) -> String {
|
fn format_duration(ms: u64) -> String {
|
||||||
let total_seconds = (ms / 1000) as u64;
|
let total_seconds = (ms / 1000) as u64;
|
||||||
let minutes = total_seconds / 60;
|
let minutes = total_seconds / 60;
|
||||||
@@ -721,6 +781,7 @@ fn format_duration(ms: u64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formats stopwatch durations with millisecond precision.
|
||||||
fn format_stopwatch(ms: u64) -> String {
|
fn format_stopwatch(ms: u64) -> String {
|
||||||
let total_seconds = ms / 1000;
|
let total_seconds = ms / 1000;
|
||||||
let minutes = total_seconds / 60;
|
let minutes = total_seconds / 60;
|
||||||
@@ -729,6 +790,7 @@ fn format_stopwatch(ms: u64) -> String {
|
|||||||
format!("{:02}:{:02}.{:03}", minutes, seconds, millis)
|
format!("{:02}:{:02}.{:03}", minutes, seconds, millis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts raw lap entries into renderable view data.
|
||||||
fn build_lap_views(entries: &[LapEntry]) -> Vec<LapView> {
|
fn build_lap_views(entries: &[LapEntry]) -> Vec<LapView> {
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -747,6 +809,7 @@ fn build_lap_views(entries: &[LapEntry]) -> Vec<LapView> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses HH:MM strings emitted by the UI.
|
||||||
fn parse_alarm_time(value: &str) -> Option<(u8, u8)> {
|
fn parse_alarm_time(value: &str) -> Option<(u8, u8)> {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
let mut parts = trimmed.split(':');
|
let mut parts = trimmed.split(':');
|
||||||
@@ -758,6 +821,7 @@ fn parse_alarm_time(value: &str) -> Option<(u8, u8)> {
|
|||||||
Some((hour, minute))
|
Some((hour, minute))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses timer duration input supporting seconds, MM:SS, or `1h 30m` formats.
|
||||||
fn parse_duration(value: &str) -> Option<u64> {
|
fn parse_duration(value: &str) -> Option<u64> {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -778,6 +842,7 @@ fn parse_duration(value: &str) -> Option<u64> {
|
|||||||
parse_unit_duration(trimmed)
|
parse_unit_duration(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper that parses unit-suffixed duration tokens like `10m 5s`.
|
||||||
fn parse_unit_duration(value: &str) -> Option<u64> {
|
fn parse_unit_duration(value: &str) -> Option<u64> {
|
||||||
let mut total_ms = 0u64;
|
let mut total_ms = 0u64;
|
||||||
for chunk in value.split_whitespace() {
|
for chunk in value.split_whitespace() {
|
||||||
@@ -805,6 +870,7 @@ fn parse_unit_duration(value: &str) -> Option<u64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculates the next trigger timestamp in milliseconds for an alarm.
|
||||||
fn resolve_next_trigger(hour: u8, minute: u8, now_local: &DateTime<Local>) -> i64 {
|
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)
|
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());
|
.unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap());
|
||||||
@@ -816,6 +882,8 @@ fn resolve_next_trigger(hour: u8, minute: u8, now_local: &DateTime<Local>) -> i6
|
|||||||
candidate.timestamp_millis()
|
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<Local> {
|
fn combine_local(date: NaiveDate, time: NaiveTime) -> DateTime<Local> {
|
||||||
let naive = NaiveDateTime::new(date, time);
|
let naive = NaiveDateTime::new(date, time);
|
||||||
match Local.from_local_datetime(&naive) {
|
match Local.from_local_datetime(&naive) {
|
||||||
|
|||||||
82
src/main.js
82
src/main.js
@@ -1,7 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Static prefix string used in every log entry to make browser console
|
||||||
|
* filtering easier when multiple apps share the same window.
|
||||||
|
*/
|
||||||
const LOG_PREFIX = "[tauclock]";
|
const LOG_PREFIX = "[tauclock]";
|
||||||
|
/**
|
||||||
|
* Permitted log levels ordered by severity so we can determine whether a
|
||||||
|
* message should be printed by comparing their indexes.
|
||||||
|
*/
|
||||||
const LOG_LEVELS = ["error", "warn", "info", "debug"];
|
const LOG_LEVELS = ["error", "warn", "info", "debug"];
|
||||||
|
/**
|
||||||
|
* Effective log level obtained either from localStorage, a query parameter or
|
||||||
|
* the default, which then gates every log call in the frontend.
|
||||||
|
*/
|
||||||
const LOG_LEVEL = resolveLogLevel();
|
const LOG_LEVEL = resolveLogLevel();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSDoc log wrapper that normalizes console usage and ensures we respect the
|
||||||
|
* active log level before emitting potentially noisy diagnostic data.
|
||||||
|
*/
|
||||||
function logFrontend(level, message, details) {
|
function logFrontend(level, message, details) {
|
||||||
if (!shouldLog(level)) return;
|
if (!shouldLog(level)) return;
|
||||||
const output = `${LOG_PREFIX} ${message}`;
|
const output = `${LOG_PREFIX} ${message}`;
|
||||||
@@ -18,6 +34,10 @@ const logWarn = (message, details) => logFrontend("warn", message, details);
|
|||||||
const logDebug = (message, details) => logFrontend("debug", message, details);
|
const logDebug = (message, details) => logFrontend("debug", message, details);
|
||||||
const logError = (message, details) => logFrontend("error", message, details);
|
const logError = (message, details) => logFrontend("error", message, details);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the requested log level is enabled by comparing the
|
||||||
|
* requested level's index with the configured LOG_LEVEL bucket.
|
||||||
|
*/
|
||||||
function shouldLog(level) {
|
function shouldLog(level) {
|
||||||
const requested = LOG_LEVELS.indexOf(level);
|
const requested = LOG_LEVELS.indexOf(level);
|
||||||
const active = LOG_LEVELS.indexOf(LOG_LEVEL);
|
const active = LOG_LEVELS.indexOf(LOG_LEVEL);
|
||||||
@@ -25,6 +45,10 @@ function shouldLog(level) {
|
|||||||
return requested <= active;
|
return requested <= active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the preferred log level by first checking localStorage, then the
|
||||||
|
* `logLevel` URL parameter, and finally falling back to `info`.
|
||||||
|
*/
|
||||||
function resolveLogLevel() {
|
function resolveLogLevel() {
|
||||||
const stored = window.localStorage?.getItem("tauclock:log-level");
|
const stored = window.localStorage?.getItem("tauclock:log-level");
|
||||||
const normalizedStored = stored?.toLowerCase();
|
const normalizedStored = stored?.toLowerCase();
|
||||||
@@ -39,6 +63,11 @@ function resolveLogLevel() {
|
|||||||
|
|
||||||
const invoke = resolveInvoke();
|
const invoke = resolveInvoke();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the best available Tauri invoke bridge and emits structured debug
|
||||||
|
* information for troubleshooting. If the bridge is missing, a stub is
|
||||||
|
* returned so the UI can fail loudly rather than silently.
|
||||||
|
*/
|
||||||
function resolveInvoke() {
|
function resolveInvoke() {
|
||||||
const tauriGlobal = window.__TAURI__ || {};
|
const tauriGlobal = window.__TAURI__ || {};
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@@ -59,10 +88,12 @@ function resolveInvoke() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cache of DOM nodes keyed by logical role identifiers. */
|
||||||
const elements = {};
|
const elements = {};
|
||||||
let tickInFlight = false;
|
let tickInFlight = false;
|
||||||
let latestState = null;
|
let latestState = null;
|
||||||
|
|
||||||
|
/** Keyboard shortcut handlers keyed by `KeyboardEvent.code`. */
|
||||||
const SHORTCUTS = {
|
const SHORTCUTS = {
|
||||||
KeyA: () => focusAndSelect(elements.alarmTime),
|
KeyA: () => focusAndSelect(elements.alarmTime),
|
||||||
KeyN: () => elements.alarmForm.requestSubmit(),
|
KeyN: () => elements.alarmForm.requestSubmit(),
|
||||||
@@ -82,6 +113,10 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||||||
bootstrap();
|
bootstrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds and stores all the DOM nodes we need to reference frequently so we
|
||||||
|
* avoid repeated lookups every render.
|
||||||
|
*/
|
||||||
function cacheElements() {
|
function cacheElements() {
|
||||||
elements.localTime = document.getElementById("local-time");
|
elements.localTime = document.getElementById("local-time");
|
||||||
elements.localDate = document.getElementById("local-date");
|
elements.localDate = document.getElementById("local-date");
|
||||||
@@ -116,6 +151,10 @@ function cacheElements() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks up all DOM events, including keyboard shortcuts, button clicks, and
|
||||||
|
* form submissions, translating them into backend commands where needed.
|
||||||
|
*/
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
elements.alarmForm.addEventListener("submit", async (event) => {
|
elements.alarmForm.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -164,6 +203,10 @@ function bindEvents() {
|
|||||||
logInfo("Core UI events bound");
|
logInfo("Core UI events bound");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the initial state fetch and schedules the recurring `tick` loop so
|
||||||
|
* that the UI stays synchronized with the backend clock.
|
||||||
|
*/
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
logInfo("Bootstrapping state");
|
logInfo("Bootstrapping state");
|
||||||
await requestState("get_state");
|
await requestState("get_state");
|
||||||
@@ -171,6 +214,10 @@ async function bootstrap() {
|
|||||||
logInfo("Tick loop scheduled", { intervalMs: 250 });
|
logInfo("Tick loop scheduled", { intervalMs: 250 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests a timer toggle from the backend, optionally forcing a reparse of
|
||||||
|
* the duration input if the user explicitly pressed Enter inside the field.
|
||||||
|
*/
|
||||||
function triggerTimerToggle(forceParse) {
|
function triggerTimerToggle(forceParse) {
|
||||||
logDebug("Timer toggle requested", { forceParse, input: elements.timerInput.value });
|
logDebug("Timer toggle requested", { forceParse, input: elements.timerInput.value });
|
||||||
requestState("toggle_timer", {
|
requestState("toggle_timer", {
|
||||||
@@ -179,6 +226,10 @@ function triggerTimerToggle(forceParse) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic wrapper around the Tauri invoke API that logs both the request and
|
||||||
|
* response, applies the new state snapshot, and gracefully handles failures.
|
||||||
|
*/
|
||||||
async function requestState(command, payload = {}) {
|
async function requestState(command, payload = {}) {
|
||||||
try {
|
try {
|
||||||
logInfo(`Invoking backend command: ${command}`, payload);
|
logInfo(`Invoking backend command: ${command}`, payload);
|
||||||
@@ -195,6 +246,11 @@ async function requestState(command, payload = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polling task that asks the backend for a light-weight state update so that
|
||||||
|
* timers, alarms, and stopwatches continue to advance even without user
|
||||||
|
* interaction. Re-entrancy is avoided via `tickInFlight`.
|
||||||
|
*/
|
||||||
async function runTick() {
|
async function runTick() {
|
||||||
if (tickInFlight) {
|
if (tickInFlight) {
|
||||||
logDebug("Tick skipped because a previous request is still pending");
|
logDebug("Tick skipped because a previous request is still pending");
|
||||||
@@ -213,6 +269,10 @@ async function runTick() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a backend-provided snapshot to every UI subsystem (clock, alarms,
|
||||||
|
* timer, stopwatch, and status messaging). Also plays the chime if requested.
|
||||||
|
*/
|
||||||
function applyState(state) {
|
function applyState(state) {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
latestState = state;
|
latestState = state;
|
||||||
@@ -231,6 +291,7 @@ function applyState(state) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-renders the clock labels for both local time and UTC. */
|
||||||
function updateClock(clock) {
|
function updateClock(clock) {
|
||||||
if (!clock) return;
|
if (!clock) return;
|
||||||
elements.localTime.textContent = clock.local_time;
|
elements.localTime.textContent = clock.local_time;
|
||||||
@@ -239,6 +300,10 @@ function updateClock(clock) {
|
|||||||
elements.utcDate.textContent = clock.utc_date;
|
elements.utcDate.textContent = clock.utc_date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the alarm list including badges, actions, and empty states, while
|
||||||
|
* also surfacing the next upcoming alarm summary.
|
||||||
|
*/
|
||||||
function renderAlarms(section) {
|
function renderAlarms(section) {
|
||||||
const list = elements.alarmList;
|
const list = elements.alarmList;
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
@@ -280,6 +345,7 @@ function renderAlarms(section) {
|
|||||||
elements.nextAlarm.textContent = section ? section.next_summary : "";
|
elements.nextAlarm.textContent = section ? section.next_summary : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reflects the backend timer information in the UI controls and labels. */
|
||||||
function renderTimer(view) {
|
function renderTimer(view) {
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
elements.timerDisplay.textContent = view.display;
|
elements.timerDisplay.textContent = view.display;
|
||||||
@@ -287,6 +353,7 @@ function renderTimer(view) {
|
|||||||
elements.timerStatus.textContent = view.status_text || "";
|
elements.timerStatus.textContent = view.status_text || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Displays stopwatch totals, the toggle label, and lap entries. */
|
||||||
function renderStopwatch(view) {
|
function renderStopwatch(view) {
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
elements.stopwatchDisplay.textContent = view.display;
|
elements.stopwatchDisplay.textContent = view.display;
|
||||||
@@ -313,17 +380,23 @@ function renderStopwatch(view) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shows transient status messages returned by the backend. */
|
||||||
function renderStatus(message) {
|
function renderStatus(message) {
|
||||||
if (!elements.statusMessage) return;
|
if (!elements.statusMessage) return;
|
||||||
elements.statusMessage.textContent = message || "";
|
elements.statusMessage.textContent = message || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Focuses a control and selects its text when possible for easy overwriting. */
|
||||||
function focusAndSelect(element) {
|
function focusAndSelect(element) {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
element.focus();
|
element.focus();
|
||||||
if (typeof element.select === "function") element.select();
|
if (typeof element.select === "function") element.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the keyboard shortcut cheat sheet overlay, optionally forcing the
|
||||||
|
* visibility state when invoked with a boolean.
|
||||||
|
*/
|
||||||
function toggleShortcutOverlay(force) {
|
function toggleShortcutOverlay(force) {
|
||||||
const overlay = elements.shortcutOverlay;
|
const overlay = elements.shortcutOverlay;
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
@@ -335,6 +408,10 @@ function toggleShortcutOverlay(force) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global keyboard listener that translates single-key shortcuts into higher
|
||||||
|
* level actions while respecting modifier keys, focus states, and overlays.
|
||||||
|
*/
|
||||||
function handleShortcuts(event) {
|
function handleShortcuts(event) {
|
||||||
if (event.defaultPrevented) return;
|
if (event.defaultPrevented) return;
|
||||||
const overlayVisible = elements.shortcutOverlay && !elements.shortcutOverlay.hidden;
|
const overlayVisible = elements.shortcutOverlay && !elements.shortcutOverlay.hidden;
|
||||||
@@ -370,6 +447,7 @@ function handleShortcuts(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns true when the event target is an editable/typing-friendly element. */
|
||||||
function isTypingTarget(target) {
|
function isTypingTarget(target) {
|
||||||
if (!target) return false;
|
if (!target) return false;
|
||||||
const element = target instanceof Element ? target : target.parentElement;
|
const element = target instanceof Element ? target : target.parentElement;
|
||||||
@@ -378,6 +456,10 @@ function isTypingTarget(target) {
|
|||||||
return Boolean(element.closest("input, textarea, select, [contenteditable='true']"));
|
return Boolean(element.closest("input, textarea, select, [contenteditable='true']"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays a very small synthesized chime using the Web Audio API in order to
|
||||||
|
* alert the user even when no native notifications are available.
|
||||||
|
*/
|
||||||
function playChime(patternName = "alarm") {
|
function playChime(patternName = "alarm") {
|
||||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||||
if (!AudioCtx) return;
|
if (!AudioCtx) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user