/** * Static prefix string used in every log entry to make browser console * filtering easier when multiple apps share the same window. */ const LOG_PREFIX = "[tauclock]"; /** * Permitted log levels ordered by severity so we can determine whether a * message should be printed by comparing their indexes. */ const LOG_LEVELS = ["error", "warn", "info", "debug"]; /** * Effective log level obtained either from localStorage, a query parameter or * the default, which then gates every log call in the frontend. */ const LOG_LEVEL = resolveLogLevel(); /** * JSDoc log wrapper that normalizes console usage and ensures we respect the * active log level before emitting potentially noisy diagnostic data. */ function logFrontend(level, message, details) { 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); /** * Returns true if the requested log level is enabled by comparing the * requested level's index with the configured LOG_LEVEL bucket. */ function shouldLog(level) { const requested = LOG_LEVELS.indexOf(level); const active = LOG_LEVELS.indexOf(LOG_LEVEL); if (requested === -1 || active === -1) return false; return requested <= active; } /** * Determines the preferred log level by first checking localStorage, then the * `logLevel` URL parameter, and finally falling back to `info`. */ function resolveLogLevel() { 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(); /** * Finds the best available Tauri invoke bridge and emits structured debug * information for troubleshooting. If the bridge is missing, a stub is * returned so the UI can fail loudly rather than silently. */ function resolveInvoke() { 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 keyed by logical role identifiers. */ const elements = {}; let tickInFlight = false; let latestState = null; let aboutInfo = null; /** Keyboard shortcut handlers keyed by `KeyboardEvent.code`. */ const SHORTCUTS = { KeyA: () => focusAndSelect(elements.alarmTime), KeyN: () => elements.alarmForm.requestSubmit(), KeyT: () => focusAndSelect(elements.timerInput), KeyG: () => triggerTimerToggle(false), KeyR: () => requestState("reset_timer"), KeyW: () => requestState("toggle_stopwatch"), KeyL: () => requestState("add_lap"), KeyP: () => requestState("reset_stopwatch"), KeyH: () => toggleShortcutOverlay(), KeyI: () => toggleAboutOverlay(), }; window.addEventListener("DOMContentLoaded", () => { logInfo("DOM ready, initializing app"); cacheElements(); bindEvents(); bootstrap(); }); /** * Finds and stores all the DOM nodes we need to reference frequently so we * avoid repeated lookups every render. */ function cacheElements() { elements.localTime = document.getElementById("local-time"); elements.localDate = document.getElementById("local-date"); elements.utcTime = document.getElementById("utc-time"); elements.utcDate = document.getElementById("utc-date"); elements.alarmForm = document.getElementById("alarm-form"); elements.alarmTime = document.getElementById("alarm-time"); elements.alarmLabel = document.getElementById("alarm-label"); elements.alarmList = document.getElementById("alarm-list"); elements.nextAlarm = document.getElementById("next-alarm"); elements.timerInput = document.getElementById("timer-input"); elements.timerDisplay = document.getElementById("timer-display"); elements.timerToggle = document.getElementById("timer-toggle"); elements.timerReset = document.getElementById("timer-reset"); elements.timerStatus = document.getElementById("timer-status"); elements.stopwatchDisplay = document.getElementById("stopwatch-display"); elements.stopwatchToggle = document.getElementById("stopwatch-toggle"); elements.stopwatchLap = document.getElementById("stopwatch-lap"); elements.stopwatchReset = document.getElementById("stopwatch-reset"); elements.stopwatchLaps = document.getElementById("stopwatch-laps"); elements.statusMessage = document.getElementById("status-message"); elements.shortcutOverlay = document.getElementById("shortcut-overlay"); elements.shortcutClose = document.getElementById("shortcut-close"); elements.shortcutTrigger = document.getElementById("shortcut-trigger"); elements.aboutOverlay = document.getElementById("about-overlay"); elements.aboutClose = document.getElementById("about-close"); elements.aboutTrigger = document.getElementById("about-trigger"); elements.aboutName = document.getElementById("about-name"); elements.aboutVersion = document.getElementById("about-version"); elements.aboutIdentifier = document.getElementById("about-identifier"); elements.aboutLicenseName = document.getElementById("about-license-name"); elements.aboutLicenseText = document.getElementById("about-license-text"); 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 }); } } /** * Hooks up all DOM events, including keyboard shortcuts, button clicks, and * form submissions, translating them into backend commands where needed. */ function bindEvents() { elements.alarmForm.addEventListener("submit", async (event) => { event.preventDefault(); 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) => { const button = event.target.closest("button[data-action]"); if (!button) return; const id = button.dataset.id; const action = button.dataset.action; if (action === "toggle") requestState("toggle_alarm", { id }); if (action === "delete") requestState("delete_alarm", { id }); }); elements.timerToggle.addEventListener("click", () => triggerTimerToggle(false)); elements.timerReset.addEventListener("click", () => requestState("reset_timer")); elements.timerInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); triggerTimerToggle(true); } }); 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) => { if (event.target === elements.shortcutOverlay) toggleShortcutOverlay(false); }); if (elements.shortcutTrigger) { elements.shortcutTrigger.addEventListener("click", () => toggleShortcutOverlay(true)); } elements.aboutClose.addEventListener("click", () => toggleAboutOverlay(false)); elements.aboutOverlay.addEventListener("click", (event) => { if (event.target === elements.aboutOverlay) toggleAboutOverlay(false); }); if (elements.aboutTrigger) { elements.aboutTrigger.addEventListener("click", () => toggleAboutOverlay(true)); } document.addEventListener("keydown", handleShortcuts, true); logInfo("Core UI events bound"); } /** * Performs the initial state fetch and schedules the recurring `tick` loop so * that the UI stays synchronized with the backend clock. */ async function bootstrap() { logInfo("Bootstrapping state"); await Promise.all([requestState("get_state"), loadAboutInfo()]); setInterval(runTick, 250); logInfo("Tick loop scheduled", { intervalMs: 250 }); } /** Loads application metadata used by the About dialog. */ async function loadAboutInfo() { try { aboutInfo = await invoke("get_about_info"); renderAboutInfo(aboutInfo); } catch (error) { logError("Failed to load About information", error); } } /** * Requests a timer toggle from the backend, optionally forcing a reparse of * the duration input if the user explicitly pressed Enter inside the field. */ function triggerTimerToggle(forceParse) { logDebug("Timer toggle requested", { forceParse, input: elements.timerInput.value }); requestState("toggle_timer", { input: elements.timerInput.value, forceParse, }); } /** * Generic wrapper around the Tauri invoke API that logs both the request and * response, applies the new state snapshot, and gracefully handles failures. */ async function requestState(command, payload = {}) { 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; } } /** * Polling task that asks the backend for a light-weight state update so that * timers, alarms, and stopwatches continue to advance even without user * interaction. Re-entrancy is avoided via `tickInFlight`. */ async function runTick() { if (tickInFlight) { logDebug("Tick skipped because a previous request is still pending"); 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; } } /** * Applies a backend-provided snapshot to every UI subsystem (clock, alarms, * timer, stopwatch, and status messaging). Also plays the chime if requested. */ function applyState(state) { 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(); } } /** Re-renders the clock labels for both local time and UTC. */ 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; } /** * Renders the alarm list including badges, actions, and empty states, while * also surfacing the next upcoming alarm summary. */ function renderAlarms(section) { const list = elements.alarmList; list.innerHTML = ""; if (!section || !section.items.length) { const empty = document.createElement("li"); empty.textContent = "アラームはまだありません"; list.appendChild(empty); } else { 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 = `${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 = `次回: ${alarm.next_relative}`; info.append(title, badge, next); const actions = document.createElement("div"); actions.className = "alarm-actions"; const toggleBtn = document.createElement("button"); toggleBtn.type = "button"; toggleBtn.dataset.action = "toggle"; toggleBtn.dataset.id = alarm.id; toggleBtn.textContent = alarm.active ? "停止" : "起動"; const deleteBtn = document.createElement("button"); deleteBtn.type = "button"; deleteBtn.dataset.action = "delete"; deleteBtn.dataset.id = alarm.id; deleteBtn.textContent = "削除"; actions.append(toggleBtn, deleteBtn); li.append(info, actions); list.appendChild(li); }); } elements.nextAlarm.textContent = section ? section.next_summary : ""; } /** Reflects the backend timer information in the UI controls and labels. */ function renderTimer(view) { if (!view) return; elements.timerDisplay.textContent = view.display; elements.timerToggle.textContent = view.toggle_label; elements.timerStatus.textContent = view.status_text || ""; } /** Displays stopwatch totals, the toggle label, and lap entries. */ function renderStopwatch(view) { if (!view) return; elements.stopwatchDisplay.textContent = view.display; elements.stopwatchToggle.textContent = view.toggle_label; const list = elements.stopwatchLaps; list.innerHTML = ""; if (!view.laps.length) { const empty = document.createElement("li"); empty.textContent = "ラップなし"; list.appendChild(empty); return; } 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); }); } /** Shows transient status messages returned by the backend. */ function renderStatus(message) { if (!elements.statusMessage) return; elements.statusMessage.textContent = message || ""; } /** Focuses a control and selects its text when possible for easy overwriting. */ function focusAndSelect(element) { if (!element) return; element.focus(); if (typeof element.select === "function") element.select(); } /** * Toggles the keyboard shortcut cheat sheet overlay, optionally forcing the * visibility state when invoked with a boolean. */ function toggleShortcutOverlay(force) { const overlay = elements.shortcutOverlay; if (!overlay) return; const next = typeof force === "boolean" ? force : overlay.hidden; if (next) { toggleAboutOverlay(false); } overlay.hidden = !next; logInfo("Shortcut overlay toggled", { visible: next }); if (next) { overlay.querySelector(".shortcut-card")?.focus?.(); } } /** * Toggles the About dialog visibility and lazily refreshes metadata when * missing so the UI can recover from early load failures. */ function toggleAboutOverlay(force) { const overlay = elements.aboutOverlay; if (!overlay) return; const next = typeof force === "boolean" ? force : overlay.hidden; if (next) { toggleShortcutOverlay(false); if (!aboutInfo) { loadAboutInfo(); } } overlay.hidden = !next; logInfo("About overlay toggled", { visible: next }); if (next) { overlay.querySelector(".about-card")?.focus?.(); } } /** Applies About metadata fields into the dialog. */ function renderAboutInfo(info) { if (!info) return; elements.aboutName.textContent = info.app_name || "-"; elements.aboutVersion.textContent = info.version || "-"; elements.aboutIdentifier.textContent = info.identifier || "-"; elements.aboutLicenseName.textContent = info.license_name || "-"; elements.aboutLicenseText.textContent = info.license_text || ""; } /** * Global keyboard listener that translates single-key shortcuts into higher * level actions while respecting modifier keys, focus states, and overlays. */ function handleShortcuts(event) { if (event.defaultPrevented) return; const shortcutOverlayVisible = elements.shortcutOverlay && !elements.shortcutOverlay.hidden; const aboutOverlayVisible = elements.aboutOverlay && !elements.aboutOverlay.hidden; const overlayVisible = shortcutOverlayVisible || aboutOverlayVisible; 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 (shortcutOverlayVisible) { toggleShortcutOverlay(false); event.preventDefault(); return; } if (aboutOverlayVisible) { toggleAboutOverlay(false); event.preventDefault(); return; } if (document.activeElement && document.activeElement !== document.body) { document.activeElement.blur(); event.preventDefault(); } return; } if (event.altKey || event.metaKey || event.ctrlKey) return; if (overlayVisible && event.code !== "KeyH" && event.code !== "KeyI") return; if (isTypingTarget(event.target)) return; const handler = SHORTCUTS[event.code]; if (handler) { event.preventDefault(); logInfo("Keyboard shortcut triggered", { code: event.code }); handler(); } } /** Returns true when the event target is an editable/typing-friendly element. */ function isTypingTarget(target) { if (!target) return false; const element = target instanceof Element ? target : target.parentElement; if (!element) return false; if (element.isContentEditable) return true; return Boolean(element.closest("input, textarea, select, [contenteditable='true']")); } /** * Plays a very small synthesized chime using the Web Audio API in order to * alert the user even when no native notifications are available. */ function playChime(patternName = "alarm") { 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; }); }