diff --git a/resources/js/Components/Timesheet/RemoveRowDialog.vue b/resources/js/Components/Timesheet/RemoveRowDialog.vue
new file mode 100644
index 00000000..65623f26
--- /dev/null
+++ b/resources/js/Components/Timesheet/RemoveRowDialog.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ Remove timesheet row?
+
+ This will delete {{ entryCount }} time
+ {{ entryCount === 1 ? 'entry' : 'entries' }}
+ for "{{ projectName }}". This action cannot be undone.
+
+
+
+ Cancel
+
+ Delete
+
+
+
+
+
diff --git a/resources/js/Components/Timesheet/TimesheetCell.vue b/resources/js/Components/Timesheet/TimesheetCell.vue
new file mode 100644
index 00000000..31556672
--- /dev/null
+++ b/resources/js/Components/Timesheet/TimesheetCell.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Stop the running time entry to edit the timesheet
+
+
+
+ emit('update', seconds ?? 0)" />
+
+
diff --git a/resources/js/Components/Timesheet/TimesheetFooterActions.vue b/resources/js/Components/Timesheet/TimesheetFooterActions.vue
new file mode 100644
index 00000000..b5a73cf2
--- /dev/null
+++ b/resources/js/Components/Timesheet/TimesheetFooterActions.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+ Copy rows only
+
+
+
+ Copy rows and time entries
+
+
+
+
+
diff --git a/resources/js/Components/Timesheet/TimesheetGrid.vue b/resources/js/Components/Timesheet/TimesheetGrid.vue
new file mode 100644
index 00000000..df54539a
--- /dev/null
+++ b/resources/js/Components/Timesheet/TimesheetGrid.vue
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+
+ Project
+
+
+
+ {{ dayjs(day).format('ddd D') }}
+
+
+
+ Total
+
+
+
+
+
$emit('cell-update', row, dayIndex, seconds)
+ "
+ @project-task-change="(pId, tId) => $emit('project-task-change', row, pId, tId)"
+ @billable-change="(billable) => $emit('billable-change', row, billable)"
+ @tags-change="(t) => $emit('tags-change', row, t)" />
+
+
+
+
emit('add-row', p, t)">
+
+
+
+
+
+
+
+
+ Total
+
+
+
+ {{ total > 0 ? formatDuration(total) : '-' }}
+
+
+
+ {{ weekTotalFormatted }}
+
+
+
+
+
+
diff --git a/resources/js/Components/Timesheet/TimesheetHeader.vue b/resources/js/Components/Timesheet/TimesheetHeader.vue
new file mode 100644
index 00000000..8ce79740
--- /dev/null
+++ b/resources/js/Components/Timesheet/TimesheetHeader.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Week Total
+
+ {{ weekTotalFormatted }}
+
+
+
+
diff --git a/resources/js/Components/Timesheet/TimesheetRow.vue b/resources/js/Components/Timesheet/TimesheetRow.vue
new file mode 100644
index 00000000..26ceb7ec
--- /dev/null
+++ b/resources/js/Components/Timesheet/TimesheetRow.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
emit('cellUpdate', dayIndex, seconds)" />
+
+
+
+ {{ rowTotalFormatted }}
+
+
+
+
+
+
+
+
diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue
index da7135b8..4f878468 100644
--- a/resources/js/Layouts/AppLayout.vue
+++ b/resources/js/Layouts/AppLayout.vue
@@ -17,6 +17,7 @@ import {
UserGroupIcon,
XMarkIcon,
DocumentTextIcon,
+ TableCellsIcon,
} from '@heroicons/vue/20/solid';
import { PanelLeft } from 'lucide-vue-next';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
@@ -135,7 +136,7 @@ const page = usePage<{
? 'max-lg:translate-x-0 max-lg:shadow-xl'
: 'max-lg:-translate-x-full',
]"
- class="flex-shrink-0 h-screen fixed w-[280px] px-2.5 py-4 hidden lg:flex flex-col justify-between bg-background border-r border-default-background-separator max-lg:z-50 max-lg:transition-transform max-lg:duration-200 max-lg:ease-in-out lg:w-[230px] 2xl:w-[250px] 2xl:px-3 lg:border-r-0"
+ class="flex-shrink-0 h-screen fixed w-[280px] px-2.5 py-4 hidden lg:flex flex-col justify-between bg-background border-r border-default-background-separator max-lg:z-50 max-lg:transition-transform max-lg:duration-200 max-lg:ease-in-out lg:w-[230px] lg:border-r-0"
:style="showSidebarMenu ? { display: 'flex' } : undefined">
-
+
(undefined);
-const calendarEnd = ref
(undefined);
+const calendarStart = ref(undefined);
+const calendarEnd = ref(undefined);
// Test-injectable activity periods (for E2E testing).
// These hooks are no-ops in production — they only take effect when test code
@@ -99,7 +100,7 @@ const { tags } = useTagsQuery();
const queryClient = useQueryClient();
-function onDatesChange({ start, end }: { start: Date; end: Date }) {
+function onDatesChange({ start, end }: { start: Dayjs; end: Dayjs }) {
calendarStart.value = start;
calendarEnd.value = end;
}
diff --git a/resources/js/Pages/Timesheet.vue b/resources/js/Pages/Timesheet.vue
new file mode 100644
index 00000000..5a9e6c62
--- /dev/null
+++ b/resources/js/Pages/Timesheet.vue
@@ -0,0 +1,199 @@
+
+
+
+
+
+
+
+
+ handleRowIdentityChange(row, { projectId, taskId })
+ "
+ @billable-change="
+ (row, billable) => handleRowIdentityChange(row, { billable })
+ "
+ @tags-change="(row, tags) => handleRowIdentityChange(row, { tags })"
+ @add-row="handleAddRow" />
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue
index df5f212a..a7e9cf5d 100644
--- a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue
+++ b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue
@@ -57,7 +57,7 @@ import type {
import type { Dayjs } from 'dayjs';
const emit = defineEmits<{
- (e: 'dates-change', payload: { start: Date; end: Date }): void;
+ (e: 'dates-change', payload: { start: Dayjs; end: Dayjs }): void;
(e: 'refresh'): void;
}>();
diff --git a/resources/js/packages/ui/src/FullCalendar/useCalendarNavigation.ts b/resources/js/packages/ui/src/FullCalendar/useCalendarNavigation.ts
index a1fd79b5..af16d54c 100644
--- a/resources/js/packages/ui/src/FullCalendar/useCalendarNavigation.ts
+++ b/resources/js/packages/ui/src/FullCalendar/useCalendarNavigation.ts
@@ -1,27 +1,17 @@
import { computed, ref } from 'vue';
import type { Dayjs } from 'dayjs';
import { getLocalizedDayJs } from '../utils/time';
-import { getWeekStart } from '../utils/settings';
+import { getWeekStartDayNumber } from '../utils/settings';
export function useCalendarNavigation(callbacks: {
- onDatesChange: (payload: { start: Date; end: Date }) => void;
+ onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
scrollToCurrentTime: () => void;
}) {
const activeView = ref('timeGridWeek');
const currentDate = ref(getLocalizedDayJs());
function getFirstDay(): number {
- const weekStart = getWeekStart();
- const weekStartMap: Record = {
- sunday: 0,
- monday: 1,
- tuesday: 2,
- wednesday: 3,
- thursday: 4,
- friday: 5,
- saturday: 6,
- };
- return weekStartMap[weekStart] ?? 1;
+ return getWeekStartDayNumber();
}
const viewDays = computed(() => {
@@ -67,8 +57,8 @@ export function useCalendarNavigation(callbacks: {
const days = viewDays.value;
if (days.length === 0) return;
- const start = days[0]!.toDate();
- const end = days[days.length - 1]!.add(1, 'day').toDate();
+ const start = days[0]!;
+ const end = days[days.length - 1]!.add(1, 'day');
callbacks.onDatesChange({ start, end });
}
diff --git a/resources/js/packages/ui/src/Input/DurationSecondsInput.vue b/resources/js/packages/ui/src/Input/DurationSecondsInput.vue
new file mode 100644
index 00000000..4042f00b
--- /dev/null
+++ b/resources/js/packages/ui/src/Input/DurationSecondsInput.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
diff --git a/resources/js/packages/ui/src/Input/EstimatedTimeInput.vue b/resources/js/packages/ui/src/Input/EstimatedTimeInput.vue
index cf927843..ff11e88c 100644
--- a/resources/js/packages/ui/src/Input/EstimatedTimeInput.vue
+++ b/resources/js/packages/ui/src/Input/EstimatedTimeInput.vue
@@ -1,12 +1,6 @@
-
+ default-unit="hours"
+ @submit="emit('submit')" />
-
-
diff --git a/resources/js/packages/ui/src/Input/TextInput.vue b/resources/js/packages/ui/src/Input/TextInput.vue
index 33669f71..3c767191 100644
--- a/resources/js/packages/ui/src/Input/TextInput.vue
+++ b/resources/js/packages/ui/src/Input/TextInput.vue
@@ -1,11 +1,15 @@
@@ -25,7 +33,8 @@ const model = defineModel();
v-model="model"
:class="
twMerge(
- 'h-9 px-3 py-1 text-base sm:text-sm border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent rounded-md shadow-sm',
+ 'border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent rounded-md shadow-sm',
+ sizeClasses,
props.class
)
"
diff --git a/resources/js/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue b/resources/js/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue
index 6ffac214..7e1e2f46 100644
--- a/resources/js/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue
+++ b/resources/js/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue
@@ -519,29 +519,33 @@ const showCreateProject = ref(false);
-
-
-
-
+
+
+
+
+
+
diff --git a/resources/js/packages/ui/src/utils/settings.ts b/resources/js/packages/ui/src/utils/settings.ts
index ce5eabc4..a6543d17 100644
--- a/resources/js/packages/ui/src/utils/settings.ts
+++ b/resources/js/packages/ui/src/utils/settings.ts
@@ -8,6 +8,20 @@ export function getWeekStart() {
}
return weekStart;
}
+
+const weekStartMap: Record = {
+ sunday: 0,
+ monday: 1,
+ tuesday: 2,
+ wednesday: 3,
+ thursday: 4,
+ friday: 5,
+ saturday: 6,
+};
+
+export function getWeekStartDayNumber(): number {
+ return weekStartMap[getWeekStart()] ?? 1;
+}
export function getUserTimezone() {
const timezone = window?.getTimezoneSetting() as string;
if (!timezone) {
diff --git a/resources/js/packages/ui/src/utils/time.ts b/resources/js/packages/ui/src/utils/time.ts
index 3c322fec..d1370dce 100644
--- a/resources/js/packages/ui/src/utils/time.ts
+++ b/resources/js/packages/ui/src/utils/time.ts
@@ -188,6 +188,15 @@ export function getLocalizedDateFromTimestamp(timestamp: string) {
return getLocalizedDayJs(timestamp).format('YYYY-MM-DD');
}
+/**
+ * Converts a local Date to a UTC-formatted ISO string.
+ * Treats the Date as being in the user's timezone and converts to UTC.
+ * This is the inverse of getLocalizedDayJs (which goes UTC → local).
+ */
+export function localDateToUtc(date: dayjs.Dayjs): string {
+ return date.tz(getUserTimezone(), true).utc().format();
+}
+
/*
* Returns a formatted date.
* @param date - date in the format of 'YYYY-MM-DD'
diff --git a/resources/js/packages/ui/tailwind.theme.js b/resources/js/packages/ui/tailwind.theme.js
index 6681b1fc..76ee9148 100644
--- a/resources/js/packages/ui/tailwind.theme.js
+++ b/resources/js/packages/ui/tailwind.theme.js
@@ -104,7 +104,7 @@ export const solidtimeTheme = {
border: 'var(--popover-border)',
},
destructive: {
- DEFAULT: 'var(--destructive)',
+ DEFAULT: 'hsl(var(--destructive))',
foreground: 'var(--destructive-foreground)',
},
border: 'var(--border)',
diff --git a/resources/js/utils/notification.ts b/resources/js/utils/notification.ts
index fee4a9b1..97e78380 100644
--- a/resources/js/utils/notification.ts
+++ b/resources/js/utils/notification.ts
@@ -57,6 +57,13 @@ export const useNotificationsStore = defineStore('notifications', () => {
'organization_has_no_subscription_but_multiple_members'
) {
showActionBlockedModal.value = true;
+ } else if (error?.response?.data?.key === 'overlapping_time_entry') {
+ addNotification(
+ 'error',
+ 'Overlapping time entries are not allowed',
+ error.response?.data?.message ??
+ 'This change would overlap with an existing time entry.'
+ );
} else {
addNotification(
'error',
diff --git a/resources/js/utils/timesheet/cellMath.ts b/resources/js/utils/timesheet/cellMath.ts
new file mode 100644
index 00000000..f8a04aed
--- /dev/null
+++ b/resources/js/utils/timesheet/cellMath.ts
@@ -0,0 +1,202 @@
+import { type Dayjs } from 'dayjs';
+import type { TimeEntry } from '@/packages/api/src';
+import { getDayJsInstance } from '@/packages/ui/src/utils/time';
+
+// `getDayJsInstance()` reads window-injected settings (week-start), which
+// aren't available at module load. Each function calls it lazily at use
+// time. The cost is a per-call locale update; cellMath doesn't use any
+// week-start-aware APIs so it's a no-op functionally.
+
+/**
+ * UTC ISO of 09:00 local on `date` — the preferred placement for new
+ * work when an empty day needs a default start time.
+ */
+export function workDayStartOn(date: string, tz: string): string {
+ const dayjs = getDayJsInstance();
+ return dayjs.tz(`${date} 09:00:00`, tz).utc().format();
+}
+
+export interface FreeWindow {
+ start: string;
+ end: string;
+}
+
+interface Interval {
+ start: Dayjs;
+ end: Dayjs;
+}
+
+/**
+ * Collect entries that intersect the day `[dayStart, dayEnd)`, clipped
+ * to those bounds. Running entries use `nowDayjs` as their end.
+ */
+function collectDayObstacles(
+ entries: TimeEntry[],
+ dayStart: Dayjs,
+ dayEnd: Dayjs,
+ nowDayjs: Dayjs
+): Interval[] {
+ const dayjs = getDayJsInstance();
+ const obstacles: Interval[] = [];
+ for (const entry of entries) {
+ const entryStart = dayjs.utc(entry.start);
+ const entryEnd = entry.end ? dayjs.utc(entry.end) : nowDayjs;
+
+ if (entryEnd.isSameOrBefore(dayStart)) continue;
+ if (entryStart.isSameOrAfter(dayEnd)) continue;
+
+ const clippedStart = entryStart.isBefore(dayStart) ? dayStart : entryStart;
+ const clippedEnd = entryEnd.isAfter(dayEnd) ? dayEnd : entryEnd;
+
+ obstacles.push({ start: clippedStart, end: clippedEnd });
+ }
+ return obstacles;
+}
+
+/**
+ * First free window on the local calendar day that fits `requiredSeconds`
+ * without colliding with any existing entry. Returns `null` if nothing fits
+ * — never crosses midnight.
+ *
+ * Obstacles include same-day entries, spillovers from adjacent days, and
+ * running entries (treated as `end = now`). All are clipped to the day's
+ * `[00:00, 24:00)` boundaries.
+ *
+ * `preferredStart` (UTC ISO) is a hard floor — windows with `start` before
+ * it are rejected. Use it to place "after some cursor."
+ */
+export function findFreeWindowOnDay(
+ entries: TimeEntry[],
+ date: string,
+ requiredSeconds: number,
+ tz: string,
+ preferredStart?: string | null,
+ now?: string | Dayjs
+): FreeWindow | null {
+ if (requiredSeconds <= 0) return null;
+
+ const dayjs = getDayJsInstance();
+ const dayStart = dayjs.tz(`${date} 00:00:00`, tz).utc();
+ const dayEnd = dayStart.add(1, 'day');
+
+ if (requiredSeconds > dayEnd.diff(dayStart, 'second')) return null;
+
+ const nowDayjs = now ? dayjs.utc(now) : dayjs.utc();
+
+ const obstacles = collectDayObstacles(entries, dayStart, dayEnd, nowDayjs);
+
+ // Sort + merge so we can walk a clean [gap, obstacle, gap, ...] sequence.
+ obstacles.sort((a, b) => a.start.diff(b.start));
+
+
+ // merge overlaps
+ const merged: Interval[] = [];
+ for (const obs of obstacles) {
+ const last = merged[merged.length - 1];
+ if (last && obs.start.isSameOrBefore(last.end)) {
+ if (obs.end.isAfter(last.end)) {
+ last.end = obs.end;
+ }
+ } else {
+ merged.push({ start: obs.start, end: obs.end });
+ }
+ }
+
+ let cursor: Dayjs = dayStart;
+ if (preferredStart) {
+ const pref = dayjs.utc(preferredStart);
+ if (pref.isAfter(cursor)) cursor = pref;
+ }
+ if (cursor.isSameOrAfter(dayEnd)) return null;
+
+ for (const obs of merged) {
+ if (obs.end.isSameOrBefore(cursor)) continue;
+
+ if (obs.start.isAfter(cursor)) {
+ const gapSeconds = obs.start.diff(cursor, 'second');
+ if (gapSeconds >= requiredSeconds) {
+ return {
+ start: cursor.format(),
+ end: cursor.add(requiredSeconds, 'second').format(),
+ };
+ }
+ }
+
+ if (obs.end.isAfter(cursor)) cursor = obs.end;
+ if (cursor.isSameOrAfter(dayEnd)) return null;
+ }
+
+ const trailingSeconds = dayEnd.diff(cursor, 'second');
+ if (trailingSeconds >= requiredSeconds) {
+ return {
+ start: cursor.format(),
+ end: cursor.add(requiredSeconds, 'second').format(),
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Seconds of free space starting at `cursor` until the next obstacle
+ * (or end of day). Returns 0 if the cursor is inside an obstacle or past
+ * midnight. Used by the extend path: "how far can I push this end forward?"
+ */
+export function freeGapSecondsAfter(
+ entries: TimeEntry[],
+ date: string,
+ tz: string,
+ cursor: string,
+ now?: string | Dayjs
+): number {
+ const dayjs = getDayJsInstance();
+ const dayStart = dayjs.tz(`${date} 00:00:00`, tz).utc();
+ const dayEnd = dayStart.add(1, 'day');
+ const cursorDjs = dayjs.utc(cursor);
+
+ if (cursorDjs.isSameOrAfter(dayEnd)) return 0;
+ if (cursorDjs.isBefore(dayStart)) return 0;
+
+ const nowDayjs = now ? dayjs.utc(now) : dayjs.utc();
+
+ // Drop obstacles ending at/before the cursor — they're behind us.
+ const obstacles = collectDayObstacles(entries, dayStart, dayEnd, nowDayjs).filter((obs) =>
+ obs.end.isAfter(cursorDjs)
+ );
+
+ obstacles.sort((a, b) => a.start.diff(b.start));
+
+ // Cursor inside an obstacle → no gap.
+ for (const obs of obstacles) {
+ if (obs.start.isSameOrBefore(cursorDjs) && obs.end.isAfter(cursorDjs)) {
+ return 0;
+ }
+ }
+
+ // Distance to first obstacle strictly after cursor, or to end of day.
+ for (const obs of obstacles) {
+ if (obs.start.isAfter(cursorDjs)) {
+ return Math.max(0, obs.start.diff(cursorDjs, 'second'));
+ }
+ }
+ return Math.max(0, dayEnd.diff(cursorDjs, 'second'));
+}
+
+/**
+ * Thrown when a required duration cannot fit on the target day without
+ * introducing an overlap. Callers reformat the message for end users.
+ */
+export class NoFreeWindowError extends Error {
+ public readonly code = 'no_free_window' as const;
+ public readonly date: string;
+ public readonly requiredSeconds: number;
+
+ constructor(date: string, requiredSeconds: number) {
+ super(
+ `Cannot fit ${requiredSeconds} seconds on ${date} without overlapping existing time entries.`
+ );
+ this.name = 'NoFreeWindowError';
+ this.date = date;
+ this.requiredSeconds = requiredSeconds;
+ }
+}
diff --git a/resources/js/utils/timesheet/useCopyLastWeek.ts b/resources/js/utils/timesheet/useCopyLastWeek.ts
new file mode 100644
index 00000000..6a8d556f
--- /dev/null
+++ b/resources/js/utils/timesheet/useCopyLastWeek.ts
@@ -0,0 +1,242 @@
+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[]
+ ) => string
+) {
+ const dayjs = getDayJsInstance();
+ const queryClient = useQueryClient();
+ const { addNotification } = useNotificationsStore();
+
+ const isCopyingLastWeek = ref(false);
+
+ 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))
+ );
+ 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);
+ if (!existingIdentities.has(identity) && !addedIdentities.has(identity)) {
+ addedIdentities.add(identity);
+ addSlot(entry.project_id, entry.task_id, entry.billable, tags);
+ }
+ }
+ }
+
+ async function copyLastWeekRows(): Promise {
+ isCopyingLastWeek.value = true;
+ try {
+ const prev = await fetchLastWeekEntries();
+ if (!prev) return;
+ addMissingRowsFromPreviousWeek(prev.data);
+ } 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();
+
+ addMissingRowsFromPreviousWeek(prev.data);
+
+ 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 prev.data) {
+ 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,
+ 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,
+ };
+}
diff --git a/resources/js/utils/timesheet/useTimesheetCellMutations.ts b/resources/js/utils/timesheet/useTimesheetCellMutations.ts
new file mode 100644
index 00000000..fafd84a9
--- /dev/null
+++ b/resources/js/utils/timesheet/useTimesheetCellMutations.ts
@@ -0,0 +1,322 @@
+import type { Ref } from 'vue';
+import { useQueryClient } from '@tanstack/vue-query';
+import { api, type CreateTimeEntryBody, type TimeEntry } from '@/packages/api/src';
+import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
+import { getUserTimezone } from '@/packages/ui/src/utils/settings';
+import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
+import {
+ makeRowKey,
+ type TimesheetCell,
+ type TimesheetRow,
+ type TimesheetRowKey,
+} from '@/utils/useTimesheetGrid';
+import { useNotificationsStore } from '@/utils/notification';
+import {
+ findFreeWindowOnDay,
+ freeGapSecondsAfter,
+ NoFreeWindowError,
+ workDayStartOn,
+ type FreeWindow,
+} from './cellMath';
+
+
+/**
+ * Cell-level edit dispatcher. Picks one of four strategies based on
+ * the diff between current and requested totals:
+ *
+ * - deleteCell — new total is 0
+ * - createCell — empty cell, place in first free window
+ * - extendCell — diff > 0, push the latest-ending entry forward,
+ * splitting the remainder into a new entry if a
+ * collision blocks the path
+ * - shrinkFromEnd — diff < 0, shorten / delete entries from most-
+ * recent backwards
+ *
+ * Running entries (end === null) are treated as immutable. Both create
+ * and extend can throw NoFreeWindowError when the day is too full.
+ *
+ * Calls the API directly (not via useTimeEntriesMutations) so a single
+ * cell edit fanning into multiple mutations produces exactly one toast
+ * and one cache invalidation.
+ */
+export function useTimesheetCellMutations(
+ weekDays: Ref,
+ timeEntries: Ref,
+ rows: Ref,
+ removeSlot: (key: TimesheetRowKey) => void
+) {
+ const dayjs = getDayJsInstance();
+ const queryClient = useQueryClient();
+ const notifications = useNotificationsStore();
+
+ async function handleCellUpdate(
+ row: TimesheetRow,
+ dayIndex: number,
+ newTotalSeconds: number
+ ): Promise {
+ const cell = row.cells.get(dayIndex);
+ const existingSeconds = cell?.totalSeconds ?? 0;
+ if (newTotalSeconds === existingSeconds) return;
+
+ // Capture row state before the mutation: a row that was empty
+ // and shares identity with another slot collapses after the
+ // first entry lands, so the entry naturally identity-routes to
+ // the surviving slot.
+ const wasEmpty = row.totalSeconds === 0;
+
+ try {
+ await dispatchCellUpdate(row, dayIndex, newTotalSeconds);
+
+ if (wasEmpty && newTotalSeconds > 0 && hasDuplicateIdentitySlot(row)) {
+ removeSlot(row.key);
+ notifications.addNotification(
+ 'success',
+ 'Merged into matching row',
+ 'Another row with the same project, task, billable status and tags already exists.'
+ );
+ }
+ } catch (err) {
+ if (err instanceof NoFreeWindowError) {
+ const friendlyDuration = formatHumanReadableDuration(
+ err.requiredSeconds,
+ 'hours-minutes',
+ 'point'
+ );
+ notifications.addNotification(
+ 'error',
+ "This day can't fit any more work",
+ `Couldn't fit ${friendlyDuration} on ${err.date} without overlapping existing entries.`
+ );
+ return;
+ }
+ notifications.addNotification(
+ 'error',
+ 'Failed to update timesheet',
+ 'Please try again later.'
+ );
+ throw err;
+ } finally {
+ queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
+ }
+ }
+
+ function hasDuplicateIdentitySlot(row: TimesheetRow): boolean {
+ const target = makeRowKey(row.projectId, row.taskId, row.billable, row.tags);
+ return rows.value.some(
+ (r) =>
+ r.key !== row.key &&
+ makeRowKey(r.projectId, r.taskId, r.billable, r.tags) === target
+ );
+ }
+
+ async function dispatchCellUpdate(
+ row: TimesheetRow,
+ dayIndex: number,
+ newTotalSeconds: number
+ ): Promise {
+ const cell = row.cells.get(dayIndex);
+ const existingSeconds = cell?.totalSeconds ?? 0;
+ const diff = newTotalSeconds - existingSeconds;
+
+ if (newTotalSeconds === 0 && cell) {
+ await deleteCell(cell);
+ return;
+ }
+
+ if (!cell || existingSeconds === 0) {
+ await createCell(row, dayIndex, newTotalSeconds);
+ return;
+ }
+
+ if (diff > 0) {
+ await extendCell(row, dayIndex, cell, diff);
+ return;
+ }
+
+ await shrinkFromEnd(cell, -diff);
+ }
+
+ async function deleteCell(cell: TimesheetCell): Promise {
+ const orgId = requireOrgId();
+ await api.deleteTimeEntries(undefined, {
+ queries: { ids: cell.entries.map((e) => e.id) },
+ params: { organization: orgId },
+ });
+ }
+
+ /**
+ * Place a new entry on the cell's day. Without `afterCursor`, prefers
+ * 09:00 local with a fall-back to start-of-day. With `afterCursor`,
+ * places strictly at-or-after that timestamp (used by extendCell to
+ * skip past a just-written extension that timeEntries.value doesn't
+ * yet reflect). Throws NoFreeWindowError if nothing fits.
+ */
+ async function createCell(
+ row: TimesheetRow,
+ dayIndex: number,
+ totalSeconds: number,
+ afterCursor?: string
+ ): Promise {
+ const date = weekDays.value[dayIndex]!;
+ const tz = getUserTimezone();
+
+ let window: FreeWindow | null;
+ if (afterCursor) {
+ window = findFreeWindowOnDay(timeEntries.value, date, totalSeconds, tz, afterCursor);
+ } else {
+ window =
+ findFreeWindowOnDay(
+ timeEntries.value,
+ date,
+ totalSeconds,
+ tz,
+ workDayStartOn(date, tz)
+ ) ?? findFreeWindowOnDay(timeEntries.value, date, totalSeconds, tz);
+ }
+
+ if (!window) throw new NoFreeWindowError(date, totalSeconds);
+
+ const orgId = requireOrgId();
+ const memberId = getCurrentMembershipId();
+ if (!memberId) throw new Error('No member context');
+
+ const body: CreateTimeEntryBody = {
+ member_id: memberId,
+ project_id: row.projectId,
+ task_id: row.taskId,
+ start: window.start,
+ end: window.end,
+ billable: row.billable,
+ description: null,
+ tags: row.tags,
+ };
+ await api.createTimeEntry(body, { params: { organization: orgId } });
+ }
+
+ /**
+ * Push the latest-ending entry's end forward by `addSeconds`, and if
+ * a collision blocks the path before that's exhausted, place the
+ * remainder as a fresh entry in the next free window on the day.
+ */
+ async function extendCell(
+ row: TimesheetRow,
+ dayIndex: number,
+ cell: TimesheetCell,
+ addSeconds: number
+ ): Promise {
+ const date = weekDays.value[dayIndex]!;
+ const tz = getUserTimezone();
+
+ // Latest END (not latest start) — extending a nested inner entry
+ // would leave the outer one as the true tail.
+ const candidate = pickLatestEndedEntry(cell);
+
+ // Running timer (or no ended entry): can't extend, place it all
+ // as a new entry instead.
+ if (!candidate || !candidate.end) {
+ await createCell(row, dayIndex, addSeconds);
+ return;
+ }
+
+ const gap = freeGapSecondsAfter(timeEntries.value, date, tz, candidate.end);
+ const extendBy = Math.min(addSeconds, gap);
+ const remainder = addSeconds - extendBy;
+ const projectedNewEnd = dayjs.utc(candidate.end).add(extendBy, 'second').format();
+
+ // Pre-flight: if there's a remainder, make sure it'll fit in a
+ // window after `projectedNewEnd` BEFORE we issue the extend PATCH.
+ // Otherwise a successful extend followed by a no-fit createCell
+ // would leave the entry persistently lengthened on the server
+ // while the user sees a "can't fit" error.
+ if (remainder > 0) {
+ const fit = findFreeWindowOnDay(
+ timeEntries.value,
+ date,
+ remainder,
+ tz,
+ projectedNewEnd
+ );
+ if (!fit) throw new NoFreeWindowError(date, addSeconds);
+ }
+
+ if (extendBy > 0) {
+ await updateEntry({ ...candidate, end: projectedNewEnd });
+ }
+ if (remainder <= 0) return;
+
+ // timeEntries.value is stale here (still shows candidate's old
+ // end). Force the placement search past projectedNewEnd so it
+ // can't propose a window that overlaps the just-extended candidate.
+ await createCell(row, dayIndex, remainder, projectedNewEnd);
+ }
+
+ async function shrinkFromEnd(cell: TimesheetCell, removeSeconds: number): Promise {
+ let toRemove = removeSeconds;
+
+ // Shrink doesn't introduce overlaps, so latest-START is fine here.
+ const sortedEntries = [...cell.entries].sort((a, b) => b.start.localeCompare(a.start));
+
+ for (const entry of sortedEntries) {
+ if (toRemove <= 0) break;
+ if (!entry.end) continue; // running entries are immutable
+
+ const entryDuration = entry.duration ?? 0;
+
+ if (entryDuration <= toRemove) {
+ await deleteEntry(entry.id);
+ toRemove -= entryDuration;
+ } else {
+ const newEnd = dayjs
+ .utc(entry.start)
+ .add(entryDuration - toRemove, 'second')
+ .format();
+ await updateEntry({ ...entry, end: newEnd });
+ toRemove = 0;
+ }
+ }
+ }
+
+ // ── api helpers ───────────────────────────────────────────────
+
+ function requireOrgId(): string {
+ const id = getCurrentOrganizationId();
+ if (!id) throw new Error('No organization context');
+ return id;
+ }
+
+ async function updateEntry(entry: TimeEntry) {
+ const orgId = requireOrgId();
+ await api.updateTimeEntry(entry, {
+ params: { organization: orgId, timeEntry: entry.id },
+ });
+ }
+
+ async function deleteEntry(id: string) {
+ const orgId = requireOrgId();
+ await api.deleteTimeEntry(undefined, {
+ params: { organization: orgId, timeEntry: id },
+ });
+ }
+
+ function pickLatestEndedEntry(cell: TimesheetCell): TimeEntry | null {
+ let best: TimeEntry | null = null;
+ for (const entry of cell.entries) {
+ if (!best) {
+ best = entry;
+ continue;
+ }
+ // Running entries are treated as "infinite" — they win.
+ if (!entry.end) {
+ best = entry;
+ continue;
+ }
+ if (best.end && entry.end > best.end) {
+ best = entry;
+ }
+ }
+ return best;
+ }
+
+
+ return { handleCellUpdate };
+}
diff --git a/resources/js/utils/timesheet/useTimesheetRowDeletion.ts b/resources/js/utils/timesheet/useTimesheetRowDeletion.ts
new file mode 100644
index 00000000..d50d2330
--- /dev/null
+++ b/resources/js/utils/timesheet/useTimesheetRowDeletion.ts
@@ -0,0 +1,70 @@
+import { computed, ref, type Ref } from 'vue';
+import type { Project, TimeEntry } from '@/packages/api/src';
+import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid';
+import type { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
+
+type Mutations = ReturnType;
+
+/**
+ * Holds the state and handlers for the "remove row" confirmation flow.
+ *
+ * Empty rows (no entries) are removed immediately without confirmation;
+ * rows with entries open a confirmation dialog, and on confirm we bulk
+ * delete every entry in the row before dropping the row from the grid.
+ */
+export function useTimesheetRowDeletion(
+ projects: Ref,
+ mutations: Pick,
+ removeSlot: (key: TimesheetRowKey) => void
+) {
+ const showDeleteDialog = ref(false);
+ const rowToDelete = ref(null);
+
+ const deleteRowEntryCount = computed(() => {
+ if (!rowToDelete.value) return 0;
+ let count = 0;
+ for (const cell of rowToDelete.value.cells.values()) {
+ count += cell.entries.length;
+ }
+ return count;
+ });
+
+ const deleteRowProjectName = computed(() => {
+ if (!rowToDelete.value?.projectId) return 'No Project';
+ return projects.value.find((p) => p.id === rowToDelete.value?.projectId)?.name ?? 'Unknown';
+ });
+
+ function requestRemoveRow(row: TimesheetRow): void {
+ if (row.totalSeconds === 0) {
+ removeSlot(row.key);
+ return;
+ }
+ rowToDelete.value = row;
+ showDeleteDialog.value = true;
+ }
+
+ async function confirmDeleteRow(): Promise {
+ if (!rowToDelete.value) return;
+
+ const allEntries: TimeEntry[] = [];
+ for (const cell of rowToDelete.value.cells.values()) {
+ allEntries.push(...cell.entries);
+ }
+
+ if (allEntries.length > 0) {
+ await mutations.deleteTimeEntries(allEntries);
+ }
+ removeSlot(rowToDelete.value.key);
+ showDeleteDialog.value = false;
+ rowToDelete.value = null;
+ }
+
+ return {
+ showDeleteDialog,
+ rowToDelete,
+ deleteRowEntryCount,
+ deleteRowProjectName,
+ requestRemoveRow,
+ confirmDeleteRow,
+ };
+}
diff --git a/resources/js/utils/timesheet/useTimesheetRowMutations.ts b/resources/js/utils/timesheet/useTimesheetRowMutations.ts
new file mode 100644
index 00000000..f7ec6b1e
--- /dev/null
+++ b/resources/js/utils/timesheet/useTimesheetRowMutations.ts
@@ -0,0 +1,150 @@
+import type { Ref } from 'vue';
+import type { Project, UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
+import {
+ makeRowKey,
+ type TimesheetRow,
+ type TimesheetRowIdentity,
+ type TimesheetRowKey,
+} from '@/utils/useTimesheetGrid';
+import type { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
+import { useNotificationsStore } from '@/utils/notification';
+
+function identityPartialToApiChanges(
+ partial: Partial
+): UpdateMultipleTimeEntriesChangeset {
+ const changes: UpdateMultipleTimeEntriesChangeset = {};
+ if ('projectId' in partial) changes.project_id = partial.projectId;
+ if ('taskId' in partial) changes.task_id = partial.taskId;
+ if ('billable' in partial) changes.billable = partial.billable;
+ if ('tags' in partial) changes.tags = partial.tags;
+ return changes;
+}
+
+type Mutations = ReturnType;
+
+/**
+ * Row-level mutations that don't involve confirmation.
+ *
+ * Rows are keyed by slot id (not identity), so any partial change to
+ * a row's identity is handled the same way: push the change to the
+ * server for any entries in the row, then migrate the slot's identity
+ * in place so the row stays at its existing position.
+ */
+export function useTimesheetRowMutations(
+ mutations: Pick,
+ projects: Ref,
+ rows: Ref,
+ addSlot: (
+ projectId: string | null,
+ taskId: string | null,
+ billable: boolean,
+ tags: string[]
+ ) => TimesheetRowKey,
+ updateSlot: (key: TimesheetRowKey, identity: TimesheetRowIdentity) => void,
+ removeSlot: (key: TimesheetRowKey) => void
+) {
+ const notifications = useNotificationsStore();
+
+ function collectEntryIds(row: TimesheetRow): string[] {
+ const ids: string[] = [];
+ for (const cell of row.cells.values()) {
+ for (const entry of cell.entries) ids.push(entry.id);
+ }
+ return ids;
+ }
+
+ function hasDuplicateIdentityRow(
+ rowKey: TimesheetRowKey,
+ identity: TimesheetRowIdentity
+ ): boolean {
+ const target = makeRowKey(
+ identity.projectId,
+ identity.taskId,
+ identity.billable,
+ identity.tags
+ );
+
+ return rows.value.some(
+ (candidate) =>
+ candidate.key !== rowKey &&
+ makeRowKey(
+ candidate.projectId,
+ candidate.taskId,
+ candidate.billable,
+ candidate.tags
+ ) === target
+ );
+ }
+
+ async function handleRowIdentityChange(
+ row: TimesheetRow,
+ partial: Partial
+ ): Promise {
+ const entryIds = collectEntryIds(row);
+ const currentIdentity = makeRowKey(row.projectId, row.taskId, row.billable, row.tags);
+ let merged: TimesheetRowIdentity = {
+ projectId: row.projectId,
+ taskId: row.taskId,
+ billable: row.billable,
+ tags: row.tags,
+ ...partial,
+ };
+
+ // Auto-default billable on the first project pick for an empty
+ // row (project provides the default; user can override after).
+ if (
+ entryIds.length === 0 &&
+ partial.projectId !== undefined &&
+ partial.projectId !== row.projectId &&
+ partial.projectId &&
+ partial.billable === undefined
+ ) {
+ const projectBillable = projects.value.find(
+ (p) => p.id === partial.projectId
+ )?.is_billable;
+ if (projectBillable !== undefined) {
+ merged = { ...merged, billable: projectBillable };
+ }
+ }
+
+ const mergedIdentity = makeRowKey(
+ merged.projectId,
+ merged.taskId,
+ merged.billable,
+ merged.tags
+ );
+ const shouldMergeIntoExistingRow =
+ entryIds.length > 0 &&
+ currentIdentity !== mergedIdentity &&
+ hasDuplicateIdentityRow(row.key, merged);
+
+ if (entryIds.length > 0) {
+ await mutations.updateTimeEntries({
+ ids: entryIds,
+ changes: identityPartialToApiChanges(partial),
+ });
+ }
+
+ if (shouldMergeIntoExistingRow) {
+ removeSlot(row.key);
+ notifications.addNotification(
+ 'success',
+ 'Merged into matching row',
+ 'Another row with the same project, task, billable status and tags already exists.'
+ );
+ return;
+ }
+
+ updateSlot(row.key, merged);
+ }
+
+ function handleAddRow(projectId: string | null = null, taskId: string | null = null): void {
+ const project = projectId ? projects.value.find((p) => p.id === projectId) : null;
+ addSlot(projectId, taskId, project?.is_billable ?? false, []);
+ }
+
+ return {
+ handleRowIdentityChange,
+ handleAddRow,
+ };
+}
diff --git a/resources/js/utils/timesheet/useTimesheetWeek.ts b/resources/js/utils/timesheet/useTimesheetWeek.ts
new file mode 100644
index 00000000..043c3f7a
--- /dev/null
+++ b/resources/js/utils/timesheet/useTimesheetWeek.ts
@@ -0,0 +1,82 @@
+import { computed, ref, watch } from 'vue';
+import type { Dayjs } from 'dayjs';
+import { useQueryClient } from '@tanstack/vue-query';
+import { getDayJsInstance } from '@/packages/ui/src/utils/time';
+import { getUserTimezone } from '@/packages/ui/src/utils/settings';
+import { prefetchTimesheetWeek } from '@/utils/useTimesheetQuery';
+import { getInitialWeekRange } from '@/utils/useTimeEntriesCalendarQuery';
+
+/**
+ * Owns week-navigation state for the timesheet page.
+ *
+ * Exposes the current week start/end, the list of day strings, derived
+ * display helpers (week number, today's date, whether this is the
+ * current week), and navigation functions.
+ *
+ * Also prefetches the adjacent weeks whenever `weekStart` changes so
+ * that clicking prev/next feels instant.
+ */
+export function useTimesheetWeek() {
+ const dayjs = getDayJsInstance();
+ const queryClient = useQueryClient();
+
+ const weekStart = ref(getInitialWeekRange().start);
+ const weekEnd = computed(() => weekStart.value.add(7, 'day'));
+
+ const weekDays = computed(() => {
+ const days: string[] = [];
+ for (let i = 0; i < 7; i++) {
+ days.push(weekStart.value.add(i, 'day').format('YYYY-MM-DD'));
+ }
+ return days;
+ });
+
+ const weekNumber = computed(() => weekStart.value.week());
+
+ const isCurrentWeek = computed(() =>
+ weekStart.value.isSame(getInitialWeekRange().start, 'day')
+ );
+
+ const todayDate = computed(() => {
+ const tz = getUserTimezone();
+ return dayjs().tz(tz).format('YYYY-MM-DD');
+ });
+
+ // Prefetch adjacent weeks so prev/next feels instant.
+ watch(
+ weekStart,
+ () => {
+ const prevStart = weekStart.value.subtract(7, 'day');
+ const prevEnd = weekStart.value;
+ const nextStart = weekStart.value.add(7, 'day');
+ const nextEnd = weekStart.value.add(14, 'day');
+ prefetchTimesheetWeek(queryClient, prevStart, prevEnd);
+ prefetchTimesheetWeek(queryClient, nextStart, nextEnd);
+ },
+ { immediate: true }
+ );
+
+ function goToPreviousWeek() {
+ weekStart.value = weekStart.value.subtract(7, 'day');
+ }
+
+ function goToNextWeek() {
+ weekStart.value = weekStart.value.add(7, 'day');
+ }
+
+ function goToCurrentWeek() {
+ weekStart.value = getInitialWeekRange().start;
+ }
+
+ return {
+ weekStart,
+ weekEnd,
+ weekDays,
+ weekNumber,
+ isCurrentWeek,
+ todayDate,
+ goToPreviousWeek,
+ goToNextWeek,
+ goToCurrentWeek,
+ };
+}
diff --git a/resources/js/utils/useTimeEntriesCalendarQuery.ts b/resources/js/utils/useTimeEntriesCalendarQuery.ts
index 215c555e..4a19310d 100644
--- a/resources/js/utils/useTimeEntriesCalendarQuery.ts
+++ b/resources/js/utils/useTimeEntriesCalendarQuery.ts
@@ -2,43 +2,28 @@ import { useQuery } from '@tanstack/vue-query';
import { api, type TimeEntryResponse, type TimeEntry } from '@/packages/api/src';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import { computed, type Ref } from 'vue';
-import { getDayJsInstance } from '@/packages/ui/src/utils/time';
-import { getUserTimezone, getWeekStart } from '@/packages/ui/src/utils/settings';
-
-const weekStartMap: Record = {
- sunday: 0,
- monday: 1,
- tuesday: 2,
- wednesday: 3,
- thursday: 4,
- friday: 5,
- saturday: 6,
-};
+import type { Dayjs } from 'dayjs';
+import { getDayJsInstance, localDateToUtc } from '@/packages/ui/src/utils/time';
+import { getWeekStartDayNumber } from '@/packages/ui/src/utils/settings';
/**
* Calculate expanded date range to include previous and next periods with timezone transformations.
* This allows smooth navigation between calendar views without loading delays.
*/
export function getExpandedCalendarDateRange(
- calendarStart: Date,
- calendarEnd: Date
+ calendarStart: Dayjs,
+ calendarEnd: Dayjs
): { start: string; end: string } {
- const dayjs = getDayJsInstance();
- const duration = dayjs(calendarEnd).diff(dayjs(calendarStart), 'milliseconds');
+ const duration = calendarEnd.diff(calendarStart, 'milliseconds');
// Calculate previous period
- const previousStart = dayjs(calendarStart).subtract(duration, 'milliseconds');
+ const previousStart = calendarStart.subtract(duration, 'milliseconds');
// Calculate next period
- const nextEnd = dayjs(calendarEnd).add(duration, 'milliseconds');
-
- // Apply timezone transformations
- const timezone = getUserTimezone();
- const formattedStart = previousStart.utc().tz(timezone, true).utc().format();
- const formattedEnd = nextEnd.utc().tz(timezone, true).utc().format();
+ const nextEnd = calendarEnd.add(duration, 'milliseconds');
return {
- start: formattedStart,
- end: formattedEnd,
+ start: localDateToUtc(previousStart),
+ end: localDateToUtc(nextEnd),
};
}
@@ -46,21 +31,17 @@ export function getExpandedCalendarDateRange(
* Get the initial week view date range based on user's week start preference.
* Matches FullCalendar's timeGridWeek initial view.
*/
-export function getInitialWeekRange(): { start: Date; end: Date } {
+export function getInitialWeekRange(): { start: Dayjs; end: Dayjs } {
const dayjs = getDayJsInstance();
- const weekStart = getWeekStart();
- const firstDay = weekStartMap[weekStart] ?? 1;
+ const firstDay = getWeekStartDayNumber();
const now = dayjs();
const currentDayOfWeek = now.day();
const daysFromWeekStart = (currentDayOfWeek - firstDay + 7) % 7;
- const calendarStart = now.subtract(daysFromWeekStart, 'day').startOf('day');
- const calendarEnd = calendarStart.add(7, 'day');
+ const start = now.subtract(daysFromWeekStart, 'day').startOf('day');
+ const end = start.add(7, 'day');
- return {
- start: calendarStart.toDate(),
- end: calendarEnd.toDate(),
- };
+ return { start, end };
}
/**
@@ -115,8 +96,8 @@ export async function fetchAllCalendarEntries(
}
export function useTimeEntriesCalendarQuery(
- calendarStart: Ref,
- calendarEnd: Ref
+ calendarStart: Ref,
+ calendarEnd: Ref
) {
const enableCalendarQuery = computed(() => {
return !!getCurrentOrganizationId() && !!calendarStart.value && !!calendarEnd.value;
diff --git a/resources/js/utils/useTimesheetGrid.ts b/resources/js/utils/useTimesheetGrid.ts
new file mode 100644
index 00000000..1da25a8b
--- /dev/null
+++ b/resources/js/utils/useTimesheetGrid.ts
@@ -0,0 +1,284 @@
+import type { TimeEntry, Project, Task } from '@/packages/api/src';
+import { getDayJsInstance, getLocalizedDateFromTimestamp } from '@/packages/ui/src/utils/time';
+import type { Dayjs } from 'dayjs';
+import { computed, ref, watch, type Ref } from 'vue';
+
+export type TimesheetRowKey = string;
+
+export interface TimesheetCell {
+ dayIndex: number;
+ date: string;
+ entries: TimeEntry[];
+ totalSeconds: number;
+}
+
+export interface TimesheetRow {
+ key: TimesheetRowKey;
+ projectId: string | null;
+ taskId: string | null;
+ billable: boolean;
+ tags: string[];
+ cells: Map;
+ totalSeconds: number;
+}
+
+export interface TimesheetRowIdentity {
+ projectId: string | null;
+ taskId: string | null;
+ billable: boolean;
+ tags: string[];
+}
+
+interface Slot extends TimesheetRowIdentity {
+ id: string;
+ // 'seeded' slots are derived from the entries query and re-sort
+ // alphabetically whenever project/task lists change. 'user' slots
+ // were created via Add Row / project-change interactions and keep
+ // their insertion order (always below the seeded block).
+ origin: 'seeded' | 'user';
+}
+
+function sortTags(tags: string[] | null | undefined): string[] {
+ return [...(tags ?? [])].sort();
+}
+
+export function makeRowKey(
+ projectId: string | null,
+ taskId: string | null,
+ billable: boolean,
+ tags: string[]
+): TimesheetRowKey {
+ return JSON.stringify([projectId, taskId, billable, sortTags(tags)]);
+}
+
+function slotIdentityKey(slot: Slot): TimesheetRowKey {
+ return makeRowKey(slot.projectId, slot.taskId, slot.billable, slot.tags);
+}
+
+let slotCounter = 0;
+
+function newSlotId(): string {
+ return `s${++slotCounter}`;
+}
+
+/**
+ * Slot-first row model.
+ *
+ * The timesheet renders one row per slot, in insertion order. Slots
+ * carry a stable id — the row's Vue key never changes across mutations,
+ * so rows don't jump positions as entries load or get edited.
+ *
+ * Entries hydrate slots: `rows` is computed by grouping entries by
+ * identity (projectId, taskId, billable, tags) and attaching the
+ * matching group to the first slot with that identity. Duplicate
+ * slots with the same identity render empty (the first one claims
+ * the entries) — callers are expected to collapse duplicates after a
+ * cell-create rather than letting them linger.
+ *
+ * Seeding: a watcher scans `timeEntries` and appends a slot for every
+ * identity that doesn't already have one. Initial loads come in as a
+ * batch and are sorted by project name so the first render is stable;
+ * slots added later (via `addSlot` or post-mutation refetches) append
+ * at the end.
+ *
+ * Mutations:
+ * - `addSlot` push a blank or pre-populated slot at the end
+ * - `removeSlot` drop a slot by id (the row's `key`)
+ * - `updateSlot` migrate a slot's identity in place — used by
+ * project/billable/tags changes so the row
+ * stays put while the server roundtrips
+ * - `clearSlots` wipe everything (used on week navigation)
+ */
+export function useTimesheetGrid(
+ timeEntries: Ref,
+ weekDays: Ref,
+ projects: Ref,
+ tasks: Ref,
+ currentTime: Ref
+) {
+ const dayjs = getDayJsInstance();
+ const slots = ref([]);
+
+ // Seed / re-sort the seeded portion of slots whenever entries,
+ // projects or tasks change. Seeded slots sort alphabetically by
+ // project name → task name → billable → tags so reloads are
+ // deterministic. User-added slots keep their insertion order and
+ // stay after the seeded block.
+ watch(
+ [() => timeEntries.value, () => projects.value, () => tasks.value],
+ ([entries, projectList, taskList]) => {
+ const present = new Set(slots.value.map(slotIdentityKey));
+ for (const entry of entries) {
+ const key = makeRowKey(
+ entry.project_id,
+ entry.task_id,
+ entry.billable,
+ sortTags(entry.tags)
+ );
+ if (present.has(key)) continue;
+ present.add(key);
+ slots.value.push({
+ id: newSlotId(),
+ origin: 'seeded',
+ projectId: entry.project_id,
+ taskId: entry.task_id,
+ billable: entry.billable,
+ tags: sortTags(entry.tags),
+ });
+ }
+
+ const projectNameMap = new Map();
+ for (const p of projectList) projectNameMap.set(p.id, p.name);
+ const taskNameMap = new Map();
+ for (const t of taskList) taskNameMap.set(t.id, t.name);
+
+ const sortKey = (s: Slot): string => {
+ const projectName = s.projectId ? (projectNameMap.get(s.projectId) ?? '') : '';
+ const taskName = s.taskId ? (taskNameMap.get(s.taskId) ?? '') : '';
+ return `${projectName}\x00${taskName}\x00${s.billable ? '1' : '0'}\x00${s.tags.join(',')}`;
+ };
+
+ const seeded = slots.value.filter((s) => s.origin === 'seeded');
+ const userAdded = slots.value.filter((s) => s.origin === 'user');
+ seeded.sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
+ slots.value = [...seeded, ...userAdded];
+ },
+ { immediate: true }
+ );
+
+ const rows = computed(() => {
+ const dayIndexMap = new Map();
+ weekDays.value.forEach((date, index) => dayIndexMap.set(date, index));
+
+ // Group entries by identity. The first slot (in render order) with
+ // a given identity claims that group; later duplicate-identity
+ // slots render empty.
+ const entriesByIdentity = new Map();
+ for (const entry of timeEntries.value) {
+ const identityKey = makeRowKey(
+ entry.project_id,
+ entry.task_id,
+ entry.billable,
+ sortTags(entry.tags)
+ );
+ if (!entriesByIdentity.has(identityKey)) entriesByIdentity.set(identityKey, []);
+ entriesByIdentity.get(identityKey)!.push(entry);
+ }
+
+ const claimed = new Set();
+
+ function buildCellsFromEntries(entries: TimeEntry[]) {
+ const cells = new Map();
+ let totalSeconds = 0;
+
+ function getEntryDurationSeconds(entry: TimeEntry): number {
+ if (entry.end !== null) {
+ return entry.duration ?? 0;
+ }
+
+ const liveNow = currentTime.value ?? dayjs.utc();
+ return Math.max(0, liveNow.diff(dayjs.utc(entry.start), 'second'));
+ }
+
+ for (const entry of entries) {
+ const entryDate = getLocalizedDateFromTimestamp(entry.start);
+ const dayIndex = dayIndexMap.get(entryDate);
+ if (dayIndex === undefined) continue;
+ const existing = cells.get(dayIndex);
+ const duration = getEntryDurationSeconds(entry);
+ if (existing) {
+ existing.entries.push(entry);
+ existing.totalSeconds += duration;
+ } else {
+ cells.set(dayIndex, {
+ dayIndex,
+ date: weekDays.value[dayIndex]!,
+ entries: [entry],
+ totalSeconds: duration,
+ });
+ }
+ totalSeconds += duration;
+ }
+ return { cells, totalSeconds };
+ }
+
+ return slots.value.map((slot) => {
+ const identityKey = slotIdentityKey(slot);
+ let collected: TimeEntry[] = [];
+
+ if (!claimed.has(identityKey)) {
+ const byIdentity = entriesByIdentity.get(identityKey);
+ if (byIdentity) {
+ claimed.add(identityKey);
+ collected = byIdentity;
+ }
+ }
+
+ const { cells, totalSeconds } = buildCellsFromEntries(collected);
+
+ return {
+ key: slot.id,
+ projectId: slot.projectId,
+ taskId: slot.taskId,
+ billable: slot.billable,
+ tags: slot.tags,
+ cells,
+ totalSeconds,
+ };
+ });
+ });
+
+ const dayTotals = computed(() =>
+ weekDays.value.map((_, dayIndex) =>
+ rows.value.reduce((sum, row) => sum + (row.cells.get(dayIndex)?.totalSeconds ?? 0), 0)
+ )
+ );
+
+ const grandTotal = computed(() => dayTotals.value.reduce((a, b) => a + b, 0));
+
+ function addSlot(
+ projectId: string | null,
+ taskId: string | null,
+ billable: boolean,
+ tags: string[]
+ ): TimesheetRowKey {
+ const id = newSlotId();
+ slots.value.push({
+ id,
+ origin: 'user',
+ projectId,
+ taskId,
+ billable,
+ tags: sortTags(tags),
+ });
+ return id;
+ }
+
+ function removeSlot(key: TimesheetRowKey) {
+ slots.value = slots.value.filter((s) => s.id !== key);
+ }
+
+ function updateSlot(key: TimesheetRowKey, identity: TimesheetRowIdentity) {
+ const slot = slots.value.find((s) => s.id === key);
+ if (!slot) return;
+ slot.projectId = identity.projectId;
+ slot.taskId = identity.taskId;
+ slot.billable = identity.billable;
+ slot.tags = sortTags(identity.tags);
+ }
+
+ function clearSlots() {
+ slots.value = [];
+ }
+
+ return {
+ rows,
+ dayTotals,
+ grandTotal,
+ slots,
+ addSlot,
+ removeSlot,
+ updateSlot,
+ clearSlots,
+ };
+}
diff --git a/resources/js/utils/useTimesheetQuery.ts b/resources/js/utils/useTimesheetQuery.ts
new file mode 100644
index 00000000..f04e8995
--- /dev/null
+++ b/resources/js/utils/useTimesheetQuery.ts
@@ -0,0 +1,102 @@
+import { useQuery, type QueryClient } from '@tanstack/vue-query';
+import { api, type TimeEntry, type TimeEntryResponse } from '@/packages/api/src';
+import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
+import { computed, type Ref } from 'vue';
+import type { Dayjs } from 'dayjs';
+import { localDateToUtc } from '@/packages/ui/src/utils/time';
+
+function createTimesheetQueryKey(
+ start: string | null,
+ end: string | null,
+ organizationId: string | null
+) {
+ return ['timeEntries', 'timesheet', { start, end, organization: organizationId }] as const;
+}
+
+async function fetchTimesheetEntries(
+ organizationId: string,
+ memberId: string | undefined,
+ start: string,
+ end: string
+): Promise {
+ const allEntries: TimeEntry[] = [];
+
+ while (true) {
+ const response = await api.getTimeEntries({
+ params: { organization: organizationId },
+ queries: {
+ start,
+ end,
+ member_id: memberId,
+ offset: allEntries.length || undefined,
+ },
+ });
+
+ if (response.data.length === 0) {
+ return { data: allEntries, meta: response.meta };
+ }
+
+ allEntries.push(...response.data);
+
+ if (allEntries.length >= response.meta.total) {
+ return { data: allEntries, meta: response.meta };
+ }
+ }
+}
+
+export function useTimesheetQuery(
+ weekStart: Ref,
+ weekEnd: Ref
+) {
+ const enabled = computed(() => {
+ return !!getCurrentOrganizationId() && !!weekStart.value && !!weekEnd.value;
+ });
+
+ const dateRange = computed(() => {
+ if (!weekStart.value || !weekEnd.value) return { start: null, end: null };
+ return {
+ start: localDateToUtc(weekStart.value),
+ end: localDateToUtc(weekEnd.value),
+ };
+ });
+
+ return useQuery({
+ queryKey: computed(() =>
+ createTimesheetQueryKey(
+ dateRange.value.start,
+ dateRange.value.end,
+ getCurrentOrganizationId()
+ )
+ ),
+ enabled,
+ queryFn: async () => {
+ return fetchTimesheetEntries(
+ getCurrentOrganizationId() || '',
+ getCurrentMembershipId(),
+ dateRange.value.start!,
+ dateRange.value.end!
+ );
+ },
+ staleTime: 1000 * 30,
+ placeholderData: (previousData) => previousData,
+ });
+}
+
+export function prefetchTimesheetWeek(queryClient: QueryClient, weekStart: Dayjs, weekEnd: Dayjs) {
+ const start = localDateToUtc(weekStart);
+ const end = localDateToUtc(weekEnd);
+ const organizationId = getCurrentOrganizationId();
+ const memberId = getCurrentMembershipId();
+
+ if (!organizationId) return;
+
+ const queryKey = createTimesheetQueryKey(start, end, organizationId);
+
+ queryClient.prefetchQuery({
+ queryKey,
+ queryFn: () => fetchTimesheetEntries(organizationId, memberId, start, end),
+ staleTime: 1000 * 30,
+ });
+}
+
+export { fetchTimesheetEntries };