diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-20 10:44:15 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-20 10:44:15 +0900 |
| commit | e8b7120a4e3b2fb457113aa2fbaeb545e9ec9463 (patch) | |
| tree | ef7d88ab98a65c2ddea63c3a75d2ae140d8d68ee /src/components | |
| parent | cb184f421e60736ad955f74ec777b0a7e69461cf (diff) | |
hp-archive: 予定一覧にカレンダーと絞り込みを追加
- 今後の予定ページにカレンダーとカテゴリ絞り込みを追加
- 審議会等・イベントの各表にも絞り込みを反映し、表ごとに
絞り込み状況と件数を表示
- 市報10月19日号・20日号、市報電子版、市公式Xの新着を追加
- 教育委員会定例会(10月22日)を審議会等の一覧に追加
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/KodairaNews.astro | 33 | ||||
| -rw-r--r-- | src/components/ScheduleInteractive.astro | 493 |
2 files changed, 526 insertions, 0 deletions
diff --git a/src/components/KodairaNews.astro b/src/components/KodairaNews.astro index 2826104..4ba2308 100644 --- a/src/components/KodairaNews.astro +++ b/src/components/KodairaNews.astro @@ -35,6 +35,39 @@ const LABEL: Record<CatId, string> = { const posts: Post[] = [ { + date: "10月20日", + cat: "other", + note: "市報こだいら10月20日号が発行されました(1面は小川パレットのオープン)。電子版も公開されています", + links: [ + { text: "市報こだいら10月20日号(市のX)", url: X("2112332973588500591") }, + { text: "市報こだいら電子版", url: "https://www.catapoke.com/search/?keyword=%E5%B0%8F%E5%B9%B3%E5%B8%82" }, + ], + }, + { + date: "10月19日", + cat: "other", + note: "ふるさと納税ポータルサイト「ふるさとチョイス」が使用できないため、当面は窓口または現金書留で受け付け", + links: [{ text: "クラウドファンディングの寄附方法の変更", url: X("2112139194940199015") }], + }, + { + date: "10月19日", + cat: "status", + note: "暫定サイトに「市からのお知らせ」「イベント情報」「電子申請一覧」「申請書ダウンロード」などのカテゴリ", + links: [{ text: "ホームページの障害状況について", url: X("2112091386333868246") }], + }, + { + date: "10月19日", + cat: "other", + note: "市役所西通り、10月19日・20日の午後10時から午前6時まで夜間通行止め(雨天順延)", + links: [{ text: "道路工事情報", url: X("2112015882972971208") }], + }, + { + date: "10月18日", + cat: "event", + note: "あかしあ通りと周辺道路の交通規制は午後4時30分に解除", + links: [{ text: "市民まつりは終了しました", url: X("2111717666654507344") }], + }, + { date: "10月18日", cat: "event", note: "午前9時30分から午後4時30分まで", diff --git a/src/components/ScheduleInteractive.astro b/src/components/ScheduleInteractive.astro new file mode 100644 index 0000000..5fb058b --- /dev/null +++ b/src/components/ScheduleInteractive.astro @@ -0,0 +1,493 @@ +<script> + const CATEGORY_DEFS = [ + { icon: '👶', label: '子育て' }, + { icon: '🧓', label: '高齢者・介護' }, + { icon: '🏥', label: '健康' }, + { icon: '♿', label: '福祉・相談' }, + { icon: '🎨', label: '文化・公民館' }, + { icon: '🏃', label: 'スポーツ' }, + { icon: '🌳', label: '環境・自然' }, + { icon: '📚', label: '学習・講演' }, + { icon: '🤝', label: '交流・地域' }, + ]; + const MEETING_ICON = '🏛️'; + const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土']; + const WEEKDAY_MAP = { 日: 0, 月: 1, 火: 2, 水: 3, 木: 4, 金: 5, 土: 6 }; + + const normalizeDigits = (s) => + s.replace(/[0-9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0)); + const pad2 = (n) => String(n).padStart(2, '0'); + const toKey = (y, m, d) => `${y}-${pad2(m)}-${pad2(d)}`; + const keyToDate = (key) => { + const [y, m, d] = key.split('-').map(Number); + return new Date(y, m - 1, d); + }; + const escapeHtml = (s) => + s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + + function applyExclusions(dateSet, raw) { + const text = normalizeDigits(raw); + const re = /(\d{1,2})月(\d{1,2})日を除く/g; + let m; + while ((m = re.exec(text)) !== null) { + const month = Number(m[1]); + const day = Number(m[2]); + const year = text.slice(0, m.index).includes('令和9年') ? 2027 : month === 1 ? 2027 : 2026; + dateSet.delete(toKey(year, month, day)); + } + } + + // 日付セル・時間セルから YYYY-MM-DD を抽出する。 + // 期間は、7日以内なら全日、曜日指定があればその曜日、それ以外は開始日と終了日を拾う。 + function parseDates(raw) { + const text = normalizeDigits(raw); + const dates = new Set(); + const add = (y, m, d) => { + if (y && m >= 1 && m <= 12 && d >= 1 && d <= 31) dates.add(toKey(y, m, d)); + }; + const yearFor = (index, month) => { + if (text.slice(0, index).includes('令和9年')) return 2027; + return month === 1 ? 2027 : 2026; + }; + + let m; + const rangeRe = /(\d{1,2})月(\d{1,2})日[^〜]*〜\s*(?:(\d{1,2})月)?(\d{1,2})日/g; + while ((m = rangeRe.exec(text)) !== null) { + const startMonth = Number(m[1]); + const startDay = Number(m[2]); + const endMonth = m[3] ? Number(m[3]) : startMonth; + const endDay = Number(m[4]); + const startYear = yearFor(m.index, startMonth); + const endYear = startYear + (endMonth < startMonth ? 1 : 0); + const start = new Date(startYear, startMonth - 1, startDay); + const end = new Date(endYear, endMonth - 1, endDay); + if (end < start) continue; + + const lengthDays = Math.round((end - start) / 86400000) + 1; + const after = text.slice(m.index + m[0].length); + let weekdays = null; + + const noWeek = after.match(/の([月火水木金土日])曜日/); + if (noWeek) { + weekdays = [WEEKDAY_MAP[noWeek[1]]]; + } else { + const group = after.match(/(([月火水木金土日](?:・[月火水木金土日])*))/); + const hasStartWeekday = new RegExp(`${startMonth}月${startDay}日([月火水木金土日]`).test(text); + if (group && (group[1].includes('・') || !hasStartWeekday)) { + weekdays = group[1].split('・').map((w) => WEEKDAY_MAP[w]); + } else { + const startWeek = text.match(new RegExp(`${startMonth}月${startDay}日(([月火水木金土日])`)); + const endWeek = text.match(new RegExp(`(?:${endMonth}月)?${endDay}日(([月火水木金土日])`)); + if (startWeek && endWeek && startWeek[1] === endWeek[1]) { + weekdays = [WEEKDAY_MAP[startWeek[1]]]; + } + } + } + + if (lengthDays <= 7 || (!weekdays && lengthDays <= 14)) { + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + add(d.getFullYear(), d.getMonth() + 1, d.getDate()); + } + } else if (weekdays) { + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + if (weekdays.includes(d.getDay())) add(d.getFullYear(), d.getMonth() + 1, d.getDate()); + } + } else { + add(start.getFullYear(), start.getMonth() + 1, start.getDate()); + add(end.getFullYear(), end.getMonth() + 1, end.getDate()); + } + } + + const fullDates = []; + const fullRe = /(\d{1,2})月(\d{1,2})日/g; + while ((m = fullRe.exec(text)) !== null) { + const month = Number(m[1]); + const day = Number(m[2]); + fullDates.push({ index: m.index, month }); + add(yearFor(m.index, month), month, day); + } + + const bareRe = /(\d{1,2})日/g; + while ((m = bareRe.exec(text)) !== null) { + const index = m.index; + if (index > 0 && text[index - 1] === '月') continue; + let previous = null; + for (const fd of fullDates) { + if (fd.index < index) previous = fd; + else break; + } + if (!previous) continue; + const day = Number(m[1]); + add(yearFor(index, previous.month), previous.month, day); + } + + // 「(11月4日を除く)」などの除外日を反映する。 + applyExclusions(dates, text); + + return [...dates].sort(); + } + + function initSchedule() { + const calendarEl = document.getElementById('schedule-calendar'); + const detailsEl = document.getElementById('schedule-day-details'); + const filtersEl = document.getElementById('event-filters'); + const meetingsTable = document.querySelector('#meetings-table table'); + const eventsTable = document.querySelector('#events-table table'); + if (!calendarEl || !detailsEl || !filtersEl || !meetingsTable || !eventsTable) return; + if (calendarEl.dataset.initialized === 'true') return; + calendarEl.dataset.initialized = 'true'; + + const entries = []; + + function collect(table, type) { + table.querySelectorAll('tbody tr').forEach((row, i) => { + const cells = row.querySelectorAll('td'); + if (cells.length < 3) return; + const dateText = cells[0].textContent.trim(); + const name = cells[1].textContent.trim(); + const timeText = cells[2].textContent.trim(); + const rowId = `${type}-row-${i}`; + row.id = rowId; + + let category = MEETING_ICON; + let categoryLabel = '審議会等'; + const cat = CATEGORY_DEFS.find((c) => name.startsWith(c.icon)); + if (cat) { + category = cat.icon; + categoryLabel = cat.label; + } else if (type === 'event') { + category = '🤝'; + categoryLabel = '交流・地域'; + } + row.dataset.category = category; + + const dates = new Set(parseDates(dateText)); + // 審議会の「毎月1回(11月12日、…も開催)」のように、 + // 時間・会場欄に追加の開催日が書かれている場合がある。 + if (type === 'meeting') { + for (const key of parseDates(timeText)) dates.add(key); + } + // イベントの「(11月4日を除く)」を反映する。 + if (type === 'event') { + // 時間欄の「(11月4日を除く)」を日付セル側の集合に反映する。 + applyExclusions(dates, timeText); + } + + entries.push({ + type, + name, + time: timeText, + dates: [...dates], + category, + categoryLabel, + rowId, + dateText, + }); + }); + } + + collect(meetingsTable, 'meeting'); + collect(eventsTable, 'event'); + + const byDate = new Map(); + for (const entry of entries) { + for (const key of entry.dates) { + if (!byDate.has(key)) byDate.set(key, []); + byDate.get(key).push(entry); + } + } + + const dateKeys = [...byDate.keys()].sort(); + if (dateKeys.length === 0) { + calendarEl.innerHTML = '<p>表示できる予定がありません。</p>'; + return; + } + + const months = [...new Set(dateKeys.map((k) => k.slice(0, 7)))].sort(); + const today = new Date(); + const todayKey = toKey(today.getFullYear(), today.getMonth() + 1, today.getDate()); + let selectedDate = byDate.has(todayKey) ? todayKey : dateKeys[0]; + let currentMonth = selectedDate.slice(0, 7); + let activeCategory = 'all'; + + function matchesCategory(e) { + if (activeCategory === 'all') return true; + if (activeCategory === MEETING_ICON) return e.type === 'meeting'; + return e.category === activeCategory; + } + + function visibleEntries(key) { + const list = byDate.get(key) || []; + return list.filter(matchesCategory); + } + + function ensureVisibleSelection() { + if (selectedDate && visibleEntries(selectedDate).length > 0) return; + // 絞り込みで選択中の日付が空になったら、当月の最初の該当日へ移動する。 + // 当月に該当日がなければ、最初の該当日へ移動する。 + const inMonth = dateKeys.filter( + (k) => k.startsWith(currentMonth) && visibleEntries(k).length > 0 + ); + const any = dateKeys.filter((k) => visibleEntries(k).length > 0); + const fallback = inMonth[0] || any[0]; + if (!fallback) return; + selectedDate = fallback; + currentMonth = fallback.slice(0, 7); + } + + function renderCalendar() { + const index = months.indexOf(currentMonth); + const [year, month] = currentMonth.split('-').map(Number); + const first = new Date(year, month - 1, 1); + const startDay = first.getDay(); + const daysInMonth = new Date(year, month, 0).getDate(); + + const cells = []; + for (let i = 0; i < startDay; i++) { + cells.push('<div class="schedule-cal-day is-blank"></div>'); + } + for (let day = 1; day <= daysInMonth; day++) { + const key = toKey(year, month, day); + const list = visibleEntries(key); + const classes = ['schedule-cal-day']; + if (key === todayKey) classes.push('is-today'); + if (key === selectedDate) classes.push('is-selected'); + + if (list.length === 0) { + cells.push( + `<div class="${classes.join(' ')}"><span class="schedule-cal-daynum">${day}</span></div>` + ); + continue; + } + + const categories = [...new Set(list.map((e) => e.category))]; + const marks = categories + .slice(0, 3) + .map((icon) => `<span class="schedule-cal-mark">${icon}</span>`) + .join(''); + const more = + categories.length > 3 + ? `<span class="schedule-cal-more">+${categories.length - 3}</span>` + : ''; + const count = + list.length > 1 ? `<span class="schedule-cal-count">${list.length}</span>` : ''; + cells.push( + `<button type="button" class="${classes.join(' ')} has-events" data-date="${key}" aria-label="${month}月${day}日、${list.length}件の予定"><span class="schedule-cal-daynum">${day}${count}</span><span class="schedule-cal-marks">${marks}${more}</span></button>` + ); + } + + calendarEl.innerHTML = ` + <div class="schedule-cal-header"> + <button type="button" class="schedule-cal-nav" data-dir="-1" aria-label="前の月" ${index <= 0 ? 'disabled' : ''}>‹</button> + <div class="schedule-cal-title">${year}年${month}月</div> + <button type="button" class="schedule-cal-nav" data-dir="1" aria-label="次の月" ${index >= months.length - 1 ? 'disabled' : ''}>›</button> + </div> + <div class="schedule-cal-grid"> + ${WEEKDAYS.map( + (w, i) => + `<div class="schedule-cal-weekday${i === 0 ? ' is-sun' : i === 6 ? ' is-sat' : ''}">${w}</div>` + ).join('')} + ${cells.join('')} + </div> + `; + + calendarEl.querySelectorAll('.schedule-cal-day[data-date]').forEach((btn) => { + btn.addEventListener('click', () => { + selectedDate = btn.dataset.date; + renderCalendar(); + showDayDetails(selectedDate); + }); + }); + + calendarEl.querySelectorAll('.schedule-cal-nav').forEach((btn) => { + btn.addEventListener('click', () => { + const next = months.indexOf(currentMonth) + Number(btn.dataset.dir); + if (next < 0 || next >= months.length) return; + currentMonth = months[next]; + selectedDate = null; + renderCalendar(); + detailsEl.innerHTML = + '<p class="schedule-day-placeholder">日付を選ぶと、その日の予定が表示されます。</p>'; + }); + }); + } + + function showDayDetails(key) { + if (!key) return; + const date = keyToDate(key); + const weekday = WEEKDAYS[date.getDay()]; + const list = visibleEntries(key) + .slice() + .sort((a, b) => { + if (a.type !== b.type) return a.type === 'meeting' ? -1 : 1; + return a.name.localeCompare(b.name, 'ja'); + }); + const title = `${date.getMonth() + 1}月${date.getDate()}日(${weekday})の予定`; + + if (list.length === 0) { + detailsEl.innerHTML = `<h3>${title}</h3><p>この日の予定はありません。</p>`; + return; + } + + const items = list + .map((e) => { + const displayName = e.name.startsWith(e.category) + ? e.name.slice(e.category.length).trim() + : e.name; + return `<li><a class="schedule-day-link" href="#${e.rowId}"><span class="schedule-day-icon">${e.category}</span>${escapeHtml(displayName)}</a><span class="schedule-day-meta">${escapeHtml(e.time)}</span></li>`; + }) + .join(''); + + detailsEl.innerHTML = `<h3>${title}</h3><ul>${items}</ul>`; + detailsEl.querySelectorAll('a.schedule-day-link').forEach((a) => { + a.addEventListener('click', () => { + const row = document.getElementById(a.getAttribute('href').slice(1)); + if (!row) return; + row.classList.add('schedule-row-highlight'); + window.setTimeout(() => row.classList.remove('schedule-row-highlight'), 3000); + }); + }); + } + + const meetingEntries = entries.filter((e) => e.type === 'meeting'); + const eventEntries = entries.filter((e) => e.type === 'event'); + const counts = {}; + for (const e of entries) { + counts[e.category] = (counts[e.category] || 0) + 1; + } + + function categoryLabel(category) { + if (category === MEETING_ICON) return '審議会等'; + const def = CATEGORY_DEFS.find((c) => c.icon === category); + return def ? def.label : '交流・地域'; + } + + function ensureStatus(container, type) { + const id = `${type}-filter-status`; + let status = document.getElementById(id); + if (!status) { + status = document.createElement('div'); + status.id = id; + status.className = 'schedule-table-status'; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + container.parentElement.insertBefore(status, container); + } + return status; + } + + const meetingStatus = ensureStatus(meetingsTable.parentElement, 'meeting'); + const eventStatus = ensureStatus(eventsTable.parentElement, 'event'); + + function updateTableStatus(status, type, total, visible) { + const tableLabel = type === 'meeting' ? '審議会等' : 'イベント'; + const filtered = activeCategory !== 'all'; + status.classList.toggle('is-filtered', filtered); + const text = filtered + ? `${tableLabel}:「${activeCategory} ${categoryLabel(activeCategory)}」で絞り込み中:${visible}件/全${total}件` + : `${tableLabel}:全${total}件を表示しています(絞り込みなし)`; + status.innerHTML = `<span class="schedule-table-status-text">${text}</span>`; + if (filtered) { + const clear = document.createElement('button'); + clear.type = 'button'; + clear.className = 'schedule-table-status-clear'; + clear.textContent = '絞り込みを解除'; + clear.addEventListener('click', () => setActiveCategory('all')); + status.appendChild(clear); + } + } + + function updateEmptyMessage(container, id, count, text) { + let empty = document.getElementById(id); + if (!empty) { + empty = document.createElement('p'); + empty.id = id; + empty.className = 'schedule-filter-empty'; + container.appendChild(empty); + } + empty.hidden = count > 0; + empty.textContent = text; + } + + function applyFilter() { + const visibleMeetings = meetingEntries.filter(matchesCategory).length; + const visibleEvents = eventEntries.filter(matchesCategory).length; + for (const e of entries) { + const row = document.getElementById(e.rowId); + if (row) { + const show = matchesCategory(e); + row.hidden = !show; + row.classList.toggle('is-filtered-out', !show); + } + } + updateTableStatus(meetingStatus, 'meeting', meetingEntries.length, visibleMeetings); + updateTableStatus(eventStatus, 'event', eventEntries.length, visibleEvents); + updateEmptyMessage( + meetingsTable.parentElement, + 'meeting-filter-empty', + visibleMeetings, + '該当する審議会等はありません。' + ); + updateEmptyMessage( + eventsTable.parentElement, + 'event-filter-empty', + visibleEvents, + '該当するイベントはありません。' + ); + } + + function setActiveCategory(category) { + activeCategory = category; + filtersEl.querySelectorAll('.schedule-filter-btn').forEach((b) => { + b.setAttribute('aria-pressed', String(b.dataset.category === activeCategory)); + }); + applyFilter(); + ensureVisibleSelection(); + renderCalendar(); + if (selectedDate) showDayDetails(selectedDate); + } + + function buildFilters() { + const buttonDefs = [ + { category: 'all', icon: '', label: 'すべて', count: entries.length }, + { + category: MEETING_ICON, + icon: MEETING_ICON, + label: '審議会等', + count: meetingEntries.length, + }, + ...CATEGORY_DEFS.map((c) => ({ + category: c.icon, + icon: c.icon, + label: c.label, + count: counts[c.icon] || 0, + })), + ]; + filtersEl.innerHTML = buttonDefs + .map( + (b) => + `<button type="button" class="schedule-filter-btn" data-category="${b.category}" aria-pressed="${b.category === activeCategory}">${b.icon ? `${b.icon} ` : ''}${b.label} (${b.count})</button>` + ) + .join(''); + filtersEl.querySelectorAll('.schedule-filter-btn').forEach((btn) => { + btn.addEventListener('click', () => setActiveCategory(btn.dataset.category)); + }); + } + + buildFilters(); + applyFilter(); + renderCalendar(); + if (selectedDate) showDayDetails(selectedDate); + } + + function boot() { + initSchedule(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot, { once: true }); + } else { + boot(); + } + document.addEventListener('astro:page-load', boot); +</script> |
