Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
461f658
feat: add core event models for PopupText notifications
rgregg Mar 30, 2026
79e3a66
feat: add in-memory event queue with priority ordering and category r…
rgregg Mar 30, 2026
350538f
test: add unit tests for InMemoryFrameEventQueue
rgregg Mar 30, 2026
fb6a689
feat: add EventHostEnabled, EventPollingIntervalSeconds, EventDefault…
rgregg Mar 30, 2026
037d5e4
feat: add events API controller, DTOs, and validator for PopupText no…
rgregg Mar 30, 2026
de050f7
test: add unit tests for FrameEventValidator
rgregg Mar 30, 2026
0a61fc8
feat: add client-side event polling service and config store updates
rgregg Mar 30, 2026
331474e
feat: add PopupTextOverlay and EventOverlayHost Svelte components
rgregg Mar 30, 2026
dbb4bca
feat: wire event polling and PopupText overlay into home page
rgregg Mar 30, 2026
fb3569e
fix: add event settings to test resource files
rgregg Mar 30, 2026
38fca0d
docs: add event notification settings to configuration examples
rgregg Mar 30, 2026
c536bd6
fix: add JsonStringEnumConverter for event mode serialization
rgregg Mar 31, 2026
e05afa0
fix: scope JsonStringEnumConverter to event enums only
rgregg Mar 31, 2026
cf58f32
fix: add event settings to ClientSettingsDto type
rgregg Mar 31, 2026
9320609
fix: start event polling unconditionally in onMount
rgregg Mar 31, 2026
fcf9c15
feat: add countdown timer to PopupText overlay, fix double degree symbol
rgregg Apr 1, 2026
d883bab
docs: add design spec for banner notification mode
rgregg May 18, 2026
e579f11
docs: add implementation plan for banner notifications
rgregg May 18, 2026
1aa71f3
feat(events): add Banner value to FrameEventMode
rgregg May 18, 2026
755f6bc
feat(events): track active event per mode in queue
rgregg May 18, 2026
615ef7b
feat(events): accept Banner mode in validator
rgregg May 18, 2026
8ba2ad2
feat(events): support mode filter on GET /api/events/next
rgregg May 18, 2026
47de34e
feat(events): add Banner overlay and split active stores
rgregg May 18, 2026
0ae6180
docs(events): document Banner mode with curl example
rgregg May 18, 2026
b252760
fix(events): correctness fixes from PR review
rgregg May 18, 2026
25beb1c
fix(events): add bounds validation to polling and timeout settings
rgregg May 18, 2026
f186d50
revert(weather): drop clock.svelte change from this branch
rgregg May 18, 2026
754ae52
fix(events): frontend cleanup and response handling from PR review
rgregg May 18, 2026
472eb5c
docs(events): PR review cleanup
rgregg May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions ImmichFrame.Core.Tests/Events/InMemoryFrameEventQueueTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
using ImmichFrame.Core.Events;
using ImmichFrame.Core.Services;
using NUnit.Framework;

namespace ImmichFrame.Core.Tests.Events;

