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:
parent
68a00638f2
commit
4b8ce53777
4 changed files with 461 additions and 1 deletions
162
frontend/src/lib/components/traits/FlowMeter.svelte
Normal file
162
frontend/src/lib/components/traits/FlowMeter.svelte
Normal 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 (0–30%) → gul (30–70%) → grønn (70–100%).
|
||||
* 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>
|
||||
292
frontend/src/lib/components/traits/StoryboardTrait.svelte
Normal file
292
frontend/src/lib/components/traits/StoryboardTrait.svelte
Normal 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>
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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]} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue