Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 0 additions & 10 deletions frontend/src/lib/forms.ts

This file was deleted.

1 change: 0 additions & 1 deletion frontend/src/lib/index.ts

This file was deleted.

94 changes: 94 additions & 0 deletions frontend/src/lib/watchtower/notifications.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as wails from "$lib/wailsjs/go/watchtower/Service";
import { notifications } from "$lib/wailsjs/go/models";
import { NotificationsService } from "$lib/watchtower/notifications.svelte";

describe("NotificationsService", () => {
const spyGetUnread = vi.spyOn(wails, "GetUnreadNotifications");
const spyMarkAsRead = vi.spyOn(wails, "MarkNotificationAsRead");

beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});

describe("getUnread()", () => {
beforeEach(() => {
spyGetUnread.mockResolvedValue([
new notifications.Notification({ id: 1, content: "Test notification" })
]);
});

it("should return unread notifications", async () => {
const notificationSvc = new NotificationsService();
const list = await notificationSvc.getUnread();
expect(list).toHaveLength(1);
expect(spyGetUnread).toHaveBeenCalledTimes(1);
});

it("should have id and content", async () => {
const notificationSvc = new NotificationsService();
const list = await notificationSvc.getUnread();
list.forEach((n) => {
expect(n).toHaveProperty("id");
expect(n).toHaveProperty("content");
});
});

it("should not refetch if not stale", async () => {
const notificationSvc = new NotificationsService();
await notificationSvc.getUnread();
expect(spyGetUnread).toHaveBeenCalledTimes(1);

await notificationSvc.getUnread();
expect(spyGetUnread).toHaveBeenCalledTimes(1);
});

it("should refetch if stale", async () => {
const notificationSvc = new NotificationsService();
await notificationSvc.getUnread();
expect(spyGetUnread).toHaveBeenCalledTimes(1);

vi.setSystemTime(new Date(Date.now() + 3 * 60 * 1000));

await notificationSvc.getUnread();
expect(spyGetUnread).toHaveBeenCalledTimes(2);
});
});

describe("markAsRead()", () => {
it("should call MarkNotificationAsRead and refresh list", async () => {
spyMarkAsRead.mockResolvedValue();
spyGetUnread.mockResolvedValue([]);

const notificationSvc = new NotificationsService();
await notificationSvc.markAsRead(1);

expect(spyMarkAsRead).toHaveBeenCalledWith(1);
});
});

describe("markAllAsRead()", () => {
it("should mark each notification as read and refresh", async () => {
const mockNotifications = [
new notifications.Notification({ id: 1, content: "N1" }),
new notifications.Notification({ id: 2, content: "N2" })
];

spyGetUnread.mockResolvedValueOnce(mockNotifications);
spyMarkAsRead.mockResolvedValue();

const notificationSvc = new NotificationsService();

await notificationSvc.getUnread();

spyGetUnread.mockResolvedValue([]);
await notificationSvc.markAllAsRead();

expect(spyMarkAsRead).toHaveBeenCalledTimes(2);
expect(spyMarkAsRead).toHaveBeenCalledWith(1);
expect(spyMarkAsRead).toHaveBeenCalledWith(2);
expect(spyGetUnread).toHaveBeenCalledTimes(2);
});
});
});
62 changes: 59 additions & 3 deletions frontend/src/lib/watchtower/notifications.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,83 @@
import { notifications } from "$lib/wailsjs/go/models";
import { STALE_TIMEOUT_MINUTES } from "$lib/watchtower/types";
import { GetUnreadNotifications, MarkNotificationAsRead } from "$lib/wailsjs/go/watchtower/Service";

export class NotificationsService {
readonly #notifications: notifications.Notification[];
#lastSync?: number;

constructor() {
this.#notifications = $state([]);
}
/**
* Retrieves the unread notifications for the user.
*/
async getUnread() {
return (await GetUnreadNotifications()) ?? [];
if (this.isStale()) {
await this.forceGetNotifications();
}

return this.#notifications;
}

/**
* Marks a notification as read based on the provided notification ID.
*/
async markAsRead(id: number) {
return await MarkNotificationAsRead(id);
await MarkNotificationAsRead(id);
const idx = this.#notifications.findIndex((n) => n.id === id);
if (idx < 0) {
return;
}

this.#notifications.splice(
idx,
1,
new notifications.Notification({
...this.#notifications[idx],
status: "read"
})
);
}

/**
* Marks all unread notifications as read.
*/
async markAllAsRead() {
const notifications = await this.getUnread();
const notifications = [...(await this.getUnread())];
for (const notification of notifications) {
await this.markAsRead(notification.id);
}

await this.forceGetNotifications();
}

/**
* Forces the retrieval of unread notifications and updates the internal state with the fetched notifications.
*/
private async forceGetNotifications() {
const notifications = (await GetUnreadNotifications()) ?? [];
this.updateInternalNotifications(notifications);
}

/**
* Updates the internal notifications by replacing the current list with the provided notifications array.
*/
private updateInternalNotifications(notifications: notifications.Notification[]) {
this.#notifications.splice(0, this.#notifications.length, ...notifications);
this.#lastSync = Date.now();
}

/**
* Determines whether the current state is considered stale based on the last synchronisation time
* and the presence of notifications.
*/
private isStale() {
if (!this.#lastSync || this.#notifications.length === 0) {
return true;
}

const diff = (Date.now() - this.#lastSync) / (1000 * 60);
return diff > STALE_TIMEOUT_MINUTES;
}
}