diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-11-01 09:19:55 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-11-01 09:19:55 +0900 |
| commit | dcb35334905f914dfd3eaa7453f0b019bd777588 (patch) | |
| tree | 2a78d999c28b40abf682b0c2b7c3f1802b98c4f3 /static/index.html | |
| parent | f0044f886359d942fe479a9a8a940af17c7047ac (diff) | |
スタジオセットごとにリンキィディンクの実際の空き枠を表示する
ダミーデータをやめ、Reserve Mart の会員ページから日別状況を読む。
取得結果は 5 分キャッシュし、ノアなど取れないスタジオは電話番号とリンクを出す。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'static/index.html')
| -rw-r--r-- | static/index.html | 189 |
1 files changed, 113 insertions, 76 deletions
diff --git a/static/index.html b/static/index.html index 55c99e8..ed7a2da 100644 --- a/static/index.html +++ b/static/index.html @@ -3,78 +3,89 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<title>西荻窪周辺 スタジオ空き枠</title> +<title>スタジオ空き枠</title> <style> - :root { --free: #2f9e5b; --free-bg: #e3f5ea; --booked: #b8b8b8; --line: #ddd; } + :root { --free: #2f9e5b; --free-bg: #e3f5ea; --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; } + .days { display: flex; gap: 4px; overflow-x: auto; max-width: 100%; } + .days button { min-width: 64px; flex: none; } 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; } + table { border-collapse: collapse; } + th, td { border: 1px solid var(--line); padding: 0; text-align: center; font-size: 12px; } + thead th { background: #f6f6f6; padding: 4px 2px; font-weight: normal; } + thead th.hour { border-right-color: #bbb; } + th.studio { text-align: left; padding: 6px 10px; white-space: nowrap; background: #fafafa; vertical-align: top; } 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); } + .meta { display: block; font-weight: normal; font-size: 11px; color: #777; } + td.room { padding: 4px 8px; white-space: nowrap; text-align: left; position: sticky; left: 0; background: #fff; } + td.cell { width: 22px; min-width: 22px; height: 28px; } + td.free { background: var(--free-bg); } + td.free a { display: block; height: 28px; } + td.free a:hover { background: #bfe8cf; } + td.booked { background: #e6e6e6; } td.closed { background: #fff; } - tr.hidden { display: none; } - .err { color: #c23a3a; font-size: 12px; } + td.hour { border-right-color: #bbb; } + .err { color: #c23a3a; font-size: 12px; font-weight: normal; } + .offline { margin-top: 16px; } + .offline h2 { font-size: 15px; margin: 0 0 6px; } + .offline li { margin: 4px 0; font-size: 14px; } .note { font-size: 12px; color: #777; margin-top: 10px; } + .legend span { display: inline-block; width: 14px; height: 14px; vertical-align: middle; border: 1px solid var(--line); margin: 0 4px 0 10px; } + #status { font-size: 13px; color: #777; } </style> </head> <body> -<h1>西荻窪周辺 スタジオ空き枠</h1> +<h1>スタジオ空き枠</h1> <div class="bar"> - <div class="days" id="days"></div> + <select id="set"></select> <input type="date" id="date"> + <span id="status"></span> </div> +<div class="bar"><div class="days" id="days"></div></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> + <label>時間帯 <select id="from"></select> 〜 <select id="to"></select></label> + <label>連続 <select id="span"> + <option value="60">1時間以上</option> + <option value="90">1.5時間以上</option> + <option value="120" selected>2時間以上</option> + <option value="180">3時間以上</option> + </select></label> + <label><input type="checkbox" id="onlyFree" checked> 条件に合う部屋だけ</label> + <span class="legend"><span style="background:var(--free-bg)"></span>空き<span style="background:#e6e6e6"></span>予約済み</span> </div> <div class="wrap"><table id="grid"></table></div> <p class="note" id="note"></p> +<div class="offline" id="offline"></div> <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")}`; +const hm = (m) => `${Math.floor(m / 60)}:${String(m % 60).padStart(2, "0")}`; +const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); +const params = new URLSearchParams(location.search); let data = null; -for (let h = 0; h <= 24; h++) { - $("from").add(new Option(`${h}時`, h)); - $("to").add(new Option(`${h}時`, h)); +for (let h = 0; h <= 30; h++) { + $("from").add(new Option(`${h}時`, h * 60)); + $("to").add(new Option(`${h}時`, h * 60)); } -$("from").value = 10; -$("to").value = 24; +$("from").value = 0; +$("to").value = 30 * 60; function renderDays(selected) { const today = new Date(); $("days").innerHTML = ""; - for (let i = 0; i < 14; i++) { + for (let i = 0; i < 21; 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()]})`; @@ -87,19 +98,26 @@ function renderDays(selected) { } async function load(day) { + day = day || $("date").value; $("date").value = day; renderDays(day); - const res = await fetch(`/api/slots?date=${day}`); - data = await res.json(); + history.replaceState(null, "", `?set=${$("set").value}&date=${day}`); + $("status").textContent = "読み込み中…"; + const res = await fetch(`/api/slots?set=${$("set").value}&date=${day}`); + const body = await res.json(); + if (!res.ok) { $("status").textContent = body.error; return; } + data = body; + $("status").textContent = ""; render(); } -// 指定した時間帯の中に、span 時間以上続く空きがあるか -function hasFreeRun(byHour, from, to, span) { +// 時間帯の中に minutes 分以上続く空きがあるか +function hasFreeRun(room, from, to, minutes) { let run = 0; - for (let h = from; h < to; h++) { - run = byHour[h] === "free" ? run + 1 : 0; - if (run >= span) return true; + for (const s of room.slots) { + if (s.start < from || s.start >= to) { run = 0; continue; } + run = s.status === "free" ? run + (s.end - s.start) : 0; + if (run >= minutes) return true; } return false; } @@ -107,53 +125,72 @@ function hasFreeRun(byHour, from, to, span) { 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 online = data.studios.filter((s) => s.online); - const rows = [`<thead><tr><th>スタジオ</th><th>部屋</th>${hours.map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>`]; - let shown = 0; + // 表示する列はセット内の全スタジオの枠の和集合 + const starts = [...new Set(online.flatMap((s) => (s.rooms || []).flatMap((r) => r.slots.map((x) => x.start))))] + .filter((t) => t >= from && t < to).sort((a, b) => a - b); - 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 hourCols = []; + for (const t of starts) { + const last = hourCols[hourCols.length - 1]; + if (last && Math.floor(last.t / 60) === Math.floor(t / 60)) last.n++; + else hourCols.push({ t, n: 1 }); + } + const isHourEnd = (t) => { const i = starts.indexOf(t); return i === starts.length - 1 || Math.floor(starts[i + 1] / 60) !== Math.floor(t / 60); }; - 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>`; + const rows = [`<thead><tr><th>スタジオ</th><th>部屋</th>${hourCols.map((c) => `<th class="hour" colspan="${c.n}">${Math.floor(c.t / 60)}</th>`).join("")}</tr></thead><tbody>`]; + let shown = 0; - if (!visible.length) { - rows.push(`<tr>${head}<td colspan="${hours.length + 1}"></td></tr>`); + for (const s of online) { + const rooms = (s.rooms || []).filter((r) => !onlyFree || hasFreeRun(r, from, to, span)); + if (!rooms.length && !s.error) continue; + shown += rooms.length; + const href = s.booking || s.url; + const updated = s.fetched_at ? `${new Date(s.fetched_at * 1000).toLocaleTimeString("ja-JP", { hour: "2-digit", minute: "2-digit" })} 取得` : ""; + const meta = [s.area, s.phone && `<a href="tel:${esc(s.phone)}">${esc(s.phone)}</a>`, updated].filter(Boolean).join(" / "); + const head = `<th class="studio" rowspan="${Math.max(rooms.length, 1)}"><a href="${esc(href)}" target="_blank" rel="noopener">${esc(s.name)}</a><span class="meta">${meta}</span>${s.error ? `<span class="err">取得失敗: ${esc(s.error)}</span>` : ""}</th>`; + + if (!rooms.length) { + rows.push(`<tr>${head}<td colspan="${starts.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>`; + rooms.forEach((r, i) => { + const byStart = Object.fromEntries(r.slots.map((x) => [x.start, x])); + const cells = starts.map((t) => { + const slot = byStart[t]; + const st = slot ? slot.status : "closed"; + const cls = `cell ${st}${isHourEnd(t) ? " hour" : ""}`; + const title = `${r.name} ${hm(t)}〜${hm(slot ? slot.end : t + 30)}`; + if (st === "free") return `<td class="${cls}" title="${esc(title)} 空き"><a href="${esc(href)}" target="_blank" rel="noopener"></a></td>`; + return `<td class="${cls}" title="${esc(title)}"></td>`; }).join(""); - rows.push(`<tr>${i === 0 ? head : ""}<td class="room">${r.room}</td>${cells}</tr>`); + rows.push(`<tr>${i === 0 ? head : ""}<td class="room">${esc(r.name)}</td>${cells}</tr>`); }); } rows.push("</tbody>"); $("grid").innerHTML = rows.join(""); - $("note").textContent = shown - ? "○ をクリックすると予約ページ(なければ公式サイト)を開きます。電話予約のみのスタジオは電話番号を表示しています。" - : "条件に合う空き枠がありません。"; + $("note").textContent = online.length + ? (shown ? "緑の枠をクリックすると予約ページを開きます(要ログイン)。24 時以降の列は翌日の深夜です。" : "条件に合う空き枠がありません。") + : ""; + + const offline = data.studios.filter((s) => !s.online); + $("offline").innerHTML = offline.length + ? `<h2>オンラインで空きを確認できないスタジオ</h2><ul>${offline.map((s) => + `<li><a href="${esc(s.url)}" target="_blank" rel="noopener">${esc(s.name)}</a>(${esc(s.area)})${s.phone ? ` <a href="tel:${esc(s.phone)}">${esc(s.phone)}</a>` : " 問い合わせフォーム"}</li>`).join("")}</ul>` + : ""; } -["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())); +async function init() { + const sets = await (await fetch("/api/sets")).json(); + for (const s of sets) $("set").add(new Option(s.name, s.id)); + if (params.get("set")) $("set").value = params.get("set"); + $("set").addEventListener("change", () => load()); + $("date").addEventListener("change", (e) => e.target.value && load(e.target.value)); + ["from", "to", "span", "onlyFree"].forEach((id) => $(id).addEventListener("change", () => data && render())); + load(params.get("date") || ymd(new Date())); +} +init(); </script> </body> </html> |
