aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/components/ScheduleInteractive.astro
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-20 10:44:15 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-20 10:44:15 +0900
commite8b7120a4e3b2fb457113aa2fbaeb545e9ec9463 (patch)
treeef7d88ab98a65c2ddea63c3a75d2ae140d8d68ee /src/components/ScheduleInteractive.astro
parentcb184f421e60736ad955f74ec777b0a7e69461cf (diff)
hp-archive: 予定一覧にカレンダーと絞り込みを追加
- 今後の予定ページにカレンダーとカテゴリ絞り込みを追加 - 審議会等・イベントの各表にも絞り込みを反映し、表ごとに 絞り込み状況と件数を表示 - 市報10月19日号・20日号、市報電子版、市公式Xの新着を追加 - 教育委員会定例会(10月22日)を審議会等の一覧に追加
Diffstat (limited to 'src/components/ScheduleInteractive.astro')
-rw-r--r--src/components/ScheduleInteractive.astro493
1 files changed, 493 insertions, 0 deletions
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>