merge: kanban lazy rows
This commit is contained in:
commit
eea26f9174
|
|
@ -347,8 +347,7 @@
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.runtime-card,
|
.runtime-card {
|
||||||
.kanban-column {
|
|
||||||
padding: var(--space-4) 0 0;
|
padding: var(--space-4) 0 0;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
@ -423,36 +422,6 @@
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kanban {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-5);
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr));
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-column h3 {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-column ul {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-column li {
|
|
||||||
padding-left: var(--space-3);
|
|
||||||
border-left: 2px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagnostics {
|
.diagnostics {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { RepositoryTicketsResponse, TicketSummary } from '$lib/workspace-sidebar/types';
|
||||||
|
|
||||||
|
const INITIAL_VISIBLE_ROWS = 30;
|
||||||
|
const VISIBLE_ROW_INCREMENT = 30;
|
||||||
|
const NEAR_BOTTOM_PX = 96;
|
||||||
|
|
||||||
|
type KanbanGroup = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
states: string[];
|
||||||
|
items: TicketSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupMetadata = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
states: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let { tickets }: { tickets: RepositoryTicketsResponse } = $props();
|
||||||
|
|
||||||
|
let visibleRowsByGroup = $state<Record<string, number>>({});
|
||||||
|
let groups = $derived(buildGroups(tickets.columns));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const groupKeys = new Set(groups.map((group) => group.key));
|
||||||
|
const nextVisibleRows = { ...visibleRowsByGroup };
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
if (nextVisibleRows[group.key] === undefined) {
|
||||||
|
nextVisibleRows[group.key] = INITIAL_VISIBLE_ROWS;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(nextVisibleRows)) {
|
||||||
|
if (!groupKeys.has(key)) {
|
||||||
|
delete nextVisibleRows[key];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
visibleRowsByGroup = nextVisibleRows;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildGroups(columns: RepositoryTicketsResponse['columns']): KanbanGroup[] {
|
||||||
|
const groupsByKey = new Map<string, KanbanGroup>();
|
||||||
|
|
||||||
|
for (const column of columns) {
|
||||||
|
const metadata = groupMetadataForState(column.state);
|
||||||
|
let group = groupsByKey.get(metadata.key);
|
||||||
|
if (!group) {
|
||||||
|
group = {
|
||||||
|
key: metadata.key,
|
||||||
|
label: metadata.label,
|
||||||
|
states: metadata.states,
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
groupsByKey.set(metadata.key, group);
|
||||||
|
}
|
||||||
|
group.items.push(...column.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groupsByKey.values()).map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: sortGroupItems(group.items)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMetadataForState(state: string): GroupMetadata {
|
||||||
|
if (state === 'planning' || state === 'ready') {
|
||||||
|
return {
|
||||||
|
key: 'ready-planning',
|
||||||
|
label: 'Ready / Planning',
|
||||||
|
states: ['ready', 'planning']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state === 'queued' || state === 'inprogress') {
|
||||||
|
return {
|
||||||
|
key: 'inprogress-queued',
|
||||||
|
label: 'In progress / Queued',
|
||||||
|
states: ['inprogress', 'queued']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state === 'other') {
|
||||||
|
return {
|
||||||
|
key: 'state:other',
|
||||||
|
label: 'Other states',
|
||||||
|
states: ['other']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: `state:${state}`,
|
||||||
|
label: state,
|
||||||
|
states: [state]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortGroupItems(items: TicketSummary[]): TicketSummary[] {
|
||||||
|
return items
|
||||||
|
.map((ticket, index) => ({ ticket, index }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const stateOrder = statePriority(left.ticket.state) - statePriority(right.ticket.state);
|
||||||
|
if (stateOrder !== 0) {
|
||||||
|
return stateOrder;
|
||||||
|
}
|
||||||
|
return left.index - right.index;
|
||||||
|
})
|
||||||
|
.map(({ ticket }) => ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statePriority(state: string): number {
|
||||||
|
if (state === 'ready' || state === 'inprogress') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (state === 'planning' || state === 'queued') {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleCount(groupKey: string): number {
|
||||||
|
return visibleRowsByGroup[groupKey] ?? INITIAL_VISIBLE_ROWS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleTickets(group: KanbanGroup): TicketSummary[] {
|
||||||
|
return group.items.slice(0, visibleCount(group.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMore(group: KanbanGroup): boolean {
|
||||||
|
return visibleCount(group.key) < group.items.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGroupScroll(group: KanbanGroup, event: Event) {
|
||||||
|
if (!hasMore(group)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.currentTarget;
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||||
|
if (distanceFromBottom > NEAR_BOTTOM_PX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleRowsByGroup = {
|
||||||
|
...visibleRowsByGroup,
|
||||||
|
[group.key]: Math.min(group.items.length, visibleCount(group.key) + VISIBLE_ROW_INCREMENT)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined): string {
|
||||||
|
return value ?? 'not recorded';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="repository-ticket-kanban">
|
||||||
|
{#each groups as group (group.key)}
|
||||||
|
<article class="ticket-group" aria-labelledby={`${group.key}-heading`}>
|
||||||
|
<header class="ticket-group-heading">
|
||||||
|
<div>
|
||||||
|
<h3 id={`${group.key}-heading`}>{group.label}</h3>
|
||||||
|
<p>{group.states.join(' + ')}</p>
|
||||||
|
</div>
|
||||||
|
<span>{group.items.length}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if group.items.length === 0}
|
||||||
|
<p class="muted">No tickets.</p>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="ticket-list-scroll"
|
||||||
|
aria-label={`${group.label} tickets`}
|
||||||
|
onscroll={(event) => onGroupScroll(group, event)}
|
||||||
|
>
|
||||||
|
<ul class="ticket-list">
|
||||||
|
{#each visibleTickets(group) as ticket (ticket.id)}
|
||||||
|
<li class="ticket-row">
|
||||||
|
<div class="ticket-row-heading">
|
||||||
|
<strong>{ticket.title}</strong>
|
||||||
|
<span class="ticket-state">{ticket.state}</span>
|
||||||
|
</div>
|
||||||
|
<small><code>{ticket.id}</code> · updated {formatDate(ticket.updated_at)}</small>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if hasMore(group)}
|
||||||
|
<p class="lazy-note">Showing {visibleCount(group.key)} of {group.items.length}; scroll for more.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.repository-ticket-kanban {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-group {
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-4) 0 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-group-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-group-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-group-heading p,
|
||||||
|
.ticket-group-heading span,
|
||||||
|
.lazy-note {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-group-heading p,
|
||||||
|
.lazy-note {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-list-scroll {
|
||||||
|
max-height: 34rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: var(--space-2);
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row {
|
||||||
|
padding-left: var(--space-3);
|
||||||
|
border-left: 2px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row-heading strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-state {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
|
||||||
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
||||||
import type {
|
import type {
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
|
|
@ -355,25 +356,7 @@
|
||||||
Read-only grouping of canonical Ticket records. No drag/drop or lifecycle mutation is exposed.
|
Read-only grouping of canonical Ticket records. No drag/drop or lifecycle mutation is exposed.
|
||||||
</p>
|
</p>
|
||||||
{#if repositoryTickets}
|
{#if repositoryTickets}
|
||||||
<div class="kanban">
|
<RepositoryTicketKanban tickets={repositoryTickets} />
|
||||||
{#each repositoryTickets.columns as column (column.state)}
|
|
||||||
<article class="kanban-column">
|
|
||||||
<h3>{column.state} <span>{column.items.length}</span></h3>
|
|
||||||
{#if column.items.length === 0}
|
|
||||||
<p class="muted">No tickets.</p>
|
|
||||||
{:else}
|
|
||||||
<ul>
|
|
||||||
{#each column.items as ticket (ticket.id)}
|
|
||||||
<li>
|
|
||||||
<strong>{ticket.title}</strong>
|
|
||||||
<small><code>{ticket.id}</code> · updated {formatDate(ticket.updated_at)}</small>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if repositoryTicketsError}
|
{:else if repositoryTicketsError}
|
||||||
<p class="error">{repositoryTicketsError}</p>
|
<p class="error">{repositoryTicketsError}</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user