feat: add recurrence and time zone support

This commit is contained in:
2026-07-17 21:52:02 +01:00
parent 22c6399056
commit c19098004e
18 changed files with 2321 additions and 132 deletions

View File

@@ -2,8 +2,12 @@
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
@@ -79,6 +83,39 @@ nocal::Event event(std::string uid, nocal::Date start_date, unsigned start_hour,
return result;
}
nocal::TimePoint utc_time(nocal::Date date, unsigned hour, unsigned minute = 0)
{
using namespace std::chrono;
return nocal::TimePoint{duration_cast<nocal::Clock::duration>(
nocal::to_sys_days(date).time_since_epoch() + hours{hour} +
minutes{minute})};
}
nocal::TimePoint zoned_time(std::string_view zone_name, nocal::Date date,
unsigned hour, unsigned minute = 0)
{
using namespace std::chrono;
const auto* zone = locate_zone(zone_name);
const local_time<nocal::Clock::duration> local{
duration_cast<nocal::Clock::duration>(local_days{date}.time_since_epoch() +
hours{hour} + minutes{minute})};
return time_point_cast<nocal::Clock::duration>(
zone->to_sys(local, choose::earliest));
}
nocal::Event utc_recurring(std::string uid, nocal::Date start_date,
unsigned start_hour, nocal::Date end_date,
unsigned end_hour, nocal::RecurrenceRule rule)
{
nocal::Event result;
result.uid = std::move(uid);
result.title = result.uid;
result.start = utc_time(start_date, start_hour);
result.end = utc_time(end_date, end_hour);
result.recurrence = std::move(rule);
return result;
}
void test_event_queries()
{
using namespace std::chrono;
@@ -118,6 +155,222 @@ void test_event_queries()
"invalid backwards event never overlaps");
}
void test_daily_dst_recurrence()
{
using namespace std::chrono;
const nocal::Date march_28{2026y / March / 28d};
const nocal::Date march_29{2026y / March / 29d};
const nocal::Date march_30{2026y / March / 30d};
nocal::Event event;
event.uid = "london-daily";
event.title = event.uid;
event.time_basis = nocal::TimeBasis::zoned;
event.time_zone = "Europe/London";
event.start = zoned_time(event.time_zone, march_28, 9);
event.end = zoned_time(event.time_zone, march_28, 10);
nocal::RecurrenceRule rule;
rule.frequency = nocal::RecurrenceFrequency::daily;
rule.count = 3U;
event.recurrence = rule;
const std::vector<nocal::Event> events{event};
const auto occurrences = nocal::occurrences_overlapping(
events, utc_time(march_28, 0), utc_time(2026y / March / 31d, 0));
check(occurrences.size() == 3,
"daily recurrence includes DTSTART and honors COUNT");
check(occurrences.size() == 3 &&
occurrences[0].start == utc_time(march_28, 9) &&
occurrences[1].start == utc_time(march_29, 8) &&
occurrences[2].start == utc_time(march_30, 8),
"zoned daily recurrence preserves 09:00 wall time across DST");
check(occurrences.size() == 3 &&
occurrences[1].end - occurrences[1].start == hours{1},
"zoned recurrence preserves the wall-clock end relationship");
}
void test_floating_dst_and_until()
{
using namespace std::chrono;
const char* previous_tz_value = std::getenv("TZ");
const std::optional<std::string> previous_tz =
previous_tz_value == nullptr
? std::nullopt
: std::optional<std::string>{previous_tz_value};
std::string london_zone_file = "/usr/share/zoneinfo/Europe/London";
if (!std::filesystem::exists(london_zone_file)) {
const auto local_zone_file = std::filesystem::canonical("/etc/localtime");
if (local_zone_file.string().ends_with("/Europe/London")) {
london_zone_file = local_zone_file.string();
}
}
const std::string london_tz = ':' + london_zone_file;
if (!std::filesystem::exists(london_zone_file) ||
::setenv("TZ", london_tz.c_str(), 1) != 0) {
check(false, "test can select the process local time zone");
return;
}
::tzset();
const nocal::Date march_28{2026y / March / 28d};
nocal::Event floating;
floating.uid = "floating-daily";
floating.title = floating.uid;
floating.time_basis = nocal::TimeBasis::floating;
floating.start = nocal::make_local_time(march_28, 9);
floating.end = nocal::make_local_time(march_28, 10);
nocal::RecurrenceRule daily;
daily.frequency = nocal::RecurrenceFrequency::daily;
daily.count = 3U;
floating.recurrence = daily;
auto until = utc_recurring("until", 2026y / July / 1d, 9,
2026y / July / 1d, 10,
nocal::RecurrenceRule{});
until.recurrence->until = utc_time(2026y / July / 3d, 9);
const std::vector<nocal::Event> floating_events{floating};
const auto floating_occurrences = nocal::occurrences_overlapping(
floating_events, utc_time(march_28, 0),
utc_time(2026y / March / 31d, 0));
check(floating_occurrences.size() == 3 &&
floating_occurrences[0].start == utc_time(march_28, 9) &&
floating_occurrences[1].start ==
utc_time(2026y / March / 29d, 8) &&
floating_occurrences[2].start ==
utc_time(2026y / March / 30d, 8),
"floating recurrence follows the selected process-local DST rules");
const std::vector<nocal::Event> until_events{until};
const auto until_occurrences = nocal::occurrences_overlapping(
until_events, utc_time(2026y / July / 1d, 0),
utc_time(2026y / July / 10d, 0));
check(until_occurrences.size() == 3 &&
until_occurrences.back().start == until.recurrence->until,
"UNTIL is inclusive and bounds uncounted recurrence expansion");
if (previous_tz) {
(void)::setenv("TZ", previous_tz->c_str(), 1);
} else {
(void)::unsetenv("TZ");
}
::tzset();
}
void test_weekly_count_and_exdates()
{
using namespace std::chrono;
const nocal::Date monday{2026y / July / 6d};
nocal::RecurrenceRule rule;
rule.frequency = nocal::RecurrenceFrequency::weekly;
rule.count = 6U;
rule.by_weekday = {Monday, Wednesday, Friday};
auto weekly = utc_recurring("weekly", monday, 9, monday, 10, rule);
weekly.recurrence_exceptions = {weekly.start,
utc_time(2026y / July / 10d, 9)};
const std::vector<nocal::Event> events{weekly};
const auto occurrences = nocal::occurrences_overlapping(
events, utc_time(monday, 0), utc_time(2026y / July / 20d, 0));
check(occurrences.size() == 4,
"EXDATE removes DTSTART and another generated weekly start");
check(occurrences.size() == 4 && occurrences[0].ordinal == 1 &&
occurrences[1].ordinal == 3 && occurrences[2].ordinal == 4 &&
occurrences[3].ordinal == 5,
"weekly ordinals and COUNT include excluded DTSTART occurrences");
check(occurrences.size() == 4 &&
occurrences[0].start == utc_time(2026y / July / 8d, 9) &&
occurrences[1].start == utc_time(2026y / July / 13d, 9),
"weekly BYDAY expansion is chronological across week boundaries");
}
void test_monthly_and_yearly_selectors()
{
using namespace std::chrono;
nocal::RecurrenceRule monthly_rule;
monthly_rule.frequency = nocal::RecurrenceFrequency::monthly;
monthly_rule.count = 4U;
monthly_rule.by_month_day = {-1};
auto monthly = utc_recurring("month-end", 2027y / January / 31d, 9,
2027y / January / 31d, 10, monthly_rule);
nocal::RecurrenceRule yearly_rule;
yearly_rule.frequency = nocal::RecurrenceFrequency::yearly;
yearly_rule.count = 4U;
yearly_rule.by_month = {1U, 6U};
auto yearly = utc_recurring("selected-months", 2024y / June / 15d, 9,
2024y / June / 15d, 10, yearly_rule);
const std::vector<nocal::Event> events{monthly, yearly};
const auto month_occurrences = nocal::occurrences_overlapping(
std::span<const nocal::Event>{events}.first(1),
utc_time(2027y / January / 1d, 0), utc_time(2027y / May / 1d, 0));
check(month_occurrences.size() == 4,
"negative BYMONTHDAY expands to each month's final day");
check(month_occurrences.size() == 4 &&
month_occurrences[1].start == utc_time(2027y / February / 28d, 9) &&
month_occurrences[2].start == utc_time(2027y / March / 31d, 9) &&
month_occurrences[3].start == utc_time(2027y / April / 30d, 9),
"monthly negative selectors account for varying month lengths");
const auto year_occurrences = nocal::occurrences_overlapping(
std::span<const nocal::Event>{events}.subspan(1),
utc_time(2024y / January / 1d, 0), utc_time(2026y / February / 1d, 0));
check(year_occurrences.size() == 4,
"yearly BYMONTH count includes DTSTART");
check(year_occurrences.size() == 4 &&
year_occurrences[1].start == utc_time(2025y / January / 15d, 9) &&
year_occurrences[2].start == utc_time(2025y / June / 15d, 9) &&
year_occurrences[3].start == utc_time(2026y / January / 15d, 9),
"yearly BYMONTH reuses DTSTART's day and wall time");
}
void test_occurrence_overlap_sorting_and_invalid_rules()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
nocal::RecurrenceRule daily;
daily.frequency = nocal::RecurrenceFrequency::daily;
daily.count = 3U;
auto overnight = utc_recurring("overnight", 2026y / July / 16d, 23, day, 2,
daily);
nocal::RecurrenceRule once;
once.frequency = nocal::RecurrenceFrequency::daily;
once.count = 1U;
auto later = utc_recurring("later", day, 12, day, 13, once);
auto all_day = utc_recurring("all-day", day, 0, 2026y / July / 18d, 0,
once);
all_day.all_day = true;
auto invalid = later;
invalid.uid = "invalid";
invalid.title = invalid.uid;
invalid.recurrence->interval = 0U;
std::vector<nocal::Event> events{later, overnight, invalid, all_day};
const auto occurrences = nocal::occurrences_overlapping(
events, utc_time(day, 0), utc_time(2026y / July / 18d, 0));
check(occurrences.size() == 5,
"occurrence overlap includes prior multi-day starts and invalid-rule base");
check(occurrences.size() == 5 && occurrences[0].source->uid == "all-day" &&
occurrences[1].source->uid == "overnight" &&
occurrences[1].ordinal == 0 &&
occurrences[4].source->uid == "overnight" &&
occurrences[4].ordinal == 1,
"occurrences sort all-day first, then by generated start");
check(occurrences.size() == 5 &&
occurrences[2].source->uid == "invalid" &&
occurrences[3].source->uid == "later",
"equal occurrence times sort by title and UID");
invalid.time_basis = nocal::TimeBasis::zoned;
invalid.time_zone = "Not/A_Real_Zone";
invalid.recurrence->interval = 1U;
const std::vector<nocal::Event> bad_zone{invalid};
const auto retained = nocal::occurrences_overlapping(
bad_zone, utc_time(day, 0), utc_time(2026y / July / 18d, 0));
check(retained.size() == 1 && retained[0].ordinal == 0,
"unrepresentable recurrence metadata safely retains DTSTART");
}
} // namespace
int main()
@@ -125,6 +378,11 @@ int main()
test_dates();
test_month_layout();
test_event_queries();
test_daily_dst_recurrence();
test_floating_dst_and_until();
test_weekly_count_and_exdates();
test_monthly_and_yearly_selectors();
test_occurrence_overlap_sorting_and_invalid_rules();
if (failures != 0) {
std::cerr << failures << " domain test(s) failed\n";
return EXIT_FAILURE;