[TestFixture]
public class InMemoryFrameEventQueueTests
{
private InMemoryFrameEventQueue _queue;

[SetUp]
public void SetUp()
{
_queue = new InMemoryFrameEventQueue();
}

private static FrameEvent MakeEvent(FrameEventMode mode, string id, string deviceId = "device-1", int priority = 0, string? category = null, int? timeoutMs = null)
{
return new FrameEvent
{
Id = id,
DeviceId = deviceId,
Type = "frame.ui.v1",
Mode = mode,
Message = $"Message for {id}",
Priority = priority,
Category = category,
TimeoutMs = timeoutMs,
PostedAt = DateTime.UtcNow
};
}

private static FrameEvent MakeEvent(string id, string deviceId = "device-1", int priority = 0, string? category = null, int? timeoutMs = null)
{
return new FrameEvent
{
Id = id,
DeviceId = deviceId,
Type = "frame.ui.v1",
Mode = FrameEventMode.PopupText,
Message = $"Message for {id}",
Priority = priority,
Category = category,
TimeoutMs = timeoutMs,
PostedAt = DateTime.UtcNow
};
}

[Test]
public async Task PeekNextAsync_ReturnsNull_WhenQueueEmpty()
{
var result = await _queue.PeekNextAsync("device-1");
Assert.That(result, Is.Null);
}

[Test]
public async Task PeekNextAsync_ReturnsHighestPriority()
{
await _queue.EnqueueAsync(MakeEvent("low", priority: 10));
await _queue.EnqueueAsync(MakeEvent("high", priority: 1));

var result = await _queue.PeekNextAsync("device-1");
Assert.That(result, Is.Not.Null);
Assert.That(result!.Id, Is.EqualTo("high"));
}

[Test]
public async Task EnqueueAsync_ReturnsFalse_WhenDuplicateId()
{
var first = await _queue.EnqueueAsync(MakeEvent("dup"));
var second = await _queue.EnqueueAsync(MakeEvent("dup"));

Assert.That(first, Is.True);
Assert.That(second, Is.False);
}

[Test]
public async Task EnqueueAsync_ReplacesCategory()
{
await _queue.EnqueueAsync(MakeEvent("old", category: "alerts"));
await _queue.EnqueueAsync(MakeEvent("new", category: "alerts"));

var result = await _queue.PeekNextAsync("device-1");
Assert.That(result!.Id, Is.EqualTo("new"));

var snapshot = _queue.GetDeviceSnapshot("device-1");
Assert.That(snapshot, Has.Count.EqualTo(1));
}

[Test]
public async Task EnqueueAsync_CloseMode_RemovesMatchingCategory()
{
await _queue.EnqueueAsync(MakeEvent("alert1", category: "alerts"));
await _queue.EnqueueAsync(MakeEvent("other", category: "info"));

var closeEvent = new FrameEvent
{
Id = "close-1",
DeviceId = "device-1",
Type = "frame.ui.v1",
Mode = FrameEventMode.Close,
Category = "alerts",
PostedAt = DateTime.UtcNow
};
await _queue.EnqueueAsync(closeEvent);

var snapshot = _queue.GetDeviceSnapshot("device-1");
Assert.That(snapshot, Has.Count.EqualTo(1));
Assert.That(snapshot[0].Event.Id, Is.EqualTo("other"));
}

[Test]
public async Task EnqueueAsync_CloseModeWithoutCategory_RemovesAll()
{
await _queue.EnqueueAsync(MakeEvent("a"));
await _queue.EnqueueAsync(MakeEvent("b"));

var closeEvent = new FrameEvent
{
Id = "close-all",
DeviceId = "device-1",
Type = "frame.ui.v1",
Mode = FrameEventMode.Close,
PostedAt = DateTime.UtcNow
};
await _queue.EnqueueAsync(closeEvent);

var result = await _queue.PeekNextAsync("device-1");
Assert.That(result, Is.Null);
}

[Test]
public async Task AckAsync_DoesNotRemove_OnShown()
{
await _queue.EnqueueAsync(MakeEvent("evt"));
await _queue.PeekNextAsync("device-1");

var acked = await _queue.AckAsync("device-1", "evt", FrameEventAckStatus.Shown);
Assert.That(acked, Is.True);

var result = await _queue.PeekNextAsync("device-1");
Assert.That(result, Is.Not.Null);
}

[Test]
public async Task AckAsync_Removes_OnClosed()
{
await _queue.EnqueueAsync(MakeEvent("evt"));
await _queue.PeekNextAsync("device-1");

var acked = await _queue.AckAsync("device-1", "evt", FrameEventAckStatus.Closed);
Assert.That(acked, Is.True);

var result = await _queue.PeekNextAsync("device-1");
Assert.That(result, Is.Null);
}

[Test]
public async Task AckAsync_ReturnsFalse_WhenEventNotFound()
{
var result = await _queue.AckAsync("device-1", "nonexistent", FrameEventAckStatus.Closed);
Assert.That(result, Is.False);
}

[Test]
public async Task GetDeviceSnapshot_ReturnsEmpty_ForUnknownDevice()
{
var snapshot = _queue.GetDeviceSnapshot("unknown");
Assert.That(snapshot, Is.Empty);
}

[Test]
public async Task PeekNext_WithModeFilter_ReturnsOnlyMatchingMode()
{
await _queue.EnqueueAsync(MakeEvent(FrameEventMode.PopupText, "popup-1"));
await _queue.EnqueueAsync(MakeEvent(FrameEventMode.Banner, "banner-1"));

var popup = await _queue.PeekNextAsync("device-1", FrameEventMode.PopupText);
var banner = await _queue.PeekNextAsync("device-1", FrameEventMode.Banner);

Assert.That(popup, Is.Not.Null);
Assert.That(popup!.Id, Is.EqualTo("popup-1"));
Assert.That(banner, Is.Not.Null);
Assert.That(banner!.Id, Is.EqualTo("banner-1"));
}

[Test]
public async Task PeekNext_WithModeFilter_ReturnsNullWhenNoMatch()
{
await _queue.EnqueueAsync(MakeEvent(FrameEventMode.PopupText, "popup-1"));

var banner = await _queue.PeekNextAsync("device-1", FrameEventMode.Banner);

Assert.That(banner, Is.Null);
}

[Test]
public async Task PeekNext_AfterAckingPopup_BannerStillReturned()
{
await _queue.EnqueueAsync(MakeEvent(FrameEventMode.PopupText, "popup-1"));
await _queue.EnqueueAsync(MakeEvent(FrameEventMode.Banner, "banner-1"));

await _queue.AckAsync("device-1", "popup-1", FrameEventAckStatus.Closed);

var banner = await _queue.PeekNextAsync("device-1", FrameEventMode.Banner);
Assert.That(banner, Is.Not.Null);
Assert.That(banner!.Id, Is.EqualTo("banner-1"));
}

[Test]
public async Task Enqueue_BannerWithCategory_DoesNotEvictPopupWithSameCategory()
{
await _queue.EnqueueAsync(MakeEvent(FrameEventMode.PopupText, "popup-1", category: "shared"));
await _queue.EnqueueAsync(MakeEvent(FrameEventMode.Banner, "banner-1", category: "shared"));

var popup = await _queue.PeekNextAsync("device-1", FrameEventMode.PopupText);
var banner = await _queue.PeekNextAsync("device-1", FrameEventMode.Banner);

Assert.That(popup, Is.Not.Null);
Assert.That(popup!.Id, Is.EqualTo("popup-1"));
Assert.That(banner, Is.Not.Null);
Assert.That(banner!.Id, Is.EqualTo("banner-1"));
}

[Test]
public async Task Enqueue_BannerWithCategory_ReplacesOlderBannerWithSameCategory()
{
await _queue.EnqueueAsync(MakeEvent(FrameEventMode.Banner, "banner-old", category: "shared"));
await _queue.EnqueueAsync(MakeEvent(FrameEventMode.Banner, "banner-new", category: "shared"));

var snapshot = _queue.GetDeviceSnapshot("device-1");
Assert.That(snapshot.Count, Is.EqualTo(1));
Assert.That(snapshot[0].Event.Id, Is.EqualTo("banner-new"));
}

[Test]
public async Task PeekNext_NoModeFilter_ReturnsHighestPriorityRegardlessOfMode()
{
// The EventEntryComparer sorts ascending by priority (lower numeric value = higher effective priority).
// popup-low gets priority 10 (low effective priority), banner-high gets priority 0 (high effective priority).
await _queue.EnqueueAsync(MakeEvent(FrameEventMode.PopupText, "popup-low", priority: 10));
await _queue.EnqueueAsync(MakeEvent(FrameEventMode.Banner, "banner-high", priority: 0));

var top = await _queue.PeekNextAsync("device-1");

Assert.That(top, Is.Not.Null);
Assert.That(top!.Id, Is.EqualTo("banner-high"));
}
}
22 changes: 22 additions & 0 deletions ImmichFrame.Core/Events/FrameEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Text.Json;

