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>
|
AZURE_GRAPH_CLIENT_SECRET=<graph-client-secret-value>
|
||||||
# Fallback email when auth provides no email (e.g. API key login)
|
# Fallback email when auth provides no email (e.g. API key login)
|
||||||
CALENDAR_USER_EMAIL=<your-email@domain.com>
|
CALENDAR_USER_EMAIL=<your-email@domain.com>
|
||||||
|
# IANA timezone for calendar event times (default: Europe/Berlin)
|
||||||
|
CALENDAR_TIMEZONE=Europe/Berlin
|
||||||
|
|
||||||
# ── VISION / INVENTORY ───────────────────────────────────────────────────────
|
# ── VISION / INVENTORY ───────────────────────────────────────────────────────
|
||||||
# AES-256-GCM key for encrypting user Vision API keys in DB
|
# 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 { 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';
|
import { db } from '$lib/db/schema';
|
||||||
|
|
||||||
interface RefPopup {
|
interface RefPopup {
|
||||||
|
|
@ -232,12 +232,33 @@ async function handleTaskRefClick(chip: HTMLElement) {
|
||||||
position: fixed; left: 50%; top: 30%; transform: translateX(-50%); z-index: 100;
|
position: fixed; left: 50%; top: 30%; transform: translateX(-50%); z-index: 100;
|
||||||
background: #2d2d2d; border: 1px solid #555; border-radius: 6px;
|
background: #2d2d2d; border: 1px solid #555; border-radius: 6px;
|
||||||
padding: 10px 14px; box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
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;';
|
const titleInput = document.createElement('input');
|
||||||
title.textContent = task.title;
|
titleInput.type = 'text';
|
||||||
popup.appendChild(title);
|
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');
|
const btnRow = document.createElement('div');
|
||||||
btnRow.style.cssText = 'display: flex; gap: 6px;';
|
btnRow.style.cssText = 'display: flex; gap: 6px;';
|
||||||
|
|
@ -247,7 +268,6 @@ async function handleTaskRefClick(chip: HTMLElement) {
|
||||||
toggleBtn.addEventListener('click', async () => {
|
toggleBtn.addEventListener('click', async () => {
|
||||||
closePopup();
|
closePopup();
|
||||||
await toggleTaskDone(taskId);
|
await toggleTaskDone(taskId);
|
||||||
// Update all chips for this task directly in DOM (no re-render triggered)
|
|
||||||
const isDoneNow = task.status !== 'done';
|
const isDoneNow = task.status !== 'done';
|
||||||
document.querySelectorAll<HTMLElement>(`[data-task-id="${taskId}"]`).forEach(el => {
|
document.querySelectorAll<HTMLElement>(`[data-task-id="${taskId}"]`).forEach(el => {
|
||||||
if (isDoneNow) {
|
if (isDoneNow) {
|
||||||
|
|
@ -264,6 +284,8 @@ async function handleTaskRefClick(chip: HTMLElement) {
|
||||||
btnRow.appendChild(toggleBtn);
|
btnRow.appendChild(toggleBtn);
|
||||||
popup.appendChild(btnRow);
|
popup.appendChild(btnRow);
|
||||||
document.body.appendChild(popup);
|
document.body.appendChild(popup);
|
||||||
|
titleInput.focus();
|
||||||
|
titleInput.select();
|
||||||
|
|
||||||
const onClickOutside = (e: MouseEvent) => { if (!popup.contains(e.target as Node)) closePopup(); };
|
const onClickOutside = (e: MouseEvent) => { if (!popup.contains(e.target as Node)) closePopup(); };
|
||||||
setTimeout(() => document.addEventListener('click', onClickOutside), 0);
|
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">
|
<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 { upsertContext, contextNameExists, renameMentionCascade } from '$lib/db/repositories';
|
||||||
|
import { db } from '$lib/db/schema';
|
||||||
import { scopeSettings } from '$lib/stores/scopeContext';
|
import { scopeSettings } from '$lib/stores/scopeContext';
|
||||||
|
|
||||||
interface Props {
|
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(
|
const headerColor = $derived(
|
||||||
showScopeSwitch
|
showScopeSwitch
|
||||||
? (journalScope === 'private' ? $scopeSettings.privateColor : $scopeSettings.businessColor)
|
? (journalScope === 'private' ? $scopeSettings.privateColor : $scopeSettings.businessColor)
|
||||||
|
|
@ -93,14 +123,37 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-2xl font-bold">{context.name}</span>
|
<div class="flex flex-col flex-1 mr-4">
|
||||||
{#if canRename}
|
<div class="flex items-center">
|
||||||
<button
|
<span class="text-2xl font-bold">{context.name}</span>
|
||||||
class="ml-2 text-[#888] hover:text-white bg-transparent border-none cursor-pointer text-base"
|
{#if canRename}
|
||||||
onclick={startEdit}
|
<button
|
||||||
title="Rename"
|
class="ml-2 text-[#888] hover:text-white bg-transparent border-none cursor-pointer text-base"
|
||||||
>✎</button>
|
onclick={startEdit}
|
||||||
{/if}
|
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}
|
||||||
|
|
||||||
{#if showModeSwitch}
|
{#if showModeSwitch}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@
|
||||||
import PersonMeetingsView from './PersonMeetingsView.svelte';
|
import PersonMeetingsView from './PersonMeetingsView.svelte';
|
||||||
import PersonTasksView from './PersonTasksView.svelte';
|
import PersonTasksView from './PersonTasksView.svelte';
|
||||||
import TaskCreateModal from './TaskCreateModal.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 {
|
interface Props {
|
||||||
contextId: string;
|
contextId: string;
|
||||||
|
|
@ -40,6 +43,45 @@
|
||||||
const initialDate = $derived(page.url.searchParams.get('date') ?? undefined);
|
const initialDate = $derived(page.url.searchParams.get('date') ?? undefined);
|
||||||
let compact = $state(false);
|
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
|
// Rating modal state
|
||||||
let ratingModal = $state<{ personName: string; topicId: string; historyEntryId: string } | null>(null);
|
let ratingModal = $state<{ personName: string; topicId: string; historyEntryId: string } | null>(null);
|
||||||
|
|
||||||
|
|
@ -130,6 +172,12 @@
|
||||||
|
|
||||||
<div bind:this={containerEl}>
|
<div bind:this={containerEl}>
|
||||||
{#if $context}
|
{#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}
|
{#if isDailyLog}
|
||||||
<ContextHeader context={$context} {journalScope} onjournalscopechange={handleScopeChange} />
|
<ContextHeader context={$context} {journalScope} onjournalscopechange={handleScopeChange} />
|
||||||
<ViewTabs context={$context} {activeView} onviewchange={handleViewChange} />
|
<ViewTabs context={$context} {activeView} onviewchange={handleViewChange} />
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import DateNavigator from './DateNavigator.svelte';
|
import DateNavigator from './DateNavigator.svelte';
|
||||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||||
import WiedervorlageSection from './WiedervorlageSection.svelte';
|
import WiedervorlageSection from './WiedervorlageSection.svelte';
|
||||||
|
import AufgabenSection from './AufgabenSection.svelte';
|
||||||
import LinkTitle from './LinkTitle.svelte';
|
import LinkTitle from './LinkTitle.svelte';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { currentScope, scopeSettings } from '$lib/stores/scopeContext';
|
import { currentScope, scopeSettings } from '$lib/stores/scopeContext';
|
||||||
|
|
@ -18,6 +19,7 @@
|
||||||
import { eventsForDate } from '$lib/stores/agenda';
|
import { eventsForDate } from '$lib/stores/agenda';
|
||||||
import { createEvent, updateEventNotes, upsertContext } from '$lib/db/repositories';
|
import { createEvent, updateEventNotes, upsertContext } from '$lib/db/repositories';
|
||||||
import { fetchCalendarEvents, type CalendarEvent } from '$lib/utils/calendarApi';
|
import { fetchCalendarEvents, type CalendarEvent } from '$lib/utils/calendarApi';
|
||||||
|
import { loadCalendarForDate } from '$lib/stores/calendarStore';
|
||||||
import { extractMentionName, quoteMention } from '$lib/actions/mentionCore';
|
import { extractMentionName, quoteMention } from '$lib/actions/mentionCore';
|
||||||
import type { PersonMeta } from '@ka-note/shared';
|
import type { PersonMeta } from '@ka-note/shared';
|
||||||
|
|
||||||
|
|
@ -348,7 +350,7 @@
|
||||||
|
|
||||||
{#if isDailyLog}
|
{#if isDailyLog}
|
||||||
<!-- Daily-log: chronological entry log with date navigation -->
|
<!-- 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}
|
{#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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<WiedervorlageSection date={selectedDate} {journalScope} />
|
<WiedervorlageSection date={selectedDate} {journalScope} />
|
||||||
|
<AufgabenSection {journalScope} />
|
||||||
|
|
||||||
<!-- Events (one-off meetings) for selected date -->
|
<!-- Events (one-off meetings) for selected date -->
|
||||||
{#if journalScope === 'business'}
|
{#if journalScope === 'business'}
|
||||||
|
|
@ -620,7 +623,7 @@
|
||||||
{@const title = lines[0].replace(/^#{1,6}\s+/, '')}
|
{@const title = lines[0].replace(/^#{1,6}\s+/, '')}
|
||||||
{@const body = lines.slice(1).join('\n').trim()}
|
{@const body = lines.slice(1).join('\n').trim()}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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>
|
<span class="mt-0.5 text-xs text-muted whitespace-nowrap">{formatTime(entry)}</span>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
{#if /^\s*(?:\\?\[\\?\]|\[T:[^\]]+\]|\\?\[X\\?\])/.test(title)}
|
{#if /^\s*(?:\\?\[\\?\]|\[T:[^\]]+\]|\\?\[X\\?\])/.test(title)}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,29 @@
|
||||||
import type { HistoryEntry } from '@ka-note/shared';
|
import type { HistoryEntry } from '@ka-note/shared';
|
||||||
import HistoryEntryText from './HistoryEntryText.svelte';
|
import HistoryEntryText from './HistoryEntryText.svelte';
|
||||||
import LinkTitle from './LinkTitle.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 {
|
interface Props {
|
||||||
entry: HistoryEntry;
|
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]"
|
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"
|
title="In Thema wandeln"
|
||||||
onclick={() => showConvert = true}
|
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>
|
><ArrowUpRight size={15} /></button>
|
||||||
<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"
|
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 { writable, derived } from 'svelte/store';
|
||||||
import { liveQuery } from 'dexie';
|
import { liveQuery } from 'dexie';
|
||||||
import { db } from '$lib/db/schema';
|
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 ---
|
// --- Writable UI state ---
|
||||||
export const currentContextId = writable<string>('daily-log');
|
export const currentContextId = writable<string>('daily-log');
|
||||||
|
|
@ -161,3 +161,31 @@ export function pendingWiedervorlage(date: string) {
|
||||||
.toArray()
|
.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,
|
isAuthenticated,
|
||||||
} from "$lib/auth/authStore.js";
|
} from "$lib/auth/authStore.js";
|
||||||
import { scopeColor } from "$lib/stores/scopeContext";
|
import { scopeColor } from "$lib/stores/scopeContext";
|
||||||
|
import { loadCalendarForDate } from "$lib/stores/calendarStore";
|
||||||
|
import { today } from "$lib/db/helpers";
|
||||||
|
|
||||||
let { children }: { children: Snippet } = $props();
|
let { children }: { children: Snippet } = $props();
|
||||||
let sidebarOpen = $state(false);
|
let sidebarOpen = $state(false);
|
||||||
|
|
@ -53,6 +55,7 @@
|
||||||
await seedIfEmpty();
|
await seedIfEmpty();
|
||||||
ready = true;
|
ready = true;
|
||||||
startSync();
|
startSync();
|
||||||
|
loadCalendarForDate(today());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -67,6 +70,7 @@
|
||||||
seedIfEmpty().then(() => {
|
seedIfEmpty().then(() => {
|
||||||
ready = true;
|
ready = true;
|
||||||
startSync();
|
startSync();
|
||||||
|
loadCalendarForDate(today());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@
|
||||||
let coverUrl = $state<string | null>(null);
|
let coverUrl = $state<string | null>(null);
|
||||||
let imageUrls = $state<Map<string, string>>(new Map());
|
let imageUrls = $state<Map<string, string>>(new Map());
|
||||||
let confirmDelete = $state(false);
|
let confirmDelete = $state(false);
|
||||||
|
let confirmDeleteImage = $state<AssetImage | null>(null);
|
||||||
|
let lightboxUrl = $state<string | null>(null);
|
||||||
let uploading = $state(false);
|
let uploading = $state(false);
|
||||||
let recognizing = $state(false);
|
let recognizing = $state(false);
|
||||||
let recognizeResult = $state<RecognizeResult | null>(null);
|
let recognizeResult = $state<RecognizeResult | null>(null);
|
||||||
|
|
@ -178,12 +180,23 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteImage(img: AssetImage) {
|
async function deleteImage(img: AssetImage) {
|
||||||
|
confirmDeleteImage = img;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteImageAction() {
|
||||||
|
const img = confirmDeleteImage;
|
||||||
|
confirmDeleteImage = null;
|
||||||
|
if (!img) return;
|
||||||
await softDeleteAssetImage(img.id);
|
await softDeleteAssetImage(img.id);
|
||||||
if (asset?.coverImageId === img.imageId) {
|
if (asset?.coverImageId === img.imageId) {
|
||||||
await save({ coverImageId: null });
|
await save({ coverImageId: null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openLightbox(img: AssetImage) {
|
||||||
|
lightboxUrl = imageUrls.get(img.id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async function setCover(img: AssetImage) {
|
async function setCover(img: AssetImage) {
|
||||||
await save({ coverImageId: img.imageId });
|
await save({ coverImageId: img.imageId });
|
||||||
}
|
}
|
||||||
|
|
@ -289,8 +302,8 @@
|
||||||
>
|
>
|
||||||
<button
|
<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"
|
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)}
|
onclick={() => openLightbox(img)}
|
||||||
title={isCover ? 'Titelbild' : 'Als Titelbild setzen'}
|
title="Vergrößern"
|
||||||
>
|
>
|
||||||
{#if imageUrls.get(img.id)}
|
{#if imageUrls.get(img.id)}
|
||||||
<img src={imageUrls.get(img.id)} alt="" class="h-full w-full object-cover" />
|
<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>
|
<div class="h-full w-full bg-white/10"></div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<!-- Cover star badge -->
|
<!-- Cover star / set-cover button -->
|
||||||
{#if isCover}
|
{#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" />
|
<Star size={13} class="text-accent fill-accent drop-shadow" />
|
||||||
</span>
|
</button>
|
||||||
{:else}
|
{: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" />
|
<Star size={13} class="text-white/60" />
|
||||||
</span>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Delete button -->
|
<!-- Delete button -->
|
||||||
<button
|
<button
|
||||||
|
|
@ -538,3 +551,21 @@
|
||||||
oncancel={() => confirmDelete = false}
|
oncancel={() => confirmDelete = false}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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 {
|
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);
|
return dateTime.slice(11, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,10 +78,13 @@ export async function getCalendarEvents(
|
||||||
`&$select=${select}` +
|
`&$select=${select}` +
|
||||||
`&$top=50`;
|
`&$top=50`;
|
||||||
|
|
||||||
|
const timezone = process.env.CALENDAR_TIMEZONE ?? 'Europe/Berlin';
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${graphToken}`,
|
Authorization: `Bearer ${graphToken}`,
|
||||||
'Content-Type': 'application/json',
|
'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 type PersonSubType = 'contact' | 'employee' | 'colleague' | 'family' | 'acquaintance';
|
||||||
|
|
||||||
|
export interface MeetingMeta {
|
||||||
|
calendarTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgendaContext extends SyncEntity {
|
export interface AgendaContext extends SyncEntity {
|
||||||
name: string;
|
name: string;
|
||||||
type: ContextType;
|
type: ContextType;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
meta: ProjectMeta | PersonMeta | CompanyMeta | EventMeta | null;
|
meta: ProjectMeta | PersonMeta | CompanyMeta | EventMeta | MeetingMeta | null;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue