mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-14 19:22:14 +01:00
add break time entries and simplified time tracker ui
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user