fix: avoid unbounded ticket board queries
This commit is contained in:
@@ -218,23 +218,22 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
"Tickets and Objectives should each be a single sidebar link",
|
||||
);
|
||||
assert(
|
||||
!ticketsLoad.includes("?limit=1000") &&
|
||||
ticketsLoad.includes('workspaceApiPath(workspaceId, "/tickets/query")') &&
|
||||
ticketsLoad.includes("ticketLaneDefinitions()") &&
|
||||
ticketsLoad.includes("ticketLaneQuery(lane)") &&
|
||||
ticketsLoad.includes("?limit=1000") &&
|
||||
!ticketsLoad.includes("/tickets/query") &&
|
||||
ticketsPage.includes('class="ticket-kanban"') &&
|
||||
ticketsPage.includes('class="ticket-lane-cards"') &&
|
||||
ticketsPage.includes("lane.tickets.slice(0, lane.visibleCount)") &&
|
||||
ticketsPage.includes("handleLaneScroll") &&
|
||||
ticketsPage.includes("Loading 30 more"),
|
||||
"Tickets list should query and incrementally scroll each Kanban lane",
|
||||
ticketsPage.includes("revealNextTickets"),
|
||||
"Tickets list should fetch lightweight summaries once and incrementally reveal each Kanban lane",
|
||||
);
|
||||
assert(
|
||||
ticketPanelModel.includes('label: "Ready + Planning"') &&
|
||||
ticketPanelModel.includes('label: "In progress + Queued"') &&
|
||||
ticketPanelModel.includes('label: "Done + Closed"') &&
|
||||
ticketPanelModel.includes("TICKET_LANE_PAGE_SIZE = 30") &&
|
||||
ticketPanelModel.includes('sort: "updated_desc"'),
|
||||
"Ticket Kanban should combine related states into independent cursor pages",
|
||||
ticketPanelModel.includes("nextTicketLaneVisibleCount"),
|
||||
"Ticket Kanban should combine related states into independent 30-item display windows",
|
||||
);
|
||||
assert(
|
||||
generatedTicketApi.includes("Generated from yoi-workspace-server") &&
|
||||
|
||||
@@ -189,15 +189,6 @@
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
.ticket-lane-load-error {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
color: #d66;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.ticket-lane-load-error button {
|
||||
justify-self: start;
|
||||
}
|
||||
.ticket-card {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
appendUniqueTicketSummaries,
|
||||
nextTicketLaneVisibleCount,
|
||||
TICKET_LANE_PAGE_SIZE,
|
||||
ticketLaneDefinitions,
|
||||
ticketLaneQuery,
|
||||
ticketLanes,
|
||||
ticketWorkerLaunchHref,
|
||||
ticketWorkerMessage,
|
||||
@@ -83,47 +81,13 @@ Deno.test("ticketLanes combines workflow states and sorts by state then update t
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("ticket lane queries request independent pages of 30", () => {
|
||||
const [readyPlanning, inprogressQueued, doneClosed] = ticketLaneDefinitions();
|
||||
|
||||
Deno.test("ticket lane visibility advances in bounded pages of 30", () => {
|
||||
assertEquals(TICKET_LANE_PAGE_SIZE, 30);
|
||||
assertEquals(ticketLaneQuery(readyPlanning).states, ["ready", "planning"]);
|
||||
assertEquals(ticketLaneQuery(inprogressQueued).states, [
|
||||
"inprogress",
|
||||
"queued",
|
||||
]);
|
||||
assertEquals(ticketLaneQuery(doneClosed, "next-page"), {
|
||||
attention: [],
|
||||
cursor: "next-page",
|
||||
event_kinds: [],
|
||||
evidence: [],
|
||||
limit: 30,
|
||||
linked_objective_id: null,
|
||||
query: null,
|
||||
related_ticket_id: null,
|
||||
relation_kind: null,
|
||||
review_status: null,
|
||||
sort: "updated_desc",
|
||||
states: ["done", "closed"],
|
||||
updated_after: null,
|
||||
updated_before: null,
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("incremental Ticket pages preserve order and discard duplicate ids", () => {
|
||||
const current = [
|
||||
{ id: "first", title: "First", state: "ready", priority: "1" },
|
||||
{ id: "second", title: "Second", state: "planning", priority: "2" },
|
||||
] as TicketSummary[];
|
||||
const incoming = [
|
||||
{ id: "second", title: "Duplicate", state: "planning", priority: "2" },
|
||||
{ id: "third", title: "Third", state: "planning", priority: "3" },
|
||||
] as TicketSummary[];
|
||||
|
||||
assertEquals(
|
||||
appendUniqueTicketSummaries(current, incoming).map((ticket) => ticket.id),
|
||||
["first", "second", "third"],
|
||||
);
|
||||
assertEquals(nextTicketLaneVisibleCount(0, 95), 30);
|
||||
assertEquals(nextTicketLaneVisibleCount(30, 95), 60);
|
||||
assertEquals(nextTicketLaneVisibleCount(60, 95), 90);
|
||||
assertEquals(nextTicketLaneVisibleCount(90, 95), 95);
|
||||
assertEquals(nextTicketLaneVisibleCount(95, 95), 95);
|
||||
});
|
||||
|
||||
Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => {
|
||||
@@ -167,15 +131,12 @@ Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestrati
|
||||
assertIncludes(panelSource, '{ method: "POST" }');
|
||||
assertIncludes(panelSource, "Start Orchestrator");
|
||||
assertIncludes(panelSource, "orchestrator.data?.online");
|
||||
assertIncludes(
|
||||
panelSource,
|
||||
'workspaceApiPath(data.workspaceId, "/tickets/query")',
|
||||
);
|
||||
assertIncludes(panelSource, "lane.tickets.slice(0, lane.visibleCount)");
|
||||
assertIncludes(
|
||||
panelSource,
|
||||
"onscroll={(event) => handleLaneScroll(event, lane.id)}",
|
||||
);
|
||||
assertIncludes(panelSource, "Loading 30 more…");
|
||||
assertIncludes(panelSource, "Scroll for");
|
||||
assertIncludes(detailSource, "{#if orchestratorOnline}");
|
||||
assertIncludes(detailSource, "!orchestratorOnline");
|
||||
assertIncludes(detailSource, "Orchestrator offline");
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type {
|
||||
TicketDetail,
|
||||
TicketQueryItem,
|
||||
TicketQueryRequest,
|
||||
TicketSummary,
|
||||
} from "$lib/generated/ticket-api";
|
||||
import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api";
|
||||
|
||||
export const TICKET_STATES = [
|
||||
"planning",
|
||||
@@ -77,57 +72,11 @@ export type TicketLane = {
|
||||
tickets: TicketCardSummary[];
|
||||
};
|
||||
|
||||
export function ticketLaneDefinitions(): readonly TicketLaneDefinition[] {
|
||||
return LANE_DEFINITIONS;
|
||||
}
|
||||
|
||||
export function ticketLaneQuery(
|
||||
lane: { states: readonly TicketState[] },
|
||||
cursor: string | null = null,
|
||||
): TicketQueryRequest {
|
||||
return {
|
||||
attention: [],
|
||||
cursor,
|
||||
event_kinds: [],
|
||||
evidence: [],
|
||||
limit: TICKET_LANE_PAGE_SIZE,
|
||||
linked_objective_id: null,
|
||||
query: null,
|
||||
related_ticket_id: null,
|
||||
relation_kind: null,
|
||||
review_status: null,
|
||||
sort: "updated_desc",
|
||||
states: [...lane.states],
|
||||
updated_after: null,
|
||||
updated_before: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function ticketSummaryFromQueryItem(
|
||||
item: TicketQueryItem,
|
||||
): TicketCardSummary {
|
||||
return {
|
||||
id: item.id,
|
||||
priority: item.priority === null ? "normal" : String(item.priority),
|
||||
state: item.state,
|
||||
title: item.title,
|
||||
updated_at: item.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendUniqueTicketSummaries(
|
||||
current: TicketCardSummary[],
|
||||
incoming: TicketCardSummary[],
|
||||
): TicketCardSummary[] {
|
||||
const ids = new Set(current.map((ticket) => ticket.id));
|
||||
return [
|
||||
...current,
|
||||
...incoming.filter((ticket) => {
|
||||
if (ids.has(ticket.id)) return false;
|
||||
ids.add(ticket.id);
|
||||
return true;
|
||||
}),
|
||||
];
|
||||
export function nextTicketLaneVisibleCount(
|
||||
current: number,
|
||||
total: number,
|
||||
): number {
|
||||
return Math.min(total, current + TICKET_LANE_PAGE_SIZE);
|
||||
}
|
||||
|
||||
function updatedAt(ticket: TicketCardSummary): number {
|
||||
|
||||
@@ -1,40 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import type { TicketQueryResponse } from "$lib/generated/ticket-api";
|
||||
import type { TicketListResponse } from "$lib/generated/ticket-api";
|
||||
import type { ApiResult } from "$lib/workspace/api/http";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
appendUniqueTicketSummaries,
|
||||
ticketLaneQuery,
|
||||
ticketSummaryFromQueryItem,
|
||||
type TicketCardSummary,
|
||||
nextTicketLaneVisibleCount,
|
||||
TICKET_LANE_PAGE_SIZE,
|
||||
ticketLanes,
|
||||
type TicketLane,
|
||||
type TicketLaneId,
|
||||
type TicketState,
|
||||
type WorkspaceOrchestratorStatus,
|
||||
} from "$lib/workspace/tickets/ticket-panel";
|
||||
import "$lib/workspace/styles/tickets.css";
|
||||
|
||||
type LanePage = {
|
||||
id: TicketLaneId;
|
||||
label: string;
|
||||
states: TicketState[];
|
||||
tickets: TicketCardSummary[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
type VisibleTicketLane = TicketLane & { visibleCount: number };
|
||||
|
||||
const { data } = $props<{
|
||||
data: {
|
||||
workspaceId: string;
|
||||
ticketLanePages: LanePage[];
|
||||
tickets: ApiResult<TicketListResponse>;
|
||||
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
||||
};
|
||||
}>();
|
||||
|
||||
let lanes = $state<(LanePage & { loading: boolean })[]>(
|
||||
let lanes = $state<VisibleTicketLane[]>(
|
||||
untrack(() =>
|
||||
data.ticketLanePages.map((lane: LanePage) => ({ ...lane, loading: false }))
|
||||
ticketLanes(data.tickets.data?.items ?? []).map((lane) => ({
|
||||
...lane,
|
||||
visibleCount: Math.min(TICKET_LANE_PAGE_SIZE, lane.tickets.length),
|
||||
}))
|
||||
),
|
||||
);
|
||||
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
|
||||
@@ -42,7 +36,7 @@
|
||||
);
|
||||
let orchestratorStarting = $state(false);
|
||||
const displayedTicketCount = $derived(
|
||||
lanes.reduce((count, lane) => count + lane.tickets.length, 0),
|
||||
lanes.reduce((count, lane) => count + lane.visibleCount, 0),
|
||||
);
|
||||
const LANE_LOAD_THRESHOLD_PX = 96;
|
||||
|
||||
@@ -63,64 +57,18 @@
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function updateLane(
|
||||
laneId: TicketLaneId,
|
||||
update: (lane: LanePage & { loading: boolean }) =>
|
||||
LanePage & { loading: boolean },
|
||||
): void {
|
||||
lanes = lanes.map((lane) => lane.id === laneId ? update(lane) : lane);
|
||||
function revealNextTickets(laneId: TicketLaneId): void {
|
||||
lanes = lanes.map((lane) =>
|
||||
lane.id === laneId
|
||||
? {
|
||||
...lane,
|
||||
visibleCount: nextTicketLaneVisibleCount(
|
||||
lane.visibleCount,
|
||||
lane.tickets.length,
|
||||
),
|
||||
}
|
||||
|
||||
async function loadMoreTickets(laneId: TicketLaneId): Promise<void> {
|
||||
const lane = lanes.find((candidate) => candidate.id === laneId);
|
||||
if (!lane || lane.loading || (!lane.hasMore && !lane.error)) return;
|
||||
|
||||
updateLane(laneId, (current) => ({
|
||||
...current,
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
let result: ApiResult<TicketQueryResponse>;
|
||||
try {
|
||||
result = await loadJson<TicketQueryResponse>(
|
||||
fetch,
|
||||
workspaceApiPath(data.workspaceId, "/tickets/query"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(ticketLaneQuery(lane, lane.nextCursor)),
|
||||
},
|
||||
: lane
|
||||
);
|
||||
} catch (error) {
|
||||
updateLane(laneId, (current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load more Tickets.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
updateLane(laneId, (current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: result.error ?? "Unable to load more Tickets.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const incoming = result.data.items.map(ticketSummaryFromQueryItem);
|
||||
updateLane(laneId, (current) => ({
|
||||
...current,
|
||||
tickets: appendUniqueTicketSummaries(current.tickets, incoming),
|
||||
nextCursor: result.data?.page.next_cursor ?? null,
|
||||
hasMore: result.data?.page.has_more ?? false,
|
||||
loading: false,
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleLaneScroll(event: Event, laneId: TicketLaneId): void {
|
||||
@@ -128,7 +76,7 @@
|
||||
const distanceFromBottom = element.scrollHeight - element.scrollTop -
|
||||
element.clientHeight;
|
||||
if (distanceFromBottom <= LANE_LOAD_THRESHOLD_PX) {
|
||||
void loadMoreTickets(laneId);
|
||||
revealNextTickets(laneId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -160,11 +108,15 @@
|
||||
</div>
|
||||
<div class="ticket-panel-summary" aria-label="Ticket summary">
|
||||
<strong>{displayedTicketCount}</strong>
|
||||
<span>tickets loaded</span>
|
||||
<span>tickets displayed</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if data.tickets.error}
|
||||
<p class="workspace-callout is-error">Tickets: {data.tickets.error}</p>
|
||||
{/if}
|
||||
|
||||
{#if orchestrator.error}
|
||||
<p class="workspace-callout is-error">
|
||||
Orchestrator status: {orchestrator.error}
|
||||
@@ -177,6 +129,8 @@
|
||||
|
||||
<section class="ticket-kanban" aria-label="Ticket workflow board">
|
||||
{#each lanes as lane (lane.id)}
|
||||
{@const displayedTickets = lane.tickets.slice(0, lane.visibleCount)}
|
||||
{@const hasMore = lane.visibleCount < lane.tickets.length}
|
||||
<section class="ticket-lane" data-state={lane.id}>
|
||||
<header class="ticket-lane-header">
|
||||
<div>
|
||||
@@ -184,7 +138,7 @@
|
||||
<h2>{lane.label}</h2>
|
||||
</div>
|
||||
<span class="ticket-lane-count">
|
||||
{lane.tickets.length}{lane.hasMore ? "+" : ""}
|
||||
{displayedTickets.length}{hasMore ? "+" : ""}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
@@ -192,7 +146,7 @@
|
||||
class="ticket-lane-cards"
|
||||
onscroll={(event) => handleLaneScroll(event, lane.id)}
|
||||
>
|
||||
{#each lane.tickets as ticket (ticket.id)}
|
||||
{#each displayedTickets as ticket (ticket.id)}
|
||||
<a
|
||||
class="ticket-card"
|
||||
href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(ticket.id)}`}
|
||||
@@ -205,24 +159,15 @@
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
{#if !lane.error}
|
||||
<div class="ticket-lane-empty">No tickets</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if lane.loading}
|
||||
<p class="ticket-lane-load-status" aria-live="polite">Loading 30 more…</p>
|
||||
{:else if lane.error}
|
||||
<div class="ticket-lane-load-error">
|
||||
<small>{lane.error}</small>
|
||||
<button
|
||||
class="workspace-secondary-button"
|
||||
type="button"
|
||||
onclick={() => loadMoreTickets(lane.id)}
|
||||
>Retry</button>
|
||||
</div>
|
||||
{:else if !lane.hasMore && lane.tickets.length > 0}
|
||||
<p class="ticket-lane-load-status">All tickets loaded.</p>
|
||||
{#if hasMore}
|
||||
<p class="ticket-lane-load-status" aria-live="polite">
|
||||
Scroll for {Math.min(TICKET_LANE_PAGE_SIZE, lane.tickets.length - lane.visibleCount)} more
|
||||
</p>
|
||||
{:else if displayedTickets.length > 0}
|
||||
<p class="ticket-lane-load-status">All tickets displayed.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,63 +1,20 @@
|
||||
import type { TicketQueryResponse } from "$lib/generated/ticket-api";
|
||||
import type { TicketListResponse } from "$lib/generated/ticket-api";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
ticketLaneDefinitions,
|
||||
ticketLaneQuery,
|
||||
ticketSummaryFromQueryItem,
|
||||
type WorkspaceOrchestratorStatus,
|
||||
} from "$lib/workspace/tickets/ticket-panel";
|
||||
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const ticketLanePagesPromise = Promise.all(
|
||||
ticketLaneDefinitions().map(async (lane) => {
|
||||
try {
|
||||
const page = await loadJson<TicketQueryResponse>(
|
||||
const [tickets, orchestrator] = await Promise.all([
|
||||
loadJson<TicketListResponse>(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/tickets/query"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(ticketLaneQuery(lane)),
|
||||
},
|
||||
);
|
||||
return {
|
||||
id: lane.id,
|
||||
label: lane.label,
|
||||
states: [...lane.states],
|
||||
tickets: page.data?.items.map(ticketSummaryFromQueryItem) ?? [],
|
||||
nextCursor: page.data?.page.next_cursor ?? null,
|
||||
hasMore: page.data?.page.has_more ?? false,
|
||||
error: page.error,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id: lane.id,
|
||||
label: lane.label,
|
||||
states: [...lane.states],
|
||||
tickets: [],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
error: error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load Tickets.",
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const [ticketLanePages, orchestrator] = await Promise.all([
|
||||
ticketLanePagesPromise,
|
||||
`${workspaceApiPath(workspaceId, "/tickets")}?limit=1000`,
|
||||
),
|
||||
loadJson<WorkspaceOrchestratorStatus>(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/orchestrator"),
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
ticketLanePages,
|
||||
orchestrator,
|
||||
};
|
||||
};
|
||||
return { workspaceId, tickets, orchestrator };
|
||||
}) satisfies PageLoad;
|
||||
|
||||
Reference in New Issue
Block a user