🔥 refactor(calendar): remove Network View feature

Remove Network View (D3 event graph visualization) to reduce complexity:
- Delete NetworkView.svelte component (~416 LOC)
- Delete network.svelte.ts store (~371 LOC)
- Delete network.ts API client (~47 LOC)
- Delete view-mode.svelte.ts store (~76 LOC)
- Remove network tab from view switcher
- Simplify view switching logic

Also adds voice recording feature (separate work, included to fix build):
- Add VoiceRecordButton and VoiceRecordingModal components
- Add voice-recording store and audio utilities
- Integrate voice input into calendar layout

Add CLEANUP_PLAN.md documenting planned simplifications.

Total removal: ~910 LOC
This commit is contained in:
Till-JS 2026-01-28 13:31:13 +01:00
parent 19199290f5
commit 9a93ca0c89
12 changed files with 1731 additions and 1038 deletions

View file

@ -1,47 +0,0 @@
/**
* Network Graph API Client
*/
import { fetchApi } from './client';
export interface NetworkTag {
id: string;
name: string;
color: string | null;
}
export interface NetworkNode {
id: string;
name: string;
photoUrl: string | null;
company: string | null;
isFavorite: boolean;
tags: NetworkTag[];
connectionCount: number;
}
export interface NetworkLink {
source: string;
target: string;
type: 'tag' | 'calendar' | 'date' | 'location';
strength: number;
sharedTags: string[];
}
export interface NetworkGraphResponse {
nodes: NetworkNode[];
links: NetworkLink[];
}
export const networkApi = {
/**
* Get the network graph of events connected by shared tags
*/
async getGraph(): Promise<NetworkGraphResponse> {
const result = await fetchApi<NetworkGraphResponse>('/network/graph');
if (result.error) {
throw result.error;
}
return result.data || { nodes: [], links: [] };
},
};

View file

