feat(time-tracking): add the time-tracking store
This commit is contained in:
parent
80c21e6f40
commit
43d0203358
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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))
|
||||
}
|
||||
Loading…
Reference in New Issue