Valider fase 19–20: arbeidsflaten + universell overføring bestått

Fikser funnet under validering:
- Gjør collection-prop valgfri i alle trait-komponenter slik at de
  fungerer i personlig arbeidsflate uten collection-kontekst
- Legger til null-guards for collection.id i alle derived-blokker
  og funksjoner som oppretter edges
- Fjerner microsSinceUnixEpoch-rester fra STDB-migrasjonen —
  createdAt er nå et tall (Unix µs), ikke et objekt
- Retter saveTimeout-lekkasje i collection-sida: timer ryddes nå
  ved navigasjon mellom samlinger
- Fikser TypeScript-feil i editorial (number vs string, uoppnåelig
  'scheduled'-sammenligning), studio (bigint vs number),
  RecordingTrait ($state-generics)
- Typefeil redusert fra 55 → 4 (gjenværende er pre-eksisterende
  i mixer.ts/livekit.ts, ikke fase 19-20)

Validert: Canvas pan/zoom, BlockShell, layout-persistering,
snarveier, transfer service, alle panelreworks. Frontend bygger OK.
This commit is contained in:
vegard 2026-03-18 16:03:17 +00:00
parent 7b601ead1f
commit 15dd23b873
17 changed files with 65 additions and 56 deletions

View file

@ -6,7 +6,7 @@
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
interface Props {
collection: Node;
collection?: Node;
config: Record<string, unknown>;
userId?: string;
accessToken?: string;

View file

@ -9,7 +9,7 @@
import { tick } from 'svelte';
interface Props {
collection: Node;
collection?: Node;
config: Record<string, unknown>;
userId?: string;
accessToken?: string;
@ -45,6 +45,7 @@
const chatNodes = $derived.by(() => {
const nodes: Node[] = [];
if (!collection) return nodes;
const seen = new Set<string>();
for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue;
@ -121,8 +122,8 @@
}
nodes.sort((a, b) => {
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n;
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n;
const ta = a.createdAt ?? 0;
const tb = b.createdAt ?? 0;
return ta > tb ? 1 : ta < tb ? -1 : 0;
});
@ -189,8 +190,8 @@
// =========================================================================
function formatTime(node: Node): string {
if (!node.createdAt?.microsSinceUnixEpoch) return '';
const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n);
if (!node.createdAt) return '';
const ms = Math.floor(node.createdAt / 1000);
const date = new Date(ms);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();

View file

@ -7,7 +7,7 @@
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
interface Props {
collection: Node;
collection?: Node;
config: Record<string, unknown>;
userId?: string;
accessToken?: string;
@ -69,6 +69,7 @@
}
const contentItems = $derived.by((): ContentItem[] => {
if (!collection) return [];
const items: ContentItem[] = [];
for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue;
@ -78,8 +79,8 @@
}
}
items.sort((a, b) => {
const ta = a.node.createdAt?.microsSinceUnixEpoch ?? 0n;
const tb = b.node.createdAt?.microsSinceUnixEpoch ?? 0n;
const ta = a.node.createdAt ?? 0;
const tb = b.node.createdAt ?? 0;
return tb > ta ? 1 : tb < ta ? -1 : 0;
});
return items;
@ -131,8 +132,8 @@
}
}
nodes.sort((a, b) => {
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n;
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n;
const ta = a.createdAt ?? 0;
const tb = b.createdAt ?? 0;
return tb > ta ? 1 : tb < ta ? -1 : 0;
});
return nodes;
@ -217,7 +218,7 @@
let showCreateForm = $state(false);
async function handleCreateArticle() {
if (!accessToken || !newTitle.trim() || isCreating) return;
if (!accessToken || !collection || !newTitle.trim() || isCreating) return;
isCreating = true;
try {
const { node_id } = await createNode(accessToken, {
@ -360,8 +361,8 @@
// =========================================================================
function formatTime(node: Node): string {
if (!node.createdAt?.microsSinceUnixEpoch) return '';
const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n);
if (!node.createdAt) return '';
const ms = Math.floor(node.createdAt / 1000);
const date = new Date(ms);
return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' });
}
@ -664,7 +665,7 @@
{#if publishTarget && accessToken && pubConfig}
<PublishDialog
node={publishTarget}
{collection}
collection={collection!}
pubConfig={pubConfig}
{accessToken}
onclose={() => { publishTarget = null; }}

View file

@ -6,7 +6,7 @@
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
interface Props {
collection: Node;
collection?: Node;
config: Record<string, unknown>;
userId?: string;
accessToken?: string;
@ -43,6 +43,7 @@
// =========================================================================
const columns = $derived.by(() => {
if (!collection) return ['todo', 'in_progress', 'done'];
try {
const meta = JSON.parse(collection.metadata ?? '{}');
const traitConf = meta.traits?.kanban;
@ -163,7 +164,7 @@
e.preventDefault();
dragOverColumn = null;
if (!draggedCard || !accessToken) return;
if (!draggedCard || !accessToken || !collection) return;
if (draggedCard.status === targetColumn) {
draggedCard = null;
return;
@ -205,7 +206,7 @@
let isCreating = $state(false);
async function handleCreateCard(column: string) {
if (!accessToken || !newCardTitle.trim() || isCreating) return;
if (!accessToken || !collection || !newCardTitle.trim() || isCreating) return;
isCreating = true;
try {
@ -259,8 +260,8 @@
// =========================================================================
function formatTime(node: Node): string {
if (!node.createdAt?.microsSinceUnixEpoch) return '';
const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n);
if (!node.createdAt) return '';
const ms = Math.floor(node.createdAt / 1000);
const date = new Date(ms);
return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' });
}

View file

@ -45,7 +45,7 @@
} from '$lib/mixer';
interface Props {
collection: Node;
collection?: Node;
config: Record<string, unknown>;
accessToken?: string;
}
@ -90,6 +90,7 @@
// Derive room_id from the collection's communication node
// Pattern: "communication_{communication_node_id}"
const roomId = $derived.by(() => {
if (!collection) return null;
for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId);

View file

@ -6,7 +6,7 @@
import TraitPanel from './TraitPanel.svelte';
interface Props {
collection: Node;
collection?: Node;
config: Record<string, unknown>;
userId?: string;
accessToken?: string;
@ -17,6 +17,7 @@
/** Media nodes (episodes) belonging to this collection */
const episodes = $derived.by(() => {
const nodes: Node[] = [];
if (!collection) return nodes;
for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId);
@ -31,8 +32,8 @@
}
}
nodes.sort((a, b) => {
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n;
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n;
const ta = a.createdAt ?? 0;
const tb = b.createdAt ?? 0;
return tb > ta ? 1 : tb < ta ? -1 : 0;
});
return nodes;

View file

@ -3,7 +3,7 @@
import TraitPanel from './TraitPanel.svelte';
interface Props {
collection: Node;
collection?: Node;
config: Record<string, unknown>;
}
@ -45,14 +45,14 @@
<div class="mt-4 flex flex-wrap gap-2">
{#if requireApproval}
<a
href="/editorial/{collection.id}"
href="/editorial/{collection?.id ?? ''}"
class="inline-flex items-center gap-1 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
>
Redaksjonell arbeidsflate
</a>
{/if}
<a
href="/collection/{collection.id}/forside"
href="/collection/{collection?.id ?? ''}/forside"
class="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"
>
Rediger forside

View file

@ -20,14 +20,14 @@
} from '$lib/livekit';
interface Props {
collection: Node;
collection?: Node;
config: Record<string, unknown>;
accessToken?: string;
}
let { collection, config, accessToken }: Props = $props();
let status: RoomStatus = $state('disconnected');
let status = $state<RoomStatus>('disconnected');
let participants: LiveKitParticipant[] = $state([]);
let localIdentity: string = $state('');
let error: string | null = $state(null);
@ -55,6 +55,7 @@
/** Find communication nodes linked to this collection */
const communicationNodes = $derived.by(() => {
const nodes: Node[] = [];
if (!collection) return nodes;
for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId);

View file

@ -3,7 +3,7 @@
import TraitPanel from './TraitPanel.svelte';
interface Props {
collection: Node;
collection?: Node;
config: Record<string, unknown>;
}
@ -14,6 +14,7 @@
/** Build the feed URL from publishing slug if available */
const feedUrl = $derived.by(() => {
if (!collection) return '';
try {
const meta = JSON.parse(collection.metadata ?? '{}');
const slug = meta.traits?.publishing?.slug;

View file

@ -28,7 +28,7 @@
}
interface Props {
collection: Node;
collection?: Node;
accessToken?: string;
isViewer?: boolean;
}
@ -56,7 +56,7 @@
// Parse pad config from collection metadata
$effect(() => {
const meta = collection.metadata ? JSON.parse(collection.metadata) : {};
const meta = collection?.metadata ? JSON.parse(collection.metadata) : {};
const mixerMeta = meta?.mixer ?? {};
const rawPads: PadConfig[] = mixerMeta?.pads ?? [];
@ -212,10 +212,10 @@
}
async function savePadConfigs(configs: PadConfig[]) {
if (!accessToken) return;
if (!accessToken || !collection) return;
// Filter out empty pads for clean storage
const padsToSave = configs.filter(p => p.cas_hash);
const meta = collection.metadata ? JSON.parse(collection.metadata) : {};
const meta = collection?.metadata ? JSON.parse(collection.metadata) : {};
const mixer = meta.mixer ?? {};
mixer.pads = padsToSave.length > 0 ? configs : undefined;
meta.mixer = mixer;

View file

@ -5,7 +5,7 @@
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
interface Props {
collection: Node;
collection?: Node;
config: Record<string, unknown>;
userId?: string;
accessToken?: string;
@ -41,6 +41,7 @@
const audioNodes = $derived.by(() => {
const nodes: Node[] = [];
if (!collection) return nodes;
for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId);
@ -55,8 +56,8 @@
}
}
nodes.sort((a, b) => {
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n;
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n;
const ta = a.createdAt ?? 0;
const tb = b.createdAt ?? 0;
return tb > ta ? 1 : tb < ta ? -1 : 0;
});
return nodes;
@ -121,8 +122,8 @@
}
function formatTime(node: Node): string {
if (!node.createdAt?.microsSinceUnixEpoch) return '';
const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n);
if (!node.createdAt) return '';
const ms = Math.floor(node.createdAt / 1000);
const date = new Date(ms);
return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' });
}

View file

@ -3,7 +3,7 @@
import TraitPanel from './TraitPanel.svelte';
interface Props {
collection: Node;
collection?: Node;
config: Record<string, unknown>;
}

View file

@ -53,8 +53,8 @@
// Sort by created_at ascending (oldest first, like a chat)
nodes.sort((a, b) => {
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n;
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n;
const ta = a.createdAt ?? 0;
const tb = b.createdAt ?? 0;
return ta > tb ? 1 : ta < tb ? -1 : 0;
});
@ -113,8 +113,8 @@
/** Format timestamp for display */
function formatTime(node: Node): string {
if (!node.createdAt?.microsSinceUnixEpoch) return '';
const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n);
if (!node.createdAt) return '';
const ms = Math.floor(node.createdAt / 1000);
const date = new Date(ms);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();

View file

@ -127,10 +127,13 @@
}
});
let saveTimeout: ReturnType<typeof setTimeout> | undefined;
// Reset when collection changes
$effect(() => {
const _id = collectionId;
layoutInitialized = false;
clearTimeout(saveTimeout);
});
/** Convert layout panels to CanvasObjects for the Canvas component */
@ -148,8 +151,6 @@
// Layout persistence
// =========================================================================
let saveTimeout: ReturnType<typeof setTimeout> | undefined;
/** Persist layout to user's edge metadata (debounced) */
function persistLayout() {
if (!accessToken || !userEdge) return;

View file

@ -56,7 +56,7 @@
feedback: string | null;
edgeId: string;
edgeMeta: Record<string, unknown>;
createdAt: string;
createdAt: number;
discussionIds: string[];
}
@ -122,7 +122,7 @@
feedback: (meta.feedback as string) ?? null,
edgeId: edge.id,
edgeMeta: meta,
createdAt: node.createdAt ?? '',
createdAt: node.createdAt ?? 0,
discussionIds
});
}
@ -143,9 +143,9 @@
}
// Sort each column: newest first for pending, oldest first for others
grouped.pending.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
grouped.pending.sort((a, b) => b.createdAt - a.createdAt);
for (const col of ['in_review', 'approved', 'scheduled'] as Column[]) {
grouped[col].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
grouped[col].sort((a, b) => a.createdAt - b.createdAt);
}
return grouped;
@ -202,12 +202,13 @@
try {
// Map column back to actual status for the edge
const newStatus = targetColumn === 'scheduled' ? 'approved' : targetColumn;
// Note: 'scheduled' case handled above with early return + dialog
const newStatus = targetColumn;
const newMeta: Record<string, unknown> = { ...card.edgeMeta, status: newStatus };
// If moving away from scheduled, remove publish_at
if (card.status === 'scheduled' && targetColumn !== 'scheduled') {
if (card.status === 'scheduled') {
delete newMeta.publish_at;
}

View file

@ -74,7 +74,7 @@
// Version history: processed nodes derived from this media node
const versions = $derived.by(() => {
if (!connected || !mediaNodeId) return [];
const nodes: { id: string; title: string; createdAt: bigint }[] = [];
const nodes: { id: string; title: string; createdAt: number }[] = [];
for (const edge of edgeStore.byTarget(mediaNodeId)) {
if (edge.edgeType !== 'derived_from') continue;
const node = nodeStore.get(edge.sourceId);
@ -82,7 +82,7 @@
nodes.push({
id: node.id,
title: node.title ?? 'Prosessert',
createdAt: node.createdAt?.microsSinceUnixEpoch ?? 0n,
createdAt: node.createdAt ?? 0,
});
}
}

View file

@ -303,8 +303,7 @@ med spesifikasjon for det som trenger en dedikert sesjon.
- [x] 23.6 Valider fase 1314 (traits + publisering): trait-validering, pakkevelger, Tera-templates, HTML-rendering, forside, slot-håndtering, redaksjonell flyt, planlagt publisering, A/B-testing.
- [x] 23.7 Valider fase 1516 (admin + lydmixer): systemvarsler, graceful shutdown, jobbkø-oversikt, ressursstyring, serverhelse, Web Audio mixer, delt kontroll, sound pads, EQ, stemmeeffekter.
- [x] 23.8 Valider fase 1718 (lydstudio-utbedring + AI-verktøy): responsivt layout, FFmpeg-validering, fade/silence, AI-presets, direction-logikk, drag-and-drop integrasjon.
- [~] 23.9 Valider fase 1920 (arbeidsflaten + universell overføring): canvas pan/zoom, BlockShell, layout-persistering, snarveier, transfer service, alle panelreworks (chat, kanban, kalender, editor, studio).
> Påbegynt: 2026-03-18T15:50
- [x] 23.9 Valider fase 1920 (arbeidsflaten + universell overføring): canvas pan/zoom, BlockShell, layout-persistering, snarveier, transfer service, alle panelreworks (chat, kanban, kalender, editor, studio).
- [ ] 23.10 Valider fase 21 (CLI-verktøy): kjør hvert synops-*-verktøy, verifiser --help, --payload-json, output-format, feilhåndtering, synops-common integrasjon.
- [ ] 23.11 Valider fase 22 (STDB-migrering): WebSocket-sanntid fungerer, PG LISTEN/NOTIFY-triggere, ingen STDB-rester i aktiv kode/konfig.