add description/project labels to break placement modal for existing

time entries
This commit is contained in:
Gregor Vostrak
2026-07-28 15:21:58 +02:00
parent a1d6c92806
commit 600daf44d9
3 changed files with 50 additions and 17 deletions

View File

@@ -697,8 +697,12 @@ test('test that adding a timesheet break to a full day splits the work entry via
const breakCell = await fillBreakCell(page, '0.5'); const breakCell = await fillBreakCell(page, '0.5');
await breakCell.press('Enter'); 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')).toBeVisible();
await expect(page.getByTestId('break_placement_summary')).toContainText(
'No Project · Split me'
);
await Promise.all([ await Promise.all([
waitForBreakCreated(page), waitForBreakCreated(page),
page.getByRole('button', { name: 'Add break' }).click(), page.getByRole('button', { name: 'Add break' }).click(),

View File

@@ -13,12 +13,14 @@ import {
planMoveInsert, planMoveInsert,
planSplitEntry, planSplitEntry,
type BreakPlacementRequest, type BreakPlacementRequest,
type Interval,
} from '@/utils/timesheet/breakPlacementMath'; } from '@/utils/timesheet/breakPlacementMath';
import { BREAK_GAP_TOLERANCE_MINUTES } from '@/packages/ui/src/utils/breakPlacement'; import { BREAK_GAP_TOLERANCE_MINUTES } from '@/packages/ui/src/utils/breakPlacement';
const props = defineProps<{ const props = defineProps<{
request: BreakPlacementRequest | null; request: BreakPlacementRequest | null;
apply: (breakStart: string, durationSeconds: number) => Promise<void>; apply: (breakStart: string, durationSeconds: number) => Promise<void>;
entryLabel: (id: string) => string;
}>(); }>();
const emit = defineEmits<{ cancel: [] }>(); const emit = defineEmits<{ cancel: [] }>();
@@ -124,30 +126,39 @@ const explanation = computed(() => {
: "There's no free gap that fits this break, so the surrounding entries will be shifted to make room."; : "There's no free gap that fits this break, so the surrounding entries will be shifted to make room.";
}); });
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') { if (mode.value === 'split') {
const plan = splitPlan.value; const plan = splitPlan.value;
if (!plan) return []; if (!plan) return [];
const workLabel = props.entryLabel(req.workEntries[0]!.id);
return [ return [
`${fmt(plan.firstHalf.start)}${fmt(plan.firstHalf.end)} (work)`, { times: range(plan.firstHalf), label: workLabel },
`${fmt(plan.breakSlot.start)}${fmt(plan.breakSlot.end)} (break)`, { times: range(plan.breakSlot), label: 'Break' },
`${fmt(plan.secondHalf.start)}${fmt(plan.secondHalf.end)} (work)`, { times: range(plan.secondHalf), label: workLabel },
...plan.shifted.map((shift) => { ...plan.shifted.map((shift) => ({
const original = props.request!.otherEntries.find((e) => e.id === shift.id)!; times: moved(req.otherEntries.find((e) => e.id === shift.id)!, shift),
return `${fmt(original.start)}${fmt(original.end)}${fmt(shift.start)}${fmt(shift.end)} (break)`; label: props.entryLabel(shift.id),
}), })),
]; ];
} }
const plan = movePlan.value; const plan = movePlan.value;
if (!plan) return []; 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) => { return plan.shifted.map((shift) => {
const isBreak = props.request!.otherEntries.some((e) => e.id === shift.id);
const original = const original =
props.request!.workEntries.find((e) => e.id === shift.id) ?? req.workEntries.find((e) => e.id === shift.id) ??
props.request!.otherEntries.find((e) => e.id === shift.id)!; req.otherEntries.find((e) => e.id === shift.id)!;
const label = `${fmt(original.start)}${fmt(original.end)}${fmt(shift.start)}${fmt(shift.end)}`; return { times: moved(original, shift), label: props.entryLabel(shift.id) };
return isBreak ? `${label} (break)` : label;
}); });
}); });
@@ -189,8 +200,14 @@ async function submit() {
<div class="text-xs uppercase tracking-wide text-text-tertiary"> <div class="text-xs uppercase tracking-wide text-text-tertiary">
{{ mode === 'split' ? 'Result' : 'Entries that move' }} {{ mode === 'split' ? 'Result' : 'Entries that move' }}
</div> </div>
<div v-for="(line, index) in changeSummary" :key="index" class="tabular-nums"> <div
{{ line }} 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> </div>
<div <div

View File

@@ -133,6 +133,17 @@ const {
() => organization.value?.prevent_overlapping_time_entries ?? false () => 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 // 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. // row, so a flat set is enough — its cells show a warning for dates in the set.
const misplacedBreakDates = computed<Set<string>>(() => { const misplacedBreakDates = computed<Set<string>>(() => {
@@ -257,6 +268,7 @@ async function createTag(name: string): Promise<Tag | undefined> {
<BreakPlacementModal <BreakPlacementModal
:request="breakPlacementRequest" :request="breakPlacementRequest"
:apply="applyBreakPlacement" :apply="applyBreakPlacement"
:entry-label="breakPlanEntryLabel"
@cancel="dismissBreakPlacement" /> @cancel="dismissBreakPlacement" />
</AppLayout> </AppLayout>
</template> </template>