chore: initial commit

This commit is contained in:
2026-01-12 01:34:32 +09:00
commit 764a6349ef
33 changed files with 7008 additions and 0 deletions

680
src/main.js Normal file
View File

@@ -0,0 +1,680 @@
/**
* TauClock frontend controller
* ----------------------------
* Coordinates three features:
* 1. Live clocks in local/UTC time
* 2. Persistent alarms with keyboard shortcuts and audible chimes
* 3. Timer + stopwatch utilities with a shared status message system
*
* The logic is split by feature area, and each function carries a short
* description so new contributors can navigate quickly.
*/
/**
* Namespaced keys used for browser localStorage.
* Keeping the strings centralized makes future migrations trivial.
*/
const STORAGE_KEYS = {
alarms: "tauclock-alarms",
};
/** Cache of DOM nodes we touch frequently to avoid repeated lookups. */
const elements = {};
/** In-memory representation of scheduled alarms. */
const alarmState = {
items: [],
};
/** Mutable state that drives the countdown timer UI. */
const timerState = {
initialMs: 0,
remainingMs: 0,
running: false,
targetTime: null,
lastInput: "",
};
/** Bookkeeping for the stopwatch including lap history. */
const stopwatchState = {
running: false,
startTime: null,
elapsedBefore: 0,
laps: [],
};
/** Handle used to clear the transient status message timeout. */
let statusTimer;
/** Keyboard shortcut handlers keyed by the raw KeyboardEvent#code. */
const SHORTCUTS = {
KeyA: () => focusAndSelect(elements.alarmTime),
KeyN: () => elements.alarmForm.requestSubmit(),
KeyT: () => focusAndSelect(elements.timerInput),
KeyG: () => toggleTimer(),
KeyR: () => resetTimer(),
KeyW: () => toggleStopwatch(),
KeyL: () => addLap(),
KeyP: () => resetStopwatch(),
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", () => {
cacheElements();
bindEvents();
loadAlarms();
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() {
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");
}
/** Wire up submit/click/keyboard handlers for alarms, timer, and stopwatch. */
function bindEvents() {
elements.alarmForm.addEventListener("submit", (event) => {
event.preventDefault();
createAlarm();
});
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") toggleAlarm(id);
if (action === "delete") deleteAlarm(id);
});
elements.timerToggle.addEventListener("click", () => toggleTimer());
elements.timerReset.addEventListener("click", () => resetTimer());
elements.timerInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
toggleTimer(true);
}
});
elements.stopwatchToggle.addEventListener("click", () => toggleStopwatch());
elements.stopwatchLap.addEventListener("click", () => addLap());
elements.stopwatchReset.addEventListener("click", () => resetStopwatch());
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));
}
document.addEventListener("keydown", handleShortcuts, true);
}
/**
* Re-render the local and UTC clocks. Called on an interval for a live view.
*/
function updateClock() {
const now = new Date();
elements.localTime.textContent = formatTime(now);
elements.localDate.textContent = formatDate(now);
const utc = new Date(now.getTime());
elements.utcTime.textContent = formatTime(utc, { timeZone: "UTC" });
elements.utcDate.textContent = formatDate(utc, { timeZone: "UTC" });
}
/** Format a Date instance into HH:MM:SS in the provided locale/timezone. */
function formatTime(date, options = {}) {
return new Intl.DateTimeFormat("ja-JP", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
...options,
}).format(date);
}
/** Format a Date instance into a friendly weekday/month/day triplet. */
function formatDate(date, options = {}) {
return new Intl.DateTimeFormat("ja-JP", {
weekday: "short",
month: "short",
day: "2-digit",
...options,
}).format(date);
}
/**
* Validate form input, build an alarm record, persist, and update the list.
*/
function createAlarm() {
const timeValue = elements.alarmTime.value;
if (!timeValue) {
pushStatus("アラーム時刻を入力してください");
return;
}
const [hour, minute] = timeValue.split(":").map((v) => Number(v));
if (Number.isNaN(hour) || Number.isNaN(minute)) {
pushStatus("時刻の形式が正しくありません");
return;
}
const label = elements.alarmLabel.value.trim() || "アラーム";
const nextTrigger = resolveNextTrigger(hour, minute);
const alarm = {
id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`,
hour,
minute,
label,
active: true,
nextTrigger,
ringing: false,
};
alarmState.items.push(alarm);
persistAlarms();
renderAlarms();
elements.alarmForm.reset();
pushStatus(`アラーム追加: ${label}`);
}
/** Return the next timestamp (ms) when an alarm should fire for hour/minute. */
function resolveNextTrigger(hour, minute) {
const now = new Date();
const candidate = new Date(now);
candidate.setHours(hour, minute, 0, 0);
if (candidate.getTime() <= now.getTime()) {
candidate.setDate(candidate.getDate() + 1);
}
return candidate.getTime();
}
/**
* Render the alarm list with action buttons and show the next scheduled alarm.
*/
function renderAlarms() {
const list = elements.alarmList;
list.innerHTML = "";
if (!alarmState.items.length) {
const empty = document.createElement("li");
empty.textContent = "アラームはまだありません";
list.appendChild(empty);
} else {
const sorted = [...alarmState.items].sort((a, b) => a.nextTrigger - b.nextTrigger);
sorted.forEach((alarm) => {
const li = document.createElement("li");
if (!alarm.active) li.classList.add("muted");
if (alarm.ringing) li.classList.add("ringing");
const info = document.createElement("div");
const title = document.createElement("strong");
title.textContent = `${twoDigits(alarm.hour)}:${twoDigits(alarm.minute)} - ${alarm.label}`;
const badge = document.createElement("span");
badge.className = "badge";
badge.textContent = alarm.active ? "ON" : "OFF";
const next = document.createElement("small");
next.textContent = `次回: ${formatRelative(alarm.nextTrigger)}`;
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);
});
}
updateNextAlarm();
}
/** Update the "next alarm" label to highlight the soonest active alarm. */
function updateNextAlarm() {
const nextLabel = elements.nextAlarm;
const active = alarmState.items.filter((alarm) => alarm.active);
if (!active.length) {
nextLabel.textContent = "アクティブなアラームはありません";
return;
}
const soonest = active.reduce((acc, alarm) => (alarm.nextTrigger < acc.nextTrigger ? alarm : acc));
nextLabel.textContent = `次のアラーム: ${twoDigits(soonest.hour)}:${twoDigits(soonest.minute)} (${soonest.label}) - ${formatRelative(soonest.nextTrigger)}`;
}
/**
* Convert a future timestamp into a concise relative label (e.g. "5分後").
*/
function formatRelative(timestamp) {
const now = Date.now();
const diff = timestamp - now;
if (diff <= 60 * 1000) return "まもなく";
const minutes = Math.round(diff / 60000);
if (minutes < 60) return `${minutes}分後`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}時間${mins}分後`;
}
/** Enable/disable alarms as the user toggles their switches. */
function toggleAlarm(id) {
const alarm = alarmState.items.find((item) => item.id === id);
if (!alarm) return;
alarm.active = !alarm.active;
alarm.nextTrigger = resolveNextTrigger(alarm.hour, alarm.minute);
alarm.ringing = false;
persistAlarms();
renderAlarms();
}
/** Remove an alarm entirely and surface a toast-like status message. */
function deleteAlarm(id) {
const index = alarmState.items.findIndex((item) => item.id === id);
if (index === -1) return;
const [removed] = alarmState.items.splice(index, 1);
persistAlarms();
renderAlarms();
pushStatus(`アラーム削除: ${removed.label}`);
}
/**
* Polling loop that checks which alarms should fire and plays feedback.
*/
function checkAlarms() {
const now = Date.now();
let triggered = false;
alarmState.items.forEach((alarm) => {
if (!alarm.active) return;
if (now >= alarm.nextTrigger) {
alarm.ringing = true;
alarm.nextTrigger = resolveNextTrigger(alarm.hour, alarm.minute);
triggered = true;
notifyUser(`アラーム: ${alarm.label}`);
setTimeout(() => {
alarm.ringing = false;
renderAlarms();
}, 6000);
}
});
if (triggered) {
persistAlarms();
renderAlarms();
}
}
/** Serialize alarms into localStorage for durability across restarts. */
function persistAlarms() {
try {
localStorage.setItem(STORAGE_KEYS.alarms, JSON.stringify(alarmState.items));
} catch (error) {
console.warn("Failed to save alarms", error);
}
}
/** Hydrate the in-memory alarm list from the persisted browser storage. */
function loadAlarms() {
try {
const raw = localStorage.getItem(STORAGE_KEYS.alarms);
if (raw) {
alarmState.items = JSON.parse(raw).map((alarm) => ({
...alarm,
nextTrigger: resolveNextTrigger(alarm.hour, alarm.minute),
ringing: false,
}));
}
} catch (error) {
console.warn("Failed to load alarms", error);
}
}
/** Helper for zero-padding numbers to two digits. */
function twoDigits(number) {
return number.toString().padStart(2, "0");
}
/**
* Accept flexible timer strings such as "5:30", "1h 2m", or raw seconds.
* Returns milliseconds or null when the input cannot be parsed.
*/
function parseDuration(value) {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.includes(":")) {
const [minutes, seconds] = trimmed.split(":").map((part) => Number(part));
if (Number.isNaN(minutes) || Number.isNaN(seconds)) return null;
return (minutes * 60 + seconds) * 1000;
}
const pattern = /^((\d+)h)?\s*((\d+)m)?\s*((\d+)s)?$/i;
const match = trimmed.match(pattern);
if (match) {
const hours = Number(match[2] || 0);
const minutes = Number(match[4] || 0);
const seconds = Number(match[6] || 0);
const totalSeconds = hours * 3600 + minutes * 60 + seconds;
return totalSeconds * 1000;
}
const seconds = Number(trimmed);
if (!Number.isNaN(seconds)) return seconds * 1000;
return null;
}
/**
* Start or pause the countdown timer, optionally re-parsing the duration.
*/
function toggleTimer(forceParse = false) {
if (timerState.running) {
pauseTimer();
return;
}
const rawInput = elements.timerInput.value.trim();
const needsParse = forceParse || timerState.initialMs === 0 || (rawInput && rawInput !== timerState.lastInput);
if (needsParse) {
const duration = parseDuration(rawInput);
if (!duration || duration < 1000) {
pushStatus("1秒以上の時間を入力してください");
return;
}
timerState.initialMs = duration;
timerState.remainingMs = duration;
timerState.lastInput = rawInput;
}
if (timerState.remainingMs <= 0) {
timerState.remainingMs = timerState.initialMs;
}
timerState.running = true;
timerState.targetTime = Date.now() + timerState.remainingMs;
elements.timerToggle.textContent = "一時停止";
elements.timerStatus.textContent = "カウント中";
}
/** Snapshot remaining time while keeping the parsed duration intact. */
function pauseTimer() {
if (!timerState.running) return;
timerState.remainingMs = Math.max(0, timerState.targetTime - Date.now());
timerState.running = false;
timerState.targetTime = null;
elements.timerToggle.textContent = "再開";
elements.timerStatus.textContent = "一時停止";
}
/** Restore the timer to its initial duration and stop playback. */
function resetTimer() {
timerState.running = false;
timerState.targetTime = null;
timerState.remainingMs = timerState.initialMs;
elements.timerToggle.textContent = "スタート";
elements.timerStatus.textContent = "";
updateTimerDisplay();
}
/** Refresh timer state and fire completion notification when needed. */
function updateTimerDisplay() {
if (timerState.running) {
timerState.remainingMs = Math.max(0, timerState.targetTime - Date.now());
if (timerState.remainingMs === 0) {
timerState.running = false;
timerState.targetTime = null;
elements.timerToggle.textContent = "スタート";
elements.timerStatus.textContent = "完了";
notifyUser("タイマー終了");
}
}
renderTimer();
}
/** Format the remaining timer milliseconds for the UI label. */
function renderTimer() {
const ms = typeof timerState.remainingMs === "number" ? timerState.remainingMs : timerState.initialMs || 0;
elements.timerDisplay.textContent = formatDuration(ms);
}
/** Represent milliseconds as MM:SS.hh or HH:MM:SS for long intervals. */
function formatDuration(ms) {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const hundred = Math.floor((ms % 1000) / 10);
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const remMinutes = minutes % 60;
return `${twoDigits(hours)}:${twoDigits(remMinutes)}:${twoDigits(seconds)}`;
}
return `${twoDigits(minutes)}:${twoDigits(seconds)}.${twoDigits(hundred)}`;
}
/** Toggle stopwatch run/pause state and align button labels accordingly. */
function toggleStopwatch() {
if (stopwatchState.running) {
stopwatchState.elapsedBefore += Date.now() - stopwatchState.startTime;
stopwatchState.running = false;
stopwatchState.startTime = null;
elements.stopwatchToggle.textContent = "スタート";
} else {
stopwatchState.running = true;
stopwatchState.startTime = Date.now();
elements.stopwatchToggle.textContent = "停止";
}
}
/** Compute elapsed milliseconds including paused time plus current run. */
function getStopwatchElapsed() {
return stopwatchState.elapsedBefore + (stopwatchState.running ? Date.now() - stopwatchState.startTime : 0);
}
/** Update the on-screen stopwatch label from the current elapsed time. */
function updateStopwatchDisplay() {
elements.stopwatchDisplay.textContent = formatStopwatch(getStopwatchElapsed());
}
/** Render both the stopwatch display and lap summary list. */
function renderStopwatch() {
updateStopwatchDisplay();
renderLaps();
}
/** Format stopwatch precision output (MM:SS.mmm). */
function formatStopwatch(ms) {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const millis = ms % 1000;
return `${twoDigits(minutes)}:${twoDigits(seconds)}.${millis.toString().padStart(3, "0")}`;
}
/** Capture the current elapsed time as a lap with delta vs. previous lap. */
function addLap() {
const elapsed = getStopwatchElapsed();
if (elapsed === 0) return;
const previous = stopwatchState.laps.at(-1)?.totalMs ?? 0;
stopwatchState.laps.push({
id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`,
totalMs: elapsed,
deltaMs: elapsed - previous,
});
renderLaps();
}
/**
* Render lap entries newest-first with cumulative and delta timings.
*/
function renderLaps() {
const list = elements.stopwatchLaps;
list.innerHTML = "";
if (!stopwatchState.laps.length) {
const empty = document.createElement("li");
empty.textContent = "ラップなし";
list.appendChild(empty);
return;
}
stopwatchState.laps
.slice()
.reverse()
.forEach((lap, index) => {
const li = document.createElement("li");
const label = document.createElement("strong");
const lapNumber = stopwatchState.laps.length - index;
label.textContent = `Lap ${lapNumber}`;
const total = document.createElement("small");
total.textContent = `合計 ${formatStopwatch(lap.totalMs)}`;
const delta = document.createElement("small");
delta.textContent = `+${formatStopwatch(lap.deltaMs)}`;
li.append(label, total, delta);
list.appendChild(li);
});
}
/** Clear stopwatch progress and lap history. */
function resetStopwatch() {
stopwatchState.running = false;
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) {
if (!element) return;
element.focus();
if (typeof element.select === "function") element.select();
}
/**
* Briefly show a non-blocking status label near the footer of the UI.
*/
function pushStatus(message) {
if (!elements.statusMessage) return;
elements.statusMessage.textContent = message;
clearTimeout(statusTimer);
statusTimer = setTimeout(() => {
elements.statusMessage.textContent = "";
}, 4000);
}
/** Combine a status toast with an audible chime. */
function notifyUser(message) {
pushStatus(message);
playChime();
}
/** Lightweight synthesizer used for alarm/timer notification chimes. */
function playChime(patternName = "alarm") {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return;
playChime.ctx = playChime.ctx || new AudioCtx();
if (playChime.ctx.state === "suspended") {
playChime.ctx.resume();
}
const ctx = playChime.ctx;
const pattern = AUDIO_PATTERNS[patternName] || AUDIO_PATTERNS.short;
let startTime = ctx.currentTime;
pattern.forEach((frequency) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.value = frequency;
osc.connect(gain);
gain.connect(ctx.destination);
gain.gain.setValueAtTime(0.0001, startTime);
gain.gain.exponentialRampToValueAtTime(0.25, startTime + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, startTime + 0.3);
osc.start(startTime);
osc.stop(startTime + 0.32);
startTime += 0.35;
});
}
/** Global keyboard shortcut dispatcher honoring focus/overlay state. */
function handleShortcuts(event) {
if (event.defaultPrevented) return;
const overlayVisible = elements.shortcutOverlay && !elements.shortcutOverlay.hidden;
if (event.key === "Escape") {
if (overlayVisible) {
toggleShortcutOverlay(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") return;
if (isTypingTarget(event.target)) return;
const handler = SHORTCUTS[event.code];
if (handler) {
event.preventDefault();
handler();
}
}
/**
* Show/hide the legend that explains available keyboard shortcuts.
*/
function toggleShortcutOverlay(force) {
const overlay = elements.shortcutOverlay;
const next = typeof force === "boolean" ? force : overlay.hidden;
overlay.hidden = !next;
if (next) {
overlay.querySelector(".shortcut-card").focus?.();
}
}
/** Determine whether a keyboard event originated from a text input area. */
function isTypingTarget(target) {
if (!target) return false;
const element = target instanceof Element ? target : target.parentElement;
if (!element) return false;
if (element.isContentEditable) return true;
return Boolean(element.closest("input, textarea, select, [contenteditable='true']"));
}