diff --git a/internal/tasktree/store.ts b/internal/tasktree/store.ts index 88ae8380..dd0e120a 100644 --- a/internal/tasktree/store.ts +++ b/internal/tasktree/store.ts @@ -154,7 +154,12 @@ export class TaskTreeStore { LEFT JOIN tasktree_tasks t ON t.group_id=g.id LEFT JOIN tasktree_execution_reports r ON r.task_id=t.id GROUP BY g.id - ORDER BY g.updated_at DESC, g.name + ORDER BY GREATEST( + g.updated_at, + COALESCE(MAX(t.updated_at),g.updated_at), + COALESCE(MAX(r.created_at),g.updated_at), + COALESCE((SELECT MAX(m.created_at) FROM tasktree_milestones m WHERE m.group_id=g.id),g.updated_at) + ) DESC, g.name `); return result.rows.map(overviewRow); } @@ -338,11 +343,25 @@ export class TaskTreeStore { async createReport(input: { taskId: string; title: string; body: string; status?: string }): Promise { await this.ensureSchema(); - const result = await this.pool.query( - "INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5) RETURNING *", - [`tr_${randomUUID()}`, input.taskId, input.title, input.body, input.status ?? "succeeded"] - ); - return reportRow(result.rows[0]); + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const result = await client.query( + "INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5) RETURNING *", + [`tr_${randomUUID()}`, input.taskId, input.title, input.body, input.status ?? "succeeded"] + ); + await client.query( + "UPDATE tasktree_groups g SET updated_at=now() FROM tasktree_tasks t WHERE t.id=$1 AND g.id=t.group_id", + [input.taskId] + ); + await client.query("COMMIT"); + return reportRow(result.rows[0]); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + throw error; + } finally { + client.release(); + } } async listReports(taskId: string): Promise { diff --git a/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue b/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue index 061037a3..50b6a46e 100644 --- a/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue +++ b/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue @@ -20,6 +20,7 @@ const collapsedTaskIds = ref(new Set()); const timelineHeaderElement = ref(null); const taskLabelsElement = ref(null); const timelineBodyElement = ref(null); +const timelineScrollbarWidth = ref(0); const labelChildScrollers = new Map(); const timelineChildScrollers = new Map(); const initializedChildGroups = new Set(); @@ -28,6 +29,7 @@ const timelineScaleIndex = ref(1); const taskColumnWidth = ref(typeof window !== "undefined" ? Math.round(window.innerWidth * (window.innerWidth <= 720 ? 0.55 : 0.21)) : 400); const loading = ref(false); const error = ref(null); +let positionedView = ""; const groupId = computed(() => String(route.params.groupId || "")); const displayTime = displayTimeConfig(); const displayPartsFormatter = new Intl.DateTimeFormat(displayTime.locale, { @@ -113,6 +115,8 @@ async function loadActiveView() { if (!groupId.value) { timeline.value = null; loading.value = false; + await nextTick(); + positionTimelineAtNow("global"); return; } const timelineResponse = await tasktreeAPI.timeline(groupId.value); @@ -124,6 +128,8 @@ async function loadActiveView() { timeline.value = timelineResponse.data.data; initializedChildGroups.clear(); syncSelectedTaskFromRoute(); + await nextTick(); + positionTimelineAtNow(groupId.value); } function selectGroup(event: Event) { const value = (event.target as HTMLSelectElement).value; @@ -282,13 +288,18 @@ async function changeTimelineScale(direction: -1 | 1) { function syncTimelineBody() { const body = timelineBodyElement.value; if (!body) return; + timelineScrollbarWidth.value = Math.max(0, body.offsetWidth - body.clientWidth); if (timelineHeaderElement.value && timelineHeaderElement.value.scrollLeft !== body.scrollLeft) timelineHeaderElement.value.scrollLeft = body.scrollLeft; if (taskLabelsElement.value && taskLabelsElement.value.scrollTop !== body.scrollTop) taskLabelsElement.value.scrollTop = body.scrollTop; } -function syncTimelineHeader() { - const header = timelineHeaderElement.value; +function positionTimelineAtNow(view: string) { const body = timelineBodyElement.value; - if (header && body && body.scrollLeft !== header.scrollLeft) body.scrollLeft = header.scrollLeft; + if (!body || positionedView === view) return; + timelineScrollbarWidth.value = Math.max(0, body.offsetWidth - body.clientWidth); + const now = todayOffset.value; + body.scrollLeft = now === null ? 0 : Math.max(0, Math.min(body.scrollWidth - body.clientWidth, body.scrollWidth * now / 100 - body.clientWidth * 0.8)); + if (timelineHeaderElement.value) timelineHeaderElement.value.scrollLeft = body.scrollLeft; + positionedView = view; } function syncTaskLabels() { const labels = taskLabelsElement.value; @@ -321,8 +332,10 @@ function displaySerial(date: Date) { const parts = displayParts(date); return Da function displayDaySerial(date: Date) { const parts = displayParts(date); return Date.UTC(parts.year, parts.month - 1, parts.day); } function barStyle(item: { startAt: string | null; dueAt: string | null }) { const start = displaySerial(new Date(item.startAt || item.dueAt || range.value.start)); const end = displaySerial(new Date(item.dueAt || item.startAt || range.value.start)); const unit = 100 / range.value.dayCount; - const left = Math.max(0, (start - displaySerial(range.value.start)) / 86400000) * unit; const width = Math.max(unit * 0.65, ((end - start) / 86400000) * unit); - return { left: `${left}%`, width: `${Math.min(100 - left, width)}%` }; + const rangeStart = displaySerial(range.value.start); + const left = Math.max(0, (start - rangeStart) / 86400000) * unit; + const rightEdge = Math.min(100, Math.max(left, (end - rangeStart) / 86400000 * unit)); + return { right: `${100 - rightEdge}%`, width: `${Math.max(0, rightEdge - left)}%` }; } function trackStyle() { return { @@ -393,7 +406,14 @@ function syncSelectedTaskFromRoute() {