Files
tauclock/src/main.js

560 lines
20 KiB
JavaScript

/**
* 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;
});
}