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

@@ -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) 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) 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 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) 9. Durable memory persistence path (multi-tenant backing store)
- reference design captured in `docs/persistence-architecture.md` - 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 - store contracts are now tightened around account/loop/device/person scoping, revision tracking, and explicit load/save boundaries

View File

@@ -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

View File

@@ -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 - 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) - 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 - 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 ### 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: Reference design:
- [persistence-architecture.md](persistence-architecture.md) - [persistence-architecture.md](persistence-architecture.md)
- [holiday-architecture.md](holiday-architecture.md)
## First Implemented Slice In `1.0.19` ## First Implemented Slice In `1.0.19`

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 Replace `UseDevelopmentStorage=true` with your real storage account connection string when you move
from local emulation to Azure. 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 ## Recovery Strategy
The first supported device path is: The first supported device path is:

View File

@@ -42,6 +42,6 @@ public interface ICloudStateStore
KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey); KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey);
IReadOnlyList<KeyRequestRecord> GetIncomingKeyRequests(); IReadOnlyList<KeyRequestRecord> GetIncomingKeyRequests();
IReadOnlyList<KeyRequestRecord> GetBinaryRequests(); IReadOnlyList<KeyRequestRecord> GetBinaryRequests();
IReadOnlyList<object> GetHolidays(); IReadOnlyList<HolidayRecord> GetHolidays(string? loopId = null);
void UpdateRobot(DeviceRegistration registration); 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)); return Task.FromResult(HandleKey(operation, envelope));
if (servicePrefix.StartsWith("Person_", StringComparison.OrdinalIgnoreCase)) if (servicePrefix.StartsWith("Person_", StringComparison.OrdinalIgnoreCase))
return Task.FromResult(HandlePerson(operation)); return Task.FromResult(HandlePerson(operation, envelope));
if (servicePrefix.StartsWith("Robot_", StringComparison.OrdinalIgnoreCase)) if (servicePrefix.StartsWith("Robot_", StringComparison.OrdinalIgnoreCase))
return Task.FromResult(HandleRobot(operation, envelope)); 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))); 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) if (!operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase))
? stateStore.GetHolidays() 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) 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) private static object MapMedia(MediaRecord item)
{ {
return new return new
@@ -665,4 +688,4 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore, IMedia
return Task.FromResult<MediaContentSnapshot?>(null); 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.Audio;
using Jibo.Cloud.Infrastructure.Commute; using Jibo.Cloud.Infrastructure.Commute;
using Jibo.Cloud.Infrastructure.Content; using Jibo.Cloud.Infrastructure.Content;
using Jibo.Cloud.Infrastructure.Holidays;
using Jibo.Cloud.Infrastructure.Media; using Jibo.Cloud.Infrastructure.Media;
using Jibo.Cloud.Infrastructure.News; using Jibo.Cloud.Infrastructure.News;
using Jibo.Cloud.Infrastructure.Persistence; using Jibo.Cloud.Infrastructure.Persistence;
@@ -41,11 +42,17 @@ public static class ServiceCollectionExtensions
if (string.IsNullOrWhiteSpace(newsApiOptions.ApiKey)) if (string.IsNullOrWhiteSpace(newsApiOptions.ApiKey))
newsApiOptions.ApiKey = Environment.GetEnvironmentVariable("NEWSAPI_KEY"); 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(sttOptions);
services.AddSingleton(openWeatherOptions); services.AddSingleton(openWeatherOptions);
services.AddSingleton(newsApiOptions); services.AddSingleton(newsApiOptions);
services.AddSingleton(holidayOptions);
services.AddHttpClient<IWeatherReportProvider, OpenWeatherReportProvider>(); services.AddHttpClient<IWeatherReportProvider, OpenWeatherReportProvider>();
services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>(); services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>();
services.AddSingleton<IHolidayCalendarProvider>(provider =>
new NagerDateHolidayCalendarProvider(provider.GetRequiredService<HolidayCalendarOptions>()));
services.AddSingleton<ICommuteReportProvider, UnavailableCommuteReportProvider>(); services.AddSingleton<ICommuteReportProvider, UnavailableCommuteReportProvider>();
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"] var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json"); ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
@@ -75,8 +82,10 @@ public static class ServiceCollectionExtensions
services.AddSingleton<ICloudStateStore>(provider => services.AddSingleton<ICloudStateStore>(provider =>
{ {
var snapshotFactory = provider.GetRequiredService<IPersistenceSnapshotStoreFactory>(); var snapshotFactory = provider.GetRequiredService<IPersistenceSnapshotStoreFactory>();
return new InMemoryCloudStateStore(snapshotFactory.Create(statePersistencePath, stateBackendKind, var holidayCalendarProvider = provider.GetRequiredService<IHolidayCalendarProvider>();
"cloud-state", stateConnectionString)); return new InMemoryCloudStateStore(
snapshotFactory.Create(statePersistencePath, stateBackendKind, "cloud-state", stateConnectionString),
holidayCalendarProvider);
}); });
services.AddSingleton<IPersonalMemoryStore>(provider => services.AddSingleton<IPersonalMemoryStore>(provider =>
{ {
@@ -117,4 +126,4 @@ public static class ServiceCollectionExtensions
? backendKind ? backendKind
: PersistenceBackendKind.File; : 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 System.Text.Json;
using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models; using Jibo.Cloud.Domain.Models;
using Jibo.Cloud.Infrastructure.Holidays;
namespace Jibo.Cloud.Infrastructure.Persistence; namespace Jibo.Cloud.Infrastructure.Persistence;
@@ -23,12 +24,14 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
_keyRequests = new(StringComparer.OrdinalIgnoreCase); _keyRequests = new(StringComparer.OrdinalIgnoreCase);
private readonly List<LoopRecord> _loops; private readonly List<LoopRecord> _loops;
private readonly List<HolidayRecord> _holidayOverrides = [];
private readonly List<MediaRecord> _media = []; private readonly List<MediaRecord> _media = [];
private readonly List<PersonRecord> _people; private readonly List<PersonRecord> _people;
private readonly ConcurrentDictionary<string, CloudSession> private readonly ConcurrentDictionary<string, CloudSession>
_sessionsByToken = new(StringComparer.OrdinalIgnoreCase); _sessionsByToken = new(StringComparer.OrdinalIgnoreCase);
private readonly IHolidayCalendarProvider _holidayCalendarProvider;
private readonly ISnapshotStore _snapshotStore; private readonly ISnapshotStore _snapshotStore;
private readonly ConcurrentDictionary<string, string> _symmetricKeys = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, string> _symmetricKeys = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _syncRoot = new(); private readonly Lock _syncRoot = new();
@@ -47,8 +50,14 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
} }
public InMemoryCloudStateStore(ISnapshotStore snapshotStore) public InMemoryCloudStateStore(ISnapshotStore snapshotStore)
: this(snapshotStore, new NagerDateHolidayCalendarProvider())
{
}
public InMemoryCloudStateStore(ISnapshotStore snapshotStore, IHolidayCalendarProvider holidayCalendarProvider)
{ {
_snapshotStore = snapshotStore; _snapshotStore = snapshotStore;
_holidayCalendarProvider = holidayCalendarProvider;
_robot = new DeviceRegistration _robot = new DeviceRegistration
{ {
HostMappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) HostMappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
@@ -155,6 +164,9 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
_loops.Clear(); _loops.Clear();
_loops.AddRange(snapshot.Loops ?? []); _loops.AddRange(snapshot.Loops ?? []);
_holidayOverrides.Clear();
_holidayOverrides.AddRange(snapshot.Holidays ?? []);
_people.Clear(); _people.Clear();
_people.AddRange(snapshot.People ?? []); _people.AddRange(snapshot.People ?? []);
@@ -201,6 +213,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
Media = _media.ToArray(), Media = _media.ToArray(),
Backups = _backups.ToArray(), Backups = _backups.ToArray(),
Loops = _loops.ToArray(), Loops = _loops.ToArray(),
Holidays = _holidayOverrides.ToArray(),
People = _people.ToArray() People = _people.ToArray()
}; };
_snapshotStore.Save(snapshot); _snapshotStore.Save(snapshot);
@@ -519,25 +532,50 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
return []; return [];
} }
public IReadOnlyList<object> GetHolidays() public IReadOnlyList<HolidayRecord> GetHolidays(string? loopId = null)
{ {
return var resolvedLoopId = string.IsNullOrWhiteSpace(loopId) ? ResolveDefaultLoopId() : loopId.Trim();
[ var years = new[] { DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Year + 1 };
new
var systemHolidays = years
.SelectMany(year => _holidayCalendarProvider.GetPublicHolidays(null, year))
.Where(holiday => holiday.IsEnabled)
.Select(holiday => new HolidayRecord
{ {
id = "easter-1", Id = holiday.Id,
eventId = (string?)null, EventId = holiday.EventId,
name = "Easter", Name = holiday.Name,
category = "holiday", Category = holiday.Category,
subcategory = (string?)null, Subcategory = holiday.Subcategory,
loopId = _loops[0].LoopId, LoopId = resolvedLoopId,
memberId = (string?)null, MemberId = holiday.MemberId,
isEnabled = true, IsEnabled = true,
date = "2026-04-05", Date = holiday.Date,
endDate = (string?)null, EndDate = holiday.EndDate,
created = DateTimeOffset.UtcNow.ToString("O") 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) public void UpdateRobot(DeviceRegistration registration)
@@ -628,6 +666,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public MediaRecord[]? Media { get; init; } public MediaRecord[]? Media { get; init; }
public BackupRecord[]? Backups { get; init; } public BackupRecord[]? Backups { get; init; }
public LoopRecord[]? Loops { get; init; } public LoopRecord[]? Loops { get; init; }
public HolidayRecord[]? Holidays { get; init; }
public PersonRecord[]? People { get; init; } public PersonRecord[]? People { get; init; }
} }
@@ -673,4 +712,4 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
}; };
} }
} }
} }

View File

@@ -81,6 +81,92 @@ public sealed class JiboCloudProtocolServiceTests
Assert.Contains("/upload/log-events", payload.RootElement.GetProperty("uploadUrl").GetString()); 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] [Fact]
public async Task MediaCreateAndGet_ReturnsCreatedItem() public async Task MediaCreateAndGet_ReturnsCreatedItem()
{ {
@@ -327,7 +413,7 @@ public sealed class JiboCloudProtocolServiceTests
}); });
using var payload = JsonDocument.Parse(result.BodyText); using var payload = JsonDocument.Parse(result.BodyText);
Assert.Single(payload.RootElement.EnumerateArray()); Assert.NotEmpty(payload.RootElement.EnumerateArray());
} }
[Fact] [Fact]
@@ -345,4 +431,4 @@ public sealed class JiboCloudProtocolServiceTests
Assert.Contains(people, Assert.Contains(people,
person => string.Equals(person.LoopId, store.GetLoops()[0].LoopId, StringComparison.OrdinalIgnoreCase)); person => string.Equals(person.LoopId, store.GetLoops()[0].LoopId, StringComparison.OrdinalIgnoreCase));
} }
} }