From 80c21e6f409d853728f28c8d14471bca6f70ec9e Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:16:16 +0200 Subject: [PATCH] feat(time-tracking): add the v2 time-entry service --- frontend/src/modelTypes/ITimeEntry.ts | 16 +++++ frontend/src/services/timeEntry.test.ts | 33 +++++++++ frontend/src/services/timeEntry.ts | 91 +++++++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 frontend/src/modelTypes/ITimeEntry.ts create mode 100644 frontend/src/services/timeEntry.test.ts create mode 100644 frontend/src/services/timeEntry.ts diff --git a/frontend/src/modelTypes/ITimeEntry.ts b/frontend/src/modelTypes/ITimeEntry.ts new file mode 100644 index 000000000..249ce2129 --- /dev/null +++ b/frontend/src/modelTypes/ITimeEntry.ts @@ -0,0 +1,16 @@ +import type {IAbstract} from './IAbstract' + +export interface ITimeEntry extends IAbstract { + id: number + userId: number + // Exactly one of taskId / projectId is set (0 means unset). + taskId: number + projectId: number + startTime: Date + // null while the live timer is running. + endTime: Date | null + comment: string + + created: Date + updated: Date +} diff --git a/frontend/src/services/timeEntry.test.ts b/frontend/src/services/timeEntry.test.ts new file mode 100644 index 000000000..faa68bcc8 --- /dev/null +++ b/frontend/src/services/timeEntry.test.ts @@ -0,0 +1,33 @@ +import {describe, it, expect} from 'vitest' + +import {parseTimeEntry} from './timeEntry' + +describe('parseTimeEntry', () => { + it('maps snake_case keys and coerces dates', () => { + const e = parseTimeEntry({ + id: 1, + user_id: 2, + task_id: 3, + project_id: 0, + start_time: '2020-01-01T09:00:00Z', + end_time: '2020-01-01T10:00:00Z', + comment: 'work', + }) + expect(e.userId).toBe(2) + expect(e.taskId).toBe(3) + expect(e.comment).toBe('work') + expect(e.startTime).toBeInstanceOf(Date) + expect(e.endTime).toBeInstanceOf(Date) + }) + + it('treats a null end time as a running timer', () => { + const e = parseTimeEntry({ + id: 1, + user_id: 1, + task_id: 1, + start_time: '2020-01-01T09:00:00Z', + end_time: null, + }) + expect(e.endTime).toBeNull() + }) +}) diff --git a/frontend/src/services/timeEntry.ts b/frontend/src/services/timeEntry.ts new file mode 100644 index 000000000..b76a3d11a --- /dev/null +++ b/frontend/src/services/timeEntry.ts @@ -0,0 +1,91 @@ +import {AuthenticatedHTTPFactory, getApiBaseUrl} from '@/helpers/fetcher' +import {objectToCamelCase, objectToSnakeCase} from '@/helpers/case' + +import type {ITimeEntry} from '@/modelTypes/ITimeEntry' + +// Time tracking is the first frontend feature on /api/v2, while the shared +// AuthenticatedHTTPFactory pins baseURL to /api/v1. We hand axios absolute v2 +// URLs to bypass that. Bespoke and intentionally a bit dirty — to be folded +// into the proper service layer once the frontend moves fully onto v2. +function v2Url(path: string): string { + const v2Base = getApiBaseUrl().replace(/\/api\/v1\/$/, '/api/v2/') + return new URL(v2Base + path, window.location.origin).toString() +} + +export function parseTimeEntry(raw: Record): ITimeEntry { + const e = objectToCamelCase(raw) + const end = e.endTime as string | null | undefined + return { + id: e.id, + userId: e.userId, + taskId: e.taskId ?? 0, + projectId: e.projectId ?? 0, + startTime: new Date(e.startTime), + // null end_time = a running timer. + endTime: end ? new Date(end) : null, + comment: e.comment ?? '', + created: new Date(e.created), + updated: new Date(e.updated), + maxPermission: e.maxPermission ?? null, + } +} + +export interface TimeEntryListParams { + filter?: string + filterTimezone?: string + q?: string + page?: number + perPage?: number +} + +export interface TimeEntryListResult { + items: ITimeEntry[] + total: number + page: number + perPage: number + totalPages: number +} + +export function useTimeEntryService() { + const http = AuthenticatedHTTPFactory() + + async function getAll(params: TimeEntryListParams = {}): Promise { + const {data} = await http.get(v2Url('time-entries'), { + params: { + filter: params.filter, + filter_timezone: params.filterTimezone, + q: params.q, + page: params.page, + per_page: params.perPage, + }, + }) + return { + items: (data.items ?? []).map(parseTimeEntry), + total: data.total, + page: data.page, + perPage: data.per_page, + totalPages: data.total_pages, + } + } + + async function create(entry: Partial): Promise { + const {data} = await http.post(v2Url('time-entries'), objectToSnakeCase(entry)) + return parseTimeEntry(data) + } + + async function update(entry: Partial & {id: number}): Promise { + const {data} = await http.put(v2Url(`time-entries/${entry.id}`), objectToSnakeCase(entry)) + return parseTimeEntry(data) + } + + async function remove(id: number): Promise { + await http.delete(v2Url(`time-entries/${id}`)) + } + + async function stopTimer(): Promise { + const {data} = await http.post(v2Url('time-entries/timer/stop')) + return parseTimeEntry(data) + } + + return {getAll, create, update, remove, stopTimer} +}