diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index 07cd2a6..33af42a 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -924,6 +924,9 @@ For `1.0.19`: 6. Presence-aware greetings and identity-triggered proactivity - implemented (trigger path, identity-aware reactive/proactive replies, cooldown metadata wiring, focused websocket coverage) 7. Personal report parity track (weather visuals, live news path, commute path, calendar parity matrix) - in progress (`2026-05-10` first live-news provider slice implemented) 8. Holidays and seasonal personality behavior built on the new memory/proactivity foundation + - system holidays should come from an up-to-date provider and merge with loop-scoped custom holiday records + - allow disabled holiday records to suppress reminders for people who do not celebrate a holiday + - birthdays and other personal dates should flow into the same loop-scoped holiday list once authoring is wired up 9. Durable memory persistence path (multi-tenant backing store) - reference design captured in `docs/persistence-architecture.md` - store contracts are now tightened around account/loop/device/person scoping, revision tracking, and explicit load/save boundaries diff --git a/OpenJibo/docs/holiday-architecture.md b/OpenJibo/docs/holiday-architecture.md new file mode 100644 index 0000000..ed84c41 --- /dev/null +++ b/OpenJibo/docs/holiday-architecture.md @@ -0,0 +1,22 @@ +# Holiday Architecture + +Pegasus exposed holidays as a loop-scoped list synchronized into `/jibo/holidays`. + +In OpenJibo, the holiday path now follows the same broad model: + +- system holidays come from a live holiday source +- custom holidays are loop-scoped +- suppressed holidays are represented as disabled records +- the cloud protocol returns the merged list for `PersonListHolidays` + +Current behavior: + +- `Person/ListHolidays` uses the loop from the request when available +- if no loop is supplied, the cloud falls back safely instead of throwing +- the merged list is built from system holidays plus any custom loop entries + +Notes: + +- `IsEnabled = false` can be used to suppress a holiday later +- birthdays and other personal events can be added as loop-scoped custom records +- the current system holiday source uses Nager.Date with a safe local fallback for uptime diff --git a/OpenJibo/docs/release-1.0.19-plan.md b/OpenJibo/docs/release-1.0.19-plan.md index e532b61..530c097 100644 --- a/OpenJibo/docs/release-1.0.19-plan.md +++ b/OpenJibo/docs/release-1.0.19-plan.md @@ -90,6 +90,9 @@ The goal is to port these in small batches, capture the source-backed phrasing w - port holiday-aware personality responses as a visible extension of the new persona slice - start with a small, source-backed set (for example birthdays/holidays already represented in legacy data paths) - ensure holiday responses feel characterful while still routing through stock-compatible payloads +- use a loop-scoped merged holiday list in the cloud protocol so system holidays and custom person holidays can coexist +- source system holidays from a live holiday provider and keep `IsEnabled = false` records available for holiday suppression +- keep birthday/custom holiday authoring aligned with person memory so future proactivity can suppress or promote holidays per loop ### 5. Multi-Tenant Memory Storage Foundation @@ -111,6 +114,7 @@ The goal is to port these in small batches, capture the source-backed phrasing w Reference design: - [persistence-architecture.md](persistence-architecture.md) +- [holiday-architecture.md](holiday-architecture.md) ## First Implemented Slice In `1.0.19` diff --git a/OpenJibo/src/Jibo.Cloud/README.md b/OpenJibo/src/Jibo.Cloud/README.md index 2bea4dd..db43c69 100644 --- a/OpenJibo/src/Jibo.Cloud/README.md +++ b/OpenJibo/src/Jibo.Cloud/README.md @@ -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: diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs index afc994f..09b7b37 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs @@ -42,6 +42,6 @@ public interface ICloudStateStore KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey); IReadOnlyList GetIncomingKeyRequests(); IReadOnlyList GetBinaryRequests(); - IReadOnlyList GetHolidays(); + IReadOnlyList GetHolidays(string? loopId = null); void UpdateRobot(DeviceRegistration registration); -} \ No newline at end of file +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IHolidayCalendarProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IHolidayCalendarProvider.cs new file mode 100644 index 0000000..7b0686f --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IHolidayCalendarProvider.cs @@ -0,0 +1,8 @@ +using Jibo.Cloud.Domain.Models; + +namespace Jibo.Cloud.Application.Abstractions; + +public interface IHolidayCalendarProvider +{ + IReadOnlyList GetPublicHolidays(string? countryCode, int year); +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs index e5f8048..5f9264d 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs @@ -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()); + + 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(null); } } -} \ No newline at end of file +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/HolidayRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/HolidayRecord.cs new file mode 100644 index 0000000..715e30f --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/HolidayRecord.cs @@ -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; +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index 9286873..6330876 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -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(); services.AddHttpClient(); + services.AddSingleton(provider => + new NagerDateHolidayCalendarProvider(provider.GetRequiredService())); services.AddSingleton(); 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(provider => { var snapshotFactory = provider.GetRequiredService(); - return new InMemoryCloudStateStore(snapshotFactory.Create(statePersistencePath, stateBackendKind, - "cloud-state", stateConnectionString)); + var holidayCalendarProvider = provider.GetRequiredService(); + return new InMemoryCloudStateStore( + snapshotFactory.Create(statePersistencePath, stateBackendKind, "cloud-state", stateConnectionString), + holidayCalendarProvider); }); services.AddSingleton(provider => { @@ -117,4 +126,4 @@ public static class ServiceCollectionExtensions ? backendKind : PersistenceBackendKind.File; } -} \ No newline at end of file +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Holidays/HolidayCalendarOptions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Holidays/HolidayCalendarOptions.cs new file mode 100644 index 0000000..294cbd2 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Holidays/HolidayCalendarOptions.cs @@ -0,0 +1,6 @@ +namespace Jibo.Cloud.Infrastructure.Holidays; + +public sealed class HolidayCalendarOptions +{ + public string CountryCode { get; set; } = "US"; +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Holidays/NagerDateHolidayCalendarProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Holidays/NagerDateHolidayCalendarProvider.cs new file mode 100644 index 0000000..31b4b7b --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Holidays/NagerDateHolidayCalendarProvider.cs @@ -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 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 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(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 + { + 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; } + } +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs index dead769..ec4c45e 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs @@ -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 _loops; + private readonly List _holidayOverrides = []; private readonly List _media = []; private readonly List _people; private readonly ConcurrentDictionary _sessionsByToken = new(StringComparer.OrdinalIgnoreCase); + private readonly IHolidayCalendarProvider _holidayCalendarProvider; private readonly ISnapshotStore _snapshotStore; private readonly ConcurrentDictionary _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(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 GetHolidays() + public IReadOnlyList 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 }; } } -} \ No newline at end of file +} diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs index c1542a0..a1ad48a 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs @@ -81,6 +81,92 @@ public sealed class JiboCloudProtocolServiceTests Assert.Contains("/upload/log-events", payload.RootElement.GetProperty("uploadUrl").GetString()); } + [Fact] + public async Task PersonListHolidays_DoesNotThrow_WhenLoopStateIsEmpty() + { + var persistencePath = Path.Combine(Path.GetTempPath(), $"openjibo-empty-holidays-{Guid.NewGuid():N}.json"); + try + { + await File.WriteAllTextAsync(persistencePath, """ + { + "SchemaVersion": "1", + "Revision": 0, + "Loops": [], + "Holidays": [] + } + """); + + var service = new JiboCloudProtocolService(new InMemoryCloudStateStore(persistencePath)); + var result = await service.DispatchAsync(new ProtocolEnvelope + { + HostName = "api.jibo.com", + Method = "POST", + ServicePrefix = "Person_20160715", + Operation = "ListHolidays", + BodyText = "{}" + }); + + using var payload = JsonDocument.Parse(result.BodyText); + Assert.Equal(200, result.StatusCode); + Assert.Equal(JsonValueKind.Array, payload.RootElement.ValueKind); + Assert.NotEmpty(payload.RootElement.EnumerateArray()); + } + finally + { + if (File.Exists(persistencePath)) File.Delete(persistencePath); + } + } + + [Fact] + public async Task PersonListHolidays_MergesPersistedLoopHolidayOverrides() + { + var persistencePath = Path.Combine(Path.GetTempPath(), $"openjibo-loop-holidays-{Guid.NewGuid():N}.json"); + try + { + await File.WriteAllTextAsync(persistencePath, """ + { + "SchemaVersion": "1", + "Revision": 0, + "Loops": [], + "Holidays": [ + { + "Id": "birthday-1", + "EventId": "birthday-1", + "Name": "Jake's Birthday", + "Category": "birthday", + "LoopId": "loop-123", + "MemberId": "person-123", + "IsEnabled": true, + "Date": "2026-05-19", + "Source": "manual", + "CountryCode": "US", + "Created": "2026-05-19T00:00:00Z" + } + ] + } + """); + + var service = new JiboCloudProtocolService(new InMemoryCloudStateStore(persistencePath)); + var result = await service.DispatchAsync(new ProtocolEnvelope + { + HostName = "api.jibo.com", + Method = "POST", + ServicePrefix = "Person_20160715", + Operation = "ListHolidays", + BodyText = """{"loopId":"loop-123"}""" + }); + + using var payload = JsonDocument.Parse(result.BodyText); + Assert.Equal(200, result.StatusCode); + Assert.Contains(payload.RootElement.EnumerateArray(), + item => item.GetProperty("name").GetString() == "Jake's Birthday"); + } + finally + { + if (File.Exists(persistencePath)) File.Delete(persistencePath); + } + } + [Fact] public async Task MediaCreateAndGet_ReturnsCreatedItem() { @@ -327,7 +413,7 @@ public sealed class JiboCloudProtocolServiceTests }); using var payload = JsonDocument.Parse(result.BodyText); - Assert.Single(payload.RootElement.EnumerateArray()); + Assert.NotEmpty(payload.RootElement.EnumerateArray()); } [Fact] @@ -345,4 +431,4 @@ public sealed class JiboCloudProtocolServiceTests Assert.Contains(people, person => string.Equals(person.LoopId, store.GetLoops()[0].LoopId, StringComparison.OrdinalIgnoreCase)); } -} \ No newline at end of file +}