Add date and ticket filters plus entry list to the project time report.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 23:23:22 +01:00
co-authored by Cursor
parent 2061df63d4
commit 34ab3b9c5b
6 changed files with 368 additions and 8 deletions
+106 -6
View File
@@ -23,6 +23,9 @@
let msg = $state('')
let report = $state(null)
let reportGroupBy = $state('ticket')
let reportFrom = $state('')
let reportTo = $state('')
let reportTicketId = $state('')
let dash = $state({ projects: [], openTickets: [] })
let tokens = $state([])
let newTokenSecret = $state('')
@@ -180,7 +183,7 @@
}
async function saveTimeEntry() {
if (!selected || !editingTimeId) return
if (!projectId || !editingTimeId) return
if (!editTimeStart || !editTimeEnd) {
error = 'Start and end are required'
return
@@ -197,7 +200,8 @@
end: end.toISOString(),
})
cancelEditTime()
await refreshTicketTime()
if (projectView === 'report') await loadReport()
else await refreshTicketTime()
msg = 'Time updated'
}
@@ -205,10 +209,25 @@
if (!confirm('Delete time entry?')) return
await api.deleteTime(projectId, eid)
if (editingTimeId === eid) cancelEditTime()
await refreshTicketTime()
if (projectView === 'report') await loadReport()
else await refreshTicketTime()
msg = 'Time deleted'
}
function ticketTitle(tid) {
const t = tickets.find((x) => x.id === tid)
return t?.title || tid || '—'
}
function reportQuery() {
return {
groupBy: reportGroupBy,
from: reportFrom,
to: reportTo,
ticketId: reportTicketId,
}
}
async function doLogin() {
error = ''
try {
@@ -414,11 +433,15 @@
await refreshTicketTime()
}
async function loadReport() {
report = await api.report(projectId, reportQuery())
}
async function showReport() {
topTab = 'projects'
projectView = 'report'
error = ''
report = await api.report(projectId, reportGroupBy)
await loadReport()
}
async function openReport() {
@@ -428,7 +451,27 @@
async function changeReportGroup(g) {
reportGroupBy = g
report = await api.report(projectId, reportGroupBy)
await loadReport()
}
async function applyReportFilters() {
error = ''
try {
await loadReport()
} catch (e) {
error = e.message || String(e)
}
}
function clearReportFilters() {
reportFrom = ''
reportTo = ''
reportTicketId = ''
}
async function resetReportFilters() {
clearReportFilters()
await applyReportFilters()
}
async function showProjectSettings() {
@@ -826,7 +869,7 @@
</section>
{:else if topTab === 'projects' && projectView === 'report'}
<section class="panel stack">
<div class="row" style="justify-content: space-between;">
<div class="row" style="justify-content: space-between; flex-wrap: wrap; gap: 0.75rem;">
<h2>Report — {projectName}</h2>
<div class="row">
{#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]}
@@ -834,8 +877,27 @@
{/each}
</div>
</div>
<div class="row" style="flex-wrap: wrap; gap: 0.75rem; align-items: flex-end;">
<label style="width: auto;">From
<input type="date" bind:value={reportFrom} style="width: auto;" />
</label>
<label style="width: auto;">To
<input type="date" bind:value={reportTo} style="width: auto;" />
</label>
<label style="width: auto; flex: 1; min-width: 12rem;">Ticket
<select bind:value={reportTicketId}>
<option value="">All tickets</option>
{#each tickets as t}
<option value={t.id}>{t.title}</option>
{/each}
</select>
</label>
<button class="primary" onclick={applyReportFilters}>Filter</button>
<button onclick={resetReportFilters}>Reset</button>
</div>
{#if report}
<p class="muted">{report.entryCount} entries · total {formatDuration(report.totalDurationNs)}</p>
<h3>Summary</h3>
<table>
<thead><tr><th>Group</th><th>Duration</th></tr></thead>
<tbody>
@@ -846,6 +908,44 @@
{/each}
</tbody>
</table>
<h3>Entries</h3>
<table>
<thead><tr><th>When</th><th>Ticket</th><th>Duration</th><th>Source</th><th>Note</th><th></th></tr></thead>
<tbody>
{#each report.entries || [] as e}
{#if editingTimeId === e.id}
<tr>
<td colspan="6">
<div class="row" style="flex-wrap: wrap; gap: 0.5rem;">
<label class="row" style="width: auto;">Start
<input type="datetime-local" bind:value={editTimeStart} style="width: auto;" />
</label>
<label class="row" style="width: auto;">End
<input type="datetime-local" bind:value={editTimeEnd} style="width: auto;" />
</label>
<button class="primary" onclick={saveTimeEntry}>Save</button>
<button onclick={cancelEditTime}>Cancel</button>
</div>
</td>
</tr>
{:else}
<tr>
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
<td>{ticketTitle(e.ticketId)}</td>
<td>{formatDuration(e.duration)}</td>
<td>{e.source}</td>
<td class="muted">{e.note || ''}</td>
<td class="row" style="width: auto; justify-content: flex-end;">
<button onclick={() => beginEditTime(e)}>Edit</button>
<button class="danger" onclick={() => removeTimeEntry(e.id)}>Delete</button>
</td>
</tr>
{/if}
{:else}
<tr><td colspan="6" class="muted">No entries for this filter.</td></tr>
{/each}
</tbody>
</table>
{/if}
</section>
{:else if topTab === 'projects' && projectView === 'settings'}
+8 -2
View File
@@ -71,8 +71,14 @@ export const api = {
body: '{}',
}),
report: (projectId, groupBy = 'ticket') =>
request(`/projects/${projectId}/report?groupBy=${encodeURIComponent(groupBy)}`),
report: (projectId, { groupBy = 'ticket', from = '', to = '', ticketId = '' } = {}) => {
const q = new URLSearchParams()
q.set('groupBy', groupBy)
if (from) q.set('from', from)
if (to) q.set('to', to)
if (ticketId) q.set('ticketId', ticketId)
return request(`/projects/${projectId}/report?${q}`)
},
listTime: (projectId) => request(`/projects/${projectId}/time`),
createTime: (projectId, body) =>
request(`/projects/${projectId}/time`, { method: 'POST', body: JSON.stringify(body) }),