Add loop-scoped holiday list support

This commit is contained in:
Jacob Dubin
2026-05-19 06:57:09 -05:00
parent 54b32bc9cf
commit 2bc6fec1bf
13 changed files with 469 additions and 31 deletions

View File

@@ -91,6 +91,18 @@ dotnet run --project dotnet/src/Jibo.Cloud.Api/Jibo.Cloud.Api.csproj
Replace `UseDevelopmentStorage=true` with your real storage account connection string when you move
from local emulation to Azure.
## Holiday Wiring
Holiday lists are now sourced from a live holiday provider and merged with loop-scoped custom
holiday records.
The default country code is `US`, but you can override it with:
- `OpenJibo:Holiday:CountryCode`
If you later add custom holiday authoring, disabled records can be used to suppress a holiday for a
loop without removing the underlying system holiday source.
## Recovery Strategy
The first supported device path is:

View File

@@ -42,6 +42,6 @@ public interface ICloudStateStore
KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey);
IReadOnlyList<KeyRequestRecord> GetIncomingKeyRequests();
IReadOnlyList<KeyRequestRecord> GetBinaryRequests();
IReadOnlyList<object> GetHolidays();
IReadOnlyList<HolidayRecord> GetHolidays(string? loopId = null);
void UpdateRobot(DeviceRegistration registration);
}
}

View File

@@ -0,0 +1,8 @@
using Jibo.Cloud.Domain.Models;
namespace Jibo.Cloud.Application.Abstractions;
public interface IHolidayCalendarProvider
{
IReadOnlyList<HolidayRecord> GetPublicHolidays(string? countryCode, int year);
}

View File

@@ -72,7 +72,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore, IMedia
return Task.FromResult(HandleKey(operation, envelope));
if (servicePrefix.StartsWith("Person_", StringComparison.OrdinalIgnoreCase))
return Task.FromResult(HandlePerson(operation));
return Task.FromResult(HandlePerson(operation, envelope));
if (servicePrefix.StartsWith("Robot_", StringComparison.OrdinalIgnoreCase))
return Task.FromResult(HandleRobot(operation, envelope));
@@ -353,11 +353,14 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore, IMedia
MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta)));
}
private ProtocolDispatchResult HandlePerson(string operation)
private ProtocolDispatchResult HandlePerson(string operation, ProtocolEnvelope envelope)
{
return ProtocolDispatchResult.Ok(operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase)
? stateStore.GetHolidays()
: []);
if (!operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(Array.Empty<object>());
var body = envelope.TryParseBody();
var loopId = ReadString(body, "loopId");
return ProtocolDispatchResult.Ok(stateStore.GetHolidays(loopId).Select(MapHoliday));
}
private ProtocolDispatchResult HandleBackup(string operation, ProtocolEnvelope envelope)
@@ -549,6 +552,26 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore, IMedia
};
}
private static object MapHoliday(HolidayRecord holiday)
{
return new
{
id = holiday.Id,
eventId = holiday.EventId,
name = holiday.Name,
category = holiday.Category,
subcategory = holiday.Subcategory,
loopId = holiday.LoopId,
memberId = holiday.MemberId,
isEnabled = holiday.IsEnabled,
date = holiday.Date,
endDate = holiday.EndDate,
source = holiday.Source,
countryCode = holiday.CountryCode,
created = holiday.Created
};
}
private static object MapMedia(MediaRecord item)
{
return new
@@ -665,4 +688,4 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore, IMedia
return Task.FromResult<MediaContentSnapshot?>(null);
}
}
}
}

View File

@@ -0,0 +1,18 @@
namespace Jibo.Cloud.Domain.Models;
public sealed class HolidayRecord
{
public string Id { get; init; } = $"holiday-{Guid.NewGuid():N}";
public string EventId { get; init; } = string.Empty;
public string Name { get; init; } = "Holiday";
public string Category { get; init; } = "holiday";
public string? Subcategory { get; init; }
public string LoopId { get; init; } = "openjibo-default-loop";
public string? MemberId { get; init; }
public bool IsEnabled { get; init; } = true;
public DateOnly Date { get; init; } = DateOnly.FromDateTime(DateTime.UtcNow);
public DateOnly? EndDate { get; init; }
public string Source { get; init; } = "nager-date";
public string CountryCode { get; init; } = "US";
public DateTimeOffset Created { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -3,6 +3,7 @@ using Jibo.Cloud.Application.Services;
using Jibo.Cloud.Infrastructure.Audio;
using Jibo.Cloud.Infrastructure.Commute;
using Jibo.Cloud.Infrastructure.Content;
using Jibo.Cloud.Infrastructure.Holidays;
using Jibo.Cloud.Infrastructure.Media;
using Jibo.Cloud.Infrastructure.News;
using Jibo.Cloud.Infrastructure.Persistence;
@@ -41,11 +42,17 @@ public static class ServiceCollectionExtensions
if (string.IsNullOrWhiteSpace(newsApiOptions.ApiKey))
newsApiOptions.ApiKey = Environment.GetEnvironmentVariable("NEWSAPI_KEY");
var holidayOptions = new HolidayCalendarOptions();
if (configuration is not null) configuration.GetSection("OpenJibo:Holiday").Bind(holidayOptions);
services.AddSingleton(sttOptions);
services.AddSingleton(openWeatherOptions);
services.AddSingleton(newsApiOptions);
services.AddSingleton(holidayOptions);
services.AddHttpClient<IWeatherReportProvider, OpenWeatherReportProvider>();
services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>();
services.AddSingleton<IHolidayCalendarProvider>(provider =>
new NagerDateHolidayCalendarProvider(provider.GetRequiredService<HolidayCalendarOptions>()));
services.AddSingleton<ICommuteReportProvider, UnavailableCommuteReportProvider>();
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
@@ -75,8 +82,10 @@ public static class ServiceCollectionExtensions
services.AddSingleton<ICloudStateStore>(provider =>
{
var snapshotFactory = provider.GetRequiredService<IPersistenceSnapshotStoreFactory>();
return new InMemoryCloudStateStore(snapshotFactory.Create(statePersistencePath, stateBackendKind,
"cloud-state", stateConnectionString));
var holidayCalendarProvider = provider.GetRequiredService<IHolidayCalendarProvider>();
return new InMemoryCloudStateStore(
snapshotFactory.Create(statePersistencePath, stateBackendKind, "cloud-state", stateConnectionString),
holidayCalendarProvider);
});
services.AddSingleton<IPersonalMemoryStore>(provider =>
{
@@ -117,4 +126,4 @@ public static class ServiceCollectionExtensions
? backendKind
: PersistenceBackendKind.File;
}
}
}

View File

@@ -0,0 +1,6 @@
namespace Jibo.Cloud.Infrastructure.Holidays;
public sealed class HolidayCalendarOptions
{
public string CountryCode { get; set; } = "US";
}

View File

@@ -0,0 +1,208 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
namespace Jibo.Cloud.Infrastructure.Holidays;
public sealed class NagerDateHolidayCalendarProvider : IHolidayCalendarProvider
{
private static readonly HttpClient HttpClient = new();
private static readonly ConcurrentDictionary<string, HolidayRecord[]> Cache = new(StringComparer.OrdinalIgnoreCase);
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly string _countryCode;
public NagerDateHolidayCalendarProvider()
: this(new HolidayCalendarOptions())
{
}
public NagerDateHolidayCalendarProvider(HolidayCalendarOptions options)
{
_countryCode = string.IsNullOrWhiteSpace(options.CountryCode) ? "US" : options.CountryCode.Trim();
}
public IReadOnlyList<HolidayRecord> GetPublicHolidays(string? countryCode, int year)
{
var resolvedCountryCode = string.IsNullOrWhiteSpace(countryCode) ? _countryCode : countryCode.Trim();
var cacheKey = $"{resolvedCountryCode.ToUpperInvariant()}-{year}";
return Cache.GetOrAdd(cacheKey, _ => LoadHolidays(resolvedCountryCode, year));
}
private static HolidayRecord[] LoadHolidays(string countryCode, int year)
{
try
{
var uri = $"https://date.nager.at/api/v3/publicholidays/{year}/{countryCode}";
using var response = HttpClient.GetAsync(uri).GetAwaiter().GetResult();
if (response.IsSuccessStatusCode)
{
using var stream = response.Content.ReadAsStream();
var payload = JsonSerializer.Deserialize<NagerDateHolidayDto[]>(stream, JsonOptions) ?? [];
var records = payload
.Where(item => item.Date.Year == year)
.Select(item => ToHolidayRecord(item, countryCode))
.OrderBy(record => record.Date)
.ThenBy(record => record.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (records.Length > 0) return records;
}
}
catch
{
// Fall back to a small local holiday set so the robot still has something sensible to show.
}
return BuildFallbackHolidays(countryCode, year);
}
private static HolidayRecord ToHolidayRecord(NagerDateHolidayDto dto, string countryCode)
{
var eventId = $"{countryCode.ToUpperInvariant()}-{Slugify(dto.Name)}";
return new HolidayRecord
{
Id = eventId,
EventId = eventId,
Name = dto.Name,
Category = "holiday",
LoopId = string.Empty,
IsEnabled = true,
Date = DateOnly.FromDateTime(dto.Date),
Source = "nager-date",
CountryCode = countryCode.ToUpperInvariant(),
Created = DateTimeOffset.UtcNow
};
}
private static HolidayRecord[] BuildFallbackHolidays(string countryCode, int year)
{
if (!countryCode.Equals("US", StringComparison.OrdinalIgnoreCase))
return [];
var easterSunday = CalculateEasterSunday(year);
var holidays = new List<HolidayRecord>
{
FixedHoliday("New Year's Day", year, 1, 1, countryCode),
ObservedHoliday("Martin Luther King Jr. Day", NthWeekdayOfMonth(year, 1, DayOfWeek.Monday, 3), countryCode),
ObservedHoliday("Presidents Day", NthWeekdayOfMonth(year, 2, DayOfWeek.Monday, 3), countryCode),
ObservedHoliday("Memorial Day", LastWeekdayOfMonth(year, 5, DayOfWeek.Monday), countryCode),
FixedHoliday("Juneteenth", year, 6, 19, countryCode),
FixedHoliday("Independence Day", year, 7, 4, countryCode),
ObservedHoliday("Labor Day", NthWeekdayOfMonth(year, 9, DayOfWeek.Monday, 1), countryCode),
ObservedHoliday("Thanksgiving", NthWeekdayOfMonth(year, 11, DayOfWeek.Thursday, 4), countryCode),
FixedHoliday("Christmas", year, 12, 25, countryCode),
ObservedHoliday("Easter", easterSunday, countryCode),
ObservedHoliday("Good Friday", easterSunday.AddDays(-2), countryCode),
ObservedHoliday("Palm Sunday", easterSunday.AddDays(-7), countryCode),
ObservedHoliday("Ash Wednesday", easterSunday.AddDays(-46), countryCode),
FixedHoliday("Halloween", year, 10, 31, countryCode),
FixedHoliday("Valentine's Day", year, 2, 14, countryCode),
ObservedHoliday("Mother's Day", NthWeekdayOfMonth(year, 5, DayOfWeek.Sunday, 2), countryCode),
ObservedHoliday("Father's Day", NthWeekdayOfMonth(year, 6, DayOfWeek.Sunday, 3), countryCode)
};
return holidays
.OrderBy(holiday => holiday.Date)
.ThenBy(holiday => holiday.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static HolidayRecord FixedHoliday(string name, int year, int month, int day, string countryCode)
{
return CreateHoliday(name, new DateOnly(year, month, day), countryCode);
}
private static HolidayRecord ObservedHoliday(string name, DateOnly date, string countryCode)
{
return CreateHoliday(name, date, countryCode);
}
private static HolidayRecord CreateHoliday(string name, DateOnly date, string countryCode)
{
var eventId = $"{countryCode.ToUpperInvariant()}-{Slugify(name)}";
return new HolidayRecord
{
Id = eventId,
EventId = eventId,
Name = name,
Category = "holiday",
LoopId = string.Empty,
IsEnabled = true,
Date = date,
Source = "fallback",
CountryCode = countryCode.ToUpperInvariant(),
Created = DateTimeOffset.UtcNow
};
}
private static DateOnly NthWeekdayOfMonth(int year, int month, DayOfWeek dayOfWeek, int occurrence)
{
var date = new DateOnly(year, month, 1);
var offset = ((int)dayOfWeek - (int)date.DayOfWeek + 7) % 7;
return date.AddDays(offset + 7 * (occurrence - 1));
}
private static DateOnly LastWeekdayOfMonth(int year, int month, DayOfWeek dayOfWeek)
{
var date = new DateOnly(year, month, DateTime.DaysInMonth(year, month));
var offset = ((int)date.DayOfWeek - (int)dayOfWeek + 7) % 7;
return date.AddDays(-offset);
}
private static DateOnly CalculateEasterSunday(int year)
{
var a = year % 19;
var b = year / 100;
var c = year % 100;
var d = b / 4;
var e = b % 4;
var f = (b + 8) / 25;
var g = (b - f + 1) / 3;
var h = (19 * a + b - d - g + 15) % 30;
var i = c / 4;
var k = c % 4;
var l = (32 + 2 * e + 2 * i - h - k) % 7;
var m = (a + 11 * h + 22 * l) / 451;
var month = (h + l - 7 * m + 114) / 31;
var day = ((h + l - 7 * m + 114) % 31) + 1;
return new DateOnly(year, month, day);
}
private static string Slugify(string value)
{
var builder = new System.Text.StringBuilder(value.Length);
var lastWasDash = false;
foreach (var ch in value.ToLowerInvariant())
{
if (char.IsLetterOrDigit(ch))
{
builder.Append(ch);
lastWasDash = false;
continue;
}
if (!lastWasDash)
{
builder.Append('-');
lastWasDash = true;
}
}
return builder.ToString().Trim('-');
}
private sealed class NagerDateHolidayDto
{
public DateTime Date { get; init; }
public string LocalName { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string CountryCode { get; init; } = string.Empty;
public bool Global { get; init; }
public string[]? Counties { get; init; }
public string[]? Types { get; init; }
}
}

View File

@@ -3,6 +3,7 @@ using System.Text;
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
using Jibo.Cloud.Infrastructure.Holidays;
namespace Jibo.Cloud.Infrastructure.Persistence;
@@ -23,12 +24,14 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
_keyRequests = new(StringComparer.OrdinalIgnoreCase);
private readonly List<LoopRecord> _loops;
private readonly List<HolidayRecord> _holidayOverrides = [];
private readonly List<MediaRecord> _media = [];
private readonly List<PersonRecord> _people;
private readonly ConcurrentDictionary<string, CloudSession>
_sessionsByToken = new(StringComparer.OrdinalIgnoreCase);
private readonly IHolidayCalendarProvider _holidayCalendarProvider;
private readonly ISnapshotStore _snapshotStore;
private readonly ConcurrentDictionary<string, string> _symmetricKeys = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _syncRoot = new();
@@ -47,8 +50,14 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
}
public InMemoryCloudStateStore(ISnapshotStore snapshotStore)
: this(snapshotStore, new NagerDateHolidayCalendarProvider())
{
}
public InMemoryCloudStateStore(ISnapshotStore snapshotStore, IHolidayCalendarProvider holidayCalendarProvider)
{
_snapshotStore = snapshotStore;
_holidayCalendarProvider = holidayCalendarProvider;
_robot = new DeviceRegistration
{
HostMappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
@@ -155,6 +164,9 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
_loops.Clear();
_loops.AddRange(snapshot.Loops ?? []);
_holidayOverrides.Clear();
_holidayOverrides.AddRange(snapshot.Holidays ?? []);
_people.Clear();
_people.AddRange(snapshot.People ?? []);
@@ -201,6 +213,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
Media = _media.ToArray(),
Backups = _backups.ToArray(),
Loops = _loops.ToArray(),
Holidays = _holidayOverrides.ToArray(),
People = _people.ToArray()
};
_snapshotStore.Save(snapshot);
@@ -519,25 +532,50 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
return [];
}
public IReadOnlyList<object> GetHolidays()
public IReadOnlyList<HolidayRecord> GetHolidays(string? loopId = null)
{
return
[
new
var resolvedLoopId = string.IsNullOrWhiteSpace(loopId) ? ResolveDefaultLoopId() : loopId.Trim();
var years = new[] { DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Year + 1 };
var systemHolidays = years
.SelectMany(year => _holidayCalendarProvider.GetPublicHolidays(null, year))
.Where(holiday => holiday.IsEnabled)
.Select(holiday => new HolidayRecord
{
id = "easter-1",
eventId = (string?)null,
name = "Easter",
category = "holiday",
subcategory = (string?)null,
loopId = _loops[0].LoopId,
memberId = (string?)null,
isEnabled = true,
date = "2026-04-05",
endDate = (string?)null,
created = DateTimeOffset.UtcNow.ToString("O")
}
];
Id = holiday.Id,
EventId = holiday.EventId,
Name = holiday.Name,
Category = holiday.Category,
Subcategory = holiday.Subcategory,
LoopId = resolvedLoopId,
MemberId = holiday.MemberId,
IsEnabled = true,
Date = holiday.Date,
EndDate = holiday.EndDate,
Source = holiday.Source,
CountryCode = holiday.CountryCode,
Created = holiday.Created
})
.ToList();
var overrides = _holidayOverrides
.Where(holiday => string.Equals(holiday.LoopId, resolvedLoopId, StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var overrideHoliday in overrides)
{
if (string.IsNullOrWhiteSpace(overrideHoliday.EventId))
continue;
systemHolidays.RemoveAll(systemHoliday =>
string.Equals(systemHoliday.EventId, overrideHoliday.EventId, StringComparison.OrdinalIgnoreCase));
}
return systemHolidays
.Concat(overrides.Where(holiday => holiday.IsEnabled))
.OrderBy(holiday => holiday.Date)
.ThenBy(holiday => holiday.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public void UpdateRobot(DeviceRegistration registration)
@@ -628,6 +666,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public MediaRecord[]? Media { get; init; }
public BackupRecord[]? Backups { get; init; }
public LoopRecord[]? Loops { get; init; }
public HolidayRecord[]? Holidays { get; init; }
public PersonRecord[]? People { get; init; }
}
@@ -673,4 +712,4 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
};
}
}
}
}