upd task management
This commit is contained in:
parent
1f68328868
commit
a322688c9e
|
|
@ -1 +1 @@
|
|||
Subproject commit 7518072e4510b0b4217809d0f54cc2de476428f0
|
||||
Subproject commit f28dc954e87e90afc18dd7891e2be338274d716c
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1.2.37
|
||||
1.2.41
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isDailyLog}
|
||||
<ContextHeader context={$context} {journalScope} onjournalscopechange={handleScopeChange} />
|
||||
<ViewTabs context={$context} {activeView} onviewchange={handleViewChange} />
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ?? [];
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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}"`,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue