mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
Compare commits
7 Commits
64ca1e9115
...
feature/br
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbaa0ae45b | ||
|
|
40dc3842ce | ||
|
|
bbd7d286d9 | ||
|
|
f6311eddfd | ||
|
|
2639b62744 | ||
|
|
8a68673a48 | ||
|
|
34f8eb0970 |
@@ -132,8 +132,12 @@ class ReportPropertiesDto implements Castable
|
||||
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;
|
||||
// Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB
|
||||
$dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
|
||||
// Note: timeEntryType was added later so it is possible that the value is missing in persisted reports in the DB
|
||||
$dto->timeEntryType = isset($data->timeEntryType) ? TimeEntryType::from($data->timeEntryType) : null;
|
||||
// Note: timeEntryType was added later, reports persisted before that are missing the value and default to "work"
|
||||
if (property_exists($data, 'timeEntryType')) {
|
||||
$dto->timeEntryType = $data->timeEntryType !== null ? TimeEntryType::from($data->timeEntryType) : null;
|
||||
} else {
|
||||
$dto->timeEntryType = TimeEntryType::Work;
|
||||
}
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
|
||||
async function goToOrganizationSettings(page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await page.locator('[data-testid="organization_switcher"]:visible').click();
|
||||
await page.getByText('Organization Settings').click();
|
||||
await page.getByRole('menuitem', { name: 'Organization Settings' }).click();
|
||||
}
|
||||
|
||||
async function createTimeEntry(page, duration: string) {
|
||||
|
||||
@@ -50,7 +50,7 @@ async function goToTimeOverview(page: Page) {
|
||||
async function goToOrganizationSettings(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await page.locator('[data-testid="organization_switcher"]:visible').click();
|
||||
await page.getByText('Organization Settings').click();
|
||||
await page.getByRole('menuitem', { name: 'Organization Settings' }).click();
|
||||
}
|
||||
|
||||
async function createEmptyTimeEntry(page: Page) {
|
||||
@@ -2315,7 +2315,9 @@ test('test that break entries show a break badge and split day total on the time
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await expect(page.getByTestId('break_badge').first()).toBeVisible();
|
||||
await expect(page.getByTestId('break_badge').first()).toContainText('Break');
|
||||
// Day heading shows the break portion separately from worked time
|
||||
// Day heading shows worked time first, then the break portion
|
||||
await expect(page.getByTestId('day_break_duration').first()).toBeVisible();
|
||||
await expect(page.getByTestId('day_break_duration').first()).toContainText('break');
|
||||
await expect(page.getByTestId('day_break_duration').first().locator('..')).toContainText(
|
||||
'2h 00min work · 0h 30min break'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createTimeEntryWithTimestampsViaApi,
|
||||
getTimeEntriesViaApi,
|
||||
updateOrganizationSettingViaApi,
|
||||
type TestContext,
|
||||
} from './utils/api';
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -65,6 +66,34 @@ function addRowButton(page: Page) {
|
||||
return page.getByRole('button', { name: /Add row/i }).first();
|
||||
}
|
||||
|
||||
async function fillBreakCell(page: Page, hours: string, dayIndex = 0) {
|
||||
const input = page
|
||||
.locator('[data-testid="timesheet_row"]')
|
||||
.filter({ has: page.getByText('Break', { exact: true }) })
|
||||
.locator('[data-testid="timesheet_cell"]')
|
||||
.nth(dayIndex)
|
||||
.locator('input');
|
||||
await input.click();
|
||||
await input.fill(hours);
|
||||
return input;
|
||||
}
|
||||
|
||||
function waitForBreakCreated(page: Page) {
|
||||
return page.waitForResponse(
|
||||
async (resp) =>
|
||||
resp.url().includes('/time-entries') &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201 &&
|
||||
(await resp.json()).data.type === 'break'
|
||||
);
|
||||
}
|
||||
|
||||
async function getDayEntriesViaApi(ctx: TestContext, day: string) {
|
||||
return (await getTimeEntriesViaApi(ctx))
|
||||
.filter((e) => e.start.startsWith(day))
|
||||
.sort((a, b) => a.start.localeCompare(b.start));
|
||||
}
|
||||
|
||||
async function chooseRowIdentity(page: Page, optionName: string) {
|
||||
await addRowButton(page).click();
|
||||
|
||||
@@ -665,37 +694,27 @@ test('test that adding a timesheet break to a full day splits the work entry via
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
|
||||
// The break row is always present — enter a 30m break on Monday
|
||||
const breakRow = page
|
||||
.locator('[data-testid="timesheet_row"]')
|
||||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||||
await breakCell.click();
|
||||
await breakCell.fill('0.5');
|
||||
const breakCell = await fillBreakCell(page, '0.5');
|
||||
await breakCell.press('Enter');
|
||||
|
||||
// The placement modal opens with the split preview
|
||||
// The placement modal opens with the split preview, naming the entry that
|
||||
// will be split so the user can recognize it.
|
||||
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
|
||||
await expect(page.getByTestId('break_placement_summary')).toContainText(
|
||||
'No Project · Split me'
|
||||
);
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
async (resp) =>
|
||||
resp.url().includes('/time-entries') &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201 &&
|
||||
(await resp.json()).data.type === 'break'
|
||||
),
|
||||
waitForBreakCreated(page),
|
||||
page.getByRole('button', { name: 'Add break' }).click(),
|
||||
]);
|
||||
|
||||
// The day now has two work halves and one break, none overlapping
|
||||
const entries = await getTimeEntriesViaApi(ctx);
|
||||
const dayEntries = entries
|
||||
.filter((e) => e.start.startsWith(day))
|
||||
.sort((a, b) => a.start.localeCompare(b.start));
|
||||
expect(dayEntries).toHaveLength(3);
|
||||
expect(dayEntries.map((e) => e.type)).toEqual(['work', 'break', 'work']);
|
||||
// The break sits flush between the two halves
|
||||
expect(dayEntries[0].end).toBe(dayEntries[1].start);
|
||||
expect(dayEntries[1].end).toBe(dayEntries[2].start);
|
||||
// The break is inserted without reducing the eight hours of work.
|
||||
const dayEntries = await getDayEntriesViaApi(ctx, day);
|
||||
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
|
||||
['work', `${day}T09:00:00Z`, `${day}T13:00:00Z`],
|
||||
['break', `${day}T13:00:00Z`, `${day}T13:30:00Z`],
|
||||
['work', `${day}T13:30:00Z`, `${day}T17:30:00Z`],
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that adding a break into an oversized gap places it without moving other entries', async ({
|
||||
@@ -722,28 +741,11 @@ test('test that adding a break into an oversized gap places it without moving ot
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
|
||||
const breakRow = page
|
||||
.locator('[data-testid="timesheet_row"]')
|
||||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||||
await breakCell.click();
|
||||
await breakCell.fill('0.5');
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
async (resp) =>
|
||||
resp.url().includes('/time-entries') &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201 &&
|
||||
(await resp.json()).data.type === 'break'
|
||||
),
|
||||
breakCell.press('Enter'),
|
||||
]);
|
||||
const breakCell = await fillBreakCell(page, '0.5');
|
||||
await Promise.all([waitForBreakCreated(page), breakCell.press('Enter')]);
|
||||
|
||||
await expect(page.getByTestId('break_placement_summary')).not.toBeVisible();
|
||||
const entries = await getTimeEntriesViaApi(ctx);
|
||||
const dayEntries = entries
|
||||
.filter((e) => e.start.startsWith(day))
|
||||
.sort((a, b) => a.start.localeCompare(b.start));
|
||||
const dayEntries = await getDayEntriesViaApi(ctx, day);
|
||||
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
|
||||
['work', `${day}T09:00:00Z`, `${day}T12:00:00Z`],
|
||||
['break', `${day}T12:00:00Z`, `${day}T12:30:00Z`],
|
||||
@@ -775,12 +777,7 @@ test('test that the placement modal warns when the chosen time would leave the b
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
|
||||
const breakRow = page
|
||||
.locator('[data-testid="timesheet_row"]')
|
||||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||||
await breakCell.click();
|
||||
await breakCell.fill('0.5');
|
||||
const breakCell = await fillBreakCell(page, '0.5');
|
||||
await breakCell.press('Enter');
|
||||
|
||||
// Default suggestion sits flush between work → no warning
|
||||
@@ -804,64 +801,23 @@ test('test that the placement modal warns when the chosen time would leave the b
|
||||
|
||||
// The warning is non-blocking: the break can still be added as chosen
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
async (resp) =>
|
||||
resp.url().includes('/time-entries') &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201 &&
|
||||
(await resp.json()).data.type === 'break'
|
||||
),
|
||||
waitForBreakCreated(page),
|
||||
page.getByRole('button', { name: 'Add break' }).click(),
|
||||
]);
|
||||
|
||||
const entries = await getTimeEntriesViaApi(ctx);
|
||||
const dayEntries = entries
|
||||
.filter((e) => e.start.startsWith(day))
|
||||
.sort((a, b) => a.start.localeCompare(b.start));
|
||||
const dayEntries = await getDayEntriesViaApi(ctx, day);
|
||||
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
|
||||
['break', `${day}T07:00:00Z`, `${day}T07:30:00Z`],
|
||||
['work', `${day}T09:00:00Z`, `${day}T12:00:00Z`],
|
||||
['work', `${day}T12:00:00Z`, `${day}T17:00:00Z`],
|
||||
]);
|
||||
// ...and the timesheet now shows the misaligned-break hint for that day
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'does not align with your work entries' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that a misplaced break shows a warning on its timesheet day cell', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Work ends at 10:00 and the break starts hours later with no work after it,
|
||||
// so it is misplaced and its day header should carry the warning hint.
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||||
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T09:00:00Z`,
|
||||
end: `${day}T10:00:00Z`,
|
||||
});
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T14:00:00Z`,
|
||||
end: `${day}T14:30:00Z`,
|
||||
type: 'break',
|
||||
});
|
||||
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
|
||||
// Exactly one warning, sitting in Monday's day header
|
||||
const hint = page.getByRole('button', {
|
||||
name: 'does not align with your work entries',
|
||||
});
|
||||
await expect(hint).toHaveCount(1);
|
||||
await expect(
|
||||
page.getByTestId('timesheet_day_header').first().getByRole('button', {
|
||||
name: 'does not align with your work entries',
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(hint).toBeVisible();
|
||||
|
||||
// The hint links to the calendar on the affected date
|
||||
// The resulting warning links to the calendar on the affected date.
|
||||
await hint.click();
|
||||
await expect(page.getByRole('link', { name: 'Fix in calendar' })).toHaveAttribute(
|
||||
'href',
|
||||
@@ -894,12 +850,7 @@ test('test that editing a timesheet break re-places it as one entry instead of f
|
||||
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
const breakRow = page
|
||||
.locator('[data-testid="timesheet_row"]')
|
||||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||||
await breakCell.click();
|
||||
await breakCell.fill('0.75'); // 45 minutes — still fits the 1h gap, so it stays anchored
|
||||
const breakCell = await fillBreakCell(page, '0.75');
|
||||
await Promise.all([
|
||||
// A break that still fits its gap is re-placed in place (PUT on the same entry),
|
||||
// not deleted and recreated — that's what keeps it a single entry.
|
||||
@@ -915,10 +866,61 @@ test('test that editing a timesheet break re-places it as one entry instead of f
|
||||
|
||||
// Still exactly one break on the day (not fragmented). It stays anchored at its current
|
||||
// start (12:15) rather than re-centering, growing its end to 13:00 to reach 45 minutes.
|
||||
const after = await getTimeEntriesViaApi(ctx);
|
||||
const breaks = after.filter((e) => e.start.startsWith(day) && e.type === 'break');
|
||||
const breaks = (await getDayEntriesViaApi(ctx, day)).filter((e) => e.type === 'break');
|
||||
expect(breaks).toHaveLength(1);
|
||||
expect(breaks[0].duration).toBe(2700);
|
||||
expect(breaks[0].start).toBe(`${day}T12:15:00Z`);
|
||||
expect(breaks[0].end).toBe(`${day}T13:00:00Z`);
|
||||
});
|
||||
|
||||
test('test that editing an adjacent break vacates its old slot before extending work', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// The existing break must move before work can extend through its old slot.
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
breaks_enabled: true,
|
||||
prevent_overlapping_time_entries: true,
|
||||
});
|
||||
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T09:00:00Z`,
|
||||
end: `${day}T17:00:00Z`,
|
||||
description: 'Work before break',
|
||||
});
|
||||
const breakEntry = await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T17:00:00Z`,
|
||||
end: `${day}T17:30:00Z`,
|
||||
type: 'break',
|
||||
});
|
||||
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
const breakCell = await fillBreakCell(page, '1');
|
||||
await breakCell.press('Enter');
|
||||
|
||||
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes(`/time-entries/${breakEntry.id}`) &&
|
||||
resp.request().method() === 'PUT' &&
|
||||
resp.status() === 200
|
||||
),
|
||||
page.waitForResponse(
|
||||
async (resp) =>
|
||||
resp.url().includes('/time-entries') &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201 &&
|
||||
(await resp.json()).data.type === 'work'
|
||||
),
|
||||
page.getByRole('button', { name: 'Add break' }).click(),
|
||||
]);
|
||||
|
||||
const entries = await getDayEntriesViaApi(ctx, day);
|
||||
expect(entries.map((entry) => [entry.id, entry.type, entry.start, entry.end])).toEqual([
|
||||
[expect.any(String), 'work', `${day}T09:00:00Z`, `${day}T13:00:00Z`],
|
||||
[breakEntry.id, 'break', `${day}T13:00:00Z`, `${day}T14:00:00Z`],
|
||||
[expect.any(String), 'work', `${day}T14:00:00Z`, `${day}T18:00:00Z`],
|
||||
]);
|
||||
});
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -8396,7 +8396,7 @@
|
||||
},
|
||||
"resources/js/packages/api": {
|
||||
"name": "@solidtime/api",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"license": "AGPL-3.0",
|
||||
"devDependencies": {
|
||||
"vite-plugin-dts": "^4.5.4"
|
||||
@@ -8411,7 +8411,7 @@
|
||||
},
|
||||
"resources/js/packages/ui": {
|
||||
"name": "@solidtime/ui",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.22",
|
||||
"license": "AGPL-3.0",
|
||||
"devDependencies": {
|
||||
"@types/chroma-js": "^3.1.2",
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
planMoveInsert,
|
||||
planSplitEntry,
|
||||
type BreakPlacementRequest,
|
||||
type Interval,
|
||||
} from '@/utils/timesheet/breakPlacementMath';
|
||||
import { BREAK_GAP_TOLERANCE_MINUTES } from '@/packages/ui/src/utils/breakPlacement';
|
||||
|
||||
const props = defineProps<{
|
||||
request: BreakPlacementRequest | null;
|
||||
apply: (breakStart: string, durationSeconds: number) => Promise<void>;
|
||||
entryLabel: (id: string) => string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ cancel: [] }>();
|
||||
@@ -54,7 +56,11 @@ const durationSeconds = computed(() =>
|
||||
|
||||
const splitPlan = computed(() => {
|
||||
if (!props.request || mode.value !== 'split' || durationSeconds.value <= 0) return null;
|
||||
return planSplitEntry(props.request.workEntries[0]!, durationSeconds.value, utcStart.value);
|
||||
return planSplitEntry(props.request.workEntries[0]!, durationSeconds.value, utcStart.value, {
|
||||
dayStart: props.request.dayStart,
|
||||
dayEnd: props.request.dayEnd,
|
||||
otherEntries: props.request.otherEntries,
|
||||
});
|
||||
});
|
||||
|
||||
const movePlan = computed(() => {
|
||||
@@ -116,31 +122,43 @@ function fmt(iso: string): string {
|
||||
const explanation = computed(() => {
|
||||
if (!props.request) return '';
|
||||
return mode.value === 'split'
|
||||
? "There's no free gap that fits this break, so the work entry will be split and the break placed inside it."
|
||||
? "There's no free gap that fits this break, so the work entry will be split around it. The work moves to make room and keeps its full length."
|
||||
: "There's no free gap that fits this break, so the surrounding entries will be shifted to make room.";
|
||||
});
|
||||
|
||||
// Human-readable summary of what will change, so the user can confirm the edit.
|
||||
const changeSummary = computed<string[]>(() => {
|
||||
interface PlanLine {
|
||||
times: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const changeSummary = computed<PlanLine[]>(() => {
|
||||
const req = props.request;
|
||||
if (!req) return [];
|
||||
const range = (interval: Interval) => `${fmt(interval.start)}–${fmt(interval.end)}`;
|
||||
const moved = (from: Interval, to: Interval) => `${range(from)} → ${range(to)}`;
|
||||
|
||||
if (mode.value === 'split') {
|
||||
const plan = splitPlan.value;
|
||||
if (!plan) return [];
|
||||
const workLabel = props.entryLabel(req.workEntries[0]!.id);
|
||||
return [
|
||||
`${fmt(plan.firstHalf.start)}–${fmt(plan.firstHalf.end)} (work)`,
|
||||
`${fmt(plan.breakSlot.start)}–${fmt(plan.breakSlot.end)} (break)`,
|
||||
`${fmt(plan.secondHalf.start)}–${fmt(plan.secondHalf.end)} (work)`,
|
||||
{ times: range(plan.firstHalf), label: workLabel },
|
||||
{ times: range(plan.breakSlot), label: 'Break' },
|
||||
{ times: range(plan.secondHalf), label: workLabel },
|
||||
...plan.shifted.map((shift) => ({
|
||||
times: moved(req.otherEntries.find((e) => e.id === shift.id)!, shift),
|
||||
label: props.entryLabel(shift.id),
|
||||
})),
|
||||
];
|
||||
}
|
||||
const plan = movePlan.value;
|
||||
if (!plan) return [];
|
||||
if (plan.shifted.length === 0) return ['No entries need to move.'];
|
||||
if (plan.shifted.length === 0) return [{ times: 'No entries need to move.', label: '' }];
|
||||
return plan.shifted.map((shift) => {
|
||||
const isBreak = props.request!.otherEntries.some((e) => e.id === shift.id);
|
||||
const original =
|
||||
props.request!.workEntries.find((e) => e.id === shift.id) ??
|
||||
props.request!.otherEntries.find((e) => e.id === shift.id)!;
|
||||
const label = `${fmt(original.start)}–${fmt(original.end)} → ${fmt(shift.start)}–${fmt(shift.end)}`;
|
||||
return isBreak ? `${label} (break)` : label;
|
||||
req.workEntries.find((e) => e.id === shift.id) ??
|
||||
req.otherEntries.find((e) => e.id === shift.id)!;
|
||||
return { times: moved(original, shift), label: props.entryLabel(shift.id) };
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,8 +200,14 @@ async function submit() {
|
||||
<div class="text-xs uppercase tracking-wide text-text-tertiary">
|
||||
{{ mode === 'split' ? 'Result' : 'Entries that move' }}
|
||||
</div>
|
||||
<div v-for="(line, index) in changeSummary" :key="index" class="tabular-nums">
|
||||
{{ line }}
|
||||
<div
|
||||
v-for="(line, index) in changeSummary"
|
||||
:key="index"
|
||||
class="flex items-baseline gap-2">
|
||||
<span class="tabular-nums whitespace-nowrap">{{ line.times }}</span>
|
||||
<span v-if="line.label" class="text-text-tertiary truncate">
|
||||
{{ line.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -202,7 +226,7 @@ async function submit() {
|
||||
class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
|
||||
{{
|
||||
mode === 'split'
|
||||
? "This break doesn't fit there — it must lie inside the work entry, leaving at least a minute of work on each side."
|
||||
? "This break doesn't fit there. It has to sit inside the work, leaving at least a minute of work on each side, and the work around it has to stay inside the day."
|
||||
: "This break doesn't fit at that time without pushing an entry outside the day. Try a shorter break or a different time."
|
||||
}}
|
||||
</div>
|
||||
|
||||
@@ -186,19 +186,27 @@ const emit = defineEmits<{
|
||||
? 'text-text-primary'
|
||||
: 'text-text-secondary',
|
||||
]">
|
||||
<span>{{ total > 0 ? formatDuration(total) : '-' }}</span>
|
||||
<span
|
||||
>{{ formatDuration(total)
|
||||
}}<template v-if="total > 0 && (breakDayTotals[dayIndex] ?? 0) > 0">
|
||||
work</template
|
||||
></span
|
||||
>
|
||||
<span
|
||||
v-if="(breakDayTotals[dayIndex] ?? 0) > 0"
|
||||
class="font-normal text-text-tertiary">
|
||||
+{{ formatDuration(breakDayTotals[dayIndex] ?? 0) }} break
|
||||
</span>
|
||||
class="font-normal text-text-tertiary"
|
||||
>{{ formatDuration(breakDayTotals[dayIndex] ?? 0) }} break</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-end justify-center border-t border-default-background-separator bg-background dark:bg-secondary pl-3 pr-3 py-1 text-xs font-semibold text-text-primary leading-tight">
|
||||
<span>{{ weekTotalFormatted }}</span>
|
||||
<span v-if="breakGrandTotal > 0" class="font-normal text-text-tertiary">
|
||||
+{{ formatDuration(breakGrandTotal) }} break
|
||||
</span>
|
||||
<span
|
||||
>{{ weekTotalFormatted
|
||||
}}<template v-if="breakGrandTotal > 0"> work</template></span
|
||||
>
|
||||
<span v-if="breakGrandTotal > 0" class="font-normal text-text-tertiary"
|
||||
>{{ formatDuration(breakGrandTotal) }} break</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="border-t border-default-background-separator bg-background dark:bg-secondary"></div>
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
import { BellAlertIcon, XMarkIcon } from '@heroicons/vue/20/solid';
|
||||
import { SecondaryButton } from '@/packages/ui/src';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
const showReleaseInfo = useStorage('showReleaseInfo-desktop', true);
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { canUpdateOrganization } from '@/utils/permissions';
|
||||
const showReleaseInfo = useStorage('showReleaseInfo-breaks', true);
|
||||
|
||||
function openDesktopGithubRepo() {
|
||||
window.open('https://github.com/solidtime-io/solidtime-desktop', '_blank')?.focus();
|
||||
function openOrganizationSettings() {
|
||||
router.visit(route('organizations.show', getCurrentOrganizationId()));
|
||||
}
|
||||
|
||||
function openBreaksDocs() {
|
||||
window.open('https://docs.solidtime.io/user-guide/breaks', '_blank')?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,7 +23,7 @@ function openDesktopGithubRepo() {
|
||||
<div
|
||||
class="text-xs pb-1.5 font-semibold text-text-tertiary flex items-center space-x-1">
|
||||
<BellAlertIcon class="w-3.5"></BellAlertIcon>
|
||||
<span> New Update </span>
|
||||
<span> New Feature </span>
|
||||
</div>
|
||||
<button>
|
||||
<XMarkIcon
|
||||
@@ -26,14 +33,22 @@ function openDesktopGithubRepo() {
|
||||
</div>
|
||||
|
||||
<p class="text-xs">
|
||||
<span class="font-semibold">Solidtime Desktop Beta</span> is here! Test our brand
|
||||
new clients for Windows, macOS and Linux now.
|
||||
<span class="font-semibold">Breaks</span> are here! Enable them in the organization
|
||||
settings to track break time in the time tracker and timesheet.
|
||||
</p>
|
||||
<SecondaryButton
|
||||
v-if="canUpdateOrganization()"
|
||||
size="small"
|
||||
class="w-full text-center justify-center mt-1.5"
|
||||
@click="openDesktopGithubRepo"
|
||||
>Download now</SecondaryButton
|
||||
@click="openOrganizationSettings"
|
||||
>Enable now</SecondaryButton
|
||||
>
|
||||
<SecondaryButton
|
||||
v-else
|
||||
size="small"
|
||||
class="w-full text-center justify-center mt-1.5"
|
||||
@click="openBreaksDocs"
|
||||
>Learn more</SecondaryButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,24 @@ const {
|
||||
breakPlacementRequest,
|
||||
applyBreakPlacement,
|
||||
dismissBreakPlacement,
|
||||
} = useTimesheetCellMutations(weekDays, allTimeEntries, rows, removeSlot);
|
||||
} = useTimesheetCellMutations(
|
||||
weekDays,
|
||||
allTimeEntries,
|
||||
rows,
|
||||
removeSlot,
|
||||
() => organization.value?.prevent_overlapping_time_entries ?? false
|
||||
);
|
||||
|
||||
function breakPlanEntryLabel(id: string): string {
|
||||
const entry = allTimeEntries.value.find((e) => e.id === id);
|
||||
if (!entry) return '';
|
||||
if (entry.type === 'break') return 'Break';
|
||||
const project = projects.value.find((p) => p.id === entry.project_id);
|
||||
const task = tasks.value.find((t) => t.id === entry.task_id);
|
||||
return [project?.name ?? 'No Project', task?.name, entry.description]
|
||||
.filter((part): part is string => !!part)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// Local dates (YYYY-MM-DD) that have a misplaced break. There is only one break
|
||||
// row, so a flat set is enough — its cells show a warning for dates in the set.
|
||||
@@ -251,6 +268,7 @@ async function createTag(name: string): Promise<Tag | undefined> {
|
||||
<BreakPlacementModal
|
||||
:request="breakPlacementRequest"
|
||||
:apply="applyBreakPlacement"
|
||||
:entry-label="breakPlanEntryLabel"
|
||||
@cancel="dismissBreakPlacement" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@solidtime/api",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"description": "Package containing the solidtime api client and type declarations",
|
||||
"main": "./dist/solidtime-api.umd.cjs",
|
||||
"module": "./dist/solidtime-api.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@solidtime/ui",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.22",
|
||||
"description": "Package containing the solidtime ui components",
|
||||
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
||||
"module": "./dist/solidtime-ui-lib.js",
|
||||
|
||||
@@ -17,6 +17,27 @@ const breakSecondsValue = computed(() => props.breakSeconds ?? 0);
|
||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
||||
const intervalFormat = computed(() => organization?.value?.interval_format);
|
||||
const numberFormat = computed(() => organization?.value?.number_format);
|
||||
|
||||
const hasBreak = computed(() => breakSecondsValue.value > 0);
|
||||
|
||||
// Without breaks the work time stands alone, so it needs no label. Once break
|
||||
// time joins it, both halves are labelled to keep them apart.
|
||||
const durationSummary = computed(() => {
|
||||
const work = formatHumanReadableDuration(
|
||||
totalSecondsValue.value,
|
||||
intervalFormat.value,
|
||||
numberFormat.value
|
||||
);
|
||||
if (!hasBreak.value) {
|
||||
return work;
|
||||
}
|
||||
const breakTime = formatHumanReadableDuration(
|
||||
breakSecondsValue.value,
|
||||
intervalFormat.value,
|
||||
numberFormat.value
|
||||
);
|
||||
return `${work} work · ${breakTime} break`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,12 +45,10 @@ const numberFormat = computed(() => organization?.value?.number_format);
|
||||
<div class="text-sm text-foreground" :class="isToday ? 'font-semibold' : 'font-medium'">
|
||||
{{ date.format('ddd') }} {{ date.date() }}
|
||||
</div>
|
||||
<span class="block text-xs text-muted-foreground font-medium mt-0.5">
|
||||
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
|
||||
<template v-if="breakSecondsValue > 0">
|
||||
· {{ formatHumanReadableDuration(breakSecondsValue, intervalFormat, numberFormat) }}
|
||||
break
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
class="block text-xs text-muted-foreground font-medium mt-0.5"
|
||||
data-testid="day_duration_summary"
|
||||
>{{ durationSummary }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
formatWeekday,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import Checkbox from '../Input/Checkbox.vue';
|
||||
import { inject, type ComputedRef } from 'vue';
|
||||
import { computed, inject, type ComputedRef } from 'vue';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
import { CalendarIcon } from '@heroicons/vue/20/solid';
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
date: string;
|
||||
duration: number;
|
||||
@@ -23,6 +23,26 @@ withDefaults(
|
||||
breakDuration: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const hasBreak = computed(() => props.breakDuration > 0);
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
return formatHumanReadableDuration(
|
||||
seconds,
|
||||
organization?.value?.interval_format,
|
||||
organization?.value?.number_format
|
||||
);
|
||||
}
|
||||
|
||||
// Without breaks the work time stands alone, so it needs no label. Once break
|
||||
// time joins it, both halves are labelled to keep them apart. The separator and
|
||||
// its spacing live inside the interpolated strings so the markup cannot collapse
|
||||
// them away.
|
||||
const workLabel = computed(() =>
|
||||
hasBreak.value ? `${formatDuration(props.duration)} work` : formatDuration(props.duration)
|
||||
);
|
||||
const breakLabel = computed(() => ` · ${formatDuration(props.breakDuration)} break`);
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectAll: [];
|
||||
unselectAll: [];
|
||||
@@ -60,29 +80,14 @@ function selectUnselectAll(value: boolean) {
|
||||
{{ formatDate(date, organization?.date_format) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-text-primary pr-2 @lg:pr-[92px]">
|
||||
<div class="flex items-center text-text-primary pr-2 @lg:pr-[92px]">
|
||||
<span class="font-medium">{{ workLabel }}</span>
|
||||
<span
|
||||
v-if="breakDuration > 0"
|
||||
v-if="hasBreak"
|
||||
data-testid="day_break_duration"
|
||||
class="text-text-secondary font-normal mr-2">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
breakDuration,
|
||||
organization?.interval_format,
|
||||
organization?.number_format
|
||||
)
|
||||
}}
|
||||
break ·
|
||||
</span>
|
||||
<span class="font-medium">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
duration,
|
||||
organization?.interval_format,
|
||||
organization?.number_format
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
class="text-text-secondary font-normal whitespace-pre"
|
||||
>{{ breakLabel }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
|
||||
@@ -2,14 +2,31 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import {
|
||||
findMisplacedBreak,
|
||||
getBreakPlacementHint,
|
||||
type BreakPlacementHint,
|
||||
} from '@/packages/ui/src/utils/breakPlacement';
|
||||
|
||||
// Decision logic behind the aggregate (collapsed grouped-break) row's placement
|
||||
// warning: the row shows the hint — and navigates the calendar — based on the
|
||||
// first misplaced break in the group.
|
||||
function breakEntry(id: string): TimeEntry {
|
||||
return { id, type: 'break', start: '2026-07-14T10:00:00Z' } as TimeEntry;
|
||||
function entry(
|
||||
id: string,
|
||||
start: string,
|
||||
end: string | null,
|
||||
type: 'work' | 'break' = 'work'
|
||||
): TimeEntry {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
start,
|
||||
end,
|
||||
duration: end ? (Date.parse(end) - Date.parse(start)) / 1000 : null,
|
||||
organization_id: 'organization-1',
|
||||
user_id: 'user-1',
|
||||
member_id: 'member-1',
|
||||
project_id: type === 'break' ? null : 'project-1',
|
||||
task_id: null,
|
||||
billable: false,
|
||||
description: null,
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
|
||||
function hint(misplaced: boolean): BreakPlacementHint {
|
||||
@@ -24,34 +41,99 @@ function hint(misplaced: boolean): BreakPlacementHint {
|
||||
|
||||
describe('findMisplacedBreak', () => {
|
||||
it('returns the first misplaced break in a group', () => {
|
||||
const entries = [breakEntry('break-a'), breakEntry('break-b')];
|
||||
const entries = [
|
||||
entry('break-a', '2026-07-14T10:00:00Z', '2026-07-14T10:30:00Z', 'break'),
|
||||
entry('break-b', '2026-07-14T12:00:00Z', '2026-07-14T12:30:00Z', 'break'),
|
||||
];
|
||||
const result = findMisplacedBreak(entries, {
|
||||
'break-a': hint(false),
|
||||
'break-b': hint(true),
|
||||
});
|
||||
expect(result?.id).toBe('break-b');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when no break in the group is misplaced', () => {
|
||||
const entries = [breakEntry('break-a'), breakEntry('break-b')];
|
||||
const result = findMisplacedBreak(entries, {
|
||||
'break-a': hint(false),
|
||||
'break-b': hint(false),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
describe('getBreakPlacementHint', () => {
|
||||
const breakEntry = entry('break', '2026-07-14T12:00:00Z', '2026-07-14T12:30:00Z', 'break');
|
||||
|
||||
it('accepts work touching both sides of the break', () => {
|
||||
const result = getBreakPlacementHint(breakEntry, [
|
||||
entry('morning', '2026-07-14T09:00:00Z', '2026-07-14T12:00:00Z'),
|
||||
entry('afternoon', '2026-07-14T12:30:00Z', '2026-07-14T17:00:00Z'),
|
||||
]);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
misplaced: false,
|
||||
gapBeforeSeconds: 0,
|
||||
gapAfterSeconds: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when the group has no placement hints', () => {
|
||||
const entries = [breakEntry('break-a'), breakEntry('break-b')];
|
||||
expect(findMisplacedBreak(entries, {})).toBeNull();
|
||||
it('accepts gaps exactly at the placement tolerance', () => {
|
||||
const result = getBreakPlacementHint(breakEntry, [
|
||||
entry('morning', '2026-07-14T09:00:00Z', '2026-07-14T11:30:00Z'),
|
||||
entry('afternoon', '2026-07-14T13:00:00Z', '2026-07-14T17:00:00Z'),
|
||||
]);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
misplaced: false,
|
||||
gapBeforeSeconds: 30 * 60,
|
||||
gapAfterSeconds: 30 * 60,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores hints for entries that are not in the group', () => {
|
||||
const entries = [breakEntry('break-a')];
|
||||
const result = findMisplacedBreak(entries, {
|
||||
'break-a': hint(false),
|
||||
'break-elsewhere': hint(true),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
it('flags a completed break when work is missing on either side', () => {
|
||||
const noPreviousWork = getBreakPlacementHint(breakEntry, [
|
||||
entry('afternoon', '2026-07-14T12:30:00Z', '2026-07-14T17:00:00Z'),
|
||||
]);
|
||||
const noNextWork = getBreakPlacementHint(breakEntry, [
|
||||
entry('morning', '2026-07-14T09:00:00Z', '2026-07-14T12:00:00Z'),
|
||||
]);
|
||||
|
||||
expect(noPreviousWork).toEqual(
|
||||
expect.objectContaining({ misplaced: true, gapBeforeSeconds: null })
|
||||
);
|
||||
expect(noNextWork).toEqual(
|
||||
expect.objectContaining({ misplaced: true, gapAfterSeconds: null })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not require work after a running break', () => {
|
||||
const runningBreak = entry('break', '2026-07-14T12:00:00Z', null, 'break');
|
||||
const result = getBreakPlacementHint(runningBreak, [
|
||||
entry('morning', '2026-07-14T09:00:00Z', '2026-07-14T12:00:00Z'),
|
||||
]);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
misplaced: false,
|
||||
gapBeforeSeconds: 0,
|
||||
gapAfterSeconds: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('treats work overlapping the break as touching both sides', () => {
|
||||
const result = getBreakPlacementHint(breakEntry, [
|
||||
entry('overlapping', '2026-07-14T11:45:00Z', '2026-07-14T12:15:00Z'),
|
||||
]);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
misplaced: false,
|
||||
gapBeforeSeconds: 0,
|
||||
gapAfterSeconds: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for work entries', () => {
|
||||
expect(
|
||||
getBreakPlacementHint(entry('work', '2026-07-14T09:00:00Z', '2026-07-14T10:00:00Z'), [])
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"./dist",
|
||||
"./node_modules",
|
||||
"./__tests__",
|
||||
"./coverage"
|
||||
"./coverage",
|
||||
"src/**/*.test.ts"
|
||||
],
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BREAK_GAP_TOLERANCE_SECONDS,
|
||||
buildDayPlacementContext,
|
||||
decideBreakPlacement,
|
||||
findAdjacentBreakSlot,
|
||||
findBreakSlotNearInDay,
|
||||
findValidBreakGap,
|
||||
findValidBreakGapNear,
|
||||
planMoveInsert,
|
||||
@@ -19,6 +22,7 @@ const HOUR = 3600;
|
||||
const DAY = '2026-07-14';
|
||||
const dayStart = `${DAY}T00:00:00Z`;
|
||||
const dayEnd = `${DAY}T24:00:00Z`;
|
||||
const MIDNIGHT = '2026-07-15T00:00:00Z';
|
||||
|
||||
function iv(startH: number, endH: number) {
|
||||
const h = (n: number) => {
|
||||
@@ -30,6 +34,84 @@ function iv(startH: number, endH: number) {
|
||||
return { start: h(startH), end: h(endH) };
|
||||
}
|
||||
|
||||
describe('decideBreakPlacement', () => {
|
||||
const context = (work: MovableInterval[] = []) => ({
|
||||
work,
|
||||
breaks: [],
|
||||
blocked: [],
|
||||
dayStart,
|
||||
dayEnd: MIDNIGHT,
|
||||
});
|
||||
const movable = (id: string, startH: number, endH: number): MovableInterval => ({
|
||||
id,
|
||||
...iv(startH, endH),
|
||||
});
|
||||
|
||||
it('returns a direct save when an existing gap fits', () => {
|
||||
const decision = decideBreakPlacement({
|
||||
date: DAY,
|
||||
durationSeconds: HOUR,
|
||||
context: context([movable('morning', 9, 12), movable('afternoon', 13, 17)]),
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: 'save',
|
||||
slot: { start: `${DAY}T12:00:00Z`, end: `${DAY}T13:00:00Z` },
|
||||
});
|
||||
});
|
||||
|
||||
it('selects generic free-window placement for an empty day', () => {
|
||||
expect(
|
||||
decideBreakPlacement({
|
||||
date: DAY,
|
||||
durationSeconds: HOUR,
|
||||
context: context(),
|
||||
})
|
||||
).toEqual({ kind: 'place-in-free-window' });
|
||||
});
|
||||
|
||||
it('returns a modal request when work must be rearranged', () => {
|
||||
const decision = decideBreakPlacement({
|
||||
date: DAY,
|
||||
durationSeconds: HOUR,
|
||||
context: context([movable('work', 9, 17)]),
|
||||
});
|
||||
|
||||
expect(decision).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'needs-input',
|
||||
request: expect.objectContaining({
|
||||
date: DAY,
|
||||
defaultBreakStart: `${DAY}T13:00:00Z`,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('saves the break next to work too short to split', () => {
|
||||
const decision = decideBreakPlacement({
|
||||
date: DAY,
|
||||
durationSeconds: HALF_HOUR,
|
||||
context: context([movable('quick', 9, 9 + 1 / 60)]),
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: 'save',
|
||||
slot: { start: `${DAY}T09:01:00Z`, end: `${DAY}T09:31:00Z` },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a day that cannot fit or rearrange the break', () => {
|
||||
expect(
|
||||
decideBreakPlacement({
|
||||
date: DAY,
|
||||
durationSeconds: HOUR,
|
||||
context: context([movable('all-day', 0, 24)]),
|
||||
})
|
||||
).toEqual({ kind: 'reject' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findValidBreakGap', () => {
|
||||
it('centers the break in a gap that fits within tolerance', () => {
|
||||
// 09-12 and 13-17 → 1h gap, 30m break → centered at 12:15-12:45
|
||||
@@ -106,53 +188,197 @@ describe('findValidBreakGap', () => {
|
||||
});
|
||||
|
||||
describe('planSplitEntry', () => {
|
||||
it('splits a single entry and centers the break', () => {
|
||||
const workSeconds = (plan: NonNullable<ReturnType<typeof planSplitEntry>>) =>
|
||||
dayjs.utc(plan.firstHalf.end).diff(dayjs.utc(plan.firstHalf.start), 'second') +
|
||||
dayjs.utc(plan.secondHalf.end).diff(dayjs.utc(plan.secondHalf.start), 'second');
|
||||
|
||||
it('splits a single entry in the middle and keeps the work length', () => {
|
||||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.firstHalf.start).toBe(`${DAY}T09:00:00Z`);
|
||||
expect(plan!.breakSlot.start).toBe(plan!.firstHalf.end);
|
||||
expect(plan!.secondHalf.start).toBe(plan!.breakSlot.end);
|
||||
expect(plan!.secondHalf.end).toBe(`${DAY}T17:00:00Z`);
|
||||
// break is 30m and centered → 12:45-13:15
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:45:00Z`, end: `${DAY}T13:15:00Z` });
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T13:00:00Z`, end: `${DAY}T13:30:00Z` });
|
||||
expect(plan!.secondHalf.end).toBe(`${DAY}T17:30:00Z`);
|
||||
expect(workSeconds(plan!)).toBe(8 * HOUR);
|
||||
});
|
||||
|
||||
it('accepts a break far longer than the work entry', () => {
|
||||
const plan = planSplitEntry(iv(9, 9.5), 4 * HOUR);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:15:00Z` });
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T09:15:00Z`, end: `${DAY}T13:15:00Z` });
|
||||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T13:15:00Z`, end: `${DAY}T13:30:00Z` });
|
||||
expect(workSeconds(plan!)).toBe(HALF_HOUR);
|
||||
});
|
||||
|
||||
it('honors an explicit break start', () => {
|
||||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T10:00:00Z`);
|
||||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T10:00:00Z` });
|
||||
expect(plan!.secondHalf.start).toBe(`${DAY}T10:30:00Z`);
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T10:00:00Z`, end: `${DAY}T10:30:00Z` });
|
||||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T10:30:00Z`, end: `${DAY}T17:30:00Z` });
|
||||
expect(workSeconds(plan!)).toBe(8 * HOUR);
|
||||
});
|
||||
|
||||
it('returns null when the entry is too short to leave work on both sides', () => {
|
||||
expect(planSplitEntry(iv(9, 9.25), HALF_HOUR)).toBeNull();
|
||||
expect(
|
||||
planSplitEntry({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:01:30Z` }, HALF_HOUR)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an explicit break start before the entry instead of clamping it', () => {
|
||||
// 07:00 lies before the 09:00-17:00 entry — relocating it silently would
|
||||
// leave a hair-thin first fragment at a time the user never picked.
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T07:00:00Z`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an explicit break start whose break would reach past the entry end', () => {
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T16:45:00Z`)).toBeNull();
|
||||
it('rejects an explicit break start at or after the end of the entry', () => {
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T17:00:00Z`)).toBeNull();
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T18:00:00Z`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an explicit break start that leaves less than the minimum fragment', () => {
|
||||
// 09:00:30 would leave only 30s of work before the break.
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T09:00:30Z`)).toBeNull();
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T16:59:30Z`)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts an explicit break start leaving exactly the minimum fragment on each side', () => {
|
||||
// 09:01 leaves 60s before; on a 09:00-09:32 entry a 30m break also leaves 60s after.
|
||||
const plan = planSplitEntry(iv(9, 9 + 32 / 60), HALF_HOUR, `${DAY}T09:01:00Z`);
|
||||
const plan = planSplitEntry(iv(9, 9 + 2 / 60), HALF_HOUR, `${DAY}T09:01:00Z`);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:01:00Z` });
|
||||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T09:31:00Z`, end: `${DAY}T09:32:00Z` });
|
||||
});
|
||||
|
||||
it('returns null when the entry cannot hold the break plus a minimum fragment per side', () => {
|
||||
// 31 minutes of work cannot hold a 30m break with 60s of work on each side.
|
||||
expect(planSplitEntry(iv(9, 9 + 31 / 60), HALF_HOUR)).toBeNull();
|
||||
it('leaves later entries alone when the extended work does not reach them', () => {
|
||||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T12:00:00Z`, {
|
||||
otherEntries: [
|
||||
{ id: 'early', ...iv(8, 8.5) },
|
||||
{ id: 'later', ...iv(18, 19) },
|
||||
],
|
||||
});
|
||||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T12:30:00Z`, end: `${DAY}T17:30:00Z` });
|
||||
expect(plan!.shifted).toEqual([]);
|
||||
});
|
||||
|
||||
it('pushes only the collision chain and preserves later entry times', () => {
|
||||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T12:00:00Z`, {
|
||||
otherEntries: [
|
||||
{ id: 'first', ...iv(17.25, 17.5) },
|
||||
{ id: 'second', ...iv(17.6, 17.9) },
|
||||
{ id: 'distant', ...iv(20, 21) },
|
||||
],
|
||||
});
|
||||
|
||||
// Moving the first break also collides with the second.
|
||||
expect(plan!.shifted).toEqual([
|
||||
{ id: 'first', ...iv(17.5, 17.75) },
|
||||
{ id: 'second', ...iv(17.75, 18.05) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('starts the work earlier when pushing it later would leave the day', () => {
|
||||
const plan = planSplitEntry(iv(22, 23.5), HOUR, undefined, { dayStart, dayEnd });
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T21:30:00Z`, end: `${DAY}T22:15:00Z` });
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T22:15:00Z`, end: `${DAY}T23:15:00Z` });
|
||||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T23:15:00Z`, end: MIDNIGHT });
|
||||
expect(workSeconds(plan!)).toBe(1.5 * HOUR);
|
||||
});
|
||||
|
||||
it('reproduces its own plan when re-planned from the break start it chose', () => {
|
||||
const options = { dayStart, dayEnd };
|
||||
const suggested = planSplitEntry(iv(22, 23.5), HOUR, undefined, options);
|
||||
const replanned = planSplitEntry(iv(22, 23.5), HOUR, suggested!.breakSlot.start, options);
|
||||
expect(replanned).toEqual(suggested);
|
||||
});
|
||||
|
||||
it('returns null when the block cannot fit in the day at all', () => {
|
||||
expect(planSplitEntry(iv(0, 23), 2 * HOUR, undefined, { dayStart, dayEnd })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when starting earlier would run into an entry that stays put', () => {
|
||||
expect(
|
||||
planSplitEntry(iv(22, 23.75), HOUR, undefined, {
|
||||
dayStart,
|
||||
dayEnd,
|
||||
otherEntries: [{ id: 'earlier', ...iv(21, 21.5) }],
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the actual collision chain would leave the day', () => {
|
||||
expect(
|
||||
planSplitEntry(iv(22, 23.5), HOUR, undefined, {
|
||||
dayStart,
|
||||
dayEnd,
|
||||
otherEntries: [{ id: 'late', ...iv(23.75, 24) }],
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not reject a valid split because of an unrelated late entry', () => {
|
||||
const plan = planSplitEntry(iv(9, 10), HOUR, `${DAY}T09:30:00Z`, {
|
||||
dayStart,
|
||||
dayEnd,
|
||||
otherEntries: [{ id: 'late', ...iv(23.5, 24) }],
|
||||
});
|
||||
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.secondHalf.end).toBe(`${DAY}T11:00:00Z`);
|
||||
expect(plan!.shifted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findBreakSlotNearInDay', () => {
|
||||
const anchor = `${DAY}T23:00:00Z`;
|
||||
|
||||
it('keeps the break where it is when the new duration still fits', () => {
|
||||
expect(findBreakSlotNearInDay(dayStart, dayEnd, HALF_HOUR, anchor)).toEqual({
|
||||
start: anchor,
|
||||
end: `${DAY}T23:30:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('slides the break earlier instead of growing past midnight', () => {
|
||||
expect(findBreakSlotNearInDay(dayStart, dayEnd, 2 * HOUR, anchor)).toEqual({
|
||||
start: `${DAY}T22:00:00Z`,
|
||||
end: MIDNIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
it('respects a day window shortened by a running entry', () => {
|
||||
expect(findBreakSlotNearInDay(dayStart, `${DAY}T12:00:00Z`, HOUR, anchor)).toEqual({
|
||||
start: `${DAY}T11:00:00Z`,
|
||||
end: `${DAY}T12:00:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the day has no free window big enough', () => {
|
||||
expect(findBreakSlotNearInDay(dayStart, dayEnd, 20 * HOUR, anchor, [iv(6, 18)])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAdjacentBreakSlot', () => {
|
||||
it('parks the break flush after the last work entry', () => {
|
||||
expect(findAdjacentBreakSlot([iv(9, 9.5)], dayStart, dayEnd, HALF_HOUR)).toEqual({
|
||||
start: `${DAY}T09:30:00Z`,
|
||||
end: `${DAY}T10:00:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to flush before the first work entry', () => {
|
||||
expect(findAdjacentBreakSlot([iv(9, 24)], dayStart, dayEnd, HALF_HOUR)).toEqual({
|
||||
start: `${DAY}T08:30:00Z`,
|
||||
end: `${DAY}T09:00:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips a candidate blocked by an existing break', () => {
|
||||
expect(
|
||||
findAdjacentBreakSlot([iv(9, 24)], dayStart, dayEnd, HALF_HOUR, [iv(8.75, 9)])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when neither side fits inside the day', () => {
|
||||
expect(findAdjacentBreakSlot([iv(0, 24)], dayStart, dayEnd, HALF_HOUR)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,32 +405,6 @@ describe('planMoveInsert', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves an oversized gap alone instead of pulling the right block flush', () => {
|
||||
// 09-12 and 15-17 (3h gap). Break flush after first at 12:00 fits in the gap
|
||||
// → nothing moves; the user's gap is preserved.
|
||||
const plan = planMoveInsert(
|
||||
[movable('a', 9, 12), movable('b', 15, 17)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
`${DAY}T12:00:00Z`,
|
||||
HALF_HOUR
|
||||
);
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
|
||||
expect(plan!.shifted).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not drag entries flush when the break sits mid-gap', () => {
|
||||
// Break at 13:00 in the middle of the 12:00-15:00 gap → neither side moves.
|
||||
const plan = planMoveInsert(
|
||||
[movable('a', 9, 12), movable('b', 15, 17)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
`${DAY}T13:00:00Z`,
|
||||
HALF_HOUR
|
||||
);
|
||||
expect(plan!.shifted).toEqual([]);
|
||||
});
|
||||
|
||||
it('shifts each side only as much as needed to clear the slot', () => {
|
||||
// Break 14:45-15:15 overlaps only the start of 'b' → 'b' pushed 15m later,
|
||||
// 'a' untouched.
|
||||
@@ -420,12 +620,6 @@ describe('findValidBreakGapNear', () => {
|
||||
expect(gap).toEqual({ start: `${DAY}T10:00:00Z`, end: `${DAY}T11:00:00Z` });
|
||||
});
|
||||
|
||||
it('clamps the anchor into the tolerance window when it sits too late', () => {
|
||||
// Anchored at 11:00 (beyond the window) → clamped back to 10:30.
|
||||
const gap = findValidBreakGapNear(work, HOUR, `${DAY}T11:00:00Z`);
|
||||
expect(gap).toEqual({ start: `${DAY}T10:30:00Z`, end: `${DAY}T11:30:00Z` });
|
||||
});
|
||||
|
||||
it('keeps the break in place inside an oversized gap', () => {
|
||||
// 09-10 and 14-15 → 4h gap. The break stays exactly where the user left it;
|
||||
// its distance from work is a soft hint, not a reason to move it.
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface SplitPlan {
|
||||
firstHalf: Interval;
|
||||
breakSlot: Interval;
|
||||
secondHalf: Interval;
|
||||
// Entries pushed later to clear the extended second half.
|
||||
shifted: MovableInterval[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,6 +98,79 @@ export interface DayPlacementContext {
|
||||
dayEnd: string;
|
||||
}
|
||||
|
||||
export type BreakPlacementDecision =
|
||||
| { kind: 'save'; slot: Interval }
|
||||
| { kind: 'place-in-free-window' }
|
||||
| { kind: 'needs-input'; request: BreakPlacementRequest }
|
||||
| { kind: 'reject' };
|
||||
|
||||
/**
|
||||
* Decide how a requested break should be placed without performing any writes.
|
||||
* Keeping this policy pure lets the composable focus on UI state and persistence.
|
||||
*/
|
||||
export function decideBreakPlacement({
|
||||
date,
|
||||
durationSeconds,
|
||||
context,
|
||||
anchorStart = null,
|
||||
replaceBreakId = null,
|
||||
}: {
|
||||
date: string;
|
||||
durationSeconds: number;
|
||||
context: DayPlacementContext;
|
||||
anchorStart?: string | null;
|
||||
replaceBreakId?: string | null;
|
||||
}): BreakPlacementDecision {
|
||||
const { work, breaks, blocked, dayStart, dayEnd } = context;
|
||||
const obstacles = [...breaks, ...blocked];
|
||||
const gap =
|
||||
(anchorStart !== null
|
||||
? findValidBreakGapNear(work, durationSeconds, anchorStart, obstacles)
|
||||
: null) ?? findValidBreakGap(work, durationSeconds, obstacles);
|
||||
|
||||
if (gap) return { kind: 'save', slot: gap };
|
||||
|
||||
if (work.length === 0) {
|
||||
if (anchorStart === null) return { kind: 'place-in-free-window' };
|
||||
const slot = findBreakSlotNearInDay(
|
||||
dayStart,
|
||||
dayEnd,
|
||||
durationSeconds,
|
||||
anchorStart,
|
||||
obstacles
|
||||
);
|
||||
return slot ? { kind: 'save', slot } : { kind: 'reject' };
|
||||
}
|
||||
|
||||
const defaultPlan =
|
||||
work.length === 1
|
||||
? planSplitEntry(work[0]!, durationSeconds, undefined, {
|
||||
dayStart,
|
||||
dayEnd,
|
||||
otherEntries: breaks,
|
||||
})
|
||||
: suggestMovePlan(work, dayStart, dayEnd, durationSeconds, breaks);
|
||||
|
||||
if (!defaultPlan) {
|
||||
const adjacent = findAdjacentBreakSlot(work, dayStart, dayEnd, durationSeconds, obstacles);
|
||||
return adjacent ? { kind: 'save', slot: adjacent } : { kind: 'reject' };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'needs-input',
|
||||
request: {
|
||||
date,
|
||||
durationSeconds,
|
||||
dayStart,
|
||||
dayEnd,
|
||||
workEntries: work,
|
||||
otherEntries: breaks,
|
||||
defaultBreakStart: defaultPlan.breakSlot.start,
|
||||
replaceBreakId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDayPlacementContext(
|
||||
entries: DayEntryLike[],
|
||||
dayStart: string,
|
||||
@@ -169,6 +244,14 @@ function toIntervalMs(interval: Interval): IntervalMs {
|
||||
};
|
||||
}
|
||||
|
||||
function slotAt(startMs: number, durationMs: number): Interval {
|
||||
const dayjs = getDayJsInstance();
|
||||
return {
|
||||
start: dayjs.utc(startMs).format(),
|
||||
end: dayjs.utc(startMs + durationMs).format(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge overlapping/touching work intervals so the space between two
|
||||
* consecutive merged intervals is genuinely work-free. Without this, an entry
|
||||
@@ -218,17 +301,12 @@ export function findValidBreakGap(
|
||||
toleranceSeconds: number = BREAK_GAP_TOLERANCE_SECONDS
|
||||
): Interval | null {
|
||||
if (durationSeconds <= 0) return null;
|
||||
const dayjs = getDayJsInstance();
|
||||
const durationMs = durationSeconds * 1000;
|
||||
const gaps = workFreeGapsMs(work);
|
||||
const obstaclesMs = obstacles.map(toIntervalMs);
|
||||
|
||||
const blockers = (startMs: number): IntervalMs[] =>
|
||||
obstaclesMs.filter((o) => startMs < o.endMs && o.startMs < startMs + durationMs);
|
||||
const slot = (startMs: number): Interval => ({
|
||||
start: dayjs.utc(startMs).format(),
|
||||
end: dayjs.utc(startMs + durationMs).format(),
|
||||
});
|
||||
|
||||
// Pass 1: a gap where the centered break keeps both sides within tolerance.
|
||||
for (const gap of gaps) {
|
||||
@@ -236,7 +314,7 @@ export function findValidBreakGap(
|
||||
if (gapMs < durationMs || gapMs > durationMs + 2 * toleranceSeconds * 1000) continue;
|
||||
const startMs = gap.startMs + Math.floor((gapMs - durationMs) / 2000) * 1000;
|
||||
if (blockers(startMs).length > 0) continue;
|
||||
return slot(startMs);
|
||||
return slotAt(startMs, durationMs);
|
||||
}
|
||||
|
||||
// Pass 2: any gap that can physically hold the break. Start flush after the
|
||||
@@ -245,7 +323,7 @@ export function findValidBreakGap(
|
||||
let startMs = gap.startMs;
|
||||
while (startMs + durationMs <= gap.endMs) {
|
||||
const blocking = blockers(startMs);
|
||||
if (blocking.length === 0) return slot(startMs);
|
||||
if (blocking.length === 0) return slotAt(startMs, durationMs);
|
||||
startMs = Math.max(...blocking.map((o) => o.endMs));
|
||||
}
|
||||
}
|
||||
@@ -274,98 +352,198 @@ export function findValidBreakGapNear(
|
||||
const dayjs = getDayJsInstance();
|
||||
const durationMs = durationSeconds * 1000;
|
||||
const anchorMs = dayjs.utc(anchorStart).valueOf();
|
||||
const obstaclesMs = obstacles.map(toIntervalMs);
|
||||
|
||||
for (const gap of workFreeGapsMs(work)) {
|
||||
// The anchor must fall inside this gap for it to be "where the break is".
|
||||
if (anchorMs < gap.startMs || anchorMs >= gap.endMs) continue;
|
||||
if (gap.endMs - gap.startMs < durationMs) return null;
|
||||
|
||||
// Walk the gap's free windows around obstacles and pick the start
|
||||
// closest to the anchor, so the break moves as little as possible
|
||||
// from where the user left it.
|
||||
const blockers = obstacles
|
||||
.map(toIntervalMs)
|
||||
.filter((o) => o.startMs < gap.endMs && o.endMs > gap.startMs)
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
let best: number | null = null;
|
||||
const consider = (winStartMs: number, winEndMs: number) => {
|
||||
if (winEndMs - winStartMs < durationMs) return;
|
||||
const candidate = Math.min(Math.max(anchorMs, winStartMs), winEndMs - durationMs);
|
||||
if (best === null || Math.abs(candidate - anchorMs) < Math.abs(best - anchorMs)) {
|
||||
best = candidate;
|
||||
}
|
||||
};
|
||||
let cursor = gap.startMs;
|
||||
for (const blocker of blockers) {
|
||||
consider(cursor, blocker.startMs);
|
||||
cursor = Math.max(cursor, blocker.endMs);
|
||||
}
|
||||
consider(cursor, gap.endMs);
|
||||
|
||||
const best = closestFreeStartMs(gap, durationMs, anchorMs, obstaclesMs);
|
||||
if (best === null) return null;
|
||||
return { start: dayjs.utc(best).format(), end: dayjs.utc(best + durationMs).format() };
|
||||
return slotAt(best, durationMs);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// A split must leave a meaningful chunk of work on each side of the break;
|
||||
// hair-thin fragments would only exist to make a bad placement "fit".
|
||||
/** Closest obstacle-free start to `anchorMs` that fits inside `range`. */
|
||||
function closestFreeStartMs(
|
||||
range: IntervalMs,
|
||||
durationMs: number,
|
||||
anchorMs: number,
|
||||
obstaclesMs: IntervalMs[]
|
||||
): number | null {
|
||||
if (range.endMs - range.startMs < durationMs) return null;
|
||||
const blockers = obstaclesMs
|
||||
.filter((o) => o.startMs < range.endMs && o.endMs > range.startMs)
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
|
||||
let best: number | null = null;
|
||||
const consider = (winStartMs: number, winEndMs: number) => {
|
||||
if (winEndMs - winStartMs < durationMs) return;
|
||||
const candidate = Math.min(Math.max(anchorMs, winStartMs), winEndMs - durationMs);
|
||||
if (best === null || Math.abs(candidate - anchorMs) < Math.abs(best - anchorMs)) {
|
||||
best = candidate;
|
||||
}
|
||||
};
|
||||
let cursor = range.startMs;
|
||||
for (const blocker of blockers) {
|
||||
consider(cursor, blocker.startMs);
|
||||
cursor = Math.max(cursor, blocker.endMs);
|
||||
}
|
||||
consider(cursor, range.endMs);
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Keep a workless-day break near its current start without leaving the day. */
|
||||
export function findBreakSlotNearInDay(
|
||||
dayStart: string,
|
||||
dayEnd: string,
|
||||
durationSeconds: number,
|
||||
anchorStart: string,
|
||||
obstacles: Interval[] = []
|
||||
): Interval | null {
|
||||
if (durationSeconds <= 0) return null;
|
||||
const dayjs = getDayJsInstance();
|
||||
const durationMs = durationSeconds * 1000;
|
||||
const range = {
|
||||
startMs: dayjs.utc(dayStart).valueOf(),
|
||||
endMs: dayjs.utc(dayEnd).valueOf(),
|
||||
};
|
||||
const best = closestFreeStartMs(
|
||||
range,
|
||||
durationMs,
|
||||
dayjs.utc(anchorStart).valueOf(),
|
||||
obstacles.map(toIntervalMs)
|
||||
);
|
||||
if (best === null) return null;
|
||||
return slotAt(best, durationMs);
|
||||
}
|
||||
|
||||
export const MIN_SPLIT_FRAGMENT_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Split a single work entry to insert a break. `breakStart` (UTC ISO) lets the
|
||||
* caller position it; without one the break is centered. Returns null when the
|
||||
* entry is too short to leave at least MIN_SPLIT_FRAGMENT_SECONDS of work on
|
||||
* both sides of the break, or when an explicit `breakStart` would not — an
|
||||
* out-of-range request is rejected rather than clamped, because silently
|
||||
* relocating the break would contradict the time the user picked.
|
||||
*/
|
||||
export interface SplitOptions {
|
||||
// Omitted bounds are unbounded.
|
||||
dayStart?: string;
|
||||
dayEnd?: string;
|
||||
// Existing breaks that may block or move with the split.
|
||||
otherEntries?: MovableInterval[];
|
||||
}
|
||||
|
||||
/** Insert a break while preserving work duration and respecting the supplied bounds. */
|
||||
export function planSplitEntry(
|
||||
entry: Interval,
|
||||
durationSeconds: number,
|
||||
breakStart?: string
|
||||
breakStart?: string,
|
||||
options: SplitOptions = {}
|
||||
): SplitPlan | null {
|
||||
if (durationSeconds <= 0) return null;
|
||||
const dayjs = getDayJsInstance();
|
||||
const entryStart = dayjs.utc(entry.start);
|
||||
const entryEnd = dayjs.utc(entry.end);
|
||||
const total = entryEnd.diff(entryStart, 'second');
|
||||
if (total < durationSeconds + 2 * MIN_SPLIT_FRAGMENT_SECONDS) return null;
|
||||
const toMs = (iso: string) => dayjs.utc(iso).valueOf();
|
||||
const iso = (ms: number) => dayjs.utc(ms).format();
|
||||
|
||||
const earliest = entryStart.add(MIN_SPLIT_FRAGMENT_SECONDS, 'second');
|
||||
const latest = entryEnd.subtract(durationSeconds + MIN_SPLIT_FRAGMENT_SECONDS, 'second');
|
||||
const entryStartMs = toMs(entry.start);
|
||||
const totalMs = toMs(entry.end) - entryStartMs;
|
||||
const minMs = MIN_SPLIT_FRAGMENT_SECONDS * 1000;
|
||||
if (totalMs < 2 * minMs) return null;
|
||||
const durationMs = durationSeconds * 1000;
|
||||
const dayEndMs = options.dayEnd !== undefined ? toMs(options.dayEnd) : Infinity;
|
||||
|
||||
let bStart = breakStart
|
||||
? dayjs.utc(breakStart)
|
||||
: entryStart.add(Math.floor((total - durationSeconds) / 2), 'second');
|
||||
// Pull the block earlier only enough to keep it inside the day.
|
||||
const blockStartMs = Math.min(entryStartMs, dayEndMs - totalMs - durationMs);
|
||||
|
||||
let bStartMs = breakStart ? toMs(breakStart) : blockStartMs + Math.floor(totalMs / 2000) * 1000;
|
||||
if (breakStart) {
|
||||
if (bStart.isBefore(earliest) || bStart.isAfter(latest)) return null;
|
||||
if (bStartMs < blockStartMs + minMs || bStartMs > blockStartMs + totalMs - minMs) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Safety net for rounding of the centered position only.
|
||||
if (bStart.isBefore(earliest)) bStart = earliest;
|
||||
if (bStart.isAfter(latest)) bStart = latest;
|
||||
bStartMs = Math.min(
|
||||
Math.max(bStartMs, blockStartMs + minMs),
|
||||
blockStartMs + totalMs - minMs
|
||||
);
|
||||
}
|
||||
const bEndMs = bStartMs + durationMs;
|
||||
const firstHalfMs = bStartMs - blockStartMs;
|
||||
|
||||
const secondHalfEndMs = bEndMs + totalMs - firstHalfMs;
|
||||
|
||||
// Carry the occupied end through the collision chain; stop at the first gap.
|
||||
const others = options.otherEntries ?? [];
|
||||
const later = others
|
||||
.filter((other) => toMs(other.start) >= bStartMs)
|
||||
.sort((a, b) => toMs(a.start) - toMs(b.start));
|
||||
const shifted: MovableInterval[] = [];
|
||||
let occupiedEndMs = secondHalfEndMs;
|
||||
for (const other of later) {
|
||||
const otherStartMs = toMs(other.start);
|
||||
if (otherStartMs >= occupiedEndMs) break;
|
||||
|
||||
const otherEndMs = toMs(other.end);
|
||||
const shiftMs = occupiedEndMs - otherStartMs;
|
||||
const shiftedEndMs = otherEndMs + shiftMs;
|
||||
if (shiftedEndMs > dayEndMs) return null;
|
||||
shifted.push({
|
||||
id: other.id,
|
||||
start: iso(otherStartMs + shiftMs),
|
||||
end: iso(shiftedEndMs),
|
||||
});
|
||||
occupiedEndMs = shiftedEndMs;
|
||||
}
|
||||
|
||||
const bEnd = bStart.add(durationSeconds, 'second');
|
||||
if (!bStart.isAfter(entryStart) || !bEnd.isBefore(entryEnd)) return null;
|
||||
// Earlier breaks constrain how far the block may move back.
|
||||
let earliestStartMs = options.dayStart !== undefined ? toMs(options.dayStart) : -Infinity;
|
||||
for (const other of others) {
|
||||
const otherEndMs = toMs(other.end);
|
||||
if (
|
||||
toMs(other.start) < bStartMs &&
|
||||
otherEndMs <= bStartMs &&
|
||||
otherEndMs > earliestStartMs
|
||||
) {
|
||||
earliestStartMs = otherEndMs;
|
||||
}
|
||||
}
|
||||
if (blockStartMs < earliestStartMs) return null;
|
||||
|
||||
return {
|
||||
firstHalf: { start: entryStart.format(), end: bStart.format() },
|
||||
breakSlot: { start: bStart.format(), end: bEnd.format() },
|
||||
secondHalf: { start: bEnd.format(), end: entryEnd.format() },
|
||||
firstHalf: { start: iso(blockStartMs), end: iso(bStartMs) },
|
||||
breakSlot: { start: iso(bStartMs), end: iso(bEndMs) },
|
||||
secondHalf: { start: iso(bEndMs), end: iso(secondHalfEndMs) },
|
||||
shifted,
|
||||
};
|
||||
}
|
||||
|
||||
/** Last resort: place the break after the last work entry or before the first. */
|
||||
export function findAdjacentBreakSlot(
|
||||
work: Interval[],
|
||||
dayStart: string,
|
||||
dayEnd: string,
|
||||
durationSeconds: number,
|
||||
obstacles: Interval[] = []
|
||||
): Interval | null {
|
||||
if (durationSeconds <= 0 || work.length === 0) return null;
|
||||
const dayjs = getDayJsInstance();
|
||||
const durationMs = durationSeconds * 1000;
|
||||
const dayStartMs = dayjs.utc(dayStart).valueOf();
|
||||
const dayEndMs = dayjs.utc(dayEnd).valueOf();
|
||||
|
||||
const merged = mergedWorkMs(work);
|
||||
const blockers = [...merged, ...obstacles.map(toIntervalMs)];
|
||||
const candidates = [merged[merged.length - 1]!.endMs, merged[0]!.startMs - durationMs];
|
||||
|
||||
for (const startMs of candidates) {
|
||||
if (startMs < dayStartMs || startMs + durationMs > dayEndMs) continue;
|
||||
const clear = blockers.every(
|
||||
(blocker) => blocker.endMs <= startMs || blocker.startMs >= startMs + durationMs
|
||||
);
|
||||
if (clear) return slotAt(startMs, durationMs);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a break at `breakStart`, shifting the surrounding entries only as much
|
||||
* as needed to clear the slot. Entries starting before the break form the left
|
||||
* block: when it reaches into the slot it is translated earlier so its latest
|
||||
* end meets the break start. The rest form the right block: when the slot
|
||||
* reaches into it, it is translated later so its earliest start meets the break
|
||||
* end. Blocks that already clear the slot are left untouched — existing gaps
|
||||
* are preserved, never tightened. Returns null if a required shift would push
|
||||
* an entry outside `[dayStart, dayEnd]`.
|
||||
* Insert a break by translating overlapping entries on either side just enough
|
||||
* to clear it. Existing gaps remain unchanged. Returns null if a shift would
|
||||
* leave the day.
|
||||
*/
|
||||
export function planMoveInsert(
|
||||
entries: MovableInterval[],
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useBreakPlacement, BreakPlacementDeferred } from './useBreakPlacement';
|
||||
import { useBreakPlacement } from './useBreakPlacement';
|
||||
import { NoFreeWindowError } from './cellMath';
|
||||
import { api } from '@/packages/api/src';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import type { TimesheetRow } from '@/utils/useTimesheetGrid';
|
||||
|
||||
const addNotification = vi.fn();
|
||||
const mutationOptionsSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/utils/useUser', () => ({
|
||||
getCurrentOrganizationId: vi.fn(() => 'org-1'),
|
||||
@@ -15,6 +17,28 @@ vi.mock('@/utils/useUser', () => ({
|
||||
|
||||
vi.mock('@tanstack/vue-query', () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: (options: {
|
||||
mutationFn: (variables: unknown) => Promise<unknown>;
|
||||
onSuccess?: (data: unknown, variables: unknown) => void | Promise<void>;
|
||||
onError?: (error: unknown, variables: unknown) => void | Promise<void>;
|
||||
onSettled?: () => void | Promise<void>;
|
||||
}) => {
|
||||
mutationOptionsSpy(options);
|
||||
return {
|
||||
mutateAsync: async (variables: unknown) => {
|
||||
try {
|
||||
const data = await options.mutationFn(variables);
|
||||
await options.onSuccess?.(data, variables);
|
||||
return data;
|
||||
} catch (error) {
|
||||
await options.onError?.(error, variables);
|
||||
throw error;
|
||||
} finally {
|
||||
await options.onSettled?.();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/notification', () => ({
|
||||
@@ -61,24 +85,29 @@ const breakRow: TimesheetRow = {
|
||||
totalSeconds: 0,
|
||||
};
|
||||
|
||||
function setup(allEntries: TimeEntry[]) {
|
||||
function setup(allEntries: TimeEntry[], preventOverlaps = false) {
|
||||
const createCell = vi.fn(async () => undefined);
|
||||
const updateEntry = vi.fn(async () => undefined);
|
||||
const updateEntry = vi.fn(async (_entry: TimeEntry) => undefined);
|
||||
const deleteEntry = vi.fn(async (_id: string) => undefined);
|
||||
const bp = useBreakPlacement({
|
||||
weekDays: ref([DATE, '2026-04-11', '2026-04-12']),
|
||||
timeEntries: ref(allEntries),
|
||||
requireOrgId: () => 'org-1',
|
||||
createCell,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
preventOverlappingTimeEntries: () => preventOverlaps,
|
||||
});
|
||||
return { bp, createCell, updateEntry };
|
||||
return { bp, createCell, updateEntry, deleteEntry };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
apiMocks.createTimeEntry.mockClear();
|
||||
apiMocks.updateTimeEntry.mockClear();
|
||||
apiMocks.deleteTimeEntry.mockClear();
|
||||
addNotification.mockClear();
|
||||
mutationOptionsSpy.mockClear();
|
||||
});
|
||||
|
||||
describe('useBreakPlacement.placeBreak', () => {
|
||||
@@ -126,13 +155,36 @@ describe('useBreakPlacement.placeBreak', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('defers to the split modal when a single work entry blocks every gap', async () => {
|
||||
it('drops a new break into the first free window when the day has no work', async () => {
|
||||
const { bp, createCell } = setup([]);
|
||||
await bp.placeBreak(breakRow, 0, HOUR);
|
||||
expect(createCell).toHaveBeenCalledWith(breakRow, 0, HOUR);
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resizes a break on a workless day in place while it still fits', async () => {
|
||||
const brk = entry('2026-04-10T20:00:00Z', '2026-04-10T20:30:00Z', {
|
||||
id: 'b1',
|
||||
type: 'break',
|
||||
});
|
||||
const { bp, updateEntry } = setup([brk]);
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR, 'b1');
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'b1',
|
||||
start: '2026-04-10T20:00:00Z',
|
||||
end: '2026-04-10T21:00:00Z',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('requests input in 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
|
||||
);
|
||||
await expect(bp.placeBreak(breakRow, 0, HOUR)).resolves.toBe('needs-input');
|
||||
expect(bp.breakPlacementRequest.value).toEqual(
|
||||
expect.objectContaining({
|
||||
durationSeconds: HOUR,
|
||||
@@ -145,15 +197,25 @@ describe('useBreakPlacement.placeBreak', () => {
|
||||
});
|
||||
|
||||
describe('useBreakPlacement.applyBreakPlacement (split)', () => {
|
||||
it('shrinks the original, creates the second half, and saves the break', async () => {
|
||||
it('configures the logical placement as a serialized, non-retrying mutation', () => {
|
||||
setup([]);
|
||||
|
||||
expect(mutationOptionsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mutationKey: ['timesheet', 'break-placement'],
|
||||
scope: { id: 'timesheet-break-placement' },
|
||||
retry: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shrinks the original, pushes the rest of the work out, 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.placeBreak(breakRow, 0, HOUR);
|
||||
await bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR);
|
||||
|
||||
// Original work shrunk to its first half.
|
||||
expect(updateEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'w1',
|
||||
@@ -161,14 +223,13 @@ describe('useBreakPlacement.applyBreakPlacement (split)', () => {
|
||||
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',
|
||||
end: '2026-04-10T18:00:00Z',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
@@ -177,14 +238,208 @@ describe('useBreakPlacement.applyBreakPlacement (split)', () => {
|
||||
}),
|
||||
])
|
||||
);
|
||||
// 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([]);
|
||||
it('moves an existing break out of the way of the pushed-out work', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'w1' });
|
||||
const existingBreak = entry('2026-04-10T12:00:00Z', '2026-04-10T12:15:00Z', {
|
||||
id: 'b1',
|
||||
type: 'break',
|
||||
});
|
||||
const { bp, updateEntry } = setup([work, existingBreak]);
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR);
|
||||
await bp.applyBreakPlacement('2026-04-10T10:00:00Z', HOUR);
|
||||
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
id: 'w1',
|
||||
start: '2026-04-10T09:00:00Z',
|
||||
end: '2026-04-10T10:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
id: 'b1',
|
||||
start: '2026-04-10T13:00:00Z',
|
||||
end: '2026-04-10T13:15:00Z',
|
||||
})
|
||||
);
|
||||
const created = apiMocks.createTimeEntry.mock.calls.map((c) => c[0]);
|
||||
expect(created).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'work',
|
||||
start: '2026-04-10T11:00:00Z',
|
||||
end: '2026-04-10T13:00:00Z',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
start: '2026-04-10T10:00:00Z',
|
||||
end: '2026-04-10T11:00:00Z',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('moves a replacement break before creating work through its old slot', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const existingBreak = entry('2026-04-10T17:00:00Z', '2026-04-10T17:30:00Z', {
|
||||
id: 'b1',
|
||||
type: 'break',
|
||||
});
|
||||
const { bp, updateEntry } = setup([work, existingBreak], true);
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR, 'b1');
|
||||
await bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR);
|
||||
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ id: 'w1', end: '2026-04-10T12:00:00Z' })
|
||||
);
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
id: 'b1',
|
||||
start: '2026-04-10T12:00:00Z',
|
||||
end: '2026-04-10T13:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.createTimeEntry.mock.calls[0]![0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'work',
|
||||
start: '2026-04-10T13:00:00Z',
|
||||
end: '2026-04-10T18:00:00Z',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the original entry when creating the second half fails', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const { bp, updateEntry } = setup([work]);
|
||||
apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR);
|
||||
await expect(bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR)).rejects.toThrow('boom');
|
||||
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
id: 'w1',
|
||||
start: '2026-04-10T09:00:00Z',
|
||||
end: '2026-04-10T12:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
id: 'w1',
|
||||
start: '2026-04-10T09:00:00Z',
|
||||
end: '2026-04-10T17:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(addNotification).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Failed to add break',
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
it('restores shifted breaks as well as the original when creating work fails', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'w1' });
|
||||
const existingBreak = entry('2026-04-10T12:00:00Z', '2026-04-10T12:15:00Z', {
|
||||
id: 'b1',
|
||||
type: 'break',
|
||||
});
|
||||
const { bp, updateEntry } = setup([work, existingBreak]);
|
||||
apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('work create failed'));
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR);
|
||||
await expect(bp.applyBreakPlacement('2026-04-10T10:00:00Z', HOUR)).rejects.toThrow(
|
||||
'work create failed'
|
||||
);
|
||||
|
||||
expect(updateEntry.mock.calls.map((call) => call[0])).toEqual([
|
||||
expect.objectContaining({ id: 'w1', end: '2026-04-10T10:00:00Z' }),
|
||||
expect.objectContaining({
|
||||
id: 'b1',
|
||||
start: '2026-04-10T13:00:00Z',
|
||||
end: '2026-04-10T13:15:00Z',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'b1',
|
||||
start: '2026-04-10T12:00:00Z',
|
||||
end: '2026-04-10T12:15:00Z',
|
||||
}),
|
||||
expect.objectContaining({ id: 'w1', end: '2026-04-10T12:00:00Z' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('deletes the created second half and restores updates when saving the break fails', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const { bp, updateEntry, deleteEntry } = setup([work]);
|
||||
apiMocks.createTimeEntry
|
||||
.mockResolvedValueOnce({ data: { id: 'second-half' } } as never)
|
||||
.mockRejectedValueOnce(new Error('break create failed'));
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR);
|
||||
await expect(bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR)).rejects.toThrow(
|
||||
'break create failed'
|
||||
);
|
||||
|
||||
expect(deleteEntry).toHaveBeenCalledWith('second-half');
|
||||
expect(updateEntry).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'w1', end: '2026-04-10T17:00:00Z' })
|
||||
);
|
||||
expect(bp.breakPlacementRequest.value).not.toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces incomplete recovery and closes a stale placement request', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const { bp, updateEntry } = setup([work]);
|
||||
updateEntry
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('restore failed'));
|
||||
apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('create failed'));
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR);
|
||||
await expect(bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR)).rejects.toThrow(
|
||||
'create failed'
|
||||
);
|
||||
|
||||
expect(addNotification).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Break placement needs attention',
|
||||
expect.any(String)
|
||||
);
|
||||
expect(bp.breakPlacementRequest.value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useBreakPlacement.applyBreakPlacement (move)', () => {
|
||||
it('rejects a replacement dependency cycle before changing any entry', async () => {
|
||||
const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', {
|
||||
id: 'morning',
|
||||
});
|
||||
const afternoon = entry('2026-04-10T12:00:00Z', '2026-04-10T17:00:00Z', {
|
||||
id: 'afternoon',
|
||||
});
|
||||
const existingBreak = entry('2026-04-10T17:00:00Z', '2026-04-10T17:30:00Z', {
|
||||
id: 'b1',
|
||||
type: 'break',
|
||||
});
|
||||
const { bp, updateEntry } = setup([morning, afternoon, existingBreak], true);
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR, 'b1');
|
||||
await expect(bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR)).rejects.toBeInstanceOf(
|
||||
NoFreeWindowError
|
||||
);
|
||||
|
||||
expect(updateEntry).not.toHaveBeenCalled();
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ref, type Ref } from 'vue';
|
||||
import { useQueryClient } from '@tanstack/vue-query';
|
||||
import { api, type TimeEntry } from '@/packages/api/src';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
||||
import { api, type CreateTimeEntryBody, type TimeEntry } from '@/packages/api/src';
|
||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||
import { getCurrentMembershipId } from '@/utils/useUser';
|
||||
import type { TimesheetRow } from '@/utils/useTimesheetGrid';
|
||||
@@ -9,24 +8,15 @@ import { useNotificationsStore } from '@/utils/notification';
|
||||
import { localDayBounds, NoFreeWindowError } from './cellMath';
|
||||
import {
|
||||
buildDayPlacementContext,
|
||||
findValidBreakGap,
|
||||
findValidBreakGapNear,
|
||||
decideBreakPlacement,
|
||||
placementMode,
|
||||
planMoveInsert,
|
||||
planSplitEntry,
|
||||
suggestMovePlan,
|
||||
type BreakPlacementRequest,
|
||||
type DayPlacementContext,
|
||||
type MovableInterval,
|
||||
} from './breakPlacementMath';
|
||||
|
||||
/** Signals the caller that a break create/edit is waiting on the placement modal. */
|
||||
export class BreakPlacementDeferred extends Error {
|
||||
constructor() {
|
||||
super('Break placement deferred to modal');
|
||||
this.name = 'BreakPlacementDeferred';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic entry primitives the break subsystem borrows from the cell-mutation
|
||||
* layer. `createCell` drops an entry in the first free window (used when there
|
||||
@@ -45,17 +35,51 @@ export interface BreakPlacementDeps {
|
||||
afterCursor?: string
|
||||
) => Promise<void>;
|
||||
updateEntry: (entry: TimeEntry) => Promise<void>;
|
||||
deleteEntry: (id: string) => Promise<void>;
|
||||
preventOverlappingTimeEntries?: () => boolean;
|
||||
}
|
||||
|
||||
interface EntryChange {
|
||||
original: TimeEntry;
|
||||
next: TimeEntry;
|
||||
}
|
||||
|
||||
interface PlacementCommit {
|
||||
updates: EntryChange[];
|
||||
creates: CreateTimeEntryBody[];
|
||||
entriesAdjusted: boolean;
|
||||
}
|
||||
|
||||
export type PlaceBreakResult = 'committed' | 'needs-input';
|
||||
|
||||
type UndoOperation = () => Promise<void>;
|
||||
|
||||
export class BreakPlacementSagaError extends Error {
|
||||
constructor(
|
||||
public readonly operationError: unknown,
|
||||
public readonly rollbackErrors: unknown[]
|
||||
) {
|
||||
super(operationError instanceof Error ? operationError.message : 'Break placement failed');
|
||||
this.name = 'BreakPlacementSagaError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Break-placement subsystem for the timesheet. Owns the placement-modal request
|
||||
* state and everything that positions a break relative to work — auto-placing it
|
||||
* into a valid gap when one exists, or deferring to the split/move modal when the
|
||||
* into a valid gap when one exists, or asking for input in the split/move modal when the
|
||||
* day has to be rearranged.
|
||||
*/
|
||||
export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
const { weekDays, timeEntries, requireOrgId, createCell, updateEntry } = deps;
|
||||
const dayjs = getDayJsInstance();
|
||||
const {
|
||||
weekDays,
|
||||
timeEntries,
|
||||
requireOrgId,
|
||||
createCell,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
preventOverlappingTimeEntries = () => false,
|
||||
} = deps;
|
||||
const queryClient = useQueryClient();
|
||||
const notifications = useNotificationsStore();
|
||||
|
||||
@@ -82,24 +106,35 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
);
|
||||
}
|
||||
|
||||
async function createBreakEntry(start: string, end: string, memberId?: string): Promise<void> {
|
||||
const orgId = requireOrgId();
|
||||
function breakEntryBody(start: string, end: string, memberId?: string): CreateTimeEntryBody {
|
||||
const member = memberId ?? getCurrentMembershipId();
|
||||
if (!member) throw new Error('No member context');
|
||||
await api.createTimeEntry(
|
||||
{
|
||||
member_id: member,
|
||||
project_id: null,
|
||||
task_id: null,
|
||||
start,
|
||||
end,
|
||||
billable: false,
|
||||
type: 'break',
|
||||
description: null,
|
||||
tags: [],
|
||||
},
|
||||
{ params: { organization: orgId } }
|
||||
);
|
||||
return {
|
||||
member_id: member,
|
||||
project_id: null,
|
||||
task_id: null,
|
||||
start,
|
||||
end,
|
||||
billable: false,
|
||||
type: 'break',
|
||||
description: null,
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function createEntry(body: CreateTimeEntryBody): Promise<string> {
|
||||
const response = await api.createTimeEntry(body, {
|
||||
params: { organization: requireOrgId() },
|
||||
});
|
||||
return response.data.id;
|
||||
}
|
||||
|
||||
async function createBreakEntry(
|
||||
start: string,
|
||||
end: string,
|
||||
memberId?: string
|
||||
): Promise<string> {
|
||||
return createEntry(breakEntryBody(start, end, memberId));
|
||||
}
|
||||
|
||||
async function saveBreakEntry(
|
||||
@@ -119,116 +154,169 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
await createBreakEntry(start, end, memberId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a break on the day (new, or re-placing an existing one when `replaceBreakId`
|
||||
* is given). Prefers a gap that already satisfies the placement tolerance; otherwise
|
||||
* raises BreakPlacementDeferred so the page opens the modal. With no work to anchor to,
|
||||
* the break is just dropped in / resized in the first free window.
|
||||
*/
|
||||
/** Place or resize a break directly when possible, otherwise open the placement modal. */
|
||||
async function placeBreak(
|
||||
row: TimesheetRow,
|
||||
dayIndex: number,
|
||||
durationSeconds: number,
|
||||
replaceBreakId?: string
|
||||
): Promise<void> {
|
||||
): Promise<PlaceBreakResult> {
|
||||
const date = weekDays.value[dayIndex]!;
|
||||
const tz = getUserTimezone();
|
||||
const { work, breaks, blocked, dayStart, dayEnd } = dayPlacementContext(
|
||||
date,
|
||||
tz,
|
||||
replaceBreakId
|
||||
);
|
||||
// Existing breaks block auto-placement into a gap (obstacles), but move
|
||||
// along with the surrounding work when a move plan shifts entries.
|
||||
// Running entries block everything from their start (never movable).
|
||||
const obstacles = [...breaks, ...blocked];
|
||||
|
||||
// On edit, keep the break where it is when its current gap still fits it; only
|
||||
// fall back to the first-gap-centered placement when it can't stay put.
|
||||
const context = dayPlacementContext(date, tz, replaceBreakId);
|
||||
const anchorStart = replaceBreakId
|
||||
? (timeEntries.value.find((e) => e.id === replaceBreakId)?.start ?? null)
|
||||
: null;
|
||||
const validGap =
|
||||
(anchorStart !== null
|
||||
? findValidBreakGapNear(work, durationSeconds, anchorStart, obstacles)
|
||||
: null) ?? findValidBreakGap(work, durationSeconds, obstacles);
|
||||
if (validGap) {
|
||||
await saveBreakEntry(validGap.start, validGap.end, replaceBreakId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (work.length === 0) {
|
||||
// No work to sit between: for an edit, resize the break in place; for a new
|
||||
// break, drop it in the first free window. Nothing to align to either way.
|
||||
if (replaceBreakId) {
|
||||
const existing = timeEntries.value.find((e) => e.id === replaceBreakId);
|
||||
if (existing) {
|
||||
const newEnd = dayjs
|
||||
.utc(existing.start)
|
||||
.add(durationSeconds, 'second')
|
||||
.format();
|
||||
await updateEntry({ ...existing, end: newEnd });
|
||||
return;
|
||||
}
|
||||
}
|
||||
await createCell(row, dayIndex, durationSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
const mode: 'split' | 'move' = work.length === 1 ? 'split' : 'move';
|
||||
const defaultBreakStart =
|
||||
mode === 'split'
|
||||
? (planSplitEntry(work[0]!, durationSeconds)?.breakSlot.start ?? null)
|
||||
: (suggestMovePlan(work, dayStart, dayEnd, durationSeconds, breaks)?.breakSlot
|
||||
.start ?? null);
|
||||
|
||||
if (!defaultBreakStart) {
|
||||
// Even splitting/moving can't open a slot on this day.
|
||||
throw new NoFreeWindowError(date, durationSeconds);
|
||||
}
|
||||
|
||||
breakPlacementRequest.value = {
|
||||
const decision = decideBreakPlacement({
|
||||
date,
|
||||
durationSeconds,
|
||||
dayStart,
|
||||
dayEnd,
|
||||
workEntries: work,
|
||||
otherEntries: breaks,
|
||||
defaultBreakStart,
|
||||
replaceBreakId: replaceBreakId ?? null,
|
||||
};
|
||||
throw new BreakPlacementDeferred();
|
||||
context,
|
||||
anchorStart,
|
||||
replaceBreakId,
|
||||
});
|
||||
|
||||
switch (decision.kind) {
|
||||
case 'save':
|
||||
await saveBreakEntry(decision.slot.start, decision.slot.end, replaceBreakId);
|
||||
return 'committed';
|
||||
case 'place-in-free-window':
|
||||
await createCell(row, dayIndex, durationSeconds);
|
||||
return 'committed';
|
||||
case 'needs-input':
|
||||
breakPlacementRequest.value = decision.request;
|
||||
return 'needs-input';
|
||||
case 'reject':
|
||||
throw new NoFreeWindowError(date, durationSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
function dismissBreakPlacement(): void {
|
||||
breakPlacementRequest.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a break at `breakStart` by executing the split or move plan. Shifts
|
||||
* happen before the break is saved so its target slot is free first.
|
||||
*/
|
||||
async function applyBreakPlacement(breakStart: string, durationSeconds: number): Promise<void> {
|
||||
const req = breakPlacementRequest.value;
|
||||
if (!req) return;
|
||||
function shiftChanges(shifted: MovableInterval[]): EntryChange[] {
|
||||
return shifted.map((shift) => {
|
||||
const original = timeEntries.value.find((e) => e.id === shift.id);
|
||||
if (!original) throw new Error('An entry to move no longer exists');
|
||||
return {
|
||||
original,
|
||||
next: { ...original, start: shift.start, end: shift.end },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// The timesheet is the current member's own, so all created/edited entries stay with them.
|
||||
const memberId = getCurrentMembershipId();
|
||||
if (!memberId) throw new Error('No member context');
|
||||
function intervalsOverlap(
|
||||
left: Pick<TimeEntry, 'start' | 'end'>,
|
||||
right: Pick<TimeEntry, 'start' | 'end'>
|
||||
): boolean {
|
||||
const leftEndMs = left.end === null ? Infinity : Date.parse(left.end);
|
||||
const rightEndMs = right.end === null ? Infinity : Date.parse(right.end);
|
||||
return Date.parse(left.start) < rightEndMs && Date.parse(right.start) < leftEndMs;
|
||||
}
|
||||
|
||||
let entriesAdjusted = true;
|
||||
/** Order updates so each target is free when overlap prevention is enabled. */
|
||||
function nonOverlappingUpdateOrder(
|
||||
changes: EntryChange[],
|
||||
memberId: string
|
||||
): EntryChange[] | null {
|
||||
if (!preventOverlappingTimeEntries()) return changes;
|
||||
|
||||
const current = new Map(
|
||||
timeEntries.value
|
||||
.filter((entry) => entry.member_id === memberId)
|
||||
.map((entry) => [entry.id, entry] as const)
|
||||
);
|
||||
const pending = [...changes];
|
||||
const ordered: EntryChange[] = [];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const index = pending.findIndex((change) =>
|
||||
[...current.values()].every(
|
||||
(entry) =>
|
||||
entry.id === change.original.id || !intervalsOverlap(change.next, entry)
|
||||
)
|
||||
);
|
||||
if (index === -1) return null;
|
||||
|
||||
const [change] = pending.splice(index, 1);
|
||||
ordered.push(change!);
|
||||
current.set(change!.original.id, change!.next);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
async function applyUpdates(changes: EntryChange[], undo: UndoOperation[]): Promise<void> {
|
||||
for (const change of changes) {
|
||||
await updateEntry(change.next);
|
||||
undo.push(() => updateEntry(change.original));
|
||||
}
|
||||
}
|
||||
|
||||
async function withCompensation<T>(
|
||||
operation: (undo: UndoOperation[]) => Promise<T>
|
||||
): Promise<T> {
|
||||
const undo: UndoOperation[] = [];
|
||||
try {
|
||||
if (placementMode(req) === 'split') {
|
||||
const original = timeEntries.value.find((e) => e.id === req.workEntries[0]!.id);
|
||||
const plan = planSplitEntry(req.workEntries[0]!, durationSeconds, breakStart);
|
||||
if (!original || !plan) throw new NoFreeWindowError(req.date, durationSeconds);
|
||||
// Shrink the original to the first half, then add the second half + break.
|
||||
await updateEntry({
|
||||
...original,
|
||||
start: plan.firstHalf.start,
|
||||
end: plan.firstHalf.end,
|
||||
});
|
||||
await api.createTimeEntry(
|
||||
return await operation(undo);
|
||||
} catch (operationError) {
|
||||
const rollbackErrors: unknown[] = [];
|
||||
for (const rollback of [...undo].reverse()) {
|
||||
try {
|
||||
await rollback();
|
||||
} catch (rollbackError) {
|
||||
rollbackErrors.push(rollbackError);
|
||||
}
|
||||
}
|
||||
throw new BreakPlacementSagaError(operationError, rollbackErrors);
|
||||
}
|
||||
}
|
||||
|
||||
function replacementChange(
|
||||
req: BreakPlacementRequest,
|
||||
start: string,
|
||||
end: string
|
||||
): EntryChange | null {
|
||||
if (!req.replaceBreakId) return null;
|
||||
const original = timeEntries.value.find((entry) => entry.id === req.replaceBreakId);
|
||||
if (!original) throw new Error('Break to update no longer exists');
|
||||
return { original, next: { ...original, start, end } };
|
||||
}
|
||||
|
||||
function buildPlacementCommit({
|
||||
req,
|
||||
breakStart,
|
||||
durationSeconds,
|
||||
memberId,
|
||||
}: {
|
||||
req: BreakPlacementRequest;
|
||||
breakStart: string;
|
||||
durationSeconds: number;
|
||||
memberId: string;
|
||||
}): PlacementCommit {
|
||||
if (placementMode(req) === 'split') {
|
||||
const original = timeEntries.value.find((e) => e.id === req.workEntries[0]!.id);
|
||||
const plan = planSplitEntry(req.workEntries[0]!, durationSeconds, breakStart, {
|
||||
dayStart: req.dayStart,
|
||||
dayEnd: req.dayEnd,
|
||||
otherEntries: req.otherEntries,
|
||||
});
|
||||
if (!original || !plan) throw new NoFreeWindowError(req.date, durationSeconds);
|
||||
|
||||
const replacement = replacementChange(req, plan.breakSlot.start, plan.breakSlot.end);
|
||||
return {
|
||||
updates: [
|
||||
{
|
||||
original,
|
||||
next: {
|
||||
...original,
|
||||
start: plan.firstHalf.start,
|
||||
end: plan.firstHalf.end,
|
||||
},
|
||||
},
|
||||
...(replacement ? [replacement] : []),
|
||||
...shiftChanges(plan.shifted),
|
||||
],
|
||||
creates: [
|
||||
{
|
||||
member_id: memberId,
|
||||
project_id: original.project_id,
|
||||
@@ -240,52 +328,65 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
description: original.description ?? null,
|
||||
tags: original.tags ?? [],
|
||||
},
|
||||
{ params: { organization: requireOrgId() } }
|
||||
);
|
||||
await saveBreakEntry(
|
||||
plan.breakSlot.start,
|
||||
plan.breakSlot.end,
|
||||
req.replaceBreakId ?? undefined,
|
||||
memberId
|
||||
);
|
||||
} else {
|
||||
const plan = planMoveInsert(
|
||||
[...req.workEntries, ...req.otherEntries],
|
||||
req.dayStart,
|
||||
req.dayEnd,
|
||||
breakStart,
|
||||
durationSeconds
|
||||
);
|
||||
if (!plan) throw new NoFreeWindowError(req.date, durationSeconds);
|
||||
entriesAdjusted = plan.shifted.length > 0;
|
||||
// Order the shifts so no intermediate step overlaps (matters when the org
|
||||
// prevents overlapping entries): entries moving earlier are updated left-to-right,
|
||||
// entries moving later right-to-left, so each one vacates before its neighbour moves.
|
||||
const shifts = plan.shifted
|
||||
.map((shift) => ({
|
||||
shift,
|
||||
original: timeEntries.value.find((e) => e.id === shift.id),
|
||||
}))
|
||||
.filter(
|
||||
(x): x is { shift: (typeof plan.shifted)[number]; original: TimeEntry } =>
|
||||
!!x.original
|
||||
);
|
||||
const movingEarlier = shifts
|
||||
.filter((x) => x.shift.start < x.original.start)
|
||||
.sort((a, b) => a.original.start.localeCompare(b.original.start));
|
||||
const movingLater = shifts
|
||||
.filter((x) => x.shift.start >= x.original.start)
|
||||
.sort((a, b) => b.original.start.localeCompare(a.original.start));
|
||||
for (const { shift, original } of [...movingEarlier, ...movingLater]) {
|
||||
await updateEntry({ ...original, start: shift.start, end: shift.end });
|
||||
}
|
||||
await saveBreakEntry(
|
||||
plan.breakSlot.start,
|
||||
plan.breakSlot.end,
|
||||
req.replaceBreakId ?? undefined,
|
||||
memberId
|
||||
);
|
||||
...(!replacement
|
||||
? [breakEntryBody(plan.breakSlot.start, plan.breakSlot.end, memberId)]
|
||||
: []),
|
||||
],
|
||||
entriesAdjusted: true,
|
||||
};
|
||||
}
|
||||
|
||||
const plan = planMoveInsert(
|
||||
[...req.workEntries, ...req.otherEntries],
|
||||
req.dayStart,
|
||||
req.dayEnd,
|
||||
breakStart,
|
||||
durationSeconds
|
||||
);
|
||||
if (!plan) throw new NoFreeWindowError(req.date, durationSeconds);
|
||||
|
||||
const replacement = replacementChange(req, plan.breakSlot.start, plan.breakSlot.end);
|
||||
return {
|
||||
updates: [...shiftChanges(plan.shifted), ...(replacement ? [replacement] : [])],
|
||||
creates: replacement
|
||||
? []
|
||||
: [breakEntryBody(plan.breakSlot.start, plan.breakSlot.end, memberId)],
|
||||
entriesAdjusted: plan.shifted.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply a declarative commit with reverse-order compensation if a later write fails. */
|
||||
async function executePlacement({
|
||||
req,
|
||||
breakStart,
|
||||
durationSeconds,
|
||||
memberId,
|
||||
}: {
|
||||
req: BreakPlacementRequest;
|
||||
breakStart: string;
|
||||
durationSeconds: number;
|
||||
memberId: string;
|
||||
}): Promise<{ entriesAdjusted: boolean }> {
|
||||
const commit = buildPlacementCommit({ req, breakStart, durationSeconds, memberId });
|
||||
const updates = nonOverlappingUpdateOrder(commit.updates, memberId);
|
||||
if (!updates) throw new NoFreeWindowError(req.date, durationSeconds);
|
||||
|
||||
return withCompensation(async (undo) => {
|
||||
await applyUpdates(updates, undo);
|
||||
for (const body of commit.creates) {
|
||||
const id = await createEntry(body);
|
||||
undo.push(() => deleteEntry(id));
|
||||
}
|
||||
return { entriesAdjusted: commit.entriesAdjusted };
|
||||
});
|
||||
}
|
||||
|
||||
const { mutateAsync: commitPlacement } = useMutation({
|
||||
mutationKey: ['timesheet', 'break-placement'],
|
||||
scope: { id: 'timesheet-break-placement' },
|
||||
retry: false,
|
||||
mutationFn: executePlacement,
|
||||
onSuccess: ({ entriesAdjusted }, { req }) => {
|
||||
notifications.addNotification(
|
||||
'success',
|
||||
req.replaceBreakId ? 'Break updated' : 'Break added',
|
||||
@@ -293,8 +394,19 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
? 'Your entries were adjusted to make room for the break.'
|
||||
: 'The break was added at the selected time.'
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof NoFreeWindowError) {
|
||||
if (breakPlacementRequest.value === req) breakPlacementRequest.value = null;
|
||||
},
|
||||
onError: (error, { req }) => {
|
||||
const operationError =
|
||||
error instanceof BreakPlacementSagaError ? error.operationError : error;
|
||||
if (error instanceof BreakPlacementSagaError && error.rollbackErrors.length > 0) {
|
||||
notifications.addNotification(
|
||||
'error',
|
||||
'Break placement needs attention',
|
||||
'Some entries could not be restored. The timesheet has been refreshed.'
|
||||
);
|
||||
if (breakPlacementRequest.value === req) breakPlacementRequest.value = null;
|
||||
} else if (operationError instanceof NoFreeWindowError) {
|
||||
notifications.addNotification(
|
||||
'error',
|
||||
"This day can't fit the break",
|
||||
@@ -303,15 +415,23 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
} else {
|
||||
notifications.addNotification(
|
||||
'error',
|
||||
'Failed to add break',
|
||||
req.replaceBreakId ? 'Failed to update break' : 'Failed to add break',
|
||||
'Please try again later.'
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
breakPlacementRequest.value = null;
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function applyBreakPlacement(breakStart: string, durationSeconds: number): Promise<void> {
|
||||
const req = breakPlacementRequest.value;
|
||||
if (!req) return;
|
||||
const memberId = getCurrentMembershipId();
|
||||
if (!memberId) throw new Error('No member context');
|
||||
|
||||
await commitPlacement({ req, breakStart, durationSeconds, memberId });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -17,6 +17,25 @@ vi.mock('@tanstack/vue-query', () => ({
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
}),
|
||||
useMutation: (options: {
|
||||
mutationFn: (variables: unknown) => Promise<unknown>;
|
||||
onSuccess?: (data: unknown, variables: unknown) => void | Promise<void>;
|
||||
onError?: (error: unknown, variables: unknown) => void | Promise<void>;
|
||||
onSettled?: () => void | Promise<void>;
|
||||
}) => ({
|
||||
mutateAsync: async (variables: unknown) => {
|
||||
try {
|
||||
const data = await options.mutationFn(variables);
|
||||
await options.onSuccess?.(data, variables);
|
||||
return data;
|
||||
} catch (error) {
|
||||
await options.onError?.(error, variables);
|
||||
throw error;
|
||||
} finally {
|
||||
await options.onSettled?.();
|
||||
}
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/notification', () => ({
|
||||
@@ -343,40 +362,6 @@ describe('useTimesheetCellMutations.handleCellUpdate', () => {
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an edited break anchored to its position instead of recentering', async () => {
|
||||
// 09-10 and 11:30-12:30 leave a 90-min gap; a resized 1h break has a valid
|
||||
// window of 10:00-10:30. The break already starts at 10:00, so it must stay
|
||||
// there (10:00-11:00) rather than jump to the centered 10:15-11:15.
|
||||
const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
|
||||
id: 'morning',
|
||||
type: 'work',
|
||||
});
|
||||
const afternoon = entry('2026-04-10T11:30:00Z', '2026-04-10T12:30:00Z', {
|
||||
id: 'afternoon',
|
||||
type: 'work',
|
||||
});
|
||||
const existingBreak = entry('2026-04-10T10:00:00Z', '2026-04-10T10:30:00Z', {
|
||||
id: 'break-1',
|
||||
project_id: null,
|
||||
type: 'break',
|
||||
});
|
||||
const row = buildRow(null, [existingBreak], 'break-row');
|
||||
row.type = 'break';
|
||||
const { cellMutations } = setup([morning, afternoon, existingBreak]);
|
||||
|
||||
await cellMutations.handleCellUpdate(row, 0, HOUR);
|
||||
|
||||
expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1);
|
||||
expect(firstArg(apiMocks.updateTimeEntry)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'break-1',
|
||||
start: '2026-04-10T10:00:00Z',
|
||||
end: '2026-04-10T11:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('grows a multi-break cell by re-placing the latest break, not fragmenting', async () => {
|
||||
// Two breaks share the break cell. Growing the cell total must extend the
|
||||
// latest-ending break (break-b) in place — never create a third break entry.
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
workDayStartOn,
|
||||
type FreeWindow,
|
||||
} from './cellMath';
|
||||
import { useBreakPlacement, BreakPlacementDeferred } from './useBreakPlacement';
|
||||
import { useBreakPlacement, type PlaceBreakResult } from './useBreakPlacement';
|
||||
|
||||
export type CellSaveStatus = 'saving' | 'saved' | 'error';
|
||||
|
||||
@@ -54,7 +54,8 @@ export function useTimesheetCellMutations(
|
||||
weekDays: Ref<string[]>,
|
||||
timeEntries: Ref<TimeEntry[]>,
|
||||
rows: Ref<TimesheetRow[]>,
|
||||
removeSlot: (key: TimesheetRowKey) => void
|
||||
removeSlot: (key: TimesheetRowKey) => void,
|
||||
preventOverlappingTimeEntries: () => boolean = () => false
|
||||
) {
|
||||
const dayjs = getDayJsInstance();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -70,7 +71,15 @@ export function useTimesheetCellMutations(
|
||||
// modal flow) is its own subsystem — it borrows the generic entry primitives
|
||||
// below (hoisted function declarations, so referenceable here).
|
||||
const { breakPlacementRequest, placeBreak, dismissBreakPlacement, applyBreakPlacement } =
|
||||
useBreakPlacement({ weekDays, timeEntries, requireOrgId, createCell, updateEntry });
|
||||
useBreakPlacement({
|
||||
weekDays,
|
||||
timeEntries,
|
||||
requireOrgId,
|
||||
createCell,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
preventOverlappingTimeEntries,
|
||||
});
|
||||
|
||||
function clearStatusTimer(key: string): void {
|
||||
clearTimeout(statusClearTimers[key]);
|
||||
@@ -125,7 +134,14 @@ export function useTimesheetCellMutations(
|
||||
const wasEmpty = row.totalSeconds === 0;
|
||||
|
||||
try {
|
||||
await dispatchCellUpdate(row, dayIndex, newTotalSeconds);
|
||||
const result = await dispatchCellUpdate(row, dayIndex, newTotalSeconds);
|
||||
if (result === 'needs-input') {
|
||||
// The placement modal owns the actual save, so return the cell to idle.
|
||||
clearStatusTimer(statusKey);
|
||||
delete cellStatus.value[statusKey];
|
||||
delete cellPendingSeconds.value[statusKey];
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasEmpty && newTotalSeconds > 0 && hasDuplicateIdentitySlot(row)) {
|
||||
removeSlot(row.key);
|
||||
@@ -137,14 +153,6 @@ export function useTimesheetCellMutations(
|
||||
}
|
||||
markSaved(statusKey);
|
||||
} catch (err) {
|
||||
if (err instanceof BreakPlacementDeferred) {
|
||||
// The break needs manual placement — revert the cell to idle (the
|
||||
// modal drives the actual save) instead of showing an error.
|
||||
clearStatusTimer(statusKey);
|
||||
delete cellStatus.value[statusKey];
|
||||
delete cellPendingSeconds.value[statusKey];
|
||||
return;
|
||||
}
|
||||
markError(statusKey);
|
||||
if (err instanceof NoFreeWindowError) {
|
||||
const friendlyDuration = formatHumanReadableDuration(
|
||||
@@ -182,25 +190,24 @@ export function useTimesheetCellMutations(
|
||||
row: TimesheetRow,
|
||||
dayIndex: number,
|
||||
newTotalSeconds: number
|
||||
): Promise<void> {
|
||||
): Promise<PlaceBreakResult> {
|
||||
const cell = row.cells.get(dayIndex);
|
||||
const existingSeconds = cell?.totalSeconds ?? 0;
|
||||
const diff = newTotalSeconds - existingSeconds;
|
||||
|
||||
if (newTotalSeconds === 0 && cell) {
|
||||
await deleteCell(cell);
|
||||
return;
|
||||
return 'committed';
|
||||
}
|
||||
|
||||
if (!cell || existingSeconds === 0) {
|
||||
// Breaks are placed relative to work (within tolerance), not just in the
|
||||
// first free slot, and may need the placement modal to resolve.
|
||||
if (row.type === 'break' && newTotalSeconds > 0) {
|
||||
await placeBreak(row, dayIndex, newTotalSeconds);
|
||||
return;
|
||||
return placeBreak(row, dayIndex, newTotalSeconds);
|
||||
}
|
||||
await createCell(row, dayIndex, newTotalSeconds);
|
||||
return;
|
||||
return 'committed';
|
||||
}
|
||||
|
||||
// Re-place breaks rather than extend/shrink them, which would fragment a break into
|
||||
@@ -209,24 +216,23 @@ export function useTimesheetCellMutations(
|
||||
// become obstacles); shrinking just trims the tail, which can't fragment.
|
||||
if (row.type === 'break') {
|
||||
if (cell.entries.length === 1) {
|
||||
await placeBreak(row, dayIndex, newTotalSeconds, cell.entries[0]!.id);
|
||||
return;
|
||||
return placeBreak(row, dayIndex, newTotalSeconds, cell.entries[0]!.id);
|
||||
}
|
||||
const tail = pickLatestEndedEntry(cell);
|
||||
if (diff > 0 && tail?.end) {
|
||||
await placeBreak(row, dayIndex, (tail.duration ?? 0) + diff, tail.id);
|
||||
return;
|
||||
return placeBreak(row, dayIndex, (tail.duration ?? 0) + diff, tail.id);
|
||||
}
|
||||
await shrinkFromEnd(cell, -diff);
|
||||
return;
|
||||
return 'committed';
|
||||
}
|
||||
|
||||
if (diff > 0) {
|
||||
await extendCell(row, dayIndex, cell, diff);
|
||||
return;
|
||||
return 'committed';
|
||||
}
|
||||
|
||||
await shrinkFromEnd(cell, -diff);
|
||||
return 'committed';
|
||||
}
|
||||
|
||||
async function deleteCell(cell: TimesheetCell): Promise<void> {
|
||||
|
||||
91
tests/Unit/Service/Dto/ReportPropertiesDtoTest.php
Normal file
91
tests/Unit/Service/Dto/ReportPropertiesDtoTest.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Service\Dto;
|
||||
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Models\Report;
|
||||
use App\Service\Dto\ReportPropertiesDto;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Tests\TestCase;
|
||||
|
||||
#[CoversClass(ReportPropertiesDto::class)]
|
||||
class ReportPropertiesDtoTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getBaseProperties(): array
|
||||
{
|
||||
return [
|
||||
'group' => 'project',
|
||||
'subGroup' => 'task',
|
||||
'historyGroup' => 'day',
|
||||
'weekStart' => 'monday',
|
||||
'timezone' => 'Europe/Vienna',
|
||||
'start' => '2024-01-01T00:00:00Z',
|
||||
'end' => '2024-01-31T00:00:00Z',
|
||||
'active' => null,
|
||||
'memberIds' => null,
|
||||
'billable' => null,
|
||||
'clientIds' => null,
|
||||
'projectIds' => null,
|
||||
'tagIds' => null,
|
||||
'taskIds' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $properties
|
||||
*/
|
||||
private function castFromJson(array $properties): ReportPropertiesDto
|
||||
{
|
||||
$json = json_encode($properties);
|
||||
$this->assertIsString($json);
|
||||
|
||||
return ReportPropertiesDto::castUsing([])->get(new Report, 'properties', $json, []);
|
||||
}
|
||||
|
||||
public function test_time_entry_type_defaults_to_work_if_the_value_is_missing_in_the_persisted_report(): void
|
||||
{
|
||||
// Arrange
|
||||
$properties = $this->getBaseProperties();
|
||||
|
||||
// Act
|
||||
$dto = $this->castFromJson($properties);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(TimeEntryType::Work, $dto->timeEntryType);
|
||||
}
|
||||
|
||||
public function test_time_entry_type_is_null_if_the_persisted_report_has_it_set_to_null(): void
|
||||
{
|
||||
// Arrange
|
||||
$properties = $this->getBaseProperties();
|
||||
$properties['timeEntryType'] = null;
|
||||
|
||||
// Act
|
||||
$dto = $this->castFromJson($properties);
|
||||
|
||||
// Assert
|
||||
$this->assertNull($dto->timeEntryType);
|
||||
}
|
||||
|
||||
public function test_time_entry_type_is_casted_to_the_enum_if_the_persisted_report_has_a_value(): void
|
||||
{
|
||||
// Arrange
|
||||
$propertiesWork = $this->getBaseProperties();
|
||||
$propertiesWork['timeEntryType'] = 'work';
|
||||
$propertiesBreak = $this->getBaseProperties();
|
||||
$propertiesBreak['timeEntryType'] = 'break';
|
||||
|
||||
// Act
|
||||
$dtoWork = $this->castFromJson($propertiesWork);
|
||||
$dtoBreak = $this->castFromJson($propertiesBreak);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(TimeEntryType::Work, $dtoWork->timeEntryType);
|
||||
$this->assertSame(TimeEntryType::Break, $dtoBreak->timeEntryType);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user