Files
solidtime/resources/js/utils/useTimesheetGrid.test.ts
2026-07-21 18:54:05 +02:00

382 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { nextTick, ref } from 'vue';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import type { Dayjs } from 'dayjs';
import type { Project, Task, TimeEntry } from '@/packages/api/src';
import { useTimesheetGrid } from './useTimesheetGrid';
dayjs.extend(utc);
const WEEK_DAYS = [
'2026-04-06',
'2026-04-07',
'2026-04-08',
'2026-04-09',
'2026-04-10',
'2026-04-11',
'2026-04-12',
];
function entry(start: string, end: string | null, overrides: Partial<TimeEntry> = {}): TimeEntry {
const startMs = new Date(start).valueOf();
const endMs = end ? new Date(end).valueOf() : startMs;
return {
id: overrides.id ?? `e-${start}-${end ?? 'running'}`,
start,
end,
duration: end ? Math.floor((endMs - startMs) / 1000) : null,
description: '',
member_id: 'm-1',
project_id: 'p-1',
task_id: null,
billable: false,
tags: [],
...overrides,
} as unknown as TimeEntry;
}
function project(id: string, name: string, isBillable = false): Project {
return {
id,
name,
is_billable: isBillable,
} as unknown as Project;
}
function task(id: string, name: string, projectId: string): Task {
return {
id,
name,
project_id: projectId,
} as unknown as Task;
}
describe('useTimesheetGrid', () => {
it('seeds unseen identities and re-sorts seeded rows when project metadata changes', async () => {
const timeEntries = ref([
entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'seed-b',
project_id: 'p-b',
task_id: null,
}),
entry('2026-04-10T10:00:00Z', '2026-04-10T11:00:00Z', {
id: 'seed-a-z',
project_id: 'p-a',
task_id: 't-z',
}),
entry('2026-04-10T11:00:00Z', '2026-04-10T12:00:00Z', {
id: 'seed-a-a',
project_id: 'p-a',
task_id: 't-a',
}),
]);
const projects = ref([project('p-a', 'Bravo'), project('p-b', 'Alpha')]);
const tasks = ref([task('t-z', 'Zulu Task', 'p-a'), task('t-a', 'Alpha Task', 'p-a')]);
const { rows } = useTimesheetGrid(
timeEntries,
ref(WEEK_DAYS),
projects,
tasks,
ref<Dayjs | null>(null)
);
expect(rows.value.map((row) => `${row.projectId}:${row.taskId}`)).toEqual([
'p-b:null',
'p-a:t-a',
'p-a:t-z',
]);
projects.value = [project('p-a', 'Aardvark'), project('p-b', 'Zulu')];
await nextTick();
expect(rows.value.map((row) => `${row.projectId}:${row.taskId}`)).toEqual([
'p-a:t-a',
'p-a:t-z',
'p-b:null',
]);
});
it('keeps user-added slots below seeded rows in insertion order', async () => {
const timeEntries = ref([
entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'seed-b',
project_id: 'p-seed-b',
}),
entry('2026-04-10T10:00:00Z', '2026-04-10T11:00:00Z', {
id: 'seed-a',
project_id: 'p-seed-a',
}),
]);
const projects = ref([
project('p-seed-a', 'Bravo Seed'),
project('p-seed-b', 'Alpha Seed'),
project('p-user-1', 'Alpha User'),
project('p-user-2', 'Zulu User'),
]);
const { rows, addSlot } = useTimesheetGrid(
timeEntries,
ref(WEEK_DAYS),
projects,
ref<Task[]>([]),
ref<Dayjs | null>(null)
);
addSlot('p-user-2', null, false, []);
addSlot('p-user-1', null, false, []);
await nextTick();
expect(rows.value.map((row) => row.projectId)).toEqual([
'p-seed-b',
'p-seed-a',
'p-user-2',
'p-user-1',
]);
projects.value = [
project('p-seed-a', 'Zulu Seed'),
project('p-seed-b', 'Alpha Seed'),
project('p-user-1', 'Aardvark User'),
project('p-user-2', 'Bravo User'),
];
await nextTick();
expect(rows.value.map((row) => row.projectId)).toEqual([
'p-seed-b',
'p-seed-a',
'p-user-2',
'p-user-1',
]);
});
it('assigns entries to the first duplicate-identity slot and leaves later duplicates empty', async () => {
const timeEntries = ref<TimeEntry[]>([]);
const { rows, addSlot } = useTimesheetGrid(
timeEntries,
ref(WEEK_DAYS),
ref([project('p-1', 'Project One')]),
ref<Task[]>([]),
ref<Dayjs | null>(null)
);
const firstKey = addSlot('p-1', null, false, []);
const secondKey = addSlot('p-1', null, false, []);
await nextTick();
timeEntries.value = [
entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', { project_id: 'p-1' }),
];
await nextTick();
expect(rows.value).toHaveLength(2);
expect(rows.value[0]?.key).toBe(firstKey);
expect(rows.value[0]?.totalSeconds).toBe(3600);
expect(rows.value[1]?.key).toBe(secondKey);
expect(rows.value[1]?.totalSeconds).toBe(0);
expect(rows.value[1]?.cells.size).toBe(0);
});
it('keeps no-project user slots aligned with refetched no-project entries', async () => {
const timeEntries = ref<TimeEntry[]>([]);
const { rows, addSlot } = useTimesheetGrid(
timeEntries,
ref(WEEK_DAYS),
ref<Project[]>([]),
ref<Task[]>([]),
ref<Dayjs | null>(null)
);
const rowKey = addSlot(null, null, false, []);
await nextTick();
expect(rows.value).toHaveLength(1);
expect(rows.value[0]).toMatchObject({ key: rowKey, projectId: null });
timeEntries.value = [
entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'no-project',
project_id: null,
}),
];
await nextTick();
expect(rows.value).toHaveLength(1);
expect(rows.value[0]).toMatchObject({
key: rowKey,
projectId: null,
totalSeconds: 3600,
});
});
it('updates a slot identity in place and clearSlots removes all rows', async () => {
const { rows, slots, addSlot, updateSlot, clearSlots } = useTimesheetGrid(
ref<TimeEntry[]>([]),
ref(WEEK_DAYS),
ref([project('p-next', 'Next Project')]),
ref([task('t-1', 'Task One', 'p-next')]),
ref<Dayjs | null>(null)
);
const key = addSlot(null, null, false, []);
await nextTick();
updateSlot(key, {
projectId: 'p-next',
taskId: 't-1',
billable: true,
tags: ['b-tag', 'a-tag'],
});
await nextTick();
expect(rows.value).toHaveLength(1);
expect(rows.value[0]).toMatchObject({
key,
projectId: 'p-next',
taskId: 't-1',
billable: true,
tags: ['a-tag', 'b-tag'],
});
clearSlots();
await nextTick();
expect(rows.value).toHaveLength(0);
expect(slots.value).toHaveLength(0);
});
it('includes running entries in row and week totals using the live timer clock', () => {
const currentTime = ref(dayjs.utc('2026-04-10T10:00:00Z'));
const runningEntry = entry('2026-04-10T09:00:00Z', null, { id: 'running' });
const { rows, dayTotals, grandTotal } = useTimesheetGrid(
ref([runningEntry]),
ref(WEEK_DAYS),
ref<Project[]>([]),
ref<Task[]>([]),
currentTime
);
expect(rows.value).toHaveLength(1);
expect(rows.value[0]?.cells.get(4)?.totalSeconds).toBe(3600);
expect(rows.value[0]?.totalSeconds).toBe(3600);
expect(dayTotals.value[4]).toBe(3600);
expect(grandTotal.value).toBe(3600);
currentTime.value = dayjs.utc('2026-04-10T11:30:00Z');
expect(rows.value[0]?.cells.get(4)?.totalSeconds).toBe(9000);
expect(rows.value[0]?.totalSeconds).toBe(9000);
expect(dayTotals.value[4]).toBe(9000);
expect(grandTotal.value).toBe(9000);
});
it('always seeds a break row pinned below all other rows when breaks are enabled', async () => {
const timeEntries = ref([
entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'work-1',
project_id: 'p-1',
type: 'work',
}),
]);
const projects = ref([project('p-1', 'Alpha')]);
const { rows, addSlot } = useTimesheetGrid(
timeEntries,
ref(WEEK_DAYS),
projects,
ref<Task[]>([]),
ref<Dayjs | null>(null),
ref(true)
);
expect(rows.value).toHaveLength(2);
expect(rows.value[1]?.type).toBe('break');
expect(rows.value[1]?.totalSeconds).toBe(0);
// User-added work rows stay above the break row
addSlot('p-1', null, true, []);
timeEntries.value = [...timeEntries.value];
await nextTick();
expect(rows.value).toHaveLength(3);
expect(rows.value[2]?.type).toBe('break');
});
it('claims break entries for the seeded break row and does not duplicate it', () => {
const breakEntry = entry('2026-04-10T12:00:00Z', '2026-04-10T12:30:00Z', {
id: 'break-1',
project_id: null,
type: 'break',
} as Partial<TimeEntry>);
const { rows } = useTimesheetGrid(
ref([breakEntry]),
ref(WEEK_DAYS),
ref<Project[]>([]),
ref<Task[]>([]),
ref<Dayjs | null>(null),
ref(true)
);
expect(rows.value).toHaveLength(1);
expect(rows.value[0]?.type).toBe('break');
expect(rows.value[0]?.totalSeconds).toBe(1800);
});
it('sums break time into break totals and keeps it out of the worked totals', () => {
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'work-1',
project_id: 'p-1',
type: 'work',
});
const brk = entry('2026-04-10T12:00:00Z', '2026-04-10T12:30:00Z', {
id: 'break-1',
project_id: null,
type: 'break',
} as Partial<TimeEntry>);
const { dayTotals, grandTotal, breakDayTotals, breakGrandTotal } = useTimesheetGrid(
ref([work, brk]),
ref(WEEK_DAYS),
ref([project('p-1', 'Alpha')]),
ref<Task[]>([]),
ref<Dayjs | null>(null),
ref(true)
);
// 2026-04-10 is dayIndex 4. Worked totals see only the 1h work entry.
expect(dayTotals.value[4]).toBe(3600);
expect(grandTotal.value).toBe(3600);
// Break time is tallied separately, per day and for the week.
expect(breakDayTotals.value[4]).toBe(1800);
expect(breakGrandTotal.value).toBe(1800);
// Days without a break contribute nothing.
expect(breakDayTotals.value[0]).toBe(0);
});
it('does not seed a break row when breaks are disabled', () => {
const { rows } = useTimesheetGrid(
ref([
entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'work-1',
project_id: 'p-1',
type: 'work',
}),
]),
ref(WEEK_DAYS),
ref([project('p-1', 'Alpha')]),
ref<Task[]>([]),
ref<Dayjs | null>(null),
ref(false)
);
expect(rows.value).toHaveLength(1);
expect(rows.value[0]?.type).toBe('work');
});
});