560 lines
20 KiB
JavaScript
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;
|
|
});
|
|
}
|