Ka-Note/ka-note/client/src/lib/components/JournalView.svelte

620 lines
22 KiB
Svelte

<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/db/schema';
import { getOrCreateJournalTopic, createHistoryEntry, updateHistoryEntry, softDeleteHistoryEntry, createTopic, JOURNAL_TOPIC_ID } from '$lib/db/repositories';
import { today } from '$lib/db/helpers';
import { mention } from '$lib/actions/mention';
import MarkdownEditor from './MarkdownEditor.svelte';
import RenderedMarkdown from './RenderedMarkdown.svelte';
import DateNavigator from './DateNavigator.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import WiedervorlageSection from './WiedervorlageSection.svelte';
import LinkTitle from './LinkTitle.svelte';
import { get } from 'svelte/store';
import { currentScope, scopeSettings } from '$lib/stores/scopeContext';
import { normalizeTitleAndBody, extractTitleAndBody } from '$lib/utils/titleUtils';
import { useUnsavedGuard } from '$lib/utils/unsavedGuard.svelte';
import EventCard from './EventCard.svelte';
import { eventsForDate } from '$lib/stores/agenda';
import { createEvent, updateEventNotes } from '$lib/db/repositories';
import { fetchCalendarEvents, type CalendarEvent } from '$lib/utils/calendarApi';
import type { PersonMeta } from '@ka-note/shared';
interface Props {
contextId: string;
journalScope?: 'business' | 'private';
initialDate?: string;
}
let { contextId, journalScope = 'business', initialDate }: Props = $props();
$effect(() => { currentScope.set(journalScope); });
const isDailyLog = $derived(contextId === 'daily-log');
// --- Daily-log journal mode ---
let entryText = $state('');
let entryEditor: MarkdownEditor;
let selectedDate = $state(initialDate ?? today());
let selectedLinkedContextId = $state('');
let wiedervorlageChecked = $state(true);
let meetingPickerOpen = $state(false);
useUnsavedGuard(() => entryText.trim() !== '' || editingId !== null);
// All meeting contexts for the link dropdown
const meetingContexts = liveQuery(() =>
db.contexts
.filter(c => !c.deletedAt && c.type === 'meeting' && c.id !== 'daily-log')
.sortBy('sortOrder')
);
// All journal entries (daily-log mode)
const journalEntries = liveQuery(async () => {
if (!isDailyLog) return [];
const topic = await db.topics.get(JOURNAL_TOPIC_ID);
if (!topic) return [];
return db.historyEntries
.where('topicId').equals(JOURNAL_TOPIC_ID)
.filter(h => !h.deletedAt)
.toArray();
});
// Filter journal entries by selected date and scope
const filteredEntries = $derived(
($journalEntries ?? [])
.filter(e => e.date === selectedDate && (journalScope === 'private' ? !!e.isPrivate : !e.isPrivate))
.sort((a, b) => b.sortOrder - a.sortOrder)
);
// Context name lookup for linked entries
const contextNameMap = $derived(() => {
const map = new Map<string, string>();
for (const ctx of $meetingContexts ?? []) {
map.set(ctx.id, ctx.name);
}
return map;
});
async function resolveUrlTitle(url: string): Promise<string | null> {
try {
const res = await fetch(`/api/fetch-title?url=${encodeURIComponent(url)}`);
const data = await res.json() as { title?: string };
return data.title || null;
} catch {
return null;
}
}
async function handleAddEntry() {
const raw = entryText.trim();
if (!raw) return;
const { maxTitleLength } = get(scopeSettings);
const extracted = extractTitleAndBody(raw, maxTitleLength);
let title = extracted.title;
let body = extracted.body;
// If title is a URL, try to fetch its page title
if (/^https?:\/\//i.test(title)) {
const fetched = await resolveUrlTitle(title);
if (fetched) {
body = body ? `[${title}](${title})\n${body}` : `[${title}](${title})`;
title = fetched;
}
}
const isPrivate = journalScope === 'private';
if (selectedLinkedContextId) {
const topic = await createTopic(selectedLinkedContextId, title, isPrivate);
if (body) {
await createHistoryEntry(topic.id, selectedDate, body, null, false, isPrivate);
}
} else {
const text = body ? `${title}\n\n${body}` : title;
await getOrCreateJournalTopic();
await createHistoryEntry(JOURNAL_TOPIC_ID, selectedDate, text, null, wiedervorlageChecked, isPrivate);
}
entryText = '';
entryEditor?.clear();
selectedLinkedContextId = '';
meetingPickerOpen = false;
wiedervorlageChecked = true;
}
let confirmDeleteId = $state<string | null>(null);
// --- Edit mode ---
let editingId = $state<string | null>(null);
let editTitle = $state('');
let editBody = $state('');
let editIsPrivate = $state(false);
let editDate = $state('');
function startEdit(entry: { id: string; text: string; date: string; isPrivate?: boolean }) {
const lines = entry.text.split('\n');
editingId = entry.id;
editTitle = lines[0].replace(/^#{1,6}\s+/, '');
editBody = lines.slice(1).join('\n').trim();
editIsPrivate = !!entry.isPrivate;
editDate = entry.date;
}
function cancelEdit() {
editingId = null;
}
async function saveEdit() {
if (!editingId || !editTitle.trim()) return;
const text = editBody.trim() ? `${editTitle.trim()}\n${editBody.trim()}` : editTitle.trim();
await updateHistoryEntry(editingId, text, editIsPrivate, editDate || undefined);
editingId = null;
}
async function handleDelete(id: string) {
confirmDeleteId = id;
}
async function confirmDelete() {
if (confirmDeleteId) await softDeleteHistoryEntry(confirmDeleteId);
confirmDeleteId = null;
}
function formatTime(entry: { sortOrder: number; updatedAt: string }): string {
try {
// sortOrder >= 1e12 means it was set as Date.now() (ms timestamp) → use as creation time
const ts = entry.sortOrder >= 1_000_000_000_000
? new Date(entry.sortOrder)
: new Date(entry.updatedAt);
return ts.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} catch {
return '';
}
}
// --- Meeting journal mode ---
const meetingEntries = liveQuery(async () => {
if (isDailyLog) return [];
const topics = await db.topics
.where('contextId').equals(contextId)
.filter(t => !t.deletedAt)
.toArray();
const topicMap = new Map(topics.map(t => [t.id, t.title]));
const history = await db.historyEntries
.where('topicId').anyOf(topics.map(t => t.id))
.filter(h => !h.deletedAt)
.toArray();
// Also fetch journal entries linked to this context
const linked = await db.historyEntries
.where('linkedContextId').equals(contextId)
.filter(h => !h.deletedAt)
.toArray();
const all = [
...history.map(h => ({ ...h, topicTitle: topicMap.get(h.topicId) ?? '', isLinked: false })),
...linked.map(h => ({ ...h, topicTitle: 'Journal', isLinked: true }))
];
return all.sort((a, b) => b.date.localeCompare(a.date));
});
const meetingByDate = $derived(() => {
const groups = new Map<string, typeof $meetingEntries>();
for (const entry of $meetingEntries ?? []) {
const existing = groups.get(entry.date) ?? [];
existing.push(entry);
groups.set(entry.date, existing);
}
return [...groups.entries()].sort(([a], [b]) => b.localeCompare(a));
});
// --- Events (one-off meetings) ---
let showNewEventForm = $state(false);
let newEventTitle = $state('');
let newEventTime = $state('');
let newEventParticipants = $state('');
let pendingBodyPreview = $state('');
// Calendar picker
let calendarPickerOpen = $state(false);
let calendarLoading = $state(false);
let calendarError = $state<string | null>(null);
let calendarEvents = $state<CalendarEvent[]>([]);
async function openCalendarPicker() {
if (!showNewEventForm) openNewEventForm();
calendarLoading = true;
calendarError = null;
calendarPickerOpen = true;
calendarEvents = [];
try {
calendarEvents = await fetchCalendarEvents(selectedDate);
} catch {
calendarError = 'Kalender nicht verfügbar';
} finally {
calendarLoading = false;
}
}
async function selectCalendarEvent(ev: CalendarEvent) {
newEventTitle = ev.subject;
newEventTime = ev.start;
pendingBodyPreview = ev.bodyPreview;
const persons = await db.contexts
.filter(c => !c.deletedAt && c.type === 'person')
.toArray();
const mentions = ev.attendees.map(att => {
const match = persons.find(
p => (p.meta as PersonMeta | null)?.email?.toLowerCase() === att.email.toLowerCase()
);
const name = match ? match.name : att.name;
return name.includes(' ') ? `@"${name}"` : `@${name}`;
});
newEventParticipants = mentions.join(' ');
calendarPickerOpen = false;
}
const dateEvents = $derived(eventsForDate(selectedDate));
function openNewEventForm() {
const now = new Date();
newEventTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
newEventTitle = '';
newEventParticipants = '';
showNewEventForm = true;
}
function parseEventParticipants(raw: string): string[] {
const results: string[] = [];
const re = /@"([^"]+)"|@([\w\-\u00C0-\u024F]+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
results.push((m[1] ?? m[2]).trim());
}
return results.filter(Boolean);
}
async function handleCreateEvent() {
const title = newEventTitle.trim();
if (!title) return;
const participants = parseEventParticipants(newEventParticipants);
const event = await createEvent(selectedDate, newEventTime || '00:00', title, participants);
if (pendingBodyPreview.trim()) {
await updateEventNotes(event.id, pendingBodyPreview.trim());
}
newEventTitle = '';
newEventTime = '';
newEventParticipants = '';
pendingBodyPreview = '';
showNewEventForm = false;
}
// Birthday banner — filtered by scope
const allPersons = liveQuery(() =>
db.contexts.filter(c => !c.deletedAt && c.type === 'person').toArray()
);
const BUSINESS_SUBTYPES = new Set(['employee', 'colleague']);
const PRIVATE_SUBTYPES = new Set(['family', 'acquaintance']);
const birthdayPersons = $derived(
($allPersons ?? []).filter(p => {
const meta = p.meta as { birthday?: string; personSubType?: string } | null;
const bd = meta?.birthday;
if (!bd || bd.slice(5) !== selectedDate.slice(5)) return false;
const sub = meta?.personSubType;
if (journalScope === 'private') return !!sub && PRIVATE_SUBTYPES.has(sub);
// business: employee/colleague/contact/undefined
return !sub || !PRIVATE_SUBTYPES.has(sub);
})
);
</script>
{#if isDailyLog}
<!-- Daily-log: chronological entry log with date navigation -->
<DateNavigator {selectedDate} onchange={(d) => selectedDate = d} />
{#if birthdayPersons.length > 0}
<div class="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-[#d4a017] bg-[#2a1f00] px-4 py-3">
<span class="text-lg">🎂</span>
<span class="text-sm font-bold text-[#d4a017]">Geburtstag heute:</span>
{#each birthdayPersons as p}
<span class="rounded-full bg-[#d4a017]/20 px-2 py-0.5 text-sm text-[#f0c040]">{p.name}</span>
{/each}
</div>
{/if}
<div class="mb-8 flex flex-col gap-2.5 rounded-lg border border-border bg-sidebar p-4" class:hidden={editingId !== null}>
<MarkdownEditor
bind:this={entryEditor}
placeholder="Was ist passiert? (1. Zeile = Titel)"
minHeight="60px"
wikiScope={journalScope === 'private'}
onchange={(md) => entryText = md}
onsave={handleAddEntry}
/>
<div class="flex items-center gap-2">
<button
class="rounded px-4 py-2 font-bold text-white hover:brightness-110"
style="background-color: {journalScope === 'private' ? $scopeSettings.privateColor : $scopeSettings.businessColor}"
onclick={handleAddEntry}
>
+ {selectedLinkedContextId ? 'Thema hinzufügen' : 'Notiz hinzufügen'}
</button>
{#if !selectedLinkedContextId}
<button
type="button"
class="rounded border p-1.5 text-base leading-none transition-colors {wiedervorlageChecked ? 'border-amber-500 bg-amber-500/20 text-amber-400' : 'border-[#444] text-muted hover:border-[#666] hover:text-white'}"
onclick={() => wiedervorlageChecked = !wiedervorlageChecked}
title="Wiedervorlage"
>{wiedervorlageChecked ? '⏰' : '○'}</button>
{/if}
<!-- Meeting picker button -->
<div class="relative {journalScope === 'private' ? 'hidden' : ''}">
<button
type="button"
class="flex items-center gap-1 rounded border px-2 py-1.5 text-sm transition-colors {selectedLinkedContextId ? 'border-accent bg-accent/20 text-accent' : 'border-[#444] bg-bg text-muted hover:text-white'}"
onclick={() => meetingPickerOpen = !meetingPickerOpen}
title="Jour-fix verknüpfen"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
</button>
{#if meetingPickerOpen}
<div class="absolute left-0 top-full z-50 mt-1 min-w-48 rounded border border-[#444] bg-[#1e1e1e] py-1 shadow-lg">
<button
class="w-full px-3 py-1.5 text-left text-sm text-muted hover:bg-[#2a2a2a] {!selectedLinkedContextId ? 'text-white' : ''}"
onclick={() => { selectedLinkedContextId = ''; meetingPickerOpen = false; }}
> kein Jour-fix —</button>
{#each $meetingContexts ?? [] as ctx}
<button
class="w-full px-3 py-1.5 text-left text-sm hover:bg-[#2a2a2a] {selectedLinkedContextId === ctx.id ? 'text-accent' : 'text-white'}"
onclick={() => { selectedLinkedContextId = ctx.id; meetingPickerOpen = false; }}
>{ctx.name}</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
<WiedervorlageSection date={selectedDate} {journalScope} />
<!-- Events (one-off meetings) for selected date -->
{#if journalScope === 'business'}
<div class="mb-6">
<div class="mb-2 flex items-center justify-between">
<span class="text-sm font-semibold text-muted">Meetings</span>
<button
class="rounded px-2 py-1 text-sm text-muted hover:bg-[#2a2a2a] hover:text-white"
onclick={openNewEventForm}
>+ Meeting</button>
</div>
{#if showNewEventForm}
<div class="mb-3 flex flex-col gap-2 rounded border border-[#333] bg-card-bg p-3">
<input
class="w-full rounded border border-[#444] bg-bg px-2 py-1 text-sm text-white placeholder-[#666]"
type="text"
bind:value={newEventTitle}
placeholder="Titel *"
/>
<div class="flex gap-2">
<input
class="w-28 rounded border border-[#444] bg-bg px-2 py-1 text-sm text-white"
type="time"
bind:value={newEventTime}
/>
<div class="relative flex-1">
<input
class="w-full rounded border border-[#444] bg-bg px-2 py-1 text-sm text-white placeholder-[#666]"
type="text"
bind:value={newEventParticipants}
placeholder="Teilnehmer (@Name, ...)"
use:mention
/>
</div>
</div>
<div class="flex gap-2">
<button
class="rounded bg-accent px-3 py-1 text-sm font-bold text-white hover:brightness-110 disabled:opacity-50"
onclick={handleCreateEvent}
disabled={!newEventTitle.trim()}
>Erstellen</button>
<button
class="rounded bg-[#444] px-3 py-1 text-sm text-white hover:bg-[#555]"
onclick={() => (showNewEventForm = false)}
>Abbrechen</button>
<button
type="button"
class="ml-auto rounded border border-[#444] px-3 py-1 text-sm text-muted hover:border-[#666] hover:text-white"
onclick={openCalendarPicker}
>Aus Kalender</button>
</div>
</div>
{#if calendarPickerOpen}
<div class="mb-3 rounded border border-[#444] bg-[#1e1e1e] shadow-lg">
{#if calendarLoading}
<div class="px-3 py-2 text-sm text-muted">Lade...</div>
{:else if calendarError}
<div class="px-3 py-2 text-sm text-red-400">{calendarError}</div>
{:else if calendarEvents.length === 0}
<div class="px-3 py-2 text-sm text-muted">Keine Termine für diesen Tag</div>
{:else}
{#each calendarEvents as ev (ev.id)}
<button
class="w-full px-3 py-2 text-left text-sm hover:bg-[#2a2a2a]"
onclick={() => selectCalendarEvent(ev)}
>
<span class="font-mono text-[#aaa]">{ev.start}</span>
<span class="ml-2 text-white">{ev.subject}</span>
{#if ev.attendees.length > 0}
<span class="ml-2 text-xs text-muted">{ev.attendees.map(a => a.name).join(', ')}</span>
{/if}
</button>
{/each}
{/if}
<button
class="w-full border-t border-[#333] px-3 py-1.5 text-left text-xs text-muted hover:bg-[#2a2a2a]"
onclick={() => (calendarPickerOpen = false)}
>Schließen</button>
</div>
{/if}
{/if}
{#each $dateEvents ?? [] as event (event.id)}
<div class="mb-2">
<EventCard {event} />
</div>
{/each}
</div>
{/if}
{#if filteredEntries.length > 0}
<div class="mb-8 border-l-2 border-[#555] pl-5">
<div class="mb-4 text-xl font-bold text-accent">{selectedDate}</div>
{#each filteredEntries as entry (entry.id)}
{#if editingId === entry.id}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="mb-3 flex flex-col gap-2 rounded border border-accent bg-card-bg p-2.5" onkeydown={(e) => { if (e.key === 'Escape') cancelEdit(); }}>
<div class="flex items-center gap-2 flex-wrap">
<button
class="rounded bg-accent px-3 py-1 text-sm font-bold text-white hover:brightness-110"
onclick={saveEdit}
>Speichern</button>
<button
class="rounded bg-[#444] px-3 py-1 text-sm text-white hover:bg-[#555]"
onclick={cancelEdit}
>Abbrechen</button>
<input
type="date"
class="rounded border border-[#444] bg-bg px-2 py-1 text-sm text-white"
bind:value={editDate}
title="Datum des Eintrags"
/>
<div class="ml-auto flex gap-1 rounded-full bg-[#333] p-0.5">
<button
class="rounded-full px-2.5 py-1 text-xs font-bold transition-all {!editIsPrivate ? 'bg-accent text-white shadow' : 'text-[#aaa] hover:text-white'}"
onclick={() => editIsPrivate = false}
>Firma</button>
<button
class="rounded-full px-2.5 py-1 text-xs font-bold transition-all {editIsPrivate ? 'text-white shadow' : 'text-[#aaa] hover:text-white'}"
style={editIsPrivate ? `background-color: ${$scopeSettings.privateColor}` : ''}
onclick={() => editIsPrivate = true}
>Privat</button>
</div>
</div>
<input
type="text"
class="rounded border border-[#444] bg-bg px-2.5 py-1.5 font-mono text-white"
bind:value={editTitle}
use:mention
onkeydown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); saveEdit(); } }}
/>
<MarkdownEditor
content={editBody}
placeholder="Details"
minHeight="60px"
wikiScope={journalScope === 'private'}
onchange={(md) => editBody = md}
onsave={saveEdit}
/>
<div class="flex items-center gap-2 flex-wrap">
<button
class="rounded bg-accent px-3 py-1 text-sm font-bold text-white hover:brightness-110"
onclick={saveEdit}
>Speichern</button>
<button
class="rounded bg-[#444] px-3 py-1 text-sm text-white hover:bg-[#555]"
onclick={cancelEdit}
>Abbrechen</button>
<input
type="date"
class="rounded border border-[#444] bg-bg px-2 py-1 text-sm text-white"
bind:value={editDate}
title="Datum des Eintrags"
/>
<div class="ml-auto flex gap-1 rounded-full bg-[#333] p-0.5">
<button
class="rounded-full px-2.5 py-1 text-xs font-bold transition-all {!editIsPrivate ? 'bg-accent text-white shadow' : 'text-[#aaa] hover:text-white'}"
onclick={() => editIsPrivate = false}
>Firma</button>
<button
class="rounded-full px-2.5 py-1 text-xs font-bold transition-all {editIsPrivate ? 'text-white shadow' : 'text-[#aaa] hover:text-white'}"
style={editIsPrivate ? `background-color: ${$scopeSettings.privateColor}` : ''}
onclick={() => editIsPrivate = true}
>Privat</button>
</div>
</div>
</div>
{:else}
{@const lines = entry.text.split('\n')}
{@const title = lines[0].replace(/^#{1,6}\s+/, '')}
{@const body = lines.slice(1).join('\n').trim()}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="group mb-3 flex items-start gap-2 rounded bg-card-bg p-2.5" ondblclick={() => startEdit(entry)}>
<span class="mt-0.5 text-xs text-muted whitespace-nowrap">{formatTime(entry)}</span>
<div class="flex-1">
<div class="flex flex-wrap items-center gap-1.5 font-bold">
<LinkTitle text={title} />
{#if entry.wiedervorlageDate && !entry.wiedervorlageResolvedAt}
<span class="text-base leading-none text-amber-400" title="In Wiedervorlage bis {entry.wiedervorlageDate}"></span>
{/if}
{#if entry.linkedContextId}
<span class="inline-block rounded bg-accent/20 px-1.5 py-0.5 text-xs font-normal text-accent">
{contextNameMap().get(entry.linkedContextId) ?? entry.linkedContextId}
</span>
{/if}
</div>
{#if body}
<RenderedMarkdown text={body} class="mt-1 text-sm text-[#ccc]" />
{/if}
</div>
<button
class="mt-0.5 text-xs text-[#666] opacity-0 transition-opacity group-hover:opacity-100 hover:text-[#ccc]"
onclick={() => startEdit(entry)}
title="Bearbeiten"
>&#9998;</button>
<button
class="mt-0.5 text-xs text-[#666] opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-400"
onclick={() => handleDelete(entry.id)}
title="Löschen"
>&times;</button>
</div>
{/if}
{/each}
</div>
{:else}
<div class="text-center text-muted">Keine Einträge für {selectedDate}.</div>
{/if}
{:else}
<!-- Meeting contexts: topic history + linked journal entries -->
{#each meetingByDate() as [date, items]}
<div class="mb-8 border-l-2 border-[#555] pl-5">
<div class="mb-4 text-xl font-bold text-accent">{date}</div>
{#each items as entry}
<div class="mb-4 rounded bg-card-bg p-2.5">
<span class="mb-1 block text-xs uppercase tracking-wider text-muted">
{entry.topicTitle}
{#if entry.isLinked}
<span class="ml-1 rounded bg-accent/20 px-1 py-0.5 normal-case text-accent">Journal</span>
{/if}
</span>
<RenderedMarkdown text={entry.text} />
</div>
{/each}
</div>
{:else}
<div class="text-center text-muted">Keine Einträge.</div>
{/each}
{/if}
{#if confirmDeleteId}
<ConfirmDialog
message="Eintrag wirklich löschen?"
onconfirm={confirmDelete}
oncancel={() => confirmDeleteId = null}
/>
{/if}