Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
974 changes: 966 additions & 8 deletions api/openapi-v2.json

Large diffs are not rendered by default.

22 changes: 21 additions & 1 deletion frontend/src/lib/api/time.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,25 @@ export const time = {
getByItem: (itemId, requestOptions = {}) =>
fetchAllV2Pages(`/items/${itemId}/worklogs`, requestOptions),
},

// Draft worklogs: incomplete, user-owned entries produced when a timer stops
// without booking information. Booked entries leave this list.
worklogDrafts: {
getAll: (requestOptions = {}) => fetchV2Data('/time/worklog-drafts', requestOptions),
get: (id, requestOptions = {}) => fetchV2Data(`/time/worklog-drafts/${id}`, requestOptions),
update: (id, data) =>
fetchV2Data(`/time/worklog-drafts/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify(data),
}),
book: (id, data) =>
fetchV2Data(`/time/worklog-drafts/${id}/book`, {
method: 'POST',
body: JSON.stringify(data),
}),
delete: (id) => fetchV2Data(`/time/worklog-drafts/${id}`, { method: 'DELETE' }),
},
};

export const timer = {
Expand All @@ -99,8 +118,9 @@ export const timer = {
body: JSON.stringify(data),
}),
getActive: () => fetchV2Data('/time/timers/active'),
stop: () =>
stop: (timerId = null) =>
fetchV2Data('/time/timers/active/stop', {
method: 'POST',
body: JSON.stringify(timerId ? { timer_id: timerId } : {}),
}),
};
10 changes: 4 additions & 6 deletions frontend/src/lib/commands/providers/timeProvider.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { navigate } from '../../router.js';
import { stopTimerAndReview } from '../../features/time/stopTimerAndReview.js';
import { timeLogStore } from '../../stores/timeLogStore.svelte.js';
import { timerStore } from '../../stores/timerStore.svelte.js';
import { errorToast, infoToast, warningToast } from '../../stores/toasts.svelte.js';
import { BUCKET } from '../buckets.js';
Expand Down Expand Up @@ -40,10 +41,7 @@ export function timeProvider(ctx) {
bucket: BUCKET.MODULE_ACTIONS,
keywords: ['log', 'time', 'entry', 'hours', 'work', 'track', 'add'],
execute: () => {
navigate('/time');
setTimeout(() => {
window.dispatchEvent(new CustomEvent('focus-time-entry-form'));
}, 100);
timeLogStore.open();
},
}),
];
Expand Down Expand Up @@ -76,7 +74,7 @@ export function timeProvider(ctx) {
execute: async () => {
if (timerStore.canStop) {
try {
await timerStore.stop();
await stopTimerAndReview(timerStore, timeLogStore);
} catch (err) {
errorToast(t('dialogs.alerts.stopTimerFailed', { error: err.message }));
}
Expand Down
43 changes: 31 additions & 12 deletions frontend/src/lib/dialogs/TimeLogModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@
workItems = [],
workspaces = [],
editingWorklog = null,
draft = null,
onsave = () => {},
oncancel = () => {}
oncancel = () => {},
ondiscard = null
} = $props();

// One explicit timezone populates and interprets every form control, so
Expand All @@ -55,18 +57,22 @@
}

const durationSync = createDurationSync(); // Guarded updates to avoid recursive time sync
// Worklogs and drafts share the same field names (absolute unix timestamps
// plus duration_minutes), so one source record drives the editable form.
const sourceRecord = editingWorklog ?? draft ?? null;
const isDraft = $derived(!!draft && !editingWorklog);
// Snapshot props for the editable form; updates are then user-driven, not prop-driven.
// svelte-ignore state_referenced_locally
let formData = $state({
project_id: editingWorklog?.project_id ?? defaultProjectId,
item_id: editingWorklog?.item_id ?? defaultItemId,
description: editingWorklog?.description ?? '',
date: editingWorklog
? dateKeyInZone(editingWorklog.start_time, formTimezone)
project_id: sourceRecord?.project_id ?? defaultProjectId,
item_id: sourceRecord?.item_id ?? defaultItemId,
description: sourceRecord?.description ?? '',
date: sourceRecord
? dateKeyInZone(sourceRecord.date ?? sourceRecord.start_time, formTimezone)
: (defaultDate ?? dateKeyInZone(Date.now() / 1000, formTimezone)),
start_time: editingWorklog ? formatClockInZone(editingWorklog.start_time, formTimezone) : (defaultStartTime ?? ''),
end_time: editingWorklog ? formatClockInZone(editingWorklog.end_time, formTimezone) : '',
duration: editingWorklog ? formatDurationFromMinutes(editingWorklog.duration_minutes) : ''
start_time: sourceRecord?.start_time != null ? formatClockInZone(sourceRecord.start_time, formTimezone) : (defaultStartTime ?? ''),
end_time: sourceRecord?.end_time != null ? formatClockInZone(sourceRecord.end_time, formTimezone) : '',
duration: sourceRecord?.duration_minutes != null ? formatDurationFromMinutes(sourceRecord.duration_minutes) : ''
});

// Time fields the form was opened with; an edit that leaves them untouched
Expand Down Expand Up @@ -244,7 +250,7 @@
>
{#snippet children(submitHint)}
<!-- Header -->
<ModalHeader title={editingWorklog ? t('time.editTimeEntry') : t('time.logTime')} showCloseButton={false} />
<ModalHeader title={editingWorklog ? t('time.editTimeEntry') : (isDraft ? t('time.completeTimeEntry') : t('time.logTime'))} showCloseButton={false} />

<!-- Form -->
<div class="p-6 space-y-4">
Expand Down Expand Up @@ -394,10 +400,23 @@
<DialogFooter
onCancel={handleCancel}
onConfirm={handleSave}
confirmLabel={editingWorklog ? t('time.updateEntry') : t('time.logTime')}
confirmLabel={editingWorklog ? t('time.updateEntry') : (isDraft ? t('time.bookTimeEntry') : t('time.logTime'))}
disabled={!isFormValid}
showKeyboardHint={true}
confirmKeyboardHint={submitHint}
/>
>
{#snippet extra()}
{#if isDraft && ondiscard}
<button
type="button"
class="text-sm font-medium hover:underline text-ds-text-danger"
data-testid="time-log-discard"
onclick={ondiscard}
>
{t('common.discard')}
</button>
{/if}
{/snippet}
</DialogFooter>
{/snippet}
</Modal>
85 changes: 31 additions & 54 deletions frontend/src/lib/features/items/ItemDetail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { publicBaseURL } from '../../runtime/contextPath.js';
import { workspacePermissions } from '../../stores/workspacePermissions.svelte.js';
import { itemDetailStore } from '../../stores/itemDetailStore.svelte.js';
import { timeLogStore } from '../../stores/timeLogStore.svelte.js';
import { t } from '../../stores/i18n.svelte.js';
import { getShortcut, matchesShortcut, isTypingInField } from '../../utils/keyboardShortcuts.js';
import Trash2 from '@lucide/svelte/icons/trash-2';
Expand Down Expand Up @@ -58,7 +59,6 @@ import ItemSplitDialog from './ItemSplitDialog.svelte';

// Import the shared content component
import ItemDetailContent from '../items/ItemDetailContent.svelte';
import TimeLogModal from '../../dialogs/TimeLogModal.svelte';
import TestCaseViewModal from '../../dialogs/TestCaseViewModal.svelte';
import RecurrenceEditor from '../../editors/RecurrenceEditor.svelte';
import Button from '../../components/Button.svelte';
Expand Down Expand Up @@ -643,13 +643,20 @@ import NativeSelect from '../../components/NativeSelect.svelte';
}

async function handleLogTime() {
await itemDetailStore.loadTimeModalData();
itemDetailStore.openTimeLogModal();
await timeLogStore.open({
itemId: parseInt(itemId),
defaultProjectId: getDefaultProjectForTimeLogging(),
showWorkItemField: false,
});
}

async function handleEditWorklog(detail) {
await itemDetailStore.loadTimeModalData();
itemDetailStore.openTimeLogModal(detail);
await timeLogStore.open({
itemId: parseInt(itemId),
defaultProjectId: getDefaultProjectForTimeLogging(),
showWorkItemField: false,
editingWorklog: detail,
});
}

async function handleDeleteWorklog(detail) {
Expand All @@ -664,28 +671,6 @@ import NativeSelect from '../../components/NativeSelect.svelte';
}
}

async function handleModalSave(event) {
try {
const data = event.detail;
if (itemDetailStore.editingWorklog) {
await time.worklogs.update(itemDetailStore.editingWorklog.id, data);
} else {
await time.worklogs.create(data);
}

// Reload worklogs
await itemDetailStore.reloadWorklogs();
itemDetailStore.closeTimeLogModal();
} catch (error) {
console.error('Failed to save worklog:', error);
showError(t('items.failedToSaveTimeEntry'), error.message || t('errors.UNKNOWN'));
}
}

function handleModalCancel() {
itemDetailStore.closeTimeLogModal();
}

// Get default project for time logging
function getDefaultProjectForTimeLogging() {
return itemDetailStore.getDefaultProjectForTimeLogging();
Expand Down Expand Up @@ -1182,16 +1167,12 @@ import NativeSelect from '../../components/NativeSelect.svelte';
description: 'Add a time entry for this work item',
keywords: ['log', 'time', 'hours', 'work', 'track', 'entry'],
action: () => {
// Trigger the time entry form in the tabs component
if (tab !== 'time') {
tab = 'time';
}
// Use a small delay to ensure tab is switched
setTimeout(() => {
window.dispatchEvent(new CustomEvent('item-detail-show-time-entry', {
detail: { itemId: item.id }
}));
}, 100);
// Open the shared time-log surface prefilled with this item.
void timeLogStore.open({
itemId: item.id,
defaultProjectId: getDefaultProjectForTimeLogging(),
showWorkItemField: false,
});
},
priority: COMMAND_PRIORITIES.HIGH,
category: 'time'
Expand Down Expand Up @@ -1263,6 +1244,18 @@ import NativeSelect from '../../components/NativeSelect.svelte';
itemDetailStore.item && populateDropdownItems();
});

// Reload worklogs after the shared time-log host saves or books an entry.
let lastTimeLogRevision = 0;
$effect(() => {
const revision = timeLogStore.revision;
if (revision !== lastTimeLogRevision) {
lastTimeLogRevision = revision;
itemDetailStore.reloadWorklogs().catch((err) => {
surfaceError(err, { context: 'reloadWorklogsAfterTimeLog' });
});
}
});

// Reload worklogs when timer stops (activeTimer becomes null from non-null)
let previousActiveTimer; // Plain variable, not reactive - prevents self-invalidation
$effect(() => {
Expand Down Expand Up @@ -1592,23 +1585,7 @@ import NativeSelect from '../../components/NativeSelect.svelte';
{/if}
{/if}

<!-- Time Log Modal: rendered outside the isModal branch so logging time
works from both the item detail modal and the full-page view. -->
{#if itemDetailStore.showTimeLogModal}
<TimeLogModal
defaultProjectId={getDefaultProjectForTimeLogging()}
defaultItemId={parseInt(itemId)}
projects={itemDetailStore.timeProjects}
customers={itemDetailStore.customers}
workItems={itemDetailStore.workItems}
workspaces={itemDetailStore.workspaces}
editingWorklog={itemDetailStore.editingWorklog}
showProjectField={true}
showWorkItemField={false}
onsave={handleModalSave}
oncancel={handleModalCancel}
/>
{/if}
<!-- Time Log Modal is mounted by the shared TimeLogHost in the shell. -->

<ItemMoveWorkspaceDialog
bind:isOpen={showWorkspaceMoveDialog}
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/features/time/FloatingTimer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { onDestroy } from 'svelte';
import { useEventListener } from 'runed';
import { timerStore } from '../../stores/timerStore.svelte.js';
import { timeLogStore } from '../../stores/timeLogStore.svelte.js';
import { stopTimerAndReview } from './stopTimerAndReview.js';
import Clock from '@lucide/svelte/icons/clock';
import Square from '@lucide/svelte/icons/square';
import Maximize2 from '@lucide/svelte/icons/maximize-2';
Expand Down Expand Up @@ -133,7 +135,7 @@

async function handleStopTimer() {
try {
await timerStore.stop();
await stopTimerAndReview(timerStore, timeLogStore);
} catch (error) {
console.error('Failed to stop timer:', error);
// Error is already handled in the store
Expand Down
72 changes: 72 additions & 0 deletions frontend/src/lib/features/time/TimeDraftsCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<script>
import Button from '../../components/Button.svelte';
import Card from '../../components/Card.svelte';
import { t } from '../../stores/i18n.svelte.js';
import { errorToast } from '../../stores/toasts.svelte.js';
import { timeLogStore } from '../../stores/timeLogStore.svelte.js';

let { class: className = '' } = $props();

function formatDuration(minutes) {
if (!minutes) return '';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours === 0) return `${mins}m`;
if (mins === 0) return `${hours}h`;
return `${hours}h ${mins}m`;
}

function draftLabel(draft) {
return draft.item_title || draft.description || draft.project_name || t('time.drafts');
}

async function complete(draft) {
try {
await timeLogStore.openDraft(draft.id);
} catch (error) {
console.error('Failed to open draft:', error);
errorToast(t('time.bookFailed'));
}
}

async function discard(draft) {
try {
await timeLogStore.removeDraft(draft.id);
} catch (error) {
console.error('Failed to discard draft:', error);
errorToast(t('time.discardFailed'));
}
}
</script>

{#if timeLogStore.drafts.length > 0}
<div data-testid="time-drafts-card">
<Card rounded="xl" shadow padding="spacious" class="mb-6 {className}">
<h3 class="text-sm font-semibold mb-4 text-ds-text">
{t('time.drafts')} ({timeLogStore.drafts.length})
</h3>
<ul class="space-y-2">
{#each timeLogStore.drafts as draft (draft.id)}
<li
class="flex items-center gap-3 py-2 border-b border-ds-border last:border-b-0"
data-testid={`time-draft-${draft.id}`}
>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate text-ds-text">{draftLabel(draft)}</div>
<div class="text-xs text-ds-text-subtle">
{#if draft.project_name}{draft.project_name} · {/if}
{formatDuration(draft.duration_minutes)}
</div>
</div>
<Button size="small" variant="primary" onclick={() => complete(draft)} dataTestid={`time-draft-complete-${draft.id}`}>
{t('time.completeTimeEntry')}
</Button>
<Button size="small" variant="ghost" onclick={() => discard(draft)} dataTestid={`time-draft-discard-${draft.id}`}>
{t('common.discard')}
</Button>
</li>
{/each}
</ul>
</Card>
</div>
{/if}
Loading
Loading