calendar: built-in holiday calendars, with motd reminders

d9460a8 · Gabriel Morell · 2026-10-19 15:03

21 files +3732 -82
Message
{commit_body(@commit)}

Files changed

modified Cargo.lock
+243 −31
@@ -373,6 +373,16 @@ version = "0.1.1"
373 373 source = "registry+https://github.com/rust-lang/crates.io-index"
374 374 checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
375 375
376 +[[package]]
377 +name = "calendrical_calculations"
378 +version = "0.2.4"
379 +source = "registry+https://github.com/rust-lang/crates.io-index"
380 +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26"
381 +dependencies = [
382 + "core_maths",
383 + "displaydoc",
384 +]
385 +
376 386 [[package]]
377 387 name = "cassowary"
378 388 version = "0.3.0"
@@ -542,6 +552,15 @@ version = "0.8.7"
542 552 source = "registry+https://github.com/rust-lang/crates.io-index"
543 553 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
544 554
555 +[[package]]
556 +name = "core_maths"
557 +version = "0.1.1"
558 +source = "registry+https://github.com/rust-lang/crates.io-index"
559 +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
560 +dependencies = [
561 + "libm",
562 +]
563 +
545 564 [[package]]
546 565 name = "cpufeatures"
547 566 version = "0.2.16"
@@ -1163,6 +1182,29 @@ dependencies = [
1163 1182 "cc",
1164 1183 ]
1165 1184
1185 +[[package]]
1186 +name = "icu_calendar"
1187 +version = "2.3.0"
1188 +source = "registry+https://github.com/rust-lang/crates.io-index"
1189 +checksum = "58655df2f728e46e4eee80bddebefacf52ec3e77cdb925014b47c5a5905eb55c"
1190 +dependencies = [
1191 + "calendrical_calculations",
1192 + "displaydoc",
1193 + "icu_calendar_data",
1194 + "icu_locale_core",
1195 + "icu_locale_fallback",
1196 + "icu_provider 2.3.1",
1197 + "ixdtf",
1198 + "tinystr 0.8.4",
1199 + "zerovec 0.11.8",
1200 +]
1201 +
1202 +[[package]]
1203 +name = "icu_calendar_data"
1204 +version = "2.3.0"
1205 +source = "registry+https://github.com/rust-lang/crates.io-index"
1206 +checksum = "dc00caaa3fb3201ff7a18aa458e8c3ab042b9da154cfdf948bdde3936c31c36e"
1207 +
1166 1208 [[package]]
1167 1209 name = "icu_collections"
1168 1210 version = "1.5.0"
@@ -1170,11 +1212,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1170 1212 checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
1171 1213 dependencies = [
1172 1214 "displaydoc",
1173 − "yoke",
1215 + "yoke 0.7.5",
1174 1216 "zerofrom",
1175 − "zerovec",
1217 + "zerovec 0.10.4",
1218 +]
1219 +
1220 +[[package]]
1221 +name = "icu_locale_core"
1222 +version = "2.3.0"
1223 +source = "registry+https://github.com/rust-lang/crates.io-index"
1224 +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
1225 +dependencies = [
1226 + "displaydoc",
1227 + "litemap 0.8.3",
1228 + "serde",
1229 + "tinystr 0.8.4",
1230 + "writeable 0.6.4",
1231 + "zerovec 0.11.8",
1232 +]
1233 +
1234 +[[package]]
1235 +name = "icu_locale_fallback"
1236 +version = "2.3.0"
1237 +source = "registry+https://github.com/rust-lang/crates.io-index"
1238 +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
1239 +dependencies = [
1240 + "icu_locale_core",
1241 + "icu_locale_fallback_data",
1242 + "icu_provider 2.3.1",
1243 + "potential_utf",
1244 + "tinystr 0.8.4",
1245 + "zerovec 0.11.8",
1176 1246 ]
1177 1247
1248 +[[package]]
1249 +name = "icu_locale_fallback_data"
1250 +version = "2.3.0"
1251 +source = "registry+https://github.com/rust-lang/crates.io-index"
1252 +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
1253 +
1178 1254 [[package]]
1179 1255 name = "icu_locid"
1180 1256 version = "1.5.0"
@@ -1182,10 +1258,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1182 1258 checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
1183 1259 dependencies = [
1184 1260 "displaydoc",
1185 − "litemap",
1186 − "tinystr",
1187 − "writeable",
1188 − "zerovec",
1261 + "litemap 0.7.4",
1262 + "tinystr 0.7.6",
1263 + "writeable 0.5.5",
1264 + "zerovec 0.10.4",
1189 1265 ]
1190 1266
1191 1267 [[package]]
@@ -1197,9 +1273,9 @@ dependencies = [
1197 1273 "displaydoc",
1198 1274 "icu_locid",
1199 1275 "icu_locid_transform_data",
1200 − "icu_provider",
1201 − "tinystr",
1202 − "zerovec",
1276 + "icu_provider 1.5.0",
1277 + "tinystr 0.7.6",
1278 + "zerovec 0.10.4",
1203 1279 ]
1204 1280
1205 1281 [[package]]
@@ -1218,12 +1294,12 @@ dependencies = [
1218 1294 "icu_collections",
1219 1295 "icu_normalizer_data",
1220 1296 "icu_properties",
1221 − "icu_provider",
1297 + "icu_provider 1.5.0",
1222 1298 "smallvec",
1223 1299 "utf16_iter",
1224 1300 "utf8_iter",
1225 1301 "write16",
1226 − "zerovec",
1302 + "zerovec 0.10.4",
1227 1303 ]
1228 1304
1229 1305 [[package]]
@@ -1242,9 +1318,9 @@ dependencies = [
1242 1318 "icu_collections",
1243 1319 "icu_locid_transform",
1244 1320 "icu_properties_data",
1245 − "icu_provider",
1246 − "tinystr",
1247 − "zerovec",
1321 + "icu_provider 1.5.0",
1322 + "tinystr 0.7.6",
1323 + "zerovec 0.10.4",
1248 1324 ]
1249 1325
1250 1326 [[package]]
@@ -1263,11 +1339,28 @@ dependencies = [
1263 1339 "icu_locid",
1264 1340 "icu_provider_macros",
1265 1341 "stable_deref_trait",
1266 − "tinystr",
1267 − "writeable",
1268 − "yoke",
1342 + "tinystr 0.7.6",
1343 + "writeable 0.5.5",
1344 + "yoke 0.7.5",
1345 + "zerofrom",
1346 + "zerovec 0.10.4",
1347 +]
1348 +
1349 +[[package]]
1350 +name = "icu_provider"
1351 +version = "2.3.1"
1352 +source = "registry+https://github.com/rust-lang/crates.io-index"
1353 +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
1354 +dependencies = [
1355 + "displaydoc",
1356 + "icu_locale_core",
1357 + "serde",
1358 + "stable_deref_trait",
1359 + "writeable 0.6.4",
1360 + "yoke 0.8.3",
1269 1361 "zerofrom",
1270 − "zerovec",
1362 + "zerotrie",
1363 + "zerovec 0.11.8",
1271 1364 ]
1272 1365
1273 1366 [[package]]
@@ -1398,6 +1491,12 @@ version = "1.0.14"
1398 1491 source = "registry+https://github.com/rust-lang/crates.io-index"
1399 1492 checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
1400 1493
1494 +[[package]]
1495 +name = "ixdtf"
1496 +version = "0.6.6"
1497 +source = "registry+https://github.com/rust-lang/crates.io-index"
1498 +checksum = "4d3667095d64c3ecffc96463a21157b04bf3e252f6e8d5750b20c02e33c194e3"
1499 +
1401 1500 [[package]]
1402 1501 name = "js-sys"
1403 1502 version = "0.3.76"
@@ -1462,6 +1561,12 @@ version = "0.7.4"
1462 1561 source = "registry+https://github.com/rust-lang/crates.io-index"
1463 1562 checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104"
1464 1563
1564 +[[package]]
1565 +name = "litemap"
1566 +version = "0.8.3"
1567 +source = "registry+https://github.com/rust-lang/crates.io-index"
1568 +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
1569 +
1465 1570 [[package]]
1466 1571 name = "lock_api"
1467 1572 version = "0.4.11"
@@ -1868,6 +1973,17 @@ version = "0.3.29"
1868 1973 source = "registry+https://github.com/rust-lang/crates.io-index"
1869 1974 checksum = "2900ede94e305130c13ddd391e0ab7cbaeb783945ae07a279c268cb05109c6cb"
1870 1975
1976 +[[package]]
1977 +name = "potential_utf"
1978 +version = "0.1.6"
1979 +source = "registry+https://github.com/rust-lang/crates.io-index"
1980 +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
1981 +dependencies = [
1982 + "serde_core",
1983 + "writeable 0.6.4",
1984 + "zerovec 0.11.8",
1985 +]
1986 +
1871 1987 [[package]]
1872 1988 name = "powerfmt"
1873 1989 version = "0.2.0"
@@ -1889,6 +2005,7 @@ dependencies = [
1889 2005 "enum-iterator",
1890 2006 "env_logger",
1891 2007 "futures",
2008 + "icu_calendar",
1892 2009 "interim",
1893 2010 "itertools 0.12.0",
1894 2011 "lazy_static",
@@ -2001,9 +2118,9 @@ dependencies = [
2001 2118
2002 2119 [[package]]
2003 2120 name = "quote"
2004 −version = "1.0.37"
2121 +version = "1.0.47"
2005 2122 source = "registry+https://github.com/rust-lang/crates.io-index"
2006 −checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
2123 +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
2007 2124 dependencies = [
2008 2125 "proc-macro2",
2009 2126 ]
@@ -2914,6 +3031,17 @@ dependencies = [
2914 3031 "unicode-ident",
2915 3032 ]
2916 3033
3034 +[[package]]
3035 +name = "syn"
3036 +version = "3.0.9"
3037 +source = "registry+https://github.com/rust-lang/crates.io-index"
3038 +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
3039 +dependencies = [
3040 + "proc-macro2",
3041 + "quote",
3042 + "unicode-ident",
3043 +]
3044 +
2917 3045 [[package]]
2918 3046 name = "synstructure"
2919 3047 version = "0.13.1"
@@ -2925,6 +3053,17 @@ dependencies = [
2925 3053 "syn 2.0.90",
2926 3054 ]
2927 3055
3056 +[[package]]
3057 +name = "synstructure"
3058 +version = "0.14.0"
3059 +source = "registry+https://github.com/rust-lang/crates.io-index"
3060 +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
3061 +dependencies = [
3062 + "proc-macro2",
3063 + "quote",
3064 + "syn 3.0.9",
3065 +]
3066 +
2928 3067 [[package]]
2929 3068 name = "tap"
2930 3069 version = "1.0.1"
@@ -3014,7 +3153,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3014 3153 checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
3015 3154 dependencies = [
3016 3155 "displaydoc",
3017 − "zerovec",
3156 + "zerovec 0.10.4",
3157 +]
3158 +
3159 +[[package]]
3160 +name = "tinystr"
3161 +version = "0.8.4"
3162 +source = "registry+https://github.com/rust-lang/crates.io-index"
3163 +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
3164 +dependencies = [
3165 + "displaydoc",
3166 + "serde_core",
3167 + "zerovec 0.11.8",
3018 3168 ]
3019 3169
3020 3170 [[package]]
@@ -3585,6 +3735,12 @@ version = "0.5.5"
3585 3735 source = "registry+https://github.com/rust-lang/crates.io-index"
3586 3736 checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
3587 3737
3738 +[[package]]
3739 +name = "writeable"
3740 +version = "0.6.4"
3741 +source = "registry+https://github.com/rust-lang/crates.io-index"
3742 +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
3743 +
3588 3744 [[package]]
3589 3745 name = "wyz"
3590 3746 version = "0.5.1"
@@ -3614,7 +3770,18 @@ checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40"
3614 3770 dependencies = [
3615 3771 "serde",
3616 3772 "stable_deref_trait",
3617 − "yoke-derive",
3773 + "yoke-derive 0.7.5",
3774 + "zerofrom",
3775 +]
3776 +
3777 +[[package]]
3778 +name = "yoke"
3779 +version = "0.8.3"
3780 +source = "registry+https://github.com/rust-lang/crates.io-index"
3781 +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
3782 +dependencies = [
3783 + "stable_deref_trait",
3784 + "yoke-derive 0.8.4",
3618 3785 "zerofrom",
3619 3786 ]
3620 3787
@@ -3627,7 +3794,19 @@ dependencies = [
3627 3794 "proc-macro2",
3628 3795 "quote",
3629 3796 "syn 2.0.90",
3630 − "synstructure",
3797 + "synstructure 0.13.1",
3798 +]
3799 +
3800 +[[package]]
3801 +name = "yoke-derive"
3802 +version = "0.8.4"
3803 +source = "registry+https://github.com/rust-lang/crates.io-index"
3804 +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
3805 +dependencies = [
3806 + "proc-macro2",
3807 + "quote",
3808 + "syn 3.0.9",
3809 + "synstructure 0.14.0",
3631 3810 ]
3632 3811
3633 3812 [[package]]
@@ -3653,23 +3832,23 @@ dependencies = [
3653 3832
3654 3833 [[package]]
3655 3834 name = "zerofrom"
3656 −version = "0.1.5"
3835 +version = "0.1.8"
3657 3836 source = "registry+https://github.com/rust-lang/crates.io-index"
3658 −checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e"
3837 +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
3659 3838 dependencies = [
3660 3839 "zerofrom-derive",
3661 3840 ]
3662 3841
3663 3842 [[package]]
3664 3843 name = "zerofrom-derive"
3665 −version = "0.1.5"
3844 +version = "0.1.8"
3666 3845 source = "registry+https://github.com/rust-lang/crates.io-index"
3667 −checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
3846 +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
3668 3847 dependencies = [
3669 3848 "proc-macro2",
3670 3849 "quote",
3671 − "syn 2.0.90",
3672 − "synstructure",
3850 + "syn 3.0.9",
3851 + "synstructure 0.14.0",
3673 3852 ]
3674 3853
3675 3854 [[package]]
@@ -3678,15 +3857,37 @@ version = "1.8.1"
3678 3857 source = "registry+https://github.com/rust-lang/crates.io-index"
3679 3858 checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
3680 3859
3860 +[[package]]
3861 +name = "zerotrie"
3862 +version = "0.2.5"
3863 +source = "registry+https://github.com/rust-lang/crates.io-index"
3864 +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
3865 +dependencies = [
3866 + "displaydoc",
3867 + "zerovec 0.11.8",
3868 +]
3869 +
3681 3870 [[package]]
3682 3871 name = "zerovec"
3683 3872 version = "0.10.4"
3684 3873 source = "registry+https://github.com/rust-lang/crates.io-index"
3685 3874 checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
3686 3875 dependencies = [
3687 − "yoke",
3876 + "yoke 0.7.5",
3688 3877 "zerofrom",
3689 − "zerovec-derive",
3878 + "zerovec-derive 0.10.3",
3879 +]
3880 +
3881 +[[package]]
3882 +name = "zerovec"
3883 +version = "0.11.8"
3884 +source = "registry+https://github.com/rust-lang/crates.io-index"
3885 +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
3886 +dependencies = [
3887 + "serde",
3888 + "yoke 0.8.3",
3889 + "zerofrom",
3890 + "zerovec-derive 0.11.6",
3690 3891 ]
3691 3892
3692 3893 [[package]]
@@ -3700,6 +3901,17 @@ dependencies = [
3700 3901 "syn 2.0.90",
3701 3902 ]
3702 3903
3904 +[[package]]
3905 +name = "zerovec-derive"
3906 +version = "0.11.6"
3907 +source = "registry+https://github.com/rust-lang/crates.io-index"
3908 +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
3909 +dependencies = [
3910 + "proc-macro2",
3911 + "quote",
3912 + "syn 3.0.9",
3913 +]
3914 +
3703 3915 [[package]]
3704 3916 name = "zmij"
3705 3917 version = "1.0.14"
modified Cargo.toml
+1 −0
@@ -59,6 +59,7 @@ time = "0.3.37"
59 59 itertools = "0.12.0"
60 60 xdg = "2.5.2"
61 61 serde_json = "1.0.149"
62 +icu_calendar = "2.3.0"
62 63
63 64
64 65 [lints.clippy]
modified README.md
+53 −2
@@ -70,7 +70,13 @@ While editing a person, `Delete` (or backspace) twice removes the selected row.
70 70
71 71 #### Calendar View
72 72
73 −Upcoming Anniversaries
73 +Upcoming anniversaries and holidays for the year; `↑`/`↓` scroll the upcoming list, which opens on today,
74 +and the selected day is highlighted in white in the month grid. Press `i` on a holiday for a short
75 +description and a Wikipedia link (`o` opens it in your browser).
76 +
77 +Press `c` to switch built-in calendars on and off. Their holidays show in the upcoming list and
78 +colour the month grid: cyan for countries, violet for religious calendars, amber for moon and fiscal.
79 +A country's states and provinces appear under it once it's on.
74 80
75 81 ![calendar.png](screenshots/calendar.png)
76 82
@@ -102,4 +108,49 @@ ppl traits add pronouns --symbol 🏳️ --color violet # new trait
102 108 ppl traits add namesday --kind date # new kind of date
103 109 ```
104 110
105 −Colors: red, gold, amber, green, teal, cyan, blue, violet, pink, slate.
111 +Colors: red, gold, amber, green, teal, cyan, blue, violet, pink, slate.
112 +
113 +### Calendars
114 +
115 +Built-in holiday calendars, all computed locally (no downloads, any year):
116 +
117 +- Countries: United States, Canada, United Kingdom, Ireland, Germany, France, Mexico, Australia,
118 + India, Thailand, plus states and provinces for the US, Canada, the UK, Germany and Australia
119 + (`ppl calendars list --all`), and US observances like Mother's Day and Halloween
120 +- Religious and cultural: Christian (Western and Orthodox), Jewish, Islamic, Chinese (lunar),
121 + Persian, Hindu, Buddhist
122 +- Moon and fiscal: moon phases with times and full-moon names, and fiscal quarters for years
123 + starting January, April, July or October, plus the retail 4-5-4 calendar
124 +
125 +```
126 +ppl calendars # what's on
127 +ppl calendars enable us us-ny jewish lunar
128 +ppl calendars disable jewish
129 +ppl calendars show # this year's holidays from the calendars that are on
130 +ppl calendars show hindu -y 2027 # or from any calendar, any year
131 +```
132 +
133 +Turning on a state turns on its country; turning off a country turns off its states.
134 +
135 +#### Holidays in the motd
136 +
137 +`ppl motd` mentions upcoming holidays from the calendars that are on. Like tiers, each calendar has a
138 +window (how many days ahead) and a remind mode (`daily`, `hourly`, `random:N`, `always`, `never`),
139 +and each group (`countries`, `religious`, `other` for moon and fiscal) has defaults for its calendars.
140 +A calendar uses its own setting, then its country's (for a state), then its group's, then on the day, daily.
141 +
142 +```
143 +ppl calendars set countries 0 # national holidays on the day
144 +ppl calendars set religious 7 # religious ones a week ahead
145 +ppl calendars remind religious always
146 +ppl calendars set us-ny 3 # but New York's three days ahead
147 +ppl calendars clear us-ny # back to the defaults
148 +```
149 +
150 +In the TUI's calendar popup the same keys as the tiers tab work on a calendar or a group heading:
151 +`+`/`-` days ahead, `m` mode, `[`/`]` random %, `x` back to the default.
152 +
153 +Jewish and Islamic days begin at sundown the evening before. Islamic dates use the Umm al-Qura
154 +calendar, and local moon sighting can move them a day. Hindu dates are worked out for India
155 +from the positions of the Sun and Moon, and regional calendars can differ by a day. Thailand's
156 +Buddhist days follow the astronomical full moon, so they can be a day off the Thai almanac.
added src/astro.rs
+226 −0
@@ -0,0 +1,226 @@
1 +//! Just enough astronomy for lunar calendars: where the Sun and Moon are, and
2 +//! when the Moon reaches a phase. Formulas are the shorter series from Jean
3 +//! Meeus' "Astronomical Algorithms", good to a few minutes of time.
4 +
5 +use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, Utc};
6 +
7 +/// Julian day of a UTC time
8 +pub fn jd(t: NaiveDateTime) -> f64 {
9 + let epoch = NaiveDate::from_ymd_opt(2000, 1, 1)
10 + .and_then(|d| d.and_hms_opt(12, 0, 0))
11 + .unwrap_or_default();
12 + 2451545.0 + (t - epoch).num_seconds() as f64 / 86400.0
13 +}
14 +
15 +/// UTC time of a Julian day
16 +pub fn utc(jd: f64) -> DateTime<Utc> {
17 + let secs = ((jd - 2451545.0) * 86400.0).round() as i64;
18 + DateTime::<Utc>::from_timestamp(946728000 + secs, 0).unwrap_or_default()
19 +}
20 +
21 +fn norm(deg: f64) -> f64 {
22 + deg.rem_euclid(360.0)
23 +}
24 +
25 +fn sin(deg: f64) -> f64 {
26 + deg.to_radians().sin()
27 +}
28 +
29 +fn centuries(jd: f64) -> f64 {
30 + (jd - 2451545.0) / 36525.0
31 +}
32 +
33 +/// The Sun's apparent ecliptic longitude, in degrees
34 +pub fn sun_longitude(jd: f64) -> f64 {
35 + let t = centuries(jd);
36 + let l0 = 280.46646 + 36000.76983 * t + 0.0003032 * t * t;
37 + let m = 357.52911 + 35999.05029 * t - 0.0001537 * t * t;
38 + let c = (1.914602 - 0.004817 * t - 0.000014 * t * t) * sin(m)
39 + + (0.019993 - 0.000101 * t) * sin(2.0 * m)
40 + + 0.000289 * sin(3.0 * m);
41 + let omega = 125.04 - 1934.136 * t;
42 + norm(l0 + c - 0.00569 - 0.00478 * sin(omega))
43 +}
44 +
45 +/// The Moon's apparent ecliptic longitude, in degrees
46 +pub fn moon_longitude(jd: f64) -> f64 {
47 + let t = centuries(jd);
48 + let t2 = t * t;
49 + let t3 = t2 * t;
50 + let t4 = t3 * t;
51 + let lp = 218.3164477 + 481267.88123421 * t - 0.0015786 * t2 + t3 / 538841.0 - t4 / 65194000.0;
52 + let d = 297.8501921 + 445267.1114034 * t - 0.0018819 * t2 + t3 / 545868.0 - t4 / 113065000.0;
53 + let m = 357.5291092 + 35999.0502909 * t - 0.0001536 * t2 + t3 / 24490000.0;
54 + let mp = 134.9633964 + 477198.8675055 * t + 0.0087414 * t2 + t3 / 69699.0 - t4 / 14712000.0;
55 + let f = 93.2720950 + 483202.0175233 * t - 0.0036539 * t2 - t3 / 3526000.0 + t4 / 863310000.0;
56 + let e = 1.0 - 0.002516 * t - 0.0000074 * t2;
57 +
58 + // The largest terms of Meeus' table 47.A: multiples of D, M, M', F and the coefficient in 1e-6 degrees
59 + #[rustfmt::skip]
60 + const TERMS: [(f64, f64, f64, f64, f64); 34] = [
61 + (0.0, 0.0, 1.0, 0.0, 6288774.0), (2.0, 0.0, -1.0, 0.0, 1274027.0),
62 + (2.0, 0.0, 0.0, 0.0, 658314.0), (0.0, 0.0, 2.0, 0.0, 213618.0),
63 + (0.0, 1.0, 0.0, 0.0, -185116.0), (0.0, 0.0, 0.0, 2.0, -114332.0),
64 + (2.0, 0.0, -2.0, 0.0, 58793.0), (2.0, -1.0, -1.0, 0.0, 57066.0),
65 + (2.0, 0.0, 1.0, 0.0, 53322.0), (2.0, -1.0, 0.0, 0.0, 45758.0),
66 + (0.0, 1.0, -1.0, 0.0, -40923.0), (1.0, 0.0, 0.0, 0.0, -34720.0),
67 + (0.0, 1.0, 1.0, 0.0, -30383.0), (2.0, 0.0, 0.0, -2.0, 15327.0),
68 + (0.0, 0.0, 1.0, 2.0, -12528.0), (0.0, 0.0, 1.0, -2.0, 10980.0),
69 + (4.0, 0.0, -1.0, 0.0, 10675.0), (0.0, 0.0, 3.0, 0.0, 10034.0),
70 + (4.0, 0.0, -2.0, 0.0, 8548.0), (2.0, 1.0, -1.0, 0.0, -7888.0),
71 + (2.0, 1.0, 0.0, 0.0, -6766.0), (1.0, 0.0, -1.0, 0.0, -5163.0),
72 + (1.0, 1.0, 0.0, 0.0, 4987.0), (2.0, -1.0, 1.0, 0.0, 4036.0),
73 + (2.0, 0.0, 2.0, 0.0, 3994.0), (4.0, 0.0, 0.0, 0.0, 3861.0),
74 + (2.0, 0.0, -3.0, 0.0, 3665.0), (0.0, 1.0, -2.0, 0.0, -2689.0),
75 + (2.0, 0.0, -1.0, 2.0, -2602.0), (2.0, -1.0, -2.0, 0.0, 2390.0),
76 + (1.0, 0.0, 1.0, 0.0, -2348.0), (2.0, -2.0, 0.0, 0.0, 2236.0),
77 + (0.0, 1.0, 2.0, 0.0, -2120.0), (0.0, 2.0, 0.0, 0.0, -2069.0),
78 + ];
79 + let mut sum = 0.0;
80 + for (cd, cm, cmp, cf, coeff) in TERMS {
81 + // Terms with the Sun's anomaly shrink as Earth's orbit becomes less eccentric
82 + let ecc = match cm.abs() as i32 {
83 + 1 => e,
84 + 2 => e * e,
85 + _ => 1.0,
86 + };
87 + sum += coeff * ecc * sin(cd * d + cm * m + cmp * mp + cf * f);
88 + }
89 + let a1 = 119.75 + 131.849 * t;
90 + let a2 = 53.09 + 479264.290 * t;
91 + sum += 3958.0 * sin(a1) + 1962.0 * sin(lp - f) + 318.0 * sin(a2);
92 + let omega = 125.04 - 1934.136 * t;
93 + norm(lp + sum / 1e6 - 0.00478 * sin(omega))
94 +}
95 +
96 +/// How far the Moon is ahead of the Sun: 0 at new moon, 180 at full
97 +pub fn elongation(jd: f64) -> f64 {
98 + norm(moon_longitude(jd) - sun_longitude(jd))
99 +}
100 +
101 +/// The Lahiri ayanamsa: how far the sidereal zodiac lags the tropical one
102 +fn ayanamsa(jd: f64) -> f64 {
103 + 23.853 + (jd - 2451545.0) / 365.25 * 0.013969
104 +}
105 +
106 +pub fn sidereal_sun_longitude(jd: f64) -> f64 {
107 + norm(sun_longitude(jd) - ayanamsa(jd))
108 +}
109 +
110 +/// Every time between `from` and `to` that `f` passes `target` degrees, `f` always increasing
111 +fn crossings(from: f64, to: f64, target: f64, step: f64, f: impl Fn(f64) -> f64) -> Vec<f64> {
112 + let off = |jd: f64| (f(jd) - target + 540.0).rem_euclid(360.0) - 180.0;
113 + let mut out = vec![];
114 + let mut a = from;
115 + while a < to {
116 + let b = a + step;
117 + if off(a) < 0.0 && off(b) >= 0.0 {
118 + let (mut lo, mut hi) = (a, b);
119 + for _ in 0..40 {
120 + let mid = (lo + hi) / 2.0;
121 + if off(mid) < 0.0 {
122 + lo = mid
123 + } else {
124 + hi = mid
125 + }
126 + }
127 + out.push((lo + hi) / 2.0);
128 + }
129 + a = b;
130 + }
131 + out
132 +}
133 +
134 +/// When the Moon reaches a phase angle (0 new, 90 first quarter, 180 full, 270 last quarter)
135 +pub fn moon_phases(from: f64, to: f64, angle: f64) -> Vec<f64> {
136 + crossings(from, to, angle, 1.0, elongation)
137 +}
138 +
139 +/// When the Sun enters a sidereal longitude, e.g. 270 for Makara (Capricorn)
140 +pub fn sidereal_ingress(from: f64, to: f64, longitude: f64) -> Vec<f64> {
141 + crossings(from, to, longitude, 5.0, sidereal_sun_longitude)
142 +}
143 +
144 +/// When the Sun reaches a tropical longitude: 0 and 180 are the equinoxes
145 +pub fn sun_ingress(from: f64, to: f64, longitude: f64) -> Vec<f64> {
146 + crossings(from, to, longitude, 5.0, sun_longitude)
147 +}
148 +
149 +/// Julian day of midnight UTC starting a date, shifted by hours
150 +pub fn jd_of(date: NaiveDate, hours: f64) -> f64 {
151 + jd(date.and_hms_opt(0, 0, 0).unwrap_or_default()) + hours / 24.0
152 +}
153 +
154 +/// Sunrise on a date at a latitude and longitude (east positive), as a Julian day.
155 +/// The NOAA approximation, good to a minute or two away from the poles.
156 +pub fn sunrise(date: NaiveDate, lat: f64, lon: f64) -> f64 {
157 + let noon = jd_of(date, 12.0 - lon / 15.0);
158 + let t = centuries(noon);
159 + let l0 = norm(280.46646 + 36000.76983 * t);
160 + let m = 357.52911 + 35999.05029 * t;
161 + let e = 0.016708634 - 0.000042037 * t;
162 + let eps = 23.439291 - 0.0130042 * t;
163 + let decl = (sin(eps) * sin(sun_longitude(noon))).asin();
164 + let y = (eps / 2.0).to_radians().tan().powi(2);
165 + let (l0r, mr) = (l0.to_radians(), m.to_radians());
166 + // Equation of time, in minutes
167 + let eot = 4.0
168 + * (y * (2.0 * l0r).sin() - 2.0 * e * mr.sin() + 4.0 * e * y * mr.sin() * (2.0 * l0r).cos()
169 + - 0.5 * y * y * (4.0 * l0r).sin()
170 + - 1.25 * e * e * (2.0 * mr).sin())
171 + .to_degrees();
172 + let phi = lat.to_radians();
173 + let cos_h =
174 + ((-0.833f64).to_radians().sin() - phi.sin() * decl.sin()) / (phi.cos() * decl.cos());
175 + let h = cos_h.clamp(-1.0, 1.0).acos().to_degrees();
176 + let minutes_utc = 720.0 - 4.0 * lon - eot - 4.0 * h;
177 + jd_of(date, minutes_utc / 60.0)
178 +}
179 +
180 +/// A UTC time as a date and time in a fixed offset from UTC
181 +pub fn local(jd: f64, offset_hours: f64) -> NaiveDateTime {
182 + utc(jd).naive_utc() + Duration::seconds((offset_hours * 3600.0) as i64)
183 +}
184 +
185 +#[cfg(test)]
186 +mod tests {
187 + use super::*;
188 +
189 + fn at(y: i32, m: u32, d: u32, h: u32, min: u32) -> f64 {
190 + jd(NaiveDate::from_ymd_opt(y, m, d)
191 + .unwrap()
192 + .and_hms_opt(h, min, 0)
193 + .unwrap())
194 + }
195 +
196 + #[test]
197 + fn phases_match_eclipses() {
198 + // The 2024-04-08 total solar eclipse new moon was at 18:21 UTC
199 + let nm = moon_phases(at(2024, 4, 1, 0, 0), at(2024, 4, 15, 0, 0), 0.0);
200 + assert_eq!(nm.len(), 1);
201 + assert!(
202 + (nm[0] - at(2024, 4, 8, 18, 21)).abs() < 10.0 / 1440.0,
203 + "{}",
204 + utc(nm[0])
205 + );
206 + // The 2024-09-18 lunar eclipse full moon was at 02:34 UTC
207 + let fm = moon_phases(at(2024, 9, 10, 0, 0), at(2024, 9, 25, 0, 0), 180.0);
208 + assert!(
209 + (fm[0] - at(2024, 9, 18, 2, 34)).abs() < 10.0 / 1440.0,
210 + "{}",
211 + utc(fm[0])
212 + );
213 + }
214 +
215 + #[test]
216 + fn sunrise_in_delhi() {
217 + // 2026-06-21 05:24 and 2026-12-21 07:10 IST
218 + let day = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
219 + let near = |jd: f64, d: NaiveDate, h: u32, m: u32| {
220 + let want = super::jd(d.and_hms_opt(h, m, 0).unwrap()) - 5.5 / 24.0;
221 + (jd - want).abs() < 2.0 / 1440.0
222 + };
223 + assert!(near(sunrise(day(6, 21), 28.61, 77.21), day(6, 21), 5, 24));
224 + assert!(near(sunrise(day(12, 21), 28.61, 77.21), day(12, 21), 7, 10));
225 + }
226 +}
modified src/data.rs
+261 −3
@@ -3,17 +3,22 @@ use crate::do_tui::{Editable, Editablez};
3 3 use crate::entities::ppl::Model;
4 4 use crate::entities::prelude::Tier;
5 5 use crate::entities::prelude::Traits;
6 −use crate::entities::prelude::{Contact, Ppl, Relation, SigDate, TierDefaults, TraitDefaults};
6 +use crate::entities::prelude::{
7 + Calendar, Contact, Ppl, Relation, SigDate, TierDefaults, TraitDefaults,
8 +};
7 9 use crate::entities::{
8 − contact, ppl, relation, sig_date, tier, tier_defaults, trait_defaults, traits,
10 + calendar, contact, ppl, relation, sig_date, tier, tier_defaults, trait_defaults, traits,
9 11 };
12 +use crate::holidays::{self, CalendarDef, Group};
10 13 use crate::PplError;
11 14 use chrono::{Local, NaiveDate};
12 15 use color_eyre::owo_colors::OwoColorize;
13 16 use log::warn;
14 17 use ratatui::style::palette::tailwind::PURPLE;
15 18 use sea_orm::ActiveValue::Set;
16 −use sea_orm::{ActiveModelTrait, DatabaseConnection, DbErr, EntityTrait, NotSet};
19 +use sea_orm::{
20 + ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, NotSet, QueryFilter,
21 +};
17 22 use std::fmt;
18 23 use std::io;
19 24 use std::str::FromStr;
@@ -775,6 +780,210 @@ pub async fn delete_editable(db: &DatabaseConnection, editable: &Editable) -> Re
775 780 Ok(())
776 781 }
777 782
783 +pub struct CalendarOps {}
784 +
785 +impl CalendarOps {
786 + /// Keys of the built-in calendars that are switched on
787 + pub async fn enabled(db: &DatabaseConnection) -> Result<Vec<String>, DbErr> {
788 + Ok(Calendar::find()
789 + .filter(calendar::Column::Enabled.eq(true))
790 + .all(db)
791 + .await?
792 + .into_iter()
793 + .filter_map(|c| holidays::find(&c.key).map(|d| d.key.to_string()))
794 + .collect())
795 + }
796 +
797 + /// Switch a calendar on or off. A state switches its country on with it, and a
798 + /// country switched off takes its states with it. Returns every calendar changed.
799 + pub async fn set(
800 + db: &DatabaseConnection,
801 + key: &str,
802 + on: bool,
803 + ) -> Result<Vec<&'static CalendarDef>, PplError> {
804 + let Some(def) = holidays::find(key) else {
805 + return Err(invalid(format!(
806 + "no calendar '{}', see `ppl calendars` for the list",
807 + key
808 + )));
809 + };
810 + let mut affected = vec![def];
811 + match on {
812 + true => affected.extend(def.parent.and_then(holidays::find)),
813 + false => affected.extend(holidays::regions(def.key)),
814 + }
815 + let enabled = Self::enabled(db).await?;
816 + let mut changed = vec![];
817 + for d in affected {
818 + if enabled.iter().any(|k| k == d.key) == on {
819 + continue;
820 + }
821 + Self::upsert(db, d.key, |row| row.enabled = Set(on)).await?;
822 + changed.push(d);
823 + }
824 + Ok(changed)
825 + }
826 +
827 + /// Every row: calendars that have been switched on or given reminder settings, and group defaults
828 + pub async fn rows(db: &DatabaseConnection) -> Result<Vec<calendar::Model>, DbErr> {
829 + Calendar::find().all(db).await
830 + }
831 +
832 + /// Set the motd window and mode of a calendar or group; None goes back to the default
833 + pub async fn set_reminder(
834 + db: &DatabaseConnection,
835 + target: ReminderTarget,
836 + delta: Option<u32>,
837 + remind: Option<MotdRemindEnum>,
838 + ) -> Result<(), PplError> {
839 + Self::upsert(db, &target.row_key(), |row| {
840 + row.sig_date_delta = Set(delta);
841 + row.sig_remind_enum = Set(remind.map(|r| r.to_string()));
842 + })
843 + .await
844 + }
845 +
846 + /// Note that the motd just showed this calendar, for the daily and hourly modes
847 + pub async fn mark_reminded(
848 + db: &DatabaseConnection,
849 + row: &calendar::Model,
850 + day: &str,
851 + hour: &str,
852 + ) -> Result<(), PplError> {
853 + let mut meta = match &row.meta {
854 + Some(serde_json::Value::Object(m)) => m.clone(),
855 + _ => serde_json::Map::new(),
856 + };
857 + meta.insert("last_reminded".to_string(), day.into());
858 + meta.insert("last_reminded_hour".to_string(), hour.into());
859 + Self::upsert(db, &row.key, |r| {
860 + r.meta = Set(Some(serde_json::Value::Object(meta)))
861 + })
862 + .await
863 + }
864 +
865 + /// Change a row, creating it (switched off) if it isn't there yet
866 + async fn upsert(
867 + db: &DatabaseConnection,
868 + key: &str,
869 + change: impl FnOnce(&mut calendar::ActiveModel),
870 + ) -> Result<(), PplError> {
871 + let today = Local::now().date_naive();
872 + let existing = Calendar::find()
873 + .filter(calendar::Column::Key.eq(key))
874 + .one(db)
875 + .await?;
876 + let exists = existing.is_some();
877 + let mut row = match existing {
878 + Some(row) => row.into(),
879 + None => calendar::ActiveModel {
880 + id: NotSet,
881 + key: Set(key.to_string()),
882 + enabled: Set(false),
883 + sig_date_delta: Set(None),
884 + sig_remind_enum: Set(None),
885 + meta: Set(None),
886 + date_ins: Set(today),
887 + date_up: Set(today),
888 + },
889 + };
890 + change(&mut row);
891 + row.date_up = Set(today);
892 + match exists {
893 + true => row.update(db).await?,
894 + false => row.insert(db).await?,
895 + };
896 + Ok(())
897 + }
898 +}
899 +
900 +/// What a motd reminder setting is attached to: one calendar, or a group's default
901 +#[derive(Clone, Copy, Debug)]
902 +pub enum ReminderTarget {
903 + Calendar(&'static CalendarDef),
904 + Group(&'static Group),
905 +}
906 +
907 +impl ReminderTarget {
908 + /// A calendar key, or a group name: countries, religious or other
909 + pub fn parse(s: &str) -> Result<Self, PplError> {
910 + if let Some(g) = holidays::GROUPS
911 + .iter()
912 + .find(|g| g.key.eq_ignore_ascii_case(s))
913 + {
914 + return Ok(ReminderTarget::Group(g));
915 + }
916 + holidays::find(s)
917 + .map(ReminderTarget::Calendar)
918 + .ok_or_else(|| {
919 + let groups = holidays::GROUPS.iter().map(|g| g.key).collect::<Vec<_>>();
920 + invalid(format!(
921 + "no calendar or group '{}' (groups: {}; calendars: `ppl calendars list --all`)",
922 + s,
923 + groups.join(", ")
924 + ))
925 + })
926 + }
927 +
928 + fn row_key(&self) -> String {
929 + match self {
930 + ReminderTarget::Calendar(c) => c.key.to_string(),
931 + ReminderTarget::Group(g) => format!("group:{}", g.key),
932 + }
933 + }
934 +
935 + pub fn label(&self) -> &'static str {
936 + match self {
937 + ReminderTarget::Calendar(c) => c.name,
938 + ReminderTarget::Group(g) => g.name,
939 + }
940 + }
941 +
942 + /// The setting itself, and what it falls back to: a state uses its country's, then a
943 + /// calendar uses its group's, then holidays show on the day, once a day
944 + pub fn reminder(&self, rows: &[calendar::Model]) -> CalendarReminder {
945 + let row = |key: &str| rows.iter().find(|r| r.key == key);
946 + let parse = |r: &calendar::Model| {
947 + r.sig_remind_enum
948 + .as_deref()
949 + .and_then(|m| m.parse::<MotdRemindEnum>().ok())
950 + };
951 + let own = row(&self.row_key());
952 + let mut fallbacks = vec![];
953 + if let ReminderTarget::Calendar(c) = self {
954 + fallbacks.extend(c.parent.and_then(row));
955 + fallbacks.extend(row(&format!("group:{}", holidays::group_of(c).key)));
956 + }
957 + CalendarReminder {
958 + delta: own.and_then(|r| r.sig_date_delta),
959 + remind: own.and_then(parse),
960 + default_delta: fallbacks.iter().find_map(|r| r.sig_date_delta).or(Some(0)),
961 + default_remind: fallbacks
962 + .iter()
963 + .find_map(|r| parse(r))
964 + .or(Some(MotdRemindEnum::OnlyOnce)),
965 + }
966 + }
967 +}
968 +
969 +/// A calendar's or group's motd settings, with the defaults they fall back to
970 +#[derive(Clone, Copy, Debug)]
971 +pub struct CalendarReminder {
972 + pub delta: Option<u32>,
973 + pub remind: Option<MotdRemindEnum>,
974 + pub default_delta: Option<u32>,
975 + pub default_remind: Option<MotdRemindEnum>,
976 +}
977 +
978 +impl CalendarReminder {
979 + pub fn window(&self) -> u32 {
980 + self.delta.or(self.default_delta).unwrap_or(0)
981 + }
982 + pub fn mode(&self) -> MotdRemindEnum {
983 + self.remind.or(self.default_remind).unwrap_or_default()
984 + }
985 +}
986 +
778 987 pub struct TierDefaultOps {}
779 988
780 989 impl TierDefaultOps {
@@ -965,3 +1174,52 @@ impl TraitDefaultOps {
965 1174 Ok(key.to_string())
966 1175 }
967 1176 }
1177 +
1178 +#[cfg(test)]
1179 +mod tests {
1180 + use super::*;
1181 +
1182 + fn row(key: &str, delta: Option<u32>, remind: Option<&str>) -> calendar::Model {
1183 + let today = NaiveDate::from_ymd_opt(2026, 10, 19).unwrap();
1184 + calendar::Model {
1185 + id: 0,
1186 + key: key.to_string(),
1187 + enabled: true,
1188 + sig_date_delta: delta,
1189 + sig_remind_enum: remind.map(|r| r.to_string()),
1190 + meta: None,
1191 + date_ins: today,
1192 + date_up: today,
1193 + }
1194 + }
1195 +
1196 + #[test]
1197 + fn calendar_reminders_fall_back() {
1198 + let rows = vec![
1199 + row("group:religious", Some(7), None),
1200 + row("us", Some(3), None),
1201 + row("us-ny", None, Some("OnceHourly")),
1202 + ];
1203 + let get = |t: &str| ReminderTarget::parse(t).unwrap().reminder(&rows);
1204 + // A state takes its country's window, and keeps its own mode
1205 + let ny = get("us-ny");
1206 + assert_eq!((ny.window(), ny.mode()), (3, MotdRemindEnum::OnceHourly));
1207 + assert_eq!(ny.delta, None);
1208 + let jewish = get("jewish");
1209 + assert_eq!(
1210 + (jewish.window(), jewish.mode()),
1211 + (7, MotdRemindEnum::OnlyOnce)
1212 + );
1213 + // With nothing set: on the day, once a day
1214 + let lunar = get("lunar");
1215 + assert_eq!(
1216 + (lunar.window(), lunar.mode()),
1217 + (0, MotdRemindEnum::OnlyOnce)
1218 + );
1219 + assert!(ReminderTarget::parse("nope").is_err());
1220 + assert!(matches!(
1221 + ReminderTarget::parse("Religious"),
1222 + Ok(ReminderTarget::Group(_))
1223 + ));
1224 + }
1225 +}
added src/do_calendars.rs
+193 −0

Click to load diff…

modified src/do_tui.rs
+243 −28

Click to load diff…

added src/entities/calendar.rs
+22 −0

Click to load diff…

modified src/entities/mod.rs
+1 −0

Click to load diff…

modified src/entities/prelude.rs
+1 −0

Click to load diff…

added src/holiday_info.tsv
+217 −0

Click to load diff…

added src/holidays.rs
+1775 −0

Click to load diff…

modified src/main.rs
+129 −18

Click to load diff…

added src/migrator/m20261019_000013_create_calendar.rs
+62 −0

Click to load diff…

added src/migrator/m20261019_000014_add_reminders_to_calendar.rs
+71 −0

Click to load diff…

modified src/migrator/mod.rs
+4 −0

Click to load diff…

modified src/tui_add.rs
+1 −1

Click to load diff…

added src/tui_calendars.rs
+230 −0

Click to load diff…

Parents: 2803012