feat(events): add Event Discovery — Phase 1 + 2

Phase 1: Manual iCal feeds + Discovery tab
- 5 new DB tables in event_discovery schema (regions, interests,
  sources, discovered_events, user_actions)
- iCal parser (node-ical) with deduplication (SHA-256 hash)
- Crawl scheduler (15-min interval, auto-deactivate after 5 errors)
- CRUD routes for regions, interests, sources + paginated feed endpoint
- Frontend: "Meine Events" / "Entdecken" tab navigation in ListView
- Discovery setup wizard (regions via mana-geocoding + interests)
- DiscoveredEventCard with save/dismiss, SourceManager for iCal feeds
- "Merken" creates a local socialEvent from discovered event

Phase 2: Auto source discovery + LLM extraction + relevance scoring
- Source discoverer: web search via mana-research to auto-find iCal
  feeds and venue websites for a region
- Website extractor: crawl via mana-research /extract, then LLM-based
  event extraction via mana-llm with structured JSON output
- Flexible date parsing (ISO, DD.MM.YYYY), markdown fence stripping
- Relevance scorer: category match, freetext match, haversine distance,
  time proximity, weekend bonus (0-100 clamped)
- Routes: POST regions/:id/discover-sources, PUT/DELETE sources/:id/activate|reject
- Frontend: "Automatisch finden" button, suggested vs active sources UI

107 tests (all passing), no regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-18 15:30:46 +02:00
parent 677123091a
commit b5d55fdb21
34 changed files with 5105 additions and 45 deletions

View file

