diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index ea3297b4..5f3b82fa 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -3,7 +3,10 @@ use std::path::{Path, PathBuf}; use project_record::validate_record_id; use serde::{Deserialize, Serialize}; -use ticket::{SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery}; +use ticket::{ + SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery, + TicketWorkspaceActionPriority, project_ticket_workspace_item, +}; use crate::{Error, Result}; @@ -34,11 +37,13 @@ impl LocalProjectRecordReader { } pub fn list_tickets(&self, limit: usize) -> Result> { - let mut items = self - .ticket_backend - .list(TicketListQuery::all())? - .into_iter() - .map(|item| TicketSummary { + let mut items = Vec::new(); + for item in self.ticket_backend.list(TicketListQuery::all())? { + let ticket = self + .ticket_backend + .show(TicketIdOrSlug::Id(item.id.clone()))?; + let projection = project_ticket_workspace_item(&item, &ticket.relations.blockers, None); + items.push(TicketSummary { id: item.id, title: item.title, state: item.workflow_state.as_str().to_string(), @@ -46,15 +51,17 @@ impl LocalProjectRecordReader { updated_at: item.updated_at, queued_by: item.queued_by, queued_at: item.queued_at, + workspace_action_priority: workspace_action_priority_name(projection.priority) + .to_string(), record_source: "sqlite_yoi_ticket".to_string(), - }) - .collect::>(); + }); + } items.sort_by(|a, b| { b.updated_at .cmp(&a.updated_at) .then_with(|| a.id.cmp(&b.id)) }); - items.truncate(limit.min(200)); + items.truncate(limit); Ok(ProjectRecordList { items, invalid_records: Vec::new(), @@ -148,6 +155,14 @@ impl LocalProjectRecordReader { } } +fn workspace_action_priority_name(priority: TicketWorkspaceActionPriority) -> &'static str { + match priority { + TicketWorkspaceActionPriority::ReadyForQueue => "ready_for_queue", + TicketWorkspaceActionPriority::ActiveWork => "active_work", + TicketWorkspaceActionPriority::Background => "background", + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProjectRecordList { pub items: Vec, @@ -170,6 +185,7 @@ pub struct TicketSummary { pub updated_at: Option, pub queued_by: Option, pub queued_at: Option, + pub workspace_action_priority: String, pub record_source: String, } @@ -304,6 +320,10 @@ mod tests { assert_eq!(tickets.items[0].record_source, "sqlite_yoi_ticket"); assert_eq!(tickets.items[0].id, "00000000001J2"); assert_eq!(tickets.items[0].state, "ready"); + assert_eq!( + tickets.items[0].workspace_action_priority, + "ready_for_queue" + ); let ticket = reader.ticket("00000000001J2").unwrap(); assert!(ticket.body.contains("Ticket body")); diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 4437ed33..7b9d8491 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -93,6 +93,9 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async ticketsLoad.includes('`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`') && ticketsPage.includes("Notion-style filtering and sorting") && ticketsPage.includes("toggleSort('updated_at')") && + ticketsPage.includes("bind:value={visibilityFilter}") && + ticketsPage.includes("sortKey = $state('panel')") && + ticketsPage.includes("workspace_action_priority") && ticketsPage.includes("bind:value={stateFilter}") && ticketsPage.includes("workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)"), "Tickets list should read the workspace-scoped Ticket API and expose sortable/filterable table links", diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index d038825d..b3eceb79 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -326,6 +326,7 @@ export type TicketSummary = { updated_at?: string | null; queued_by?: string | null; queued_at?: string | null; + workspace_action_priority?: 'ready_for_queue' | 'active_work' | 'background' | null; record_source?: string; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte index ad68c919..97687fe0 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -3,17 +3,18 @@ import type { TicketSummary } from '$lib/workspace/sidebar/types'; import type { PageProps } from './$types'; - type SortKey = 'title' | 'state' | 'priority' | 'updated_at' | 'queued_at' | 'id'; + type SortKey = 'panel' | 'title' | 'state' | 'priority' | 'updated_at' | 'queued_at' | 'id'; type SortDirection = 'asc' | 'desc'; let { data }: PageProps = $props(); let query = $state(''); + let visibilityFilter = $state<'open' | 'closed' | 'all'>('open'); let stateFilter = $state('all'); let priorityFilter = $state('all'); let queuedFilter = $state('all'); - let sortKey = $state('updated_at'); - let sortDirection = $state('desc'); + let sortKey = $state('panel'); + let sortDirection = $state('asc'); const tickets = $derived(data.tickets.data?.items ?? []); const states = $derived(uniqueValues(tickets.map((ticket) => ticket.state))); @@ -34,6 +35,12 @@ function filterTickets(items: TicketSummary[]): TicketSummary[] { const needle = query.trim().toLowerCase(); return items.filter((ticket) => { + if (visibilityFilter === 'open' && ticket.state === 'closed') { + return false; + } + if (visibilityFilter === 'closed' && ticket.state !== 'closed') { + return false; + } if (stateFilter !== 'all' && ticket.state !== stateFilter) { return false; } @@ -63,12 +70,36 @@ } function compareTicketValues(left: TicketSummary, right: TicketSummary, key: SortKey): number { + if (key === 'panel') { + return comparePanelOrder(left, right); + } if (key === 'updated_at' || key === 'queued_at') { return compareDate(left[key], right[key]); } return compareText(ticketValue(left, key), ticketValue(right, key)); } + function comparePanelOrder(left: TicketSummary, right: TicketSummary): number { + return compareNumber(panelActionPriority(left), panelActionPriority(right)) + || compareDate(right.updated_at, left.updated_at) + || compareText(left.title, right.title); + } + + function panelActionPriority(ticket: TicketSummary): number { + if (ticket.workspace_action_priority) { + if (ticket.workspace_action_priority === 'ready_for_queue') return 0; + if (ticket.workspace_action_priority === 'active_work') return 1; + if (ticket.workspace_action_priority === 'background') return 2; + } + if (ticket.state === 'ready') return 0; + if (ticket.state === 'queued' || ticket.state === 'inprogress') return 1; + return 2; + } + + function compareNumber(left: number, right: number): number { + return left - right; + } + function ticketValue(ticket: TicketSummary, key: SortKey): string | null | undefined { if (key === 'id') return ticket.id; if (key === 'title') return ticket.title; @@ -107,9 +138,12 @@ function resetFilters() { query = ''; + visibilityFilter = 'open'; stateFilter = 'all'; priorityFilter = 'all'; queuedFilter = 'all'; + sortKey = 'panel'; + sortDirection = 'asc'; } @@ -142,6 +176,14 @@ Search + + @@ -232,7 +275,7 @@ .ticket-database-toolbar { display: grid; - grid-template-columns: minmax(16rem, 1.8fr) repeat(3, minmax(9rem, 1fr)) auto; + grid-template-columns: minmax(16rem, 1.8fr) repeat(4, minmax(9rem, 1fr)) auto auto; gap: 0.75rem; align-items: end; margin: 1rem 0;