summaryrefslogtreecommitdiff
path: root/static
diff options
context:
space:
mode:
authoryyamashita <8nmdh5w22f@privaterelay.appleid.com>2026-11-01 08:59:15 +0900
committeryyamashita <8nmdh5w22f@privaterelay.appleid.com>2026-11-01 08:59:15 +0900
commita661eedbd30206e940ed8fb6a19f139311adbdfb (patch)
tree6eed45d0a1d7b9be04de04c6d93f491a5071109c /static
西荻窪周辺スタジオの空き枠一覧ページを追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'static')
-rw-r--r--static/index.html159
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>