mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
import { defineStore } from 'pinia';
|
|
import { computed, ref } from 'vue';
|
|
import { api } from '@/packages/api/src';
|
|
import type { TimeEntry } from '@/packages/api/src';
|
|
import dayjs, { Dayjs } from 'dayjs';
|
|
import utc from 'dayjs/plugin/utc';
|
|
import {
|
|
getCurrentMembershipId,
|
|
getCurrentOrganizationId,
|
|
getCurrentUserId,
|
|
} from '@/utils/useUser';
|
|
import { useLocalStorage } from '@vueuse/core';
|
|
import { useNotificationsStore } from '@/utils/notification';
|
|
import { useQueryClient } from '@tanstack/vue-query';
|
|
|
|
dayjs.extend(utc);
|
|
|
|
const emptyTimeEntry = {
|
|
id: '',
|
|
description: '',
|
|
user_id: '',
|
|
start: '',
|
|
end: null,
|
|
duration: null,
|
|
task_id: null,
|
|
project_id: null,
|
|
tags: [],
|
|
billable: false,
|
|
type: 'work',
|
|
organization_id: '',
|
|
} as TimeEntry;
|
|
|
|
export type ResumeTimeEntryContext = {
|
|
description: string | null;
|
|
project_id: string | null;
|
|
task_id: string | null;
|
|
tags: string[];
|
|
billable: boolean;
|
|
};
|
|
|
|
/**
|
|
* Time entries are loaded newest-first. Ignore scheduled entries so resuming after a break
|
|
* always uses the latest work entry that has actually started.
|
|
*/
|
|
export function getLastWorkTimeEntry(
|
|
timeEntries: TimeEntry[],
|
|
currentTime: Dayjs = dayjs().utc()
|
|
): TimeEntry | null {
|
|
return (
|
|
timeEntries.find(
|
|
(entry) => entry.type === 'work' && !dayjs(entry.start).utc().isAfter(currentTime)
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|
const currentTimeEntry = ref<TimeEntry>({ ...emptyTimeEntry });
|
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
|
const queryClient = useQueryClient();
|
|
|
|
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
|
|
deep: true,
|
|
});
|
|
|
|
function $reset() {
|
|
currentTimeEntry.value = { ...emptyTimeEntry };
|
|
}
|
|
|
|
const now = ref<null | Dayjs>(null);
|
|
const interval = ref<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
function startLiveTimer() {
|
|
stopLiveTimer();
|
|
now.value = dayjs().utc();
|
|
interval.value = setInterval(() => {
|
|
now.value = dayjs().utc();
|
|
}, 1000);
|
|
}
|
|
|
|
function stopLiveTimer() {
|
|
if (interval.value !== null) {
|
|
clearInterval(interval.value);
|
|
}
|
|
}
|
|
|
|
async function fetchCurrentTimeEntry() {
|
|
const organizationId = getCurrentOrganizationId();
|
|
if (organizationId) {
|
|
try {
|
|
const timeEntriesResponse = await api.getMyActiveTimeEntry({});
|
|
if (timeEntriesResponse?.data) {
|
|
if (timeEntriesResponse.data) {
|
|
currentTimeEntry.value = timeEntriesResponse.data;
|
|
if (
|
|
currentTimeEntry.value.start !== '' &&
|
|
currentTimeEntry.value.end === null
|
|
) {
|
|
startLiveTimer();
|
|
}
|
|
} else {
|
|
// No active time entry on server
|
|
// Only reset if we had a previously started timer (has an ID)
|
|
// Don't reset if user is preparing a new time entry (no ID yet)
|
|
if (currentTimeEntry.value.id !== '') {
|
|
currentTimeEntry.value = { ...emptyTimeEntry };
|
|
stopLiveTimer();
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// API error (e.g., 404 when no active time entry)
|
|
// Only reset if we had a previously started timer (has an ID)
|
|
// Don't reset if user is preparing a new time entry (no ID yet)
|
|
if (currentTimeEntry.value.id !== '') {
|
|
currentTimeEntry.value = { ...emptyTimeEntry };
|
|
stopLiveTimer();
|
|
}
|
|
}
|
|
} else {
|
|
throw new Error(
|
|
'Failed to fetch current time entry because organization ID is missing.'
|
|
);
|
|
}
|
|
}
|
|
|
|
async function startTimer() {
|
|
const organization = getCurrentOrganizationId();
|
|
const membership = getCurrentMembershipId();
|
|
if (organization && membership) {
|
|
const startTime =
|
|
currentTimeEntry.value.start !== ''
|
|
? currentTimeEntry.value.start
|
|
: dayjs().utc().format();
|
|
const response = await handleApiRequestNotifications(
|
|
() =>
|
|
api.createTimeEntry(
|
|
{
|
|
member_id: membership,
|
|
start: startTime,
|
|
description: currentTimeEntry.value?.description,
|
|
project_id: currentTimeEntry.value?.project_id,
|
|
task_id: currentTimeEntry.value?.task_id,
|
|
billable: currentTimeEntry.value.billable,
|
|
type: currentTimeEntry.value?.type ?? 'work',
|
|
tags: currentTimeEntry.value?.tags,
|
|
},
|
|
{ params: { organization: organization } }
|
|
),
|
|
'Timer started!'
|
|
);
|
|
if (response?.data) {
|
|
currentTimeEntry.value = response.data;
|
|
}
|
|
} else {
|
|
throw new Error(
|
|
'Failed to fetch current time entry because organization ID is missing.'
|
|
);
|
|
}
|
|
}
|
|
|
|
async function stopTimer(endTime?: string) {
|
|
const user = getCurrentUserId();
|
|
const organization = getCurrentOrganizationId();
|
|
if (organization) {
|
|
const currentDateTime = endTime ?? dayjs().utc().format();
|
|
await handleApiRequestNotifications(
|
|
() =>
|
|
api.updateTimeEntry(
|
|
{
|
|
user_id: user,
|
|
start: currentTimeEntry.value.start,
|
|
end: currentDateTime,
|
|
},
|
|
{
|
|
params: {
|
|
organization: organization,
|
|
timeEntry: currentTimeEntry.value.id,
|
|
},
|
|
}
|
|
),
|
|
'Timer stopped!'
|
|
);
|
|
$reset();
|
|
} else {
|
|
throw new Error('Failed to stop current timer because organization ID is missing.');
|
|
}
|
|
}
|
|
|
|
async function startBreak() {
|
|
const organization = getCurrentOrganizationId();
|
|
const membership = getCurrentMembershipId();
|
|
if (!organization || !membership) {
|
|
throw new Error('Failed to start break because organization ID is missing.');
|
|
}
|
|
// One timestamp for both the work end and the break start, so the entries touch exactly
|
|
const switchTime = dayjs().utc().format();
|
|
if (isActive.value && currentTimeEntry.value.type !== 'break') {
|
|
await stopTimer(switchTime);
|
|
}
|
|
startLiveTimer();
|
|
const response = await handleApiRequestNotifications(
|
|
() =>
|
|
api.createTimeEntry(
|
|
{
|
|
member_id: membership,
|
|
start: switchTime,
|
|
billable: false,
|
|
type: 'break',
|
|
},
|
|
{ params: { organization: organization } }
|
|
),
|
|
'Break started!',
|
|
'Your timer was stopped, but the break could not be started.'
|
|
);
|
|
if (response?.data) {
|
|
currentTimeEntry.value = response.data;
|
|
} else {
|
|
stopLiveTimer();
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
|
}
|
|
|
|
async function resumeWorkAfterBreak(context: ResumeTimeEntryContext) {
|
|
if (isActive.value && currentTimeEntry.value.type === 'break') {
|
|
stopLiveTimer();
|
|
await stopTimer();
|
|
}
|
|
currentTimeEntry.value = {
|
|
...emptyTimeEntry,
|
|
description: context.description ?? '',
|
|
project_id: context.project_id,
|
|
task_id: context.task_id,
|
|
tags: context.tags ?? [],
|
|
billable: context.billable,
|
|
};
|
|
startLiveTimer();
|
|
await startTimer();
|
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
|
}
|
|
|
|
async function updateTimer() {
|
|
const user = getCurrentUserId();
|
|
const organization = getCurrentOrganizationId();
|
|
if (organization) {
|
|
const response = await handleApiRequestNotifications(
|
|
() =>
|
|
api.updateTimeEntry(
|
|
{
|
|
description: currentTimeEntry.value.description,
|
|
user_id: user,
|
|
project_id: currentTimeEntry.value.project_id,
|
|
task_id: currentTimeEntry.value.task_id,
|
|
start: currentTimeEntry.value.start,
|
|
billable: currentTimeEntry.value.billable,
|
|
end: currentTimeEntry.value.end,
|
|
tags: currentTimeEntry.value.tags,
|
|
},
|
|
{
|
|
params: {
|
|
organization: organization,
|
|
timeEntry: currentTimeEntry.value.id,
|
|
},
|
|
}
|
|
),
|
|
'Time entry updated!'
|
|
);
|
|
if (response?.data) {
|
|
if (response.data.end === null) {
|
|
currentTimeEntry.value = response.data;
|
|
} else {
|
|
$reset();
|
|
stopLiveTimer();
|
|
}
|
|
}
|
|
} else {
|
|
throw new Error(
|
|
'Failed to fetch current time entry because organization ID is missing.'
|
|
);
|
|
}
|
|
}
|
|
|
|
const isActive = computed(() => {
|
|
if (currentTimeEntry.value) {
|
|
return (
|
|
currentTimeEntry.value.start !== '' &&
|
|
currentTimeEntry.value.start !== null &&
|
|
currentTimeEntry.value.end === null
|
|
);
|
|
}
|
|
return false;
|
|
});
|
|
|
|
const isOnBreak = computed(() => {
|
|
return isActive.value && currentTimeEntry.value.type === 'break';
|
|
});
|
|
|
|
async function setActiveState(newState: boolean) {
|
|
if (newState) {
|
|
startLiveTimer();
|
|
await startTimer();
|
|
} else {
|
|
stopLiveTimer();
|
|
await stopTimer();
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
|
}
|
|
|
|
return {
|
|
currentTimeEntry,
|
|
fetchCurrentTimeEntry,
|
|
updateTimer,
|
|
isActive,
|
|
isOnBreak,
|
|
startBreak,
|
|
resumeWorkAfterBreak,
|
|
startLiveTimer,
|
|
stopLiveTimer,
|
|
now,
|
|
setActiveState,
|
|
$reset,
|
|
};
|
|
});
|