feat(time-tracking) : add TimeEntry DTO, service, timer store, and i18n keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 22:22:40 +01:00
parent 1e07eb1d64
commit cf021d6136
5 changed files with 197 additions and 0 deletions

View File

@@ -63,5 +63,10 @@
"created": "Utilisateur créé avec succès.",
"updated": "Utilisateur mis à jour avec succès.",
"deleted": "Utilisateur supprimé avec succès."
},
"timeEntries": {
"created": "Temps enregistré",
"updated": "Temps modifié",
"deleted": "Temps supprimé"
}
}

View File

@@ -4,6 +4,7 @@ import type { TaskPriority } from './task-priority'
import type { TaskType } from './task-type'
import type { TaskGroup } from './task-group'
import type { UserData } from './user-data'
import type { Project } from './project'
export type Task = {
id: number
@@ -15,6 +16,7 @@ export type Task = {
priority: TaskPriority | null
assignee: UserData | null
group: TaskGroup | null
project: Project | null
types: TaskType[]
}

View File

@@ -0,0 +1,28 @@
import type { UserData } from './user-data'
import type { Project } from './project'
import type { Task } from './task'
import type { TaskType } from './task-type'
export type TimeEntry = {
id: number
'@id'?: string
title: string | null
description: string | null
startedAt: string
stoppedAt: string | null
user: UserData
project: Project | null
task: Task | null
types: TaskType[]
}
export type TimeEntryWrite = {
title?: string | null
description?: string | null
startedAt: string
stoppedAt?: string | null
user: string
project?: string | null
task?: string | null
types?: string[]
}

View File

@@ -0,0 +1,53 @@
import type { TimeEntry, TimeEntryWrite } from './dto/time-entry'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useTimeEntryService() {
const api = useApi()
async function getByDateRange(params: {
after: string
before: string
user?: number
types?: number[]
}): Promise<TimeEntry[]> {
const query: Record<string, unknown> = {
'startedAt[after]': params.after,
'startedAt[before]': params.before,
}
if (params.user) {
query.user = `/api/users/${params.user}`
}
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
return extractHydraMembers(data)
}
async function getActive(): Promise<TimeEntry | null> {
try {
const result = await api.get<TimeEntry | null>('/time_entries/active', {}, { toast: false })
return result ?? null
} catch {
return null
}
}
async function create(payload: TimeEntryWrite): Promise<TimeEntry> {
return api.post<TimeEntry>('/time_entries', payload as Record<string, unknown>, {
toastSuccessKey: 'timeEntries.created',
})
}
async function update(id: number, payload: Partial<TimeEntryWrite>): Promise<TimeEntry> {
return api.patch<TimeEntry>(`/time_entries/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'timeEntries.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/time_entries/${id}`, {}, {
toastSuccessKey: 'timeEntries.deleted',
})
}
return { getByDateRange, getActive, create, update, remove }
}

109
frontend/stores/timer.ts Normal file
View File

@@ -0,0 +1,109 @@
import { defineStore } from 'pinia'
import type { TimeEntry } from '~/services/dto/time-entry'
import type { Task } from '~/services/dto/task'
import { useTimeEntryService } from '~/services/time-entries'
export const useTimerStore = defineStore('timer', () => {
const activeEntry = ref<TimeEntry | null>(null)
const now = ref(Date.now())
let intervalId: ReturnType<typeof setInterval> | null = null
const isRunning = computed(() => activeEntry.value !== null)
const elapsed = computed(() => {
if (!activeEntry.value) return 0
const start = new Date(activeEntry.value.startedAt).getTime()
return Math.floor((now.value - start) / 1000)
})
const elapsedFormatted = computed(() => {
const total = elapsed.value
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
return [h, m, s].map((v) => String(v).padStart(2, '0')).join(':')
})
function startTicking() {
stopTicking()
now.value = Date.now()
intervalId = setInterval(() => {
now.value = Date.now()
}, 1000)
}
function stopTicking() {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
}
async function fetchActive() {
const { getActive } = useTimeEntryService()
activeEntry.value = await getActive()
if (activeEntry.value) {
startTicking()
} else {
stopTicking()
}
}
async function start() {
const authStore = useAuthStore()
if (!authStore.user) return
if (isRunning.value) {
await stop()
}
const { create } = useTimeEntryService()
activeEntry.value = await create({
startedAt: new Date().toISOString(),
user: `/api/users/${authStore.user.id}`,
})
startTicking()
}
async function startFromTask(task: Task) {
const authStore = useAuthStore()
if (!authStore.user) return
if (isRunning.value) {
await stop()
}
const { create } = useTimeEntryService()
activeEntry.value = await create({
startedAt: new Date().toISOString(),
user: `/api/users/${authStore.user.id}`,
title: task.title,
project: task.project?.['@id'] ?? `/api/projects/${task.project?.id}`,
task: task['@id'] ?? `/api/tasks/${task.id}`,
types: task.types.map((t) => t['@id'] ?? `/api/task_types/${t.id}`),
})
startTicking()
}
async function stop() {
if (!activeEntry.value) return
const { update } = useTimeEntryService()
await update(activeEntry.value.id, {
stoppedAt: new Date().toISOString(),
})
activeEntry.value = null
stopTicking()
}
return {
activeEntry,
isRunning,
elapsed,
elapsedFormatted,
fetchActive,
start,
startFromTask,
stop,
}
})