Improve logging controls
This commit is contained in:
@@ -47,6 +47,13 @@ npm run tauri dev
|
|||||||
```
|
```
|
||||||
ホットリロード付きでウィンドウが立ち上がり、UIから各機能を試せます。
|
ホットリロード付きでウィンドウが立ち上がり、UIから各機能を試せます。
|
||||||
|
|
||||||
|
### ログレベルの切り替え
|
||||||
|
- フロントエンド: 既定で `info` レベルのみ表示します。`localStorage.setItem('tauclock:log-level', 'debug')` か
|
||||||
|
`?logLevel=debug` をクエリに付けると詳細ログが有効になります。
|
||||||
|
- バックエンド: `TAUCLOCK_LOG_LEVEL`(または互換の `TAUCLOCK_LOG`)環境変数で制御できます。
|
||||||
|
例: `TAUCLOCK_LOG_LEVEL=trace npm run tauri dev`。
|
||||||
|
未設定の場合はデバッグビルドが `debug`、リリースビルドが `info` になります。
|
||||||
|
|
||||||
### ビルド
|
### ビルド
|
||||||
```bash
|
```bash
|
||||||
npm run tauri build
|
npm run tauri build
|
||||||
|
|||||||
@@ -1,21 +1,164 @@
|
|||||||
//! Core TauClock backend that wires plugins and exposes Rust commands.
|
mod state;
|
||||||
//!
|
|
||||||
//! The UI currently uses only frontend logic, but the scaffold is documented
|
use log::LevelFilter;
|
||||||
//! so contributors know where to extend native capabilities (timers, IPC, etc).
|
use state::{AppState, FrontendState};
|
||||||
|
use std::{path::PathBuf, str::FromStr};
|
||||||
|
use tauri_plugin_log::{Target, TargetKind};
|
||||||
|
|
||||||
/// Example command exposed to the frontend. Extend or replace as needed.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
fn get_state(app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
log::debug!("get_state invoked");
|
||||||
|
app_state.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn tick(app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
|
log::trace!("tick invoked");
|
||||||
|
app_state.tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn create_alarm(
|
||||||
|
time: String,
|
||||||
|
label: Option<String>,
|
||||||
|
app_state: tauri::State<AppState>,
|
||||||
|
) -> FrontendState {
|
||||||
|
let has_label = label
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
log::info!(
|
||||||
|
"create_alarm invoked (time={time}, has_label={has_label})",
|
||||||
|
time = time,
|
||||||
|
has_label = has_label
|
||||||
|
);
|
||||||
|
app_state.create_alarm(time, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn toggle_alarm(id: String, app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
|
log::info!("toggle_alarm invoked (id={id})", id = id);
|
||||||
|
app_state.toggle_alarm(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_alarm(id: String, app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
|
log::info!("delete_alarm invoked (id={id})", id = id);
|
||||||
|
app_state.delete_alarm(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn toggle_timer(
|
||||||
|
input: Option<String>,
|
||||||
|
force_parse: bool,
|
||||||
|
app_state: tauri::State<AppState>,
|
||||||
|
) -> FrontendState {
|
||||||
|
let has_input = input
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
log::info!(
|
||||||
|
"toggle_timer invoked (force_parse={force_parse}, has_input={has_input})",
|
||||||
|
force_parse = force_parse,
|
||||||
|
has_input = has_input
|
||||||
|
);
|
||||||
|
app_state.toggle_timer(input, force_parse)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn reset_timer(app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
|
log::debug!("reset_timer invoked");
|
||||||
|
app_state.reset_timer()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn toggle_stopwatch(app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
|
log::info!("toggle_stopwatch invoked");
|
||||||
|
app_state.toggle_stopwatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn add_lap(app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
|
log::debug!("add_lap invoked");
|
||||||
|
app_state.add_lap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn reset_stopwatch(app_state: tauri::State<AppState>) -> FrontendState {
|
||||||
|
log::debug!("reset_stopwatch invoked");
|
||||||
|
app_state.reset_stopwatch()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
/// Build the Tauri application, attach plugins, and start the runtime loop.
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
let context = tauri::generate_context!();
|
||||||
|
let storage_path = resolve_storage_path(context.config());
|
||||||
|
let storage_display = storage_path.display().to_string();
|
||||||
|
let app_state = AppState::new(storage_path);
|
||||||
|
let log_level = resolve_log_level();
|
||||||
|
let log_plugin = tauri_plugin_log::Builder::new()
|
||||||
|
.targets([
|
||||||
|
Target::new(TargetKind::Stdout),
|
||||||
|
Target::new(TargetKind::Webview),
|
||||||
|
Target::new(TargetKind::LogDir { file_name: None }),
|
||||||
|
])
|
||||||
|
.level(log_level)
|
||||||
|
.build();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_log::Builder::new().build())
|
.manage(app_state)
|
||||||
|
.plugin(log_plugin)
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![greet])
|
.invoke_handler(tauri::generate_handler![
|
||||||
.run(tauri::generate_context!())
|
get_state,
|
||||||
|
tick,
|
||||||
|
create_alarm,
|
||||||
|
toggle_alarm,
|
||||||
|
delete_alarm,
|
||||||
|
toggle_timer,
|
||||||
|
reset_timer,
|
||||||
|
toggle_stopwatch,
|
||||||
|
add_lap,
|
||||||
|
reset_stopwatch
|
||||||
|
])
|
||||||
|
.setup(move |_| {
|
||||||
|
log::info!("Tauclock backend ready; storage_path={}", storage_display);
|
||||||
|
log::debug!("Backend log level set to {:?}", log_level);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.run(context)
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_log_level() -> LevelFilter {
|
||||||
|
let env_value = std::env::var("TAUCLOCK_LOG_LEVEL")
|
||||||
|
.or_else(|_| std::env::var("TAUCLOCK_LOG"))
|
||||||
|
.ok();
|
||||||
|
if let Some(raw) = env_value {
|
||||||
|
if let Ok(level) = LevelFilter::from_str(raw.trim()) {
|
||||||
|
return level;
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"[tauclock] Invalid TAUCLOCK_LOG_LEVEL '{}', falling back to default",
|
||||||
|
raw
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
LevelFilter::Debug
|
||||||
|
} else {
|
||||||
|
LevelFilter::Info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_storage_path(config: &tauri::Config) -> PathBuf {
|
||||||
|
let mut dir = dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||||
|
let identifier = if config.identifier.is_empty() {
|
||||||
|
"tauclock".to_string()
|
||||||
|
} else {
|
||||||
|
config.identifier.clone()
|
||||||
|
};
|
||||||
|
dir.push(identifier);
|
||||||
|
dir.join("alarms.json")
|
||||||
|
}
|
||||||
|
|||||||
727
src/main.js
727
src/main.js
@@ -1,94 +1,87 @@
|
|||||||
/**
|
const LOG_PREFIX = "[tauclock]";
|
||||||
* TauClock frontend controller
|
const LOG_LEVELS = ["error", "warn", "info", "debug"];
|
||||||
* ----------------------------
|
const LOG_LEVEL = resolveLogLevel();
|
||||||
* Coordinates three features:
|
|
||||||
* 1. Live clocks in local/UTC time
|
|
||||||
* 2. Persistent alarms with keyboard shortcuts and audible chimes
|
|
||||||
* 3. Timer + stopwatch utilities with a shared status message system
|
|
||||||
*
|
|
||||||
* The logic is split by feature area, and each function carries a short
|
|
||||||
* description so new contributors can navigate quickly.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
function logFrontend(level, message, details) {
|
||||||
* Namespaced keys used for browser localStorage.
|
if (!shouldLog(level)) return;
|
||||||
* Keeping the strings centralized makes future migrations trivial.
|
const output = `${LOG_PREFIX} ${message}`;
|
||||||
*/
|
const logger = console[level] || console.log;
|
||||||
const STORAGE_KEYS = {
|
if (details === undefined) {
|
||||||
alarms: "tauclock-alarms",
|
logger.call(console, output);
|
||||||
};
|
} else {
|
||||||
|
logger.call(console, output, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logInfo = (message, details) => logFrontend("info", message, details);
|
||||||
|
const logWarn = (message, details) => logFrontend("warn", message, details);
|
||||||
|
const logDebug = (message, details) => logFrontend("debug", message, details);
|
||||||
|
const logError = (message, details) => logFrontend("error", message, details);
|
||||||
|
|
||||||
|
function shouldLog(level) {
|
||||||
|
const requested = LOG_LEVELS.indexOf(level);
|
||||||
|
const active = LOG_LEVELS.indexOf(LOG_LEVEL);
|
||||||
|
if (requested === -1 || active === -1) return false;
|
||||||
|
return requested <= active;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLogLevel() {
|
||||||
|
const stored = window.localStorage?.getItem("tauclock:log-level");
|
||||||
|
const normalizedStored = stored?.toLowerCase();
|
||||||
|
if (normalizedStored && LOG_LEVELS.includes(normalizedStored)) return normalizedStored;
|
||||||
|
if (window.location?.search?.includes("logLevel=")) {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const requested = params.get("logLevel")?.toLowerCase();
|
||||||
|
if (requested && LOG_LEVELS.includes(requested)) return requested;
|
||||||
|
}
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoke = resolveInvoke();
|
||||||
|
|
||||||
|
function resolveInvoke() {
|
||||||
|
const tauriGlobal = window.__TAURI__ || {};
|
||||||
|
const candidates = [
|
||||||
|
{ fn: tauriGlobal?.core?.invoke, ctx: tauriGlobal?.core, namespace: "core" },
|
||||||
|
{ fn: tauriGlobal?.tauri?.invoke, ctx: tauriGlobal?.tauri, namespace: "tauri" },
|
||||||
|
{ fn: tauriGlobal.invoke, ctx: tauriGlobal, namespace: "root" },
|
||||||
|
].filter(({ fn }) => typeof fn === "function");
|
||||||
|
if (candidates.length) {
|
||||||
|
const { fn, ctx, namespace } = candidates[0];
|
||||||
|
logDebug("Resolved Tauri invoke bridge", { namespace });
|
||||||
|
return fn.bind(ctx);
|
||||||
|
}
|
||||||
|
logWarn("Tauri bridge was not found on window.__TAURI__");
|
||||||
|
return async (command) => {
|
||||||
|
const error = new Error("Tauri backend interface is unavailable");
|
||||||
|
logError(`Attempted to invoke '${command}' but the Tauri bridge is missing`, error);
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Cache of DOM nodes we touch frequently to avoid repeated lookups. */
|
|
||||||
const elements = {};
|
const elements = {};
|
||||||
|
let tickInFlight = false;
|
||||||
|
let latestState = null;
|
||||||
|
|
||||||
/** In-memory representation of scheduled alarms. */
|
|
||||||
const alarmState = {
|
|
||||||
items: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Mutable state that drives the countdown timer UI. */
|
|
||||||
const timerState = {
|
|
||||||
initialMs: 0,
|
|
||||||
remainingMs: 0,
|
|
||||||
running: false,
|
|
||||||
targetTime: null,
|
|
||||||
lastInput: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Bookkeeping for the stopwatch including lap history. */
|
|
||||||
const stopwatchState = {
|
|
||||||
running: false,
|
|
||||||
startTime: null,
|
|
||||||
elapsedBefore: 0,
|
|
||||||
laps: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Handle used to clear the transient status message timeout. */
|
|
||||||
let statusTimer;
|
|
||||||
|
|
||||||
/** Keyboard shortcut handlers keyed by the raw KeyboardEvent#code. */
|
|
||||||
const SHORTCUTS = {
|
const SHORTCUTS = {
|
||||||
KeyA: () => focusAndSelect(elements.alarmTime),
|
KeyA: () => focusAndSelect(elements.alarmTime),
|
||||||
KeyN: () => elements.alarmForm.requestSubmit(),
|
KeyN: () => elements.alarmForm.requestSubmit(),
|
||||||
KeyT: () => focusAndSelect(elements.timerInput),
|
KeyT: () => focusAndSelect(elements.timerInput),
|
||||||
KeyG: () => toggleTimer(),
|
KeyG: () => triggerTimerToggle(false),
|
||||||
KeyR: () => resetTimer(),
|
KeyR: () => requestState("reset_timer"),
|
||||||
KeyW: () => toggleStopwatch(),
|
KeyW: () => requestState("toggle_stopwatch"),
|
||||||
KeyL: () => addLap(),
|
KeyL: () => requestState("add_lap"),
|
||||||
KeyP: () => resetStopwatch(),
|
KeyP: () => requestState("reset_stopwatch"),
|
||||||
KeyH: () => toggleShortcutOverlay(),
|
KeyH: () => toggleShortcutOverlay(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Sequences of oscillator frequencies for the lightweight chime player. */
|
|
||||||
const AUDIO_PATTERNS = {
|
|
||||||
short: [660, 880, 660],
|
|
||||||
alarm: [523, 659, 784, 659, 523],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main entry point once the DOM is ready. We populate caches, hook up
|
|
||||||
* listeners, restore persisted data, and start ticking UI updates.
|
|
||||||
*/
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
logInfo("DOM ready, initializing app");
|
||||||
cacheElements();
|
cacheElements();
|
||||||
bindEvents();
|
bindEvents();
|
||||||
loadAlarms();
|
bootstrap();
|
||||||
updateClock();
|
|
||||||
renderAlarms();
|
|
||||||
renderTimer();
|
|
||||||
renderStopwatch();
|
|
||||||
setInterval(updateClock, 500);
|
|
||||||
setInterval(() => {
|
|
||||||
updateTimerDisplay();
|
|
||||||
updateStopwatchDisplay();
|
|
||||||
}, 75);
|
|
||||||
setInterval(checkAlarms, 1000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect and memoize every DOM element referenced throughout the app.
|
|
||||||
* Running this once avoids repeated querySelector calls inside hot paths.
|
|
||||||
*/
|
|
||||||
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");
|
||||||
@@ -113,13 +106,28 @@ function cacheElements() {
|
|||||||
elements.shortcutOverlay = document.getElementById("shortcut-overlay");
|
elements.shortcutOverlay = document.getElementById("shortcut-overlay");
|
||||||
elements.shortcutClose = document.getElementById("shortcut-close");
|
elements.shortcutClose = document.getElementById("shortcut-close");
|
||||||
elements.shortcutTrigger = document.getElementById("shortcut-trigger");
|
elements.shortcutTrigger = document.getElementById("shortcut-trigger");
|
||||||
|
const missing = Object.entries(elements)
|
||||||
|
.filter(([, value]) => !value)
|
||||||
|
.map(([key]) => key);
|
||||||
|
if (missing.length) {
|
||||||
|
logWarn("Some expected DOM nodes were not found", { missing });
|
||||||
|
} else {
|
||||||
|
logDebug("All DOM nodes cached successfully", { count: Object.keys(elements).length });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wire up submit/click/keyboard handlers for alarms, timer, and stopwatch. */
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
elements.alarmForm.addEventListener("submit", (event) => {
|
elements.alarmForm.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
createAlarm();
|
const beforeCount = latestState?.alarms?.items?.length ?? 0;
|
||||||
|
const state = await requestState("create_alarm", {
|
||||||
|
time: elements.alarmTime.value,
|
||||||
|
label: elements.alarmLabel.value,
|
||||||
|
});
|
||||||
|
const afterCount = state?.alarms?.items?.length ?? beforeCount;
|
||||||
|
if (afterCount > beforeCount) {
|
||||||
|
elements.alarmForm.reset();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.alarmList.addEventListener("click", (event) => {
|
elements.alarmList.addEventListener("click", (event) => {
|
||||||
@@ -127,22 +135,22 @@ function bindEvents() {
|
|||||||
if (!button) return;
|
if (!button) return;
|
||||||
const id = button.dataset.id;
|
const id = button.dataset.id;
|
||||||
const action = button.dataset.action;
|
const action = button.dataset.action;
|
||||||
if (action === "toggle") toggleAlarm(id);
|
if (action === "toggle") requestState("toggle_alarm", { id });
|
||||||
if (action === "delete") deleteAlarm(id);
|
if (action === "delete") requestState("delete_alarm", { id });
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.timerToggle.addEventListener("click", () => toggleTimer());
|
elements.timerToggle.addEventListener("click", () => triggerTimerToggle(false));
|
||||||
elements.timerReset.addEventListener("click", () => resetTimer());
|
elements.timerReset.addEventListener("click", () => requestState("reset_timer"));
|
||||||
elements.timerInput.addEventListener("keydown", (event) => {
|
elements.timerInput.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleTimer(true);
|
triggerTimerToggle(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.stopwatchToggle.addEventListener("click", () => toggleStopwatch());
|
elements.stopwatchToggle.addEventListener("click", () => requestState("toggle_stopwatch"));
|
||||||
elements.stopwatchLap.addEventListener("click", () => addLap());
|
elements.stopwatchLap.addEventListener("click", () => requestState("add_lap"));
|
||||||
elements.stopwatchReset.addEventListener("click", () => resetStopwatch());
|
elements.stopwatchReset.addEventListener("click", () => requestState("reset_stopwatch"));
|
||||||
|
|
||||||
elements.shortcutClose.addEventListener("click", () => toggleShortcutOverlay(false));
|
elements.shortcutClose.addEventListener("click", () => toggleShortcutOverlay(false));
|
||||||
elements.shortcutOverlay.addEventListener("click", (event) => {
|
elements.shortcutOverlay.addEventListener("click", (event) => {
|
||||||
@@ -153,108 +161,104 @@ function bindEvents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", handleShortcuts, true);
|
document.addEventListener("keydown", handleShortcuts, true);
|
||||||
|
logInfo("Core UI events bound");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function bootstrap() {
|
||||||
* Re-render the local and UTC clocks. Called on an interval for a live view.
|
logInfo("Bootstrapping state");
|
||||||
*/
|
await requestState("get_state");
|
||||||
function updateClock() {
|
setInterval(runTick, 250);
|
||||||
const now = new Date();
|
logInfo("Tick loop scheduled", { intervalMs: 250 });
|
||||||
elements.localTime.textContent = formatTime(now);
|
|
||||||
elements.localDate.textContent = formatDate(now);
|
|
||||||
const utc = new Date(now.getTime());
|
|
||||||
elements.utcTime.textContent = formatTime(utc, { timeZone: "UTC" });
|
|
||||||
elements.utcDate.textContent = formatDate(utc, { timeZone: "UTC" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a Date instance into HH:MM:SS in the provided locale/timezone. */
|
function triggerTimerToggle(forceParse) {
|
||||||
function formatTime(date, options = {}) {
|
logDebug("Timer toggle requested", { forceParse, input: elements.timerInput.value });
|
||||||
return new Intl.DateTimeFormat("ja-JP", {
|
requestState("toggle_timer", {
|
||||||
hour: "2-digit",
|
input: elements.timerInput.value,
|
||||||
minute: "2-digit",
|
forceParse,
|
||||||
second: "2-digit",
|
});
|
||||||
hour12: false,
|
|
||||||
...options,
|
|
||||||
}).format(date);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a Date instance into a friendly weekday/month/day triplet. */
|
async function requestState(command, payload = {}) {
|
||||||
function formatDate(date, options = {}) {
|
try {
|
||||||
return new Intl.DateTimeFormat("ja-JP", {
|
logInfo(`Invoking backend command: ${command}`, payload);
|
||||||
weekday: "short",
|
const state = await invoke(command, payload);
|
||||||
month: "short",
|
logDebug(`Command ${command} succeeded`, {
|
||||||
day: "2-digit",
|
hasClock: Boolean(state?.clock),
|
||||||
...options,
|
hasStatus: Boolean(state?.status),
|
||||||
}).format(date);
|
});
|
||||||
|
applyState(state);
|
||||||
|
return state;
|
||||||
|
} catch (error) {
|
||||||
|
logError(`Command ${command} failed`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function runTick() {
|
||||||
* Validate form input, build an alarm record, persist, and update the list.
|
if (tickInFlight) {
|
||||||
*/
|
logDebug("Tick skipped because a previous request is still pending");
|
||||||
function createAlarm() {
|
|
||||||
const timeValue = elements.alarmTime.value;
|
|
||||||
if (!timeValue) {
|
|
||||||
pushStatus("アラーム時刻を入力してください");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [hour, minute] = timeValue.split(":").map((v) => Number(v));
|
tickInFlight = true;
|
||||||
if (Number.isNaN(hour) || Number.isNaN(minute)) {
|
try {
|
||||||
pushStatus("時刻の形式が正しくありません");
|
logDebug("Requesting tick update");
|
||||||
return;
|
const state = await invoke("tick");
|
||||||
|
logDebug("Tick update received", { localTime: state?.clock?.local_time });
|
||||||
|
applyState(state);
|
||||||
|
} catch (error) {
|
||||||
|
logError("Tick failed", error);
|
||||||
|
} finally {
|
||||||
|
tickInFlight = false;
|
||||||
}
|
}
|
||||||
const label = elements.alarmLabel.value.trim() || "アラーム";
|
|
||||||
const nextTrigger = resolveNextTrigger(hour, minute);
|
|
||||||
const alarm = {
|
|
||||||
id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`,
|
|
||||||
hour,
|
|
||||||
minute,
|
|
||||||
label,
|
|
||||||
active: true,
|
|
||||||
nextTrigger,
|
|
||||||
ringing: false,
|
|
||||||
};
|
|
||||||
alarmState.items.push(alarm);
|
|
||||||
persistAlarms();
|
|
||||||
renderAlarms();
|
|
||||||
elements.alarmForm.reset();
|
|
||||||
pushStatus(`アラーム追加: ${label}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return the next timestamp (ms) when an alarm should fire for hour/minute. */
|
function applyState(state) {
|
||||||
function resolveNextTrigger(hour, minute) {
|
if (!state) return;
|
||||||
const now = new Date();
|
latestState = state;
|
||||||
const candidate = new Date(now);
|
logDebug("Applying state snapshot", {
|
||||||
candidate.setHours(hour, minute, 0, 0);
|
nextAlarm: state.alarms?.next_summary,
|
||||||
if (candidate.getTime() <= now.getTime()) {
|
timerStatus: state.timer?.status_text,
|
||||||
candidate.setDate(candidate.getDate() + 1);
|
shouldChime: state.should_chime,
|
||||||
|
});
|
||||||
|
updateClock(state.clock);
|
||||||
|
renderAlarms(state.alarms);
|
||||||
|
renderTimer(state.timer);
|
||||||
|
renderStopwatch(state.stopwatch);
|
||||||
|
renderStatus(state.status);
|
||||||
|
if (state.should_chime) {
|
||||||
|
playChime();
|
||||||
}
|
}
|
||||||
return candidate.getTime();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function updateClock(clock) {
|
||||||
* Render the alarm list with action buttons and show the next scheduled alarm.
|
if (!clock) return;
|
||||||
*/
|
elements.localTime.textContent = clock.local_time;
|
||||||
function renderAlarms() {
|
elements.localDate.textContent = clock.local_date;
|
||||||
|
elements.utcTime.textContent = clock.utc_time;
|
||||||
|
elements.utcDate.textContent = clock.utc_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlarms(section) {
|
||||||
const list = elements.alarmList;
|
const list = elements.alarmList;
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
if (!alarmState.items.length) {
|
if (!section || !section.items.length) {
|
||||||
const empty = document.createElement("li");
|
const empty = document.createElement("li");
|
||||||
empty.textContent = "アラームはまだありません";
|
empty.textContent = "アラームはまだありません";
|
||||||
list.appendChild(empty);
|
list.appendChild(empty);
|
||||||
} else {
|
} else {
|
||||||
const sorted = [...alarmState.items].sort((a, b) => a.nextTrigger - b.nextTrigger);
|
section.items.forEach((alarm) => {
|
||||||
sorted.forEach((alarm) => {
|
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
if (!alarm.active) li.classList.add("muted");
|
if (!alarm.active) li.classList.add("muted");
|
||||||
if (alarm.ringing) li.classList.add("ringing");
|
if (alarm.ringing) li.classList.add("ringing");
|
||||||
const info = document.createElement("div");
|
const info = document.createElement("div");
|
||||||
const title = document.createElement("strong");
|
const title = document.createElement("strong");
|
||||||
title.textContent = `${twoDigits(alarm.hour)}:${twoDigits(alarm.minute)} - ${alarm.label}`;
|
title.textContent = `${alarm.time_label} - ${alarm.label}`;
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
badge.className = "badge";
|
badge.className = "badge";
|
||||||
badge.textContent = alarm.active ? "ON" : "OFF";
|
badge.textContent = alarm.active ? "ON" : "OFF";
|
||||||
const next = document.createElement("small");
|
const next = document.createElement("small");
|
||||||
next.textContent = `次回: ${formatRelative(alarm.nextTrigger)}`;
|
next.textContent = `次回: ${alarm.next_relative}`;
|
||||||
info.append(title, badge, next);
|
info.append(title, badge, next);
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "alarm-actions";
|
actions.className = "alarm-actions";
|
||||||
@@ -273,369 +277,76 @@ function renderAlarms() {
|
|||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
updateNextAlarm();
|
elements.nextAlarm.textContent = section ? section.next_summary : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update the "next alarm" label to highlight the soonest active alarm. */
|
function renderTimer(view) {
|
||||||
function updateNextAlarm() {
|
if (!view) return;
|
||||||
const nextLabel = elements.nextAlarm;
|
elements.timerDisplay.textContent = view.display;
|
||||||
const active = alarmState.items.filter((alarm) => alarm.active);
|
elements.timerToggle.textContent = view.toggle_label;
|
||||||
if (!active.length) {
|
elements.timerStatus.textContent = view.status_text || "";
|
||||||
nextLabel.textContent = "アクティブなアラームはありません";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const soonest = active.reduce((acc, alarm) => (alarm.nextTrigger < acc.nextTrigger ? alarm : acc));
|
|
||||||
nextLabel.textContent = `次のアラーム: ${twoDigits(soonest.hour)}:${twoDigits(soonest.minute)} (${soonest.label}) - ${formatRelative(soonest.nextTrigger)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function renderStopwatch(view) {
|
||||||
* Convert a future timestamp into a concise relative label (e.g. "5分後").
|
if (!view) return;
|
||||||
*/
|
elements.stopwatchDisplay.textContent = view.display;
|
||||||
function formatRelative(timestamp) {
|
elements.stopwatchToggle.textContent = view.toggle_label;
|
||||||
const now = Date.now();
|
|
||||||
const diff = timestamp - now;
|
|
||||||
if (diff <= 60 * 1000) return "まもなく";
|
|
||||||
const minutes = Math.round(diff / 60000);
|
|
||||||
if (minutes < 60) return `${minutes}分後`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const mins = minutes % 60;
|
|
||||||
return `${hours}時間${mins}分後`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enable/disable alarms as the user toggles their switches. */
|
|
||||||
function toggleAlarm(id) {
|
|
||||||
const alarm = alarmState.items.find((item) => item.id === id);
|
|
||||||
if (!alarm) return;
|
|
||||||
alarm.active = !alarm.active;
|
|
||||||
alarm.nextTrigger = resolveNextTrigger(alarm.hour, alarm.minute);
|
|
||||||
alarm.ringing = false;
|
|
||||||
persistAlarms();
|
|
||||||
renderAlarms();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove an alarm entirely and surface a toast-like status message. */
|
|
||||||
function deleteAlarm(id) {
|
|
||||||
const index = alarmState.items.findIndex((item) => item.id === id);
|
|
||||||
if (index === -1) return;
|
|
||||||
const [removed] = alarmState.items.splice(index, 1);
|
|
||||||
persistAlarms();
|
|
||||||
renderAlarms();
|
|
||||||
pushStatus(`アラーム削除: ${removed.label}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Polling loop that checks which alarms should fire and plays feedback.
|
|
||||||
*/
|
|
||||||
function checkAlarms() {
|
|
||||||
const now = Date.now();
|
|
||||||
let triggered = false;
|
|
||||||
alarmState.items.forEach((alarm) => {
|
|
||||||
if (!alarm.active) return;
|
|
||||||
if (now >= alarm.nextTrigger) {
|
|
||||||
alarm.ringing = true;
|
|
||||||
alarm.nextTrigger = resolveNextTrigger(alarm.hour, alarm.minute);
|
|
||||||
triggered = true;
|
|
||||||
notifyUser(`アラーム: ${alarm.label}`);
|
|
||||||
setTimeout(() => {
|
|
||||||
alarm.ringing = false;
|
|
||||||
renderAlarms();
|
|
||||||
}, 6000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (triggered) {
|
|
||||||
persistAlarms();
|
|
||||||
renderAlarms();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Serialize alarms into localStorage for durability across restarts. */
|
|
||||||
function persistAlarms() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEYS.alarms, JSON.stringify(alarmState.items));
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to save alarms", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hydrate the in-memory alarm list from the persisted browser storage. */
|
|
||||||
function loadAlarms() {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEYS.alarms);
|
|
||||||
if (raw) {
|
|
||||||
alarmState.items = JSON.parse(raw).map((alarm) => ({
|
|
||||||
...alarm,
|
|
||||||
nextTrigger: resolveNextTrigger(alarm.hour, alarm.minute),
|
|
||||||
ringing: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to load alarms", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper for zero-padding numbers to two digits. */
|
|
||||||
function twoDigits(number) {
|
|
||||||
return number.toString().padStart(2, "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accept flexible timer strings such as "5:30", "1h 2m", or raw seconds.
|
|
||||||
* Returns milliseconds or null when the input cannot be parsed.
|
|
||||||
*/
|
|
||||||
function parseDuration(value) {
|
|
||||||
if (!value) return null;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
if (trimmed.includes(":")) {
|
|
||||||
const [minutes, seconds] = trimmed.split(":").map((part) => Number(part));
|
|
||||||
if (Number.isNaN(minutes) || Number.isNaN(seconds)) return null;
|
|
||||||
return (minutes * 60 + seconds) * 1000;
|
|
||||||
}
|
|
||||||
const pattern = /^((\d+)h)?\s*((\d+)m)?\s*((\d+)s)?$/i;
|
|
||||||
const match = trimmed.match(pattern);
|
|
||||||
if (match) {
|
|
||||||
const hours = Number(match[2] || 0);
|
|
||||||
const minutes = Number(match[4] || 0);
|
|
||||||
const seconds = Number(match[6] || 0);
|
|
||||||
const totalSeconds = hours * 3600 + minutes * 60 + seconds;
|
|
||||||
return totalSeconds * 1000;
|
|
||||||
}
|
|
||||||
const seconds = Number(trimmed);
|
|
||||||
if (!Number.isNaN(seconds)) return seconds * 1000;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start or pause the countdown timer, optionally re-parsing the duration.
|
|
||||||
*/
|
|
||||||
function toggleTimer(forceParse = false) {
|
|
||||||
if (timerState.running) {
|
|
||||||
pauseTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rawInput = elements.timerInput.value.trim();
|
|
||||||
const needsParse = forceParse || timerState.initialMs === 0 || (rawInput && rawInput !== timerState.lastInput);
|
|
||||||
if (needsParse) {
|
|
||||||
const duration = parseDuration(rawInput);
|
|
||||||
if (!duration || duration < 1000) {
|
|
||||||
pushStatus("1秒以上の時間を入力してください");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
timerState.initialMs = duration;
|
|
||||||
timerState.remainingMs = duration;
|
|
||||||
timerState.lastInput = rawInput;
|
|
||||||
}
|
|
||||||
if (timerState.remainingMs <= 0) {
|
|
||||||
timerState.remainingMs = timerState.initialMs;
|
|
||||||
}
|
|
||||||
timerState.running = true;
|
|
||||||
timerState.targetTime = Date.now() + timerState.remainingMs;
|
|
||||||
elements.timerToggle.textContent = "一時停止";
|
|
||||||
elements.timerStatus.textContent = "カウント中";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Snapshot remaining time while keeping the parsed duration intact. */
|
|
||||||
function pauseTimer() {
|
|
||||||
if (!timerState.running) return;
|
|
||||||
timerState.remainingMs = Math.max(0, timerState.targetTime - Date.now());
|
|
||||||
timerState.running = false;
|
|
||||||
timerState.targetTime = null;
|
|
||||||
elements.timerToggle.textContent = "再開";
|
|
||||||
elements.timerStatus.textContent = "一時停止";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Restore the timer to its initial duration and stop playback. */
|
|
||||||
function resetTimer() {
|
|
||||||
timerState.running = false;
|
|
||||||
timerState.targetTime = null;
|
|
||||||
timerState.remainingMs = timerState.initialMs;
|
|
||||||
elements.timerToggle.textContent = "スタート";
|
|
||||||
elements.timerStatus.textContent = "";
|
|
||||||
updateTimerDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Refresh timer state and fire completion notification when needed. */
|
|
||||||
function updateTimerDisplay() {
|
|
||||||
if (timerState.running) {
|
|
||||||
timerState.remainingMs = Math.max(0, timerState.targetTime - Date.now());
|
|
||||||
if (timerState.remainingMs === 0) {
|
|
||||||
timerState.running = false;
|
|
||||||
timerState.targetTime = null;
|
|
||||||
elements.timerToggle.textContent = "スタート";
|
|
||||||
elements.timerStatus.textContent = "完了";
|
|
||||||
notifyUser("タイマー終了");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
renderTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format the remaining timer milliseconds for the UI label. */
|
|
||||||
function renderTimer() {
|
|
||||||
const ms = typeof timerState.remainingMs === "number" ? timerState.remainingMs : timerState.initialMs || 0;
|
|
||||||
elements.timerDisplay.textContent = formatDuration(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Represent milliseconds as MM:SS.hh or HH:MM:SS for long intervals. */
|
|
||||||
function formatDuration(ms) {
|
|
||||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
const hundred = Math.floor((ms % 1000) / 10);
|
|
||||||
if (minutes >= 60) {
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const remMinutes = minutes % 60;
|
|
||||||
return `${twoDigits(hours)}:${twoDigits(remMinutes)}:${twoDigits(seconds)}`;
|
|
||||||
}
|
|
||||||
return `${twoDigits(minutes)}:${twoDigits(seconds)}.${twoDigits(hundred)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Toggle stopwatch run/pause state and align button labels accordingly. */
|
|
||||||
function toggleStopwatch() {
|
|
||||||
if (stopwatchState.running) {
|
|
||||||
stopwatchState.elapsedBefore += Date.now() - stopwatchState.startTime;
|
|
||||||
stopwatchState.running = false;
|
|
||||||
stopwatchState.startTime = null;
|
|
||||||
elements.stopwatchToggle.textContent = "スタート";
|
|
||||||
} else {
|
|
||||||
stopwatchState.running = true;
|
|
||||||
stopwatchState.startTime = Date.now();
|
|
||||||
elements.stopwatchToggle.textContent = "停止";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compute elapsed milliseconds including paused time plus current run. */
|
|
||||||
function getStopwatchElapsed() {
|
|
||||||
return stopwatchState.elapsedBefore + (stopwatchState.running ? Date.now() - stopwatchState.startTime : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update the on-screen stopwatch label from the current elapsed time. */
|
|
||||||
function updateStopwatchDisplay() {
|
|
||||||
elements.stopwatchDisplay.textContent = formatStopwatch(getStopwatchElapsed());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render both the stopwatch display and lap summary list. */
|
|
||||||
function renderStopwatch() {
|
|
||||||
updateStopwatchDisplay();
|
|
||||||
renderLaps();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format stopwatch precision output (MM:SS.mmm). */
|
|
||||||
function formatStopwatch(ms) {
|
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
const millis = ms % 1000;
|
|
||||||
return `${twoDigits(minutes)}:${twoDigits(seconds)}.${millis.toString().padStart(3, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Capture the current elapsed time as a lap with delta vs. previous lap. */
|
|
||||||
function addLap() {
|
|
||||||
const elapsed = getStopwatchElapsed();
|
|
||||||
if (elapsed === 0) return;
|
|
||||||
const previous = stopwatchState.laps.at(-1)?.totalMs ?? 0;
|
|
||||||
stopwatchState.laps.push({
|
|
||||||
id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`,
|
|
||||||
totalMs: elapsed,
|
|
||||||
deltaMs: elapsed - previous,
|
|
||||||
});
|
|
||||||
renderLaps();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render lap entries newest-first with cumulative and delta timings.
|
|
||||||
*/
|
|
||||||
function renderLaps() {
|
|
||||||
const list = elements.stopwatchLaps;
|
const list = elements.stopwatchLaps;
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
if (!stopwatchState.laps.length) {
|
if (!view.laps.length) {
|
||||||
const empty = document.createElement("li");
|
const empty = document.createElement("li");
|
||||||
empty.textContent = "ラップなし";
|
empty.textContent = "ラップなし";
|
||||||
list.appendChild(empty);
|
list.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stopwatchState.laps
|
view.laps.forEach((lap) => {
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.forEach((lap, index) => {
|
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
|
li.dataset.id = lap.id;
|
||||||
const label = document.createElement("strong");
|
const label = document.createElement("strong");
|
||||||
const lapNumber = stopwatchState.laps.length - index;
|
label.textContent = lap.label;
|
||||||
label.textContent = `Lap ${lapNumber}`;
|
|
||||||
const total = document.createElement("small");
|
const total = document.createElement("small");
|
||||||
total.textContent = `合計 ${formatStopwatch(lap.totalMs)}`;
|
total.textContent = lap.total;
|
||||||
const delta = document.createElement("small");
|
const delta = document.createElement("small");
|
||||||
delta.textContent = `+${formatStopwatch(lap.deltaMs)}`;
|
delta.textContent = lap.delta;
|
||||||
li.append(label, total, delta);
|
li.append(label, total, delta);
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear stopwatch progress and lap history. */
|
function renderStatus(message) {
|
||||||
function resetStopwatch() {
|
if (!elements.statusMessage) return;
|
||||||
stopwatchState.running = false;
|
elements.statusMessage.textContent = message || "";
|
||||||
stopwatchState.startTime = null;
|
|
||||||
stopwatchState.elapsedBefore = 0;
|
|
||||||
stopwatchState.laps = [];
|
|
||||||
elements.stopwatchToggle.textContent = "スタート";
|
|
||||||
renderStopwatch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Focus an input element and select its contents if supported. */
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function toggleShortcutOverlay(force) {
|
||||||
* Briefly show a non-blocking status label near the footer of the UI.
|
const overlay = elements.shortcutOverlay;
|
||||||
*/
|
if (!overlay) return;
|
||||||
function pushStatus(message) {
|
const next = typeof force === "boolean" ? force : overlay.hidden;
|
||||||
if (!elements.statusMessage) return;
|
overlay.hidden = !next;
|
||||||
elements.statusMessage.textContent = message;
|
logInfo("Shortcut overlay toggled", { visible: next });
|
||||||
clearTimeout(statusTimer);
|
if (next) {
|
||||||
statusTimer = setTimeout(() => {
|
overlay.querySelector(".shortcut-card")?.focus?.();
|
||||||
elements.statusMessage.textContent = "";
|
|
||||||
}, 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Combine a status toast with an audible chime. */
|
|
||||||
function notifyUser(message) {
|
|
||||||
pushStatus(message);
|
|
||||||
playChime();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lightweight synthesizer used for alarm/timer notification chimes. */
|
|
||||||
function playChime(patternName = "alarm") {
|
|
||||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
|
||||||
if (!AudioCtx) return;
|
|
||||||
playChime.ctx = playChime.ctx || new AudioCtx();
|
|
||||||
if (playChime.ctx.state === "suspended") {
|
|
||||||
playChime.ctx.resume();
|
|
||||||
}
|
}
|
||||||
const ctx = playChime.ctx;
|
|
||||||
const pattern = AUDIO_PATTERNS[patternName] || AUDIO_PATTERNS.short;
|
|
||||||
let startTime = ctx.currentTime;
|
|
||||||
pattern.forEach((frequency) => {
|
|
||||||
const osc = ctx.createOscillator();
|
|
||||||
const gain = ctx.createGain();
|
|
||||||
osc.type = "sine";
|
|
||||||
osc.frequency.value = frequency;
|
|
||||||
osc.connect(gain);
|
|
||||||
gain.connect(ctx.destination);
|
|
||||||
gain.gain.setValueAtTime(0.0001, startTime);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.25, startTime + 0.02);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.0001, startTime + 0.3);
|
|
||||||
osc.start(startTime);
|
|
||||||
osc.stop(startTime + 0.32);
|
|
||||||
startTime += 0.35;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Global keyboard shortcut dispatcher honoring focus/overlay state. */
|
|
||||||
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;
|
||||||
|
const trackedKey = Boolean(SHORTCUTS[event.code]) || event.key === "Escape";
|
||||||
|
if (trackedKey) {
|
||||||
|
logDebug("Shortcut event received", {
|
||||||
|
code: event.code,
|
||||||
|
overlayVisible,
|
||||||
|
hasModifier: event.altKey || event.metaKey || event.ctrlKey,
|
||||||
|
targetTag: event.target?.tagName,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
if (overlayVisible) {
|
if (overlayVisible) {
|
||||||
toggleShortcutOverlay(false);
|
toggleShortcutOverlay(false);
|
||||||
@@ -654,23 +365,11 @@ function handleShortcuts(event) {
|
|||||||
const handler = SHORTCUTS[event.code];
|
const handler = SHORTCUTS[event.code];
|
||||||
if (handler) {
|
if (handler) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
logInfo("Keyboard shortcut triggered", { code: event.code });
|
||||||
handler();
|
handler();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show/hide the legend that explains available keyboard shortcuts.
|
|
||||||
*/
|
|
||||||
function toggleShortcutOverlay(force) {
|
|
||||||
const overlay = elements.shortcutOverlay;
|
|
||||||
const next = typeof force === "boolean" ? force : overlay.hidden;
|
|
||||||
overlay.hidden = !next;
|
|
||||||
if (next) {
|
|
||||||
overlay.querySelector(".shortcut-card").focus?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Determine whether a keyboard event originated from a text input area. */
|
|
||||||
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;
|
||||||
@@ -678,3 +377,33 @@ function isTypingTarget(target) {
|
|||||||
if (element.isContentEditable) return true;
|
if (element.isContentEditable) return true;
|
||||||
return Boolean(element.closest("input, textarea, select, [contenteditable='true']"));
|
return Boolean(element.closest("input, textarea, select, [contenteditable='true']"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playChime(patternName = "alarm") {
|
||||||
|
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AudioCtx) return;
|
||||||
|
playChime.ctx = playChime.ctx || new AudioCtx();
|
||||||
|
if (playChime.ctx.state === "suspended") {
|
||||||
|
playChime.ctx.resume();
|
||||||
|
}
|
||||||
|
const ctx = playChime.ctx;
|
||||||
|
const patterns = {
|
||||||
|
short: [660, 880, 660],
|
||||||
|
alarm: [523, 659, 784, 659, 523],
|
||||||
|
};
|
||||||
|
const pattern = patterns[patternName] || patterns.alarm;
|
||||||
|
let startTime = ctx.currentTime;
|
||||||
|
pattern.forEach((frequency) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = "sine";
|
||||||
|
osc.frequency.value = frequency;
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
gain.gain.setValueAtTime(0.0001, startTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.25, startTime + 0.02);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, startTime + 0.3);
|
||||||
|
osc.start(startTime);
|
||||||
|
osc.stop(startTime + 0.32);
|
||||||
|
startTime += 0.35;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user