feat: paginate Ticket board lanes independently

This commit is contained in:
2026-08-18 03:08:39 +09:00
parent bc835b8503
commit 6ca5dfbe11
4 changed files with 195 additions and 29 deletions
@@ -169,8 +169,32 @@
display: grid;
align-content: start;
gap: 0.55rem;
max-height: min(68vh, 48rem);
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.6rem;
}
.ticket-lane-page-state {
display: flex;
justify-content: center;
gap: 0.5rem;
margin: 0;
padding: 0.45rem;
color: var(--text-muted);
font-size: 0.72rem;
text-align: center;
}
.ticket-lane-page-error {
align-items: center;
color: var(--danger);
}
.ticket-lane-page-error button {
border: 1px solid var(--line);
border-radius: 0.35rem;
background: var(--bg-raised);
color: inherit;
padding: 0.2rem 0.45rem;
}
.ticket-card {
display: grid;
gap: 0.55rem;
@@ -105,6 +105,32 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
);
});
Deno.test("ticket board paginates every lane independently with bounded summary requests", async () => {
const loadSource = await Deno.readTextFile(
"src/routes/w/[workspaceId]/tickets/+page.ts",
);
const pageSource = await Deno.readTextFile(
"src/routes/w/[workspaceId]/tickets/+page.svelte",
);
assertIncludes(loadSource, 'limit: "30"');
assertIncludes(loadSource, "states: states.join");
assertIncludes(pageSource, "lane.page.next_cursor");
assertIncludes(pageSource, "lane.loading");
assertIncludes(pageSource, "mergeTickets");
assertIncludes(pageSource, "onscroll");
assertIncludes(pageSource, "Retry");
if (loadSource.includes("limit=1000") || pageSource.includes("limit=1000")) {
throw new Error("Ticket board must not fetch the legacy 1000-item list");
}
if (
loadSource.includes("/tickets/query") ||
pageSource.includes("/tickets/query")
) {
throw new Error("Ticket board must use the bounded summary endpoint");
}
});
Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestration actions", async () => {
const panelSource = await Deno.readTextFile(
"src/routes/w/[workspaceId]/tickets/+page.svelte",
@@ -113,7 +139,10 @@ Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestrati
"src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
);
assertIncludes(panelSource, 'workspaceApiPath(data.workspaceId, "/orchestrator")');
assertIncludes(
panelSource,
'workspaceApiPath(data.workspaceId, "/orchestrator")',
);
assertIncludes(panelSource, '{ method: "POST" }');
assertIncludes(panelSource, "Start Orchestrator");
assertIncludes(panelSource, "orchestrator.data?.online");
@@ -2,30 +2,94 @@
import { untrack } from "svelte";
import type { ApiResult } from "$lib/workspace/api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type {
QueryPage,
TicketListResponse,
TicketSummary,
} from "$lib/generated/ticket-api";
import {
ticketLanes,
type WorkspaceOrchestratorStatus,
} from "$lib/workspace/tickets/ticket-panel";
import type {
TicketListResponse,
TicketSummary,
} from "$lib/workspace/sidebar/types";
import type { PageData } from "./$types";
const { data } = $props<{
data: {
workspaceId: string;
tickets: ApiResult<TicketListResponse>;
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
};
}>();
type LaneState = {
states: string[];
tickets: TicketSummary[];
page: QueryPage;
loading: boolean;
error: string | null;
};
const initialTickets = untrack(() => data.tickets.data?.items ?? []);
let tickets = $state<TicketSummary[]>(initialTickets);
let { data }: { data: PageData } = $props();
// svelte-ignore state_referenced_locally
let laneState = $state<Record<string, LaneState>>(
Object.fromEntries(
Object.entries(data.ticketLanes).map(([laneId, lane]) => [
laneId,
{
states: [...lane.states],
tickets: lane.response.items,
page: lane.response.page,
loading: false,
error: null,
},
]),
),
);
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
untrack(() => data.orchestrator),
);
let orchestratorStarting = $state(false);
let lanes = $derived(ticketLanes(tickets));
const tickets = $derived(
Object.values(laneState).flatMap((lane) => lane.tickets),
);
const lanes = $derived(ticketLanes(tickets));
function mergeTickets(
current: TicketSummary[],
incoming: TicketSummary[],
): TicketSummary[] {
const byId = new Map(current.map((ticket) => [ticket.id, ticket]));
for (const ticket of incoming) byId.set(ticket.id, ticket);
return [...byId.values()];
}
async function loadMore(laneId: string): Promise<void> {
const lane = laneState[laneId];
if (!lane || lane.loading || !lane.page.has_more || !lane.page.next_cursor) {
return;
}
lane.loading = true;
lane.error = null;
try {
const search = new URLSearchParams({
limit: "30",
states: lane.states.join(","),
cursor: lane.page.next_cursor,
});
const response = await fetch(
`/api/w/${encodeURIComponent(data.workspaceId)}/tickets?${search}`,
);
if (!response.ok) {
throw new Error(`追加読み込みに失敗しました (${response.status})`);
}
const page = (await response.json()) as TicketListResponse;
lane.tickets = mergeTickets(lane.tickets, page.items);
lane.page = page.page;
} catch (error) {
lane.error = error instanceof Error ? error.message : String(error);
} finally {
lane.loading = false;
}
}
function handleLaneScroll(event: Event, laneId: string): void {
const container = event.currentTarget as HTMLElement;
const remaining =
container.scrollHeight - container.scrollTop - container.clientHeight;
if (remaining <= 96) void loadMore(laneId);
}
async function startOrchestrator() {
if (orchestratorStarting || orchestrator.data?.online) return;
@@ -45,7 +109,9 @@
}
</script>
<svelte:head><title>Tickets · Yoi</title></svelte:head>
<svelte:head>
<title>Tickets · {data.workspaceId}</title>
</svelte:head>
<div class="workspace-page ticket-panel-page">
<header class="workspace-page-header ticket-panel-header">
@@ -76,7 +142,7 @@
</div>
<div class="ticket-panel-summary" aria-label="Ticket summary">
<strong>{tickets.length}</strong>
<span>tickets</span>
<span>loaded tickets</span>
</div>
</div>
</header>
@@ -93,6 +159,7 @@
<section class="ticket-kanban" aria-label="Ticket workflow board">
{#each lanes as lane (lane.id)}
{@const pagination = laneState[lane.id]}
<section class="ticket-lane" data-state={lane.id}>
<header class="ticket-lane-header">
<div>
@@ -101,8 +168,11 @@
</div>
<span class="ticket-lane-count">{lane.tickets.length}</span>
</header>
<div class="ticket-lane-cards">
<div
class="ticket-lane-cards"
data-lane-id={lane.id}
onscroll={(event) => handleLaneScroll(event, lane.id)}
>
{#each lane.tickets as ticket (ticket.id)}
<a
class="ticket-card"
@@ -118,6 +188,16 @@
{:else}
<div class="ticket-lane-empty">No tickets</div>
{/each}
{#if pagination?.loading}
<p class="ticket-lane-page-state" aria-live="polite">Loading…</p>
{:else if pagination?.error}
<div class="ticket-lane-page-state ticket-lane-page-error" role="alert">
<span>{pagination.error}</span>
<button type="button" onclick={() => loadMore(lane.id)}>Retry</button>
</div>
{:else if pagination && !pagination.page.has_more && lane.tickets.length > 0}
<p class="ticket-lane-page-state">End of lane</p>
{/if}
</div>
</section>
{/each}
@@ -1,23 +1,56 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { TicketListResponse } from "$lib/generated/ticket-api";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { TicketListResponse } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
const [tickets, orchestrator] = await Promise.all([
loadJson<TicketListResponse>(
fetch,
`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`,
const LANE_STATES = {
"ready-planning": ["ready", "planning"],
"inprogress-queued": ["inprogress", "queued"],
"done-closed": ["done", "closed"],
} as const;
export type TicketLaneId = keyof typeof LANE_STATES;
export type TicketLanePage = {
states: readonly string[];
response: TicketListResponse;
};
export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const [entries, orchestrator] = await Promise.all([
Promise.all(
Object.entries(LANE_STATES).map(async ([laneId, states]) => {
const search = new URLSearchParams({
limit: "30",
states: states.join(","),
});
const response = await fetch(
`/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`,
);
if (!response.ok) {
throw new Error(
`failed to load ${laneId} Ticket lane (${response.status})`,
);
}
return [
laneId,
{ states: [...states], response: await response.json() },
] as const;
}),
),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
workspaceApiPath(workspaceId, "/orchestrator"),
),
]);
return {
workspaceId: params.workspaceId,
tickets,
workspaceId,
ticketLanes: Object.fromEntries(entries) as unknown as Record<
TicketLaneId,
TicketLanePage
>,
orchestrator,
};
}) satisfies PageLoad;
};