diff options
Diffstat (limited to 'static/index.html')
| -rw-r--r-- | static/index.html | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..55c99e8 --- /dev/null +++ b/static/index.html @@ -0,0 +1,159 @@ +<!doctype html> +<html lang="ja"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>西荻窪周辺 スタジオ空き枠</title> +<style> + :root { --free: #2f9e5b; --free-bg: #e3f5ea; --booked: #b8b8b8; --line: #ddd; } + * { box-sizing: border-box; } + body { font-family: system-ui, "Hiragino Sans", "Noto Sans JP", sans-serif; margin: 0; padding: 16px; color: #222; } + h1 { font-size: 20px; margin: 0 0 12px; } + .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; } + .days { display: flex; gap: 4px; overflow-x: auto; } + .days button { min-width: 64px; } + button, input, select { font: inherit; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; background: #fff; cursor: pointer; } + button.active { background: #222; color: #fff; border-color: #222; } + .sat { color: #2463c2; } .sun { color: #c23a3a; } + button.active.sat, button.active.sun { color: #fff; } + .wrap { overflow-x: auto; } + table { border-collapse: collapse; min-width: 100%; } + th, td { border: 1px solid var(--line); padding: 0; text-align: center; font-size: 13px; } + thead th { background: #f6f6f6; padding: 6px 4px; position: sticky; top: 0; } + th.studio { text-align: left; padding: 6px 10px; white-space: nowrap; background: #fafafa; } + th.studio a { color: inherit; } + th.studio .meta { display: block; font-weight: normal; font-size: 11px; color: #777; } + td.room { padding: 6px 8px; white-space: nowrap; font-weight: bold; } + td.cell { width: 44px; height: 32px; } + td.free { background: var(--free-bg); color: var(--free); font-weight: bold; } + td.free a { display: block; line-height: 32px; color: inherit; text-decoration: none; } + td.booked { background: repeating-linear-gradient(45deg, #f2f2f2, #f2f2f2 4px, #e8e8e8 4px, #e8e8e8 8px); color: var(--booked); } + td.closed { background: #fff; } + tr.hidden { display: none; } + .err { color: #c23a3a; font-size: 12px; } + .note { font-size: 12px; color: #777; margin-top: 10px; } +</style> +</head> +<body> +<h1>西荻窪周辺 スタジオ空き枠</h1> + +<div class="bar"> + <div class="days" id="days"></div> + <input type="date" id="date"> +</div> +<div class="bar"> + <label>時間帯 + <select id="from"></select> 〜 <select id="to"></select> + </label> + <label>連続 + <select id="span"> + <option value="1">1時間以上</option> + <option value="2" selected>2時間以上</option> + <option value="3">3時間以上</option> + </select> + </label> + <label><input type="checkbox" id="onlyFree" checked> 空きがある部屋だけ</label> +</div> + +<div class="wrap"><table id="grid"></table></div> +<p class="note" id="note"></p> + +<script> +const $ = (id) => document.getElementById(id); +const WEEK = ["日", "月", "火", "水", "木", "金", "土"]; +const ymd = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +let data = null; + +for (let h = 0; h <= 24; h++) { + $("from").add(new Option(`${h}時`, h)); + $("to").add(new Option(`${h}時`, h)); +} +$("from").value = 10; +$("to").value = 24; + +function renderDays(selected) { + const today = new Date(); + $("days").innerHTML = ""; + for (let i = 0; i < 14; i++) { + const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + i); + const b = document.createElement("button"); + b.textContent = `${d.getMonth() + 1}/${d.getDate()}(${WEEK[d.getDay()]})`; + if (d.getDay() === 6) b.classList.add("sat"); + if (d.getDay() === 0) b.classList.add("sun"); + if (ymd(d) === selected) b.classList.add("active"); + b.onclick = () => load(ymd(d)); + $("days").append(b); + } +} + +async function load(day) { + $("date").value = day; + renderDays(day); + const res = await fetch(`/api/slots?date=${day}`); + data = await res.json(); + render(); +} + +// 指定した時間帯の中に、span 時間以上続く空きがあるか +function hasFreeRun(byHour, from, to, span) { + let run = 0; + for (let h = from; h < to; h++) { + run = byHour[h] === "free" ? run + 1 : 0; + if (run >= span) return true; + } + return false; +} + +function render() { + const from = +$("from").value, to = +$("to").value, span = +$("span").value; + const onlyFree = $("onlyFree").checked; + const hours = []; + for (let h = from; h < to; h++) hours.push(h); + + const rows = [`<thead><tr><th>スタジオ</th><th>部屋</th>${hours.map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>`]; + let shown = 0; + + for (const s of data.studios) { + const rooms = s.rooms.map((room) => { + const byHour = {}; + for (const slot of s.slots) if (slot.room === room) byHour[slot.hour] = slot.status; + return { room, byHour, ok: hasFreeRun(byHour, from, to, span) }; + }); + const visible = rooms.filter((r) => !onlyFree || r.ok); + if (!visible.length && !s.error) continue; + shown += visible.length; + + const link = s.booking ? `<a href="${s.booking}" target="_blank" rel="noopener">${s.name}</a>` : `<a href="${s.url}" target="_blank" rel="noopener">${s.name}</a>`; + const meta = [s.area, s.phone, s.provider === "sample" ? "サンプルデータ" : ""].filter(Boolean).join(" / "); + const head = `<th class="studio" rowspan="${Math.max(visible.length, 1)}">${link}<span class="meta">${meta}</span>${s.error ? `<span class="err">取得失敗: ${s.error}</span>` : ""}</th>`; + + if (!visible.length) { + rows.push(`<tr>${head}<td colspan="${hours.length + 1}"></td></tr>`); + continue; + } + visible.forEach((r, i) => { + const cells = hours.map((h) => { + const st = r.byHour[h]; + if (st === "free") { + const href = s.booking || s.url; + return `<td class="cell free" title="${r.room} ${h}:00-${h + 1}:00 空き"><a href="${href}" target="_blank" rel="noopener">○</a></td>`; + } + if (st === "booked") return `<td class="cell booked">×</td>`; + return `<td class="cell closed"></td>`; + }).join(""); + rows.push(`<tr>${i === 0 ? head : ""}<td class="room">${r.room}</td>${cells}</tr>`); + }); + } + rows.push("</tbody>"); + $("grid").innerHTML = rows.join(""); + $("note").textContent = shown + ? "○ をクリックすると予約ページ(なければ公式サイト)を開きます。電話予約のみのスタジオは電話番号を表示しています。" + : "条件に合う空き枠がありません。"; +} + +["from", "to", "span", "onlyFree"].forEach((id) => $(id).addEventListener("change", () => data && render())); +$("date").addEventListener("change", (e) => e.target.value && load(e.target.value)); +load(ymd(new Date())); +</script> +</body> +</html> |
