diff --git a/frontend/src/stores/timeTracking.test.ts b/frontend/src/stores/timeTracking.test.ts new file mode 100644 index 000000000..62d3c7d09 --- /dev/null +++ b/frontend/src/stores/timeTracking.test.ts @@ -0,0 +1,139 @@ +import {describe, it, expect, beforeEach, vi} from 'vitest' +import {setActivePinia, createPinia} from 'pinia' + +import {useTimeTrackingStore} from './timeTracking' +import type {ITimeEntry} from '@/modelTypes/ITimeEntry' + +const {getAllMock, removeMock, authInfo} = vi.hoisted(() => ({ + getAllMock: vi.fn(), + removeMock: vi.fn(), + authInfo: {value: {id: 7} as {id: number} | null}, +})) + +vi.mock('@/services/timeEntry', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + useTimeEntryService: () => ({ + getAll: getAllMock, + remove: removeMock, + }), + } +}) + +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ + info: authInfo.value, + }), +})) + +function entry(id: number, endTime: Date | null): ITimeEntry { + return { + id, + userId: 1, + taskId: 1, + projectId: 0, + startTime: new Date(), + endTime, + comment: '', + created: new Date(), + updated: new Date(), + maxPermission: null, + } +} + +describe('timeTracking store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + getAllMock.mockReset() + removeMock.mockReset() + authInfo.value = {id: 7} + }) + + it('a running entry becomes the active timer', () => { + const store = useTimeTrackingStore() + store.applyTimerEvent(entry(4, null)) + expect(store.activeTimer?.id).toBe(4) + expect(store.hasActiveTimer).toBe(true) + }) + + it('a stopped entry clears the matching active timer', () => { + const store = useTimeTrackingStore() + store.applyTimerEvent(entry(4, null)) + store.applyTimerEvent(entry(4, new Date())) + expect(store.activeTimer).toBeNull() + }) + + it('a stop for a different timer leaves the active one alone', () => { + const store = useTimeTrackingStore() + store.applyTimerEvent(entry(4, null)) + store.applyTimerEvent(entry(5, new Date())) + expect(store.activeTimer?.id).toBe(4) + }) + + it('patches a stopped entry in the loaded list', () => { + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null), entry(5, null)] + const stopped = entry(4, new Date('2026-01-01T10:00:00Z')) + store.applyTimerEvent(stopped) + expect(store.browsedEntries.find((e: ITimeEntry) => e.id === 4)?.endTime).toEqual(stopped.endTime) + expect(store.browsedEntries).toHaveLength(2) + }) + + it('does not insert an unknown entry into the loaded list', () => { + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null)] + store.applyTimerEvent(entry(9, new Date())) + expect(store.browsedEntries).toHaveLength(1) + expect(store.browsedEntries.find((e: ITimeEntry) => e.id === 9)).toBeUndefined() + }) + + it('hydrates the active timer scoped to the current user', async () => { + getAllMock.mockResolvedValue({items: [entry(4, null)]}) + + const store = useTimeTrackingStore() + await store.hydrateActiveTimer() + + expect(getAllMock).toHaveBeenCalledWith({ + filter: 'user_id = 7 && end_time = null', + perPage: 1, + }) + expect(store.activeTimer?.id).toBe(4) + }) + + it('clears the active timer when deleting the running entry', async () => { + removeMock.mockResolvedValue(undefined) + + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null), entry(5, new Date())] + store.applyTimerEvent(entry(4, null)) + + await store.removeEntry(4) + + expect(removeMock).toHaveBeenCalledWith(4) + expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([5]) + expect(store.activeTimer).toBeNull() + }) + + it('applyTimerDeletion drops the entry and clears the matching active timer', () => { + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null), entry(5, new Date())] + store.applyTimerEvent(entry(4, null)) + + store.applyTimerDeletion(4) + + expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([5]) + expect(store.activeTimer).toBeNull() + }) + + it('applyTimerDeletion of another entry leaves the active timer alone', () => { + const store = useTimeTrackingStore() + store.browsedEntries = [entry(4, null), entry(5, new Date())] + store.applyTimerEvent(entry(4, null)) + + store.applyTimerDeletion(5) + + expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([4]) + expect(store.activeTimer?.id).toBe(4) + }) +}) diff --git a/frontend/src/stores/timeTracking.ts b/frontend/src/stores/timeTracking.ts new file mode 100644 index 000000000..b384c0538 --- /dev/null +++ b/frontend/src/stores/timeTracking.ts @@ -0,0 +1,142 @@ +import {ref, computed} from 'vue' +import {acceptHMRUpdate, defineStore} from 'pinia' + +import {useWebSocket} from '@/composables/useWebSocket' +import {useTimeEntryService, parseTimeEntry} from '@/services/timeEntry' +import {useAuthStore} from '@/stores/auth' + +import type {ITimeEntry} from '@/modelTypes/ITimeEntry' + +export const useTimeTrackingStore = defineStore('timeTracking', () => { + const activeTimer = ref(null) + const browsedEntries = ref([]) + + const hasActiveTimer = computed(() => activeTimer.value !== null) + + async function browseEntries(filter: string) { + const {items} = await useTimeEntryService().getAll({ + filter, + filterTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + perPage: 250, + }) + browsedEntries.value = items + } + + // Drop a deleted entry from the list and clear the active timer if it was it. + // Shared by the local delete and the cross-tab WebSocket "timer.deleted". + function applyTimerDeletion(id: number) { + browsedEntries.value = browsedEntries.value.filter(entry => entry.id !== id) + if (activeTimer.value?.id === id) { + activeTimer.value = null + } + } + + async function removeEntry(id: number) { + await useTimeEntryService().remove(id) + applyTimerDeletion(id) + } + + // Replace an already-loaded entry in place so a stop (or any update) is + // reflected without a refetch. Never inserts — an event for an entry that + // isn't in the current filter shouldn't appear in the list. + function patchInList(entry: ITimeEntry) { + const index = browsedEntries.value.findIndex(existing => existing.id === entry.id) + if (index !== -1) { + browsedEntries.value.splice(index, 1, entry) + } + } + + // Reconcile the active timer from a timer event (WebSocket) or a local + // action: an entry with an end time is a stop — clear it if it's the one we + // track; otherwise it is the running timer. + function applyTimerEvent(entry: ITimeEntry) { + patchInList(entry) + if (entry.endTime !== null) { + if (activeTimer.value?.id === entry.id) { + activeTimer.value = null + } + return + } + activeTimer.value = entry + } + + // Source of truth on (re)connect: the caller's own running timer, if any. + async function hydrateActiveTimer() { + const userId = useAuthStore().info?.id + if (userId === undefined) { + activeTimer.value = null + return + } + + const {items} = await useTimeEntryService().getAll({ + filter: `user_id = ${userId} && end_time = null`, + perPage: 1, + }) + activeTimer.value = items[0] ?? null + } + + // Create any entry (manual, with an end time, or a running timer when end is + // omitted) and reconcile the active timer from the result. + async function createEntry(payload: Partial) { + const entry = await useTimeEntryService().create(payload) + applyTimerEvent(entry) + return entry + } + + async function updateEntry(payload: Partial & {id: number}) { + const entry = await useTimeEntryService().update(payload) + applyTimerEvent(entry) + return entry + } + + async function stopTimer() { + const entry = await useTimeEntryService().stopTimer() + applyTimerEvent(entry) + return entry + } + + let unsubscribers: Array<() => void> = [] + function subscribeToTimerEvents() { + const {subscribe} = useWebSocket() + // Ignore messages without a payload (e.g. subscribe acknowledgements). + const onEvent = (msg: {data?: unknown}) => { + if (msg.data == null) { + return + } + applyTimerEvent(parseTimeEntry(msg.data as Record)) + } + const onDelete = (msg: {data?: unknown}) => { + if (msg.data == null) { + return + } + applyTimerDeletion(parseTimeEntry(msg.data as Record).id) + } + unsubscribers.push(subscribe('timer.created', onEvent)) + unsubscribers.push(subscribe('timer.updated', onEvent)) + unsubscribers.push(subscribe('timer.deleted', onDelete)) + } + function unsubscribeFromTimerEvents() { + unsubscribers.forEach(unsubscribe => unsubscribe()) + unsubscribers = [] + } + + return { + activeTimer, + browsedEntries, + hasActiveTimer, + applyTimerEvent, + applyTimerDeletion, + hydrateActiveTimer, + browseEntries, + createEntry, + updateEntry, + stopTimer, + removeEntry, + subscribeToTimerEvents, + unsubscribeFromTimerEvents, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useTimeTrackingStore, import.meta.hot)) +}