feat(time-tracking): add the time-tracking store

This commit is contained in:
kolaente 2026-06-08 15:16:16 +02:00 committed by kolaente
parent 80c21e6f40
commit 43d0203358
2 changed files with 281 additions and 0 deletions

View File

@ -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<typeof import('@/services/timeEntry')>()
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)
})
})

View File

@ -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<ITimeEntry | null>(null)
const browsedEntries = ref<ITimeEntry[]>([])
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<ITimeEntry>) {
const entry = await useTimeEntryService().create(payload)
applyTimerEvent(entry)
return entry
}
async function updateEntry(payload: Partial<ITimeEntry> & {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<string, unknown>))
}
const onDelete = (msg: {data?: unknown}) => {
if (msg.data == null) {
return
}
applyTimerDeletion(parseTimeEntry(msg.data as Record<string, unknown>).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))
}