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
|
// 行事予定の日付解析(ブラウザとビルド時の両方で使う)。
//
// 日付セルには「11月1日(日)」「11月1日・18日、12月6日」「10月24日(土)〜11月22日(日)」
// 「11月2日〜12月14日の月曜日」など、さまざまな書き方がある。
// ここでは、それらから YYYY-MM-DD を組み立てる。
export const WEEKDAY_MAP: Record<string, number> = {
日: 0,
月: 1,
火: 2,
水: 3,
木: 4,
金: 5,
土: 6,
};
export const normalizeDigits = (s: string) =>
s.replace(/[0-9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0));
export const pad2 = (n: number) => String(n).padStart(2, '0');
export const toKey = (y: number, m: number, d: number) => `${y}-${pad2(m)}-${pad2(d)}`;
export const keyToDate = (key: string) => {
const [y, m, d] = key.split('-').map(Number);
return new Date(y, m - 1, d);
};
/** 「(11月4日を除く)」などの除外日を、日付の集合から取り除く */
export function applyExclusions(dateSet: Set<string>, raw: string) {
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日以内なら全日、曜日指定があればその曜日、それ以外は開始日と終了日を拾う。
export function parseDates(raw: string): string[] {
const text = normalizeDigits(raw);
const dates = new Set<string>();
const add = (y: number, m: number, d: number) => {
if (y && m >= 1 && m <= 12 && d >= 1 && d <= 31) dates.add(toKey(y, m, d));
};
const yearFor = (index: number, month: number) => {
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.getTime() - start.getTime()) / 86400000) + 1;
const after = text.slice(m.index + m[0].length);
let weekdays: number[] | null = 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: { index: number; month: number }[] = [];
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: { index: number; month: number } | null = 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();
}
/**
* 行事予定1行分の開催日を求める。
* - 会議:日付セルに加えて、時間・会場セル内の日付も拾う(「毎月1回(11月12日も開催)」など)
* - イベント:時間セルの「(11月4日を除く)」を反映する
* カレンダー(ブラウザ)と月別アーカイブ(ビルド時)で同じ結果になるよう、ここに集約する。
*/
export function entryDates(
type: 'meeting' | 'event',
dateText: string,
placeText: string,
): string[] {
const dates = new Set(parseDates(dateText));
if (type === 'meeting') {
for (const key of parseDates(placeText)) dates.add(key);
} else {
applyExclusions(dates, placeText);
}
return [...dates].sort();
}
const monthKeyOf = (date: Date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}`;
/**
* メインページに載せる範囲の開始月(前月)。例: 2026-11-05 → 2026-10
* 前月分も残すことで、終わったばかりの予定をメインページで確認できる。
*/
export function mainStartMonth(now = new Date()): string {
return monthKeyOf(new Date(now.getFullYear(), now.getMonth() - 1, 1));
}
/**
* アーカイブ(月別・年度別ページ)に移す範囲の終わり月(前々月)。
* 例: 2026-11-05 → 2026-09。前月以前はメインページに残す。
*/
export function archiveEndMonth(now = new Date()): string {
return monthKeyOf(new Date(now.getFullYear(), now.getMonth() - 2, 1));
}
|