namespace ImmichFrame.Core.Events;

public class FrameEvent
{
public string DeviceId { get; init; } = string.Empty;
public string Id { get; init; } = string.Empty;
public string Type { get; init; } = string.Empty;
public FrameEventMode Mode { get; init; }
public string? Message { get; init; }
public int? TimeoutMs { get; init; }
public int Priority { get; init; }
public string? Category { get; init; }
public string? Title { get; init; }
public IReadOnlyDictionary<string, JsonElement>? Meta { get; init; }
public IReadOnlyList<FrameEventAction> Actions { get; init; } = Array.Empty<FrameEventAction>();
public FrameEventInput Input { get; init; } = new();
public DateTime PostedAt { get; init; }
}
13 changes: 13 additions & 0 deletions ImmichFrame.Core/Events/FrameEventAckStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;

namespace ImmichFrame.Core.Events;

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum FrameEventAckStatus
{
Shown,
Closed,
Timeout,
Error,
Dismissed
}
8 changes: 8 additions & 0 deletions ImmichFrame.Core/Events/FrameEventAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace ImmichFrame.Core.Events;

public class FrameEventAction
{
public string Id { get; init; } = string.Empty;
public string Label { get; init; } = string.Empty;
public string? Kind { get; init; }
}
7 changes: 7 additions & 0 deletions ImmichFrame.Core/Events/FrameEventInput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace ImmichFrame.Core.Events;

public class FrameEventInput
{
public bool AllowTouchDismiss { get; init; } = true;
public bool AllowKeyboardDismiss { get; init; } = true;
}
11 changes: 11 additions & 0 deletions ImmichFrame.Core/Events/FrameEventMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;

namespace ImmichFrame.Core.Events;

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum FrameEventMode
{
PopupText,
Close,
Banner
}
15 changes: 15 additions & 0 deletions ImmichFrame.Core/Interfaces/IFrameEventQueue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ImmichFrame.Core.Events;

namespace ImmichFrame.Core.Interfaces;

public interface IFrameEventQueue
{
Task<bool> EnqueueAsync(FrameEvent frameEvent, CancellationToken cancellationToken = default);
Task<FrameEvent?> PeekNextAsync(string deviceId, FrameEventMode? mode = null, CancellationToken cancellationToken = default);
Task<bool> AckAsync(string deviceId, string eventId, FrameEventAckStatus status, CancellationToken cancellationToken = default);
Task<int> RemoveByCategoryAsync(string deviceId, string category, CancellationToken cancellationToken = default);
IReadOnlyList<(FrameEvent Event, FrameEventAckStatus? LastAckStatus)> GetDeviceSnapshot(string deviceId);
}
3 changes: 3 additions & 0 deletions ImmichFrame.Core/Interfaces/IServerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ public interface IGeneralSettings
public bool PlayAudio { get; }
public string Layout { get; }
public string Language { get; }
public bool EventHostEnabled { get; }
public int EventPollingIntervalSeconds { get; }
public int EventDefaultTimeoutMs { get; }

public void Validate();
}
Expand Down
Loading
Loading