Flow Meter: varighetsindikator for storyboard

Ny FlowMeter-komponent som viser episodeprogresjon som en
fargekodet linje (rød→gul→grønn) med pulsering nær mål.
StoryboardTrait viser Flow Meter øverst og kort gruppert
etter status (Klar, Tatt opp, Droppet).

Ref: docs/proposals/flow_meter.md
This commit is contained in:
vegard 2026-03-19 22:09:20 +00:00
parent 68a00638f2
commit 4b8ce53777
4 changed files with 461 additions and 1 deletions

View file

@ -0,0 +1,162 @@
<script lang="ts">
/**
* FlowMeter — Visuell varighetsindikator for storyboard.
*
* Tynn progresjonslinje som fylles basert på opptakstid vs. målvarighet.
* Fargeovergang: rød (030%) → gul (3070%) → grønn (70100%).
* Pulserer sakte når man nærmer seg mål (>85%).
*
* Ref: docs/proposals/flow_meter.md
*/
interface Props {
/** Målvarighet i minutter (f.eks. 45) */
targetMinutes: number;
/** Registrert/opptatt varighet i minutter */
recordedMinutes: number;
}
let { targetMinutes, recordedMinutes }: Props = $props();
const progress = $derived(targetMinutes > 0 ? Math.min(recordedMinutes / targetMinutes, 1.5) : 0);
const percent = $derived(Math.min(progress * 100, 100));
const nearGoal = $derived(progress >= 0.85 && progress < 1.0);
const overGoal = $derived(progress >= 1.0);
/** Farge basert på progresjon: rød → gul → grønn */
const barColor = $derived.by(() => {
if (progress < 0.3) return '#ef4444'; // rød
if (progress < 0.7) return '#eab308'; // gul
return '#22c55e'; // grønn
});
function formatMinutes(mins: number): string {
const m = Math.floor(mins);
const s = Math.round((mins - m) * 60);
return s > 0 ? `${m}:${s.toString().padStart(2, '0')}` : `${m} min`;
}
</script>
<div class="flow-meter" class:flow-meter--pulse={nearGoal} class:flow-meter--over={overGoal}>
<div class="flow-meter__track">
<div
class="flow-meter__fill"
style="width: {percent}%; background: {barColor};"
></div>
</div>
<div class="flow-meter__label">
<span class="flow-meter__time">{formatMinutes(recordedMinutes)}</span>
<span class="flow-meter__separator">/</span>
<span class="flow-meter__target">{formatMinutes(targetMinutes)}</span>
{#if overGoal}
<span class="flow-meter__badge flow-meter__badge--over">Over mål</span>
{:else if percent >= 70}
<span class="flow-meter__badge flow-meter__badge--good">Solid</span>
{:else if percent >= 30}
<span class="flow-meter__badge flow-meter__badge--mid">Trenger mer</span>
{:else}
<span class="flow-meter__badge flow-meter__badge--low">For kort</span>
{/if}
</div>
</div>
<style>
.flow-meter {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
flex-shrink: 0;
}
.flow-meter__track {
height: 6px;
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
overflow: hidden;
}
.flow-meter__fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease, background 0.4s ease;
}
.flow-meter--pulse .flow-meter__fill {
animation: flow-pulse 2s ease-in-out infinite;
}
.flow-meter--over .flow-meter__fill {
background: #22c55e !important;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4);
}
@keyframes flow-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.flow-meter__label {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #8a8a96;
}
.flow-meter__time {
font-weight: 600;
color: #c4c4cc;
font-variant-numeric: tabular-nums;
}
.flow-meter__separator {
color: #4a4a54;
}
.flow-meter__target {
font-variant-numeric: tabular-nums;
}
.flow-meter__badge {
margin-left: auto;
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: 9999px;
}
.flow-meter__badge--low {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.flow-meter__badge--mid {
background: rgba(234, 179, 8, 0.15);
color: #facc15;
}
.flow-meter__badge--good {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.flow-meter__badge--over {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
/* Responsive: tighter on small panels */
@container (max-width: 300px) {
.flow-meter {
padding: 6px 8px;
}
.flow-meter__label {
font-size: 10px;
}
.flow-meter__badge {
font-size: 9px;
}
}
</style>

View file

@ -0,0 +1,292 @@
<script lang="ts">
/**
* StoryboardTrait — Storyboard-panel med Flow Meter.
*
* Viser en varighetsindikator (Flow Meter) øverst basert på kort
* med status "tatt_opp" som tilhører samlingen. Kort med
* metadata.duration_seconds bidrar til total opptakstid.
*
* Konfigurasjon via trait-config:
* { target_duration_minutes: 45 }
*
* Ref: docs/proposals/flow_meter.md, docs/proposals/storyboard.md
*/
import type { Node, Edge } from '$lib/realtime';
import { edgeStore, nodeStore, nodeVisibility } from '$lib/realtime';
import FlowMeter from './FlowMeter.svelte';
interface Props {
collection?: Node;
config: Record<string, unknown>;
userId?: string;
accessToken?: string;
}
let { collection, config, userId, accessToken }: Props = $props();
/** Målvarighet fra trait-config, default 45 minutter */
const targetMinutes = $derived(
typeof config.target_duration_minutes === 'number'
? config.target_duration_minutes
: 45
);
/** Alle kort som tilhører samlingen */
interface CardData {
node: Node;
status: string;
durationSeconds: number;
}
const cards = $derived.by((): CardData[] => {
if (!collection?.id) return [];
const result: CardData[] = [];
for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId);
if (!node || nodeVisibility(node, userId) === 'hidden') continue;
let status = 'klar';
for (const e of edgeStore.bySource(node.id)) {
if (e.edgeType === 'status' && e.targetId === collection.id) {
try {
const meta = JSON.parse(e.metadata ?? '{}');
if (meta.value) status = meta.value;
} catch { /* ignore */ }
break;
}
}
let durationSeconds = 0;
try {
const meta = JSON.parse(node.metadata ?? '{}');
if (typeof meta.duration_seconds === 'number') {
durationSeconds = meta.duration_seconds;
}
} catch { /* ignore */ }
result.push({ node, status, durationSeconds });
}
return result;
});
const cardsByStatus = $derived.by(() => {
const grouped: Record<string, CardData[]> = {
klar: [],
tatt_opp: [],
droppet: [],
};
for (const card of cards) {
const key = grouped[card.status] ? card.status : 'klar';
grouped[key].push(card);
}
return grouped;
});
/** Summert opptakstid for "Tatt opp"-kort, i minutter */
const recordedMinutes = $derived(
(cardsByStatus.tatt_opp ?? []).reduce((sum, c) => sum + c.durationSeconds, 0) / 60
);
const statusLabels: Record<string, string> = {
klar: 'Klar',
tatt_opp: 'Tatt opp',
droppet: 'Droppet',
};
const statusIcons: Record<string, string> = {
klar: '',
tatt_opp: '',
droppet: '',
};
</script>
<div class="storyboard-trait">
<!-- Flow Meter — varighetsindikator -->
<FlowMeter {targetMinutes} {recordedMinutes} />
<!-- Kortliste gruppert etter status -->
<div class="storyboard-cards">
{#each ['tatt_opp', 'klar', 'droppet'] as status (status)}
{@const group = cardsByStatus[status] ?? []}
{#if group.length > 0}
<div class="storyboard-group">
<div class="storyboard-group-header">
<span class="storyboard-status-icon">{statusIcons[status]}</span>
<span class="storyboard-status-label">{statusLabels[status]}</span>
<span class="storyboard-status-count">{group.length}</span>
</div>
{#each group as card (card.node.id)}
<div class="storyboard-card storyboard-card--{status}">
<span class="storyboard-card-title">{card.node.title || 'Uten tittel'}</span>
{#if card.durationSeconds > 0}
<span class="storyboard-card-duration">
{Math.floor(card.durationSeconds / 60)}:{(card.durationSeconds % 60).toString().padStart(2, '0')}
</span>
{/if}
</div>
{/each}
</div>
{/if}
{/each}
{#if cards.length === 0}
<div class="storyboard-empty">
<p>Ingen kort ennå.</p>
<p class="storyboard-empty-hint">
Legg til kort med status for å se episodeprogresjonen i Flow Meter.
</p>
</div>
{/if}
</div>
</div>
<style>
.storyboard-trait {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
/* ================================================================= */
/* Card list */
/* ================================================================= */
.storyboard-cards {
flex: 1;
overflow-y: auto;
padding: 0 8px 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.storyboard-group {
display: flex;
flex-direction: column;
gap: 3px;
}
.storyboard-group-header {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
font-size: 11px;
color: #8a8a96;
}
.storyboard-status-icon {
font-size: 12px;
}
.storyboard-status-label {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
font-size: 10px;
}
.storyboard-status-count {
margin-left: auto;
font-size: 10px;
color: #5a5a66;
background: rgba(255, 255, 255, 0.06);
border-radius: 9999px;
padding: 0 5px;
line-height: 16px;
}
/* ================================================================= */
/* Card */
/* ================================================================= */
.storyboard-card {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
border-left: 3px solid transparent;
transition: background 0.1s;
}
.storyboard-card:hover {
background: rgba(255, 255, 255, 0.07);
}
.storyboard-card--klar {
border-left-color: rgba(255, 255, 255, 0.2);
}
.storyboard-card--tatt_opp {
border-left-color: #22c55e;
}
.storyboard-card--droppet {
border-left-color: #ef4444;
opacity: 0.5;
}
.storyboard-card-title {
flex: 1;
font-size: 12px;
color: #c4c4cc;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.storyboard-card-duration {
font-size: 10px;
font-variant-numeric: tabular-nums;
color: #8a8a96;
flex-shrink: 0;
}
/* ================================================================= */
/* Empty state */
/* ================================================================= */
.storyboard-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 24px;
text-align: center;
color: #5a5a66;
font-size: 13px;
gap: 4px;
}
.storyboard-empty-hint {
font-size: 11px;
color: #4a4a54;
}
/* ================================================================= */
/* Responsive */
/* ================================================================= */
@container (max-width: 300px) {
.storyboard-card {
padding: 4px 6px;
}
.storyboard-card-title {
font-size: 11px;
}
}
@media (max-width: 480px) {
.storyboard-cards {
padding: 0 6px 6px;
}
}
</style>

View file

@ -58,6 +58,7 @@ export const TRAIT_PANEL_INFO: Record<string, TraitPanelInfo> = {
mindmap: { title: 'Tankekart', icon: '🧠', defaultWidth: 600, defaultHeight: 500 },
ai: { title: 'AI-verktøy', icon: '🤖', defaultWidth: 420, defaultHeight: 500 },
usage: { title: 'Ressursforbruk', icon: '📊', defaultWidth: 380, defaultHeight: 350 },
storyboard: { title: 'Storyboard', icon: '🎬', defaultWidth: 500, defaultHeight: 450 },
};
/** Default info for unknown traits */

View file

@ -36,6 +36,7 @@
import MixerTrait from '$lib/components/traits/MixerTrait.svelte';
import OrchestrationTrait from '$lib/components/traits/OrchestrationTrait.svelte';
import MindMapTrait from '$lib/components/traits/MindMapTrait.svelte';
import StoryboardTrait from '$lib/components/traits/StoryboardTrait.svelte';
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte';
@ -70,7 +71,7 @@
/** Traits with dedicated components */
const knownTraits = new Set([
'editor', 'chat', 'kanban', 'podcast', 'publishing',
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer', 'orchestration', 'mindmap'
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer', 'orchestration', 'mindmap', 'storyboard'
]);
/** Count of child nodes */
@ -365,6 +366,8 @@
<OrchestrationTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'mindmap'}
<MindMapTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'storyboard'}
<StoryboardTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{/if}
{:else}
<GenericTrait name={trait} config={traits[trait]} />
@ -424,6 +427,8 @@
<OrchestrationTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'mindmap'}
<MindMapTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'storyboard'}
<StoryboardTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{/if}
{:else}
<GenericTrait name={trait} config={traits[trait]} />