mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 12:12:15 +01:00
add break time entries and simplified time tracker ui
This commit is contained in:
191
resources/js/utils/timesheet/useBreakPlacement.test.ts
Normal file
191
resources/js/utils/timesheet/useBreakPlacement.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useBreakPlacement, BreakPlacementDeferred } from './useBreakPlacement';
|
||||
import { api } from '@/packages/api/src';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import type { TimesheetRow } from '@/utils/useTimesheetGrid';
|
||||
|
||||
const addNotification = vi.fn();
|
||||
|
||||
vi.mock('@/utils/useUser', () => ({
|
||||
getCurrentOrganizationId: vi.fn(() => 'org-1'),
|
||||
getCurrentMembershipId: vi.fn(() => 'mem-1'),
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/vue-query', () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/notification', () => ({
|
||||
useNotificationsStore: () => ({ addNotification }),
|
||||
}));
|
||||
|
||||
vi.mock('@/packages/api/src', () => ({
|
||||
api: {
|
||||
createTimeEntry: vi.fn(async () => ({ data: { id: 'new-id' } })),
|
||||
updateTimeEntry: vi.fn(async () => undefined),
|
||||
deleteTimeEntry: vi.fn(async () => undefined),
|
||||
deleteTimeEntries: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const apiMocks = vi.mocked(api);
|
||||
const DATE = '2026-04-10';
|
||||
const HOUR = 3600;
|
||||
|
||||
function entry(start: string, end: string | null, overrides: Partial<TimeEntry> = {}): TimeEntry {
|
||||
return {
|
||||
id: overrides.id ?? `e-${start}`,
|
||||
start,
|
||||
end,
|
||||
description: '',
|
||||
member_id: 'mem-1',
|
||||
project_id: 'p-1',
|
||||
task_id: null,
|
||||
billable: false,
|
||||
tags: [],
|
||||
type: 'work',
|
||||
...overrides,
|
||||
} as unknown as TimeEntry;
|
||||
}
|
||||
|
||||
const breakRow: TimesheetRow = {
|
||||
key: 'break-row',
|
||||
projectId: null,
|
||||
taskId: null,
|
||||
billable: false,
|
||||
tags: [],
|
||||
type: 'break',
|
||||
cells: new Map(),
|
||||
totalSeconds: 0,
|
||||
};
|
||||
|
||||
function setup(allEntries: TimeEntry[]) {
|
||||
const createCell = vi.fn(async () => undefined);
|
||||
const updateEntry = vi.fn(async () => undefined);
|
||||
const bp = useBreakPlacement({
|
||||
weekDays: ref([DATE, '2026-04-11', '2026-04-12']),
|
||||
timeEntries: ref(allEntries),
|
||||
requireOrgId: () => 'org-1',
|
||||
createCell,
|
||||
updateEntry,
|
||||
});
|
||||
return { bp, createCell, updateEntry };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
apiMocks.createTimeEntry.mockClear();
|
||||
apiMocks.updateTimeEntry.mockClear();
|
||||
addNotification.mockClear();
|
||||
});
|
||||
|
||||
describe('useBreakPlacement.placeBreak', () => {
|
||||
it('saves the break directly when it drops into a valid gap', async () => {
|
||||
const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', {
|
||||
id: 'morning',
|
||||
});
|
||||
const afternoon = entry('2026-04-10T13:00:00Z', '2026-04-10T17:00:00Z', {
|
||||
id: 'afternoon',
|
||||
});
|
||||
const { bp } = setup([morning, afternoon]);
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR); // exactly fills the 12:00-13:00 gap
|
||||
|
||||
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.createTimeEntry.mock.calls[0]![0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
start: '2026-04-10T12:00:00Z',
|
||||
end: '2026-04-10T13:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(bp.breakPlacementRequest.value).toBeNull();
|
||||
});
|
||||
|
||||
it('never places a break over a running entry', async () => {
|
||||
const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'morning' });
|
||||
const afternoon = entry('2026-04-10T13:00:00Z', '2026-04-10T17:00:00Z', {
|
||||
id: 'afternoon',
|
||||
});
|
||||
const running = entry('2026-04-10T12:30:00Z', null, { id: 'running' });
|
||||
const { bp } = setup([morning, afternoon, running]);
|
||||
|
||||
// Centered placement (12:15-12:45) would overlap the running entry, so
|
||||
// the break slides to the free part of the gap instead.
|
||||
await bp.placeBreak(breakRow, 0, HOUR / 2);
|
||||
|
||||
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.createTimeEntry.mock.calls[0]![0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
start: '2026-04-10T12:00:00Z',
|
||||
end: '2026-04-10T12:30:00Z',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('defers to the split modal when a single work entry blocks every gap', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const { bp } = setup([work]);
|
||||
|
||||
await expect(bp.placeBreak(breakRow, 0, HOUR)).rejects.toBeInstanceOf(
|
||||
BreakPlacementDeferred
|
||||
);
|
||||
expect(bp.breakPlacementRequest.value).toEqual(
|
||||
expect.objectContaining({
|
||||
durationSeconds: HOUR,
|
||||
replaceBreakId: null,
|
||||
workEntries: [expect.objectContaining({ id: 'w1' })],
|
||||
})
|
||||
);
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useBreakPlacement.applyBreakPlacement (split)', () => {
|
||||
it('shrinks the original, creates the second half, and saves the break', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const { bp, updateEntry } = setup([work]);
|
||||
|
||||
// Open the placement request, then commit the break at noon.
|
||||
await bp.placeBreak(breakRow, 0, HOUR).catch(() => undefined);
|
||||
await bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR);
|
||||
|
||||
// Original work shrunk to its first half.
|
||||
expect(updateEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'w1',
|
||||
start: '2026-04-10T09:00:00Z',
|
||||
end: '2026-04-10T12:00:00Z',
|
||||
})
|
||||
);
|
||||
// Second half of work + the break both created.
|
||||
const created = apiMocks.createTimeEntry.mock.calls.map((c) => c[0]);
|
||||
expect(created).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'work',
|
||||
start: '2026-04-10T13:00:00Z',
|
||||
end: '2026-04-10T17:00:00Z',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
start: '2026-04-10T12:00:00Z',
|
||||
end: '2026-04-10T13:00:00Z',
|
||||
}),
|
||||
])
|
||||
);
|
||||
// Request cleared and a success toast surfaced.
|
||||
expect(bp.breakPlacementRequest.value).toBeNull();
|
||||
expect(addNotification).toHaveBeenCalledWith('success', 'Break added', expect.any(String));
|
||||
});
|
||||
|
||||
it('does nothing when there is no pending placement request', async () => {
|
||||
const { bp, updateEntry } = setup([]);
|
||||
await bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR);
|
||||
expect(updateEntry).not.toHaveBeenCalled();
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user