@ -4,6 +4,7 @@
import { eventsStore } from './stores/events.svelte';
import { drainTombstones } from './tombstones';
import EventCard from './components/EventCard.svelte';
import DiscoveryTab from './components/DiscoveryTab.svelte';
import type { SocialEvent } from './types';
import type { ViewProps } from '$lib/app-registry';
@ -13,6 +14,8 @@
const past = usePastEvents();
const guestsByEvent = useGuestsByEvent();
let activeTab = $state<'mine' | 'discover'>('mine');
// Retry any orphaned server snapshots from previous failed deletes.
onMount(() => {
void drainTombstones();
@ -60,55 +63,72 @@
</svelte:head>
<div class="events-page">
<header class="events-header">
<p class="page-subtitle">
{(upcoming.value ?? []).length} bevorstehend · {(past.value ?? []).length} vergangen
</p>
<button class="new-btn" onclick={() => (showCreate = !showCreate)}>
{showCreate ? 'Abbrechen' : '+ Neues Event'}
<div class="tab-bar">
<button class="tab" class:active={activeTab === 'mine'} onclick={() => (activeTab = 'mine')}>
Meine Events
</button>
</header>
<button
class="tab"
class:active={activeTab === 'discover'}
onclick={() => (activeTab = 'discover')}
>
Entdecken
</button>
</div>
{#if showCreate}
<form class="create-form" onsubmit={handleCreate}>
<input
class="input"
bind:value={newTitle}
placeholder="Worum geht's? (z. B. Geburtstag Anna)"
required
/>
<div class="form-row">
<input class="input" type="date" bind:value={newDate} required />
<input class="input" type="time" bind:value={newTime} />
<input class="input" bind:value={newLocation} placeholder="Ort (optional)" />
</div>
<button type="submit" class="action-btn primary">Event anlegen</button>
</form>
{/if}
{#if activeTab === 'mine'}
<header class="events-header">
<p class="page-subtitle">
{(upcoming.value ?? []).length} bevorstehend · {(past.value ?? []).length} vergangen
</p>
<button class="new-btn" onclick={() => (showCreate = !showCreate)}>
{showCreate ? 'Abbrechen' : '+ Neues Event'}
</button>
</header>
<section class="event-section">
<h2 class="section-title">Bevorstehend</h2>
{#if (upcoming.value ?? []).length === 0}
<p class="empty">Keine bevorstehenden Events. Zeit für eine Party?</p>
{:else}
<div class="event-list">
{#each upcoming.value ?? [] as event (event.id)}
{@const summary = summarizeRsvps(guestsByEvent.value?.get(event.id) ?? [])}
<EventCard {event} {summary} onclick={() => open(event)} />
{/each}
</div>
{#if showCreate}
<form class="create-form" onsubmit={handleCreate}>
<input
class="input"
bind:value={newTitle}
placeholder="Worum geht's? (z. B. Geburtstag Anna)"
required
/>
<div class="form-row">
<input class="input" type="date" bind:value={newDate} required />
<input class="input" type="time" bind:value={newTime} />
<input class="input" bind:value={newLocation} placeholder="Ort (optional)" />
</div>
<button type="submit" class="action-btn primary">Event anlegen</button>
</form>
{/if}
</section>
{#if (past.value ?? []).length > 0}
<section class="event-section">
<h2 class="section-title">Vergangen</h2>
<div class="event-list">
{#each past.value ?? [] as event (event.id)}
<EventCard {event} onclick={() => open(event)} />
{/each}
</div>
<h2 class="section-title">Bevorstehend</h2>
{#if (upcoming.value ?? []).length === 0}
<p class="empty">Keine bevorstehenden Events. Zeit fur eine Party?</p>
{:else}
<div class="event-list">
{#each upcoming.value ?? [] as event (event.id)}
{@const summary = summarizeRsvps(guestsByEvent.value?.get(event.id) ?? [])}
<EventCard {event} {summary} onclick={() => open(event)} />
{/each}
</div>
{/if}
</section>
{#if (past.value ?? []).length > 0}
<section class="event-section">
<h2 class="section-title">Vergangen</h2>
<div class="event-list">
{#each past.value ?? [] as event (event.id)}
<EventCard {event} onclick={() => open(event)} />
{/each}
</div>
</section>
{/if}
{:else}
<DiscoveryTab />
{/if}
</div>
@ -121,6 +141,30 @@
max-width: 880px;
margin: 0 auto;
}
.tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid hsl(var(--color-border));
}
.tab {
padding: 0.5rem 1rem;
border: none;
background: none;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-family: inherit;
}
.tab.active {
color: hsl(var(--color-foreground));
border-bottom-color: hsl(var(--color-primary));
}
.tab:hover:not(.active) {
color: hsl(var(--color-foreground));
}
.events-header {
display: flex;
align-items: flex-start;

View file

@ -0,0 +1,204 @@
<script lang="ts">
import type { DiscoveredEvent } from '../discovery/types';
interface Props {
event: DiscoveredEvent;
onSave?: () => void;
onDismiss?: () => void;
}
let { event, onSave, onDismiss }: Props = $props();
const startDate = $derived(new Date(event.startAt));
const dateLabel = $derived(
startDate.toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: 'short',
})
);
const timeLabel = $derived(
event.allDay
? 'Ganztag'
: startDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
);
const isSaved = $derived(event.userAction === 'save');
</script>
<div class="discovered-card">
<div class="date-block">
<div class="date">{dateLabel}</div>
<div class="time">{timeLabel}</div>
</div>
<div class="card-body">
<div class="title-row">
<h3 class="title">{event.title}</h3>
{#if event.category}
<span class="category-badge">{event.category}</span>
{/if}
</div>
{#if event.location}
<div class="meta-line">{event.location}</div>
{/if}
{#if event.priceInfo}
<div class="meta-line">{event.priceInfo}</div>
{/if}
{#if event.description}
<div class="description">
{event.description.slice(0, 150)}{event.description.length > 150 ? '...' : ''}
</div>
{/if}
<div class="footer">
{#if event.sourceName}
<a class="source-link" href={event.sourceUrl} target="_blank" rel="noopener">
{event.sourceName}
</a>
{:else}
<a class="source-link" href={event.sourceUrl} target="_blank" rel="noopener"> Quelle </a>
{/if}
<div class="actions">
{#if isSaved}
<span class="saved-label">Gespeichert</span>
{:else}
<button class="action-btn save" onclick={onSave}>Merken</button>
<button class="action-btn dismiss" onclick={onDismiss}>Ausblenden</button>
{/if}
</div>
</div>
</div>
</div>
<style>
.discovered-card {
display: flex;
align-items: stretch;
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
background: hsl(var(--color-card));
overflow: hidden;
transition:
transform 0.1s ease,
box-shadow 0.15s;
}
.discovered-card:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.date-block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 4.5rem;
padding: 0.75rem 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.date {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
opacity: 0.9;
}
.time {
font-size: 0.8125rem;
font-weight: 700;
margin-top: 0.125rem;
}
.card-body {
flex: 1;
padding: 0.625rem 0.875rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.title {
flex: 1;
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-badge {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
}
.meta-line {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.description {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.25rem;
gap: 0.5rem;
}
.source-link {
font-size: 0.75rem;
color: hsl(var(--color-primary));
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-link:hover {
text-decoration: underline;
}
.actions {
display: flex;
gap: 0.375rem;
flex-shrink: 0;
}
.action-btn {
padding: 0.25rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
font-size: 0.75rem;
cursor: pointer;
font-family: inherit;
}
.action-btn.save {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: transparent;
}
.action-btn.dismiss {
opacity: 0.7;
}
.action-btn.dismiss:hover {
opacity: 1;
}
.saved-label {
font-size: 0.75rem;
font-weight: 600;
color: rgb(22, 163, 74);
}
</style>

View file

@ -0,0 +1,169 @@
<script lang="ts">
import { discoveryStore } from '../discovery/store.svelte';
import { EVENT_CATEGORIES } from '../discovery/types';
import RegionPicker from './RegionPicker.svelte';
interface Props {
onComplete?: () => void;
}
let { onComplete }: Props = $props();
let step = $state<1 | 2>(1);
let selectedCategories = $state<Set<string>>(new Set());
let freetext = $state('');
function toggleCategory(id: string) {
const next = new Set(selectedCategories);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
selectedCategories = next;
}
async function finishSetup() {
// Save interests
for (const cat of selectedCategories) {
await discoveryStore.addInterest({ category: cat });
}
if (freetext.trim()) {
for (const text of freetext
.split(',')
.map((t) => t.trim())
.filter(Boolean)) {
await discoveryStore.addInterest({ category: 'other', freetext: text });
}
}
onComplete?.();
}
const canProceed = $derived(discoveryStore.regions.length > 0);
const canFinish = $derived(selectedCategories.size > 0 || freetext.trim().length > 0);
</script>
<div class="setup">
<h2 class="setup-title">Event-Entdeckung einrichten</h2>
{#if step === 1}
<div class="step">
<p class="step-desc">Welche Regionen sollen nach Events durchsucht werden?</p>
<RegionPicker regions={discoveryStore.regions} />
<button class="next-btn" disabled={!canProceed} onclick={() => (step = 2)}> Weiter </button>
</div>
{:else}
<div class="step">
<p class="step-desc">Was interessiert dich?</p>
<div class="category-grid">
{#each EVENT_CATEGORIES as cat}
<button
class="category-chip"
class:selected={selectedCategories.has(cat.id)}
onclick={() => toggleCategory(cat.id)}
>
{cat.label}
</button>
{/each}
</div>
<input
class="input"
bind:value={freetext}
placeholder="Weitere Interessen (kommagetrennt, z.B. Impro-Theater, Rust Meetups)"
/>
<div class="step-actions">
<button class="back-btn" onclick={() => (step = 1)}>Zuruck</button>
<button class="next-btn" disabled={!canFinish} onclick={finishSetup}> Fertig </button>
</div>
</div>
{/if}
</div>
<style>
.setup {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-card));
}
.setup-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.step {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.step-desc {
margin: 0;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.category-grid {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.category-chip {
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 1rem;
background: hsl(var(--color-background));
font-size: 0.8125rem;
cursor: pointer;
color: hsl(var(--color-foreground));
transition:
background 0.1s,
border-color 0.1s;
font-family: inherit;
}
.category-chip.selected {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.input {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-background));
font-size: 0.875rem;
color: hsl(var(--color-foreground));
font-family: inherit;
}
.step-actions {
display: flex;
gap: 0.5rem;
}
.next-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
font-family: inherit;
}
.next-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.back-btn {
padding: 0.5rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
cursor: pointer;
font-family: inherit;
}
</style>

View file

@ -0,0 +1,188 @@
<script lang="ts">
import { onMount } from 'svelte';
import { discoveryStore } from '../discovery/store.svelte';
import DiscoverySetup from './DiscoverySetup.svelte';
import DiscoveredEventCard from './DiscoveredEventCard.svelte';
import RegionPicker from './RegionPicker.svelte';
import SourceManager from './SourceManager.svelte';
let initialized = $state(false);
let showSources = $state(false);
onMount(async () => {
await discoveryStore.init();
initialized = true;
});
async function handleSetupComplete() {
await discoveryStore.refreshFeed();
}
async function handleSave(eventId: string) {
await discoveryStore.saveEvent(eventId);
}
async function handleDismiss(eventId: string) {
await discoveryStore.dismissEvent(eventId);
}
</script>
<div class="discovery-tab">
{#if !initialized}
<p class="loading">Lade...</p>
{:else if !discoveryStore.isSetUp}
<DiscoverySetup onComplete={handleSetupComplete} />
{:else}
<div class="controls">
<RegionPicker regions={discoveryStore.regions} />
<div class="control-row">
<button class="control-btn" onclick={() => discoveryStore.refreshFeed()}>
Aktualisieren
</button>
<button
class="control-btn"
class:active={showSources}
onclick={() => (showSources = !showSources)}
>
Quellen {discoveryStore.sources.length > 0 ? `(${discoveryStore.sources.length})` : ''}
</button>
</div>
</div>
{#if showSources}
<SourceManager sources={discoveryStore.sources} regions={discoveryStore.regions} />
{/if}
{#if discoveryStore.loading}
<p class="loading">Lade Events...</p>
{:else if discoveryStore.error}
<p class="error-msg">{discoveryStore.error}</p>
{:else if discoveryStore.feed.length === 0}
<div class="empty">
<p class="empty-title">Noch keine Events gefunden</p>
<p class="empty-hint">
Fuge iCal-Feeds von Venues oder Vereinen hinzu, um Events zu entdecken.
</p>
{#if !showSources}
<button class="action-btn" onclick={() => (showSources = true)}>
Quellen verwalten
</button>
{/if}
</div>
{:else}
<div class="feed">
{#each discoveryStore.feed as event (event.id)}
<DiscoveredEventCard
{event}
onSave={() => handleSave(event.id)}
onDismiss={() => handleDismiss(event.id)}
/>
{/each}
{#if discoveryStore.feedHasMore}
<button
class="load-more"
onclick={() => discoveryStore.refreshFeed({ offset: discoveryStore.feed.length })}
>
Mehr laden
</button>
{/if}
</div>
{/if}
{/if}
</div>
<style>
.discovery-tab {
display: flex;
flex-direction: column;
gap: 1rem;
}
.controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.control-row {
display: flex;
gap: 0.375rem;
}
.control-btn {
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-card));
font-size: 0.8125rem;
cursor: pointer;
color: hsl(var(--color-foreground));
font-family: inherit;
}
.control-btn.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: transparent;
}
.loading {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
padding: 2rem 0;
margin: 0;
}
.error-msg {
font-size: 0.875rem;
color: rgb(220, 38, 38);
text-align: center;
padding: 1rem;
margin: 0;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 1rem;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.625rem;
text-align: center;
}
.empty-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.empty-hint {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.action-btn {
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-card));
font-size: 0.8125rem;
cursor: pointer;
color: hsl(var(--color-foreground));
font-family: inherit;
}
.feed {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.load-more {
padding: 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-card));
font-size: 0.8125rem;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
text-align: center;
font-family: inherit;
}
.load-more:hover {
color: hsl(var(--color-foreground));
}
</style>

View file

@ -0,0 +1,241 @@
<script lang="ts">
import type { DiscoveryRegion } from '../discovery/types';
import { discoveryStore } from '../discovery/store.svelte';
interface Props {
regions: DiscoveryRegion[];
}
let { regions }: Props = $props();
let searchQuery = $state('');
let suggestions = $state<{ label: string; lat: number; lon: number }[]>([]);
let searching = $state(false);
let showForm = $state(false);
let radiusKm = $state(25);
async function searchLocation() {
const q = searchQuery.trim();
if (q.length < 2) {
suggestions = [];
return;
}
searching = true;
try {
const res = await fetch(
`http://localhost:3018/api/v1/geocode/search?q=${encodeURIComponent(q)}&limit=5&lang=de`
);
if (res.ok) {
const data = await res.json();
suggestions = (data.results ?? []).map(
(r: { label: string; latitude: number; longitude: number }) => ({
label: r.label,
lat: r.latitude,
lon: r.longitude,
})
);
}
} catch {
suggestions = [];
} finally {
searching = false;
}
}
async function selectSuggestion(s: { label: string; lat: number; lon: number }) {
await discoveryStore.addRegion({
label: s.label.split(',')[0] ?? s.label,
lat: s.lat,
lon: s.lon,
radiusKm,
});
searchQuery = '';
suggestions = [];
showForm = false;
}
async function removeRegion(id: string) {
await discoveryStore.removeRegion(id);
}
let debounceTimer: ReturnType<typeof setTimeout>;
function onSearchInput() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(searchLocation, 300);
}
</script>
<div class="region-picker">
<div class="region-list">
{#each regions as region (region.id)}
<div class="region-chip">
<span class="region-label">{region.label}</span>
<span class="region-radius">{region.radiusKm} km</span>
<button class="remove-btn" onclick={() => removeRegion(region.id)}>x</button>
</div>
{/each}
{#if !showForm}
<button class="add-btn" onclick={() => (showForm = true)}>+ Region</button>
{/if}
</div>
{#if showForm}
<div class="search-form">
<input
class="input"
bind:value={searchQuery}
oninput={onSearchInput}
placeholder="Stadt oder Region suchen..."
/>
<div class="radius-row">
<label class="radius-label">Radius: {radiusKm} km</label>
<input type="range" min="5" max="100" step="5" bind:value={radiusKm} />
</div>
{#if suggestions.length > 0}
<ul class="suggestions">
{#each suggestions as s}
<li>
<button class="suggestion-btn" onclick={() => selectSuggestion(s)}>
{s.label}
</button>
</li>
{/each}
</ul>
{/if}
{#if searching}
<p class="searching-hint">Suche...</p>
{/if}
<button
class="cancel-btn"
onclick={() => {
showForm = false;
searchQuery = '';
suggestions = [];
}}
>
Abbrechen
</button>
</div>
{/if}
</div>
<style>
.region-picker {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.region-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.region-chip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 1rem;
background: hsl(var(--color-muted));
font-size: 0.8125rem;
}
.region-label {
font-weight: 500;
color: hsl(var(--color-foreground));
}
.region-radius {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.remove-btn {
background: none;
border: none;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
padding: 0 0.25rem;
font-family: inherit;
}
.remove-btn:hover {
color: hsl(var(--color-foreground));
}
.add-btn {
padding: 0.25rem 0.625rem;
border: 1px dashed hsl(var(--color-border));
border-radius: 1rem;
background: none;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-family: inherit;
}
.add-btn:hover {
color: hsl(var(--color-foreground));
border-color: hsl(var(--color-foreground));
}
.search-form {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-card));
}
.input {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-background));
font-size: 0.875rem;
color: hsl(var(--color-foreground));
font-family: inherit;
}
.radius-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.radius-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
}
.suggestions {
list-style: none;
margin: 0;
padding: 0;
}
.suggestion-btn {
width: 100%;
text-align: left;
padding: 0.375rem 0.5rem;
border: none;
background: none;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
cursor: pointer;
border-radius: 0.25rem;
font-family: inherit;
}
.suggestion-btn:hover {
background: hsl(var(--color-muted));
}
.searching-hint {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.cancel-btn {
align-self: flex-start;
padding: 0.25rem 0.5rem;
border: none;
background: none;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-family: inherit;
}
</style>

View file

@ -0,0 +1,384 @@
<script lang="ts">
import type { DiscoverySource, DiscoveryRegion } from '../discovery/types';
import { discoveryStore } from '../discovery/store.svelte';
interface Props {
sources: DiscoverySource[];
regions: DiscoveryRegion[];
}
let { sources, regions }: Props = $props();
let showForm = $state(false);
let newName = $state('');
let newUrl = $state('');
let newRegionId = $state('');
let discovering = $state(false);
const activeSources = $derived(sources.filter((s) => s.isActive));
const suggestedSources = $derived(sources.filter((s) => !s.isActive));
async function handleDiscover() {
const regionId = regions[0]?.id;
if (!regionId) return;
discovering = true;
try {
await discoveryStore.discoverSources(regionId);
} finally {
discovering = false;
}
}
async function handleActivate(id: string) {
await discoveryStore.activateSource(id);
}
async function handleReject(id: string) {
await discoveryStore.rejectSource(id);
}
const regionIdDefault = $derived(regions[0]?.id ?? '');
async function handleAdd(e: SubmitEvent) {
e.preventDefault();
const name = newName.trim();
const url = newUrl.trim();
const regionId = newRegionId || regionIdDefault;
if (!name || !url || !regionId) return;
await discoveryStore.addSource({ type: 'ical', url, name, regionId });
newName = '';
newUrl = '';
showForm = false;
}
async function handleRemove(id: string) {
await discoveryStore.removeSource(id);
}
async function handleCrawl(id: string) {
await discoveryStore.crawlSource(id);
}
function formatDate(iso: string | null): string {
if (!iso) return 'nie';
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
<div class="source-manager">
<div class="header">
<h3 class="section-title">Quellen</h3>
<div class="header-actions">
<button
class="discover-btn"
onclick={handleDiscover}
disabled={discovering || regions.length === 0}
>
{discovering ? 'Suche...' : 'Automatisch finden'}
</button>
{#if !showForm}
<button class="add-btn" onclick={() => (showForm = true)}>+ iCal-Feed</button>
{/if}
</div>
</div>
{#if showForm}
<form class="add-form" onsubmit={handleAdd}>
<input
class="input"
bind:value={newName}
placeholder="Name (z.B. Jazzhaus Freiburg)"
required
/>
<input class="input" bind:value={newUrl} placeholder="iCal URL (.ics)" type="url" required />
{#if regions.length > 1}
<select class="input" bind:value={newRegionId}>
{#each regions as r (r.id)}
<option value={r.id}>{r.label}</option>
{/each}
</select>
{/if}
<div class="form-actions">
<button type="submit" class="action-btn primary">Hinzufugen</button>
<button type="button" class="action-btn" onclick={() => (showForm = false)}
>Abbrechen</button
>
</div>
</form>
{/if}
{#if suggestedSources.length > 0}
<div class="suggestions-section">
<h4 class="sub-title">Vorgeschlagene Quellen</h4>
<div class="source-list">
{#each suggestedSources as source (source.id)}
<div class="source-item suggested">
<div class="source-info">
<div class="source-name">{source.name}</div>
<div class="source-meta">
{source.type.toUpperCase()}
{#if source.url}
· <a class="source-url" href={source.url} target="_blank" rel="noopener"
>{new URL(source.url).hostname}</a
>
{/if}
</div>
</div>
<div class="source-actions">
<button class="icon-btn activate" onclick={() => handleActivate(source.id)}
>Aktivieren</button
>
<button class="icon-btn danger" onclick={() => handleReject(source.id)}>x</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if activeSources.length === 0 && suggestedSources.length === 0}
<p class="empty">
Noch keine Quellen. Nutze "Automatisch finden" oder fuge iCal-Feeds manuell hinzu.
</p>
{:else if activeSources.length > 0}
<div class="source-list">
{#each activeSources as source (source.id)}
<div class="source-item" class:error={source.errorCount > 0}>
<div class="source-info">
<div class="source-name">{source.name}</div>
<div class="source-meta">
{source.type.toUpperCase()} · Letzter Scan: {formatDate(source.lastCrawledAt)}
{#if source.errorCount > 0}
<span class="error-badge">{source.errorCount} Fehler</span>
{/if}
</div>
{#if source.lastError}
<div class="source-error">{source.lastError}</div>
{/if}
</div>
<div class="source-actions">
<button class="icon-btn" onclick={() => handleCrawl(source.id)} title="Jetzt scannen">
Scannen
</button>
<button
class="icon-btn danger"
onclick={() => handleRemove(source.id)}
title="Entfernen"
>
x
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.source-manager {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-actions {
display: flex;
gap: 0.375rem;
}
.discover-btn {
padding: 0.25rem 0.625rem;
border: 1px solid hsl(var(--color-primary));
border-radius: 0.375rem;
background: none;
font-size: 0.75rem;
color: hsl(var(--color-primary));
cursor: pointer;
font-family: inherit;
}
.discover-btn:hover {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.discover-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.suggestions-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.sub-title {
margin: 0;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--color-primary));
}
.source-item.suggested {
border-color: hsl(var(--color-primary) / 0.3);
background: hsl(var(--color-primary) / 0.05);
}
.source-url {
color: hsl(var(--color-primary));
text-decoration: none;
font-size: 0.6875rem;
}
.source-url:hover {
text-decoration: underline;
}
.icon-btn.activate {
color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
}
.section-title {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.add-btn {
padding: 0.25rem 0.625rem;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.375rem;
background: none;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-family: inherit;
}
.add-form {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-card));
}
.input {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-background));
font-size: 0.875rem;
color: hsl(var(--color-foreground));
font-family: inherit;
}
.form-actions {
display: flex;
gap: 0.375rem;
}
.action-btn {
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-card));
font-size: 0.8125rem;
cursor: pointer;
font-family: inherit;
color: hsl(var(--color-foreground));
}
.action-btn.primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: transparent;
}
.source-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.source-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-card));
}
.source-item.error {
border-color: rgba(239, 68, 68, 0.3);
}
.source-item.inactive {
opacity: 0.5;
}
.source-info {
flex: 1;
min-width: 0;
}
.source-name {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.source-meta {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.error-badge {
font-size: 0.625rem;
font-weight: 600;
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
background: rgba(239, 68, 68, 0.15);
color: rgb(220, 38, 38);
}
.inactive-badge {
font-size: 0.625rem;
font-weight: 600;
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
}
.source-error {
font-size: 0.6875rem;
color: rgb(220, 38, 38);
margin-top: 0.125rem;
}
.source-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.icon-btn {
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.25rem;
background: none;
font-size: 0.75rem;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
font-family: inherit;
}
.icon-btn:hover {
color: hsl(var(--color-foreground));
}
.icon-btn.danger:hover {
color: rgb(220, 38, 38);
}
.empty {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
</style>

View file

@ -0,0 +1,170 @@
/**
* Discovery HTTP client JWT-authenticated calls to mana-events discovery endpoints.
*/
import { authStore } from '$lib/stores/auth.svelte';
import { getManaEventsUrl } from '$lib/api/config';
import type { DiscoveryRegion, DiscoveryInterest, DiscoverySource, DiscoveredEvent } from './types';
async function fetchWithAuth<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = await authStore.getValidToken();
const res = await fetch(`${getManaEventsUrl()}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Request failed' }));
throw new Error(err.message || `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
}
// ─── Regions ────────────────────────────────────────────────────────
export async function getRegions(): Promise<DiscoveryRegion[]> {
const { regions } = await fetchWithAuth<{ regions: DiscoveryRegion[] }>(
'/api/v1/discovery/regions'
);
return regions;
}
export async function createRegion(input: {
label: string;
lat: number;
lon: number;
radiusKm?: number;
}): Promise<DiscoveryRegion> {
const { region } = await fetchWithAuth<{ region: DiscoveryRegion }>('/api/v1/discovery/regions', {
method: 'POST',
body: JSON.stringify(input),
});
return region;
}
export async function updateRegion(
id: string,
input: { label?: string; radiusKm?: number; isActive?: boolean }
): Promise<DiscoveryRegion> {
const { region } = await fetchWithAuth<{ region: DiscoveryRegion }>(
`/api/v1/discovery/regions/${id}`,
{ method: 'PUT', body: JSON.stringify(input) }
);
return region;
}
export async function deleteRegion(id: string): Promise<void> {
await fetchWithAuth(`/api/v1/discovery/regions/${id}`, { method: 'DELETE' });
}
// ─── Interests ──────────────────────────────────────────────────────
export async function getInterests(): Promise<DiscoveryInterest[]> {
const { interests } = await fetchWithAuth<{ interests: DiscoveryInterest[] }>(
'/api/v1/discovery/interests'
);
return interests;
}
export async function createInterest(input: {
category: string;
freetext?: string | null;
weight?: number;
}): Promise<DiscoveryInterest> {
const { interest } = await fetchWithAuth<{ interest: DiscoveryInterest }>(
'/api/v1/discovery/interests',
{ method: 'POST', body: JSON.stringify(input) }
);
return interest;
}
export async function deleteInterest(id: string): Promise<void> {
await fetchWithAuth(`/api/v1/discovery/interests/${id}`, { method: 'DELETE' });
}
// ─── Sources ────────────────────────────────────────────────────────
export async function getSources(): Promise<DiscoverySource[]> {
const { sources } = await fetchWithAuth<{ sources: DiscoverySource[] }>(
'/api/v1/discovery/sources'
);
return sources;
}
export async function createSource(input: {
type: 'ical' | 'website';
url: string;
name: string;
regionId: string;
crawlIntervalHours?: number;
}): Promise<DiscoverySource> {
const { source } = await fetchWithAuth<{ source: DiscoverySource }>('/api/v1/discovery/sources', {
method: 'POST',
body: JSON.stringify(input),
});
return source;
}
export async function deleteSource(id: string): Promise<void> {
await fetchWithAuth(`/api/v1/discovery/sources/${id}`, { method: 'DELETE' });
}
export async function crawlSourceNow(id: string): Promise<{ upserted: number; error?: string }> {
return fetchWithAuth(`/api/v1/discovery/sources/${id}/crawl`, { method: 'POST' });
}
export async function activateSource(id: string): Promise<DiscoverySource> {
const { source } = await fetchWithAuth<{ source: DiscoverySource }>(
`/api/v1/discovery/sources/${id}/activate`,
{ method: 'PUT' }
);
return source;
}
export async function rejectSource(id: string): Promise<void> {
await fetchWithAuth(`/api/v1/discovery/sources/${id}/reject`, { method: 'DELETE' });
}
export async function discoverSources(
regionId: string
): Promise<{ suggestedCount: number; queries: number; searchResults: number }> {
return fetchWithAuth(`/api/v1/discovery/regions/${regionId}/discover-sources`, {
method: 'POST',
});
}
// ─── Feed ───────────────────────────────────────────────────────────
export interface FeedParams {
from?: string;
to?: string;
category?: string;
limit?: number;
offset?: number;
hideDismissed?: boolean;
}
export async function getFeed(
params: FeedParams = {}
): Promise<{ events: DiscoveredEvent[]; total: number; hasMore: boolean }> {
const searchParams = new URLSearchParams();
if (params.from) searchParams.set('from', params.from);
if (params.to) searchParams.set('to', params.to);
if (params.category) searchParams.set('category', params.category);
if (params.limit) searchParams.set('limit', String(params.limit));
if (params.offset) searchParams.set('offset', String(params.offset));
if (params.hideDismissed) searchParams.set('hideDismissed', 'true');
const qs = searchParams.toString();
return fetchWithAuth(`/api/v1/discovery/feed${qs ? `?${qs}` : ''}`);
}
export async function setEventAction(eventId: string, action: 'save' | 'dismiss'): Promise<void> {
await fetchWithAuth(`/api/v1/discovery/feed/${eventId}/action`, {
method: 'POST',
body: JSON.stringify({ action }),
});
}

View file

@ -0,0 +1,206 @@
/**
* Discovery store reactive state for regions, interests, sources, and feed.
*
* Server-authoritative: all reads fetch from mana-events, no Dexie.
* State is held in Svelte 5 runes ($state) and refreshed on mount / mutation.
*/
import * as api from './api';
import type { DiscoveryRegion, DiscoveryInterest, DiscoverySource, DiscoveredEvent } from './types';
import { eventsStore } from '../stores/events.svelte';
// ─── State ──────────────────────────────────────────────────────────
let regions = $state<DiscoveryRegion[]>([]);
let interests = $state<DiscoveryInterest[]>([]);
let sources = $state<DiscoverySource[]>([]);
let feed = $state<DiscoveredEvent[]>([]);
let feedHasMore = $state(false);
let loading = $state(false);
let error = $state<string | null>(null);
// ─── Loaders ────────────────────────────────────────────<E29480><E29480>───────────
async function loadRegions() {
try {
regions = await api.getRegions();
} catch (e) {
console.error('[discovery] failed to load regions:', e);
}
}
async function loadInterests() {
try {
interests = await api.getInterests();
} catch (e) {
console.error('[discovery] failed to load interests:', e);
}
}
async function loadSources() {
try {
sources = await api.getSources();
} catch (e) {
console.error('[discovery] failed to load sources:', e);
}
}
async function loadFeed(params: api.FeedParams = {}) {
loading = true;
error = null;
try {
const result = await api.getFeed({ hideDismissed: true, ...params });
feed = result.events;
feedHasMore = result.hasMore;
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden';
console.error('[discovery] failed to load feed:', e);
} finally {
loading = false;
}
}
// ─── Exported store ─────────────────────────────────────────────────
export const discoveryStore = {
// Reactive getters
get regions() {
return regions;
},
get interests() {
return interests;
},
get sources() {
return sources;
},
get feed() {
return feed;
},
get feedHasMore() {
return feedHasMore;
},
get loading() {
return loading;
},
get error() {
return error;
},
get isSetUp() {
return regions.length > 0;
},
// ── Init ─────────────────────────────────────────────────────
async init() {
await Promise.all([loadRegions(), loadInterests(), loadSources()]);
if (regions.length > 0) {
await loadFeed();
}
},
async refreshFeed(params?: api.FeedParams) {
await loadFeed(params);
},
// ── Regions ──────────────────────────────────────────────────
async addRegion(input: { label: string; lat: number; lon: number; radiusKm?: number }) {
const region = await api.createRegion(input);
regions = [...regions, region];
return region;
},
async updateRegion(id: string, input: { label?: string; radiusKm?: number; isActive?: boolean }) {
const region = await api.updateRegion(id, input);
regions = regions.map((r) => (r.id === id ? region : r));
return region;
},
async removeRegion(id: string) {
await api.deleteRegion(id);
regions = regions.filter((r) => r.id !== id);
},
// ── Interests ────────────────────────────────────────────────
async addInterest(input: { category: string; freetext?: string | null; weight?: number }) {
const interest = await api.createInterest(input);
interests = [...interests, interest];
return interest;
},
async removeInterest(id: string) {
await api.deleteInterest(id);
interests = interests.filter((i) => i.id !== id);
},
// ── Sources <20><><EFBFBD>─────────────────────────────────────────────────
async addSource(input: {
type: 'ical' | 'website';
url: string;
name: string;
regionId: string;
crawlIntervalHours?: number;
}) {
const source = await api.createSource(input);
sources = [...sources, source];
// Trigger immediate crawl
api
.crawlSourceNow(source.id)
.then(() => loadFeed())
.catch(() => {});
return source;
},
async removeSource(id: string) {
await api.deleteSource(id);
sources = sources.filter((s) => s.id !== id);
},
async crawlSource(id: string) {
const result = await api.crawlSourceNow(id);
await loadSources();
await loadFeed();
return result;
},
async activateSource(id: string) {
const source = await api.activateSource(id);
sources = sources.map((s) => (s.id === id ? source : s));
},
async rejectSource(id: string) {
await api.rejectSource(id);
sources = sources.filter((s) => s.id !== id);
},
async discoverSources(regionId: string) {
const result = await api.discoverSources(regionId);
await loadSources(); // refresh to include new suggestions
return result;
},
// ── Feed Actions ─────────────────────────────────────────────
async saveEvent(eventId: string) {
const event = feed.find((e) => e.id === eventId);
if (!event) return;
await api.setEventAction(eventId, 'save');
feed = feed.map((e) => (e.id === eventId ? { ...e, userAction: 'save' as const } : e));
// Create a local socialEvent from the discovered event
const startMs = new Date(event.startAt).getTime();
const fallbackEnd = new Date(startMs + 2 * 60 * 60 * 1000).toISOString();
await eventsStore.createEvent({
title: event.title,
startTime: event.startAt,
endTime: event.endAt ?? fallbackEnd,
location: event.location ?? undefined,
description: event.description
? `${event.description}\n\nQuelle: ${event.sourceUrl}`
: `Quelle: ${event.sourceUrl}`,
});
},
async dismissEvent(eventId: string) {
await api.setEventAction(eventId, 'dismiss');
feed = feed.filter((e) => e.id !== eventId);
},
};

View file

@ -0,0 +1,72 @@
/**
* Discovery types shared between API client, queries, and UI components.
*/
export interface DiscoveryRegion {
id: string;
label: string;
lat: number;
lon: number;
radiusKm: number;
isActive: boolean;
createdAt: string;
}
export interface DiscoveryInterest {
id: string;
category: string;
freetext: string | null;
weight: number;
createdAt: string;
}
export interface DiscoverySource {
id: string;
type: 'ical' | 'website';
url: string | null;
name: string;
regionId: string | null;
crawlIntervalHours: number;
lastCrawledAt: string | null;
lastSuccessAt: string | null;
errorCount: number;
lastError: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface DiscoveredEvent {
id: string;
title: string;
description: string | null;
location: string | null;
lat: number | null;
lon: number | null;
startAt: string;
endAt: string | null;
allDay: boolean;
imageUrl: string | null;
sourceUrl: string;
sourceName: string | null;
category: string | null;
priceInfo: string | null;
crawledAt: string;
userAction: 'save' | 'dismiss' | null;
}
export const EVENT_CATEGORIES = [
{ id: 'music', label: 'Musik' },
{ id: 'theater', label: 'Theater' },
{ id: 'art', label: 'Kunst' },
{ id: 'tech', label: 'Tech' },
{ id: 'sport', label: 'Sport' },
{ id: 'food', label: 'Kulinarik' },
{ id: 'family', label: 'Familie' },
{ id: 'nature', label: 'Natur' },
{ id: 'education', label: 'Bildung' },
{ id: 'community', label: 'Community' },
{ id: 'nightlife', label: 'Nachtleben' },
{ id: 'market', label: 'Markt' },
{ id: 'other', label: 'Sonstiges' },
] as const;