Improve logging controls

This commit is contained in:
2026-01-12 02:25:28 +09:00
parent 764a6349ef
commit c8c90f0641
3 changed files with 396 additions and 517 deletions

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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() const li = document.createElement("li");
.reverse() li.dataset.id = lap.id;
.forEach((lap, index) => { const label = document.createElement("strong");
const li = document.createElement("li"); label.textContent = lap.label;
const label = document.createElement("strong"); const total = document.createElement("small");
const lapNumber = stopwatchState.laps.length - index; total.textContent = lap.total;
label.textContent = `Lap ${lapNumber}`; const delta = document.createElement("small");
const total = document.createElement("small"); delta.textContent = lap.delta;
total.textContent = `合計 ${formatStopwatch(lap.totalMs)}`; li.append(label, total, delta);
const delta = document.createElement("small"); list.appendChild(li);
delta.textContent = `+${formatStopwatch(lap.deltaMs)}`; });
li.append(label, total, delta);
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;
});
}