diff --git a/frontend/src/lib/forms.ts b/frontend/src/lib/forms.ts deleted file mode 100644 index 365bd8f..0000000 --- a/frontend/src/lib/forms.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function debouncedInput(fn: (input: string) => void, delay: number = 500) { - let timeout: number; - - return (input: string) => { - window.clearTimeout(timeout); - timeout = window.setTimeout(() => { - fn(input); - }, delay); - }; -} diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/frontend/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/lib/watchtower/notifications.svelte.test.ts b/frontend/src/lib/watchtower/notifications.svelte.test.ts new file mode 100644 index 0000000..3b143f4 --- /dev/null +++ b/frontend/src/lib/watchtower/notifications.svelte.test.ts @@ -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); + }); + }); +}); diff --git a/frontend/src/lib/watchtower/notifications.svelte.ts b/frontend/src/lib/watchtower/notifications.svelte.ts index 0305153..0eb201e 100644 --- a/frontend/src/lib/watchtower/notifications.svelte.ts +++ b/frontend/src/lib/watchtower/notifications.svelte.ts @@ -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; } }