upd task management

This commit is contained in:
beo3000 2026-03-15 11:32:20 +01:00
parent 1f68328868
commit a322688c9e
18 changed files with 436 additions and 31 deletions

@ -1 +1 @@
Subproject commit 7518072e4510b0b4217809d0f54cc2de476428f0
Subproject commit f28dc954e87e90afc18dd7891e2be338274d716c

View File

@ -14,6 +14,8 @@ AZURE_GRAPH_CLIENT_ID=<graph-app-registration-client-id>
AZURE_GRAPH_CLIENT_SECRET=<graph-client-secret-value>
# Fallback email when auth provides no email (e.g. API key login)
CALENDAR_USER_EMAIL=<your-email@domain.com>
# IANA timezone for calendar event times (default: Europe/Berlin)
CALENDAR_TIMEZONE=Europe/Berlin
# ── VISION / INVENTORY ───────────────────────────────────────────────────────
# AES-256-GCM key for encrypting user Vision API keys in DB

View File

@ -1 +1 @@
1.2.37
1.2.41

View File

@ -1,5 +1,5 @@
import { goto } from '$app/navigation';
import { findContextByMentionName, findContextByAbbreviation, upsertContext, createPage, toggleTaskDone } from '$lib/db/repositories';
import { findContextByMentionName, findContextByAbbreviation, upsertContext, createPage, toggleTaskDone, updateTask } from '$lib/db/repositories';
import { db } from '$lib/db/schema';
interface RefPopup {
@ -232,12 +232,33 @@ async function handleTaskRefClick(chip: HTMLElement) {
position: fixed; left: 50%; top: 30%; transform: translateX(-50%); z-index: 100;
background: #2d2d2d; border: 1px solid #555; border-radius: 6px;
padding: 10px 14px; box-shadow: 0 4px 12px rgba(0,0,0,0.5);
font-size: 0.85rem; color: #e0e0e0; min-width: 200px;
font-size: 0.85rem; color: #e0e0e0; min-width: 220px;
`;
const title = document.createElement('div');
title.style.cssText = 'font-weight: bold; margin-bottom: 6px; font-size: 0.9rem;';
title.textContent = task.title;
popup.appendChild(title);
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.value = task.title;
titleInput.style.cssText = `
width: 100%; box-sizing: border-box; margin-bottom: 8px;
background: #3a3a3a; border: 1px solid #666; border-radius: 4px;
color: #e0e0e0; font-size: 0.9rem; font-weight: bold; padding: 3px 6px;
outline: none;
`;
const saveTitle = async () => {
const newTitle = titleInput.value.trim();
if (newTitle && newTitle !== task.title) {
await updateTask(taskId, { title: newTitle });
document.querySelectorAll<HTMLElement>(`[data-task-id="${taskId}"]`).forEach(el => {
el.title = newTitle;
});
}
};
titleInput.addEventListener('blur', saveTitle);
titleInput.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') { e.preventDefault(); await saveTitle(); closePopup(); }
if (e.key === 'Escape') { closePopup(); }
});
popup.appendChild(titleInput);
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display: flex; gap: 6px;';
@ -247,7 +268,6 @@ async function handleTaskRefClick(chip: HTMLElement) {
toggleBtn.addEventListener('click', async () => {
closePopup();
await toggleTaskDone(taskId);
// Update all chips for this task directly in DOM (no re-render triggered)
const isDoneNow = task.status !== 'done';
document.querySelectorAll<HTMLElement>(`[data-task-id="${taskId}"]`).forEach(el => {
if (isDoneNow) {
@ -264,6 +284,8 @@ async function handleTaskRefClick(chip: HTMLElement) {
btnRow.appendChild(toggleBtn);
popup.appendChild(btnRow);
document.body.appendChild(popup);
titleInput.focus();
titleInput.select();
const onClickOutside = (e: MouseEvent) => { if (!popup.contains(e.target as Node)) closePopup(); };
setTimeout(() => document.addEventListener('click', onClickOutside), 0);

View File

@ -0,0 +1,142 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/db/schema';
import type { Task } from '@ka-note/shared';
import { toggleTaskDone, updateTask, softDeleteTask } from '$lib/db/repositories';
import { today } from '$lib/db/helpers';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { Check, CalendarDays, ArrowUpRight, Trash2 } from 'lucide-svelte';
interface Props {
task: Task;
}
let { task }: Props = $props();
let showDueDate = $state(false);
let newDueDate = $state(task.dueDate ?? '');
const todayStr = today();
const isOverdue = $derived(!!task.dueDate && task.dueDate < todayStr);
// Resolve source: historyEntry → topic → context
const source = liveQuery(async () => {
if (!task.historyEntryId) return null;
const entry = await db.historyEntries.get(task.historyEntryId);
if (!entry) return null;
const topic = await db.topics.get(entry.topicId);
if (!topic) return { entryDate: entry.date, contextId: 'daily-log', contextName: null, topicTitle: null };
const ctx = await db.contexts.get(topic.contextId);
return {
entryDate: entry.date,
contextId: topic.contextId,
contextName: ctx && topic.contextId !== 'daily-log' ? ctx.name : null,
topicTitle: topic.contextId !== 'daily-log' ? topic.title : null,
};
});
async function handleDone() {
await toggleTaskDone(task.id);
}
async function handleDelete() {
await softDeleteTask(task.id);
}
async function handleSetDueDate() {
await updateTask(task.id, { dueDate: newDueDate || null });
showDueDate = false;
}
function navigateToSource() {
const s = $source;
if (!s) return;
const targetPath = `/context/${s.contextId}`;
const currentPath = page.url.pathname;
const currentDate = page.url.searchParams.get('date') ?? todayStr;
if (currentPath === targetPath && currentDate === s.entryDate && task.historyEntryId) {
// Same page & date → scroll
const el = document.getElementById(`history-entry-${task.historyEntryId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-2', 'ring-blue-400', 'rounded');
setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400', 'rounded'), 1500);
return;
}
}
// Navigate to the right context + date
goto(`${targetPath}?date=${s.entryDate}`);
}
</script>
<div class="mb-3 rounded-lg border border-blue-500/60 bg-blue-950/30 p-3">
<div class="mb-1 flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider {isOverdue ? 'text-red-400' : 'text-blue-400'}">
📋 Aufgabe{task.dueDate ? ` · Fällig ${task.dueDate}` : ''}
</span>
{#if task.assignee}
<span class="ml-auto rounded bg-gray-700 px-1.5 py-0.5 text-xs text-gray-300">
{task.assignee}
</span>
{/if}
</div>
{#if $source}
<div class="mb-2 text-xs text-gray-500">
{#if $source.contextName}
{$source.contextName}
{#if $source.topicTitle} · {$source.topicTitle}{/if}
· {$source.entryDate}
{:else}
{$source.entryDate}
{/if}
</div>
{/if}
<div class="mb-3">
<div class="font-bold text-white">{task.title}</div>
</div>
{#if showDueDate}
<div class="mb-2 flex items-center gap-2">
<input
type="date"
class="rounded border border-[#444] bg-bg px-2 py-1 text-sm text-white"
bind:value={newDueDate}
/>
<button
class="rounded bg-blue-700 px-3 py-1 text-sm font-bold text-white hover:brightness-110"
onclick={handleSetDueDate}
>OK</button>
<button
class="rounded bg-[#444] px-3 py-1 text-sm text-white hover:bg-[#555]"
onclick={() => showDueDate = false}
>Abbrechen</button>
</div>
{:else}
<div class="flex gap-1.5">
<button
class="flex items-center justify-center rounded bg-green-700 px-2.5 py-1.5 text-white hover:brightness-110 active:brightness-90"
title="Erledigt"
onclick={handleDone}
><Check size={15} /></button>
<button
class="flex items-center justify-center rounded bg-blue-700 px-2.5 py-1.5 text-white hover:brightness-110 active:brightness-90"
title="Fälligkeit setzen"
onclick={() => { newDueDate = task.dueDate ?? ''; showDueDate = true; }}
><CalendarDays size={15} /></button>
{#if task.historyEntryId}
<button
class="flex items-center justify-center rounded bg-[#444] px-2.5 py-1.5 text-white hover:bg-[#555] active:bg-[#555]"
title="Zur Quelle"
onclick={navigateToSource}
><ArrowUpRight size={15} /></button>
{/if}
<button
class="ml-auto flex items-center justify-center rounded bg-red-900/60 px-2.5 py-1.5 text-red-300 hover:bg-red-800 active:bg-red-800"
title="Löschen"
onclick={handleDelete}
><Trash2 size={15} /></button>
</div>
{/if}
</div>

View File

@ -0,0 +1,22 @@
<script lang="ts">
import { allOpenTasks } from '$lib/stores/agenda';
import AufgabenCard from './AufgabenCard.svelte';
interface Props {
journalScope?: 'business' | 'private';
}
let { journalScope = 'business' }: Props = $props();
const tasks = $derived(allOpenTasks(journalScope));
</script>
{#if ($tasks ?? []).length > 0}
<div class="mb-6 rounded-lg border border-blue-500/40 bg-blue-950/20 p-4">
<div class="mb-3 text-sm font-semibold uppercase tracking-wider text-blue-400">
Aufgaben ({($tasks ?? []).length})
</div>
{#each ($tasks ?? []) as task (task.id)}
<AufgabenCard {task} />
{/each}
</div>
{/if}

View File

@ -1,6 +1,7 @@
<script lang="ts">
import type { AgendaContext, PersonMeta } from '@ka-note/shared';
import type { AgendaContext, PersonMeta, MeetingMeta } from '@ka-note/shared';
import { upsertContext, contextNameExists, renameMentionCascade } from '$lib/db/repositories';
import { db } from '$lib/db/schema';
import { scopeSettings } from '$lib/stores/scopeContext';
interface Props {
@ -70,6 +71,35 @@
}
}
const isMeeting = $derived(context.type === 'meeting' && context.id !== 'daily-log');
let editingCalendarTitle = $state(false);
let calendarTitleInput = $state('');
function startEditCalendarTitle() {
calendarTitleInput = (context.meta as MeetingMeta | null)?.calendarTitle ?? '';
editingCalendarTitle = true;
}
async function saveCalendarTitle() {
editingCalendarTitle = false;
const trimmed = calendarTitleInput.trim();
const existingMeta = context.meta ?? {};
await db.contexts.update(context.id, {
meta: { ...existingMeta, calendarTitle: trimmed || undefined },
updatedAt: new Date().toISOString(),
version: context.version + 1,
});
}
function onCalendarTitleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
editingCalendarTitle = false;
}
}
const headerColor = $derived(
showScopeSwitch
? (journalScope === 'private' ? $scopeSettings.privateColor : $scopeSettings.businessColor)
@ -93,14 +123,37 @@
{/if}
</div>
{:else}
<span class="text-2xl font-bold">{context.name}</span>
{#if canRename}
<button
class="ml-2 text-[#888] hover:text-white bg-transparent border-none cursor-pointer text-base"
onclick={startEdit}
title="Rename"
>✎</button>
{/if}
<div class="flex flex-col flex-1 mr-4">
<div class="flex items-center">
<span class="text-2xl font-bold">{context.name}</span>
{#if canRename}
<button
class="ml-2 text-[#888] hover:text-white bg-transparent border-none cursor-pointer text-base"
onclick={startEdit}
title="Rename"
>✎</button>
{/if}
{#if isMeeting}
<button
class="ml-2 text-[#888] hover:text-white bg-transparent border-none cursor-pointer text-sm"
onclick={startEditCalendarTitle}
title="Kalender-Titel bearbeiten"
>📅</button>
{/if}
</div>
{#if isMeeting && editingCalendarTitle}
<input
class="text-sm bg-transparent border-b border-muted text-inherit outline-none mt-0.5 w-full max-w-xs"
placeholder="Kalender-Titel..."
bind:value={calendarTitleInput}
onkeydown={onCalendarTitleKeydown}
onblur={saveCalendarTitle}
autofocus
/>
{:else if isMeeting && (context.meta as MeetingMeta | null)?.calendarTitle}
<span class="text-xs text-muted mt-0.5">📅 {(context.meta as MeetingMeta).calendarTitle}</span>
{/if}
</div>
{/if}
{#if showModeSwitch}

View File

@ -17,6 +17,9 @@
import PersonMeetingsView from './PersonMeetingsView.svelte';
import PersonTasksView from './PersonTasksView.svelte';
import TaskCreateModal from './TaskCreateModal.svelte';
import type { MeetingMeta } from '@ka-note/shared';
import { getEventsForDate } from '$lib/stores/calendarStore';
import { today } from '$lib/db/helpers';
interface Props {
contextId: string;
@ -40,6 +43,45 @@
const initialDate = $derived(page.url.searchParams.get('date') ?? undefined);
let compact = $state(false);
let showAutoMeetingHint = $state(false);
function timeToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
}
function currentTimeHHMM(): string {
const now = new Date();
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
}
function timeIsInRange(now: string, start: string, end: string, bufferMin: number): boolean {
const n = timeToMinutes(now);
const s = timeToMinutes(start) - bufferMin;
const e = timeToMinutes(end) + bufferMin;
return n >= s && n <= e;
}
$effect(() => {
const ctx = $context;
if (ctx?.type === 'meeting' && !isDailyLog) {
const calendarTitle = (ctx.meta as MeetingMeta | null)?.calendarTitle;
if (calendarTitle) {
const events = getEventsForDate(today());
const match = events.find(e => e.subject.toLowerCase() === calendarTitle.toLowerCase());
if (match) {
const now = currentTimeHHMM();
const isActive = timeIsInRange(now, match.start, match.end, 10);
if (isActive && mode !== 'meeting') {
mode = 'meeting';
showAutoMeetingHint = true;
setTimeout(() => { showAutoMeetingHint = false; }, 4000);
}
}
}
}
});
// Rating modal state
let ratingModal = $state<{ personName: string; topicId: string; historyEntryId: string } | null>(null);
@ -130,6 +172,12 @@
<div bind:this={containerEl}>
{#if $context}
{#if showAutoMeetingHint}
<div class="mb-3 flex items-center justify-between bg-warning/20 border border-warning text-sm px-3 py-1 rounded">
<span>Meeting läuft - automatisch in Meeting-Modus gewechselt</span>
<button class="ml-2 text-muted hover:text-white" onclick={() => showAutoMeetingHint = false}>&times;</button>
</div>
{/if}
{#if isDailyLog}
<ContextHeader context={$context} {journalScope} onjournalscopechange={handleScopeChange} />
<ViewTabs context={$context} {activeView} onviewchange={handleViewChange} />

View File

@ -9,6 +9,7 @@
import DateNavigator from './DateNavigator.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import WiedervorlageSection from './WiedervorlageSection.svelte';
import AufgabenSection from './AufgabenSection.svelte';
import LinkTitle from './LinkTitle.svelte';
import { get } from 'svelte/store';
import { currentScope, scopeSettings } from '$lib/stores/scopeContext';
@ -18,6 +19,7 @@
import { eventsForDate } from '$lib/stores/agenda';
import { createEvent, updateEventNotes, upsertContext } from '$lib/db/repositories';
import { fetchCalendarEvents, type CalendarEvent } from '$lib/utils/calendarApi';
import { loadCalendarForDate } from '$lib/stores/calendarStore';
import { extractMentionName, quoteMention } from '$lib/actions/mentionCore';
import type { PersonMeta } from '@ka-note/shared';
@ -348,7 +350,7 @@
{#if isDailyLog}
<!-- Daily-log: chronological entry log with date navigation -->
<DateNavigator {selectedDate} onchange={(d) => selectedDate = d} />
<DateNavigator {selectedDate} onchange={(d) => { selectedDate = d; loadCalendarForDate(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">
@ -419,6 +421,7 @@
</div>
<WiedervorlageSection date={selectedDate} {journalScope} />
<AufgabenSection {journalScope} />
<!-- Events (one-off meetings) for selected date -->
{#if journalScope === 'business'}
@ -620,7 +623,7 @@
{@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" data-history-entry-id={entry.id} data-topic-id={entry.topicId} ondblclick={() => startEdit(entry)}>
<div id="history-entry-{entry.id}" class="group mb-3 flex items-start gap-2 rounded bg-card-bg p-2.5" data-history-entry-id={entry.id} data-topic-id={entry.topicId} ondblclick={() => startEdit(entry)}>
<span class="mt-0.5 text-xs text-muted whitespace-nowrap">{formatTime(entry)}</span>
<div class="flex-1">
{#if /^\s*(?:\\?\[\\?\]|\[T:[^\]]+\]|\\?\[X\\?\])/.test(title)}

View File

@ -5,7 +5,29 @@
import type { HistoryEntry } from '@ka-note/shared';
import HistoryEntryText from './HistoryEntryText.svelte';
import LinkTitle from './LinkTitle.svelte';
import { Check, CalendarDays, ArrowUpRight, Trash2 } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { today } from '$lib/db/helpers';
import { Check, CalendarDays, ArrowUpRight, FolderInput, Trash2 } from 'lucide-svelte';
const todayStr = today();
function navigateToEntry() {
const targetPath = '/context/daily-log';
const currentPath = page.url.pathname;
const currentDate = page.url.searchParams.get('date') ?? todayStr;
if (currentPath === targetPath && currentDate === entry.date) {
const el = document.getElementById(`history-entry-${entry.id}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-2', 'ring-amber-400', 'rounded');
setTimeout(() => el.classList.remove('ring-2', 'ring-amber-400', 'rounded'), 1500);
return;
}
}
goto(`${targetPath}?date=${entry.date}`);
}
interface Props {
entry: HistoryEntry;
@ -114,6 +136,11 @@
class="flex items-center justify-center rounded bg-[#444] px-2.5 py-1.5 text-white hover:bg-[#555] active:bg-[#555]"
title="In Thema wandeln"
onclick={() => showConvert = true}
><FolderInput size={15} /></button>
<button
class="flex items-center justify-center rounded bg-[#444] px-2.5 py-1.5 text-white hover:bg-[#555] active:bg-[#555]"
title="Zum Eintrag"
onclick={navigateToEntry}
><ArrowUpRight size={15} /></button>
<button
class="ml-auto flex items-center justify-center rounded bg-red-900/60 px-2.5 py-1.5 text-red-300 hover:bg-red-800 active:bg-red-800"

View File

@ -1,7 +1,7 @@
import { writable, derived } from 'svelte/store';
import { liveQuery } from 'dexie';
import { db } from '$lib/db/schema';
import type { AgendaContext, Topic, HistoryEntry, ContextType, EventMeta } from '@ka-note/shared';
import type { AgendaContext, Topic, HistoryEntry, ContextType, EventMeta, Task } from '@ka-note/shared';
// --- Writable UI state ---
export const currentContextId = writable<string>('daily-log');
@ -161,3 +161,31 @@ export function pendingWiedervorlage(date: string) {
.toArray()
);
}
export function allOpenTasks(scope: 'business' | 'private') {
return liveQuery(async () => {
const tasks = await db.tasks
.where('status').equals('open')
.filter(t => !t.deletedAt)
.toArray();
const filtered: Task[] = [];
for (const task of tasks) {
if (task.historyEntryId) {
const entry = await db.historyEntries.get(task.historyEntryId);
const entryIsPrivate = !!entry?.isPrivate;
const scopeMatches = scope === 'private' ? entryIsPrivate : !entryIsPrivate;
if (entry && scopeMatches) filtered.push(task);
} else {
filtered.push(task);
}
}
return filtered.sort((a, b) => {
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.localeCompare(b.dueDate);
});
});
}

View File

@ -0,0 +1,16 @@
import { fetchCalendarEvents, type CalendarEvent } from '$lib/utils/calendarApi';
const cache = new Map<string, CalendarEvent[]>();
export async function loadCalendarForDate(date: string): Promise<void> {
try {
const events = await fetchCalendarEvents(date);
cache.set(date, events);
} catch {
// silently ignore
}
}
export function getEventsForDate(date: string): CalendarEvent[] {
return cache.get(date) ?? [];
}

View File

@ -20,6 +20,8 @@
isAuthenticated,
} from "$lib/auth/authStore.js";
import { scopeColor } from "$lib/stores/scopeContext";
import { loadCalendarForDate } from "$lib/stores/calendarStore";
import { today } from "$lib/db/helpers";
let { children }: { children: Snippet } = $props();
let sidebarOpen = $state(false);
@ -53,6 +55,7 @@
await seedIfEmpty();
ready = true;
startSync();
loadCalendarForDate(today());
}
});
@ -67,6 +70,7 @@
seedIfEmpty().then(() => {
ready = true;
startSync();
loadCalendarForDate(today());
});
}
});

View File

@ -29,6 +29,8 @@
let coverUrl = $state<string | null>(null);
let imageUrls = $state<Map<string, string>>(new Map());
let confirmDelete = $state(false);
let confirmDeleteImage = $state<AssetImage | null>(null);
let lightboxUrl = $state<string | null>(null);
let uploading = $state(false);
let recognizing = $state(false);
let recognizeResult = $state<RecognizeResult | null>(null);
@ -178,12 +180,23 @@
}
async function deleteImage(img: AssetImage) {
confirmDeleteImage = img;
}
async function confirmDeleteImageAction() {
const img = confirmDeleteImage;
confirmDeleteImage = null;
if (!img) return;
await softDeleteAssetImage(img.id);
if (asset?.coverImageId === img.imageId) {
await save({ coverImageId: null });
}
}
function openLightbox(img: AssetImage) {
lightboxUrl = imageUrls.get(img.id) ?? null;
}
async function setCover(img: AssetImage) {
await save({ coverImageId: img.imageId });
}
@ -289,8 +302,8 @@
>
<button
class="h-16 w-16 overflow-hidden rounded-lg border-2 {isCover ? 'border-accent' : 'border-transparent'} hover:border-accent/50 cursor-grab active:cursor-grabbing"
onclick={() => setCover(img)}
title={isCover ? 'Titelbild' : 'Als Titelbild setzen'}
onclick={() => openLightbox(img)}
title="Vergrößern"
>
{#if imageUrls.get(img.id)}
<img src={imageUrls.get(img.id)} alt="" class="h-full w-full object-cover" />
@ -298,15 +311,15 @@
<div class="h-full w-full bg-white/10"></div>
{/if}
</button>
<!-- Cover star badge -->
<!-- Cover star / set-cover button -->
{#if isCover}
<span class="absolute left-0.5 top-0.5 pointer-events-none">
<button class="absolute left-0.5 top-0.5" onclick={() => setCover(img)} title="Titelbild">
<Star size={13} class="text-accent fill-accent drop-shadow" />
</span>
</button>
{:else}
<span class="absolute left-0.5 top-0.5 pointer-events-none hidden group-hover:block">
<button class="absolute left-0.5 top-0.5 hidden group-hover:block" onclick={() => setCover(img)} title="Als Titelbild setzen">
<Star size={13} class="text-white/60" />
</span>
</button>
{/if}
<!-- Delete button -->
<button
@ -538,3 +551,21 @@
oncancel={() => confirmDelete = false}
/>
{/if}
{#if confirmDeleteImage}
<ConfirmDialog
message="Dieses Bild wirklich löschen?"
onconfirm={confirmDeleteImageAction}
oncancel={() => confirmDeleteImage = null}
/>
{/if}
{#if lightboxUrl}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
onclick={() => lightboxUrl = null}
>
<img src={lightboxUrl} alt="" class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl" />
</div>
{/if}

Binary file not shown.

Binary file not shown.

View File

@ -57,7 +57,7 @@ async function getAppToken(): Promise<string> {
}
function toHHMM(dateTime: string): string {
// Graph returns "2026-02-26T09:30:00.0000000" (local TZ, no Z)
// Graph returns local time when Prefer: outlook.timezone header is set
return dateTime.slice(11, 16);
}
@ -78,10 +78,13 @@ export async function getCalendarEvents(
`&$select=${select}` +
`&$top=50`;
const timezone = process.env.CALENDAR_TIMEZONE ?? 'Europe/Berlin';
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${graphToken}`,
'Content-Type': 'application/json',
'Prefer': `outlook.timezone="${timezone}"`,
},
});

View File

@ -10,11 +10,15 @@ export type ContextType = 'meeting' | 'project' | 'person' | 'company' | 'event'
export type PersonSubType = 'contact' | 'employee' | 'colleague' | 'family' | 'acquaintance';
export interface MeetingMeta {
calendarTitle?: string;
}
export interface AgendaContext extends SyncEntity {
name: string;
type: ContextType;
sortOrder: number;
meta: ProjectMeta | PersonMeta | CompanyMeta | EventMeta | null;
meta: ProjectMeta | PersonMeta | CompanyMeta | EventMeta | MeetingMeta | null;
archivedAt: string | null;
isFavorite: boolean;
}