Add loop-scoped holiday list support
This commit is contained in:
@@ -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
|
||||||
|
|||||||
22
OpenJibo/docs/holiday-architecture.md
Normal file
22
OpenJibo/docs/holiday-architecture.md
Normal 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
|
||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Jibo.Cloud.Domain.Models;
|
||||||
|
|
||||||
|
namespace Jibo.Cloud.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IHolidayCalendarProvider
|
||||||
|
{
|
||||||
|
IReadOnlyList<HolidayRecord> GetPublicHolidays(string? countryCode, int year);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Jibo.Cloud.Infrastructure.Holidays;
|
||||||
|
|
||||||
|
public sealed class HolidayCalendarOptions
|
||||||
|
{
|
||||||
|
public string CountryCode { get; set; } = "US";
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user