LoginSignup
140
62

祝日を計算しようと思ったら闇が深かった話

Last updated at Posted at 2023-06-07

はじめに

巷にカレンダーのAPIは溢れているが、万年カレンダーを作ってみるなどした。

実装方針

日付の基本的な計算はChronoライブラリを使う

これで今日が何日で何曜日かの計算は簡単にできる。

祝祭日は政府が定義しているルールに従って実装すれば良い。

と思っていた時期がありました。

祝祭日のルール

国民の祝日に関する法律(昭和23年法律第178号)に則り、内閣府が公布している資料では以下の通り

この資料の2条の定義と3条のルールに従えば大体の祝日をカバーできることになる。

ただし、このページだけをサラッと読んだだけでは理解できない罠がいくつかある。
ちゃんとリンク先まで読めばまず引っかからないが、一旦ここでも要約する。

春分の日と秋分の日

これらは、日本国内においては天文学的に定まっており年によって変化する。

表にあるように、4年に1回の周期で23と22日で繰り返すように成っているかのようにも思えるが実際はそうではない。
そのためパターン化できないのでこのあたりの資料を元に定数定義してやる必要がある。

第N週月曜日の祝日

春分の日や秋分の日はデータ定義だけで済むけど、この第N週というのが厄介。

  • 成人の日(1月の第2週の月曜日)
  • 海の日(7月の第3週の月曜日)
  • 敬老の日(9月の第3週の月曜日)
  • スポーツの日(10月の第2週の月曜日)

とりあえず、将来的に何が有るかはわからないので、N月の第M週のX曜日みたいな雰囲気で管理することにする。
大体は「土日祝日」で3連休作ろうぜで制定されてるような気もしなくもないからほとんど月曜日に寄ってるけども。

GWの3日の祝日

GWの祝日は以下の通り

  • 憲法記念日(5/3)
  • みどりの日(5/4)
  • こどもの日(5/5)
    これらのいずれかが日曜日と被っていた場合は、こどもの日の翌日は「振替休日」としなければならない。
    要約すると土日が重なる場合においては、最低4連休にならないとだめ と言う決まりがある。

祝祭日CSV

1955年からの祝日一覧が以下のcsvファイルに記載されている。
これを使えば、上記で言及した法律とか色々知らなくても祝日判定ができる。
ただし、1年後の祝日までしか載っていない。

改めて

少なくとも10年くらいは表示できたほうが嬉しい気もする(うれしくない?)
ので、暫定で発表されている春分の日・秋分の日を定数定義するとともに、祝祭日の定義を用いて振替休日(第3条第2項)や祝日と祝日に挟まれた日は休日となる(第3条第3項)を計算する方針とする。

やってみる

とりあえずややこしいのはわかった。
やってみよう。

まずは祝日の定義からやってみる。

holidays.json
{
    "holiday": {
        "1/1": {"name": "元日", "substitute": 1},
        "1/2": {"name": "休日", "substitute": 1},
        "2/11": {"name": "建国記念の日", "substitute": 1},
        "2/23": {"name": "天皇誕生日", "substitute": 1},
        "4/29": {"name": "昭和の日", "substitute": 1},
        "5/3": {"name": "憲法記念日", "substitute": 1},
        "5/4": {"name": "みどりの日", "substitute": 2},
        "5/5": {"name": "こどもの日", "substitute": 3},
        "8/11": {"name": "山の日", "substitute": 1},
        "11/3": {"name": "文化の日", "substitute": 1},
        "11/23": {"name": "勤労感謝の日", "substitute": 1}
    },
    // 月/週/曜日
    "holiday_at_week": {
        "1/2/1": {"name": "成人の日", "substitute": 1},
        "7/3/1": {"name": "海の日", "substitute": 1},
        "9/3/1": {"name": "敬老の日", "substitute": 1},
        "10/2/1": {"name": "スポーツの日", "substitute": 1}
    },
    // 春分の日と秋分の日の定義。今から大体30年間ぐらいの予想日
    "shunbun_syubun": {
        "2020/3/20": {"name": "春分の日", "substitute": 1},
        "2021/3/20": {"name": "春分の日", "substitute": 1},
        "2022/3/21": {"name": "春分の日", "substitute": 1},
        "2023/3/21": {"name": "春分の日", "substitute": 1},
        "2024/3/20": {"name": "春分の日", "substitute": 1},
        "2025/3/20": {"name": "春分の日", "substitute": 1},
        // 長いので割愛
        "2047/9/23": {"name": "秋分の日", "substitute": 1},
        "2048/9/22": {"name": "秋分の日", "substitute": 1},
        "2049/9/22": {"name": "秋分の日", "substitute": 1},
        "2050/9/23": {"name": "秋分の日", "substitute": 1}
    },
    // 曜日の文言定義
    "weekday": ["日","月","火","水","木","金","土"]
}

その日が祝日であるかどうかを判定するロジック

JpHolidayInfoという祝日に関する情報を入れる構造体は後述します。
簡単に言うと、祝日の名前とGWの計算で使う制御情報が入っている構造体です。