@ -1,416 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { networkStore, type SimulationNode } from '$lib/stores/network.svelte';
import { NetworkGraph, NetworkControls } from '@manacore/shared-ui';
import '$lib/i18n';
let graphComponent = $state<NetworkGraph | null>(null);
let controlsComponent: NetworkControls;
let graphContainer: HTMLDivElement;
function handleNodeClick(node: SimulationNode) {
networkStore.selectNode(node.id);
}
function handleNodeDoubleClick(node: SimulationNode) {
goto(`/event/${node.id}`);
}
function handleBackgroundClick() {
networkStore.selectNode(null);
}
function handleDragStart(node: SimulationNode) {
networkStore.fixNode(node.id, node.x ?? 0, node.y ?? 0);
networkStore.reheatSimulation();
}
function handleDrag(node: SimulationNode, x: number, y: number) {
networkStore.fixNode(node.id, x, y);
}
function handleDragEnd(node: SimulationNode) {
networkStore.releaseNode(node.id);
}
function handleZoomIn() {
graphComponent?.zoomIn();
}
function handleZoomOut() {
graphComponent?.zoomOut();
}
function handleResetZoom() {
graphComponent?.resetZoom();
}
function handleFocusSelected() {
graphComponent?.focusOnSelectedNode();
}
function handleFocusSearch() {
controlsComponent?.focusSearch();
}
function handleSearch(query: string) {
networkStore.setSearch(query);
}
function handleTagFilter(tagId: string | null) {
networkStore.setFilterTag(tagId);
}
function handleSubtitleFilter(location: string | null) {
networkStore.setFilterLocation(location);
}
function handleStrengthFilter(strength: number) {
networkStore.setMinStrength(strength);
}
function handleClearFilters() {
networkStore.clearFilters();
}
// Initialize simulation when data is loaded and container is ready
$effect(() => {
if (!networkStore.loading && networkStore.allNodes.length > 0 && graphContainer) {
const rect = graphContainer.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
networkStore.initSimulation(rect.width, rect.height);
}
}
});
onMount(() => {
networkStore.loadGraph();
});
onDestroy(() => {
networkStore.stopSimulation();
});
</script>
<div class="network-view">
<!-- Controls (floating) -->
<div class="controls-wrapper">
<NetworkControls
bind:this={controlsComponent}
searchQuery={networkStore.searchQuery}
tags={networkStore.uniqueTags}
selectedTagId={networkStore.filterTagId}
subtitles={networkStore.uniqueLocations}
selectedSubtitle={networkStore.filterLocation}
subtitleLabel="Ort"
nodeCount={networkStore.nodes.length}
linkCount={networkStore.links.length}
nodeLabel="Events"
linkLabel="Verbindungen"
searchPlaceholder="Event suchen..."
minStrength={networkStore.minStrength}
onSearch={handleSearch}
onTagFilter={handleTagFilter}
onSubtitleFilter={handleSubtitleFilter}
onStrengthFilter={handleStrengthFilter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onResetZoom={handleResetZoom}
onFocusSelected={handleFocusSelected}
onClearFilters={handleClearFilters}
/>
</div>
<!-- Error Banner -->
{#if networkStore.error}
<div class="error-banner" role="alert">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{networkStore.error}</span>
</div>
{/if}
<!-- Main Content -->
<div class="graph-container" bind:this={graphContainer}>
{#if networkStore.loading}
<div class="loading-container">
<div class="loading-spinner"></div>
<p>Lade Netzwerk-Graph...</p>
</div>
{:else}
<NetworkGraph
bind:this={graphComponent}
nodes={networkStore.nodes}
links={networkStore.links}
selectedNodeId={networkStore.selectedNodeId}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onBackgroundClick={handleBackgroundClick}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
onFocusSearch={handleFocusSearch}
/>
{/if}
</div>
<!-- Selected Event Info Panel -->
{#if networkStore.selectedNode}
<div class="info-panel">
<div class="info-header">
<h3>{networkStore.selectedNode.name}</h3>
<button
class="close-btn"
onclick={() => networkStore.selectNode(null)}
aria-label="Schlie\u00dfen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{#if networkStore.selectedNode.subtitle}
<p class="info-subtitle">{networkStore.selectedNode.subtitle}</p>
{/if}
{#if networkStore.selectedNode.tags.length > 0}
<div class="info-tags">
{#each networkStore.selectedNode.tags as tag}
<span
class="tag"
style="background-color: {tag.color || 'hsl(var(--muted))'}; color: white;"
>
{tag.name}
</span>
{/each}
</div>
{/if}
<div class="info-stats">
<span>{networkStore.selectedNode.connectionCount} Verbindungen</span>
</div>
<button class="view-btn" onclick={() => goto(`/event/${networkStore.selectedNode?.id}`)}>
Event anzeigen
</button>
</div>
{/if}
</div>
<style>
.network-view {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: hsl(var(--color-background));
border-radius: var(--radius-lg);
overflow: hidden;
}
/* Floating Controls */
.controls-wrapper {
position: absolute;
top: 1rem;
left: 1rem;
z-index: 10;
max-width: calc(100% - 2rem);
}
/* Error Banner */
.error-banner {
position: absolute;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.3);
border-radius: 0.875rem;
color: hsl(var(--destructive));
backdrop-filter: blur(8px);
}
/* Graph Container - Full size within parent */
.graph-container {
flex: 1;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
/* Loading */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 1rem;
color: hsl(var(--muted-foreground));
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid hsl(var(--muted));
border-top-color: hsl(var(--primary));
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Info Panel */
.info-panel {
position: absolute;
top: 1rem;
right: 1rem;
bottom: 1rem;
width: 320px;
max-width: calc(100vw - 2rem);
z-index: 50;
background: hsl(var(--card) / 0.9);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 1rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
animation: slideInRight 0.2s ease-out;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.info-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.info-header h3 {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
.close-btn {
padding: 0.25rem;
border-radius: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
color: hsl(var(--muted-foreground));
transition: all 0.15s;
}
.close-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.info-subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0;
}
.info-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.info-stats {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
}
.view-btn {
margin-top: auto;
padding: 0.75rem 1rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.view-btn:hover {
opacity: 0.9;
}
/* Responsive */
@media (max-width: 1024px) {
.info-panel {
width: 100%;
max-width: 100%;
top: auto;
right: 0;
bottom: 0;
height: auto;
max-height: 50%;
border-radius: 1rem 1rem 0 0;
animation: slideInUp 0.2s ease-out;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
@media (max-width: 768px) {
.controls-wrapper {
width: calc(100% - 2rem);
max-width: none;
}
}
</style>

View file

@ -0,0 +1,235 @@
<script lang="ts">
import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte';
interface Props {
/** Called when voice recording completes with transcription */
onResult?: (text: string) => void;
/** Called when recording starts */
onRecordingStart?: () => void;
/** Size of the button in pixels */
size?: number;
}
let { onResult, onRecordingStart, size = 32 }: Props = $props();
// Reactive state from store
let isRecording = $derived(voiceRecordingStore.isRecording);
let isProcessing = $derived(voiceRecordingStore.isProcessing);
let isSupported = $derived(voiceRecordingStore.isSupported);
let hasError = $derived(voiceRecordingStore.hasError);
let errorMessage = $derived(voiceRecordingStore.error?.message || '');
// Handle click
async function handleClick() {
if (!isSupported) {
return;
}
if (isRecording) {
// Stop recording
await voiceRecordingStore.stopRecording();
} else if (!isProcessing) {
// Start recording
onRecordingStart?.();
await voiceRecordingStore.startRecording();
}
}
// Set up result callback
$effect(() => {
voiceRecordingStore.setOnResult((result) => {
onResult?.(result.text);
});
});
// Clear error after 5 seconds
$effect(() => {
if (hasError) {
const timeout = setTimeout(() => {
voiceRecordingStore.clearError();
}, 5000);
return () => clearTimeout(timeout);
}
});
</script>
{#if isSupported}
<button
type="button"
class="voice-btn"
class:recording={isRecording}
class:processing={isProcessing}
class:error={hasError}
onclick={handleClick}
disabled={isProcessing}
title={hasError
? errorMessage
: isRecording
? 'Aufnahme stoppen'
: isProcessing
? 'Verarbeite...'
: 'Spracheingabe'}
style="--btn-size: {size}px"
>
{#if isProcessing}
<!-- Spinner -->
<div class="spinner"></div>
{:else if isRecording}
<!-- Pulsing red dot -->
<div class="recording-dot"></div>
{:else}
<!-- Microphone icon -->
<svg
class="mic-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
/>
</svg>
{/if}
</button>
<!-- Error tooltip -->
{#if hasError && errorMessage}
<div class="error-tooltip" role="alert">
{errorMessage}
</div>
{/if}
{/if}
<style>
.voice-btn {
position: relative;
width: var(--btn-size, 32px);
height: var(--btn-size, 32px);
border-radius: 50%;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
flex-shrink: 0;
}
.voice-btn:hover:not(:disabled) {
background: hsl(var(--color-muted) / 0.5);
color: hsl(var(--color-foreground));
}
.voice-btn:disabled {
cursor: not-allowed;
opacity: 0.7;
}
/* Recording state */
.voice-btn.recording {
background: hsl(var(--color-error) / 0.1);
color: hsl(var(--color-error));
}
.voice-btn.recording:hover {
background: hsl(var(--color-error) / 0.2);
}
/* Processing state */
.voice-btn.processing {
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
/* Error state */
.voice-btn.error {
color: hsl(var(--color-error));
}
/* Microphone icon */
.mic-icon {
width: 60%;
height: 60%;
}
/* Recording dot with pulse animation */
.recording-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: hsl(var(--color-error));
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.7;
}
}
/* Spinner */
.spinner {
width: 16px;
height: 16px;
border: 2px solid hsl(var(--color-primary) / 0.3);
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Error tooltip */
.error-tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: hsl(var(--color-error));
color: hsl(var(--color-error-foreground, 0 0% 100%));
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md, 6px);
font-size: 0.75rem;
white-space: nowrap;
z-index: 100;
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.15);
animation: fadeIn 0.15s ease;
}
.error-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: hsl(var(--color-error));
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
</style>

View file

@ -0,0 +1,363 @@
<script lang="ts">
import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte';
import { fade, scale } from 'svelte/transition';
interface Props {
/** Called when recording completes with transcription */
onResult?: (text: string) => void;
/** Called when modal is closed (via cancel or completion) */
onClose?: () => void;
}
let { onResult, onClose }: Props = $props();
// Reactive state from store
let isRecording = $derived(voiceRecordingStore.isRecording);
let isProcessing = $derived(voiceRecordingStore.isProcessing);
let isRequesting = $derived(voiceRecordingStore.state === 'requesting');
let hasError = $derived(voiceRecordingStore.hasError);
let errorMessage = $derived(voiceRecordingStore.error?.message || '');
let formattedDuration = $derived(voiceRecordingStore.formattedDuration);
let duration = $derived(voiceRecordingStore.duration);
// Warning when approaching max duration (50 seconds = 50000ms)
let showWarning = $derived(duration >= 50000 && isRecording);
// Show modal when any voice activity is happening
let isVisible = $derived(isRecording || isProcessing || isRequesting || hasError);
// Handle stop
async function handleStop() {
await voiceRecordingStore.stopRecording();
}
// Handle cancel
function handleCancel() {
voiceRecordingStore.cancel();
onClose?.();
}
// Set up result callback
$effect(() => {
voiceRecordingStore.setOnResult((result) => {
onResult?.(result.text);
onClose?.();
});
});
// Handle keyboard
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
handleCancel();
} else if (event.key === 'Enter' && isRecording) {
handleStop();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isVisible}
<!-- Backdrop -->
<div class="backdrop" transition:fade={{ duration: 150 }} onclick={handleCancel}></div>
<!-- Modal -->
<div
class="modal"
transition:scale={{ duration: 150, start: 0.9 }}
role="dialog"
aria-modal="true"
aria-label="Sprachaufnahme"
>
{#if isRequesting}
<!-- Requesting permission state -->
<div class="modal-content">
<div class="spinner large"></div>
<p class="status-text">Mikrofonzugriff wird angefordert...</p>
</div>
{:else if isProcessing}
<!-- Processing/transcribing state -->
<div class="modal-content">
<div class="spinner large"></div>
<p class="status-text">Verarbeite...</p>
</div>
{:else if hasError}
<!-- Error state -->
<div class="modal-content error">
<div class="error-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<p class="error-message">{errorMessage}</p>
<button class="btn btn-primary" onclick={handleCancel}>Schließen</button>
</div>
{:else if isRecording}
<!-- Recording state -->
<div class="modal-content recording">
<!-- Recording indicator -->
<div class="recording-indicator">
<div class="recording-dot-large"></div>
<span class="recording-label">Aufnahme</span>
</div>
<!-- Timer -->
<div class="timer" class:warning={showWarning}>
{formattedDuration}
</div>
{#if showWarning}
<p class="warning-text">Max. 60 Sekunden</p>
{/if}
<!-- Controls -->
<div class="controls">
<button class="btn btn-icon btn-cancel" onclick={handleCancel} title="Abbrechen (Esc)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<button
class="btn btn-icon btn-stop"
onclick={handleStop}
title="Aufnahme beenden (Enter)"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</button>
</div>
<p class="hint-text">Sprechen Sie Ihren Termin...</p>
</div>
{/if}
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 201;
background: hsl(var(--color-surface));
border-radius: 1.5rem;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px hsl(var(--color-border));
padding: 2rem;
min-width: 280px;
max-width: 90vw;
}
.modal-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
/* Recording indicator */
.recording-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: hsl(var(--color-error) / 0.1);
border-radius: 9999px;
}
.recording-dot-large {
width: 10px;
height: 10px;
border-radius: 50%;
background: hsl(var(--color-error));
animation: pulse 1s ease-in-out infinite;
}
.recording-label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-error));
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Timer */
.timer {
font-size: 3rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
line-height: 1;
}
.timer.warning {
color: hsl(var(--color-warning));
animation: blink 1s ease-in-out infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.warning-text {
font-size: 0.75rem;
color: hsl(var(--color-warning));
font-weight: 500;
}
/* Controls */
.controls {
display: flex;
gap: 1.5rem;
margin-top: 1rem;
}
.btn {
border: none;
cursor: pointer;
font-weight: 500;
transition: all 0.15s ease;
}
.btn-icon {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon svg {
width: 24px;
height: 24px;
}
.btn-cancel {
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
}
.btn-cancel:hover {
background: hsl(var(--color-muted) / 0.8);
color: hsl(var(--color-foreground));
}
.btn-stop {
background: hsl(var(--color-success));
color: hsl(var(--color-success-foreground, 0 0% 100%));
}
.btn-stop:hover {
transform: scale(1.05);
filter: brightness(1.1);
}
.btn-primary {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
}
.btn-primary:hover {
filter: brightness(1.1);
}
.hint-text {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.5rem;
}
.status-text {
font-size: 1rem;
color: hsl(var(--color-muted-foreground));
}
/* Error state */
.modal-content.error {
gap: 1.25rem;
}
.error-icon {
width: 48px;
height: 48px;
color: hsl(var(--color-error));
}
.error-icon svg {
width: 100%;
height: 100%;
}
.error-message {
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
max-width: 250px;
}
/* Spinner */
.spinner {
border: 3px solid hsl(var(--color-border));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner.large {
width: 48px;
height: 48px;
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.7;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,141 @@
/**
* Speech-to-Text (STT) Service Client
*
* Communicates with the mana-stt service for audio transcription.
*/
import { browser } from '$app/environment';
/**
* STT service URL - defaults to localhost for development
*/
const STT_URL = browser
? import.meta.env.PUBLIC_STT_URL || 'http://localhost:3020'
: 'http://localhost:3020';
export interface TranscriptionResult {
/** The transcribed text */
text: string;
/** Detected or specified language */
language: string;
/** Model used for transcription */
model: string;
}
export interface TranscriptionError {
message: string;
code?: string;
}
export type TranscriptionResponse =
| { success: true; data: TranscriptionResult }
| { success: false; error: TranscriptionError };
/**
* Transcribe audio using the mana-stt service
*
* @param audioBlob - The audio blob to transcribe
* @param language - Optional language code ('de', 'en', etc.) or 'auto' for auto-detection
* @returns The transcription result or error
*/
export async function transcribeAudio(
audioBlob: Blob,
language?: string
): Promise<TranscriptionResponse> {
try {
const formData = new FormData();
// Determine file extension based on MIME type
const mimeType = audioBlob.type || 'audio/webm';
let extension = 'webm';
if (mimeType.includes('ogg')) extension = 'ogg';
else if (mimeType.includes('mp4')) extension = 'mp4';
else if (mimeType.includes('mpeg') || mimeType.includes('mp3')) extension = 'mp3';
formData.append('file', audioBlob, `recording.${extension}`);
// Add language parameter if specified (and not 'auto')
if (language && language !== 'auto') {
formData.append('language', language);
}
const response = await fetch(`${STT_URL}/transcribe`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
let errorMessage = 'Transcription failed';
try {
const errorData = await response.json();
errorMessage = errorData.error || errorData.message || errorMessage;
} catch {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
return {
success: false,
error: {
message: errorMessage,
code: `HTTP_${response.status}`,
},
};
}
const data = await response.json();
// Handle empty transcription
if (!data.text || data.text.trim() === '') {
return {
success: false,
error: {
message: 'Keine Sprache erkannt. Bitte erneut versuchen.',
code: 'EMPTY_TRANSCRIPTION',
},
};
}
return {
success: true,
data: {
text: data.text.trim(),
language: data.language || language || 'auto',
model: data.model || 'unknown',
},
};
} catch (error) {
// Handle network errors
if (error instanceof TypeError && error.message.includes('fetch')) {
return {
success: false,
error: {
message: 'Spracherkennung nicht verfügbar',
code: 'NETWORK_ERROR',
},
};
}
return {
success: false,
error: {
message: error instanceof Error ? error.message : 'Unknown error',
code: 'UNKNOWN_ERROR',
},
};
}
}
/**
* Check if the STT service is available
*/
export async function checkSttServiceHealth(): Promise<boolean> {
try {
const response = await fetch(`${STT_URL}/health`, {
method: 'GET',
signal: AbortSignal.timeout(5000), // 5 second timeout
});
return response.ok;
} catch {
return false;
}
}

View file

@ -1,371 +0,0 @@
/**
* Network Store - Manages network graph state with D3-force simulation
*/
import { browser } from '$app/environment';
import { networkApi } from '$lib/api/network';
import type { NetworkNode, NetworkLink } from '$lib/api/network';
import {
forceSimulation,
forceLink,
forceManyBody,
forceCenter,
forceCollide,
type Simulation,
} from 'd3-force';
import type {
SimulationNode as SharedSimulationNode,
SimulationLink as SharedSimulationLink,
} from '@manacore/shared-ui';
// Re-export types from shared-ui for convenience
export type SimulationNode = SharedSimulationNode;
export type SimulationLink = SharedSimulationLink;
// State
let nodes = $state<SimulationNode[]>([]);
let links = $state<SimulationLink[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let selectedNodeId = $state<string | null>(null);
let simulation: Simulation<SimulationNode, SimulationLink> | null = null;
let searchQuery = $state('');
let filterTagId = $state<string | null>(null);
let filterLocation = $state<string | null>(null);
let minStrength = $state(0);
let tickCounter = $state(0);
let simulationInitialized = false;
let dataLoaded = false;
let lastDimensions = { width: 0, height: 0 };
// Derived state for filtering
const filteredNodes = $derived.by(() => {
let result = nodes;
// Search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(
(node) =>
node.name.toLowerCase().includes(query) ||
node.subtitle?.toLowerCase().includes(query) ||
node.tags.some((t) => t.name.toLowerCase().includes(query))
);
}
// Tag filter
if (filterTagId) {
result = result.filter((node) => node.tags.some((t) => t.id === filterTagId));
}
// Location filter (uses subtitle field)
if (filterLocation) {
result = result.filter((node) => node.subtitle === filterLocation);
}
return result;
});
const filteredLinks = $derived.by(() => {
const filteredNodeIds = new Set(filteredNodes.map((n) => n.id));
return links.filter((link) => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
// Check if both nodes are visible
if (!filteredNodeIds.has(sourceId) || !filteredNodeIds.has(targetId)) {
return false;
}
// Filter by minimum strength
if (minStrength > 0 && link.strength < minStrength) {
return false;
}
return true;
});
});
// Get unique locations for filter dropdown
const uniqueLocations = $derived.by(() => {
const locations = new Set<string>();
for (const node of nodes) {
if (node.subtitle) {
locations.add(node.subtitle);
}
}
return Array.from(locations).sort();
});
// Get unique tags for filter dropdown
const uniqueTags = $derived.by(() => {
const tagsMap = new Map<string, { id: string; name: string; color: string | null }>();
for (const node of nodes) {
for (const tag of node.tags) {
if (!tagsMap.has(tag.id)) {
tagsMap.set(tag.id, tag);
}
}
}
return Array.from(tagsMap.values()).sort((a, b) => a.name.localeCompare(b.name));
});
export const networkStore = {
// Getters
get nodes() {
void tickCounter;
return filteredNodes;
},
get allNodes() {
void tickCounter;
return nodes;
},
get links() {
void tickCounter;
return filteredLinks;
},
get allLinks() {
void tickCounter;
return links;
},
get tick() {
return tickCounter;
},
get loading() {
return loading;
},
get error() {
return error;
},
get selectedNodeId() {
return selectedNodeId;
},
get selectedNode() {
return nodes.find((n) => n.id === selectedNodeId) || null;
},
get searchQuery() {
return searchQuery;
},
get filterTagId() {
return filterTagId;
},
get filterLocation() {
return filterLocation;
},
get minStrength() {
return minStrength;
},
get uniqueLocations() {
return uniqueLocations;
},
get uniqueTags() {
return uniqueTags;
},
/**
* Load network graph data from API
*/
async loadGraph(force = false) {
if (dataLoaded && !force) {
return;
}
if (loading) {
return;
}
loading = true;
error = null;
if (simulation) {
simulation.stop();
simulation = null;
}
simulationInitialized = false;
try {
const response = await networkApi.getGraph();
// Convert to simulation nodes with subtitle for location
nodes = response.nodes.map((node) => ({
...node,
subtitle: node.company, // Map company/location to subtitle
x: undefined,
y: undefined,
vx: undefined,
vy: undefined,
fx: null,
fy: null,
}));
// Convert to simulation links
// Cast type to be compatible with SimulationLink (calendar API has extended types)
links = response.links.map((link) => ({
source: link.source,
target: link.target,
type: link.type as SimulationLink['type'],
strength: link.strength,
sharedTags: link.sharedTags,
}));
dataLoaded = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load network graph';
console.error('Failed to load network graph:', e);
} finally {
loading = false;
}
},
/**
* Initialize D3 force simulation
*/
initSimulation(width: number, height: number) {
if (!browser) return;
if (nodes.length === 0) return;
if (width <= 0 || height <= 0) return;
if (simulationInitialized && simulation) {
if (
Math.abs(lastDimensions.width - width) > 50 ||
Math.abs(lastDimensions.height - height) > 50
) {
lastDimensions = { width, height };
this.updateSimulationCenter(width, height);
}
return;
}
if (simulation) {
simulation.stop();
}
lastDimensions = { width, height };
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) / 3;
nodes.forEach((node, i) => {
if (node.x === undefined || node.y === undefined) {
const angle = (i / nodes.length) * 2 * Math.PI;
const r = radius * (0.5 + Math.random() * 0.5);
node.x = centerX + r * Math.cos(angle);
node.y = centerY + r * Math.sin(angle);
}
});
simulation = forceSimulation<SimulationNode, SimulationLink>(nodes)
.force(
'link',
forceLink<SimulationNode, SimulationLink>(links)
.id((d) => d.id)
.distance(100)
.strength(0.5)
)
.force('charge', forceManyBody().strength(-300))
.force('center', forceCenter(centerX, centerY))
.force('collision', forceCollide().radius(50))
.on('tick', () => {
tickCounter++;
});
simulationInitialized = true;
simulation.alpha(1).restart();
},
updateSimulationCenter(width: number, height: number) {
if (simulation) {
simulation.force('center', forceCenter(width / 2, height / 2));
simulation.alpha(0.3).restart();
}
},
stopSimulation() {
if (simulation) {
simulation.stop();
simulation = null;
}
simulationInitialized = false;
},
reset() {
this.stopSimulation();
nodes = [];
links = [];
dataLoaded = false;
lastDimensions = { width: 0, height: 0 };
tickCounter = 0;
},
reheatSimulation() {
if (simulation) {
simulation.alpha(0.3).restart();
}
},
fixNode(nodeId: string, x: number, y: number) {
const node = nodes.find((n) => n.id === nodeId);
if (node) {
node.fx = x;
node.fy = y;
}
},
releaseNode(nodeId: string) {
const node = nodes.find((n) => n.id === nodeId);
if (node) {
node.fx = null;
node.fy = null;
}
},
selectNode(nodeId: string | null) {
selectedNodeId = nodeId;
},
setSearch(query: string) {
searchQuery = query;
},
setFilterTag(tagId: string | null) {
filterTagId = tagId;
},
setFilterLocation(location: string | null) {
filterLocation = location;
},
setMinStrength(strength: number) {
minStrength = strength;
},
clearFilters() {
searchQuery = '';
filterTagId = null;
filterLocation = null;
minStrength = 0;
},
getConnectedNodes(nodeId: string): SimulationNode[] {
const connectedIds = new Set<string>();
for (const link of links) {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
if (sourceId === nodeId) {
connectedIds.add(targetId);
} else if (targetId === nodeId) {
connectedIds.add(sourceId);
}
}
return nodes.filter((n) => connectedIds.has(n.id));
},
getNodeLinks(nodeId: string): SimulationLink[] {
return links.filter((link) => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
return sourceId === nodeId || targetId === nodeId;
});
},
};

View file

@ -1,76 +0,0 @@
/**
* View Mode Store - Manages app view mode (calendar vs network)
* Similar pattern to Contacts app view-mode store
*/
import { browser } from '$app/environment';
export type AppViewMode = 'calendar' | 'network';
const STORAGE_KEY = 'calendar-app-view-mode';
// Valid view modes
const VALID_MODES: AppViewMode[] = ['calendar', 'network'];
function isValidMode(mode: string | null): mode is AppViewMode {
return mode !== null && VALID_MODES.includes(mode as AppViewMode);
}
// Get initial mode from sessionStorage or default to 'calendar'
function getInitialMode(): AppViewMode {
if (!browser) return 'calendar';
const sessionMode = sessionStorage.getItem(STORAGE_KEY);
if (isValidMode(sessionMode)) {
return sessionMode;
}
return 'calendar';
}
let mode = $state<AppViewMode>(getInitialMode());
export const viewModeStore = {
get mode() {
return mode;
},
setMode(newMode: AppViewMode) {
mode = newMode;
if (browser) {
sessionStorage.setItem(STORAGE_KEY, newMode);
}
},
/**
* Toggle between calendar and network mode
*/
toggle() {
const newMode = mode === 'calendar' ? 'network' : 'calendar';
this.setMode(newMode);
},
/**
* Reset to default view (calendar)
*/
resetToDefault() {
mode = 'calendar';
if (browser) {
sessionStorage.removeItem(STORAGE_KEY);
}
},
/**
* Initialize mode from sessionStorage (call on app load)
*/
initialize() {
if (!browser) return;
const sessionMode = sessionStorage.getItem(STORAGE_KEY);
if (isValidMode(sessionMode)) {
mode = sessionMode;
} else {
mode = 'calendar';
}
},
};

View file

@ -0,0 +1,243 @@
/**
* Voice Recording Store
*
* Manages the state of voice recording for event creation.
* Uses Svelte 5 runes for reactive state management.
*/
import {
createAudioRecorder,
requestMicrophonePermission,
isAudioRecordingSupported,
formatDuration,
type AudioRecorder,
type PermissionState,
} from '$lib/utils/audio-recorder';
import { transcribeAudio, type TranscriptionResult } from '$lib/services/stt';
import { settingsStore } from './settings.svelte';
export type VoiceRecordingState =
| 'idle'
| 'requesting' // Requesting microphone permission
| 'recording'
| 'processing' // Transcribing audio
| 'error';
export interface VoiceRecordingError {
message: string;
code?: string;
}
// State
let state = $state<VoiceRecordingState>('idle');
let duration = $state(0);
let error = $state<VoiceRecordingError | null>(null);
let permissionState = $state<PermissionState>('prompt');
// Internal
let recorder: AudioRecorder | null = null;
let onResultCallback: ((result: TranscriptionResult) => void) | null = null;
/**
* Voice Recording Store
*/
export const voiceRecordingStore = {
// Getters
get state() {
return state;
},
get duration() {
return duration;
},
get formattedDuration() {
return formatDuration(duration);
},
get error() {
return error;
},
get permissionState() {
return permissionState;
},
get isSupported() {
return isAudioRecordingSupported();
},
get isIdle() {
return state === 'idle';
},
get isRecording() {
return state === 'recording';
},
get isProcessing() {
return state === 'processing';
},
get hasError() {
return state === 'error';
},
/**
* Set the callback for when transcription completes successfully
*/
setOnResult(callback: (result: TranscriptionResult) => void) {
onResultCallback = callback;
},
/**
* Check microphone permission without starting recording
*/
async checkPermission(): Promise<PermissionState> {
permissionState = await requestMicrophonePermission();
return permissionState;
},
/**
* Start voice recording
*/
async startRecording(): Promise<void> {
if (state !== 'idle' && state !== 'error') {
return;
}
// Reset error state
error = null;
state = 'requesting';
try {
// Check permission
permissionState = await requestMicrophonePermission();
if (permissionState === 'unsupported') {
throw {
message: 'Kein Mikrofon gefunden',
code: 'NOT_SUPPORTED',
};
}
if (permissionState === 'denied') {
throw {
message: 'Mikrofonzugriff verweigert. Bitte in Browsereinstellungen erlauben.',
code: 'PERMISSION_DENIED',
};
}
// Create and start recorder
recorder = createAudioRecorder({
maxDuration: 60000, // 60 seconds max
onDurationUpdate: (ms) => {
duration = ms;
},
onMaxDurationWarning: () => {
// Could show a toast or visual warning
console.warn('Approaching max recording duration');
},
onError: (err) => {
error = {
message: err.message,
code: 'RECORDER_ERROR',
};
state = 'error';
},
});
await recorder.start();
state = 'recording';
duration = 0;
} catch (err) {
// Handle error objects with message/code
if (err && typeof err === 'object' && 'message' in err) {
error = err as VoiceRecordingError;
} else if (err instanceof Error) {
error = {
message: err.message,
code: 'START_ERROR',
};
} else {
error = {
message: 'Aufnahme konnte nicht gestartet werden',
code: 'UNKNOWN_ERROR',
};
}
state = 'error';
recorder = null;
}
},
/**
* Stop recording and process transcription
*/
async stopRecording(): Promise<void> {
if (state !== 'recording' || !recorder) {
return;
}
state = 'processing';
try {
const audioBlob = await recorder.stop();
recorder = null;
// Get language setting
const language = settingsStore.sttLanguage;
// Transcribe
const result = await transcribeAudio(audioBlob, language);
if (result.success) {
// Success - call the callback
state = 'idle';
duration = 0;
onResultCallback?.(result.data);
} else {
// Transcription error
error = result.error;
state = 'error';
}
} catch (err) {
error = {
message: err instanceof Error ? err.message : 'Transkription fehlgeschlagen',
code: 'TRANSCRIPTION_ERROR',
};
state = 'error';
recorder = null;
}
},
/**
* Cancel recording without transcription
*/
cancel(): void {
if (recorder) {
recorder.cancel();
recorder = null;
}
state = 'idle';
duration = 0;
error = null;
},
/**
* Clear error and return to idle state
*/
clearError(): void {
error = null;
if (state === 'error') {
state = 'idle';
}
},
/**
* Reset to initial state
*/
reset(): void {
this.cancel();
error = null;
},
};

View file

@ -0,0 +1,272 @@
/**
* Audio Recorder Utility
*
* Wrapper around MediaRecorder API for voice recording functionality.
* Handles microphone permissions, recording state, and audio blob creation.
*/
export type PermissionState = 'granted' | 'denied' | 'prompt' | 'unsupported';
export interface AudioRecorderOptions {
/** Called when recording duration updates (every 100ms) */
onDurationUpdate?: (durationMs: number) => void;
/** Called when an error occurs */
onError?: (error: Error) => void;
/** Maximum recording duration in milliseconds (default: 60000 = 60s) */
maxDuration?: number;
/** Warning callback when approaching max duration */
onMaxDurationWarning?: () => void;
}
export interface AudioRecorder {
/** Start recording audio */
start(): Promise<void>;
/** Stop recording and return the audio blob */
stop(): Promise<Blob>;
/** Cancel recording without returning data */
cancel(): void;
/** Whether currently recording */
readonly isRecording: boolean;
/** Current recording duration in milliseconds */
readonly duration: number;
}
/**
* Check if the browser supports audio recording
*/
export function isAudioRecordingSupported(): boolean {
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.MediaRecorder);
}
/**
* Request microphone permission and return the current permission state
*/
export async function requestMicrophonePermission(): Promise<PermissionState> {
if (!isAudioRecordingSupported()) {
return 'unsupported';
}
try {
// Check existing permission if available
if (navigator.permissions) {
const permissionStatus = await navigator.permissions.query({
name: 'microphone' as PermissionName,
});
if (permissionStatus.state === 'granted') {
return 'granted';
}
if (permissionStatus.state === 'denied') {
return 'denied';
}
}
// Try to get user media to trigger permission prompt
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Stop the stream immediately - we just needed to check permission
stream.getTracks().forEach((track) => track.stop());
return 'granted';
} catch (error) {
if (error instanceof DOMException) {
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
return 'denied';
}
if (error.name === 'NotFoundError') {
return 'unsupported';
}
}
return 'denied';
}
}
/**
* Get the best supported audio MIME type
*/
function getSupportedMimeType(): string {
const mimeTypes = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/mp4',
'audio/mpeg',
];
for (const mimeType of mimeTypes) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
// Fallback - let the browser decide
return '';
}
/**
* Create an audio recorder instance
*/
export function createAudioRecorder(options: AudioRecorderOptions = {}): AudioRecorder {
const { onDurationUpdate, onError, maxDuration = 60000, onMaxDurationWarning } = options;
let mediaRecorder: MediaRecorder | null = null;
let mediaStream: MediaStream | null = null;
let audioChunks: Blob[] = [];
let isRecording = false;
let duration = 0;
let durationInterval: ReturnType<typeof setInterval> | null = null;
let startTime = 0;
let warningShown = false;
const recorder: AudioRecorder = {
get isRecording() {
return isRecording;
},
get duration() {
return duration;
},
async start() {
if (isRecording) {
throw new Error('Already recording');
}
if (!isAudioRecordingSupported()) {
throw new Error('Audio recording is not supported in this browser');
}
try {
// Get audio stream
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
// Create MediaRecorder with best supported format
const mimeType = getSupportedMimeType();
mediaRecorder = new MediaRecorder(mediaStream, mimeType ? { mimeType } : undefined);
audioChunks = [];
duration = 0;
warningShown = false;
// Handle data chunks
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
// Handle errors
mediaRecorder.onerror = (event) => {
const error = new Error(
'MediaRecorder error: ' + (event as any).error?.message || 'Unknown error'
);
onError?.(error);
cleanup();
};
// Start recording
mediaRecorder.start(100); // Collect data every 100ms for smoother stop
isRecording = true;
startTime = Date.now();
// Track duration
durationInterval = setInterval(() => {
duration = Date.now() - startTime;
onDurationUpdate?.(duration);
// Warning at 50 seconds (10 seconds before max)
if (!warningShown && duration >= maxDuration - 10000) {
warningShown = true;
onMaxDurationWarning?.();
}
// Auto-stop at max duration
if (duration >= maxDuration) {
recorder.stop().catch(onError);
}
}, 100);
} catch (error) {
cleanup();
throw error;
}
},
async stop(): Promise<Blob> {
if (!isRecording || !mediaRecorder) {
throw new Error('Not currently recording');
}
return new Promise((resolve, reject) => {
if (!mediaRecorder) {
reject(new Error('MediaRecorder not available'));
return;
}
mediaRecorder.onstop = () => {
const mimeType = mediaRecorder?.mimeType || 'audio/webm';
const blob = new Blob(audioChunks, { type: mimeType });
cleanup();
resolve(blob);
};
try {
mediaRecorder.stop();
} catch (error) {
cleanup();
reject(error);
}
});
},
cancel() {
cleanup();
},
};
function cleanup() {
isRecording = false;
duration = 0;
if (durationInterval) {
clearInterval(durationInterval);
durationInterval = null;
}
if (mediaRecorder) {
if (mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch {
// Ignore errors when stopping
}
}
mediaRecorder = null;
}
if (mediaStream) {
mediaStream.getTracks().forEach((track) => track.stop());
mediaStream = null;
}
audioChunks = [];
}
return recorder;
}
/**
* Format duration in milliseconds to MM:SS format
*/
export function formatDuration(durationMs: number): string {
const totalSeconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}

View file

@ -44,7 +44,6 @@
isNavCollapsed as collapsedStore,
isToolbarCollapsed as toolbarCollapsedStore,
} from '$lib/stores/navigation';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
@ -66,6 +65,9 @@
import ViewModePillContextMenu from '$lib/components/calendar/ViewModePillContextMenu.svelte';
import SettingsModal from '$lib/components/settings/SettingsModal.svelte';
import AuthGateModal from '$lib/components/AuthGateModal.svelte';
import VoiceRecordButton from '$lib/components/voice/VoiceRecordButton.svelte';
import VoiceRecordingModal from '$lib/components/voice/VoiceRecordingModal.svelte';
import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import { sessionEventsStore } from '$lib/stores/session-events.svelte';
import { GuestWelcomeModal, shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
@ -366,37 +368,21 @@
return viewLabels[view];
}
// Handle view/mode change - switches between calendar views and network mode
function handleViewModeChange(id: string) {
if (id === 'network') {
viewModeStore.setMode('network');
} else {
// Switch to calendar mode and set the view type
viewModeStore.setMode('calendar');
// Handle view change
function handleViewChange(id: string) {
viewStore.setViewType(id as CalendarViewType);
}
}
// Current view value - shows 'network' when in network mode, otherwise the calendar view type
let currentViewValue = $derived(
viewModeStore.mode === 'network' ? 'network' : viewStore.viewType
);
// View switcher tab group (only shown on calendar main page)
// Includes calendar views + network option
let viewSwitcherTabGroup = $derived<PillTabGroupConfig>({
type: 'tabs',
options: [
...enabledViews.map((view) => ({
options: enabledViews.map((view) => ({
id: view,
label: getViewLabel(view),
title:
view === 'custom' ? `${settingsStore.customDayCount}-Tage-Ansicht` : viewTitles[view],
title: view === 'custom' ? `${settingsStore.customDayCount}-Tage-Ansicht` : viewTitles[view],
})),
{ id: 'network', label: 'N', title: 'Netzwerk-Ansicht' },
],
value: currentViewValue,
onChange: handleViewModeChange,
value: viewStore.viewType,
onChange: handleViewChange,
onContextMenu: handleViewContextMenu,
});
@ -571,6 +557,31 @@
let hasSessionEvents = $derived(sessionEventsStore.hasEvents);
let sessionEventCount = $derived(sessionEventsStore.count);
// Voice recording result handler
function handleVoiceResult(transcription: string) {
if (!browser) return;
// Parse the transcribed text to extract event data
const parsed = parseEventInput(transcription);
// Dispatch custom event for +page.svelte to handle
// The event data includes parsed info plus original transcription as description
window.dispatchEvent(
new CustomEvent('voice-event-create', {
detail: {
title: parsed.title || transcription,
startTime: parsed.startTime,
endTime: parsed.endTime,
location: parsed.location,
isAllDay: parsed.isAllDay,
tagNames: parsed.tagNames,
calendarName: parsed.calendarName,
description: transcription, // Original transcription as description
},
})
);
}
onMount(async () => {
// Initialize split-panel from URL/localStorage
splitPanel.initialize();
@ -753,8 +764,9 @@
{/if}
{/if}
<!-- Global Input Bar (hidden via CSS in immersive mode to prevent re-mount focus) -->
<!-- Global Input Bar with Voice Button (hidden via CSS in immersive mode to prevent re-mount focus) -->
<div class="input-bar-wrapper" class:hidden={settingsStore.immersiveModeEnabled}>
<div class="input-bar-row">
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
@ -785,7 +797,26 @@
onShowShortcuts={handleShowShortcuts}
onShowSyntaxHelp={handleShowSyntaxHelp}
/>
<!-- Voice Record Button -->
{#if voiceRecordingStore.isSupported}
<div
class="voice-button-wrapper"
style="--bottom-offset: {isMobile
? `${70 + tagStripOffset}px`
: isSidebarMode
? `${tagStripOffset}px`
: showCalendarToolbar && !isToolbarCollapsed
? `${140 + tagStripOffset}px`
: `${70 + tagStripOffset}px`}"
>
<VoiceRecordButton onResult={handleVoiceResult} size={40} />
</div>
{/if}
</div>
</div>
<!-- Voice Recording Modal -->
<VoiceRecordingModal onResult={handleVoiceResult} />
<!-- Immersive Mode Toggle (always visible on main calendar page) -->
<ImmersiveModeToggle
@ -1059,4 +1090,55 @@
padding-right: calc(54px + 1rem + 8px); /* FAB width + margin + gap */
}
}
/* Voice Button Wrapper */
.input-bar-row {
position: relative;
}
.voice-button-wrapper {
position: fixed;
bottom: calc(var(--bottom-offset, 70px) + env(safe-area-inset-bottom, 0px) + 7px);
left: 50%;
transform: translateX(calc(-50% + 260px)); /* Position to the right of centered InputBar */
z-index: 91;
background: hsl(var(--color-surface) / 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-border));
border-radius: 50%;
padding: 0.25rem;
box-shadow:
0 4px 6px -1px hsl(var(--color-foreground) / 0.1),
0 2px 4px -1px hsl(var(--color-foreground) / 0.06);
transition:
bottom 0.3s ease,
transform 0.15s ease;
}
.voice-button-wrapper:hover {
transform: translateX(calc(-50% + 260px)) scale(1.05);
}
/* Adjust voice button position on smaller screens */
@media (max-width: 900px) {
.voice-button-wrapper {
right: calc(1rem + 54px + 8px); /* FAB width + margin + gap from right FAB */
left: auto;
transform: none;
}
.voice-button-wrapper:hover {
transform: scale(1.05);
}
}
/* Mobile: Hide voice button (use modal instead) */
@media (max-width: 640px) {
.voice-button-wrapper {
right: calc(54px + 1rem + 54px + 8px); /* Right FAB + margin + voice btn + gap */
left: auto;
transform: none;
}
}
</style>

View file

@ -5,14 +5,13 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import ViewCarousel from '$lib/components/calendar/ViewCarousel.svelte';
import NetworkView from '$lib/components/calendar/NetworkView.svelte';
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
import { CalendarViewSkeleton } from '$lib/components/skeletons';
import type { CalendarEvent } from '@calendar/shared';
import { addMinutes } from 'date-fns';
import { browser } from '$app/environment';
let initialized = $state(false);
@ -74,6 +73,67 @@
// Event is automatically removed from store
}
// Voice event creation handler
interface VoiceEventData {
title: string;
startTime?: Date;
endTime?: Date;
location?: string;
isAllDay: boolean;
tagNames: string[];
calendarName?: string;
description: string;
}
function handleVoiceEventCreate(event: CustomEvent<VoiceEventData>) {
const data = event.detail;
// Close any existing overlay first
editingEvent = null;
eventsStore.clearDraftEvent();
// Determine start time - use parsed time or default to now
const startTime = data.startTime || new Date();
quickCreateDate = startTime;
// Calculate end time
let endTime: Date;
if (data.endTime) {
endTime = data.endTime;
} else if (data.isAllDay) {
endTime = new Date(startTime);
endTime.setHours(23, 59, 59, 999);
} else {
endTime = addMinutes(startTime, settingsStore.defaultEventDuration);
}
// Get default calendar
const defaultCalendar = calendarsStore.defaultCalendar;
// Create draft event with voice transcription data
eventsStore.createDraftEvent({
calendarId: defaultCalendar?.id || '',
title: data.title,
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
isAllDay: data.isAllDay,
location: data.location,
description: data.description ? `Sprachnotiz: ${data.description}` : undefined,
});
overlayKey++;
showQuickOverlay = true;
}
// Listen for voice event creation from layout
$effect(() => {
if (browser) {
const handler = (e: Event) => handleVoiceEventCreate(e as CustomEvent<VoiceEventData>);
window.addEventListener('voice-event-create', handler);
return () => window.removeEventListener('voice-event-create', handler);
}
});
// Track view changes to refetch events
let lastViewType = $state(viewStore.viewType);
let lastDateKey = $state(viewStore.currentDate.toDateString());
@ -102,14 +162,7 @@
<title>{$_('app.name')}</title>
</svelte:head>
{#if viewModeStore.mode === 'network'}
<!-- Network View Mode -->
<div class="network-layout">
<NetworkView />
</div>
{:else}
<!-- Calendar View Mode -->
<div class="calendar-layout">
<div class="calendar-layout">
<!-- Desktop: Left Sidebar -->
<aside class="calendar-sidebar desktop-only" class:collapsed={settingsStore.sidebarCollapsed}>
<!-- Collapse button at top -->
@ -163,18 +216,9 @@
/>
{/key}
{/if}
</div>
{/if}
</div>
<style>
/* Network Layout - Full height without sidebar */
.network-layout {
width: 100%;
height: 100%;
flex: 1;
min-height: 0;
}
.calendar-layout {
display: flex;
gap: 1.5rem;

View file

@ -0,0 +1,223 @@
# Calendar App - Cleanup Plan
Dieser Plan dokumentiert Features und Code, die überdurchschnittlich viel Komplexität erzeugen bei geringem Nutzen. Ziel ist eine schlankere, wartbarere Codebase.
## Status-Legende
- ✅ Erledigt
- 🔄 In Bearbeitung
- ⏳ Geplant
- ❌ Abgelehnt
---
## Erledigte Aufräumarbeiten
### ✅ Statistiken & Heatmap (2024-01-28)
**Commit:** `2f3473b7`
**Entfernte Dateien:**
- `src/lib/stores/statistics.svelte.ts` (270 Zeilen)
- `src/lib/stores/heatmap.svelte.ts` (190 Zeilen)
- `src/lib/components/calendar/StatsSidebarSection.svelte` (434 Zeilen)
- `src/lib/components/calendar/StatsOverlay.svelte` (257 Zeilen)
**Geänderte Dateien:**
- Heatmap-CSS aus allen View-Komponenten entfernt
- Nav-Item "Statistiken" entfernt
- Toolbar Heatmap-Toggle entfernt
**Ersparnis:** ~1.450 Zeilen
---
## Geplante Aufräumarbeiten
### Priorität 1: Quick Wins (Hoher ROI)
#### ⏳ 1.1 Network View entfernen
**Status:** Geplant
**Geschätzte Ersparnis:** ~800 Zeilen
**Komplexität:** HOCH | **Nutzen:** NIEDRIG
**Beschreibung:**
D3 Force-Simulation für Event-Graph-Visualisierung. Kalendereinträge sind keine natürliche Graph-Struktur. Nutzer navigieren nach Datum, nicht nach "Event-Beziehungen".
**Zu entfernende Dateien:**
- `src/lib/components/calendar/NetworkView.svelte` (~416 Zeilen)
- `src/lib/stores/network.svelte.ts` (~372 Zeilen)
**Zu ändernde Dateien:**
- `src/lib/stores/view-mode.svelte.ts` - Network-Mode entfernen
- `src/routes/(app)/+page.svelte` - Network-View Conditional entfernen
- `src/routes/(app)/+layout.svelte` - Network-Tab aus ViewSwitcher entfernen
---
#### ⏳ 1.2 Session Events (Guest Mode) entfernen
**Status:** Geplant
**Geschätzte Ersparnis:** ~150 Zeilen
**Komplexität:** MITTEL | **Nutzen:** NIEDRIG
**Beschreibung:**
Event-Management für unauthentifizierte Nutzer via sessionStorage. Events verschwinden bei Tab-Schließung - frustrierendes UX. Vereinfacht den events-Store erheblich.
**Zu entfernende Dateien:**
- `src/lib/stores/session-events.svelte.ts` (~154 Zeilen)
**Zu ändernde Dateien:**
- `src/lib/stores/events.svelte.ts` - Session-Event-Logik entfernen
- `src/routes/(app)/+layout.svelte` - Guest-Banner/Modal anpassen
- `src/lib/api/events.ts` - Session-Fallback entfernen
---
#### ⏳ 1.3 Event Parser (NLP) entfernen
**Status:** Geplant
**Geschätzte Ersparnis:** ~260 Zeilen
**Komplexität:** MITTEL | **Nutzen:** NIEDRIG
**Beschreibung:**
Natural Language Parsing für Termineinträge (nur Deutsch). Regex-basiert und fehleranfällig. Die meisten Nutzer verwenden strukturierte Formulare.
**Zu entfernende Dateien:**
- `src/lib/utils/event-parser.ts` (~261 Zeilen)
**Zu ändernde Dateien:**
- `src/routes/(app)/+layout.svelte` - QuickInputBar onCreate/onParseCreate entfernen
- QuickInputBar-Integration vereinfachen (nur Suche, kein Quick-Create)
---
### Priorität 2: Mittlerer Aufwand
#### ⏳ 2.1 Swipe Navigation entfernen
**Status:** Geplant
**Geschätzte Ersparnis:** ~180 Zeilen
**Komplexität:** MITTEL | **Nutzen:** NIEDRIG
**Beschreibung:**
Trackpad-/Touch-Swipe für horizontale Kalendernavigation. Pfeiltasten und Buttons reichen völlig aus.
**Zu entfernende Dateien:**
- `src/lib/composables/useSwipeNavigation.svelte.ts` (~183 Zeilen)
**Zu ändernde Dateien:**
- `src/lib/components/calendar/ViewCarousel.svelte` - Swipe-Integration entfernen
---
#### ⏳ 2.2 Context Menus entfernen
**Status:** Geplant
**Geschätzte Ersparnis:** ~400 Zeilen
**Komplexität:** MITTEL | **Nutzen:** NIEDRIG
**Beschreibung:**
4+ verschiedene Context-Menus mit duplizierten Aktionen. Mobile unterstützt keine Context-Menus. Aktionen besser in sichtbare Buttons verschieben.
**Zu entfernende Dateien:**
- `src/lib/components/event/EventContextMenu.svelte`
- `src/lib/components/calendar/CalendarHeaderContextMenu.svelte`
- `src/lib/components/calendar/DateStripContextMenu.svelte`
- `src/lib/components/calendar/ViewModePillContextMenu.svelte`
- `src/lib/stores/eventContextMenu.svelte.ts`
---
#### ⏳ 2.3 Settings vereinfachen
**Status:** Geplant
**Geschätzte Ersparnis:** ~200 Zeilen
**Komplexität:** MITTEL | **Nutzen:** NIEDRIG
**Beschreibung:**
Aktuell ~42 Einstellungen - die meisten Nutzer verwenden Defaults. Reduzieren auf ~8 Kern-Einstellungen.
**Zu entfernende Settings:**
- Mondphasen-Anzeige
- DateStrip-Varianten (compact, eventIndicators, etc.)
- Header-Format-Optionen
- Zeitfilter-Optionen
---
### Priorität 3: Größere Refactorings
#### ⏳ 3.1 Calendar Views reduzieren (7 → 3)
**Status:** Geplant
**Geschätzte Ersparnis:** ~1.500 Zeilen
**Komplexität:** HOCH | **Nutzen:** HOCH (Vereinfachung)
**Beschreibung:**
7 verschiedene View-Typen sind zu viel. Die meisten Nutzer brauchen nur Week, Month, Agenda.
**Behalten:**
- `WeekView.svelte` (Standard)
- `MonthView.svelte`
- `AgendaView.svelte`
**Entfernen:**
- `YearView.svelte` (~420 Zeilen)
- `DayView.svelte` (~1.104 Zeilen) - Week-View für einzelne Tage nutzen
- `MultiDayView.svelte` (~1.594 Zeilen) - Week-View mit variablem dayCount
---
#### ⏳ 3.2 Tag-System vereinfachen
**Status:** Geplant
**Geschätzte Ersparnis:** ~1.600 Zeilen
**Komplexität:** HOCH | **Nutzen:** MITTEL
**Beschreibung:**
Tag-Gruppen-Hierarchie entfernen → nur flache Tags. Drag-Drop-Sortierung entfernen → alphabetisch sortieren.
**Zu vereinfachende Dateien:**
- `src/lib/components/calendar/TagStripModal.svelte` (1.463 Zeilen → ~300 Zeilen)
- `src/lib/stores/event-tag-groups.svelte.ts` (entfernen)
- `src/lib/stores/event-tags.svelte.ts` (vereinfachen)
---
#### ⏳ 3.3 Birthday-Integration vereinfachen
**Status:** Geplant
**Geschätzte Ersparnis:** ~350 Zeilen
**Komplexität:** MITTEL | **Nutzen:** MITTEL-NIEDRIG
**Beschreibung:**
Cross-App API-Integration für Geburtstage. Ersetzbar durch manuelles Eintragen oder einfachen Import.
**Zu entfernende/vereinfachende Dateien:**
- `src/lib/stores/birthdays.svelte.ts` (~220 Zeilen)
- `src/lib/api/birthdays.ts` (~101 Zeilen)
- `src/lib/components/birthday/BirthdayPopover.svelte`
---
## Zusammenfassung
| Phase | Features | LOC Ersparnis | Status |
|-------|----------|---------------|--------|
| ✅ Done | Statistiken/Heatmap | ~1.450 | Erledigt |
| 🟢 Prio 1 | Network, Sessions, Parser | ~1.200 | Geplant |
| 🟡 Prio 2 | Swipe, Context, Settings | ~780 | Geplant |
| 🔴 Prio 3 | Views, Tags, Birthdays | ~3.450 | Geplant |
| **Gesamt** | | **~6.880** | |
**Ziel:** ~30% Code-Reduktion bei gleichem/besserem Nutzererlebnis
---
## Changelog
| Datum | Aktion | Commit |
|-------|--------|--------|
| 2024-01-28 | Statistiken & Heatmap entfernt | `2f3473b7` |