Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+366 -67
View File
@@ -26,10 +26,23 @@
opened_at: string;
closed_at: string | null;
scripts: Array<{
id: number; name: string; target_rel_path: string; kind: ArtifactKind;
version: { id: number; version: number; release_version: string | null; sha256: string } | null;
id: number;
name: string;
target_rel_path: string;
kind: ArtifactKind;
version: {
id: number;
version: number;
release_version: string | null;
sha256: string;
} | null;
}>;
servers: Array<{
server: string;
server_deleted_at?: string | null;
stage: string;
error: string | null;
}>;
servers: Array<{ server: string; server_deleted_at?: string | null; stage: string; error: string | null }>;
}
let betas = $state<BetaListRow[]>([]);
@@ -38,17 +51,27 @@
let tab = $state('active');
let search = $state('');
let status = $state('');
let pageNumber = $state(1), totalPages = $state(1), total = $state(0);
let pageNumber = $state(1),
totalPages = $state(1),
total = $state(0);
let counts = $state({ active: 0, history: 0 });
let request = 0;
const active = (beta: BetaListRow) => beta.state === 'running' || beta.state === 'ready-promote';
const tabs = $derived([{id:'active',label:'Active',badge:counts.active},{id:'history',label:'History',badge:counts.history}]);
const active = (beta: BetaListRow) =>
beta.state === 'running' || beta.state === 'ready-promote';
const tabs = $derived([
{ id: 'active', label: 'Active', badge: counts.active },
{ id: 'history', label: 'History', badge: counts.history }
]);
let now = $state(Date.now());
function deadlineLabel(beta: BetaListRow) {
if (!beta.deadlineAt || !active(beta) || beta.testOutcome === 'passed') return '';
const minutes = Math.ceil((Date.parse(beta.deadlineAt) - now) / 60000);
return minutes <= 0 ? 'Testing window ended' : minutes <= 120 ? `${minutes}m remaining` : '';
return minutes <= 0
? 'Testing window ended'
: minutes <= 120
? `${minutes}m remaining`
: '';
}
$effect(() => {
const params = $page.url.searchParams;
@@ -60,11 +83,15 @@
});
function saveContext() {
const url = new URL($page.url);
if (tab === 'history') url.searchParams.set('view', tab); else url.searchParams.delete('view');
if (search) url.searchParams.set('q', search); else url.searchParams.delete('q');
if (status) url.searchParams.set('status', status); else url.searchParams.delete('status');
if (tab === 'history') url.searchParams.set('view', tab);
else url.searchParams.delete('view');
if (search) url.searchParams.set('q', search);
else url.searchParams.delete('q');
if (status) url.searchParams.set('status', status);
else url.searchParams.delete('status');
url.searchParams.delete('limit');
if (pageNumber > 1) url.searchParams.set('page', String(pageNumber)); else url.searchParams.delete('page');
if (pageNumber > 1) url.searchParams.set('page', String(pageNumber));
else url.searchParams.delete('page');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
async function load(params = new URLSearchParams(window.location.search)) {
@@ -77,79 +104,351 @@
if (!res.ok) throw new Error('Could not refresh betas.');
const body = await res.json();
if (current !== request) return;
betas = body.betas; counts = body.counts; total = body.total; totalPages = body.totalPages; pageNumber = body.page;
now = Date.now(); error = '';
} catch (e) { if (current === request) error = (e as Error).message; }
finally { if (current === request) loading = false; }
betas = body.betas;
counts = body.counts;
total = body.total;
totalPages = body.totalPages;
pageNumber = body.page;
now = Date.now();
error = '';
} catch (e) {
if (current === request) error = (e as Error).message;
} finally {
if (current === request) loading = false;
}
}
onMount(() => {
const query = new URLSearchParams(window.location.search);
if (query.has('artifact') || query.has('pluginId')) { void goto('/betas/new' + window.location.search, {replaceState:true}); return; }
if (query.has('artifact') || query.has('pluginId')) {
void goto('/betas/new' + window.location.search, { replaceState: true });
return;
}
const stop = betaUpdates(() => load());
const clock = setInterval(() => { now = Date.now(); }, 30000);
return () => { stop(); clearInterval(clock); request++; };
const clock = setInterval(() => {
now = Date.now();
}, 30000);
return () => {
stop();
clearInterval(clock);
request++;
};
});
</script>
<svelte:head><title>Betas · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Betas">
{#snippet actions()}{#if loading || betas.length || error}<Button variant="primary" href="/betas/new">New beta</Button>{/if}{/snippet}
{#snippet actions()}{#if loading || betas.length || error}<Button
variant="primary"
href="/betas/new">New beta</Button
>{/if}{/snippet}
</SectionHeader>
<div class="toolbar">
<div class="view-tabs"><Tabs {tabs} bind:value={tab} onchange={() => { pageNumber = 1; status = ''; saveContext(); }} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" /></div>
<label class="search">Search<input type="search" bind:value={search} oninput={() => { pageNumber = 1; saveContext(); }} placeholder="Name, beta number, file, or server" maxlength="200" /></label>
<label>Status<select bind:value={status} onchange={() => { pageNumber = 1; saveContext(); }}>
<option value="">All statuses</option>
{#if tab === 'active'}<option value="ready">Ready to promote</option><option value="testing">Testing</option><option value="attention">Needs attention</option><option value="paused">Paused</option>
{:else}<option value="promoted">Promoted</option><option value="rolled-back">Rolled back</option>{/if}
</select></label>
{#if betas.length && (search || status)}<Button variant="ghost" onclick={() => { search = ''; status = ''; pageNumber = 1; saveContext(); }}>Clear filters</Button>{/if}
<div class="view-tabs">
<Tabs
{tabs}
bind:value={tab}
onchange={() => {
pageNumber = 1;
status = '';
saveContext();
}}
idPrefix="beta-tabs"
panelId="beta-list"
ariaLabel="Beta views"
/>
</div>
<label class="search"
>Search<input
type="search"
bind:value={search}
oninput={() => {
pageNumber = 1;
saveContext();
}}
placeholder="Name, beta number, file, or server"
maxlength="200"
/></label
>
<label
>Status<select
bind:value={status}
onchange={() => {
pageNumber = 1;
saveContext();
}}
>
<option value="">All statuses</option>
{#if tab === 'active'}<option value="ready">Ready to promote</option><option
value="testing">Testing</option
><option value="attention">Needs attention</option><option value="paused"
>Paused</option
>
{:else}<option value="promoted">Promoted</option><option value="rolled-back"
>Rolled back</option
>{/if}
</select></label
>
{#if betas.length && (search || status)}<Button
variant="ghost"
onclick={() => {
search = '';
status = '';
pageNumber = 1;
saveContext();
}}>Clear filters</Button
>{/if}
</div>
{#if error}<div class="ui-error" role="alert">{error} {betas.length ? 'Showing the last loaded results.' : ''}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
{#if error}<div class="ui-error" role="alert">
{error}
{betas.length ? 'Showing the last loaded results.' : ''}<Button
size="sm"
onclick={() => load()}>Retry</Button
>
</div>{/if}
<div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}>
{#if loading}<EmptyState title="Loading betas…" />
{:else if !betas.length && !error}<EmptyState title={search || status ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={search || status ? 'Try another search or clear the filters.' : tab === 'active' ? 'Betas test file versions on selected servers before promotion. Choose the versions and servers to begin.' : 'Completed and rolled-back betas appear here. Start a beta to test a file version before promotion.'}>
{#snippet actions()}{#if search || status}<Button onclick={() => { search = ''; status = ''; pageNumber = 1; saveContext(); }}>Clear filters</Button>{:else}<Button variant="primary" href="/betas/new">New beta</Button>{/if}{/snippet}
</EmptyState>
{:else if !betas.length && !error}<EmptyState
title={search || status
? 'No matching betas'
: tab === 'active'
? 'No active betas'
: 'No beta history'}
description={search || status
? 'Try another search or clear the filters.'
: tab === 'active'
? 'Betas test file versions on selected servers before promotion. Choose the versions and servers to begin.'
: 'Completed and rolled-back betas appear here. Start a beta to test a file version before promotion.'}
>
{#snippet actions()}{#if search || status}<Button
onclick={() => {
search = '';
status = '';
pageNumber = 1;
saveContext();
}}>Clear filters</Button
>{:else}<Button variant="primary" href="/betas/new">New beta</Button
>{/if}{/snippet}
</EmptyState>
{:else}
<div class="table-scroll"><table>
<thead><tr><th scope="col">Beta</th><th scope="col">Files</th><th scope="col">Status</th><th scope="col" class="started">Started</th></tr></thead>
<tbody>{#each betas as beta (beta.id)}
{@const presentation = betaStatus(beta)}
{@const expiry = deadlineLabel(beta)}
<tr>
<td><a class="beta-link" href={contextHref('/betas/' + beta.id, $page.url)}>{beta.notes || `Beta #${beta.id}`}</a>{#if beta.notes}<span class="beta-id">#{beta.id}</span>{/if}</td>
<td class="files">{#if beta.scripts.length === 1}{beta.scripts[0].name} <span>{artifactVersionLabel(beta.scripts[0].version)}</span>{:else}{beta.scripts.length} files{/if}</td>
<td><Badge variant={presentation.tone} size="md">{presentation.label}</Badge>{#if expiry}<span class="expiry">{expiry}</span>{/if}</td>
<td class="started"><button type="button" class="timestamp" aria-label={`Started ${$timeFormat.dateTime(beta.opened_at)}`}><RelativeTime value={beta.opened_at} /><span class="exact-time" aria-hidden="true">{$timeFormat.dateTime(beta.opened_at)}</span></button></td>
</tr>
{/each}</tbody>
</table></div>
{#if totalPages > 1}<div class="pagination" aria-label="Beta pagination"><span>{total} betas · Page {pageNumber} of {totalPages}</span><div><Button size="sm" disabled={pageNumber <= 1} onclick={() => { pageNumber--; saveContext(); }}>Previous</Button><Button size="sm" disabled={pageNumber >= totalPages} onclick={() => { pageNumber++; saveContext(); }}>Next</Button></div></div>{/if}
<div class="table-scroll">
<table>
<thead
><tr
><th scope="col">Beta</th><th scope="col">Files</th><th scope="col"
>Status</th
><th scope="col" class="started">Started</th></tr
></thead
>
<tbody
>{#each betas as beta (beta.id)}
{@const presentation = betaStatus(beta)}
{@const expiry = deadlineLabel(beta)}
<tr>
<td
><a
class="beta-link"
href={contextHref('/betas/' + beta.id, $page.url)}
>{beta.notes || `Beta #${beta.id}`}</a
>{#if beta.notes}<span class="beta-id">#{beta.id}</span
>{/if}</td
>
<td class="files"
>{#if beta.scripts.length === 1}{beta.scripts[0].name}
<span>{artifactVersionLabel(beta.scripts[0].version)}</span
>{:else}{beta.scripts.length} files{/if}</td
>
<td
><Badge variant={presentation.tone} size="md"
>{presentation.label}</Badge
>{#if expiry}<span class="expiry">{expiry}</span>{/if}</td
>
<td class="started"
><button
type="button"
class="timestamp"
aria-label={`Started ${$timeFormat.dateTime(beta.opened_at)}`}
><RelativeTime value={beta.opened_at} /><span
class="exact-time"
aria-hidden="true"
>{$timeFormat.dateTime(beta.opened_at)}</span
></button
></td
>
</tr>
{/each}</tbody
>
</table>
</div>
{#if totalPages > 1}<div class="pagination" aria-label="Beta pagination">
<span>{total} betas · Page {pageNumber} of {totalPages}</span>
<div>
<Button
size="sm"
disabled={pageNumber <= 1}
onclick={() => {
pageNumber--;
saveContext();
}}>Previous</Button
><Button
size="sm"
disabled={pageNumber >= totalPages}
onclick={() => {
pageNumber++;
saveContext();
}}>Next</Button
>
</div>
</div>{/if}
{/if}
</div>
</div>
<style>
.page { display:grid; gap:24px; min-width:0; }
.toolbar { display:flex; align-items:flex-end; flex-wrap:wrap; gap:12px; }
.view-tabs { align-self:flex-end; max-width:100%; }
label { display:grid; gap:6px; font-size:var(--text-xs); color:var(--text-secondary); }
.search { flex:1; min-width:240px; }
input,select { padding:10px 12px; min-height:40px; background:var(--surface-sunken); color:var(--text-primary); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); }
.table-scroll { overflow-x:auto; }
table { width:100%; border-collapse:collapse; font-size:var(--text-sm); text-align:left; }
th { padding:10px 16px; border-bottom:1px solid var(--border-strong); color:var(--text-tertiary); font-size:var(--text-xs); font-weight:var(--weight-medium); }
td { padding:16px; border-bottom:1px solid var(--border-subtle); }
tbody tr:hover { background:var(--surface); }
.beta-link { font-weight:var(--weight-semibold); color:var(--text-primary); text-decoration:none; overflow-wrap:anywhere; }
.beta-link:hover { color:var(--accent-primary-hover); text-decoration:underline; }
.beta-id { margin-left:8px; font-size:var(--text-xs); color:var(--text-tertiary); }
.files { color:var(--text-secondary); overflow-wrap:anywhere; }.files span { white-space:nowrap; }
.expiry { display:block; margin-top:6px; font-size:var(--text-xs); color:var(--state-warning); }
.started { text-align:right; color:var(--text-secondary); }
.timestamp { position:relative; display:inline-block; white-space:nowrap; cursor:help; background:transparent; border:0; color:inherit; font:inherit; padding:0; }
.exact-time { display:none; position:absolute; right:0; bottom:100%; z-index:1; background:var(--surface-overlay); color:var(--text-primary); border:1px solid var(--border-strong); border-radius:var(--radius-sm); padding:6px 10px; font-size:var(--text-xs); }
.timestamp:hover .exact-time,.timestamp:focus .exact-time { display:block; }
.pagination,.pagination > div { display:flex; align-items:center; gap:12px; }.pagination { justify-content:space-between; margin-top:16px; font-size:var(--text-sm); color:var(--text-secondary); }
@media(max-width:720px) { table { min-width:620px; } th,td { padding:12px 8px; }.search { flex-basis:100%; }.pagination { flex-wrap:wrap; } }
.page {
display: grid;
gap: 24px;
min-width: 0;
}
.toolbar {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 12px;
}
.view-tabs {
align-self: flex-end;
max-width: 100%;
}
label {
display: grid;
gap: 6px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.search {
flex: 1;
min-width: 240px;
}
input,
select {
padding: 10px 12px;
min-height: 40px;
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
font: inherit;
font-size: var(--text-sm);
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
text-align: left;
}
th {
padding: 10px 16px;
border-bottom: 1px solid var(--border-strong);
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
}
td {
padding: 16px;
border-bottom: 1px solid var(--border-subtle);
}
tbody tr:hover {
background: var(--surface);
}
.beta-link {
font-weight: var(--weight-semibold);
color: var(--text-primary);
text-decoration: none;
overflow-wrap: anywhere;
}
.beta-link:hover {
color: var(--accent-primary-hover);
text-decoration: underline;
}
.beta-id {
margin-left: 8px;
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.files {
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.files span {
white-space: nowrap;
}
.expiry {
display: block;
margin-top: 6px;
font-size: var(--text-xs);
color: var(--state-warning);
}
.started {
text-align: right;
color: var(--text-secondary);
}
.timestamp {
position: relative;
display: inline-block;
white-space: nowrap;
cursor: help;
background: transparent;
border: 0;
color: inherit;
font: inherit;
padding: 0;
}
.exact-time {
display: none;
position: absolute;
right: 0;
bottom: 100%;
z-index: 1;
background: var(--surface-overlay);
color: var(--text-primary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 6px 10px;
font-size: var(--text-xs);
}
.timestamp:hover .exact-time,
.timestamp:focus .exact-time {
display: block;
}
.pagination,
.pagination > div {
display: flex;
align-items: center;
gap: 12px;
}
.pagination {
justify-content: space-between;
margin-top: 16px;
font-size: var(--text-sm);
color: var(--text-secondary);
}
@media (max-width: 720px) {
table {
min-width: 620px;
}
th,
td {
padding: 12px 8px;
}
.search {
flex-basis: 100%;
}
.pagination {
flex-wrap: wrap;
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff