add break time entries and simplified time tracker ui

This commit is contained in:
Gregor Vostrak
2026-07-21 16:48:37 +02:00
parent c07c62bfab
commit 64ca1e9115
128 changed files with 6252 additions and 437 deletions

View File

@@ -1,4 +1,4 @@
import type { TimeEntry, Project, Task } from '@/packages/api/src';
import type { TimeEntry, TimeEntryType, 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';
@@ -18,6 +18,7 @@ export interface TimesheetRow {
taskId: string | null;
billable: boolean;
tags: string[];
type: TimeEntryType;
cells: Map<number, TimesheetCell>;
totalSeconds: number;
}
@@ -27,9 +28,11 @@ export interface TimesheetRowIdentity {
taskId: string | null;
billable: boolean;
tags: string[];
type?: TimeEntryType;
}
interface Slot extends TimesheetRowIdentity {
type: TimeEntryType;
id: string;
// 'seeded' slots are derived from the entries query and re-sort
// alphabetically whenever project/task lists change. 'user' slots
@@ -46,13 +49,14 @@ export function makeRowKey(
projectId: string | null,
taskId: string | null,
billable: boolean,
tags: string[]
tags: string[],
type: TimeEntryType = 'work'
): TimesheetRowKey {
return JSON.stringify([projectId, taskId, billable, sortTags(tags)]);
return JSON.stringify([projectId, taskId, billable, sortTags(tags), type]);
}
function slotIdentityKey(slot: Slot): TimesheetRowKey {
return makeRowKey(slot.projectId, slot.taskId, slot.billable, slot.tags);
return makeRowKey(slot.projectId, slot.taskId, slot.billable, slot.tags, slot.type);
}
let slotCounter = 0;
@@ -79,7 +83,9 @@ function newSlotId(): string {
* 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.
* at the end. With breaks enabled a break slot is always seeded, so
* the grid shows a permanent break row (pinned to the bottom) even
* when the week has no break entries.
*
* Mutations:
* - `addSlot` push a blank or pre-populated slot at the end
@@ -94,7 +100,8 @@ export function useTimesheetGrid(
weekDays: Ref<string[]>,
projects: Ref<Project[]>,
tasks: Ref<Task[]>,
currentTime: Ref<Dayjs | null>
currentTime: Ref<Dayjs | null>,
breaksEnabled?: Ref<boolean>
) {
const dayjs = getDayJsInstance();
const slots = ref<Slot[]>([]);
@@ -105,7 +112,12 @@ export function useTimesheetGrid(
// deterministic. User-added slots keep their insertion order and
// stay after the seeded block.
watch(
[() => timeEntries.value, () => projects.value, () => tasks.value],
[
() => timeEntries.value,
() => projects.value,
() => tasks.value,
() => breaksEnabled?.value,
],
([entries, projectList, taskList]) => {
const present = new Set(slots.value.map(slotIdentityKey));
for (const entry of entries) {
@@ -113,7 +125,8 @@ export function useTimesheetGrid(
entry.project_id,
entry.task_id,
entry.billable,
sortTags(entry.tags)
sortTags(entry.tags),
entry.type
);
if (present.has(key)) continue;
present.add(key);
@@ -124,6 +137,23 @@ export function useTimesheetGrid(
taskId: entry.task_id,
billable: entry.billable,
tags: sortTags(entry.tags),
type: entry.type,
});
}
// With breaks enabled the grid always shows a break row, even when
// the week has no break entries yet. Break entries can only have
// one identity (no project/task/tags, non-billable), so one break
// slot covers every break entry of the week.
if (breaksEnabled?.value && !present.has(makeRowKey(null, null, false, [], 'break'))) {
slots.value.push({
id: newSlotId(),
origin: 'seeded',
projectId: null,
taskId: null,
billable: false,
tags: [],
type: 'break',
});
}
@@ -138,10 +168,12 @@ export function useTimesheetGrid(
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');
const seeded = slots.value.filter((s) => s.origin === 'seeded' && s.type !== 'break');
const userAdded = slots.value.filter((s) => s.origin === 'user' && s.type !== 'break');
// The break row is pinned below all work rows, including user-added ones
const breakSlots = slots.value.filter((s) => s.type === 'break');
seeded.sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
slots.value = [...seeded, ...userAdded];
slots.value = [...seeded, ...userAdded, ...breakSlots];
},
{ immediate: true }
);
@@ -159,7 +191,8 @@ export function useTimesheetGrid(
entry.project_id,
entry.task_id,
entry.billable,
sortTags(entry.tags)
sortTags(entry.tags),
entry.type
);
if (!entriesByIdentity.has(identityKey)) entriesByIdentity.set(identityKey, []);
entriesByIdentity.get(identityKey)!.push(entry);
@@ -222,25 +255,43 @@ export function useTimesheetGrid(
taskId: slot.taskId,
billable: slot.billable,
tags: slot.tags,
type: slot.type,
cells,
totalSeconds,
};
});
});
// Breaks are not working time: the totals sum work rows only, the break row itself shows the break time
const dayTotals = computed<number[]>(() =>
weekDays.value.map((_, dayIndex) =>
rows.value.reduce((sum, row) => sum + (row.cells.get(dayIndex)?.totalSeconds ?? 0), 0)
rows.value
.filter((row) => row.type !== 'break')
.reduce((sum, row) => sum + (row.cells.get(dayIndex)?.totalSeconds ?? 0), 0)
)
);
const grandTotal = computed(() => dayTotals.value.reduce((a, b) => a + b, 0));
// Break time is surfaced separately from worked time (dayTotals excludes it). These
// sum only the break rows, per day and for the week; consumers render them only when
// non-zero so break-free timesheets look unchanged.
const breakDayTotals = computed<number[]>(() =>
weekDays.value.map((_, dayIndex) =>
rows.value
.filter((row) => row.type === 'break')
.reduce((sum, row) => sum + (row.cells.get(dayIndex)?.totalSeconds ?? 0), 0)
)
);
const breakGrandTotal = computed(() => breakDayTotals.value.reduce((a, b) => a + b, 0));
function addSlot(
projectId: string | null,
taskId: string | null,
billable: boolean,
tags: string[]
tags: string[],
type: TimeEntryType = 'work'
): TimesheetRowKey {
const id = newSlotId();
slots.value.push({
@@ -250,6 +301,7 @@ export function useTimesheetGrid(
taskId,
billable,
tags: sortTags(tags),
type,
});
return id;
}
@@ -265,6 +317,7 @@ export function useTimesheetGrid(
slot.taskId = identity.taskId;
slot.billable = identity.billable;
slot.tags = sortTags(identity.tags);
slot.type = identity.type ?? slot.type;
}
function clearSlots() {
@@ -275,6 +328,8 @@ export function useTimesheetGrid(
rows,
dayTotals,
grandTotal,
breakDayTotals,
breakGrandTotal,
slots,
addSlot,
removeSlot,