diff --git a/README.md b/README.md index 980edee..7210b4e 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,13 @@ npm run tauri dev ``` ホットリロード付きでウィンドウが立ち上がり、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 npm run tauri build diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1f8cc04..db137bc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,21 +1,164 @@ -//! Core TauClock backend that wires plugins and exposes Rust commands. -//! -//! The UI currently uses only frontend logic, but the scaffold is documented -//! so contributors know where to extend native capabilities (timers, IPC, etc). +mod state; + +use log::LevelFilter; +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] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +fn get_state(app_state: tauri::State) -> FrontendState { + log::debug!("get_state invoked"); + app_state.snapshot() +} + +#[tauri::command] +fn tick(app_state: tauri::State) -> FrontendState { + log::trace!("tick invoked"); + app_state.tick() +} + +#[tauri::command] +fn create_alarm( + time: String, + label: Option, + app_state: tauri::State, +) -> 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) -> 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) -> FrontendState { + log::info!("delete_alarm invoked (id={id})", id = id); + app_state.delete_alarm(id) +} + +#[tauri::command] +fn toggle_timer( + input: Option, + force_parse: bool, + app_state: tauri::State, +) -> 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) -> FrontendState { + log::debug!("reset_timer invoked"); + app_state.reset_timer() +} + +#[tauri::command] +fn toggle_stopwatch(app_state: tauri::State) -> FrontendState { + log::info!("toggle_stopwatch invoked"); + app_state.toggle_stopwatch() +} + +#[tauri::command] +fn add_lap(app_state: tauri::State) -> FrontendState { + log::debug!("add_lap invoked"); + app_state.add_lap() +} + +#[tauri::command] +fn reset_stopwatch(app_state: tauri::State) -> FrontendState { + log::debug!("reset_stopwatch invoked"); + app_state.reset_stopwatch() } #[cfg_attr(mobile, tauri::mobile_entry_point)] -/// Build the Tauri application, attach plugins, and start the runtime loop. 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() - .plugin(tauri_plugin_log::Builder::new().build()) + .manage(app_state) + .plugin(log_plugin) .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) - .run(tauri::generate_context!()) + .invoke_handler(tauri::generate_handler![ + 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"); } + +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") +} diff --git a/src/main.js b/src/main.js index 5a5a696..9d336eb 100644 --- a/src/main.js +++ b/src/main.js @@ -1,94 +1,87 @@ -/** - * TauClock frontend controller - * ---------------------------- - * 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. - */ +const LOG_PREFIX = "[tauclock]"; +const LOG_LEVELS = ["error", "warn", "info", "debug"]; +const LOG_LEVEL = resolveLogLevel(); -/** - * Namespaced keys used for browser localStorage. - * Keeping the strings centralized makes future migrations trivial. - */ -const STORAGE_KEYS = { - alarms: "tauclock-alarms", -}; +function logFrontend(level, message, details) { + if (!shouldLog(level)) return; + const output = `${LOG_PREFIX} ${message}`; + const logger = console[level] || console.log; + if (details === undefined) { + 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 = {}; +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 = { KeyA: () => focusAndSelect(elements.alarmTime), KeyN: () => elements.alarmForm.requestSubmit(), KeyT: () => focusAndSelect(elements.timerInput), - KeyG: () => toggleTimer(), - KeyR: () => resetTimer(), - KeyW: () => toggleStopwatch(), - KeyL: () => addLap(), - KeyP: () => resetStopwatch(), + KeyG: () => triggerTimerToggle(false), + KeyR: () => requestState("reset_timer"), + KeyW: () => requestState("toggle_stopwatch"), + KeyL: () => requestState("add_lap"), + KeyP: () => requestState("reset_stopwatch"), 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", () => { + logInfo("DOM ready, initializing app"); cacheElements(); bindEvents(); - loadAlarms(); - updateClock(); - renderAlarms(); - renderTimer(); - renderStopwatch(); - setInterval(updateClock, 500); - setInterval(() => { - updateTimerDisplay(); - updateStopwatchDisplay(); - }, 75); - setInterval(checkAlarms, 1000); + bootstrap(); }); -/** - * Collect and memoize every DOM element referenced throughout the app. - * Running this once avoids repeated querySelector calls inside hot paths. - */ function cacheElements() { elements.localTime = document.getElementById("local-time"); elements.localDate = document.getElementById("local-date"); @@ -113,13 +106,28 @@ function cacheElements() { elements.shortcutOverlay = document.getElementById("shortcut-overlay"); elements.shortcutClose = document.getElementById("shortcut-close"); 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() { - elements.alarmForm.addEventListener("submit", (event) => { + elements.alarmForm.addEventListener("submit", async (event) => { 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) => { @@ -127,22 +135,22 @@ function bindEvents() { if (!button) return; const id = button.dataset.id; const action = button.dataset.action; - if (action === "toggle") toggleAlarm(id); - if (action === "delete") deleteAlarm(id); + if (action === "toggle") requestState("toggle_alarm", { id }); + if (action === "delete") requestState("delete_alarm", { id }); }); - elements.timerToggle.addEventListener("click", () => toggleTimer()); - elements.timerReset.addEventListener("click", () => resetTimer()); + elements.timerToggle.addEventListener("click", () => triggerTimerToggle(false)); + elements.timerReset.addEventListener("click", () => requestState("reset_timer")); elements.timerInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); - toggleTimer(true); + triggerTimerToggle(true); } }); - elements.stopwatchToggle.addEventListener("click", () => toggleStopwatch()); - elements.stopwatchLap.addEventListener("click", () => addLap()); - elements.stopwatchReset.addEventListener("click", () => resetStopwatch()); + elements.stopwatchToggle.addEventListener("click", () => requestState("toggle_stopwatch")); + elements.stopwatchLap.addEventListener("click", () => requestState("add_lap")); + elements.stopwatchReset.addEventListener("click", () => requestState("reset_stopwatch")); elements.shortcutClose.addEventListener("click", () => toggleShortcutOverlay(false)); elements.shortcutOverlay.addEventListener("click", (event) => { @@ -153,108 +161,104 @@ function bindEvents() { } document.addEventListener("keydown", handleShortcuts, true); + logInfo("Core UI events bound"); } -/** - * Re-render the local and UTC clocks. Called on an interval for a live view. - */ -function updateClock() { - const now = new Date(); - 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" }); +async function bootstrap() { + logInfo("Bootstrapping state"); + await requestState("get_state"); + setInterval(runTick, 250); + logInfo("Tick loop scheduled", { intervalMs: 250 }); } -/** Format a Date instance into HH:MM:SS in the provided locale/timezone. */ -function formatTime(date, options = {}) { - return new Intl.DateTimeFormat("ja-JP", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - ...options, - }).format(date); +function triggerTimerToggle(forceParse) { + logDebug("Timer toggle requested", { forceParse, input: elements.timerInput.value }); + requestState("toggle_timer", { + input: elements.timerInput.value, + forceParse, + }); } -/** Format a Date instance into a friendly weekday/month/day triplet. */ -function formatDate(date, options = {}) { - return new Intl.DateTimeFormat("ja-JP", { - weekday: "short", - month: "short", - day: "2-digit", - ...options, - }).format(date); +async function requestState(command, payload = {}) { + try { + logInfo(`Invoking backend command: ${command}`, payload); + const state = await invoke(command, payload); + logDebug(`Command ${command} succeeded`, { + hasClock: Boolean(state?.clock), + hasStatus: Boolean(state?.status), + }); + applyState(state); + return state; + } catch (error) { + logError(`Command ${command} failed`, error); + return null; + } } -/** - * Validate form input, build an alarm record, persist, and update the list. - */ -function createAlarm() { - const timeValue = elements.alarmTime.value; - if (!timeValue) { - pushStatus("アラーム時刻を入力してください"); +async function runTick() { + if (tickInFlight) { + logDebug("Tick skipped because a previous request is still pending"); return; } - const [hour, minute] = timeValue.split(":").map((v) => Number(v)); - if (Number.isNaN(hour) || Number.isNaN(minute)) { - pushStatus("時刻の形式が正しくありません"); - return; + tickInFlight = true; + try { + logDebug("Requesting tick update"); + 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 resolveNextTrigger(hour, minute) { - const now = new Date(); - const candidate = new Date(now); - candidate.setHours(hour, minute, 0, 0); - if (candidate.getTime() <= now.getTime()) { - candidate.setDate(candidate.getDate() + 1); +function applyState(state) { + if (!state) return; + latestState = state; + logDebug("Applying state snapshot", { + nextAlarm: state.alarms?.next_summary, + timerStatus: state.timer?.status_text, + 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(); } -/** - * Render the alarm list with action buttons and show the next scheduled alarm. - */ -function renderAlarms() { +function updateClock(clock) { + if (!clock) return; + elements.localTime.textContent = clock.local_time; + 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; list.innerHTML = ""; - if (!alarmState.items.length) { + if (!section || !section.items.length) { const empty = document.createElement("li"); empty.textContent = "アラームはまだありません"; list.appendChild(empty); } else { - const sorted = [...alarmState.items].sort((a, b) => a.nextTrigger - b.nextTrigger); - sorted.forEach((alarm) => { + section.items.forEach((alarm) => { const li = document.createElement("li"); if (!alarm.active) li.classList.add("muted"); if (alarm.ringing) li.classList.add("ringing"); const info = document.createElement("div"); 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"); badge.className = "badge"; badge.textContent = alarm.active ? "ON" : "OFF"; const next = document.createElement("small"); - next.textContent = `次回: ${formatRelative(alarm.nextTrigger)}`; + next.textContent = `次回: ${alarm.next_relative}`; info.append(title, badge, next); const actions = document.createElement("div"); actions.className = "alarm-actions"; @@ -273,369 +277,76 @@ function renderAlarms() { list.appendChild(li); }); } - updateNextAlarm(); + elements.nextAlarm.textContent = section ? section.next_summary : ""; } -/** Update the "next alarm" label to highlight the soonest active alarm. */ -function updateNextAlarm() { - const nextLabel = elements.nextAlarm; - const active = alarmState.items.filter((alarm) => alarm.active); - if (!active.length) { - 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 renderTimer(view) { + if (!view) return; + elements.timerDisplay.textContent = view.display; + elements.timerToggle.textContent = view.toggle_label; + elements.timerStatus.textContent = view.status_text || ""; } -/** - * Convert a future timestamp into a concise relative label (e.g. "5分後"). - */ -function formatRelative(timestamp) { - 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() { +function renderStopwatch(view) { + if (!view) return; + elements.stopwatchDisplay.textContent = view.display; + elements.stopwatchToggle.textContent = view.toggle_label; const list = elements.stopwatchLaps; list.innerHTML = ""; - if (!stopwatchState.laps.length) { + if (!view.laps.length) { const empty = document.createElement("li"); empty.textContent = "ラップなし"; list.appendChild(empty); return; } - stopwatchState.laps - .slice() - .reverse() - .forEach((lap, index) => { - const li = document.createElement("li"); - const label = document.createElement("strong"); - const lapNumber = stopwatchState.laps.length - index; - label.textContent = `Lap ${lapNumber}`; - const total = document.createElement("small"); - total.textContent = `合計 ${formatStopwatch(lap.totalMs)}`; - const delta = document.createElement("small"); - delta.textContent = `+${formatStopwatch(lap.deltaMs)}`; - li.append(label, total, delta); - list.appendChild(li); - }); + view.laps.forEach((lap) => { + const li = document.createElement("li"); + li.dataset.id = lap.id; + const label = document.createElement("strong"); + label.textContent = lap.label; + const total = document.createElement("small"); + total.textContent = lap.total; + const delta = document.createElement("small"); + delta.textContent = lap.delta; + li.append(label, total, delta); + list.appendChild(li); + }); } -/** Clear stopwatch progress and lap history. */ -function resetStopwatch() { - stopwatchState.running = false; - stopwatchState.startTime = null; - stopwatchState.elapsedBefore = 0; - stopwatchState.laps = []; - elements.stopwatchToggle.textContent = "スタート"; - renderStopwatch(); +function renderStatus(message) { + if (!elements.statusMessage) return; + elements.statusMessage.textContent = message || ""; } -/** Focus an input element and select its contents if supported. */ function focusAndSelect(element) { if (!element) return; element.focus(); if (typeof element.select === "function") element.select(); } -/** - * Briefly show a non-blocking status label near the footer of the UI. - */ -function pushStatus(message) { - if (!elements.statusMessage) return; - elements.statusMessage.textContent = message; - clearTimeout(statusTimer); - statusTimer = setTimeout(() => { - 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(); +function toggleShortcutOverlay(force) { + const overlay = elements.shortcutOverlay; + if (!overlay) return; + const next = typeof force === "boolean" ? force : overlay.hidden; + overlay.hidden = !next; + logInfo("Shortcut overlay toggled", { visible: next }); + if (next) { + overlay.querySelector(".shortcut-card")?.focus?.(); } - 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) { if (event.defaultPrevented) return; 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 (overlayVisible) { toggleShortcutOverlay(false); @@ -654,23 +365,11 @@ function handleShortcuts(event) { const handler = SHORTCUTS[event.code]; if (handler) { event.preventDefault(); + logInfo("Keyboard shortcut triggered", { code: event.code }); 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) { if (!target) return false; const element = target instanceof Element ? target : target.parentElement; @@ -678,3 +377,33 @@ function isTypingTarget(target) { if (element.isContentEditable) return 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; + }); +}