Some(JpHolidayInfo)であれば祝日で、詳細が構造体に入ってくる。
Noneであれば平日とする。

    // 今日の日付が第N週かを計算する
    fn get_week_number(&self, loc_date: &NaiveDate) -> u32 {
        (loc_date.day0()) / 7 + 1
    }
    
    fn get_holiday_with_dict(&self, loc_date: &NaiveDate) -> Option<JpHolidayInfo> {
        // n月第m週が祝日であるかどうかの確認をする
        let month = loc_date.month();
        let weekday = JpHoliday::weekday_jp(&loc_date.weekday()) as u32;
        // let week_num = loc_date.day() / 7 + 1;
        let week_num = self.get_week_number(loc_date);
        // 月/週/曜日 で定義済みの祝日を引く
        let key = format!("{}/{}/{}", month, week_num, weekday);
        let name = self.holiday_at_week.get(&key);
        if let Some(s) = name {
            return Some(s.clone());
        }
        // 月/日 で定義済みの祝日を引く
        let name = self
            .holiday
            .get(&format!("{}/{}", loc_date.month(), loc_date.day()));
        if let Some(s) = name {
            return Some(s.clone());
        };
        // 年/月/日で定義済みの祝日を引く
        let name = self.shunbun_syubun.get(&format!(
            "{}/{}/{}",
            loc_date.year(),
            loc_date.month(),
            loc_date.day()
        ));
        if let Some(s) = name {
            return Some(s.clone());
        };
        None
    }

閏年計算

忘れがちだけど、これも大事。
NaiveDateの拡張として実装する(というのがStackOverflowに書いてあったのでそのままパクった)
集合知強い。

trait NaiveDateExt {
    fn days_in_month(&self) -> i32;
    fn days_in_year(&self) -> i32;
    fn is_leap_year(&self) -> bool;
}

impl NaiveDateExt for chrono::NaiveDate {
    fn days_in_month(&self) -> i32 {
        let month = self.month();
        match month {
            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
            4 | 6 | 9 | 11 => 30,
            2 => {
                if self.is_leap_year() {
                    29
                } else {
                    28
                }
            }
            _ => panic!("Invalid month: {}", month),
        }
    }

    fn days_in_year(&self) -> i32 {
        if self.is_leap_year() {
            366
        } else {
            365
        }
    }

    fn is_leap_year(&self) -> bool {
        let year = self.year();
        return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
    }
}

祝日に関する諸々の規定に倣って動的に祝日を計算する

    #[derive(Debug, Clone, Deserialize, Serialize)]
    struct JpHolidayInfo {
        name: String, // 祝日の名称
        substitute: u32, // 振替休日算出時に使用する値
    }

    fn get_holiday(&self, loc_date: &NaiveDate) -> Option<Self::HolidayStruct> {
        if let Some(holiday_info) = self.get_holiday_with_dict(loc_date) {
            return Some(holiday_info);
        } else if loc_date.weekday() == Weekday::Sun {
            return None;
        }
        let day = loc_date.checked_sub_days(Days::new(1)).unwrap();
        let holiday = match self.get_holiday_with_dict(&day) {
            Some(s) => {
                // 今日の曜日と1日前のsubstituteを比較して今日のweekdayがsubstitute以下であれば振替休日
                // 言い換えると、今日の曜日のsubstitute日前が日曜日と被っている場合は振替休日とする。
                let today_weekday = JpHoliday::weekday_jp(&loc_date.weekday()) as u32;
                if today_weekday <= s.substitute {
                    let s = self
                        .get_holiday_with_dict(
                            &loc_date
                                .checked_sub_days(Days::new(today_weekday as u64))
                                .unwrap(),
                        )
                        .unwrap();
                    Some(JpHolidayInfo {
                        name: format!("{}の振替休日", s.name),
                        substitute: 1,
                    })
                } else {
                    None
                }
            }
            None => return None,
        };
        // さらに、もし前日と翌日の両方が祝日であった場合は休日となる。
        // 国民の祝日に関する法律第3条第3項
        if let None = holiday {
            let day = loc_date.checked_add_days(Days::new(1)).unwrap();
            if let Some(_) = self.get_holiday_with_dict(&day) {
                return Some(JpHolidayInfo {
                    name: "国民の休日".to_owned(),
                    substitute: 1,
                });
            }
        }
        holiday
    }

一応、主要ロジックは解説したので実行結果を見てみる。

実行結果

休日の後ろにはアスタリスク * がつくようなロジックを組みます。

2023年1月のカレンダー
日   月   火   水   木   金   土   
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
2024年1月のカレンダー
日   月   火   水   木   金   土   
     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
2025年1月のカレンダー
日   月   火   水   木   金   土   
               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
2023年5月のカレンダー
日   月   火   水   木   金   土   
     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
2024年5月のカレンダー(5日の振替休日が6日に来る年)
日   月   火   水   木   金   土
               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
2025年5月のカレンダー(4日の振替休日が6日に来る年)
日   月   火   水   木   金   土
                    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*
2026年5月のカレンダー(3日の振替休日が6日に来る年)
日   月   火   水   木   金   土
                         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*
2023年9月のカレンダー
日   月   火   水   木   金   土
                         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*
2024年9月のカレンダー
日   月   火   水   木   金   土
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
2025年9月のカレンダー
日   月   火   水   木   金   土
     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
2026年9月のカレンダー(21日と23日が祝日で、22日が休日になる)
日   月   火   水   木   金   土
          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
2027年9月のカレンダー
日   月   火   水   木   金   土
               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

とまぁこんな感じでいい感じになりました。
祝日と言う闇は結構勉強になったし、いい感じ。

今回はこんなもんで!

140
62
14

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
140
62