feat(web): public read-only collection view renderers (TASK-1679) (#679)

* feat(web): public read-only collection view renderers (TASK-1679)

Adds purpose-built read-only renderers for the public share page that
match the owner's view TYPE (kanban/list/table) and grouping, styled for
an anonymous external audience — no app chrome, no edit/drag/create
affordances, no internal app links.

New components under web/src/lib/components/share/:
- shareView.ts — payload types + DEFENSIVE parsers (settings/schema/
  fields tolerated as JSON string OR object, since TASK-1678 ships in
  parallel) + label/status/priority color + grouping helpers lifted
  from the in-app vocabularies (ItemCard / BoardView / ListView).
- PublicItemCard.svelte — inert read-only card (board).
- PublicBoardView / PublicListView / PublicTableView — the three view
  renderers; board groups by board_group_by|status, list optionally
  groups by list_group_by, table mirrors TableView's CSS-grid layout.
- PublicCollectionView.svelte — entry point that parses the raw payload
  and switches on settings.default_view, delegating to a leaf renderer.

Why: the share page currently renders a hardcoded flat list, ignoring
the owner's chosen view. These components are the render layer; wiring
into /s/[token] against the merged payload is TASK-1680. Row/card
components carry an optional expandable + onactivate contract so the
inline read-only expand (TASK-1684) can be added without a rewrite.

Not wired into any route yet; npm run build passes.

Parent: PLAN-1677.

* fix(web): stable unique each-keys for public renderers per Codex review (round 1)

PublicItem now carries a `key` assigned at parse time from the item's
payload position (ref-prefixed when present). The board/list/table
renderers key their {#each} blocks on it instead of `ref || title` —
refs may be empty and titles aren't unique, so the old key could collide
for two same-titled unprefixed items and break rendering/state.

Parent: PLAN-1677.
This commit is contained in:
xarmian
2026-05-31 00:59:01 -04:00
committed by GitHub
parent 873d351e24
commit a58c64fb07
6 changed files with 1122 additions and 0 deletions
@@ -0,0 +1,135 @@
<script lang="ts">
// Read-only kanban renderer for the public share page (TASK-1679).
//
// Groups items into columns by the collection's `board_group_by` (or
// `status`) and renders each column as a static stack of PublicItemCards.
// No drag/drop, no column reorder, no add/draft, no status mutation — just
// the owner's columns, presented for an anonymous audience. Mirrors the
// in-app BoardView's column layout + header accents without its coupling.
import type { PublicCollection, PublicItem } from './shareView';
import {
findField,
resolveGroupField,
groupItems,
formatLabel,
columnAccentClass
} from './shareView';
import PublicItemCard from './PublicItemCard.svelte';
interface Props {
collection: PublicCollection;
items: PublicItem[];
/** Forwarded to each card for the deferred inline-expand (TASK-1684). */
expandable?: boolean;
onactivate?: (item: PublicItem) => void;
}
let { collection, items, expandable = false, onactivate }: Props = $props();
let groupField = $derived(resolveGroupField(collection));
let optionOrder = $derived(findField(collection.fields, groupField)?.options ?? []);
let columns = $derived(groupItems(items, groupField, optionOrder));
</script>
<div class="public-board">
{#each columns as column (column.value)}
<section class="board-column" aria-label="{formatLabel(column.value) || 'Ungrouped'} column">
<header class="column-header {columnAccentClass(column.value)}">
<span class="column-name">{formatLabel(column.value) || 'Ungrouped'}</span>
<span class="column-count">{column.items.length}</span>
</header>
<div class="column-cards">
{#each column.items as item (item.key)}
<PublicItemCard {item} fields={collection.fields} {expandable} {onactivate} />
{/each}
{#if column.items.length === 0}
<p class="column-empty">No {(formatLabel(column.value) || 'ungrouped').toLowerCase()} items</p>
{/if}
</div>
</section>
{/each}
</div>
<style>
.public-board {
display: flex;
gap: var(--space-4);
align-items: flex-start;
overflow-x: auto;
padding-bottom: var(--space-2);
}
.board-column {
display: flex;
flex-direction: column;
flex: 1 0 0;
min-width: 240px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 2px solid var(--text-secondary);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
font-weight: 700;
font-size: 0.9em;
}
.column-header.col-in-progress {
border-bottom-color: var(--accent-amber);
}
.column-header.col-done {
border-bottom-color: var(--accent-green);
}
.column-header.col-blocked {
border-bottom-color: var(--accent-orange);
}
.column-name {
color: var(--text-primary);
}
.column-count {
font-size: 0.8em;
font-weight: 400;
color: var(--text-muted);
background: var(--bg-tertiary);
padding: 1px 8px;
border-radius: 10px;
}
.column-cards {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2);
}
.column-empty {
text-align: center;
padding: var(--space-4);
color: var(--text-muted);
font-size: 0.82em;
margin: 0;
}
@media (max-width: 768px) {
.public-board {
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
gap: var(--space-3);
}
.board-column {
min-width: 80vw;
max-width: 80vw;
scroll-snap-align: center;
flex-shrink: 0;
}
}
</style>
@@ -0,0 +1,114 @@
<script lang="ts">
// Public read-only collection view (TASK-1679 / PLAN-1677).
//
// The single entry point for rendering a shared collection on the `/s/[token]`
// page (wired by TASK-1680). Takes the RAW share-link payload — `collection`
// and `items` exactly as the backend (TASK-1678) sends them — parses
// defensively (settings/schema/fields may be JSON strings OR objects), and
// delegates to the board / list / table renderer matching the owner's
// `settings.default_view`.
//
// Either pass raw payload pieces (`collection` + `items`) for the switcher to
// normalize, OR pre-parsed `parsedCollection` + `parsedItems` if the caller
// already normalized. The raw path is the common case.
//
// Read-only: no chrome, no edit/drag/create, no internal links. An optional
// `view` prop overrides the default view (the read-only view switcher,
// Phase 2 / TASK-1680, will drive this); `onactivate` + `expandable` are
// forwarded to the leaf renderers for the deferred inline expand (TASK-1684).
import type { PublicCollection, PublicItem } from './shareView';
import { parsePublicCollection, parsePublicItems } from './shareView';
import PublicBoardView from './PublicBoardView.svelte';
import PublicListView from './PublicListView.svelte';
import PublicTableView from './PublicTableView.svelte';
interface Props {
/** Raw `collection` branch of the share payload (string/object tolerant). */
collection?: unknown;
/** Raw `items` array of the share payload. */
items?: unknown;
/** Pre-parsed collection — supply instead of `collection` to skip parsing. */
parsedCollection?: PublicCollection;
/** Pre-parsed items — supply instead of `items` to skip parsing. */
parsedItems?: PublicItem[];
/** Override the rendered view; defaults to settings.default_view. */
view?: 'list' | 'board' | 'table';
/** Forwarded to leaf renderers for the deferred inline expand (TASK-1684). */
expandable?: boolean;
onactivate?: (item: PublicItem) => void;
}
let {
collection,
items,
parsedCollection,
parsedItems,
view,
expandable = false,
onactivate
}: Props = $props();
let coll = $derived<PublicCollection>(parsedCollection ?? parsePublicCollection(collection));
let list = $derived<PublicItem[]>(parsedItems ?? parsePublicItems(items));
let activeView = $derived(view ?? coll.settings.default_view);
</script>
<div class="public-collection">
<header class="collection-header">
{#if coll.icon}<span class="collection-icon">{coll.icon}</span>{/if}
<h1 class="collection-name">{coll.name}</h1>
</header>
{#if coll.description}
<p class="collection-description">{coll.description}</p>
{/if}
{#if list.length === 0}
<p class="collection-empty">No items in this collection.</p>
{:else if activeView === 'board'}
<PublicBoardView collection={coll} items={list} {expandable} {onactivate} />
{:else if activeView === 'table'}
<PublicTableView collection={coll} items={list} {expandable} {onactivate} />
{:else}
<PublicListView collection={coll} items={list} {expandable} {onactivate} />
{/if}
</div>
<style>
.public-collection {
display: flex;
flex-direction: column;
gap: var(--space-5);
min-width: 0;
}
.collection-header {
display: flex;
align-items: center;
gap: var(--space-3);
}
.collection-icon {
font-size: 1.6em;
line-height: 1;
}
.collection-name {
font-size: 1.8em;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
}
.collection-description {
color: var(--text-secondary);
font-size: 0.95em;
margin: 0;
}
.collection-empty {
color: var(--text-muted);
font-size: 0.9em;
padding: var(--space-4) 0;
margin: 0;
}
</style>
@@ -0,0 +1,169 @@
<script lang="ts">
// Read-only item card for the public board/list renderers (TASK-1679).
//
// Strictly presentational: no links, no star, no status cycling, no drag.
// Renders the title, ref, and a small meta row (status + priority) styled
// for an anonymous audience. Mirrors the in-app ItemCard's visual language
// (status/priority colors, uppercase status) without any of its coupling to
// page.params / mutation handlers.
//
// Designed for a future inline read-only expand (TASK-1684): the optional
// `onactivate` callback + `expandable` flag turn the card into a button-like
// affordance WITHOUT changing the markup contract. When omitted (the
// default), the card is an inert <div> — no interactivity is implied.
import type { FieldDef } from '$lib/types';
import type { PublicItem } from './shareView';
import { findField, formatLabel, statusColor, priorityColor } from './shareView';
interface Props {
item: PublicItem;
fields: FieldDef[];
/** When true (and onactivate is set), the card advertises itself as an
* expand affordance. Wiring deferred to TASK-1684. */
expandable?: boolean;
/** Fired when an expandable card is activated (click / Enter / Space).
* Deferred wiring — TASK-1684. */
onactivate?: (item: PublicItem) => void;
}
let { item, fields, expandable = false, onactivate }: Props = $props();
let interactive = $derived(expandable && !!onactivate);
let statusFieldDef = $derived(findField(fields, 'status'));
let priorityFieldDef = $derived(findField(fields, 'priority'));
let status = $derived(
typeof item.fields.status === 'string' ? (item.fields.status as string) : ''
);
let priority = $derived(
typeof item.fields.priority === 'string' ? (item.fields.priority as string) : ''
);
function activate() {
if (interactive) onactivate?.(item);
}
function onKey(e: KeyboardEvent) {
if (!interactive) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onactivate?.(item);
}
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- `role` and `tabindex` are correlated at runtime: when tabindex is 0 the
role is always 'button' (both gated by `interactive`), so the element is
focusable only when it is genuinely interactive. The analyzer can't see
that correlation. Wiring lands in TASK-1684. -->
<div
class="public-card"
class:interactive
role={interactive ? 'button' : undefined}
tabindex={interactive ? 0 : undefined}
onclick={interactive ? activate : undefined}
onkeydown={interactive ? onKey : undefined}
>
{#if item.ref}
<div class="card-top">
<span class="card-ref">{item.ref}</span>
</div>
{/if}
<div class="card-title">{item.title}</div>
{#if (statusFieldDef && status) || (priorityFieldDef && priority)}
<div class="card-meta">
{#if statusFieldDef && status}
<span class="meta-status" style:color={statusColor(status)}>
{formatLabel(status).toUpperCase()}
</span>
{/if}
{#if priorityFieldDef && priority}
{#if statusFieldDef && status}<span class="meta-sep">&middot;</span>{/if}
<span class="meta-priority" style:color={priorityColor(priority)}>
{formatLabel(priority)}
</span>
{/if}
</div>
{/if}
</div>
<style>
.public-card {
display: flex;
flex-direction: column;
gap: var(--space-2);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-3) var(--space-4);
min-width: 0;
}
.public-card.interactive {
cursor: pointer;
transition: background 0.1s, border-color 0.1s;
}
.public-card.interactive:hover {
background: var(--bg-hover);
border-color: var(--text-tertiary, var(--text-secondary));
}
.public-card.interactive:focus-visible {
outline: 2px solid var(--accent-blue);
outline-offset: -2px;
}
.card-top {
display: flex;
align-items: center;
gap: var(--space-2);
}
.card-ref {
font-family: var(--font-mono);
font-size: 0.72em;
color: var(--text-muted);
font-weight: 400;
white-space: nowrap;
}
.card-title {
font-size: 0.92em;
color: var(--text-primary);
line-height: 1.45;
font-weight: 600;
overflow-wrap: anywhere;
min-width: 0;
}
.card-meta {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
min-width: 0;
}
.meta-status {
font-size: 0.7em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
white-space: nowrap;
}
.meta-sep {
font-size: 0.7em;
color: var(--text-muted);
}
.meta-priority {
font-size: 0.7em;
font-weight: 600;
white-space: nowrap;
}
</style>
@@ -0,0 +1,212 @@
<script lang="ts">
// Read-only list renderer for the public share page (TASK-1679).
//
// Renders items as a vertical stack of rows. When the collection's
// `list_group_by` is set (and resolves to a real field), rows are split
// into labeled groups in option order — mirroring the in-app ListView's
// grouping — otherwise it's a single flat list. No drag, no collapse
// toggles, no mutation; rows are inert (or expand-only once TASK-1684 wires
// `onactivate`).
import type { FieldDef } from '$lib/types';
import type { PublicCollection, PublicItem } from './shareView';
import {
findField,
groupItems,
formatLabel,
statusColor,
priorityColor
} from './shareView';
interface Props {
collection: PublicCollection;
items: PublicItem[];
/** Deferred inline-expand affordance (TASK-1684). */
expandable?: boolean;
onactivate?: (item: PublicItem) => void;
}
let { collection, items, expandable = false, onactivate }: Props = $props();
let interactive = $derived(expandable && !!onactivate);
let groupField = $derived.by(() => {
const key = collection.settings.list_group_by;
return key && findField(collection.fields, key) ? key : '';
});
let statusFieldDef = $derived<FieldDef | undefined>(findField(collection.fields, 'status'));
let priorityFieldDef = $derived<FieldDef | undefined>(findField(collection.fields, 'priority'));
let groups = $derived.by(() => {
if (!groupField) return [{ value: '', items }];
const optionOrder = findField(collection.fields, groupField)?.options ?? [];
return groupItems(items, groupField, optionOrder);
});
function statusOf(item: PublicItem): string {
return typeof item.fields.status === 'string' ? (item.fields.status as string) : '';
}
function priorityOf(item: PublicItem): string {
return typeof item.fields.priority === 'string' ? (item.fields.priority as string) : '';
}
function activate(item: PublicItem) {
if (interactive) onactivate?.(item);
}
function onKey(e: KeyboardEvent, item: PublicItem) {
if (!interactive) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onactivate?.(item);
}
}
</script>
<div class="public-list">
{#each groups as group (group.value)}
{#if groupField}
<h3 class="group-heading">
<span>{formatLabel(group.value) || 'Ungrouped'}</span>
<span class="group-count">{group.items.length}</span>
</h3>
{/if}
<div class="group-rows">
{#each group.items as item (item.key)}
{@const status = statusOf(item)}
{@const priority = priorityOf(item)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- role + tabindex are runtime-correlated (both gated by
`interactive`): focusable only when genuinely a button.
Wiring lands in TASK-1684. -->
<div
class="list-row"
class:interactive
role={interactive ? 'button' : undefined}
tabindex={interactive ? 0 : undefined}
onclick={interactive ? () => activate(item) : undefined}
onkeydown={interactive ? (e) => onKey(e, item) : undefined}
>
{#if item.ref}<span class="row-ref">{item.ref}</span>{/if}
<span class="row-title">{item.title}</span>
<span class="row-meta">
{#if priorityFieldDef && priority}
<span class="row-priority" style:color={priorityColor(priority)}
>{formatLabel(priority)}</span
>
{/if}
{#if statusFieldDef && status}
<span class="row-status" style:color={statusColor(status)}
>{formatLabel(status).toUpperCase()}</span
>
{/if}
</span>
</div>
{/each}
{#if group.items.length === 0}
<p class="group-empty">No items</p>
{/if}
</div>
{/each}
</div>
<style>
.public-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.group-heading {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.85em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-secondary);
margin: 0 0 var(--space-2);
}
.group-count {
font-size: 0.9em;
font-weight: 400;
color: var(--text-muted);
background: var(--bg-tertiary);
padding: 1px 8px;
border-radius: 10px;
}
.group-rows {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.list-row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--bg-secondary);
border: 1px solid var(--border-subtle, var(--border));
border-radius: var(--radius);
min-width: 0;
}
.list-row.interactive {
cursor: pointer;
transition: background 0.1s;
}
.list-row.interactive:hover {
background: var(--bg-hover);
}
.list-row.interactive:focus-visible {
outline: 2px solid var(--accent-blue);
outline-offset: -2px;
}
.row-ref {
font-family: var(--font-mono);
font-size: 0.78em;
color: var(--text-muted);
flex-shrink: 0;
}
.row-title {
flex: 1;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-meta {
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
}
.row-priority {
font-size: 0.75em;
font-weight: 600;
white-space: nowrap;
}
.row-status {
font-size: 0.72em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
white-space: nowrap;
}
.group-empty {
color: var(--text-muted);
font-size: 0.85em;
padding: var(--space-2) var(--space-4);
margin: 0;
}
</style>
@@ -0,0 +1,199 @@
<script lang="ts">
// Read-only table renderer for the public share page (TASK-1679).
//
// Renders Ref + Title + one column per (non-computed) schema field as a CSS
// grid (role="table"/"row"/"cell"), matching the in-app TableView's layout
// and accessibility semantics. Read-only: no sortable headers that mutate,
// no row links, no status cycling. Status/priority cells get the shared
// color vocabulary; rows are inert (or expand-only once TASK-1684 wires
// `onactivate`).
import type { PublicCollection, PublicItem } from './shareView';
import {
visibleFields,
formatLabel,
formatFieldValue,
statusColor,
priorityColor
} from './shareView';
interface Props {
collection: PublicCollection;
items: PublicItem[];
/** Deferred inline-expand affordance (TASK-1684). */
expandable?: boolean;
onactivate?: (item: PublicItem) => void;
}
let { collection, items, expandable = false, onactivate }: Props = $props();
let interactive = $derived(expandable && !!onactivate);
let columns = $derived(visibleFields(collection.fields));
let hasRefs = $derived(items.some((i) => !!i.ref));
let gridTemplate = $derived(
[
...(hasRefs ? ['70px'] : []),
'minmax(200px, 1fr)',
...columns.map(() => 'auto')
].join(' ')
);
function cellColor(key: string, value: string): string | undefined {
if (key === 'status') return statusColor(value);
if (key === 'priority') return priorityColor(value);
return undefined;
}
function activate(item: PublicItem) {
if (interactive) onactivate?.(item);
}
function onKey(e: KeyboardEvent, item: PublicItem) {
if (!interactive) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onactivate?.(item);
}
}
</script>
<div class="table-scroll">
<div class="public-table" role="table" style:grid-template-columns={gridTemplate}>
<div class="table-row table-header" role="row">
{#if hasRefs}<div class="table-cell col-ref" role="columnheader">Ref</div>{/if}
<div class="table-cell" role="columnheader">Title</div>
{#each columns as field (field.key)}
<div class="table-cell" role="columnheader">{field.label || formatLabel(field.key)}</div>
{/each}
</div>
{#each items as item (item.key)}
<div
class="table-row"
class:interactive
role="row"
tabindex={interactive ? 0 : undefined}
onclick={interactive ? () => activate(item) : undefined}
onkeydown={interactive ? (e) => onKey(e, item) : undefined}
>
{#if hasRefs}
<div class="table-cell col-ref" role="cell"><span class="ref">{item.ref}</span></div>
{/if}
<div class="table-cell col-title" role="cell">
<span class="title">{item.title}</span>
</div>
{#each columns as field (field.key)}
{@const raw = item.fields[field.key]}
{@const text = formatFieldValue(raw)}
{@const color = typeof raw === 'string' ? cellColor(field.key, raw) : undefined}
<div class="table-cell" role="cell">
{#if field.key === 'status' && text}
<span class="cell-status" style:color>{formatLabel(text).toUpperCase()}</span>
{:else if color && text}
<span class="cell-value" style:color>{formatLabel(text)}</span>
{:else}
<span class="cell-value">{text}{field.suffix && text ? ` ${field.suffix}` : ''}</span>
{/if}
</div>
{/each}
</div>
{/each}
</div>
</div>
<style>
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.public-table {
display: grid;
width: 100%;
font-size: 0.88em;
}
.table-row {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
border-bottom: 1px solid var(--border-subtle, var(--border));
}
@supports not (grid-template-columns: subgrid) {
.table-row {
grid-template-columns: inherit;
}
}
.table-row.table-header {
position: sticky;
top: 0;
background: var(--bg-primary);
z-index: 1;
border-bottom: 2px solid var(--border);
}
.table-row:not(.table-header) {
content-visibility: auto;
contain-intrinsic-size: auto 36px;
}
.table-row.interactive:not(.table-header) {
cursor: pointer;
}
.table-row.interactive:not(.table-header):hover {
background: var(--bg-hover);
}
.table-row.interactive:focus-visible {
outline: 2px solid var(--accent-blue);
outline-offset: -2px;
}
.table-cell {
padding: var(--space-2) var(--space-3);
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.table-header .table-cell {
font-weight: 600;
font-size: 0.85em;
color: var(--text-secondary);
white-space: nowrap;
}
.col-title {
min-width: 0;
}
.ref {
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--text-muted);
}
.title {
color: var(--text-primary);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-value {
color: var(--text-secondary);
font-size: 0.9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-status {
font-size: 0.78em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
white-space: nowrap;
}
</style>
+293
View File
@@ -0,0 +1,293 @@
// Shared types + helpers for the public read-only collection view renderers
// (TASK-1679 / PLAN-1677).
//
// These renderers consume the share-link payload produced by TASK-1678 (which
// ships in parallel), so EVERYTHING here parses defensively: `settings`,
// `schema`, and per-item `fields` may arrive as a JSON string OR an
// already-parsed object. We never assume; we coerce. TASK-1680 reconciles
// against the final wire shape — until then these helpers are the single
// choke-point for tolerating either form.
//
// Deliberately decoupled from `$lib/types`' `Item`/`Collection` DB row shapes
// and from the interactive BoardView/ListView/TableView (which depend on
// `page.params`, internal links, and mutation handlers — exactly what a
// logged-out, read-only audience must not see). The color/label vocabularies
// mirror the in-app helpers so a shared kanban looks like the owner's kanban.
import type { FieldDef } from '$lib/types';
/** Collection settings as understood by the public renderers. View-type +
* grouping/sort knobs only — interactive-only settings are ignored. */
export interface PublicViewSettings {
default_view: 'list' | 'board' | 'table';
board_group_by?: string;
list_sort_by?: string;
list_group_by?: string;
layout?: string;
}
/** The collection branch of the share payload. `settings`/`schema` are
* parsed defensively from string-or-object by `parsePublicCollection`. */
export interface PublicCollection {
name: string;
icon?: string;
description?: string;
settings: PublicViewSettings;
fields: FieldDef[];
}
/** One item in the share payload, normalized. `fields` is parsed from
* string-or-object; `content` is the raw markdown body (rendering is the
* renderer's concern — kept here so a future inline expand (TASK-1684) has
* the body without a re-fetch). `key` is a stable, unique identity assigned
* at parse time (the item's position in the payload) — refs may be empty and
* titles aren't unique, so renderers key `{#each}` blocks on this instead. */
export interface PublicItem {
key: string;
title: string;
ref: string;
fields: Record<string, unknown>;
content: string;
}
// ── Defensive parsing ───────────────────────────────────────────────────────
/** Accept a value that may be a JSON string, an object, or null/undefined and
* return a plain record. Never throws. */
export function coerceObject(value: unknown): Record<string, unknown> {
if (value == null) return {};
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
if (typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
/** Pull a `fields` array out of a schema that may be a JSON string, an object
* with a `fields` array, or already an array of FieldDef. Returns a clean
* FieldDef[] (entries missing a `key` are dropped). */
export function coerceFields(schema: unknown): FieldDef[] {
let candidate: unknown = schema;
if (typeof schema === 'string') {
const trimmed = schema.trim();
if (!trimmed) return [];
try {
candidate = JSON.parse(trimmed);
} catch {
return [];
}
}
let arr: unknown;
if (Array.isArray(candidate)) {
arr = candidate;
} else if (candidate && typeof candidate === 'object') {
arr = (candidate as Record<string, unknown>).fields;
}
if (!Array.isArray(arr)) return [];
return arr.filter(
(f): f is FieldDef => !!f && typeof f === 'object' && typeof (f as FieldDef).key === 'string'
);
}
const DEFAULT_SETTINGS: PublicViewSettings = { default_view: 'list' };
/** Normalize raw `settings` (string-or-object) into PublicViewSettings,
* validating `default_view` against the known set and defaulting to 'list'. */
export function coerceSettings(settings: unknown): PublicViewSettings {
const obj = coerceObject(settings);
const view = obj.default_view;
const default_view: PublicViewSettings['default_view'] =
view === 'board' || view === 'table' || view === 'list' ? view : 'list';
return {
default_view,
board_group_by: typeof obj.board_group_by === 'string' ? obj.board_group_by : undefined,
list_sort_by: typeof obj.list_sort_by === 'string' ? obj.list_sort_by : undefined,
list_group_by: typeof obj.list_group_by === 'string' ? obj.list_group_by : undefined,
layout: typeof obj.layout === 'string' ? obj.layout : undefined
};
}
/** Normalize the raw collection branch of the share payload into a
* PublicCollection. Tolerates `settings` and `schema` arriving as either a
* JSON string or an object (TASK-1678 ships in parallel). */
export function parsePublicCollection(raw: unknown): PublicCollection {
const obj = coerceObject(raw);
return {
name: typeof obj.name === 'string' && obj.name ? obj.name : 'Collection',
icon: typeof obj.icon === 'string' ? obj.icon : undefined,
description: typeof obj.description === 'string' ? obj.description : undefined,
settings: coerceSettings(obj.settings),
fields: coerceFields(obj.schema)
};
}
/** Normalize one raw item into a PublicItem. `fields` parsed defensively;
* `ref` falls back across `ref` / `item_ref`. `index` is the item's position
* in the payload, used to derive a stable unique `key`. */
export function parsePublicItem(raw: unknown, index = 0): PublicItem {
const obj = coerceObject(raw);
const ref =
typeof obj.ref === 'string' && obj.ref
? obj.ref
: typeof obj.item_ref === 'string'
? obj.item_ref
: '';
return {
// Refs can be empty and titles aren't unique, so anchor the key on the
// payload position; prefix the ref when present for readable debugging.
key: ref ? `${ref}#${index}` : `idx#${index}`,
title: typeof obj.title === 'string' && obj.title ? obj.title : 'Untitled',
ref,
fields: coerceObject(obj.fields),
content: typeof obj.content === 'string' ? obj.content : ''
};
}
export function parsePublicItems(raw: unknown): PublicItem[] {
if (!Array.isArray(raw)) return [];
return raw.map((item, i) => parsePublicItem(item, i));
}
// ── Field lookup ──────────────────────────────────────────────────────────
export function findField(fields: FieldDef[], key: string): FieldDef | undefined {
return fields.find((f) => f.key === key);
}
/** Fields a table/list should show: drop computed fields (they aren't part of
* the shared snapshot's meaningful columns). */
export function visibleFields(fields: FieldDef[]): FieldDef[] {
return fields.filter((f) => !f.computed);
}
/** Resolve the field key to group a board by: explicit `board_group_by`,
* else `status` if the schema has one, else the first select field, else ''. */
export function resolveGroupField(collection: PublicCollection): string {
const explicit = collection.settings.board_group_by;
if (explicit && findField(collection.fields, explicit)) return explicit;
if (findField(collection.fields, 'status')) return 'status';
const firstSelect = collection.fields.find((f) => f.type === 'select');
return firstSelect?.key ?? '';
}
// ── Presentation helpers (mirror the in-app vocabularies) ───────────────────
/** Title-case a snake/kebab field key or value for display. */
export function formatLabel(value: string): string {
return value.replace(/[_-]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Stringify any field value for read-only display: arrays join, objects
* JSON-stringify, primitives coerce, null/undefined → ''. */
export function formatFieldValue(value: unknown): string {
if (value === null || value === undefined) return '';
if (Array.isArray(value)) return value.map((v) => formatFieldValue(v)).join(', ');
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch {
return '';
}
}
return String(value);
}
/** Status color — mirrors ItemCard.statusColor so a shared board matches the
* owner's palette. Returns a CSS custom-property reference. */
export function statusColor(status: string): string {
switch (status) {
case 'open':
return 'var(--text-secondary)';
case 'in_progress':
return 'var(--accent-amber)';
case 'done':
return 'var(--accent-green)';
case 'blocked':
return 'var(--accent-orange)';
default:
return 'var(--text-muted)';
}
}
/** Priority color — mirrors ItemCard.priorityColor. */
export function priorityColor(priority: string): string {
switch (priority) {
case 'critical':
return 'var(--accent-orange)';
case 'high':
return 'var(--accent-amber)';
case 'medium':
return 'var(--text-secondary)';
case 'low':
return 'var(--text-muted)';
default:
return 'var(--text-muted)';
}
}
/** A board column's accent class, mirroring BoardView.columnCssClass. */
export function columnAccentClass(value: string): string {
switch (value) {
case 'in_progress':
return 'col-in-progress';
case 'done':
return 'col-done';
case 'blocked':
return 'col-blocked';
default:
return '';
}
}
/** Group `items` by `groupField` value, in option order with any extra values
* appended (sorted), and ungrouped ('') last when present. Mirrors
* ListView/BoardView grouping so a shared view preserves the owner's columns. */
export function groupItems(
items: PublicItem[],
groupField: string,
optionOrder: string[]
): { value: string; items: PublicItem[] }[] {
const buckets = new Map<string, PublicItem[]>();
for (const opt of optionOrder) buckets.set(opt, []);
const extras: string[] = [];
let hasUngrouped = false;
for (const item of items) {
const raw = item.fields[groupField];
const value = typeof raw === 'string' ? raw : raw == null ? '' : String(raw);
if (!value) {
hasUngrouped = true;
if (!buckets.has('')) buckets.set('', []);
buckets.get('')!.push(item);
continue;
}
if (!buckets.has(value)) {
buckets.set(value, []);
extras.push(value);
}
buckets.get(value)!.push(item);
}
const order = [...optionOrder, ...extras.sort()];
if (hasUngrouped) order.push('');
// Dedupe while preserving order (an option could also appear in extras edge cases).
const seen = new Set<string>();
const result: { value: string; items: PublicItem[] }[] = [];
for (const v of order) {
if (seen.has(v)) continue;
seen.add(v);
result.push({ value: v, items: buckets.get(v) ?? [] });
}
return result;
}