PageSourceSearch

https://jyanken-poker.onrender.com/assets/controllers/rainy_day_po…4a5cd5598f88273751e1751e5d175de32662b35c.js

js jyanken-poker.onrender.com collected 2026-10-03 11:42:08 UTC 15,160 bytes, 335 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import { ceilToTen, periodIndexOf } from "utils/rainy_periods"
3
4// 雨の日ポイントの確認画面(機能フラグ :rainy_day_points の店)。
5//
6// 「今日の雨の時間帯」を入れると、その間に来た人に自動でチェックを入れる(9/29 tty)。
7// 時間帯はこの端末のブラウザ(localStorage)に、店+営業日のキーで保存する。サーバーには送らない
8// (マイグレーションを避けた。現場には「同じ端末で操作して」と伝える前提)。
9// 二重付与を止めるのはサーバー側なので、端末ごとに時間帯が違っても二重には付かない。
10//
11// 時刻はすべて「営業日の 0:00 からの分」で持つ(日付を跨いだ 1:00 は 25:00 = 1500 分)。
12// 終わりが未定(まだ降っている)の時間帯は、始め以降に受付した人を全員入れる。
13// 始め・終わりちょうどに来た人も雨の間に入れる(お客さんに有利な側。9/29 tty)。
14// 来店時刻は10分単位で後ろへずらした値なので、「いま止んだ」「いま」の終わりは次の10分に切り上げる(utils/rainy_periods.js)。
15//
16// 手で足したり外したりしたチェックは、時間帯を変えるまでそのまま。時間帯を変えたら自動のチェックをやり直す。
17// 付与で止まって出し直した画面(keepChecks)は、送ったときのチェックを残す(外した人に押し直しで付けないように)
18export default class extends Controller {
19  static targets = ["panel", "summary", "row", "grantedRow", "checkbox", "tag", "submit", "cancelArea"]
20  static values = {
21    storageKey: String,
22    storagePrefix: String,
23    dayStart: Number,
24    openMinutes: Number,
25    keepChecks: Boolean,
26  }
27
28  connect() {
29    this.periods = this.load()
30    this.editing = null
31    this.cleanupOldDays()
32    this.renderPanel()
33    if (this.keepChecksValue) {
34      this.refresh()
35    } else {
36      this.applyPeriods()
37    }
38  }
39
40  // ---- 保存(失敗しても画面は動く。その場かぎりの時間帯になるだけ) ----
41
42  load() {
43    try {
44      const periods = JSON.parse(window.localStorage.getItem(this.storageKeyValue) || "[]")
45      return Array.isArray(periods) ? periods.filter((p) => Number.isInteger(p.start)) : []
46    } catch (_e) {
47      return []
48    }
49  }
50
51  save() {
52    this.periods.sort((a, b) => a.start - b.start)
53    try {
54      window.localStorage.setItem(this.storageKeyValue, JSON.stringify(this.periods))
55    } catch (_e) {
56      // シークレットモードなどで保存できなくても続ける
57    }
58  }
59
60  // 前の営業日の時間帯は要らないので消す(同じ店のキーだけ)
61  cleanupOldDays() {
62    try {
63      const storage = window.localStorage
64      for (let i = storage.length - 1; i >= 0; i--) {
65        const key = storage.key(i)
66        if (key && key.startsWith(this.storagePrefixValue) && key !== this.storageKeyValue) storage.removeItem(key)
67      }
68    } catch (_e) {
69      // 同上
70    }
71  }
72
73  // ---- 時刻 ----
74
75  nowMinutes() {
76    return Math.floor((Date.now() - this.dayStartValue) / 60000)
77  }
78
79  format(minutes) {
80    const h = Math.floor(minutes / 60) % 24
81    const m = minutes % 60
82    return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`
83  }
84
85  // 「開店」から始まる時間帯は時刻を出さない。開店の時刻=営業日を開いた時刻(14:44 など)で、
86  // 実際の開店(15:00)と食い違って見えるため(9/29 tty)。雨の範囲はその時刻からで変わらない
87  startLabel(minutes) {
88    return minutes === this.openMinutesValue ? "開店" : this.format(minutes)
89  }
90
91  // 何番目の雨か(①②③…)
92  mark(index) {
93    return String.fromCharCode(0x2460 + index)
94  }
95
96  periodIndexOf(minutes) {
97    return periodIndexOf(this.periods, minutes)
98  }
99
100  // 終わりの「いま」=次の10分に切り上げたいま(13:05 → 13:10)
101  nowEndMinutes() {
102    return ceilToTen(this.nowMinutes())
103  }
104
105  get openPeriod() {
106    return this.periods.find((p) => p.end === null)
107  }
108
109  // ---- 時間帯の操作 ----
110
111  startFromOpen() {
112    this.addPeriod({ start: this.openMinutesValue, end: null })
113  }
114
115  startNow() {
116    this.addPeriod({ start: this.nowMinutes(), end: null })
117  }
118
119  addPeriod(period) {
120    this.periods.push(period)
121    this.save()
122    this.editing = null
123    this.renderPanel()
124    this.applyPeriods()
125  }
126
127  stopNow(event) {
128    const period = this.periods[Number(event.currentTarget.dataset.index)]
129    period.end = Math.max(this.nowEndMinutes(), period.start)
130    this.save()
131    this.renderPanel()
132    this.applyPeriods()
133  }
134
135  removePeriod(event) {
136    this.periods.splice(Number(event.currentTarget.dataset.index), 1)
137    this.save()
138    this.editing = null
139    this.renderPanel()
140    this.applyPeriods()
141  }
142
143  // 新しく足す(index = null)か、既存を直す
144  openEditor(event) {
145    const index = event.currentTarget.dataset.index
146    if (index === undefined) {
147      this.editing = { index: null, start: this.nowMinutes(), end: null, error: null }
148    } else {
149      const period = this.periods[Number(index)]
150      this.editing = { index: Number(index), start: period.start, end: period.end, error: null }
151    }
152    this.renderPanel()
153  }
154
155  closeEditor() {
156    this.editing = null
157    this.renderPanel()
158  }
159
160  // 「開店」「いま」「まだ降っている」の1タップ
161  quick(event) {
162    const { field, preset } = event.currentTarget.dataset
163    const now = field === "end" ? this.nowEndMinutes() : this.nowMinutes()
164    const value = { open: this.openMinutesValue, now, none: null }[preset]
165    this.editing[field] = value
166    this.editing.error = null
167    this.renderPanel()
168  }
169
170  // 時・分のセレクト。終わりの時だけ選んで分が空なら 00 分にする
171  pick() {
172    const read = (field) => {
173      const hour = this.panelTarget.querySelector(`[data-select="${field}-hour"]`).value
174      const minute = this.panelTarget.querySelector(`[data-select="${field}-minute"]`).value
175      if (hour === "") return null
176      return Number(hour) * 60 + Number(minute || 0)
177    }
178    this.editing.start = read("start")
179    this.editing.end = read("end")
180    this.editing.error = null
181    this.renderPanel()
182  }
183
184  commitEditor() {
185    const { index, start, end } = this.editing
186    if (start === null) {
187      this.editing.error = "始めの時刻を選んでください"
188    } else if (end !== null && end < start) {
189      this.editing.error = "終わりは始めより後の時刻にしてください"
190    } else if (end === null && this.periods.some((p, i) => p.end === null && i !== index)) {
191      this.editing.error = "「まだ降っている」時間帯がもう1つあります。先にそちらの「いま止んだ」を押してください"
192    }
193    if (this.editing.error) {
194      this.renderPanel()
195      return
196    }
197
198    if (index === null) {
199      this.periods.push({ start, end })
200    } else {
201      this.periods[index] = { start, end }
202    }
203    this.save()
204    this.editing = null
205    this.renderPanel()
206    this.applyPeriods()
207  }
208
209  // ---- チェック ----
210
211  // 時間帯から自動でチェックを入れ直す(手で変えたチェックも上書きする)
212  applyPeriods() {
213    this.rowTargets.forEach((row) => {
214      const checkbox = row.querySelector("input[type=checkbox]")
215      checkbox.checked = this.periodIndexOf(Number(row.dataset.entryMinutes)) >= 0
216    })
217    this.refresh()
218  }
219
220  // タグ・行の色・件数・ボタンの表示を、いまのチェックに合わせる
221  refresh() {
222    let checked = 0
223    let inRain = 0
224    this.rowTargets.forEach((row) => {
225      const checkbox = row.querySelector("input[type=checkbox]")
226      const index = this.periodIndexOf(Number(row.dataset.entryMinutes))
227      const tag = row.querySelector("[data-rainy-day-points-target=tag]")
228      tag.innerHTML = index >= 0 ? `<span class="rainy-points-tag">雨${this.mark(index)}に来店</span>` : ""
229      row.classList.toggle("is-off", !checkbox.checked)
230      if (checkbox.checked) checked++
231      if (index >= 0) inRain++
232    })
233    const grantedInRain = this.grantedRowTargets.filter((row) => this.periodIndexOf(Number(row.dataset.entryMinutes)) >= 0).length
234
235    if (this.hasSummaryTarget) {
236      this.summaryTarget.innerHTML = this.periods.length === 0
237        ? "雨の時間帯を入れると、その間に来た人に自動でチェックが入ります(手でチェックしても付けられます)。"
238        : `雨の間に来た人 <b>${inRain + grantedInRain}人</b>(うち付与済み ${grantedInRain}人)→ <b>${checked}人</b>にチェックが入っています。チェックは手で足したり外したりできます。`
239    }
240    if (this.hasSubmitTarget) {
241      this.submitTarget.textContent = `チェックした${checked}人に付ける`
242      this.submitTarget.disabled = checked === 0
243    }
244  }
245
246  // ---- 取り消し(行の中で確かめる) ----
247
248  askCancel(event) {
249    const cell = event.currentTarget.closest("td")
250    cell.querySelector("[data-rainy-day-points-target=cancelArea]").hidden = true
251    cell.querySelector(".rainy-points-cancel-confirm").hidden = false
252  }
253
254  closeCancel(event) {
255    const cell = event.currentTarget.closest("td")
256    cell.querySelector("[data-rainy-day-points-target=cancelArea]").hidden = false
257    cell.querySelector(".rainy-points-cancel-confirm").hidden = true
258  }
259
260  // ---- パネルの描画 ----
261
262  renderPanel() {
263    const head = `<div class="rainy-periods-head">☔ 今日の雨の時間帯<small>この端末に保存します</small></div>`
264    const now = this.format(this.nowMinutes())
265
266    if (this.periods.length === 0 && !this.editing) {
267      this.panelTarget.innerHTML = `${head}
268        <div>この端末には、今日の雨の時間帯がまだありません。いまの状況を選んでください。</div>
269        <div class="rainy-periods-choices">
270          <button type="button" class="stores-btn stores-btn-secondary stores-btn-size-medium" data-action="rainy-day-points#startFromOpen">☔ 開店から降っている</button>
271          <button type="button" class="stores-btn stores-btn-secondary stores-btn-size-medium" data-action="rainy-day-points#startNow">☔ いま降り始めた(${now}〜)</button>
272          <button type="button" class="stores-btn stores-btn-secondary stores-btn-size-medium" data-action="rainy-day-points#openEditor">時刻を選んで入れる</button>
273        </div>`
274      return
275    }
276
277    const rows = this.periods.map((p, i) => {
278      if (this.editing && this.editing.index === i) return ""
279      const live = p.end === null
280      return `<div class="rainy-periods-row">
281        <span class="rainy-periods-no">${this.mark(i)}</span>
282        <span class="rainy-periods-time">${this.startLabel(p.start)} 〜 ${live ? "" : this.format(p.end)}${live ? '<span class="rainy-periods-live">☔ いまも降っている</span>' : ""}</span>
283        <span class="rainy-periods-spacer"></span>
284        ${live ? `<button type="button" class="stores-btn stores-btn-secondary stores-btn-size-small" data-action="rainy-day-points#stopNow" data-index="${i}">いま止んだ(${now})</button>` : ""}
285        <button type="button" class="stores-btn-text stores-btn-text-edit" data-action="rainy-day-points#openEditor" data-index="${i}">直す</button>
286        <button type="button" class="stores-btn-text stores-btn-text-delete" data-action="rainy-day-points#removePeriod" data-index="${i}">消す</button>
287      </div>`
288    }).join("")
289
290    let footer = ""
291    if (this.editing) {
292      footer = this.editorHtml(now)
293    } else if (!this.openPeriod) {
294      footer = `<div class="rainy-periods-add"><button type="button" class="stores-btn stores-btn-secondary stores-btn-size-small" data-action="rainy-day-points#openEditor">+ また降り始めた(時間帯を足す)</button></div>`
295    }
296    this.panelTarget.innerHTML = `${head}${rows}${footer}`
297  }
298
299  editorHtml(now) {
300    const { start, end, error } = this.editing
301    const on = (active) => (active ? " is-on" : "")
302    const nowMinutes = this.nowMinutes()
303    return `<div class="rainy-periods-editor">
304      <div class="rainy-periods-editor-line"><b>始め</b>
305        <button type="button" class="rainy-periods-quick${on(start === this.openMinutesValue)}" data-action="rainy-day-points#quick" data-field="start" data-preset="open">開店</button>
306        <button type="button" class="rainy-periods-quick${on(start === nowMinutes)}" data-action="rainy-day-points#quick" data-field="start" data-preset="now">いま(${now})</button>
307        ${this.timeSelects("start", start)}
308      </div>
309      <div class="rainy-periods-editor-line"><b>終わり</b>
310        <button type="button" class="rainy-periods-quick${on(end === null)}" data-action="rainy-day-points#quick" data-field="end" data-preset="none">まだ降っている</button>
311        <button type="button" class="rainy-periods-quick${on(end !== null && end === this.nowEndMinutes())}" data-action="rainy-day-points#quick" data-field="end" data-preset="now">いま(${now})</button>
312        ${this.timeSelects("end", end)}
313      </div>
314      ${error ? `<div class="stores-flash-message stores-flash-warning rainy-periods-editor-note">${error}</div>` : ""}
315      <button type="button" class="stores-btn stores-btn-primary stores-btn-size-small" data-action="rainy-day-points#commitEditor">この時間帯にする</button>
316      <button type="button" class="stores-btn stores-btn-tertiary stores-btn-size-small" data-action="rainy-day-points#closeEditor">やめる</button>
317    </div>`
318  }
319
320  // 時は開店の時から 14 時間分(日付を跨いだら 24 を足した値で持つ)。分は 5 分刻み(いまの時刻なã
320©ã®åŠç«¯ãªåˆ†ã‚‚出す)
321  timeSelects(field, value) {
322    const openHour = Math.floor(this.openMinutesValue / 60)
323    const hours = Array.from({ length: 15 }, (_, i) => openHour + i)
324    const minutes = Array.from({ length: 12 }, (_, i) => i * 5)
325    if (value !== null && !minutes.includes(value % 60)) minutes.push(value % 60)
326    minutes.sort((a, b) => a - b)
327    const hourValue = value === null ? "" : Math.floor(value / 60)
328    const minuteValue = value === null ? "" : value % 60
329    const option = (v, label, selected) => `<option value="${v}"${selected ? " selected" : ""}>${label}</option>`
330    const hourOptions = [option("", "--", hourValue === ""), ...hours.map((h) => option(h, String(h % 24).padStart(2, "0"), h === hourValue))].join("")
331    const minuteOptions = [option("", "--", minuteValue === ""), ...minutes.map((m) => option(m, String(m).padStart(2, "0"), m === minuteValue))].join("")
332    return `<select class="form-select form-select-sm" data-select="${field}-hour" data-action="rainy-day-points#pick">${hourOptions}</select> :
333      <select class="form-select form-select-sm" data-select="${field}-minute" data-action="rainy-day-points#pick">${minuteOptions}</select>`
334  }
335};

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.