1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
|
<!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; --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; 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; }
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; }
.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; }
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>
<div class="bar">
<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="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 <= 30; h++) {
$("from").add(new Option(`${h}時`, h * 60));
$("to").add(new Option(`${h}時`, h * 60));
}
$("from").value = 0;
$("to").value = 30 * 60;
function renderDays(selected) {
const today = new Date();
$("days").innerHTML = "";
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()]})`;
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) {
day = day || $("date").value;
$("date").value = day;
renderDays(day);
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();
}
// 時間帯の中に minutes 分以上続く空きがあるか
function hasFreeRun(room, from, to, minutes) {
let run = 0;
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;
}
function render() {
const from = +$("from").value, to = +$("to").value, span = +$("span").value;
const onlyFree = $("onlyFree").checked;
const online = data.studios.filter((s) => s.online);
// 表示する列はセット内の全スタジオの枠の和集合
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);
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 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;
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;
}
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">${esc(r.name)}</td>${cells}</tr>`);
});
}
rows.push("</tbody>");
$("grid").innerHTML = rows.join("");
$("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>`
: "";
}
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>
|