PageSourceSearch

https://golf-line-kanji.pages.dev/screens/attendance.js

js golf-line-kanji.pages.dev collected 2026-10-03 05:11:54 UTC 8,170 bytes, 200 lines download raw bytes

1import * as api from "../api.js";
2import * as storage from "../storage.js";
3import * as ui from "../ui-common.js";
4import { normalizeName } from "../names.js";
5const defaultDeps = { api, storage, ui };
6
7const ROSTER_ROWS = [["yes", "◯ 参加"], ["maybe", "△ 未定"], ["no", "× 不参加"]];
8
9// 画面2: 出欠調整(メンバー)。props: { event, attendances, identity, groupToken, navigate }
10export function mount(container, props, deps = defaultDeps) {
11  const ev = props.event;
12  const id = props.identity || { success: false };
13  // ★いまの回答(◯△×)はメンバーにも見せる(実機FB 2026-09-15)。🔑の無い端末で開いた幹事も
14  //   ここに来るので、見せないと誰が参加するのか分からない。get-event は元から全員に返している。
15  let atts = props.attendances || [];
16
17  // 同じ名前(normalizeName)の二重登録は1人として数える。当ツールの人の同定は元々名前キー
18  // (グループ戦績も同名を1人として集計)なので、それに揃える。
19  function uniqueNames(rows) {
20    const seen = new Set();
21    const out = [];
22    for (const a of rows) {
23      const name = (a.player && a.player.display_name) || "";
24      const key = normalizeName(name);
25      if (!key || seen.has(key)) continue;
26      seen.add(key);
27      out.push(name);
28    }
29    return out;
30  }
31
32  function roster() {
33    const box = document.createElement("div");
34    box.id = "att-roster";
35    box.setAttribute("style", "margin: 8px 0");
36    const line = (text) => {
37      const d = document.createElement("div");
38      d.textContent = text;
39      box.appendChild(d);
40    };
41    line("👥 いまの回答");
42    if (atts.length === 0) {
43      line("まだ回答がありません");
44      return box;
45    }
46    for (const [answer, label] of ROSTER_ROWS) {
47      const names = uniqueNames(atts.filter((a) => a.answer === answer));
48      line(names.length ? `${label} ${names.length}名: ${names.join(" / ")}` : `${label} 0名`);
49    }
50    const drivers = uniqueNames(atts.filter((a) => a.answer === "yes" && a.can_drive)).length;
51    line(`🚗 車出し可能(参加者): ${drivers}名`);
52    return box;
53  }
54
55  function renderForm() {
56    container.innerHTML = "";
57
58    const head = document.createElement("div");
59    head.className = "card";
60    const org = (ev.organizer && ev.organizer.display_name) || "";
61    const orgGuide = org ? `幹事(${escapeHtml(org)})に届きます` : "幹事に届きます";
62    head.innerHTML = `<div class="subhead">${escapeHtml(ev.name || "ラウンド出欠")}</div>` +
63      `<div>幹事: ${escapeHtml(org || "-")}</div>` +
64      `<div>プレー日: ${escapeHtml(ev.play_date || "-")}</div>`;
65    head.appendChild(roster());
66    const guide = document.createElement("p");
67    guide.className = "notice";
68    guide.innerHTML = `💡 回答すると${orgGuide}。`; // orgGuide の幹事名は escapeHtml 済み
69    head.appendChild(guide);
70    container.appendChild(head);
71
72    const card = document.createElement("div");
73    card.className = "card";
74
75    // 名前(LIFF 取得済なら初期値)
76    const nameWrap = document.createElement("div");
77    nameWrap.innerHTML = `<label for="att-name">お名前</label>`;
78    const nameInput = document.createElement("input");
79    nameInput.id = "att-name";
80    nameInput.value = id.success ? (id.display_name || "") : "";
81    nameWrap.appendChild(nameInput);
82    card.appendChild(nameWrap);
83
84    // 出欠ラジオ
85    const ans = document.createElement("div");
86    ans.innerHTML = `<label>出欠</label>` +
87      radio("answer", "yes", "◯ 参加", true) +
88      radio("answer", "maybe", "△ 未定", false) +
89      radio("answer", "no", "× 不参加", false);
90    card.appendChild(ans);
91
92    // 車出し
93    const drive = document.createElement("div");
94    const dl = document.createElement("label");
95    const dc = document.createElement("input");
96    dc.type = "checkbox";
97    dc.id = "can-drive";
98    dl.appendChild(dc);
99    dl.appendChild(document.createTextNode(" 🚗 車出し可能"));
100    drive.appendChild(dl);
101    card.appendChild(drive);
102
103    const submit = document.createElement("button");
104    submit.className = "btn btn-primary";
105    submit.textContent = "回答を送信する";
106    submit.addEventListener("click", onSubmit);
107    card.appendChild(submit);
108
109    const inline = document.createElement("p");
110    inline.className = "notice hidden";
111    card.appendChild(inline);
112    container.appendChild(card);
113
114    // ★:checked セレクタは使わない(テスト環境の deno-dom が一致しない)。.checked で見て、
115    //   値は属性から読む(HTML文字列由来の要素は .value が属性を反映しない実装がある)。
116    function selectedAnswer() {
117      const checked = [...container.querySelectorAll('input[name="answer"]')].find((r) => r.checked);
118      return checked ? checked.getAttribute("value") : "yes";
119    }
120
121    async function onSubmit() {
122      const name = nameInput.value.trim();
123      if (!name) {
124        inline.textContent = "お名前を入力してください";
125        inline.classList.remove("hidden");
126        return;
127      }
128      submit.disabled = true;
129      deps.ui.showLoading();
130      try {
131        const req = {
132          event_id: ev.id,
133          display_name: name,
134          answer: selectedAnswer(),
135          can_drive: dc.checked,
136        };
137        if (id.success && id.id_token) req.id_token = id.id_token;
138        const editToken = deps.storage.getEditToken(ev.id);
139        if (editToken) req.edit_token = editToken;
140        const res = await deps.api.upsertAttendance(req);
141        deps.ui.hideLoading();
142        if (res.edit_token_raw) deps.storage.saveEditToken(ev.id, res.edit_token_raw);
143        // 自分の回答を「いまの回答」に反映する。本人は player_id で同定し、前の行は置き換える。
144        // 既存 player の表示名はサーバが書き換えないので、前の行があればその名前のまま出す。
145        if (res.player_id) {
146          const prev = atts.find((a) => a.player && a.player.id === res.player_id);
147          atts = atts.filter((a) => a !== prev);
148          atts.push({
149            id: res.attendance_id,
150            player: { id: res.player_id, display_name: prev ? prev.player.display_name : name },
151            answer: req.answer,
152            can_drive: req.can_drive,
153          });
154        }
155        renderDone();
156      } catch (e) {
157        deps.ui.hideLoading();
158        submit.disabled = false;
159        inline.textContent = e.message || "送信に失敗しました";
160        inline.classList.remove("hidden");
161      }
162    }
163  }
164
165  function renderDone() {
166    container.innerHTML = "";
167    const card = document.createElement("div");
168    card.className = "card";
169    card.innerHTML = `<div class="subhead">✅ 回答を送信しました!</div>`;
170    card.appendChild(roster());
171    const change = document.createElement("button");
172    change.className = "btn btn-sub";
173    change.textContent = "回答を変更する";
174    change.addEventListener("click", () => renderForm());
175    card.appendChild(change);
176    const close = document.createElement("button");
177    close.className = "btn btn-primary";
178    close.textContent = "✅ 閉じる";
179    close.addEventListener("click", () => {
180      const ok = deps.ui.closeOrHome(deps, props.navigate, props.groupToken);
181      if (!ok) deps.ui.showToast("回答は送信済みです。この画面は閉じてOKです");
182    });
183    card.appendChild(close);
184    container.appendChild(card);
185  }
186
187  renderForm();
188}
189
190function radio(name, value, label, checked) {
191  return `<label><input type="radio" name="${name}" value="${value}"${
192    checked ? " checked" : ""
193  }/> ${label}</label>`;
194}
195function escapeHtml(s) {
196  return String(s).replace(
197    /[&<>"]/g,
198    (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]),
199  );
200}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.