add break time entries and simplified time tracker ui

This commit is contained in:
Gregor Vostrak
2026-07-21 16:48:37 +02:00
parent 114a32536d
commit cbcd1e51f6
128 changed files with 6252 additions and 437 deletions

View File

@@ -9,7 +9,7 @@ export const buttonVariants = cva(
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 dark:bg-destructive/70',
outline:
'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',

View File

@@ -94,6 +94,7 @@ const emit = defineEmits<{
getEventOpacityClass(dayEvent, dayStr),
{
'running-entry rounded-b-none': dayEvent.event.isRunning,
'fc-event-break': dayEvent.event.isBreak,
'fc-event-dragging': isDragging && dragEventId === dayEvent.event.id,
'fc-event-resizing': resizeEventId === dayEvent.event.id,
'rounded-t-none': dayEvent.isClippedStart,
@@ -121,6 +122,8 @@ const emit = defineEmits<{
:project-name="dayEvent.event.project?.name"
:task-name="dayEvent.event.task?.name"
:client-name="dayEvent.event.client?.name"
:is-break="dayEvent.event.isBreak"
:is-misplaced-break="dayEvent.event.isMisplacedBreak"
:duration-seconds="getEventDurationSeconds(dayEvent, dayStr)" />
</div>
<div
@@ -413,4 +416,15 @@ const emit = defineEmits<{
.fc-events-inset-expanded {
left: 204px;
}
/* Breaks get a hatched texture so they can not be confused with a project color */
.fc-event-break {
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 5px,
rgba(217, 119, 6, 0.15) 5px,
rgba(217, 119, 6, 0.15) 7px
);
}
</style>

View File

@@ -7,10 +7,12 @@ import type { Dayjs } from 'dayjs';
const props = defineProps<{
date: Dayjs;
totalSeconds?: number;
breakSeconds?: number;
isToday?: boolean;
}>();
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
const breakSecondsValue = computed(() => props.breakSeconds ?? 0);
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
const intervalFormat = computed(() => organization?.value?.interval_format);
@@ -24,6 +26,10 @@ const numberFormat = computed(() => organization?.value?.number_format);
</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>
</div>
</template>

View File

@@ -2,6 +2,8 @@
import { computed, inject, type ComputedRef } from 'vue';
import { formatHumanReadableDuration, getDayJsInstance } from '../utils/time';
import type { Organization } from '@/packages/api/src';
import { Coffee } from '@lucide/vue';
import { ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
const props = defineProps<{
title: string;
@@ -11,6 +13,8 @@ const props = defineProps<{
durationSeconds?: number;
start?: string | Date | null;
end?: string | Date | null;
isBreak?: boolean;
isMisplacedBreak?: boolean;
}>();
const effectiveDurationSeconds = computed(() => {
@@ -41,7 +45,15 @@ const formattedDuration = computed(() =>
<template>
<div class="text-2xs leading-tight px-0.5 py-1">
<div class="font-semibold">{{ title }}</div>
<div class="font-semibold flex items-center gap-1">
<Coffee v-if="isBreak" class="w-3 h-3 shrink-0" />
<span class="truncate">{{ title }}</span>
<ExclamationTriangleIcon
v-if="isMisplacedBreak"
data-testid="calendar_break_placement_hint"
title="This break does not align with your work entries"
class="w-3 h-3 shrink-0 text-amber-600 dark:text-amber-400" />
</div>
<div v-if="projectName" class="font-medium opacity-90">
{{ projectName }}
</div>

View File

@@ -12,8 +12,10 @@ import {
} from 'vue';
import { useLocalStorage } from '@vueuse/core';
import { useCssVariable } from '../utils/useCssVariable';
import { getLocalizedDayJs } from '../utils/time';
import { useBreaksEnabled } from '../utils/useBreaksEnabled';
import { getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
import BreakCreateModal from '../TimeEntry/BreakCreateModal.vue';
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
import CalendarToolbar from './CalendarToolbar.vue';
import CalendarDayColumn from './CalendarDayColumn.vue';
@@ -34,6 +36,7 @@ import {
StopIcon,
XMarkIcon,
} from '@heroicons/vue/20/solid';
import { Coffee } from '@lucide/vue';
import type { ActivityPeriod } from './activityTypes';
import { SLOT_HEIGHT, TIME_AXIS_WIDTH, type DayEvent } from './calendarTypes';
import { useCalendarGrid } from './useCalendarGrid';
@@ -74,6 +77,8 @@ const props = defineProps<{
currency: string;
canCreateProject: boolean;
organizationBillableRate: number | null;
// Local date (YYYY-MM-DD) to open the calendar on, e.g. from a "Fix in calendar" deep link
initialDate?: string | null;
createTimeEntry: (
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
@@ -87,6 +92,9 @@ const props = defineProps<{
const newEventStart = ref<Dayjs | null>(null);
const newEventEnd = ref<Dayjs | null>(null);
const showCreateBreakModal = ref(false);
const newBreakStart = ref<Dayjs | null>(null);
const newBreakEnd = ref<Dayjs | null>(null);
const showCreateTimeEntryModal = ref<boolean>(false);
const showEditTimeEntryModal = ref<boolean>(false);
const selectedTimeEntry = ref<TimeEntry | null>(null);
@@ -114,6 +122,7 @@ const currentTime = ref(getLocalizedDayJs());
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
const organization = inject<ComputedRef<Organization>>('organization');
const breaksEnabled = useBreaksEnabled();
const {
slots,
@@ -138,23 +147,34 @@ const {
} = useCalendarNavigation({
onDatesChange: (payload) => emit('dates-change', payload),
scrollToCurrentTime: () => scrollToCurrentTime(),
// Parse as local midnight in the user's timezone — getLocalizedDayJs would
// treat the bare date as UTC midnight, landing on the previous local day
// for negative UTC offsets
initialDate: props.initialDate ? getLocalizedDayJsFromMinutes(props.initialDate, 0) : null,
});
const cssBackground = useCssVariable('--color-bg-background');
const { optimisticOverrides, calendarEvents, eventsByDay, dailyTotals, isToday, nowIndicatorTop } =
useCalendarEvents({
timeEntries: () => props.timeEntries,
projects: () => props.projects,
clients: () => props.clients,
tasks: () => props.tasks,
calendarSettings,
viewDays,
currentTime,
cssBackground,
minutesToPixels,
timeToMinutesFromMidnight,
});
const {
optimisticOverrides,
calendarEvents,
eventsByDay,
dailyTotals,
dailyBreakTotals,
isToday,
nowIndicatorTop,
} = useCalendarEvents({
timeEntries: () => props.timeEntries,
projects: () => props.projects,
clients: () => props.clients,
tasks: () => props.tasks,
calendarSettings,
viewDays,
currentTime,
cssBackground,
minutesToPixels,
timeToMinutesFromMidnight,
});
const {
activityBoxesForDay,
@@ -244,6 +264,7 @@ const {
handleContextStop,
handleContextDiscard,
handleContextCreate,
handleContextCreateBreak,
} = useContextMenu({
calendarSettings,
calendarEvents,
@@ -262,6 +283,11 @@ const {
newEventEnd.value = end;
showCreateTimeEntryModal.value = true;
},
onCreateBreak: (start, end) => {
newBreakStart.value = start;
newBreakEnd.value = end;
showCreateBreakModal.value = true;
},
emitRefresh: () => emit('refresh'),
});
@@ -274,6 +300,14 @@ watch(showCreateTimeEntryModal, (value) => {
}
});
watch(showCreateBreakModal, (value) => {
if (!value) {
newBreakStart.value = null;
newBreakEnd.value = null;
emit('refresh');
}
});
watch(showEditTimeEntryModal, (value) => {
if (!value) {
selectedTimeEntry.value = null;
@@ -455,6 +489,12 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
:start="newEventStart ? newEventStart.toISOString() : undefined"
:end="newEventEnd ? newEventEnd.toISOString() : undefined" />
<BreakCreateModal
v-model:show="showCreateBreakModal"
:create-time-entry="createTimeEntry"
:start="newBreakStart ? newBreakStart.toISOString() : undefined"
:end="newBreakEnd ? newBreakEnd.toISOString() : undefined" />
<TimeEntryEditModal
v-model:show="showEditTimeEntryModal"
:time-entry="selectedTimeEntry as any"
@@ -516,8 +556,9 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<FullCalendarDayHeader
:date="day"
:is-today="isToday(day)"
:total-seconds="
dailyTotals[day.format('YYYY-MM-DD')] || 0
:total-seconds="dailyTotals[day.format('YYYY-MM-DD')] || 0"
:break-seconds="
dailyBreakTotals[day.format('YYYY-MM-DD')] || 0
" />
</div>
</div>
@@ -682,11 +723,19 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<PencilIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuItem class="space-x-3" @select="handleContextDuplicate()">
<!-- Duplicate/Split create a new entry of the same type, which the
server rejects for breaks when breaks are disabled -->
<ContextMenuItem
v-if="contextMenuTimeEntry.type !== 'break' || breaksEnabled"
class="space-x-3"
@select="handleContextDuplicate()">
<DocumentDuplicateIcon class="w-4 h-4 text-icon-default" />
<span>Duplicate</span>
</ContextMenuItem>
<ContextMenuItem class="space-x-3" @select="handleContextSplit()">
<ContextMenuItem
v-if="contextMenuTimeEntry.type !== 'break' || breaksEnabled"
class="space-x-3"
@select="handleContextSplit()">
<ScissorsIcon class="w-4 h-4 text-icon-default" />
<span>Split</span>
</ContextMenuItem>
@@ -716,6 +765,13 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<PlusIcon class="w-4 h-4 text-icon-default" />
<span>Create Time Entry</span>
</ContextMenuItem>
<ContextMenuItem
v-if="breaksEnabled"
class="space-x-3"
@select="handleContextCreateBreak()">
<Coffee class="w-4 h-4 text-icon-default" />
<span>Add Break</span>
</ContextMenuItem>
</template>
</ContextMenuContent>
</ContextMenu>

View File

@@ -13,6 +13,8 @@ export interface CalendarEvent {
client?: Client;
task?: Task;
isRunning: boolean;
isBreak: boolean;
isMisplacedBreak: boolean;
durationMinutes: number;
title: string;
backgroundColor: string;

View File

@@ -2,6 +2,7 @@ import { computed, ref, type Ref, type ComputedRef } from 'vue';
import chroma from 'chroma-js';
import type { Dayjs } from 'dayjs';
import type { TimeEntry, Project, Client, Task } from '@/packages/api/src';
import { getBreakPlacementHint } from '../utils/breakPlacement';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent, DayEvent } from './calendarTypes';
@@ -181,7 +182,8 @@ export function useCalendarEvents(params: {
const calendarEvents = computed<CalendarEvent[]>(() => {
const themeBackground = params.cssBackground.value?.trim();
return params.timeEntries().map((rawEntry) => {
const allEntries = params.timeEntries();
return allEntries.map((rawEntry) => {
const timeEntry = optimisticOverrides.value.get(rawEntry.id) || rawEntry;
const isRunning = timeEntry.end === null;
const project = params.projects().find((p) => p.id === timeEntry.project_id);
@@ -196,9 +198,20 @@ export function useCalendarEvents(params: {
'minutes'
);
const title = timeEntry.description || 'No description';
const baseColor = project?.color || '#6B7280';
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
const isBreak = timeEntry.type === 'break';
const isMisplacedBreak = isBreak
? (getBreakPlacementHint(timeEntry, allEntries)?.misplaced ?? false)
: false;
let title: string;
if (isBreak) {
title = timeEntry.description ? `Break · ${timeEntry.description}` : 'Break';
} else {
title = timeEntry.description || 'No description';
}
const baseColor = isBreak ? '#F59E0B' : project?.color || '#6B7280';
const backgroundColor = chroma
.mix(baseColor, themeBackground, isBreak ? 0.75 : 0.65, 'lab')
.hex();
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
const startTime = getLocalizedDayJs(timeEntry.start);
@@ -215,6 +228,8 @@ export function useCalendarEvents(params: {
client,
task,
isRunning,
isBreak,
isMisplacedBreak,
durationMinutes,
title,
backgroundColor,
@@ -253,28 +268,37 @@ export function useCalendarEvents(params: {
return result;
});
const dailyTotals = computed(() => {
function computeDailyTotals(filter: (entry: TimeEntry) => boolean): Record<string, number> {
const totals: Record<string, number> = {};
params.timeEntries().forEach((entry) => {
const date = getLocalizedDayJs(entry.start).format('YYYY-MM-DD');
let durationSeconds: number;
params
.timeEntries()
.filter(filter)
.forEach((entry) => {
const date = getLocalizedDayJs(entry.start).format('YYYY-MM-DD');
let durationSeconds: number;
if (entry.end !== null) {
durationSeconds = getDayJsInstance()(entry.end).diff(
getDayJsInstance()(entry.start),
'seconds'
);
} else {
durationSeconds = Math.max(
0,
params.currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds')
);
}
if (entry.end !== null) {
durationSeconds = getDayJsInstance()(entry.end).diff(
getDayJsInstance()(entry.start),
'seconds'
);
} else {
durationSeconds = Math.max(
0,
params.currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds')
);
}
totals[date] = (totals[date] || 0) + durationSeconds;
});
totals[date] = (totals[date] || 0) + durationSeconds;
});
return totals;
});
}
// Breaks are not working time: the day total only sums work entries,
// the break portion is exposed separately
const dailyTotals = computed(() => computeDailyTotals((entry) => entry.type !== 'break'));
const dailyBreakTotals = computed(() => computeDailyTotals((entry) => entry.type === 'break'));
function isToday(day: Dayjs): boolean {
return day.isSame(getLocalizedDayJs(), 'day');
@@ -294,6 +318,7 @@ export function useCalendarEvents(params: {
calendarEvents,
eventsByDay,
dailyTotals,
dailyBreakTotals,
isToday,
nowIndicatorTop,
};

View File

@@ -6,9 +6,10 @@ import { getWeekStartDayNumber } from '../utils/settings';
export function useCalendarNavigation(callbacks: {
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
scrollToCurrentTime: () => void;
initialDate?: Dayjs | null;
}) {
const activeView = ref('timeGridWeek');
const currentDate = ref(getLocalizedDayJs());
const currentDate = ref(callbacks.initialDate ?? getLocalizedDayJs());
function getFirstDay(): number {
return getWeekStartDayNumber();

View File

@@ -0,0 +1,95 @@
import { computed, ref } from 'vue';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TimeEntry } from '@/packages/api/src';
import type { CalendarEvent } from './calendarTypes';
import { useContextMenu } from './useContextMenu';
function breakEntry(): TimeEntry {
return {
id: 'break-1',
start: '2026-07-14T10:00:00Z',
end: '2026-07-14T11:00:00Z',
duration: 3600,
description: 'Lunch',
project_id: null,
task_id: null,
organization_id: 'organization-1',
user_id: 'user-1',
tags: [],
billable: false,
type: 'break',
} as TimeEntry;
}
describe('useContextMenu break actions', () => {
const createTimeEntry = vi.fn().mockResolvedValue(undefined);
const updateTimeEntry = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.clearAllMocks();
});
function contextMenu() {
const entry = breakEntry();
const calendarEvents = computed(() => [
{
id: entry.id,
timeEntry: entry,
} as CalendarEvent,
]);
const menu = useContextMenu({
calendarSettings: ref({
snapMinutes: 15,
startHour: 0,
endHour: 24,
slotMinutes: 15,
}),
calendarEvents,
pixelsToMinutesFromMidnight: () => 0,
getDayFromClientX: () => null,
clientYToGridPixels: () => 0,
createTimeEntry,
updateTimeEntry,
deleteTimeEntry: vi.fn().mockResolvedValue(undefined),
onEditEvent: vi.fn(),
onCreateEvent: vi.fn(),
onCreateBreak: vi.fn(),
emitRefresh: vi.fn(),
});
menu.handleCalendarContextMenu({
target: {
closest: () => ({
getAttribute: () => entry.id,
}),
},
} as unknown as MouseEvent);
return menu;
}
it('preserves the type when duplicating a break', async () => {
await contextMenu().handleContextDuplicate();
expect(createTimeEntry).toHaveBeenCalledWith(
expect.objectContaining({
type: 'break',
})
);
});
it('preserves the type when creating the second half of a split break', async () => {
await contextMenu().handleContextSplit();
expect(updateTimeEntry).toHaveBeenCalledWith(
expect.objectContaining({
type: 'break',
})
);
expect(createTimeEntry).toHaveBeenCalledWith(
expect.objectContaining({
type: 'break',
})
);
});
});

View File

@@ -1,7 +1,7 @@
import { ref, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDayJsFromMinutes } from '../utils/time';
import { getDayJsInstance, getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent } from './calendarTypes';
@@ -19,6 +19,7 @@ export function useContextMenu(params: {
deleteTimeEntry: (id: string) => Promise<void>;
onEditEvent: (entry: TimeEntry) => void;
onCreateEvent: (start: Dayjs, end: Dayjs) => void;
onCreateBreak: (start: Dayjs, end: Dayjs) => void;
emitRefresh: () => void;
}) {
const contextMenuTimeEntry = ref<TimeEntry | null>(null);
@@ -73,6 +74,7 @@ export function useContextMenu(params: {
start: entry.start,
end: entry.end,
billable: entry.billable,
type: entry.type,
description: entry.description,
project_id: entry.project_id,
task_id: entry.task_id,
@@ -108,6 +110,7 @@ export function useContextMenu(params: {
start: midpoint.utc().format(),
end: entry.end,
billable: entry.billable,
type: entry.type,
description: entry.description,
project_id: entry.project_id,
task_id: entry.task_id,
@@ -154,6 +157,47 @@ export function useContextMenu(params: {
}
}
function handleContextCreateBreak() {
const dayjs = getDayJsInstance();
if (!contextMenuCreateTime.value) {
params.onCreateBreak(dayjs().utc().subtract(30, 'minute'), dayjs().utc());
return;
}
const clickTime = contextMenuCreateTime.value.start;
// Day matching must use the user's configured timezone (the calendar renders
// its day columns in that timezone), not the browser's local timezone
const clickDate = getLocalizedDayJs(clickTime.format()).format('YYYY-MM-DD');
// When the click lands in a gap between two entries of the same day,
// the break is prefilled to exactly fill that gap
let previousEnd: Dayjs | null = null;
let nextStart: Dayjs | null = null;
for (const calendarEvent of params.calendarEvents.value) {
const entry = calendarEvent.timeEntry;
const entryStart = dayjs.utc(entry.start);
if (getLocalizedDayJs(entry.start).format('YYYY-MM-DD') !== clickDate) {
continue;
}
const entryEnd = entry.end === null ? null : dayjs.utc(entry.end);
if (entryEnd !== null && !entryEnd.isAfter(clickTime)) {
if (previousEnd === null || entryEnd.isAfter(previousEnd)) {
previousEnd = entryEnd;
}
}
if (!entryStart.isBefore(clickTime)) {
if (nextStart === null || entryStart.isBefore(nextStart)) {
nextStart = entryStart;
}
}
}
if (previousEnd !== null && nextStart !== null && previousEnd.isBefore(nextStart)) {
params.onCreateBreak(previousEnd, nextStart);
return;
}
params.onCreateBreak(contextMenuCreateTime.value.start, contextMenuCreateTime.value.end);
}
return {
contextMenuTimeEntry,
contextMenuCreateTime,
@@ -165,5 +209,6 @@ export function useContextMenu(params: {
handleContextStop,
handleContextDiscard,
handleContextCreate,
handleContextCreateBreak,
};
}

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, ref, watch } from 'vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { Field, FieldLabel } from '../field';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import type { CreateTimeEntryBody } from '@/packages/api/src';
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
import { Coffee } from '@lucide/vue';
const show = defineModel('show', { default: false });
const saving = ref(false);
const props = defineProps<{
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => Promise<void>;
start?: string;
end?: string;
}>();
function defaultStart() {
return getDayJsInstance().utc().subtract(30, 'm').second(0).format();
}
function defaultEnd() {
return getDayJsInstance().utc().second(0).format();
}
const note = ref('');
const localStart = ref(getLocalizedDayJs(defaultStart()).format());
const localEnd = ref(getLocalizedDayJs(defaultEnd()).format());
// Prefill start/end when the modal is opened with a given range (e.g. from the calendar)
watch(
() => props.start,
(value) => {
if (value) {
localStart.value = getLocalizedDayJs(value).format();
}
}
);
watch(
() => props.end,
(value) => {
if (value) {
localEnd.value = getLocalizedDayJs(value).format();
}
}
);
const durationSeconds = computed(() =>
getLocalizedDayJs(localEnd.value).diff(getLocalizedDayJs(localStart.value), 'second')
);
async function submit() {
if (durationSeconds.value <= 0) return;
saving.value = true;
try {
await props.createTimeEntry({
description: note.value,
project_id: null,
task_id: null,
tags: [],
billable: false,
type: 'break',
start: getLocalizedDayJs(localStart.value).utc().format(),
end: getLocalizedDayJs(localEnd.value).utc().format(),
});
note.value = '';
localStart.value = getLocalizedDayJs(defaultStart()).format();
localEnd.value = getLocalizedDayJs(defaultEnd()).format();
show.value = false;
} finally {
saving.value = false;
}
}
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400">
<Coffee class="w-5 h-5" />
<span> Add break </span>
</div>
</template>
<template #content>
<div class="space-y-4">
<TimeRangeFields
v-model:start="localStart"
v-model:end="localEnd"
date-picker-size="sm"></TimeRangeFields>
<Field>
<FieldLabel for="break_note">Note (optional)</FieldLabel>
<TextInput
id="break_note"
v-model="note"
placeholder="e.g. Lunch"
type="text"
class="block w-full"
@keydown.enter="submit" />
</Field>
</div>
</template>
<template #footer>
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
tabindex="2"
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving || durationSeconds <= 0"
@click="submit">
Add Break
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import { Coffee } from '@lucide/vue';
</script>
<template>
<div
data-testid="break_badge"
class="flex items-center space-x-1.5 text-sm font-medium text-text-secondary">
<Coffee class="w-4 h-4 text-text-tertiary" />
<span>Break</span>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ExclamationTriangleIcon, ArrowRightIcon } from '@heroicons/vue/20/solid';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/packages/ui/src';
// Warning affordance for a misplaced break: an amber triangle that opens a small
// popover offering to jump to the break's day in the calendar. Rendered by the
// caller only when the break is actually misplaced (see `showPlacementHint`).
defineProps<{
// Local day (YYYY-MM-DD) the calendar should navigate to.
fixDate: string;
// Delegated navigation — packages/ui stays router-agnostic.
fixInCalendar?: (date: string) => void;
}>();
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
type="button"
data-testid="break_placement_hint"
title="This break does not align with your work entries"
class="flex items-center justify-center shrink-0 rounded-full p-0.5 text-amber-500 hover:bg-amber-500/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<ExclamationTriangleIcon class="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[260px]" align="start">
<div class="px-3 py-2 space-y-1.5">
<p class="text-xs text-text-secondary">
This break is not directly between work entries.
</p>
<button
v-if="fixInCalendar"
type="button"
data-testid="break_fix_in_calendar"
class="inline-flex items-center gap-1 text-sm font-medium text-accent-400 hover:underline"
@click="fixInCalendar(fixDate)">
Fix in calendar
<ArrowRightIcon class="w-3.5 h-3.5" />
</button>
</div>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -16,11 +16,19 @@ import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTag
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import { ref, inject, type ComputedRef } from 'vue';
import { formatHumanReadableDuration, formatStartEnd } from '@/packages/ui/src/utils/time';
import { ref, inject, computed, type ComputedRef } from 'vue';
import {
formatHumanReadableDuration,
formatStartEnd,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
import {
findMisplacedBreak,
type BreakPlacementHint,
} from '@/packages/ui/src/utils/breakPlacement';
import {
Checkbox,
ContextMenu,
@@ -30,6 +38,9 @@ import {
ContextMenuTrigger,
} from '@/packages/ui/src';
import { PlayIcon, TrashIcon } from '@heroicons/vue/20/solid';
import BreakLabel from '@/packages/ui/src/TimeEntry/BreakLabel.vue';
import BreakPlacementHintButton from '@/packages/ui/src/TimeEntry/BreakPlacementHintButton.vue';
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
timeEntry: TimeEntriesGroupedByType;
@@ -50,6 +61,8 @@ const props = defineProps<{
selectedTimeEntries: TimeEntry[];
enableEstimatedTime: boolean;
canCreateProject: boolean;
breakPlacementHints?: Record<string, BreakPlacementHint | null>;
fixInCalendar?: (date: string) => void;
}>();
const emit = defineEmits<{
selected: [TimeEntry[]];
@@ -57,6 +70,26 @@ const emit = defineEmits<{
}>();
const organization = inject<ComputedRef<Organization>>('organization');
const breaksEnabled = useBreaksEnabled();
// Continue creates a new entry of the same type, which the server rejects
// for breaks when breaks are disabled for the organization
const canRecreate = computed(() => props.timeEntry.type !== 'break' || breaksEnabled.value);
// Grouped breaks collapse into a single summary row, so surface the placement
// warning if any entry in the group is misplaced. All grouped entries share the
// same day, so the first misplaced one supplies the calendar navigation date.
const misplacedBreakEntry = computed<TimeEntry | null>(() =>
findMisplacedBreak(props.timeEntry.timeEntries, props.breakPlacementHints ?? {})
);
const showPlacementHint = computed(
() => props.timeEntry.type === 'break' && misplacedBreakEntry.value !== null
);
const breakFixDate = computed(() =>
misplacedBreakEntry.value
? getLocalizedDayJs(misplacedBreakEntry.value.start).format('YYYY-MM-DD')
: ''
);
function updateTimeEntryDescription(description: string) {
props.updateTimeEntries(
@@ -121,12 +154,26 @@ function onSelectChange(checked: boolean) {
{{ timeEntry?.timeEntries?.length }}
</GroupedItemsCountButton>
<TimeEntryDescriptionInput
v-if="timeEntry.type !== 'break'"
class="min-w-0 mr-4 shrink"
:model-value="timeEntry.description"
@changed="
updateTimeEntryDescription
"></TimeEntryDescriptionInput>
<BreakLabel
v-if="timeEntry.type === 'break'"
class="px-2 shrink-0" />
<span
v-if="timeEntry.type === 'break' && timeEntry.description"
class="min-w-0 mr-4 shrink truncate text-sm text-text-secondary">
{{ timeEntry.description }}
</span>
<BreakPlacementHintButton
v-if="showPlacementHint"
:fix-date="breakFixDate"
:fix-in-calendar="fixInCalendar" />
<TimeTrackerProjectTaskDropdown
v-if="timeEntry.type !== 'break'"
class="min-w-0 shrink"
:clients
:create-project
@@ -147,11 +194,13 @@ function onSelectChange(checked: boolean) {
<div
class="hidden @lg:flex items-center font-medium space-x-1 @lg:space-x-2 shrink-0">
<TimeEntryRowTagDropdown
v-if="timeEntry.type !== 'break'"
:create-tag
:tags="tags"
:model-value="timeEntry.tags"
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
<BillableToggleButton
v-if="timeEntry.type !== 'break'"
:model-value="timeEntry.billable"
size="small"
faded
@@ -189,6 +238,7 @@ function onSelectChange(checked: boolean) {
</button>
<TimeTrackerStartStop
v-if="canRecreate"
:active="!!(timeEntry.start && !timeEntry.end)"
variant="secondary"
class="opacity-60 flex group-hover:opacity-100 focus-visible:opacity-100"
@@ -231,7 +281,17 @@ function onSelectChange(checked: boolean) {
</div>
<!-- Second row: project/task - tags - billable - start - more -->
<div class="flex items-center justify-between mt-1">
<div
v-if="timeEntry.type === 'break'"
class="flex items-center min-w-0">
<BreakLabel class="px-2 min-w-0" />
<BreakPlacementHintButton
v-if="showPlacementHint"
:fix-date="breakFixDate"
:fix-in-calendar="fixInCalendar" />
</div>
<TimeTrackerProjectTaskDropdown
v-else
class="min-w-0"
:clients
:create-project
@@ -249,16 +309,19 @@ function onSelectChange(checked: boolean) {
"></TimeTrackerProjectTaskDropdown>
<div class="flex items-center shrink-0">
<TimeEntryRowTagDropdown
v-if="timeEntry.type !== 'break'"
:create-tag
:tags="tags"
:model-value="timeEntry.tags"
compact
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
<BillableToggleButton
v-if="timeEntry.type !== 'break'"
:model-value="timeEntry.billable"
size="small"
@changed="updateTimeEntryBillable"></BillableToggleButton>
<TimeTrackerStartStop
v-if="canRecreate"
:active="!!(timeEntry.start && !timeEntry.end)"
variant="secondary"
class="ml-2"
@@ -278,7 +341,7 @@ function onSelectChange(checked: boolean) {
</MainContainer>
<div
v-if="expanded"
class="w-full border-t border-default-background-separator bg-black/15">
class="w-full border-t border-default-background-separator bg-black/5 dark:bg-black/15">
<TimeEntryRow
v-for="subEntry in timeEntry.timeEntries"
:key="subEntry.id"
@@ -303,6 +366,8 @@ function onSelectChange(checked: boolean) {
:duplicate-time-entry="() => duplicateTimeEntry(subEntry)"
:currency="currency"
:create-tag
:placement-hint="breakPlacementHints?.[subEntry.id] ?? null"
:fix-in-calendar="fixInCalendar"
:time-entry="subEntry"
@selected="emit('selected', [subEntry])"
@unselected="emit('unselected', [subEntry])"></TimeEntryRow>
@@ -311,12 +376,13 @@ function onSelectChange(checked: boolean) {
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem
v-if="canRecreate"
class="space-x-3"
@select="onStartStopClick(timeEntry.timeEntries[0]!)">
<PlayIcon class="w-4 h-4 text-icon-default" />
<span>Continue</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSeparator v-if="canRecreate" />
<ContextMenuItem
class="space-x-3 text-destructive"
@select="deleteTimeEntries(timeEntry?.timeEntries ?? [])">

View File

@@ -5,7 +5,6 @@ import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, nextTick, ref, watch } from 'vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import { Field, FieldLabel } from '../field';
import { TagIcon } from '@heroicons/vue/20/solid';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import type {
@@ -19,12 +18,8 @@ import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
import type { Tag, Task } from '@/packages/api/src';
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
const show = defineModel('show', { default: false });
const saving = ref(false);
@@ -62,6 +57,7 @@ const timeEntryDefaultValues = {
task_id: null,
tags: [],
billable: false,
type: 'work' as CreateTimeEntryBody['type'],
start: getDayJsInstance().utc().subtract(1, 'h').second(0).format(),
end: getDayJsInstance().utc().second(0).format(),
};
@@ -107,9 +103,6 @@ const localEnd = ref(getLocalizedDayJs(timeEntryDefaultValues.end).format());
watch(localStart, (value) => {
timeEntry.value.start = getLocalizedDayJs(value).utc().format();
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
localEnd.value = value;
}
});
watch(localEnd, (value) => {
@@ -202,39 +195,11 @@ const billableProxy = computed({
</Select>
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4 pt-4">
<Field class="col-span-2 sm:col-span-3">
<FieldLabel>Duration</FieldLabel>
<div class="space-y-2 flex flex-col">
<DurationHumanInput
v-model:start="localStart"
v-model:end="localEnd"
name="Duration"></DurationHumanInput>
<div class="text-sm flex space-x-1">
<InformationCircleIcon
class="w-4 shrink-0 text-text-quaternary"></InformationCircleIcon>
<span class="text-text-secondary text-xs">
You can type natural language like
<span class="font-semibold"> 2h 30m</span>
</span>
</div>
</div>
</Field>
<Field>
<FieldLabel>Start</FieldLabel>
<div class="flex flex-col gap-2">
<TimePickerSimple v-model="localStart" class="w-full"></TimePickerSimple>
<DatePicker v-model="localStart" class="w-full" tabindex="1"></DatePicker>
</div>
</Field>
<Field>
<FieldLabel>End</FieldLabel>
<div class="flex flex-col gap-2">
<TimePickerSimple v-model="localEnd" class="w-full"></TimePickerSimple>
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
</div>
</Field>
</div>
<TimeRangeFields
v-model:start="localStart"
v-model:end="localEnd"
show-hint
class="pt-4"></TimeRangeFields>
</template>
<template #footer>
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>

View File

@@ -23,8 +23,14 @@ import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
import { Coffee } from '@lucide/vue';
import type { Tag, Task } from '@/packages/api/src';
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
// Breaks may have been disabled after this entry was created, so an existing break can still be
// edited (and converted back), but a work entry may only offer the break option when enabled.
const breaksEnabled = useBreaksEnabled();
const show = defineModel('show', { default: false });
const saving = ref(false);
@@ -137,6 +143,24 @@ const billableProxy = computed({
}
},
});
const isBreak = computed(() => editableTimeEntry.value?.type === 'break');
const typeProxy = computed({
get: () => editableTimeEntry.value?.type ?? 'work',
set: (value: string) => {
if (editableTimeEntry.value) {
editableTimeEntry.value.type = value as TimeEntry['type'];
if (value === 'break') {
// Breaks can not be billable, have tags or belong to a project/task
editableTimeEntry.value.project_id = null;
editableTimeEntry.value.task_id = null;
editableTimeEntry.value.billable = false;
editableTimeEntry.value.tags = [];
}
}
},
});
</script>
<template>
@@ -162,7 +186,7 @@ const billableProxy = computed({
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-2">
<div class="flex-1 min-w-0">
<div v-if="!isBreak" class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="editableTimeEntry.project_id"
v-model:task="editableTimeEntry.task_id"
@@ -178,8 +202,24 @@ const billableProxy = computed({
:tasks="tasks"
:enable-estimated-time="enableEstimatedTime" />
</div>
<div v-else class="flex-1 min-w-0"></div>
<div class="flex items-center gap-2 shrink-0">
<Select v-if="breaksEnabled || isBreak" v-model="typeProxy">
<SelectTrigger :show-chevron="false">
<SelectValue class="flex items-center gap-2">
<Coffee
class="h-4 w-4"
:class="isBreak ? 'text-amber-500' : 'text-icon-default'" />
<span>{{ isBreak ? 'Break' : 'Work time' }}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="work">Work time</SelectItem>
<SelectItem value="break">Break</SelectItem>
</SelectContent>
</Select>
<TagDropdown
v-if="!isBreak"
v-model="editableTimeEntry.tags"
:create-tag
:tags="tags"
@@ -195,7 +235,7 @@ const billableProxy = computed({
</Button>
</template>
</TagDropdown>
<Select v-model="billableProxy">
<Select v-if="!isBreak" v-model="billableProxy">
<SelectTrigger :show-chevron="false">
<SelectValue class="flex items-center gap-2">
<BillableIcon class="h-4 text-icon-default" />

View File

@@ -11,6 +11,10 @@ import type {
Client,
} from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDateFromTimestamp } from '@/packages/ui/src/utils/time';
import {
getBreakPlacementHint,
type BreakPlacementHint,
} from '@/packages/ui/src/utils/breakPlacement';
import TimeEntryAggregateRow from '@/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue';
import TimeEntryRowHeading from '@/packages/ui/src/TimeEntry/TimeEntryRowHeading.vue';
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
@@ -39,12 +43,24 @@ const props = withDefaults(
enableEstimatedTime: boolean;
canCreateProject: boolean;
groupSimilarTimeEntries?: boolean;
// Host-provided navigation to the calendar for a break's day (YYYY-MM-DD)
fixInCalendar?: (date: string) => void;
}>(),
{
groupSimilarTimeEntries: true,
}
);
const breakPlacementHints = computed<Record<string, BreakPlacementHint | null>>(() => {
const hints: Record<string, BreakPlacementHint | null> = {};
for (const entry of props.timeEntries) {
if (entry.type === 'break') {
hints[entry.id] = getBreakPlacementHint(entry, props.timeEntries);
}
}
return hints;
});
const groupedTimeEntries = computed(() => {
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
for (const entry of props.timeEntries) {
@@ -75,6 +91,7 @@ const groupedTimeEntries = computed(() => {
e.project_id === entry.project_id &&
e.task_id === entry.task_id &&
e.billable === entry.billable &&
e.type === entry.type &&
e.description === entry.description
);
if (oldEntriesIndex !== -1 && newDailyEntries[oldEntriesIndex]) {
@@ -113,13 +130,24 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
start: getDayJsInstance().utc().format(),
end: null,
billable: entry.billable,
type: entry.type,
description: entry.description,
tags: [...entry.tags],
});
}
function sumDuration(timeEntries: TimeEntry[]) {
return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
// Breaks are not working time: the day total only sums work entries,
// the break portion is shown separately in the heading
return timeEntries
.filter((entry) => entry.type !== 'break')
.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
}
function sumBreakDuration(timeEntries: TimeEntry[]) {
return timeEntries
.filter((entry) => entry.type === 'break')
.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
}
function selectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
for (const timeEntry of value) {
@@ -151,6 +179,7 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
<TimeEntryRowHeading
:date="String(key)"
:duration="sumDuration(value)"
:break-duration="sumBreakDuration(value)"
:checked="
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
"
@@ -176,6 +205,8 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
:create-tag
:currency="currency"
:organization-billable-rate="organizationBillableRate"
:break-placement-hints="breakPlacementHints"
:fix-in-calendar="fixInCalendar"
:time-entry="entry"
@selected="
(timeEntries: TimeEntry[]) => {
@@ -213,6 +244,9 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
:delete-time-entry="() => deleteTimeEntries([entry])"
:duplicate-time-entry="() => createTimeEntry(entry)"
:create-time-entry="createTimeEntry"
:placement-hint="breakPlacementHints[entry.timeEntries[0]!.id] ?? null"
:fix-in-calendar="fixInCalendar"
:currency="currency"
:time-entry="entry.timeEntries[0]!"
@selected="selectedTimeEntries.push(entry)"

View File

@@ -15,7 +15,7 @@ import {
type UpdateMultipleTimeEntriesChangeset,
} from '@/packages/api/src';
import { Checkbox } from '@/packages/ui/src';
import { TagIcon } from '@heroicons/vue/20/solid';
import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
@@ -129,6 +129,21 @@ watch(removeAllTags, () => {
selectedTags.value = [];
}
});
const selectedBreaksCount = computed(
() => props.timeEntries.filter((entry) => entry.type === 'break').length
);
// Mirrors the server-side skip in TimeEntryController::updateMultiple: a break
// entry is skipped entirely when the changeset assigns a project, makes it
// billable, or adds tags (clearing tags via removeAllTags is fine).
const showBreakWarning = computed(
() =>
selectedBreaksCount.value > 0 &&
((projectId.value !== null && projectId.value !== '') ||
billable.value === true ||
selectedTags.value.length > 0)
);
</script>
<template>
@@ -141,6 +156,20 @@ watch(removeAllTags, () => {
<template #content>
<div class="space-y-4">
<div
v-if="showBreakWarning"
data-testid="mass_update_break_warning"
class="flex items-start space-x-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:text-amber-400">
<ExclamationTriangleIcon class="w-4 h-4 mt-0.5 shrink-0" />
<span>
{{ selectedBreaksCount }}
{{ selectedBreaksCount === 1 ? 'break is' : 'breaks are' }} selected
breaks can not have a project or tags, or be billable, so
{{ selectedBreaksCount === 1 ? 'this entry' : 'these entries' }} will be
skipped entirely and none of the changes (including the description) will be
applied to {{ selectedBreaksCount === 1 ? 'it' : 'them' }}.
</span>
</div>
<Field>
<FieldLabel for="description">Description</FieldLabel>
<TextInput

View File

@@ -18,6 +18,8 @@ import TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowD
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import { TimeEntryEditModal } from '@/packages/ui/src';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
import { computed, ref } from 'vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import {
@@ -29,6 +31,10 @@ import {
ContextMenuTrigger,
} from '@/packages/ui/src';
import { PlayIcon, PencilIcon, DocumentDuplicateIcon, TrashIcon } from '@heroicons/vue/20/solid';
import BreakLabel from '@/packages/ui/src/TimeEntry/BreakLabel.vue';
import BreakPlacementHintButton from '@/packages/ui/src/TimeEntry/BreakPlacementHintButton.vue';
import type { BreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
import type { CreateTimeEntryBody } from '@/packages/api/src';
const props = defineProps<{
timeEntry: TimeEntry;
@@ -45,6 +51,9 @@ const props = defineProps<{
deleteTimeEntry: () => void;
duplicateTimeEntry?: () => void;
updateTimeEntry: (timeEntry: TimeEntry) => void;
createTimeEntry?: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
placementHint?: BreakPlacementHint | null;
fixInCalendar?: (date: string) => void;
currency: string;
organizationBillableRate: number | null;
showMember?: boolean;
@@ -59,6 +68,20 @@ const emit = defineEmits<{ selected: []; unselected: [] }>();
const showEditModal = ref(false);
const breaksEnabled = useBreaksEnabled();
const isBreak = computed(() => props.timeEntry.type === 'break');
// Continue/Duplicate create a new entry of the same type, which the server
// rejects for breaks when breaks are disabled for the organization
const canRecreate = computed(() => !isBreak.value || breaksEnabled.value);
const showPlacementHint = computed(
() => isBreak.value && props.placementHint != null && props.placementHint.misplaced
);
const breakFixDate = computed(() => getLocalizedDayJs(props.timeEntry.start).format('YYYY-MM-DD'));
function updateTimeEntryDescription(description: string) {
props.updateTimeEntry({ ...props.timeEntry, description });
}
@@ -131,10 +154,22 @@ async function handleDeleteTimeEntry() {
<Checkbox :checked="selected" @update:checked="onSelectChange" />
<div v-if="indent === true" class="w-10 h-7"></div>
<TimeEntryDescriptionInput
v-if="!isBreak"
class="min-w-0 mr-4 shrink"
:model-value="timeEntry.description"
@changed="updateTimeEntryDescription"></TimeEntryDescriptionInput>
<BreakLabel v-if="isBreak" class="pl-1.5 @lg:pl-3 pr-2 shrink-0" />
<span
v-if="isBreak && timeEntry.description"
class="min-w-0 mr-4 shrink truncate text-sm text-text-secondary">
{{ timeEntry.description }}
</span>
<BreakPlacementHintButton
v-if="showPlacementHint"
:fix-date="breakFixDate"
:fix-in-calendar="fixInCalendar" />
<TimeTrackerProjectTaskDropdown
v-if="!isBreak"
class="min-w-0 shrink"
:create-project
:create-client
@@ -154,11 +189,13 @@ async function handleDeleteTimeEntry() {
{{ memberName }}
</div>
<TimeEntryRowTagDropdown
v-if="!isBreak"
:create-tag
:tags="tags"
:model-value="timeEntry.tags"
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
<BillableToggleButton
v-if="!isBreak"
:model-value="timeEntry.billable"
size="small"
faded
@@ -176,11 +213,13 @@ async function handleDeleteTimeEntry() {
:is-report="props.isReport"
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
<TimeTrackerStartStop
v-if="canRecreate"
:active="!!(timeEntry.start && !timeEntry.end)"
variant="secondary"
class="opacity-60 flex focus-visible:opacity-100 group-hover:opacity-100"
@changed="onStartStopClick"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
:show-duplicate="canRecreate"
@edit="handleEdit"
@duplicate="duplicateTimeEntry"
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
@@ -190,11 +229,17 @@ async function handleDeleteTimeEntry() {
<!-- First row: description + duration -->
<div class="flex items-center justify-between min-w-0">
<TimeEntryDescriptionInput
v-if="!isBreak"
class="min-w-0 flex-1"
:model-value="timeEntry.description"
@changed="
updateTimeEntryDescription
"></TimeEntryDescriptionInput>
<span
v-else
class="min-w-0 flex-1 truncate text-sm text-text-secondary pl-1.5">
{{ timeEntry.description }}
</span>
<TimeEntryRowDurationInput
:start="timeEntry.start"
:end="timeEntry.end"
@@ -203,7 +248,9 @@ async function handleDeleteTimeEntry() {
</div>
<!-- Second row: project/task - tags - billable - start - more -->
<div class="flex items-center justify-between mt-1">
<BreakLabel v-if="isBreak" class="pl-1.5 pr-2 min-w-0" />
<TimeTrackerProjectTaskDropdown
v-else
class="min-w-0"
:create-project
:create-client
@@ -221,21 +268,25 @@ async function handleDeleteTimeEntry() {
"></TimeTrackerProjectTaskDropdown>
<div class="flex items-center shrink-0">
<TimeEntryRowTagDropdown
v-if="!isBreak"
:create-tag
:tags="tags"
:model-value="timeEntry.tags"
compact
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
<BillableToggleButton
v-if="!isBreak"
:model-value="timeEntry.billable"
size="small"
@changed="updateTimeEntryBillable"></BillableToggleButton>
<TimeTrackerStartStop
v-if="canRecreate"
:active="!!(timeEntry.start && !timeEntry.end)"
variant="secondary"
class="ml-2"
@changed="onStartStopClick"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
:show-duplicate="canRecreate"
@edit="handleEdit"
@duplicate="duplicateTimeEntry"
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
@@ -247,7 +298,7 @@ async function handleDeleteTimeEntry() {
</div>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem class="space-x-3" @select="onStartStopClick()">
<ContextMenuItem v-if="canRecreate" class="space-x-3" @select="onStartStopClick()">
<PlayIcon class="w-4 h-4 text-icon-default" />
<span>Continue</span>
</ContextMenuItem>
@@ -255,7 +306,7 @@ async function handleDeleteTimeEntry() {
<PencilIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuItem class="space-x-3" @select="duplicateTimeEntry?.()">
<ContextMenuItem v-if="canRecreate" class="space-x-3" @select="duplicateTimeEntry?.()">
<DocumentDuplicateIcon class="w-4 h-4 text-icon-default" />
<span>Duplicate</span>
</ContextMenuItem>

View File

@@ -12,11 +12,17 @@ import { CalendarIcon } from '@heroicons/vue/20/solid';
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{
date: string;
duration: number;
checked: boolean;
}>();
withDefaults(
defineProps<{
date: string;
duration: number;
checked: boolean;
breakDuration?: number;
}>(),
{
breakDuration: 0,
}
);
const emit = defineEmits<{
selectAll: [];
unselectAll: [];
@@ -55,6 +61,19 @@ function selectUnselectAll(value: boolean) {
</span>
</div>
<div class="text-text-primary pr-2 @lg:pr-[92px]">
<span
v-if="breakDuration > 0"
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(

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { watch } from 'vue';
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
import { Field, FieldLabel } from '../field';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
// Local (user timezone) ISO strings, as produced by getLocalizedDayJs(...).format()
const start = defineModel<string>('start', { required: true });
const end = defineModel<string>('end', { required: true });
defineProps<{
showHint?: boolean;
datePickerSize?: 'sm';
}>();
// Moving the start to or past the end drags the end along, preserving the
// range's previous duration, so the range never collapses or inverts.
watch(start, (value, oldValue) => {
if (getLocalizedDayJs(end.value).isAfter(getLocalizedDayJs(value))) return;
const previousDuration = Math.max(
0,
getLocalizedDayJs(end.value).diff(getLocalizedDayJs(oldValue), 'second')
);
end.value = getLocalizedDayJs(value).add(previousDuration, 'second').format();
});
</script>
<template>
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4">
<Field class="col-span-2 sm:col-span-3">
<FieldLabel>Duration</FieldLabel>
<div class="space-y-2 flex flex-col">
<DurationHumanInput
v-model:start="start"
v-model:end="end"
name="Duration"></DurationHumanInput>
<div v-if="showHint" class="text-sm flex space-x-1">
<InformationCircleIcon
class="w-4 shrink-0 text-text-quaternary"></InformationCircleIcon>
<span class="text-text-secondary text-xs">
You can type natural language like
<span class="font-semibold"> 2h 30m</span>
</span>
</div>
</div>
</Field>
<Field>
<FieldLabel>Start</FieldLabel>
<div class="flex flex-col gap-2">
<TimePickerSimple v-model="start" class="w-full"></TimePickerSimple>
<DatePicker
v-model="start"
:size="datePickerSize"
class="w-full"
tabindex="1"></DatePicker>
</div>
</Field>
<Field>
<FieldLabel>End</FieldLabel>
<div class="flex flex-col gap-2">
<TimePickerSimple v-model="end" class="w-full"></TimePickerSimple>
<DatePicker
v-model="end"
:size="datePickerSize"
class="w-full"
tabindex="1"></DatePicker>
</div>
</Field>
</div>
</template>

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import TimeTrackerTagDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerTagDropdown.vue';
import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
import TimeTrackerRangeSelector from '@/packages/ui/src/TimeTracker/TimeTrackerRangeSelector.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import TimeTrackerEntryInput from '@/packages/ui/src/TimeTracker/TimeTrackerEntryInput.vue';
import TimeTrackerProjectControls from '@/packages/ui/src/TimeTracker/TimeTrackerProjectControls.vue';
import type {
CreateClientBody,
CreateProjectBody,
@@ -13,35 +12,45 @@ import type {
TimeEntry,
Client,
} from '@/packages/api/src';
import { computed, nextTick, ref, watch } from 'vue';
import { nextTick, ref, watch } from 'vue';
import type { Dayjs } from 'dayjs';
import { useFocus } from '@vueuse/core';
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
import { useSelectEvents } from '@/packages/ui/src/utils/select';
import { Coffee, Play } from '@lucide/vue';
import type { TimeTrackerMode } from '@/packages/ui/src/TimeTracker/types';
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
required: true,
});
const liveTimer = defineModel<Dayjs | null>('liveTimer', { required: true });
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
const props = defineProps<{
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
timeEntries: TimeEntry[];
createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
isActive: boolean;
currency: string;
organizationBillableRate: number | null;
enableEstimatedTime: boolean;
canCreateProject: boolean;
}>();
const props = withDefaults(
defineProps<{
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
timeEntries: TimeEntry[];
createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
isActive: boolean;
currency: string;
organizationBillableRate: number | null;
enableEstimatedTime: boolean;
canCreateProject: boolean;
isOnBreak?: boolean;
breaksEnabled?: boolean;
canResumeAfterBreak?: boolean;
resumeDescription?: string | null;
timeTrackerMode?: TimeTrackerMode;
}>(),
{
isOnBreak: false,
breaksEnabled: false,
canResumeAfterBreak: false,
resumeDescription: null,
timeTrackerMode: 'project',
}
);
const emit = defineEmits<{
startTimer: [];
@@ -50,251 +59,136 @@ const emit = defineEmits<{
startLiveTimer: [];
stopLiveTimer: [];
createTimeEntry: [];
startBreak: [];
resumeAfterBreak: [];
}>();
function updateProject() {
setBillableDefaultForProject();
emit('updateTimeEntry');
}
function setAndStartTimer(timeEntry: TimeEntry) {
setCurrentTimeEntry(timeEntry);
if (!props.isActive) {
emit('startTimer');
} else {
emit('updateTimeEntry');
}
}
function setCurrentTimeEntry(timeEntry: TimeEntry) {
currentTimeEntry.value.description = timeEntry.description;
currentTimeEntry.value.project_id = timeEntry.project_id;
currentTimeEntry.value.task_id = timeEntry.task_id;
currentTimeEntry.value.tags = timeEntry.tags;
currentTimeEntry.value.billable = timeEntry.billable;
}
function startTimerIfNotActive() {
if (highlightedDropdownEntryId.value) {
const timeEntry = filteredRecentlyTrackedTimeEntries.value.find(
(item) => item.id === highlightedDropdownEntryId.value
);
if (timeEntry) {
setCurrentTimeEntry(timeEntry);
showDropdown.value = false;
}
} else {
currentTimeEntry.value.description = tempDescription.value;
}
if (!props.isActive) {
emit('startTimer');
} else {
emit('updateTimeEntry');
}
}
function setBillableDefaultForProject() {
const project = props.projects.find(
(project) => project.id === currentTimeEntry.value.project_id
);
if (project) {
currentTimeEntry.value.billable = project.is_billable;
}
}
const blockRefocus = ref(false);
const entryInput = ref<InstanceType<typeof TimeTrackerEntryInput> | null>(null);
function onToggleButtonPress(newState: boolean) {
if (newState) {
emit('startTimer');
if (!blockRefocus.value) {
currentTimeEntryDescriptionInput.value?.focus();
}
entryInput.value?.focusAfterStart();
} else {
emit('stopTimer');
}
}
const tempDescription = ref(currentTimeEntry.value.description);
watch(
() => currentTimeEntry.value.description,
() => {
tempDescription.value = currentTimeEntry.value.description;
}
);
function updateTimeEntryDescription() {
if (currentTimeEntry.value.description !== tempDescription.value) {
currentTimeEntry.value.description = tempDescription.value;
emit('updateTimeEntry');
}
// Pressing Enter in the range selector starts the timer, same as in the description input.
function onRangeEnter() {
entryInput.value?.submit();
}
const filteredRecentlyTrackedTimeEntries = computed(() => {
// do not include running time entries
const finishedTimeEntries = props.timeEntries.filter((item) => item.end !== null);
// filter out duplicates based on description, task, project, tags and billable
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
return (
index ===
self.findIndex(
(t) =>
t.description === item.description &&
t.task_id === item.task_id &&
t.project_id === item.project_id &&
t.tags.length === item.tags.length &&
t.tags.every((tag) => item.tags.includes(tag)) &&
t.billable === item.billable
)
);
});
// filter time entries based on current description
return nonDuplicateTimeEntries
.filter((item) => {
return item.description
?.toLowerCase()
?.includes(tempDescription.value?.toLowerCase()?.trim() || '');
})
.slice(0, 5);
});
const showDropdown = ref(false);
const { focused } = useFocus(currentTimeEntryDescriptionInput);
watch(focused, (focused) => {
nextTick(() => {
// make sure the click event on the dropdown does not get interrupted
showDropdown.value = focused;
// make sure that the input does not get refocused after the dropdown is closed
if (!focused) {
blockRefocus.value = true;
setTimeout(() => {
blockRefocus.value = false;
}, 100);
// After a break ends the tracker returns to the idle input; focus it so a fresh
// entry is just type + Enter.
watch(
() => props.isOnBreak,
async (isOnBreak, wasOnBreak) => {
if (wasOnBreak && !isOnBreak) {
await nextTick();
entryInput.value?.focusAfterStart();
}
});
});
const floating = ref(null);
const { floatingStyles } = useFloating(currentTimeEntryDescriptionInput, floating, {
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
middleware: [
offset(10),
shift({
limiter: limitShift({
offset: 5,
}),
}),
flip({
fallbackAxisSideDirection: 'start',
}),
],
});
const highlightedDropdownEntryId = ref<string | null>(null);
useSelectEvents(
filteredRecentlyTrackedTimeEntries,
highlightedDropdownEntryId,
(item) => item.id,
showDropdown
}
);
</script>
<template>
<div class="flex items-center relative @container" data-testid="dashboard_timer">
<div
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg border transition shadow-card"
:class="
isOnBreak
? 'bg-amber-500/10 border-amber-500/30'
: 'bg-card-background border-card-border'
">
<div class="flex flex-1 items-center relative">
<input
ref="currentTimeEntryDescriptionInput"
v-model="tempDescription"
placeholder="What are you working on?"
data-testid="time_entry_description"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary focus:ring-0 transition"
type="text"
@keydown.enter="startTimerIfNotActive"
@keydown.esc="showDropdown = false"
@blur="updateTimeEntryDescription" />
<div class="@2xl:hidden pr-3 shrink-0">
<div
v-if="isOnBreak"
class="flex w-full items-center gap-2 py-4 sm:py-2.5 px-3.5 @2xl:px-4 text-base font-medium text-amber-600 dark:text-amber-400">
<Coffee class="w-5 h-5 shrink-0" />
<span>On break</span>
</div>
<TimeTrackerEntryInput
v-else
ref="entryInput"
v-model:current-time-entry="currentTimeEntry"
:time-entries="timeEntries"
:projects="projects"
:tasks="tasks"
:is-active="isActive"
@start-timer="emit('startTimer')"
@update-time-entry="emit('updateTimeEntry')"></TimeTrackerEntryInput>
<div class="@2xl:hidden pr-3 shrink-0 flex items-center space-x-2">
<button
v-if="breaksEnabled && !isOnBreak && isActive"
type="button"
title="Take a break"
aria-label="Take a break"
class="flex items-center justify-center w-8 h-8 rounded-full bg-quaternary text-text-tertiary hover:text-amber-500 focus:ring-2 focus:ring-border-tertiary transition"
@click="emit('startBreak')">
<Coffee class="w-4 h-4" />
</button>
<TimeTrackerStartStop
:active="isActive"
:variant="isOnBreak ? 'break' : 'primary'"
@changed="onToggleButtonPress"></TimeTrackerStartStop>
</div>
<div
v-if="showDropdown && filteredRecentlyTrackedTimeEntries.length > 0"
ref="floating"
class="z-50 w-[min(640px,100vw-2rem)]"
:style="floatingStyles">
<div
class="rounded-lg w-full border border-card-border overflow-hidden shadow-dropdown bg-card-background">
<div
class="text-text-tertiary text-xs font-semibold border-b border-border-tertiary px-2 py-1.5">
Recently Tracked Time Entries
</div>
<div class="text-text-secondary py-1 px-1.5">
<TimeTrackerRecentlyTrackedEntry
v-for="timeEntry in filteredRecentlyTrackedTimeEntries"
:key="timeEntry.id"
:time-entry="timeEntry"
:highlighted="highlightedDropdownEntryId === timeEntry.id"
:projects="projects"
:tasks="tasks"
@mousedown="setAndStartTimer(timeEntry)"
@mouseenter="
highlightedDropdownEntryId = timeEntry.id
"></TimeTrackerRecentlyTrackedEntry>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between pl-2 shrink min-w-0">
<div class="flex items-center w-[130px] @2xl:w-auto shrink min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="currentTimeEntry.project_id"
v-model:task="currentTimeEntry.task_id"
variant="outline"
:create-client
:can-create-project
:clients
:create-project
:currency="currency"
:organization-billable-rate="organizationBillableRate"
:projects="projects"
:tasks="tasks"
:enable-estimated-time="enableEstimatedTime"
@changed="updateProject"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center space-x-0 @4xl:space-x-2 px-2 @4xl:px-4 shrink-0">
<TimeTrackerTagDropdown
v-model="currentTimeEntry.tags"
:create-tag
:tags="tags"
@changed="$emit('updateTimeEntry')"></TimeTrackerTagDropdown>
<BillableToggleButton
v-model="currentTimeEntry.billable"
@changed="$emit('updateTimeEntry')"></BillableToggleButton>
</div>
<div class="border-l border-card-border">
<TimeTrackerProjectControls
v-if="!isOnBreak && timeTrackerMode !== 'simple'"
v-model:current-time-entry="currentTimeEntry"
:projects="projects"
:tasks="tasks"
:tags="tags"
:clients="clients"
:create-tag="createTag"
:create-project="createProject"
:create-client="createClient"
:currency="currency"
:organization-billable-rate="organizationBillableRate"
:enable-estimated-time="enableEstimatedTime"
:can-create-project="canCreateProject"
@update-time-entry="emit('updateTimeEntry')"></TimeTrackerProjectControls>
<button
v-if="isOnBreak && canResumeAfterBreak"
type="button"
class="mx-2 flex min-w-0 shrink items-center gap-1.5 h-8 px-3 rounded-md bg-transparent border border-amber-500/40 hover:bg-amber-500/15 text-sm font-medium text-amber-600 dark:text-amber-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 transition"
@click="emit('resumeAfterBreak')">
<Play class="w-4 h-4 shrink-0" />
<span class="truncate">{{
resumeDescription ? `Resume "${resumeDescription}"` : 'Resume'
}}</span>
</button>
<div
class="border-l"
:class="isOnBreak ? 'border-amber-500/40' : 'border-card-border'">
<TimeTrackerRangeSelector
v-model:current-time-entry="currentTimeEntry"
v-model:live-timer="liveTimer"
:is-on-break="isOnBreak"
@start-live-timer="emit('startLiveTimer')"
@stop-live-timer="emit('stopLiveTimer')"
@update-timer="emit('updateTimeEntry')"
@start-timer="emit('startTimer')"
@create-time-entry="emit('createTimeEntry')"
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
@keydown.enter="onRangeEnter"></TimeTrackerRangeSelector>
</div>
</div>
</div>
<div class="pl-4 @2xl:pl-6 pr-3 hidden @2xl:block">
<div class="pl-4 @2xl:pl-6 pr-3 hidden @2xl:flex items-center space-x-3">
<button
v-if="breaksEnabled && !isOnBreak && isActive"
type="button"
title="Take a break"
aria-label="Take a break"
class="flex items-center justify-center w-9 h-9 rounded-full bg-quaternary text-text-tertiary hover:text-amber-500 focus:ring-2 focus:ring-border-tertiary transition"
@click="emit('startBreak')">
<Coffee class="w-5 h-5" />
</button>
<TimeTrackerStartStop
:active="isActive"
:variant="isOnBreak ? 'break' : 'primary'"
size="large"
@changed="onToggleButtonPress"></TimeTrackerStartStop>
</div>

View File

@@ -0,0 +1,200 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useFocus } from '@vueuse/core';
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
import { useSelectEvents } from '@/packages/ui/src/utils/select';
import type { Project, Task, TimeEntry } from '@/packages/api/src';
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', { required: true });
const props = defineProps<{
timeEntries: TimeEntry[];
projects: Project[];
tasks: Task[];
isActive: boolean;
}>();
const emit = defineEmits<{ startTimer: []; updateTimeEntry: [] }>();
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
const tempDescription = ref(currentTimeEntry.value.description);
watch(
() => currentTimeEntry.value.description,
() => {
tempDescription.value = currentTimeEntry.value.description;
}
);
function updateTimeEntryDescription() {
if (currentTimeEntry.value.description !== tempDescription.value) {
currentTimeEntry.value.description = tempDescription.value;
emit('updateTimeEntry');
}
}
function setCurrentTimeEntry(timeEntry: TimeEntry) {
currentTimeEntry.value.description = timeEntry.description;
currentTimeEntry.value.project_id = timeEntry.project_id;
currentTimeEntry.value.task_id = timeEntry.task_id;
currentTimeEntry.value.tags = timeEntry.tags;
currentTimeEntry.value.billable = timeEntry.billable;
currentTimeEntry.value.type = timeEntry.type;
}
function setAndStartTimer(timeEntry: TimeEntry) {
setCurrentTimeEntry(timeEntry);
if (!props.isActive) {
emit('startTimer');
} else {
emit('updateTimeEntry');
}
}
// Starts the timer from the description input / range selector Enter: picks the highlighted
// recently-tracked entry if one is active, otherwise commits the typed description.
function submit() {
if (highlightedDropdownEntryId.value) {
const timeEntry = filteredRecentlyTrackedTimeEntries.value.find(
(item) => item.id === highlightedDropdownEntryId.value
);
if (timeEntry) {
setCurrentTimeEntry(timeEntry);
showDropdown.value = false;
}
} else {
currentTimeEntry.value.description = tempDescription.value;
}
if (!props.isActive) {
emit('startTimer');
} else {
emit('updateTimeEntry');
}
}
const filteredRecentlyTrackedTimeEntries = computed(() => {
// do not include running time entries and breaks (breaks are started via the break button)
const finishedTimeEntries = props.timeEntries.filter(
(item) => item.end !== null && item.type !== 'break'
);
// filter out duplicates based on description, task, project, tags and billable
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
return (
index ===
self.findIndex(
(t) =>
t.description === item.description &&
t.task_id === item.task_id &&
t.project_id === item.project_id &&
t.tags.length === item.tags.length &&
t.tags.every((tag) => item.tags.includes(tag)) &&
t.billable === item.billable
)
);
});
// filter time entries based on current description
return nonDuplicateTimeEntries
.filter((item) => {
return item.description
?.toLowerCase()
?.includes(tempDescription.value?.toLowerCase()?.trim() || '');
})
.slice(0, 5);
});
const showDropdown = ref(false);
const blockRefocus = ref(false);
const { focused } = useFocus(currentTimeEntryDescriptionInput);
watch(focused, (focused) => {
nextTick(() => {
// make sure the click event on the dropdown does not get interrupted
showDropdown.value = focused;
// make sure that the input does not get refocused after the dropdown is closed
if (!focused) {
blockRefocus.value = true;
setTimeout(() => {
blockRefocus.value = false;
}, 100);
}
});
});
const floating = ref(null);
const { floatingStyles } = useFloating(currentTimeEntryDescriptionInput, floating, {
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
middleware: [
offset(10),
shift({
limiter: limitShift({
offset: 5,
}),
}),
flip({
fallbackAxisSideDirection: 'start',
}),
],
});
const highlightedDropdownEntryId = ref<string | null>(null);
useSelectEvents(
filteredRecentlyTrackedTimeEntries,
highlightedDropdownEntryId,
(item) => item.id,
showDropdown
);
// Called by the shell after the start/stop button starts a timer, so typing can continue.
function focusAfterStart() {
if (!blockRefocus.value) {
currentTimeEntryDescriptionInput.value?.focus();
}
}
defineExpose({ submit, focusAfterStart });
</script>
<template>
<input
ref="currentTimeEntryDescriptionInput"
v-model="tempDescription"
placeholder="What are you working on?"
data-testid="time_entry_description"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary focus:ring-0 transition"
type="text"
@keydown.enter="submit"
@keydown.esc="showDropdown = false"
@blur="updateTimeEntryDescription" />
<div
v-if="showDropdown && filteredRecentlyTrackedTimeEntries.length > 0"
ref="floating"
class="z-50 w-[min(640px,100vw-2rem)]"
:style="floatingStyles">
<div
class="rounded-lg w-full border border-card-border overflow-hidden shadow-dropdown bg-card-background">
<div
class="text-text-tertiary text-xs font-semibold border-b border-border-tertiary px-2 py-1.5">
Recently Tracked Time Entries
</div>
<div class="text-text-secondary py-1 px-1.5">
<TimeTrackerRecentlyTrackedEntry
v-for="timeEntry in filteredRecentlyTrackedTimeEntries"
:key="timeEntry.id"
:time-entry="timeEntry"
:highlighted="highlightedDropdownEntryId === timeEntry.id"
:projects="projects"
:tasks="tasks"
@mousedown="setAndStartTimer(timeEntry)"
@mouseenter="
highlightedDropdownEntryId = timeEntry.id
"></TimeTrackerRecentlyTrackedEntry>
</div>
</div>
</div>
</template>

View File

@@ -1,14 +1,28 @@
<script setup lang="ts">
import { PlusIcon, XMarkIcon } from '@heroicons/vue/20/solid';
import { PlusIcon, XMarkIcon, ClockIcon } from '@heroicons/vue/20/solid';
import { Coffee } from '@lucide/vue';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '..';
import type { TimeTrackerMode } from '@/packages/ui/src/TimeTracker/types';
const props = defineProps<{
hasActiveTimer: boolean;
}>();
const props = withDefaults(
defineProps<{
hasActiveTimer: boolean;
timeTrackerMode?: TimeTrackerMode;
breaksEnabled?: boolean;
isOnBreak?: boolean;
}>(),
{
timeTrackerMode: 'project',
breaksEnabled: false,
isOnBreak: false,
}
);
const emit = defineEmits<{
manualEntry: [];
startBreak: [];
discard: [];
toggleTimeTrackerMode: [];
}>();
</script>
@@ -39,6 +53,23 @@ const emit = defineEmits<{
<PlusIcon class="w-5" />
<span>Manual time entry</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="props.breaksEnabled && !props.isOnBreak"
class="flex items-center space-x-3 cursor-pointer"
@click="emit('startBreak')">
<Coffee class="w-5" />
<span>Start Break</span>
</DropdownMenuItem>
<DropdownMenuItem
class="flex items-center space-x-3 cursor-pointer"
@click="emit('toggleTimeTrackerMode')">
<ClockIcon class="w-5" />
<span>{{
props.timeTrackerMode === 'simple'
? 'Switch to project mode'
: 'Switch to simple mode'
}}</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="props.hasActiveTimer"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"

View File

@@ -0,0 +1,56 @@
import { shallowMount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
import TimeTrackerProjectControls from './TimeTrackerProjectControls.vue';
import TimeTrackerProjectTaskDropdown from './TimeTrackerProjectTaskDropdown.vue';
import type { Project, TimeEntry } from '@/packages/api/src';
function timeEntry(overrides: Partial<TimeEntry> = {}): TimeEntry {
return {
id: 'te-1',
description: '',
start: '2026-07-14T09:00:00Z',
end: null,
duration: null,
project_id: null,
task_id: null,
organization_id: 'org-1',
user_id: 'user-1',
tags: [],
billable: false,
type: 'work',
...overrides,
} as TimeEntry;
}
describe('TimeTrackerProjectControls', () => {
it('adopts the billable default of a newly selected project', async () => {
const current = timeEntry({ project_id: null, billable: false });
const billableProject = { id: 'p-1', is_billable: true } as Project;
const wrapper = shallowMount(TimeTrackerProjectControls, {
props: {
currentTimeEntry: current,
projects: [billableProject],
tasks: [],
tags: [],
clients: [],
createTag: vi.fn(),
createProject: vi.fn(),
createClient: vi.fn(),
currency: 'EUR',
organizationBillableRate: null,
enableEstimatedTime: false,
canCreateProject: false,
},
});
const dropdown = wrapper.findComponent(TimeTrackerProjectTaskDropdown);
// The dropdown sets the project via v-model, then emits `changed`.
dropdown.vm.$emit('update:project', 'p-1');
dropdown.vm.$emit('changed');
await nextTick();
expect(current.billable).toBe(true);
expect(wrapper.emitted('updateTimeEntry')).toBeTruthy();
});
});

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import TimeTrackerTagDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerTagDropdown.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import type {
Client,
CreateClientBody,
CreateProjectBody,
Project,
Tag,
Task,
TimeEntry,
} from '@/packages/api/src';
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', { required: true });
const props = defineProps<{
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string;
organizationBillableRate: number | null;
enableEstimatedTime: boolean;
canCreateProject: boolean;
}>();
const emit = defineEmits<{ updateTimeEntry: [] }>();
function updateProject() {
// Adopt the project's billable default when a project is picked.
const project = props.projects.find((p) => p.id === currentTimeEntry.value.project_id);
if (project) {
currentTimeEntry.value.billable = project.is_billable;
}
emit('updateTimeEntry');
}
</script>
<template>
<div class="flex items-center w-[130px] @2xl:w-auto shrink min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="currentTimeEntry.project_id"
v-model:task="currentTimeEntry.task_id"
variant="outline"
:create-client="createClient"
:can-create-project="canCreateProject"
:clients="clients"
:create-project="createProject"
:currency="currency"
:organization-billable-rate="organizationBillableRate"
:projects="projects"
:tasks="tasks"
:enable-estimated-time="enableEstimatedTime"
@changed="updateProject"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center space-x-0 @4xl:space-x-2 px-2 @4xl:px-4 shrink-0">
<TimeTrackerTagDropdown
v-model="currentTimeEntry.tags"
:create-tag="createTag"
:tags="tags"
@changed="emit('updateTimeEntry')"></TimeTrackerTagDropdown>
<BillableToggleButton
v-model="currentTimeEntry.billable"
@changed="emit('updateTimeEntry')"></BillableToggleButton>
</div>
</template>

View File

@@ -694,8 +694,8 @@ const showCreateProject = ref(false);
class="flex items-center space-x-2 w-full px-5 py-1.5 text-start text-xs font-semibold leading-5 text-text-primary focus:outline-none transition duration-150 ease-in-out"
:class="
row.task.id === highlightedItemId
? 'bg-card-background-active'
: 'bg-quaternary'
? 'bg-quaternary dark:bg-tertiary'
: 'bg-tertiary dark:bg-quaternary'
"
@click="selectTask(row.task.id)"
@mouseenter="setHighlightItemId(row.task.id)">

View File

@@ -11,6 +11,15 @@ const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
});
const now = defineModel<null | Dayjs>('liveTimer');
withDefaults(
defineProps<{
isOnBreak?: boolean;
}>(),
{
isOnBreak: false,
}
);
const emit = defineEmits<{
startLiveTimer: [];
stopLiveTimer: [];
@@ -154,7 +163,12 @@ function closeAndFocusInput() {
v-model="currentTime"
placeholder="00:00:00"
data-testid="time_entry_time"
class="w-[110px] lg:w-[120px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base font-semibold bg-card-background border-none placeholder-text-tertiary focus:ring-0 transition"
class="w-[110px] lg:w-[120px] h-full py-2.5 rounded-lg text-center px-4 text-base font-semibold placeholder-text-tertiary focus:ring-0 transition"
:class="
isOnBreak
? 'text-amber-600 dark:text-amber-400 bg-transparent border-none'
: 'text-text-primary bg-card-background border-border-secondary border border-none'
"
type="text"
@focusin="openModalOnTab"
@click="openModalOnClick"

View File

@@ -0,0 +1,6 @@
/**
* How the time tracker presents its controls. `project` shows the full
* project/task/tag/billable controls; `simple` hides them for plain
* description-only tracking. Persisted client-side as a UI preference.
*/
export type TimeTrackerMode = 'project' | 'simple';

View File

@@ -11,6 +11,7 @@ const timeTrackerVariants = cva(
'text-white ring-accent-200/10 focus-visible:ring-ring focus-visible:ring-2 ring-4 sm:ring-[6px]',
secondary:
'bg-quaternary text-text-tertiary hover:text-text-primary focus:ring-2 focus:ring-border-tertiary',
break: 'text-white ring-amber-200/10 focus-visible:ring-ring focus-visible:ring-2 ring-4 sm:ring-[6px]',
},
size: {
small: 'w-6 h-6',
@@ -33,6 +34,16 @@ const timeTrackerVariants = cva(
active: false,
class: 'bg-accent-300/70 hover:bg-accent-400/70 focus:bg-accent-700',
},
{
variant: 'break',
active: true,
class: 'bg-amber-500/80 hover:bg-amber-600/80 focus:bg-amber-600/80',
},
{
variant: 'break',
active: false,
class: 'bg-accent-300/70 hover:bg-accent-400/70 focus:bg-accent-700',
},
],
defaultVariants: {
variant: 'primary',

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import type { TimeEntry } from '@/packages/api/src';
import {
findMisplacedBreak,
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 hint(misplaced: boolean): BreakPlacementHint {
return {
misplaced,
previousWorkEnd: null,
nextWorkStart: null,
gapBeforeSeconds: null,
gapAfterSeconds: null,
};
}
describe('findMisplacedBreak', () => {
it('returns the first misplaced break in a group', () => {
const entries = [breakEntry('break-a'), breakEntry('break-b')];
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();
});
it('returns null when the group has no placement hints', () => {
const entries = [breakEntry('break-a'), breakEntry('break-b')];
expect(findMisplacedBreak(entries, {})).toBeNull();
});
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();
});
});

View File

@@ -0,0 +1,102 @@
import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
export interface BreakPlacementHint {
misplaced: boolean;
// Closest work end at or before the break start (null if none is known)
previousWorkEnd: string | null;
// Closest work start at or after the break end (null if none is known)
nextWorkStart: string | null;
gapBeforeSeconds: number | null;
gapAfterSeconds: number | null;
}
// How far a break may sit from the nearest work entry before it gets a placement hint
export const BREAK_GAP_TOLERANCE_MINUTES = 30;
/**
* Grouped breaks collapse into a single summary row, so the row needs to know
* whether any break in the group is misplaced (to show the warning) and which
* one to navigate to (all grouped entries share the same day). Returns the first
* misplaced break in the group, or null when none is flagged.
*/
export function findMisplacedBreak(
entries: TimeEntry[],
hints: Record<string, BreakPlacementHint | null>
): TimeEntry | null {
return entries.find((entry) => hints[entry.id]?.misplaced) ?? null;
}
/**
* A break only means something between work. This computes how far a break
* sits from the nearest work entry on either side — everything non-work
* (untracked gaps and other breaks) counts as distance. If either side has
* more than the tolerated gap (or no work at all), the break gets a hint.
*
* Non-blocking by design: placement can not be validated at write time
* (it depends on entries that may not exist yet), so it is derived at read
* time from the loaded entries.
*/
export function getBreakPlacementHint(
breakEntry: TimeEntry,
allEntries: TimeEntry[]
): BreakPlacementHint | null {
if (breakEntry.type !== 'break') {
return null;
}
const dayjs = getDayJsInstance();
const breakStart = dayjs.utc(breakEntry.start);
const breakEnd = breakEntry.end === null ? dayjs.utc() : dayjs.utc(breakEntry.end);
const toleranceSeconds = BREAK_GAP_TOLERANCE_MINUTES * 60;
let previousWorkEnd: ReturnType<typeof dayjs> | null = null;
let nextWorkStart: ReturnType<typeof dayjs> | null = null;
for (const entry of allEntries) {
if (entry.type === 'break' || entry.id === breakEntry.id) {
continue;
}
const entryStart = dayjs.utc(entry.start);
const entryEnd = entry.end === null ? dayjs.utc() : dayjs.utc(entry.end);
// Work overlapping the break counts as touching on both sides
if (entryEnd.isAfter(breakStart) && entryStart.isBefore(breakEnd)) {
return {
misplaced: false,
previousWorkEnd: entryEnd.format(),
nextWorkStart: entryStart.format(),
gapBeforeSeconds: 0,
gapAfterSeconds: 0,
};
}
if (!entryEnd.isAfter(breakStart)) {
if (previousWorkEnd === null || entryEnd.isAfter(previousWorkEnd)) {
previousWorkEnd = entryEnd;
}
}
if (!entryStart.isBefore(breakEnd)) {
if (nextWorkStart === null || entryStart.isBefore(nextWorkStart)) {
nextWorkStart = entryStart;
}
}
}
const gapBeforeSeconds =
previousWorkEnd !== null ? breakStart.diff(previousWorkEnd, 'second') : null;
const gapAfterSeconds = nextWorkStart !== null ? nextWorkStart.diff(breakEnd, 'second') : null;
// A running break has no "after" side yet — only judge the before side
const isRunning = breakEntry.end === null;
const beforeMisplaced = gapBeforeSeconds === null || gapBeforeSeconds > toleranceSeconds;
const afterMisplaced =
!isRunning && (gapAfterSeconds === null || gapAfterSeconds > toleranceSeconds);
return {
misplaced: beforeMisplaced || afterMisplaced,
previousWorkEnd: previousWorkEnd?.format() ?? null,
nextWorkStart: nextWorkStart?.format() ?? null,
gapBeforeSeconds,
gapAfterSeconds,
};
}

View File

@@ -0,0 +1,19 @@
import { computed, inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
/**
* Whether break tracking is enabled for the current organization.
*
* Components below the app layout can call this with no argument (the layout
* provides `organization`); pages that sit above the layout pass their own
* organization ref. Without an organization (e.g. public report views) breaks
* count as disabled.
*/
export function useBreaksEnabled(organization?: {
value: Organization | undefined | null;
}): ComputedRef<boolean> {
const org =
organization ??
inject<ComputedRef<Organization | undefined> | undefined>('organization', undefined);
return computed(() => org?.value?.breaks_enabled ?? false);
}