import { ref, type Ref } from 'vue'; import type { Dayjs } from 'dayjs'; import axios from 'axios'; import { useQueryClient } from '@tanstack/vue-query'; import { api, type CreateTimeEntryBody, type TimeEntry, type TimeEntryResponse, } from '@/packages/api/src'; import { getDayJsInstance, getLocalizedDateFromTimestamp, localDateToUtc, } from '@/packages/ui/src/utils/time'; import { getUserTimezone } from '@/packages/ui/src/utils/settings'; import { fetchTimesheetEntries } from '@/utils/useTimesheetQuery'; import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser'; import { makeRowKey, type TimesheetRow } from '@/utils/useTimesheetGrid'; import { useNotificationsStore } from '@/utils/notification'; import { findFreeWindowOnDay, workDayStartOn } from './cellMath'; /** * Implements both variants of "Copy last week": * * - `copyLastWeekRows()` — only add rows for each distinct * (project, task) pair from last week * that doesn't already exist * - `copyLastWeekWithTime()` — same, but also duplicates each * previous-week entry into the same * day-of-week in the current week, * stacking copies after any existing * work on that day */ export function useCopyLastWeek( weekStart: Ref, weekDays: Ref, rows: Ref, timeEntries: Ref, addSlot: ( projectId: string | null, taskId: string | null, billable: boolean, tags: string[], type?: 'work' | 'break' ) => string, breaksEnabled: Ref ) { const dayjs = getDayJsInstance(); const queryClient = useQueryClient(); const { addNotification } = useNotificationsStore(); const isCopyingLastWeek = ref(false); // The server rejects creating break entries while breaks are disabled, // so leave last week's breaks out of the copy in that case function copyableEntries(response: TimeEntryResponse): TimeEntry[] { return breaksEnabled.value ? response.data : response.data.filter((entry) => entry.type !== 'break'); } async function fetchLastWeekEntries(): Promise { const prevStart = weekStart.value.subtract(7, 'day'); const prevEnd = weekStart.value; const orgId = getCurrentOrganizationId(); const memberId = getCurrentMembershipId(); if (!orgId) return null; return await fetchTimesheetEntries( orgId, memberId, localDateToUtc(prevStart), localDateToUtc(prevEnd) ); } /** * For every entry in `prevEntries`, if the current week doesn't * already have a row for that (project, task) combination, add one. * Deduplicates so each combination is added at most once. */ function addMissingRowsFromPreviousWeek(prevEntries: TimeEntry[]): void { const existingIdentities = new Set( rows.value.map((r) => makeRowKey(r.projectId, r.taskId, r.billable, r.tags, r.type)) ); const addedIdentities = new Set(); for (const entry of prevEntries) { const tags = entry.tags ?? []; const identity = makeRowKey( entry.project_id, entry.task_id, entry.billable, tags, entry.type ); if (!existingIdentities.has(identity) && !addedIdentities.has(identity)) { addedIdentities.add(identity); addSlot(entry.project_id, entry.task_id, entry.billable, tags, entry.type); } } } async function copyLastWeekRows(): Promise { isCopyingLastWeek.value = true; try { const prev = await fetchLastWeekEntries(); if (!prev) return; addMissingRowsFromPreviousWeek(copyableEntries(prev)); } finally { isCopyingLastWeek.value = false; } } async function copyLastWeekWithTime(): Promise { isCopyingLastWeek.value = true; try { const prev = await fetchLastWeekEntries(); if (!prev) return; const orgId = getCurrentOrganizationId(); const memberId = getCurrentMembershipId(); if (!orgId || !memberId) return; const tz = getUserTimezone(); const prevEntries = copyableEntries(prev); addMissingRowsFromPreviousWeek(prevEntries); const prevWeekStart = weekStart.value.subtract(7, 'day'); // Working copy of the current week's entries; placed copies // are appended so subsequent placement queries see them as // obstacles (timeEntries.value isn't refreshed until the // queryClient.invalidate at the end of the loop). const workingEntries: TimeEntry[] = [...timeEntries.value]; let attempted = 0; let succeeded = 0; let overlapFailures = 0; let otherFailures = 0; for (const entry of prevEntries) { if (!entry.end || !entry.duration) continue; // Map previous-week date → same day-of-week in current week. const entryDate = getLocalizedDateFromTimestamp(entry.start); const dayOffset = dayjs(entryDate).diff(prevWeekStart, 'day'); const newDate = weekDays.value[dayOffset]; if (!newDate) continue; // Try the source's wall-clock time on the target day first // (preserves "Monday 14:00 meeting" → "Monday 14:00 meeting" // when the slot is free); fall back to 09:00, then to // anywhere on the day. const sourceTimeOfDay = dayjs.utc(entry.start).tz(tz).format('HH:mm:ss'); const sourceStartOnTarget = dayjs .tz(`${newDate} ${sourceTimeOfDay}`, tz) .utc() .format(); const window = findFreeWindowOnDay( workingEntries, newDate, entry.duration, tz, sourceStartOnTarget ) ?? findFreeWindowOnDay( workingEntries, newDate, entry.duration, tz, workDayStartOn(newDate, tz) ) ?? findFreeWindowOnDay(workingEntries, newDate, entry.duration, tz); if (!window) { attempted++; otherFailures++; continue; } const body: CreateTimeEntryBody = { member_id: memberId, project_id: entry.project_id, task_id: entry.task_id, start: window.start, end: window.end, billable: entry.billable, type: entry.type, description: entry.description ?? null, tags: entry.tags ?? [], }; attempted++; try { await api.createTimeEntry(body, { params: { organization: orgId } }); succeeded++; workingEntries.push({ start: window.start, end: window.end, } as TimeEntry); } catch (error) { if ( axios.isAxiosError(error) && error.response?.data?.key === 'overlapping_time_entry' ) { overlapFailures++; } else { otherFailures++; } } } queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); if (attempted === 0) return; if (succeeded === attempted) { addNotification( 'success', `Copied ${succeeded} ${succeeded === 1 ? 'entry' : 'entries'} from last week` ); } else if (succeeded > 0) { const skipped = overlapFailures + otherFailures; const detail = overlapFailures > 0 && otherFailures === 0 ? `${overlapFailures} overlapping` : otherFailures > 0 && overlapFailures === 0 ? `${otherFailures} failed` : `${skipped} skipped`; addNotification( 'error', `Copied ${succeeded} of ${attempted} entries from last week`, `${detail}.` ); } else { addNotification( 'error', 'Failed to copy entries from last week', overlapFailures > 0 && otherFailures === 0 ? 'All entries would overlap with existing time entries.' : 'Please try again later.' ); } } finally { isCopyingLastWeek.value = false; } } return { isCopyingLastWeek, copyLastWeekRows, copyLastWeekWithTime, }; }