♻️ refactor(contacts): remove statistics, network view and session storage; implement demo mode

- Remove statistics feature (stores, routes, ~560 LOC)
- Remove network view with D3.js graph (~1,100 LOC)
- Remove session-based contact storage
- Add demo contacts for unauthenticated users (10 sample contacts)
- Add auth gate prompts for create/edit/delete/favorite actions
- Update layout with Demo-Modus banner and event handling
- ~1,760 lines of code removed for simpler, cleaner codebase

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-28 14:12:19 +01:00
parent 1dc4f58edb
commit 437d612e81
17 changed files with 517 additions and 2868 deletions

View file

@ -1,74 +0,0 @@
import { authStore } from '$lib/stores/auth.svelte';
import { API_BASE } from './config';
async function fetchWithAuth(url: string, options: RequestInit = {}) {
let token: string | null = null;
try {
token = await authStore.getAccessToken();
console.log('[Network API] Got token:', token ? 'present' : 'missing');
} catch (e) {
console.error('[Network API] Error getting token:', e);
}
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const fullUrl = `${API_BASE}${url}`;
console.log('[Network API] Fetching:', fullUrl);
const response = await fetch(fullUrl, {
...options,
headers,
});
console.log('[Network API] Response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('[Network API] Error response:', errorText);
let error: { message?: string } = { message: 'Request failed' };
try {
error = JSON.parse(errorText);
} catch {
error = { message: errorText || 'Request failed' };
}
throw new Error(error.message || 'Request failed');
}
return response.json();
}
export interface NetworkNode {
id: string;
name: string;
photoUrl: string | null;
company: string | null;
isFavorite: boolean;
tags: { id: string; name: string; color: string | null }[];
connectionCount: number;
}
export interface NetworkLink {
source: string;
target: string;
type: 'tag';
strength: number;
sharedTags: string[];
}
export interface NetworkGraphResponse {
nodes: NetworkNode[];
links: NetworkLink[];
}
export const networkApi = {
async getGraph(): Promise<NetworkGraphResponse> {
return fetchWithAuth('/network/graph');
},
};

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { sessionContactsStore } from '$lib/stores/session-contacts.svelte';
interface Props {
visible: boolean;
@ -16,7 +15,7 @@
save: {
title: 'Anmelden um zu speichern',
description:
'Melde dich an, um deine Kontakte in der Cloud zu speichern und auf allen Geräten zu synchronisieren.',
'Im Demo-Modus kannst du die App erkunden. Melde dich an, um eigene Kontakte zu erstellen und zu speichern.',
icon: 'cloud',
},
sync: {
@ -33,7 +32,6 @@
};
let currentMessage = $derived(messages[action]);
let sessionContactCount = $derived(sessionContactsStore.count);
function handleLogin() {
// Store return URL for redirect after login
@ -121,20 +119,6 @@
{currentMessage.description}
</p>
<!-- Session contacts info -->
{#if sessionContactCount > 0}
<div class="bg-muted/50 mb-6 rounded-lg p-3 text-center text-sm">
<span class="text-muted-foreground">
Du hast <strong class="text-foreground">{sessionContactCount}</strong>
{sessionContactCount === 1 ? 'Kontakt' : 'Kontakte'} in dieser Sitzung erstellt.
</span>
<br />
<span class="text-muted-foreground text-xs">
Diese werden nach der Anmeldung in deinen Account übernommen.
</span>
</div>
{/if}
<!-- Buttons -->
<div class="flex flex-col gap-3">
<button
@ -159,8 +143,8 @@
<!-- Info text -->
<p class="text-muted-foreground mt-4 text-center text-xs">
Du kannst weiterhin Kontakte erstellen. Diese werden lokal gespeichert und gehen beim
Schließen des Tabs verloren.
Im Demo-Modus werden Beispielkontakte angezeigt. Melde dich an, um eigene Kontakte zu
erstellen.
</p>
</div>
</div>

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { contactsApi, photoApi, type Contact } from '$lib/api/contacts';
import { contactsStore } from '$lib/stores/contacts.svelte';
import ContactNotes from './ContactNotes.svelte';
import ContactTasks from './ContactTasks.svelte';
import { ContactDetailSkeleton } from '$lib/components/skeletons';
@ -134,6 +135,12 @@
}
async function handleSave() {
// Demo contact: show auth gate
if (contactsStore.isDemoContact(contactId)) {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
return;
}
saving = true;
error = null;
try {
@ -177,6 +184,12 @@
}
async function handleDelete() {
// Demo contact: show auth gate
if (contactsStore.isDemoContact(contactId)) {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
return;
}
if (!confirm('Kontakt wirklich löschen?')) return;
deleting = true;
try {
@ -190,6 +203,13 @@
async function handleToggleFavorite() {
if (!contact) return;
// Demo contact: show auth gate
if (contactsStore.isDemoContact(contactId)) {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
return;
}
try {
contact = await contactsApi.toggleFavorite(contactId);
} catch (e) {

View file

@ -7,12 +7,7 @@
import { goto } from '$app/navigation';
import ContactGridView from '$lib/components/views/ContactGridView.svelte';
import ContactAlphabetView from '$lib/components/views/ContactAlphabetView.svelte';
import ContactNetworkView from '$lib/components/views/ContactNetworkView.svelte';
import {
ContactListSkeleton,
ContactGridSkeleton,
NetworkGraphSkeleton,
} from '$lib/components/skeletons';
import { ContactListSkeleton, ContactGridSkeleton } from '$lib/components/skeletons';
import { batchApi } from '$lib/api/batch';
import { toasts } from '$lib/stores/toast';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
@ -140,7 +135,12 @@
async function handleToggleFavorite(e: MouseEvent, id: string) {
e.stopPropagation();
await contactsStore.toggleFavorite(id);
const result = await contactsStore.toggleFavorite(id);
// Show auth gate if authentication required (demo mode)
if (result && 'error' in result && result.error === 'auth_required') {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
}
}
function handleContactClick(id: string) {
@ -374,9 +374,7 @@
<!-- Loading state with skeleton -->
{#if contactsStore.loading}
{#if viewModeStore.mode === 'network'}
<NetworkGraphSkeleton />
{:else if viewModeStore.mode === 'grid'}
{#if viewModeStore.mode === 'grid'}
<ContactGridSkeleton count={8} />
{:else}
<ContactListSkeleton count={10} />
@ -393,9 +391,7 @@
</div>
{:else}
<!-- Contacts View -->
{#if viewModeStore.mode === 'network'}
<ContactNetworkView />
{:else if viewModeStore.mode === 'grid'}
{#if viewModeStore.mode === 'grid'}
<ContactGridView
contacts={sortedContacts}
onContactClick={handleContactClick}
@ -416,8 +412,8 @@
/>
{/if}
<!-- Infinite scroll trigger & loading more indicator (not for network view) -->
{#if viewModeStore.mode !== 'network' && contactsStore.hasMore}
<!-- Infinite scroll trigger & loading more indicator -->
{#if contactsStore.hasMore}
<div bind:this={loadMoreTrigger} class="load-more-trigger">
{#if contactsStore.loadingMore}
<div class="loading-more">
@ -428,13 +424,11 @@
</div>
{/if}
<!-- Total count (not for network view) -->
{#if viewModeStore.mode !== 'network'}
<p class="text-sm text-muted-foreground text-center">
{contactsStore.contacts.length} / {contactsStore.total}
{contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
</p>
{/if}
<!-- Total count -->
<p class="text-sm text-muted-foreground text-center">
{contactsStore.contacts.length} / {contactsStore.total}
{contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
</p>
{/if}
</div>

View file

@ -2,10 +2,8 @@
import { _ } from 'svelte-i18n';
import { onMount } from 'svelte';
import { PillViewSwitcher, FilterDropdown, type FilterDropdownOption } from '@manacore/shared-ui';
import { ZoomIn, ZoomOut, RotateCcw, Focus, X } from 'lucide-svelte';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import { X } from 'lucide-svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import { networkStore } from '$lib/stores/network.svelte';
import { tagsApi, type ContactTag, type Contact } from '$lib/api/contacts';
import type { ContactFilter, BirthdayFilter } from '$lib/components/FilterBar.svelte';
@ -82,14 +80,6 @@
contactsFilterStore.setSelectedCompany(null);
}
// Network strength state
let strengthValue = $state(networkStore.minStrength);
// Sync strength with store
$effect(() => {
strengthValue = networkStore.minStrength;
});
// Sort options
const sortOptions = [
{ id: 'firstName', label: $_('sort.firstName'), title: $_('sort.firstName') },
@ -100,173 +90,77 @@
contactsFilterStore.setSortField(value as 'firstName' | 'lastName');
}
function handleStrengthChange(event: Event) {
const target = event.target as HTMLInputElement;
strengthValue = parseInt(target.value, 10);
networkStore.setMinStrength(strengthValue);
}
function clearNetworkFilters() {
strengthValue = 0;
networkStore.clearFilters();
}
const hasActiveNetworkFilters = $derived(networkStore.minStrength > 0);
// Check if in network mode
const isNetworkMode = $derived(viewModeStore.mode === 'network');
onMount(() => {
loadTags();
});
</script>
<div class="toolbar-content-inner">
{#if isNetworkMode}
<!-- Network Mode Controls -->
<!-- Filter Dropdowns -->
<div class="filter-group">
<!-- Tags Filter -->
<FilterDropdown
options={tagOptions}
value={contactsFilterStore.selectedTagId}
onChange={(v) => contactsFilterStore.setSelectedTagId(typeof v === 'string' ? v : null)}
placeholder={$_('filters.allTags')}
embedded={true}
direction="up"
/>
<!-- Strength Filter -->
<div class="strength-group">
<label for="network-strength-filter" class="strength-label">
{$_('network.strength')}: {strengthValue}%
</label>
<input
id="network-strength-filter"
type="range"
min="0"
max="100"
step="10"
value={strengthValue}
oninput={handleStrengthChange}
class="strength-slider"
title={$_('network.minStrength')}
<!-- Contact Info Filter -->
<FilterDropdown
options={contactFilterOptions}
value={contactsFilterStore.contactFilter}
onChange={(v) =>
contactsFilterStore.setContactFilter((typeof v === 'string' ? v : 'all') as ContactFilter)}
placeholder={$_('filters.contact.all')}
embedded={true}
direction="up"
/>
<!-- Birthday Filter -->
<FilterDropdown
options={birthdayFilterOptions}
value={contactsFilterStore.birthdayFilter}
onChange={(v) =>
contactsFilterStore.setBirthdayFilter(
(typeof v === 'string' ? v : 'all') as BirthdayFilter
)}
placeholder={$_('filters.birthday.all')}
embedded={true}
direction="up"
/>
<!-- Company Filter (only if companies exist) -->
{#if companyOptions.length > 0}
<FilterDropdown
options={companyOptions}
value={contactsFilterStore.selectedCompany}
onChange={(v) => contactsFilterStore.setSelectedCompany(typeof v === 'string' ? v : null)}
placeholder={$_('filters.allCompanies')}
embedded={true}
direction="up"
/>
</div>
<div class="toolbar-divider"></div>
<!-- Zoom Controls -->
<div class="zoom-controls">
<button
onclick={() => networkStore.zoomIn()}
class="control-btn"
aria-label={$_('network.zoomIn')}
title={$_('network.zoomIn')}
>
<ZoomIn size={16} />
</button>
<button
onclick={() => networkStore.zoomOut()}
class="control-btn"
aria-label={$_('network.zoomOut')}
title={$_('network.zoomOut')}
>
<ZoomOut size={16} />
</button>
<button
onclick={() => networkStore.resetZoom()}
class="control-btn"
aria-label={$_('network.resetZoom')}
title={$_('network.resetZoom')}
>
<RotateCcw size={16} />
</button>
<button
onclick={() => networkStore.focusOnSelected()}
class="control-btn"
aria-label={$_('network.focusSelected')}
title={$_('network.focusSelected')}
>
<Focus size={16} />
</button>
</div>
<!-- Clear Filters -->
{#if hasActiveNetworkFilters}
<div class="toolbar-divider"></div>
<button onclick={clearNetworkFilters} class="clear-btn" title={$_('common.clearFilters')}>
<X size={14} />
<span>{$_('common.clearFilters')}</span>
</button>
{/if}
<!-- Stats -->
<div class="stats">
<span class="stat">{networkStore.nodes.length} {$_('contacts.contactsPlural')}</span>
<span class="stat-divider"></span>
<span class="stat">{networkStore.links.length} {$_('network.connections')}</span>
</div>
{:else}
<!-- Standard Mode Controls (Grid/Alphabet) -->
<!-- Clear Filters Button -->
{#if activeFilterCount > 0}
<button onclick={clearAllFilters} class="clear-btn" title={$_('filters.clearAll')}>
<X size={14} />
</button>
{/if}
</div>
<!-- Filter Dropdowns -->
<div class="filter-group">
<!-- Tags Filter -->
<FilterDropdown
options={tagOptions}
value={contactsFilterStore.selectedTagId}
onChange={(v) => contactsFilterStore.setSelectedTagId(typeof v === 'string' ? v : null)}
placeholder={$_('filters.allTags')}
embedded={true}
direction="up"
/>
<div class="toolbar-divider"></div>
<!-- Contact Info Filter -->
<FilterDropdown
options={contactFilterOptions}
value={contactsFilterStore.contactFilter}
onChange={(v) =>
contactsFilterStore.setContactFilter(
(typeof v === 'string' ? v : 'all') as ContactFilter
)}
placeholder={$_('filters.contact.all')}
embedded={true}
direction="up"
/>
<!-- Birthday Filter -->
<FilterDropdown
options={birthdayFilterOptions}
value={contactsFilterStore.birthdayFilter}
onChange={(v) =>
contactsFilterStore.setBirthdayFilter(
(typeof v === 'string' ? v : 'all') as BirthdayFilter
)}
placeholder={$_('filters.birthday.all')}
embedded={true}
direction="up"
/>
<!-- Company Filter (only if companies exist) -->
{#if companyOptions.length > 0}
<FilterDropdown
options={companyOptions}
value={contactsFilterStore.selectedCompany}
onChange={(v) => contactsFilterStore.setSelectedCompany(typeof v === 'string' ? v : null)}
placeholder={$_('filters.allCompanies')}
embedded={true}
direction="up"
/>
{/if}
<!-- Clear Filters Button -->
{#if activeFilterCount > 0}
<button onclick={clearAllFilters} class="clear-btn" title={$_('filters.clearAll')}>
<X size={14} />
</button>
{/if}
</div>
<div class="toolbar-divider"></div>
<!-- Sort Toggle -->
<PillViewSwitcher
options={sortOptions}
value={contactsFilterStore.sortField}
onChange={handleSortChange}
embedded={true}
/>
{/if}
<!-- Sort Toggle -->
<PillViewSwitcher
options={sortOptions}
value={contactsFilterStore.sortField}
onChange={handleSortChange}
embedded={true}
/>
</div>
<style>
@ -284,79 +178,6 @@
margin: 0 0.25rem;
}
/* Network-specific styles */
.strength-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.strength-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
}
.strength-slider {
width: 80px;
height: 4px;
border-radius: 2px;
background: hsl(var(--color-muted));
appearance: none;
cursor: pointer;
}
.strength-slider::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: hsl(var(--color-primary));
cursor: pointer;
transition: transform 0.1s;
}
.strength-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.strength-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border: none;
border-radius: 50%;
background: hsl(var(--color-primary));
cursor: pointer;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 0.125rem;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: transparent;
border: none;
border-radius: 9999px;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
transition: all 0.15s ease;
}
.control-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.control-btn:active {
transform: scale(0.95);
}
.clear-btn {
display: flex;
align-items: center;
@ -375,18 +196,6 @@
background: hsl(var(--destructive) / 0.15);
}
.stats {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.stat-divider {
opacity: 0.5;
}
/* Filter Group */
.filter-group {
display: flex;
@ -394,19 +203,4 @@
gap: 0.375rem;
flex-wrap: wrap;
}
@media (max-width: 640px) {
.stats {
display: none;
}
.strength-group {
flex: 1;
min-width: 120px;
}
.strength-slider {
flex: 1;
}
}
</style>

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { contactsApi, photoApi } from '$lib/api/contacts';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
import SocialMediaFields from './forms/SocialMediaFields.svelte';
import DateFields from './forms/DateFields.svelte';
@ -119,6 +120,12 @@
}
async function handleSave() {
// Demo mode: show auth gate
if (!authStore.isAuthenticated) {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
return;
}
if (!firstName && !lastName && !email) {
error = 'Bitte mindestens Name oder E-Mail angeben';
return;

View file

@ -1,374 +0,0 @@
<script lang="ts">
import { networkStore } from '$lib/stores/network.svelte';
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X } from 'lucide-svelte';
interface Props {
onZoomIn: () => void;
onZoomOut: () => void;
onResetZoom: () => void;
}
let { onZoomIn, onZoomOut, onResetZoom }: Props = $props();
let searchInput = $state(networkStore.searchQuery);
let showFilters = $state(false);
function handleSearchInput(event: Event) {
const target = event.target as HTMLInputElement;
searchInput = target.value;
networkStore.setSearch(target.value);
}
function clearSearch() {
searchInput = '';
networkStore.setSearch('');
}
function handleTagChange(event: Event) {
const target = event.target as HTMLSelectElement;
networkStore.setFilterTag(target.value || null);
}
function handleCompanyChange(event: Event) {
const target = event.target as HTMLSelectElement;
networkStore.setFilterCompany(target.value || null);
}
function clearAllFilters() {
searchInput = '';
networkStore.clearFilters();
}
const hasActiveFilters = $derived(
networkStore.searchQuery || networkStore.filterTagId || networkStore.filterCompany
);
</script>
<div class="network-controls">
<!-- Search bar -->
<div class="search-container">
<Search size={18} class="search-icon" />
<input
type="text"
placeholder="Kontakt suchen..."
value={searchInput}
oninput={handleSearchInput}
class="search-input"
/>
{#if searchInput}
<button onclick={clearSearch} class="clear-btn" aria-label="Suche löschen">
<X size={16} />
</button>
{/if}
</div>
<!-- Filter toggle -->
<button
onclick={() => (showFilters = !showFilters)}
class="control-btn"
class:active={showFilters || hasActiveFilters}
aria-label="Filter anzeigen"
title="Filter"
>
<Filter size={18} />
{#if hasActiveFilters}
<span class="filter-badge"></span>
{/if}
</button>
<!-- Zoom controls -->
<div class="zoom-controls">
<button onclick={onZoomIn} class="control-btn" aria-label="Vergrößern" title="Vergrößern">
<ZoomIn size={18} />
</button>
<button onclick={onZoomOut} class="control-btn" aria-label="Verkleinern" title="Verkleinern">
<ZoomOut size={18} />
</button>
<button
onclick={onResetZoom}
class="control-btn"
aria-label="Ansicht zurücksetzen"
title="Zurücksetzen"
>
<RotateCcw size={18} />
</button>
</div>
<!-- Stats -->
<div class="stats">
<span class="stat">
{networkStore.nodes.length} Kontakte
</span>
<span class="stat-divider"></span>
<span class="stat">
{networkStore.links.length} Verbindungen
</span>
</div>
</div>
<!-- Filter panel -->
{#if showFilters}
<div class="filter-panel">
<div class="filter-row">
<!-- Tag filter -->
<div class="filter-group">
<label for="tag-filter" class="filter-label">Tag</label>
<select
id="tag-filter"
onchange={handleTagChange}
value={networkStore.filterTagId || ''}
class="filter-select"
>
<option value="">Alle Tags</option>
{#each networkStore.uniqueTags as tag}
<option value={tag.id}>
{tag.name}
</option>
{/each}
</select>
</div>
<!-- Company filter -->
<div class="filter-group">
<label for="company-filter" class="filter-label">Firma</label>
<select
id="company-filter"
onchange={handleCompanyChange}
value={networkStore.filterCompany || ''}
class="filter-select"
>
<option value="">Alle Firmen</option>
{#each networkStore.uniqueCompanies as company}
<option value={company}>
{company}
</option>
{/each}
</select>
</div>
<!-- Clear filters button -->
{#if hasActiveFilters}
<button onclick={clearAllFilters} class="clear-filters-btn">
<X size={14} />
Filter löschen
</button>
{/if}
</div>
</div>
{/if}
<style>
.network-controls {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 9999px;
flex-wrap: wrap;
}
.search-container {
position: relative;
flex: 1;
min-width: 200px;
max-width: 300px;
}
.search-container :global(.search-icon) {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.5rem 2rem 0.5rem 2.5rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.search-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
}
.search-input::placeholder {
color: hsl(var(--muted-foreground));
}
.clear-btn {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
padding: 0.25rem;
background: none;
border: none;
color: hsl(var(--muted-foreground));
cursor: pointer;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted));
}
.control-btn {
position: relative;
padding: 0.5rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
color: hsl(var(--muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.control-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.control-btn.active {
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary));
color: hsl(var(--primary));
}
.filter-badge {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: hsl(var(--primary));
border-radius: 50%;
}
.zoom-controls {
display: flex;
gap: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid hsl(var(--border));
}
.stats {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.stat-divider {
opacity: 0.5;
}
/* Filter panel */
.filter-panel {
margin-top: 0.5rem;
padding: 0.75rem 1rem;
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 1rem;
}
.filter-row {
display: flex;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 150px;
}
.filter-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.filter-select {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: hsl(var(--primary));
}
.clear-filters-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.2);
border-radius: 0.5rem;
color: hsl(var(--destructive));
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.clear-filters-btn:hover {
background: hsl(var(--destructive) / 0.15);
}
@media (max-width: 640px) {
.network-controls {
flex-direction: column;
align-items: stretch;
}
.search-container {
max-width: none;
}
.zoom-controls {
padding-left: 0;
border-left: none;
justify-content: center;
}
.stats {
justify-content: center;
margin-left: 0;
}
}
</style>

View file

@ -1,492 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import {
networkStore,
type SimulationNode,
type SimulationLink,
} from '$lib/stores/network.svelte';
import { zoom, zoomIdentity, type ZoomBehavior } from 'd3-zoom';
import { select } from 'd3-selection';
interface Props {
width?: number;
height?: number;
onNodeClick?: (node: SimulationNode) => void;
}
let { width = 800, height = 600, onNodeClick }: Props = $props();
let svgElement: SVGSVGElement;
let containerElement: HTMLDivElement;
let zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> | null = null;
let transform = $state({ x: 0, y: 0, k: 1 });
let draggedNode: SimulationNode | null = null;
let resizeObserver: ResizeObserver | null = null;
let containerWidth = $state(0);
let containerHeight = $state(0);
let hasInitialized = $state(false);
let initTimeoutId: ReturnType<typeof setTimeout> | null = null;
// Initialize simulation ONCE when nodes are loaded AND dimensions are stable
function tryInitialize() {
const nodeCount = networkStore.allNodes.length;
if (!hasInitialized && nodeCount > 0 && containerWidth > 100 && containerHeight > 100) {
console.log(
'[NetworkGraph] Initializing with dimensions:',
containerWidth,
'x',
containerHeight
);
hasInitialized = true;
networkStore.initSimulation(containerWidth, containerHeight);
}
}
// Try to initialize when nodes become available
$effect(() => {
const nodeCount = networkStore.allNodes.length;
if (nodeCount > 0 && containerWidth > 100 && containerHeight > 100) {
tryInitialize();
}
});
// Get nodes and links (these will update on each tick)
const graphNodes = $derived(networkStore.nodes);
const graphLinks = $derived(networkStore.links);
// Setup zoom behavior
$effect(() => {
if (svgElement) {
zoomBehavior = zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
transform = {
x: event.transform.x,
y: event.transform.y,
k: event.transform.k,
};
});
select(svgElement).call(zoomBehavior);
}
});
onMount(() => {
// Setup resize observer - wait for stable dimensions before initializing
if (containerElement) {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newWidth = entry.contentRect.width;
const newHeight = entry.contentRect.height;
if (newWidth > 100 && newHeight > 100) {
containerWidth = newWidth;
containerHeight = newHeight;
// Debounce initialization to wait for layout to stabilize
if (!hasInitialized) {
if (initTimeoutId) clearTimeout(initTimeoutId);
initTimeoutId = setTimeout(() => {
console.log(
'[NetworkGraph] Stable dimensions:',
containerWidth,
'x',
containerHeight
);
tryInitialize();
}, 100);
}
}
}
});
resizeObserver.observe(containerElement);
}
});
onDestroy(() => {
if (initTimeoutId) clearTimeout(initTimeoutId);
networkStore.reset();
resizeObserver?.disconnect();
});
function handleNodeClick(node: SimulationNode) {
networkStore.selectNode(node.id);
onNodeClick?.(node);
}
function handleNodeDoubleClick(node: SimulationNode) {
// Navigate to contact detail
goto(`/contacts/${node.id}`);
}
function handleDragStart(event: MouseEvent, node: SimulationNode) {
event.stopPropagation();
draggedNode = node;
networkStore.fixNode(node.id, node.x ?? 0, node.y ?? 0);
networkStore.reheatSimulation();
}
function handleDrag(event: MouseEvent) {
if (!draggedNode) return;
// Convert screen coordinates to graph coordinates
const x = (event.clientX - svgElement.getBoundingClientRect().left - transform.x) / transform.k;
const y = (event.clientY - svgElement.getBoundingClientRect().top - transform.y) / transform.k;
networkStore.fixNode(draggedNode.id, x, y);
}
function handleDragEnd() {
if (draggedNode) {
networkStore.releaseNode(draggedNode.id);
draggedNode = null;
}
}
function resetZoom() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(300).call(zoomBehavior.transform, zoomIdentity);
}
}
function zoomIn() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 1.3);
}
}
function zoomOut() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 0.7);
}
}
// Helper to get node initials
function getInitials(name: string): string {
const parts = name.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
// Helper to generate consistent color from string
function stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = hash % 360;
return `hsl(${hue}, 70%, 50%)`;
}
// Get link coordinates
function getLinkCoords(link: SimulationLink) {
const source = link.source as SimulationNode;
const target = link.target as SimulationNode;
return {
x1: source.x ?? 0,
y1: source.y ?? 0,
x2: target.x ?? 0,
y2: target.y ?? 0,
};
}
// Check if a node is connected to selected node
function isConnectedToSelected(nodeId: string, links: typeof graphLinks): boolean {
if (!networkStore.selectedNodeId) return false;
if (nodeId === networkStore.selectedNodeId) return true;
return links.some((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 === networkStore.selectedNodeId && targetId === nodeId) ||
(targetId === networkStore.selectedNodeId && sourceId === nodeId)
);
});
}
// Export zoom functions for parent component
export { resetZoom, zoomIn, zoomOut };
</script>
<div
bind:this={containerElement}
class="network-graph-container"
onmousemove={handleDrag}
onmouseup={handleDragEnd}
onmouseleave={handleDragEnd}
role="application"
aria-label="Kontakt-Netzwerk Graph"
>
<svg bind:this={svgElement} class="network-graph-svg" style="width: 100%; height: 100%;">
<g transform="translate({transform.x}, {transform.y}) scale({transform.k})">
<!-- Links -->
<g class="links">
{#each graphLinks as link}
{@const coords = getLinkCoords(link)}
{@const sourceId = typeof link.source === 'string' ? link.source : link.source.id}
{@const targetId = typeof link.target === 'string' ? link.target : link.target.id}
{@const isHighlighted =
networkStore.selectedNodeId &&
(sourceId === networkStore.selectedNodeId || targetId === networkStore.selectedNodeId)}
<line
x1={coords.x1}
y1={coords.y1}
x2={coords.x2}
y2={coords.y2}
stroke-width={Math.max(1, link.strength / 25)}
class="link"
class:highlighted={isHighlighted}
class:dimmed={networkStore.selectedNodeId && !isHighlighted}
>
<title>{link.sharedTags.join(', ')}</title>
</line>
{/each}
</g>
<!-- Nodes -->
<g class="nodes">
{#each graphNodes as node (node.id)}
{@const isSelected = node.id === networkStore.selectedNodeId}
{@const isConnected = isConnectedToSelected(node.id, graphLinks)}
{@const isDimmed = networkStore.selectedNodeId && !isConnected}
<g
transform="translate({node.x ?? 0}, {node.y ?? 0})"
class="node"
class:selected={isSelected}
class:connected={isConnected && !isSelected}
class:dimmed={isDimmed}
onmousedown={(e) => handleDragStart(e, node)}
onclick={() => handleNodeClick(node)}
ondblclick={() => handleNodeDoubleClick(node)}
role="button"
tabindex="0"
aria-label={node.name}
>
<!-- Node circle -->
<circle r={isSelected ? 28 : 24} fill={stringToColor(node.name)} class="node-circle" />
<!-- Avatar image or initials -->
{#if node.photoUrl}
<clipPath id="clip-{node.id}">
<circle r={isSelected ? 26 : 22} />
</clipPath>
<image
href={node.photoUrl}
x={isSelected ? -26 : -22}
y={isSelected ? -26 : -22}
width={isSelected ? 52 : 44}
height={isSelected ? 52 : 44}
clip-path="url(#clip-{node.id})"
preserveAspectRatio="xMidYMid slice"
/>
{:else}
<text
class="node-initials"
text-anchor="middle"
dominant-baseline="central"
fill="white"
font-size={isSelected ? 14 : 12}
font-weight="600"
>
{getInitials(node.name)}
</text>
{/if}
<!-- Favorite indicator -->
{#if node.isFavorite}
<circle
cx={isSelected ? 20 : 17}
cy={isSelected ? -20 : -17}
r="8"
fill="hsl(var(--background))"
/>
<text
x={isSelected ? 20 : 17}
y={isSelected ? -20 : -17}
text-anchor="middle"
dominant-baseline="central"
font-size="10"
>
</text>
{/if}
<!-- Connection count badge -->
{#if node.connectionCount > 0}
<circle
cx={isSelected ? -20 : -17}
cy={isSelected ? -20 : -17}
r="10"
fill="hsl(var(--primary))"
/>
<text
x={isSelected ? -20 : -17}
y={isSelected ? -20 : -17}
text-anchor="middle"
dominant-baseline="central"
fill="white"
font-size="9"
font-weight="600"
>
{node.connectionCount}
</text>
{/if}
<!-- Node label -->
<text
y={isSelected ? 42 : 38}
class="node-label"
text-anchor="middle"
font-size={isSelected ? 13 : 11}
font-weight={isSelected ? '600' : '500'}
>
{node.name}
</text>
<!-- Company label (uses subtitle field) -->
{#if node.subtitle}
<text
y={isSelected ? 56 : 50}
class="node-company"
text-anchor="middle"
font-size="9"
>
{node.subtitle}
</text>
{/if}
</g>
{/each}
</g>
</g>
</svg>
<!-- Empty state -->
{#if graphNodes.length === 0 && !networkStore.loading}
<div class="empty-state">
<div class="empty-icon">🔗</div>
<p class="empty-title">Keine Verbindungen gefunden</p>
<p class="empty-description">
Kontakte werden verbunden, wenn sie gemeinsame Tags haben. Füge Tags zu deinen Kontakten
hinzu, um das Netzwerk zu sehen.
</p>
</div>
{/if}
</div>
<style>
.network-graph-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: hsl(var(--background));
}
.network-graph-svg {
display: block;
cursor: grab;
}
.network-graph-svg:active {
cursor: grabbing;
}
/* Links */
.link {
stroke: hsl(var(--muted-foreground) / 0.3);
transition:
stroke 0.2s,
stroke-width 0.2s,
opacity 0.2s;
}
.link.highlighted {
stroke: hsl(var(--primary));
stroke-width: 3 !important;
}
.link.dimmed {
opacity: 0.1;
}
/* Nodes */
.node {
cursor: pointer;
transition: opacity 0.2s;
}
.node:hover .node-circle {
filter: brightness(1.1);
}
.node.selected .node-circle {
stroke: hsl(var(--primary));
stroke-width: 4;
}
.node.connected .node-circle {
stroke: hsl(var(--primary) / 0.5);
stroke-width: 2;
}
.node.dimmed {
opacity: 0.3;
}
.node-circle {
transition:
r 0.2s,
stroke 0.2s,
stroke-width 0.2s,
filter 0.2s;
}
.node-initials {
pointer-events: none;
user-select: none;
}
.node-label {
fill: hsl(var(--foreground));
pointer-events: none;
user-select: none;
}
.node-company {
fill: hsl(var(--muted-foreground));
pointer-events: none;
user-select: none;
}
/* Empty state */
.empty-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
padding: 2rem;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.5rem;
}
.empty-description {
color: hsl(var(--muted-foreground));
max-width: 300px;
line-height: 1.5;
}
</style>

View file

@ -1,257 +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 { contactsFilterStore } from '$lib/stores/filter.svelte';
import { NetworkGraph } from '@manacore/shared-ui';
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
import { NetworkGraphSkeleton } from '$lib/components/skeletons';
// Sync global search to network store
$effect(() => {
networkStore.setSearch(contactsFilterStore.searchQuery);
});
// Sync tag filter to network store
$effect(() => {
networkStore.setFilterTag(contactsFilterStore.selectedTagId);
});
// Sync company filter to network store
$effect(() => {
networkStore.setFilterCompany(contactsFilterStore.selectedCompany);
});
// Refocus view when search results change
let previousNodeCount = $state(0);
$effect(() => {
const currentNodeCount = networkStore.nodes.length;
const hasSearch = contactsFilterStore.searchQuery.length > 0;
// If search is active and node count changed, reset zoom to show all results
if (hasSearch && currentNodeCount !== previousNodeCount && currentNodeCount > 0) {
setTimeout(() => {
graphComponent?.resetZoom();
}, 100);
}
previousNodeCount = currentNodeCount;
});
let graphComponent: NetworkGraph;
let graphContainer: HTMLDivElement;
function handleNodeClick(node: SimulationNode) {
// Select node (highlight connections and show detail sidebar)
networkStore.selectNode(node.id);
}
function handleNodeDoubleClick(node: SimulationNode) {
// Navigate to contact detail page
goto(`/contacts/${node.id}`);
}
function handleBackgroundClick() {
networkStore.selectNode(null);
}
function handleCloseSidebar() {
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);
}
// Register graph component with store when it changes
$effect(() => {
networkStore.setGraphComponent(graphComponent);
});
// 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.setGraphComponent(null);
networkStore.stopSimulation();
});
</script>
<div class="network-view">
<!-- Error Banner -->
{#if networkStore.error}
<div class="error-banner" role="alert">
<svg class="error-icon" 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}
<!-- Graph Container -->
<div class="graph-container" bind:this={graphContainer}>
{#if networkStore.loading}
<NetworkGraphSkeleton />
{: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}
/>
{/if}
</div>
<!-- Contact Detail Modal as Sidebar -->
{#if networkStore.selectedNodeId}
<div class="modal-sidebar-wrapper">
<ContactDetailModal contactId={networkStore.selectedNodeId} onClose={handleCloseSidebar} />
</div>
{/if}
</div>
<style>
.network-view {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
z-index: 1;
}
/* 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);
}
.error-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
/* Graph Container - Full screen */
.graph-container {
flex: 1;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
/* Modal Sidebar Wrapper - Override modal positioning */
.modal-sidebar-wrapper {
position: fixed;
top: 1rem;
right: 1rem;
bottom: calc(200px + env(safe-area-inset-bottom));
width: 400px;
max-width: calc(100vw - 2rem);
z-index: 50;
}
/* Override the modal styles when inside the sidebar wrapper */
.modal-sidebar-wrapper :global(.modal-backdrop) {
position: absolute;
background: transparent;
backdrop-filter: none;
padding: 0;
align-items: stretch;
justify-content: flex-end;
}
.modal-sidebar-wrapper :global(.modal-container) {
max-width: 100%;
width: 100%;
max-height: 100%;
height: 100%;
border-radius: 1rem;
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
animation: slideInRight 0.2s ease-out;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Responsive */
@media (max-width: 1024px) {
.modal-sidebar-wrapper {
width: 100%;
max-width: 100%;
top: auto;
right: 0;
bottom: 0;
height: 70vh;
}
.modal-sidebar-wrapper :global(.modal-container) {
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);
}
}
}
</style>

View file

@ -0,0 +1,215 @@
/**
* Demo Contacts - Static sample contacts for unauthenticated users
*
* Shows a realistic contact list with various contact types to demonstrate
* the app's capabilities without requiring login.
*/
import type { Contact } from '$lib/api/contacts';
/**
* Generate demo contacts
*/
export function generateDemoContacts(): Contact[] {
const now = new Date().toISOString();
const demoContacts: Contact[] = [
{
id: 'demo_1',
userId: 'demo',
firstName: 'Anna',
lastName: 'Müller',
displayName: 'Anna Müller',
email: 'anna.mueller@example.com',
phone: '+49 30 12345678',
mobile: '+49 170 1234567',
company: 'Tech Solutions GmbH',
jobTitle: 'Product Manager',
city: 'Berlin',
country: 'Deutschland',
isFavorite: true,
isArchived: false,
visibility: 'private',
tags: [{ id: 'tag_1', name: 'Arbeit', color: '#3b82f6' }],
createdAt: now,
updatedAt: now,
},
{
id: 'demo_2',
userId: 'demo',
firstName: 'Max',
lastName: 'Schmidt',
displayName: 'Max Schmidt',
email: 'max.schmidt@example.com',
mobile: '+49 171 9876543',
company: 'Design Studio',
jobTitle: 'UX Designer',
city: 'München',
country: 'Deutschland',
isFavorite: true,
isArchived: false,
visibility: 'private',
tags: [{ id: 'tag_1', name: 'Arbeit', color: '#3b82f6' }],
createdAt: now,
updatedAt: now,
},
{
id: 'demo_3',
userId: 'demo',
firstName: 'Lisa',
lastName: 'Weber',
displayName: 'Lisa Weber',
email: 'lisa.w@example.com',
phone: '+49 40 87654321',
city: 'Hamburg',
country: 'Deutschland',
isFavorite: false,
isArchived: false,
visibility: 'private',
tags: [{ id: 'tag_2', name: 'Freunde', color: '#22c55e' }],
birthday: '1992-03-15',
createdAt: now,
updatedAt: now,
},
{
id: 'demo_4',
userId: 'demo',
firstName: 'Thomas',
lastName: 'Becker',
displayName: 'Thomas Becker',
email: 'thomas.becker@example.com',
mobile: '+49 172 5555555',
company: 'Consulting Partners',
jobTitle: 'Senior Consultant',
city: 'Frankfurt',
country: 'Deutschland',
website: 'https://example.com',
isFavorite: false,
isArchived: false,
visibility: 'private',
tags: [{ id: 'tag_1', name: 'Arbeit', color: '#3b82f6' }],
createdAt: now,
updatedAt: now,
},
{
id: 'demo_5',
userId: 'demo',
firstName: 'Sarah',
lastName: 'Klein',
displayName: 'Sarah Klein',
email: 'sarah.klein@example.com',
phone: '+49 221 1111111',
mobile: '+49 173 2222222',
city: 'Köln',
country: 'Deutschland',
isFavorite: false,
isArchived: false,
visibility: 'private',
tags: [{ id: 'tag_3', name: 'Familie', color: '#f59e0b' }],
birthday: '1988-07-22',
createdAt: now,
updatedAt: now,
},
{
id: 'demo_6',
userId: 'demo',
firstName: 'Michael',
lastName: 'Hoffmann',
displayName: 'Michael Hoffmann',
email: 'm.hoffmann@example.com',
company: 'Startup Ventures',
jobTitle: 'CEO',
city: 'Berlin',
country: 'Deutschland',
linkedin: 'michael-hoffmann',
isFavorite: false,
isArchived: false,
visibility: 'private',
tags: [{ id: 'tag_1', name: 'Arbeit', color: '#3b82f6' }],
createdAt: now,
updatedAt: now,
},
{
id: 'demo_7',
userId: 'demo',
firstName: 'Julia',
lastName: 'Fischer',
displayName: 'Julia Fischer',
mobile: '+49 174 3333333',
city: 'Stuttgart',
country: 'Deutschland',
isFavorite: false,
isArchived: false,
visibility: 'private',
tags: [{ id: 'tag_2', name: 'Freunde', color: '#22c55e' }],
createdAt: now,
updatedAt: now,
},
{
id: 'demo_8',
userId: 'demo',
firstName: 'Dr. Stefan',
lastName: 'Wagner',
displayName: 'Dr. Stefan Wagner',
email: 'dr.wagner@praxis.de',
phone: '+49 89 4444444',
company: 'Praxis Dr. Wagner',
jobTitle: 'Arzt',
street: 'Hauptstraße 42',
city: 'München',
postalCode: '80331',
country: 'Deutschland',
isFavorite: false,
isArchived: false,
visibility: 'private',
notes: 'Hausarzt, Termine immer vormittags',
createdAt: now,
updatedAt: now,
},
{
id: 'demo_9',
userId: 'demo',
firstName: 'Emma',
lastName: 'Braun',
displayName: 'Emma Braun',
email: 'emma.b@example.com',
mobile: '+49 175 6666666',
company: 'Marketing Pro',
jobTitle: 'Marketing Director',
city: 'Düsseldorf',
country: 'Deutschland',
isFavorite: true,
isArchived: false,
visibility: 'private',
tags: [{ id: 'tag_1', name: 'Arbeit', color: '#3b82f6' }],
createdAt: now,
updatedAt: now,
},
{
id: 'demo_10',
userId: 'demo',
firstName: 'Peter',
lastName: 'Schneider',
displayName: 'Peter Schneider',
email: 'peter.schneider@example.com',
phone: '+49 511 7777777',
city: 'Hannover',
country: 'Deutschland',
isFavorite: false,
isArchived: true,
visibility: 'private',
tags: [],
createdAt: now,
updatedAt: now,
},
];
return demoContacts;
}
/**
* Check if a contact ID is a demo contact
*/
export function isDemoContact(id: string): boolean {
return id.startsWith('demo_');
}

View file

@ -1,9 +1,13 @@
/**
* Contacts Store - Manages contacts state using Svelte 5 runes
* Authenticated users: contacts from API
* Demo mode: static sample contacts to showcase the app
*/
import { contactsApi } from '$lib/api/contacts';
import type { Contact, ContactFilters } from '$lib/api/contacts';
import { authStore } from './auth.svelte';
import { generateDemoContacts, isDemoContact } from '$lib/data/demo-contacts';
// Default page size for pagination
const DEFAULT_PAGE_SIZE = 50;
@ -48,6 +52,7 @@ export const contactsStore = {
/**
* Load contacts with optional filters (resets to first page)
* In demo mode, loads static sample contacts
*/
async loadContacts(newFilters?: ContactFilters) {
if (newFilters) {
@ -58,6 +63,35 @@ export const contactsStore = {
error = null;
currentOffset = 0;
// Demo mode: load static demo contacts
if (!authStore.isAuthenticated) {
let demoContacts = generateDemoContacts();
// Apply filters to demo contacts
if (filters.search) {
const search = filters.search.toLowerCase();
demoContacts = demoContacts.filter(
(c) =>
c.displayName?.toLowerCase().includes(search) ||
c.email?.toLowerCase().includes(search) ||
c.company?.toLowerCase().includes(search)
);
}
if (filters.isFavorite !== undefined) {
demoContacts = demoContacts.filter((c) => c.isFavorite === filters.isFavorite);
}
if (filters.isArchived !== undefined) {
demoContacts = demoContacts.filter((c) => c.isArchived === filters.isArchived);
}
contacts = demoContacts;
total = demoContacts.length;
hasMore = false;
loading = false;
return;
}
// Authenticated: fetch from API
try {
const result = await contactsApi.list({
...filters,
@ -127,8 +161,14 @@ export const contactsStore = {
/**
* Create a new contact
* Requires authentication - demo mode shows auth gate
*/
async createContact(data: Partial<Contact>) {
// Demo mode: require authentication
if (!authStore.isAuthenticated) {
return { error: 'auth_required' as const };
}
loading = true;
error = null;
@ -149,8 +189,14 @@ export const contactsStore = {
/**
* Update a contact
* Demo contacts require authentication
*/
async updateContact(id: string, data: Partial<Contact>) {
// Demo contact: require authentication
if (isDemoContact(id)) {
return { error: 'auth_required' as const };
}
loading = true;
error = null;
@ -173,8 +219,14 @@ export const contactsStore = {
/**
* Delete a contact
* Demo contacts require authentication
*/
async deleteContact(id: string) {
// Demo contact: require authentication
if (isDemoContact(id)) {
return { error: 'auth_required' as const };
}
loading = true;
error = null;
@ -197,8 +249,14 @@ export const contactsStore = {
/**
* Toggle favorite status
* Demo contacts require authentication
*/
async toggleFavorite(id: string) {
// Demo contact: require authentication
if (isDemoContact(id)) {
return { error: 'auth_required' as const };
}
try {
const contact = await contactsApi.toggleFavorite(id);
// Update in local state
@ -215,8 +273,14 @@ export const contactsStore = {
/**
* Toggle archive status
* Demo contacts require authentication
*/
async toggleArchive(id: string) {
// Demo contact: require authentication
if (isDemoContact(id)) {
return { error: 'auth_required' as const };
}
try {
const contact = await contactsApi.toggleArchive(id);
// Remove from current view if archived/unarchived
@ -260,4 +324,11 @@ export const contactsStore = {
clearSelected() {
selectedContact = null;
},
/**
* Check if a contact is a demo contact (static sample data)
*/
isDemoContact(contactId: string) {
return isDemoContact(contactId);
},
};

View file

@ -1,539 +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;
// Interface for NetworkGraph component zoom methods
interface NetworkGraphZoomMethods {
zoomIn(): void;
zoomOut(): void;
resetZoom(): void;
focusOnSelectedNode(): void;
}
// Graph component reference for zoom controls
let graphComponentRef: NetworkGraphZoomMethods | null = null;
// localStorage key for toolbar state
const TOOLBAR_STORAGE_KEY = 'network-toolbar-state';
// Load toolbar state from localStorage
function loadToolbarState(): boolean {
if (!browser) return true;
try {
const stored = localStorage.getItem(TOOLBAR_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return parsed.isCollapsed ?? true;
}
} catch {
// Ignore parse errors
}
return true;
}
// Save toolbar state to localStorage
function saveToolbarState(isCollapsed: boolean) {
if (!browser) return;
try {
localStorage.setItem(TOOLBAR_STORAGE_KEY, JSON.stringify({ isCollapsed }));
} catch {
// Ignore storage errors
}
}
// 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 filterCompany = $state<string | null>(null);
let minStrength = $state(0);
let tickCounter = $state(0); // Used to trigger reactivity on simulation tick
let simulationInitialized = false;
let dataLoaded = false; // Prevent double loading
let lastDimensions = { width: 0, height: 0 };
let isToolbarCollapsed = $state(loadToolbarState());
// 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));
}
// Company filter (uses subtitle field)
if (filterCompany) {
result = result.filter((node) => node.subtitle === filterCompany);
}
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 and strength meets minimum
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 companies for filter dropdown (uses subtitle field)
const uniqueCompanies = $derived.by(() => {
const companies = new Set<string>();
for (const node of nodes) {
if (node.subtitle) {
companies.add(node.subtitle);
}
}
return Array.from(companies).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() {
// Access tickCounter to trigger reactivity on simulation updates
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 filterCompany() {
return filterCompany;
},
get minStrength() {
return minStrength;
},
get uniqueCompanies() {
return uniqueCompanies;
},
get uniqueTags() {
return uniqueTags;
},
get isToolbarCollapsed() {
return isToolbarCollapsed;
},
/**
* Set toolbar collapsed state
*/
setToolbarCollapsed(value: boolean) {
isToolbarCollapsed = value;
saveToolbarState(value);
},
/**
* Toggle toolbar collapsed state
*/
toggleToolbar() {
isToolbarCollapsed = !isToolbarCollapsed;
saveToolbarState(isToolbarCollapsed);
},
/**
* Register graph component reference for zoom controls
*/
setGraphComponent(component: NetworkGraphZoomMethods | null) {
graphComponentRef = component;
},
/**
* Zoom in on the graph
*/
zoomIn() {
graphComponentRef?.zoomIn();
},
/**
* Zoom out on the graph
*/
zoomOut() {
graphComponentRef?.zoomOut();
},
/**
* Reset zoom to fit all nodes
*/
resetZoom() {
graphComponentRef?.resetZoom();
},
/**
* Focus on the currently selected node
*/
focusOnSelected() {
graphComponentRef?.focusOnSelectedNode();
},
/**
* Load network graph data from API
*/
async loadGraph(force = false) {
// Prevent double loading
if (dataLoaded && !force) {
console.log('[Network] Data already loaded, skipping');
return;
}
if (loading) {
console.log('[Network] Already loading, skipping');
return;
}
loading = true;
error = null;
// Reset simulation state for fresh data
if (simulation) {
simulation.stop();
simulation = null;
}
simulationInitialized = false;
try {
const response = await networkApi.getGraph();
console.log(
'[Network] Loaded',
response.nodes.length,
'nodes and',
response.links.length,
'links'
);
// Convert to simulation nodes with subtitle for company
nodes = response.nodes.map((node) => ({
...node,
subtitle: node.company, // Map company to subtitle for shared component
x: undefined,
y: undefined,
vx: undefined,
vy: undefined,
fx: null,
fy: null,
}));
// Convert to simulation links
links = response.links.map((link) => ({
source: link.source,
target: link.target,
type: link.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;
// Prevent re-initialization if already running
if (simulationInitialized && simulation) {
// Only update center if dimensions changed significantly
if (
Math.abs(lastDimensions.width - width) > 50 ||
Math.abs(lastDimensions.height - height) > 50
) {
console.log('[Network] Updating simulation center for new dimensions:', width, 'x', height);
lastDimensions = { width, height };
this.updateSimulationCenter(width, height);
}
return;
}
// Stop existing simulation
if (simulation) {
simulation.stop();
}
console.log(
'[Network] Initializing simulation with',
nodes.length,
'nodes, dimensions:',
width,
'x',
height
);
lastDimensions = { width, height };
// Initialize node positions spread around the center
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) / 3;
nodes.forEach((node, i) => {
// Only set initial position if not already set
if (node.x === undefined || node.y === undefined) {
// Spread nodes in a circle initially
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);
}
});
// Create new simulation
simulation = forceSimulation<SimulationNode, SimulationLink>(nodes)
.force(
'link',
forceLink<SimulationNode, SimulationLink>(links)
.id((d) => d.id)
.distance(100) // Fixed distance for cleaner layout
.strength(0.5)
)
.force('charge', forceManyBody().strength(-300))
.force('center', forceCenter(centerX, centerY))
.force('collision', forceCollide().radius(50))
.on('tick', () => {
// Trigger Svelte reactivity by incrementing counter
tickCounter++;
});
simulationInitialized = true;
// Run simulation with higher alpha for better initial spread
simulation.alpha(1).restart();
},
/**
* Update simulation dimensions (e.g., on window resize)
*/
updateSimulationCenter(width: number, height: number) {
if (simulation) {
simulation.force('center', forceCenter(width / 2, height / 2));
simulation.alpha(0.3).restart();
}
},
/**
* Stop the simulation
*/
stopSimulation() {
if (simulation) {
simulation.stop();
simulation = null;
}
simulationInitialized = false;
// Don't reset dataLoaded here - only reset when navigating away
},
/**
* Reset the store completely (call when leaving the page)
*/
reset() {
this.stopSimulation();
nodes = [];
links = [];
dataLoaded = false;
lastDimensions = { width: 0, height: 0 };
tickCounter = 0;
},
/**
* Reheat simulation (restart with some energy)
*/
reheatSimulation() {
if (simulation) {
simulation.alpha(0.3).restart();
}
},
/**
* Fix node position (for dragging)
*/
fixNode(nodeId: string, x: number, y: number) {
const node = nodes.find((n) => n.id === nodeId);
if (node) {
node.fx = x;
node.fy = y;
}
},
/**
* Release node (after dragging)
*/
releaseNode(nodeId: string) {
const node = nodes.find((n) => n.id === nodeId);
if (node) {
node.fx = null;
node.fy = null;
}
},
/**
* Select a node
*/
selectNode(nodeId: string | null) {
selectedNodeId = nodeId;
},
/**
* Set search query
*/
setSearch(query: string) {
searchQuery = query;
},
/**
* Set tag filter
*/
setFilterTag(tagId: string | null) {
filterTagId = tagId;
},
/**
* Set company filter
*/
setFilterCompany(company: string | null) {
filterCompany = company;
},
/**
* Set minimum strength filter
*/
setMinStrength(strength: number) {
minStrength = strength;
},
/**
* Clear all filters
*/
clearFilters() {
searchQuery = '';
filterTagId = null;
filterCompany = null;
minStrength = 0;
},
/**
* Get connected nodes for a given node
*/
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));
},
/**
* Get links for a given node
*/
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,235 +0,0 @@
/**
* Session Contacts Store - Temporary local contacts for guest users
* Contacts are stored in sessionStorage and lost when the browser tab is closed
*/
import type { Contact } from '$lib/api/contacts';
import { browser } from '$app/environment';
const STORAGE_KEY = 'contacts-session-contacts';
// Generate a unique ID for session contacts
function generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
// Load contacts from sessionStorage
function loadFromStorage(): Contact[] {
if (!browser) return [];
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
// Save contacts to sessionStorage
function saveToStorage(contacts: Contact[]) {
if (!browser) return;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(contacts));
} catch (e) {
console.warn('Failed to save session contacts:', e);
}
}
// State
let contacts = $state<Contact[]>(loadFromStorage());
export const sessionContactsStore = {
get contacts() {
return contacts;
},
get hasContacts() {
return contacts.length > 0;
},
/**
* Initialize from sessionStorage (call on mount)
*/
initialize() {
contacts = loadFromStorage();
},
/**
* Create a new session contact
*/
createContact(data: Partial<Contact>): Contact {
const now = new Date().toISOString();
const newContact: Contact = {
id: generateSessionId(),
userId: 'guest',
firstName: data.firstName || null,
lastName: data.lastName || null,
displayName: data.displayName || null,
nickname: data.nickname || null,
email: data.email || null,
phone: data.phone || null,
mobile: data.mobile || null,
street: data.street || null,
city: data.city || null,
postalCode: data.postalCode || null,
country: data.country || null,
company: data.company || null,
jobTitle: data.jobTitle || null,
department: data.department || null,
website: data.website || null,
birthday: data.birthday || null,
notes: data.notes || null,
photoUrl: data.photoUrl || null,
customDates: data.customDates || null,
linkedin: data.linkedin || null,
twitter: data.twitter || null,
facebook: data.facebook || null,
instagram: data.instagram || null,
xing: data.xing || null,
github: data.github || null,
youtube: data.youtube || null,
tiktok: data.tiktok || null,
telegram: data.telegram || null,
whatsapp: data.whatsapp || null,
signal: data.signal || null,
discord: data.discord || null,
bluesky: data.bluesky || null,
tags: [],
isFavorite: data.isFavorite || false,
isArchived: data.isArchived || false,
organizationId: null,
teamId: null,
visibility: 'private',
createdAt: now,
updatedAt: now,
};
contacts = [...contacts, newContact];
saveToStorage(contacts);
return newContact;
},
/**
* Update a session contact
*/
updateContact(id: string, data: Partial<Contact>): Contact | null {
const index = contacts.findIndex((c) => c.id === id);
if (index === -1) return null;
const updatedContact = {
...contacts[index],
...data,
updatedAt: new Date().toISOString(),
};
contacts = contacts.map((c) => (c.id === id ? updatedContact : c));
saveToStorage(contacts);
return updatedContact;
},
/**
* Toggle favorite status
*/
toggleFavorite(id: string): Contact | null {
const contact = contacts.find((c) => c.id === id);
if (!contact) return null;
return this.updateContact(id, { isFavorite: !contact.isFavorite });
},
/**
* Toggle archive status
*/
toggleArchive(id: string): Contact | null {
const contact = contacts.find((c) => c.id === id);
if (!contact) return null;
return this.updateContact(id, { isArchived: !contact.isArchived });
},
/**
* Delete a session contact
*/
deleteContact(id: string): boolean {
const hadContact = contacts.some((c) => c.id === id);
contacts = contacts.filter((c) => c.id !== id);
saveToStorage(contacts);
return hadContact;
},
/**
* Get contact by ID
*/
getById(id: string): Contact | undefined {
return contacts.find((c) => c.id === id);
},
/**
* Check if a contact ID is a session contact
*/
isSessionContact(id: string): boolean {
return id.startsWith('session_');
},
/**
* Get all contacts (for migration to cloud on login)
*/
getAllContacts(): Contact[] {
return [...contacts];
},
/**
* Clear all session contacts (after migration or on explicit clear)
*/
clear() {
contacts = [];
if (browser) {
sessionStorage.removeItem(STORAGE_KEY);
}
},
/**
* Get count of session contacts
*/
get count() {
return contacts.length;
},
/**
* Get favorite contacts
*/
get favoriteContacts() {
return contacts.filter((c) => c.isFavorite && !c.isArchived);
},
/**
* Get archived contacts
*/
get archivedContacts() {
return contacts.filter((c) => c.isArchived);
},
/**
* Get active (non-archived) contacts
*/
get activeContacts() {
return contacts.filter((c) => !c.isArchived);
},
/**
* Search session contacts
*/
search(query: string): Contact[] {
if (!query.trim()) return this.activeContacts;
const lower = query.toLowerCase();
return this.activeContacts.filter((c) => {
const searchFields = [
c.firstName,
c.lastName,
c.displayName,
c.email,
c.phone,
c.mobile,
c.company,
];
return searchFields.some((field) => field?.toLowerCase().includes(lower));
});
},
};

View file

@ -1,275 +0,0 @@
/**
* Contacts Statistics Store - Calculates contact statistics using Svelte 5 runes
*/
import type { Contact } from '$lib/api/contacts';
import { subDays, format, parseISO, isWithinInterval, getMonth, eachDayOfInterval } from 'date-fns';
import { de } from 'date-fns/locale';
import type {
HeatmapDataPoint,
TrendDataPoint,
DonutSegment,
ProgressItem,
} from '@manacore/shared-ui';
// Types
export interface ContactTag {
id: string;
name: string;
color: string;
}
// State
let contacts = $state<Contact[]>([]);
let tags = $state<ContactTag[]>([]);
export const contactsStatisticsStore = {
// Setters
setContacts(newContacts: Contact[]) {
contacts = newContacts;
},
setTags(newTags: ContactTag[]) {
tags = newTags;
},
// Quick Stats
get totalContacts() {
return contacts.length;
},
get favoriteContacts() {
return contacts.filter((c) => c.isFavorite).length;
},
get archivedContacts() {
return contacts.filter((c) => c.isArchived).length;
},
get activeContacts() {
return contacts.filter((c) => !c.isArchived).length;
},
get recentlyAdded() {
const weekAgo = subDays(new Date(), 7);
return contacts.filter((c) => {
const createdAt =
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
return createdAt >= weekAgo;
}).length;
},
get birthdaysThisMonth() {
const currentMonth = getMonth(new Date());
return contacts.filter((c) => {
if (!c.birthday) return false;
const birthday = typeof c.birthday === 'string' ? parseISO(c.birthday) : new Date(c.birthday);
return getMonth(birthday) === currentMonth;
}).length;
},
get contactsWithEmail() {
return contacts.filter((c) => c.email).length;
},
get contactsWithPhone() {
return contacts.filter((c) => c.phone || c.mobile).length;
},
// Completeness rate (contacts with email AND phone)
get completenessRate() {
if (contacts.length === 0) return 0;
const complete = contacts.filter((c) => c.email && (c.phone || c.mobile)).length;
return Math.round((complete / contacts.length) * 100);
},
// Activity Heatmap (last 6 months) - based on contact creation
get activityHeatmap(): HeatmapDataPoint[] {
const endDate = new Date();
const startDate = subDays(endDate, 180);
// Count contacts created per day
const creationMap = new Map<string, number>();
contacts.forEach((c) => {
const createdAt =
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
if (createdAt >= startDate && createdAt <= endDate) {
const dateKey = format(createdAt, 'yyyy-MM-dd');
creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1);
}
});
// Generate all days
const days = eachDayOfInterval({ start: startDate, end: endDate });
return days.map((day) => {
const dateKey = format(day, 'yyyy-MM-dd');
return {
date: dateKey,
count: creationMap.get(dateKey) || 0,
dayOfWeek: day.getDay(),
};
});
},
// Weekly Trend (last 4 weeks)
get weeklyTrend(): TrendDataPoint[] {
const endDate = new Date();
const startDate = subDays(endDate, 27);
const creationMap = new Map<string, number>();
contacts.forEach((c) => {
const createdAt =
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
if (createdAt >= startDate && createdAt <= endDate) {
const dateKey = format(createdAt, 'yyyy-MM-dd');
creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1);
}
});
const days = eachDayOfInterval({ start: startDate, end: endDate });
return days.map((day) => {
const dateKey = format(day, 'yyyy-MM-dd');
return {
date: dateKey,
count: creationMap.get(dateKey) || 0,
label: format(day, 'EEE', { locale: de }),
};
});
},
// Contact Status Breakdown (Donut Chart) - Favorites / Active / Archived
get statusBreakdown(): DonutSegment[] {
const total = contacts.length;
if (total === 0) return [];
const favorites = contacts.filter((c) => c.isFavorite && !c.isArchived).length;
const archived = contacts.filter((c) => c.isArchived).length;
const regular = contacts.filter((c) => !c.isFavorite && !c.isArchived).length;
return [
{
id: 'favorites',
label: 'Favoriten',
count: favorites,
percentage: Math.round((favorites / total) * 100),
color: '#F59E0B', // amber
},
{
id: 'regular',
label: 'Aktiv',
count: regular,
percentage: Math.round((regular / total) * 100),
color: '#10B981', // green
},
{
id: 'archived',
label: 'Archiviert',
count: archived,
percentage: Math.round((archived / total) * 100),
color: '#6B7280', // gray
},
];
},
// Tags Progress (Progress Bars)
get tagProgress(): ProgressItem[] {
// Count contacts per tag
const tagCountMap = new Map<string, number>();
// This requires contacts to have a tags array - we'll estimate from the tag data
// For now, we'll show tags with placeholder counts
// In a real implementation, we'd need contactTags relation data
const result: ProgressItem[] = tags.map((tag) => ({
id: tag.id,
name: tag.name,
color: tag.color || '#6B7280',
total: contacts.length, // Total contacts as reference
completed: 0, // Would need contact-tag relation to calculate
percentage: 0,
}));
return result.sort((a, b) => b.completed - a.completed);
},
// Info completeness breakdown
get infoBreakdown(): DonutSegment[] {
const total = contacts.length;
if (total === 0) return [];
const withEmail = contacts.filter((c) => c.email).length;
const withPhone = contacts.filter((c) => c.phone || c.mobile).length;
const withCompany = contacts.filter((c) => c.company).length;
const withBirthday = contacts.filter((c) => c.birthday).length;
return [
{
id: 'email',
label: 'Mit E-Mail',
count: withEmail,
percentage: Math.round((withEmail / total) * 100),
color: '#3B82F6', // blue
},
{
id: 'phone',
label: 'Mit Telefon',
count: withPhone,
percentage: Math.round((withPhone / total) * 100),
color: '#10B981', // green
},
{
id: 'company',
label: 'Mit Firma',
count: withCompany,
percentage: Math.round((withCompany / total) * 100),
color: '#8B5CF6', // violet
},
{
id: 'birthday',
label: 'Mit Geburtstag',
count: withBirthday,
percentage: Math.round((withBirthday / total) * 100),
color: '#EC4899', // pink
},
];
},
// Country breakdown
get countryBreakdown(): ProgressItem[] {
const countryMap = new Map<string, number>();
contacts.forEach((c) => {
const country = c.country || 'Unbekannt';
countryMap.set(country, (countryMap.get(country) || 0) + 1);
});
const result: ProgressItem[] = [];
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#6B7280'];
let colorIndex = 0;
countryMap.forEach((count, country) => {
if (country !== 'Unbekannt' || count > 0) {
result.push({
id: country,
name: country,
color: colors[colorIndex % colors.length],
total: contacts.length,
completed: count,
percentage: Math.round((count / contacts.length) * 100),
});
colorIndex++;
}
});
return result.sort((a, b) => b.completed - a.completed).slice(0, 8);
},
// Total tags count
get totalTags() {
return tags.length;
},
};

View file

@ -47,8 +47,8 @@
} from '$lib/utils/contact-parser';
import ContactsToolbar from '$lib/components/ContactsToolbar.svelte';
import AuthGateModal from '$lib/components/AuthGateModal.svelte';
import { sessionContactsStore } from '$lib/stores/session-contacts.svelte';
import { GuestWelcomeModal, shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { browser } from '$app/environment';
// Tags state for Quick-Create
let availableTags = $state<{ id: string; name: string }[]>([]);
@ -140,7 +140,6 @@
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Kontakte', icon: 'users' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
{ href: '/help', label: 'Hilfe', icon: 'help-circle' },
@ -227,8 +226,17 @@
showAuthGateModal = true;
}
// Session contacts indicator
let sessionContactCount = $derived(sessionContactsStore.count);
// Listen for show-auth-gate events from child components
$effect(() => {
if (browser) {
const handler = (e: Event) => {
const customEvent = e as CustomEvent<{ action?: 'save' | 'sync' | 'feature' }>;
showAuthGate(customEvent.detail?.action || 'save');
};
window.addEventListener('show-auth-gate', handler);
return () => window.removeEventListener('show-auth-gate', handler);
}
});
async function handleCloseContactModal() {
// Refresh contacts list in case something was changed
@ -298,9 +306,6 @@
viewModeStore.initialize();
contactsFilterStore.initialize();
// Initialize session contacts for guest mode
sessionContactsStore.initialize();
// Show guest welcome modal for unauthenticated users
if (!authStore.isAuthenticated && shouldShowGuestWelcome('contacts')) {
showGuestWelcome = true;
@ -318,23 +323,6 @@
} catch (e) {
console.error('Failed to load tags:', e);
}
// Check for session contacts to migrate after login
if (sessionContactsStore.hasContacts) {
// Migrate session contacts to cloud
const sessionContacts = sessionContactsStore.getAllContacts();
for (const contact of sessionContacts) {
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, userId, createdAt, updatedAt, ...contactData } = contact;
await contactsStore.createContact(contactData);
} catch (e) {
console.error('Failed to migrate session contact:', e);
}
}
// Clear session contacts after migration
sessionContactsStore.clear();
}
}
// Initialize sidebar mode from localStorage
@ -366,7 +354,7 @@
<SplitPaneContainer>
<!-- Navigation Layout -->
<div class="layout-container">
<!-- Guest Mode Banner -->
<!-- Demo Mode Banner -->
{#if !authStore.isAuthenticated}
<div
class="guest-banner bg-primary/10 border-primary/20 fixed top-0 right-0 left-0 z-50 flex items-center justify-between border-b px-4 py-2"
@ -377,21 +365,24 @@
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<span class="text-foreground">
<strong>Gast-Modus</strong>
{#if sessionContactCount > 0}
- {sessionContactCount}
{sessionContactCount === 1 ? 'Kontakt' : 'Kontakte'} lokal gespeichert
{:else}
- Kontakte werden nur in diesem Tab gespeichert
{/if}
<strong>Demo-Modus</strong>
<span class="text-muted-foreground hidden sm:inline">
- Beispiel-Kontakte zum Ausprobieren
</span>
</span>
</div>
<button
onclick={() => showAuthGate('sync')}
onclick={() => showAuthGate('save')}
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-1 text-sm font-medium transition-colors"
>
Anmelden

View file

@ -1,281 +0,0 @@
<script lang="ts">
import { onMount, type Component } from 'svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { contactsStatisticsStore } from '$lib/stores/statistics.svelte';
import { tagsApi } from '$lib/api/contacts';
import {
StatsGrid,
ActivityHeatmap,
TrendLineChart,
DonutChart,
ProgressBars,
StatisticsSkeleton,
type StatItem,
} from '@manacore/shared-ui';
import { BarChart3, Users, Star, UserPlus, Cake, Mail, CircleCheck } from 'lucide-svelte';
let loading = $state(true);
// Update statistics when contacts change
$effect(() => {
contactsStatisticsStore.setContacts(contactsStore.contacts);
});
// Build stats items for StatsGrid
// Note: Cast icons to Component to satisfy type requirements
let statsItems = $derived<StatItem[]>([
{
id: 'total',
label: 'Gesamt',
value: contactsStatisticsStore.totalContacts,
icon: Users as unknown as Component,
variant: 'primary',
},
{
id: 'favorites',
label: 'Favoriten',
value: contactsStatisticsStore.favoriteContacts,
icon: Star as unknown as Component,
variant: 'accent',
},
{
id: 'recentlyAdded',
label: 'Neu (7 Tage)',
value: contactsStatisticsStore.recentlyAdded,
icon: UserPlus as unknown as Component,
variant: 'success',
},
{
id: 'birthdays',
label: 'Geburtstage',
value: contactsStatisticsStore.birthdaysThisMonth,
icon: Cake as unknown as Component,
variant: 'info',
},
{
id: 'withEmail',
label: 'Mit E-Mail',
value: contactsStatisticsStore.contactsWithEmail,
icon: Mail as unknown as Component,
variant: 'neutral',
},
{
id: 'completeness',
label: 'Vollständigkeit',
value: `${contactsStatisticsStore.completenessRate}%`,
icon: CircleCheck as unknown as Component,
variant: contactsStatisticsStore.completenessRate >= 70 ? 'success' : 'danger',
},
]);
onMount(async () => {
// Fetch all contacts (without filters for statistics)
await contactsStore.loadContacts({ isArchived: false });
// Also load archived for complete statistics
const allContacts = [...contactsStore.contacts];
// Fetch tags
try {
const { tags } = await tagsApi.list();
contactsStatisticsStore.setTags(tags);
} catch (e) {
console.error('Failed to load tags:', e);
}
loading = false;
});
</script>
<svelte:head>
<title>Statistiken - Kontakte</title>
</svelte:head>
<div class="statistics-page">
<header class="page-header">
<div class="header-icon">
<BarChart3 size={28} />
</div>
<div class="header-content">
<h1>Statistiken</h1>
<p class="header-subtitle">Deine Kontakte im Überblick</p>
</div>
</header>
{#if loading}
<StatisticsSkeleton statCards={6} legendItems={4} />
{:else}
<!-- Quick Stats -->
<section class="stats-section">
<StatsGrid items={statsItems} columns={6} />
</section>
<!-- Charts Grid -->
<div class="charts-grid">
<!-- Activity Heatmap -->
<section class="chart-section heatmap-section">
<ActivityHeatmap
data={contactsStatisticsStore.activityHeatmap}
itemName="Kontakt"
itemNamePlural="Kontakte"
/>
</section>
<!-- Weekly Trend + Status Donut -->
<div class="charts-row">
<section class="chart-section trend-section">
<TrendLineChart
data={contactsStatisticsStore.weeklyTrend}
itemName="Kontakt"
itemNamePlural="Kontakte"
/>
</section>
<section class="chart-section donut-section">
<DonutChart
data={contactsStatisticsStore.statusBreakdown}
title="Status"
centerLabel="Kontakte"
centerValue={contactsStatisticsStore.totalContacts}
/>
</section>
</div>
<!-- Info Completeness -->
<div class="charts-row">
<section class="chart-section info-section">
<DonutChart
data={contactsStatisticsStore.infoBreakdown}
title="Informationen"
centerLabel="Kontakte"
centerValue={contactsStatisticsStore.totalContacts}
/>
</section>
<section class="chart-section country-section">
<ProgressBars
data={contactsStatisticsStore.countryBreakdown}
title="Nach Land"
emptyMessage="Keine Länder angegeben"
/>
</section>
</div>
</div>
<!-- Additional Stats -->
<div class="additional-stats">
<div class="stat-card-small">
<span class="stat-label">Aktive Kontakte</span>
<span class="stat-value">{contactsStatisticsStore.activeContacts}</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Archivierte Kontakte</span>
<span class="stat-value">{contactsStatisticsStore.archivedContacts}</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Tags</span>
<span class="stat-value">{contactsStatisticsStore.totalTags}</span>
</div>
</div>
{/if}
</div>
<style>
.statistics-page {
padding-bottom: 6rem;
}
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
border-radius: 1rem;
}
.header-content h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
margin: 0;
}
.header-subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0 0 0;
}
.stats-section {
margin-bottom: 1.5rem;
}
.charts-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.charts-row {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 768px) {
.charts-row {
grid-template-columns: 1fr 1fr;
}
}
.chart-section {
min-width: 0;
}
.additional-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.stat-card-small {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
}
:global(.dark) .stat-card-small {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.stat-card-small .stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.stat-card-small .stat-value {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
}
</style>

View file

@ -0,0 +1,100 @@
# Contacts 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
---
## Geplante Aufräumarbeiten
### Priorität 1: Quick Wins (Hoher ROI)
#### ✅ 1.1 Statistiken entfernen
**Status:** Erledigt
**Geschätzte Ersparnis:** ~560 Zeilen
**Komplexität:** MITTEL | **Nutzen:** NIEDRIG
**Beschreibung:**
Analytics-Dashboard mit Activity Heatmap, Trend Charts, Donut Charts. Geringe Nutzung bei hoher Komplexität.
**Entfernte Dateien:**
- `src/lib/stores/statistics.svelte.ts` (~276 Zeilen)
- `src/routes/(app)/statistics/+page.svelte` (~282 Zeilen)
**Geänderte Dateien:**
- `src/routes/(app)/+layout.svelte` - Nav-Item entfernt
---
#### ✅ 1.2 Network View entfernen
**Status:** Erledigt
**Geschätzte Ersparnis:** ~1.100 Zeilen
**Komplexität:** HOCH | **Nutzen:** NIEDRIG
**Beschreibung:**
D3.js Force-Directed Graph zur Visualisierung von Kontakt-Beziehungen. Komplex (Force Simulation, Node Dragging, Filtering) aber wenig genutzt.
**Entfernte Dateien:**
- `src/lib/stores/network.svelte.ts` (~540 Zeilen)
- `src/lib/api/network.ts` (~50 Zeilen)
- `src/lib/components/network/NetworkGraph.svelte`
- `src/lib/components/network/NetworkControls.svelte`
- `src/lib/components/views/ContactNetworkView.svelte`
**Geänderte Dateien:**
- `src/lib/components/ContactList.svelte` - Network-View entfernt
- `src/lib/components/ContactsToolbarContent.svelte` - Network-Controls entfernt
---
#### ✅ 1.3 Session Contacts → Demo-Modus
**Status:** Erledigt
**Geschätzte Ersparnis:** ~100 Zeilen (netto)
**Komplexität:** MITTEL | **Nutzen:** HOCH (bessere UX)
**Beschreibung:**
Wie bei Calendar/Todo: Session-basiertes Kontakt-Management durch statischen Demo-Modus ersetzen. Statt frustrierender UX (Kontakte verschwinden bei Tab-Schließung) zeigt die App Beispiel-Kontakte.
**Entfernte Dateien:**
- `src/lib/stores/session-contacts.svelte.ts` (~236 Zeilen)
**Neue Dateien:**
- `src/lib/data/demo-contacts.ts` (~215 Zeilen) - 10 Demo-Kontakte
**Geänderte Dateien:**
- `src/lib/stores/contacts.svelte.ts` - Demo-Kontakte statt Session-Logik
- `src/routes/(app)/+layout.svelte` - Demo-Banner, Auth-Gate Events
- `src/lib/components/ContactList.svelte` - Auth-Gate bei Favoriten
- `src/lib/components/ContactDetailModal.svelte` - Auth-Gate bei Demo-Kontakten
- `src/lib/components/NewContactModal.svelte` - Auth-Gate bei Erstellung
- `src/lib/components/AuthGateModal.svelte` - Angepasste Demo-Modus Texte
---
## Zusammenfassung
| Phase | Features | LOC Ersparnis | Status |
|-------|----------|---------------|--------|
| 🟢 Prio 1.1 | Statistiken | ~560 | ✅ Erledigt |
| 🟢 Prio 1.2 | Network View | ~1.100 | ✅ Erledigt |
| 🟢 Prio 1.3 | Sessions → Demo | ~100 | ✅ Erledigt |
| **Gesamt** | | **~1.760** | ✅ |
**Erreicht:** ~20% Code-Reduktion bei gleichem/besserem Nutzererlebnis
---
## Changelog
| Datum | Aktion | Commit |
|-------|--------|--------|
| 2026-01-28 | Statistiken, Network View, Session Contacts entfernt; Demo-Modus implementiert | - |