mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 19:19:41 +02:00
feat(manacore): cross-app IndexedDB readers for dashboard widgets
Replace REST API polling with direct IndexedDB reads for 4 dashboard widgets: TasksToday, TasksUpcoming, CalendarEvents, ContactsFavorites. Data is now reactive via Dexie liveQuery — updates instantly when any app writes to its IndexedDB (sync, other tabs, local edits). No more 30-60s polling intervals or retry logic needed. New files: - cross-app-stores.ts: Opens todo/calendar/contacts IndexedDB databases - cross-app-queries.ts: Reactive queries (useOpenTasks, useUpcomingEvents, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
31faa5b994
commit
80ea301ac1
7 changed files with 351 additions and 264 deletions
|
|
@ -1,56 +1,25 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* CalendarEventsWidget - Upcoming calendar events
|
||||
* CalendarEventsWidget - Upcoming calendar events (local-first)
|
||||
*
|
||||
* Reads directly from Calendar's IndexedDB via cross-app reader.
|
||||
* Reactive: auto-updates when events change (sync, other tabs).
|
||||
*/
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { calendarService, type CalendarEvent } from '$lib/api/services';
|
||||
import { useAutoRefresh } from '$lib/utils/autoRefresh';
|
||||
import WidgetSkeleton from '../WidgetSkeleton.svelte';
|
||||
import WidgetError from '../WidgetError.svelte';
|
||||
import { useUpcomingEvents } from '$lib/data/cross-app-queries';
|
||||
import type { CrossAppEvent } from '$lib/data/cross-app-stores';
|
||||
import { APP_URLS } from '@manacore/shared-branding';
|
||||
|
||||
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
|
||||
const calendarUrl = isDev ? APP_URLS.calendar.dev : APP_URLS.calendar.prod;
|
||||
|
||||
let state = $state<'loading' | 'success' | 'error'>('loading');
|
||||
let data = $state<CalendarEvent[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let retrying = $state(false);
|
||||
let retryCount = $state(0);
|
||||
const events = useUpcomingEvents(7);
|
||||
|
||||
const MAX_DISPLAY = 5;
|
||||
|
||||
async function load() {
|
||||
if (data.length === 0) state = 'loading';
|
||||
retrying = true;
|
||||
|
||||
const result = await calendarService.getUpcomingEvents(7);
|
||||
|
||||
if (result.data) {
|
||||
data = result.data;
|
||||
state = 'success';
|
||||
retryCount = 0;
|
||||
} else {
|
||||
if (data.length === 0) {
|
||||
error = result.error;
|
||||
state = 'error';
|
||||
}
|
||||
|
||||
const isServiceUnavailable = error?.includes('nicht erreichbar');
|
||||
if (!isServiceUnavailable && retryCount < 3) {
|
||||
retryCount++;
|
||||
setTimeout(load, 5000 * retryCount);
|
||||
}
|
||||
}
|
||||
|
||||
retrying = false;
|
||||
}
|
||||
|
||||
useAutoRefresh(load, 60000);
|
||||
|
||||
function formatEventTime(event: CalendarEvent): string {
|
||||
const start = new Date(event.startTime);
|
||||
function formatEventTime(event: CrossAppEvent): string {
|
||||
const start = new Date(event.startDate);
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
|
@ -68,7 +37,7 @@
|
|||
});
|
||||
}
|
||||
|
||||
if (event.isAllDay) {
|
||||
if (event.allDay) {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
|
|
@ -76,8 +45,8 @@
|
|||
return `${dateStr}, ${timeStr}`;
|
||||
}
|
||||
|
||||
const displayedEvents = $derived((data || []).slice(0, MAX_DISPLAY));
|
||||
const remainingCount = $derived(Math.max(0, (data || []).length - MAX_DISPLAY));
|
||||
const displayedEvents = $derived((events.value ?? []).slice(0, MAX_DISPLAY));
|
||||
const remainingCount = $derived(Math.max(0, (events.value ?? []).length - MAX_DISPLAY));
|
||||
</script>
|
||||
|
||||
<div>
|
||||
|
|
@ -86,18 +55,20 @@
|
|||
<span>🗓️</span>
|
||||
{$_('dashboard.widgets.calendar.title')}
|
||||
</h3>
|
||||
{#if (data || []).length > 0}
|
||||
{#if (events.value ?? []).length > 0}
|
||||
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-sm font-medium text-primary">
|
||||
{(data || []).length}
|
||||
{(events.value ?? []).length}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if state === 'loading'}
|
||||
<WidgetSkeleton lines={4} />
|
||||
{:else if state === 'error'}
|
||||
<WidgetError {error} onRetry={load} {retrying} />
|
||||
{:else if (data || []).length === 0}
|
||||
{#if events.loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(4) as _}
|
||||
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if (events.value ?? []).length === 0}
|
||||
<div class="py-6 text-center">
|
||||
<div class="mb-2 text-3xl">📅</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
|
|
@ -106,7 +77,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each displayedEvents as event}
|
||||
{#each displayedEvents as event (event.id)}
|
||||
<div class="flex items-start gap-3 rounded-lg p-2 transition-colors hover:bg-surface-hover">
|
||||
<div
|
||||
class="mt-1 h-3 w-3 flex-shrink-0 rounded-full"
|
||||
|
|
|
|||
|
|
@ -1,59 +1,28 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ContactsFavoritesWidget - Favorite contacts
|
||||
* ContactsFavoritesWidget - Favorite contacts (local-first)
|
||||
*
|
||||
* Reads directly from Contacts' IndexedDB via cross-app reader.
|
||||
* Reactive: auto-updates when contacts change (sync, other tabs).
|
||||
*/
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { contactsService, type Contact } from '$lib/api/services';
|
||||
import { useFavoriteContacts } from '$lib/data/cross-app-queries';
|
||||
import type { CrossAppContact } from '$lib/data/cross-app-stores';
|
||||
import { APP_URLS } from '@manacore/shared-branding';
|
||||
import WidgetSkeleton from '../WidgetSkeleton.svelte';
|
||||
import WidgetError from '../WidgetError.svelte';
|
||||
|
||||
let state = $state<'loading' | 'success' | 'error'>('loading');
|
||||
let data = $state<Contact[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let retrying = $state(false);
|
||||
let retryCount = $state(0);
|
||||
|
||||
const MAX_DISPLAY = 5;
|
||||
const contacts = useFavoriteContacts(MAX_DISPLAY);
|
||||
|
||||
// Determine app URL based on environment
|
||||
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
|
||||
const contactsUrl = isDev ? APP_URLS.contacts.dev : APP_URLS.contacts.prod;
|
||||
|
||||
async function load() {
|
||||
state = 'loading';
|
||||
retrying = true;
|
||||
|
||||
const result = await contactsService.getFavoriteContacts(MAX_DISPLAY);
|
||||
|
||||
if (result.data) {
|
||||
data = result.data;
|
||||
state = 'success';
|
||||
retryCount = 0;
|
||||
} else {
|
||||
error = result.error;
|
||||
state = 'error';
|
||||
|
||||
// Don't retry if service is unavailable (network error)
|
||||
const isServiceUnavailable = error?.includes('nicht erreichbar');
|
||||
if (!isServiceUnavailable && retryCount < 3) {
|
||||
retryCount++;
|
||||
setTimeout(load, 5000 * retryCount);
|
||||
}
|
||||
}
|
||||
|
||||
retrying = false;
|
||||
function getDisplayName(contact: CrossAppContact): string {
|
||||
const parts = [contact.firstName, contact.lastName].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' ') : contact.email || 'Unbekannt';
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function getDisplayName(contact: Contact): string {
|
||||
return contactsService.getDisplayName(contact);
|
||||
}
|
||||
|
||||
function getInitials(contact: Contact): string {
|
||||
function getInitials(contact: CrossAppContact): string {
|
||||
const name = getDisplayName(contact);
|
||||
const parts = name.split(' ');
|
||||
if (parts.length >= 2) {
|
||||
|
|
@ -66,18 +35,20 @@
|
|||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<span>=e</span>
|
||||
<span>👥</span>
|
||||
{$_('dashboard.widgets.contacts.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{#if state === 'loading'}
|
||||
<WidgetSkeleton lines={4} />
|
||||
{:else if state === 'error'}
|
||||
<WidgetError {error} onRetry={load} {retrying} />
|
||||
{:else if data.length === 0}
|
||||
{#if contacts.loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(4) as _}
|
||||
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if (contacts.value ?? []).length === 0}
|
||||
<div class="py-6 text-center">
|
||||
<div class="mb-2 text-3xl">=<3D></div>
|
||||
<div class="mb-2 text-3xl">👤</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_('dashboard.widgets.contacts.empty')}
|
||||
</p>
|
||||
|
|
@ -92,7 +63,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each data as contact}
|
||||
{#each contacts.value ?? [] as contact (contact.id)}
|
||||
<a
|
||||
href="{contactsUrl}/contacts/{contact.id}"
|
||||
target="_blank"
|
||||
|
|
@ -118,7 +89,7 @@
|
|||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-amber-500">P</span>
|
||||
<span class="text-amber-500">⭐</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -129,7 +100,7 @@
|
|||
rel="noopener"
|
||||
class="mt-3 block text-center text-sm text-primary hover:underline"
|
||||
>
|
||||
{$_('dashboard.widgets.contacts.view_all')} <EFBFBD>
|
||||
{$_('dashboard.widgets.contacts.view_all')} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* TasksTodayWidget - Today's tasks from Todo app
|
||||
* TasksTodayWidget - Today's tasks from Todo app (local-first)
|
||||
*
|
||||
* Reads directly from Todo's IndexedDB via cross-app reader.
|
||||
* Reactive: auto-updates when tasks change (sync, other tabs).
|
||||
*/
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { todoService, type Task } from '$lib/api/services';
|
||||
import { useAutoRefresh } from '$lib/utils/autoRefresh';
|
||||
import { useOpenTasks } from '$lib/data/cross-app-queries';
|
||||
import { crossTaskCollection, type CrossAppTask } from '$lib/data/cross-app-stores';
|
||||
import { APP_URLS } from '@manacore/shared-branding';
|
||||
import { format, isToday, isTomorrow, isPast } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import WidgetSkeleton from '../WidgetSkeleton.svelte';
|
||||
import WidgetError from '../WidgetError.svelte';
|
||||
|
||||
function formatDueDate(dueDate?: string): string | null {
|
||||
function formatDueDate(dueDate?: string | null): string | null {
|
||||
if (!dueDate) return null;
|
||||
const date = new Date(dueDate);
|
||||
if (isToday(date)) return 'Heute';
|
||||
|
|
@ -20,17 +21,13 @@
|
|||
return format(date, 'dd. MMM', { locale: de });
|
||||
}
|
||||
|
||||
function isOverdue(dueDate?: string): boolean {
|
||||
function isOverdue(dueDate?: string | null): boolean {
|
||||
if (!dueDate) return false;
|
||||
const date = new Date(dueDate);
|
||||
return isPast(date) && !isToday(date);
|
||||
}
|
||||
|
||||
let state = $state<'loading' | 'success' | 'error'>('loading');
|
||||
let data = $state<Task[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let retrying = $state(false);
|
||||
let retryCount = $state(0);
|
||||
const tasks = useOpenTasks();
|
||||
|
||||
const MAX_DISPLAY = 5;
|
||||
|
||||
|
|
@ -44,71 +41,31 @@
|
|||
low: '#22c55e',
|
||||
};
|
||||
|
||||
async function load() {
|
||||
if (data.length === 0) state = 'loading';
|
||||
retrying = true;
|
||||
|
||||
const result = await todoService.getAllOpenTasks();
|
||||
|
||||
if (result.data) {
|
||||
data = result.data;
|
||||
state = 'success';
|
||||
retryCount = 0;
|
||||
} else {
|
||||
if (data.length === 0) {
|
||||
error = result.error;
|
||||
state = 'error';
|
||||
}
|
||||
|
||||
const isServiceUnavailable = error?.includes('nicht erreichbar');
|
||||
if (!isServiceUnavailable && retryCount < 3) {
|
||||
retryCount++;
|
||||
setTimeout(load, 5000 * retryCount);
|
||||
}
|
||||
}
|
||||
|
||||
retrying = false;
|
||||
}
|
||||
|
||||
useAutoRefresh(load, 30000);
|
||||
|
||||
const displayedTasks = $derived((data || []).slice(0, MAX_DISPLAY));
|
||||
const remainingCount = $derived(Math.max(0, (data || []).length - MAX_DISPLAY));
|
||||
const completedCount = $derived((data || []).filter((t) => t.isCompleted).length);
|
||||
const totalCount = $derived((data || []).length);
|
||||
const displayedTasks = $derived((tasks.value ?? []).slice(0, MAX_DISPLAY));
|
||||
const remainingCount = $derived(Math.max(0, (tasks.value ?? []).length - MAX_DISPLAY));
|
||||
const totalCount = $derived((tasks.value ?? []).length);
|
||||
|
||||
// Track tasks being toggled (for optimistic UI)
|
||||
let togglingIds: Set<string> = $state(new Set());
|
||||
|
||||
async function handleToggleComplete(e: MouseEvent, task: Task) {
|
||||
async function handleToggleComplete(e: MouseEvent, task: CrossAppTask) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (togglingIds.has(task.id)) return;
|
||||
|
||||
// Optimistic update
|
||||
togglingIds = new Set([...togglingIds, task.id]);
|
||||
const wasCompleted = task.isCompleted;
|
||||
task.isCompleted = !wasCompleted;
|
||||
|
||||
const result = wasCompleted
|
||||
? await todoService.uncompleteTask(task.id)
|
||||
: await todoService.completeTask(task.id);
|
||||
|
||||
if (result.error) {
|
||||
// Revert on error
|
||||
task.isCompleted = wasCompleted;
|
||||
} else if (!wasCompleted) {
|
||||
// Task completed: remove from list after brief delay
|
||||
setTimeout(() => {
|
||||
data = data.filter((t) => t.id !== task.id);
|
||||
}, 600);
|
||||
}
|
||||
// Write directly to IndexedDB — sync engine will push to server
|
||||
await crossTaskCollection.update(task.id, {
|
||||
isCompleted: !task.isCompleted,
|
||||
completedAt: task.isCompleted ? null : new Date().toISOString(),
|
||||
} as Partial<CrossAppTask>);
|
||||
|
||||
togglingIds = new Set([...togglingIds].filter((id) => id !== task.id));
|
||||
}
|
||||
|
||||
function getSubtaskProgress(task: Task): string | null {
|
||||
function getSubtaskProgress(task: CrossAppTask): string | null {
|
||||
if (!task.subtasks || task.subtasks.length === 0) return null;
|
||||
const done = task.subtasks.filter((s) => s.isCompleted).length;
|
||||
return `${done}/${task.subtasks.length}`;
|
||||
|
|
@ -123,15 +80,17 @@
|
|||
</h3>
|
||||
{#if totalCount > 0}
|
||||
<span class="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
|
||||
{completedCount}/{totalCount}
|
||||
{totalCount}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if state === 'loading'}
|
||||
<WidgetSkeleton lines={4} />
|
||||
{:else if state === 'error'}
|
||||
<WidgetError {error} onRetry={load} {retrying} />
|
||||
{#if tasks.loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(4) as _}
|
||||
<div class="h-8 animate-pulse rounded bg-surface-hover"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if totalCount === 0}
|
||||
<div class="py-6 text-center">
|
||||
<div class="mb-2 text-3xl">🎉</div>
|
||||
|
|
@ -141,7 +100,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each displayedTasks as task}
|
||||
{#each displayedTasks as task (task.id)}
|
||||
<a
|
||||
href={todoUrl}
|
||||
target="_blank"
|
||||
|
|
@ -198,41 +157,13 @@
|
|||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Meta row: time, subtasks, labels -->
|
||||
{#if task.dueTime || getSubtaskProgress(task) || (task.labels && task.labels.length > 0)}
|
||||
{#if task.dueTime || getSubtaskProgress(task)}
|
||||
<div class="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{#if task.dueTime}
|
||||
<span>{task.dueTime}</span>
|
||||
{/if}
|
||||
{#if getSubtaskProgress(task)}
|
||||
<span class="flex items-center gap-0.5">
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
{getSubtaskProgress(task)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.labels && task.labels.length > 0}
|
||||
{#each task.labels.slice(0, 2) as label}
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="inline-block h-2 w-2 rounded-full"
|
||||
style="background-color: {label.color}"
|
||||
></span>
|
||||
{label.name}
|
||||
</span>
|
||||
{/each}
|
||||
{#if task.labels.length > 2}
|
||||
<span>+{task.labels.length - 2}</span>
|
||||
{/if}
|
||||
<span>{getSubtaskProgress(task)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,16 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* TasksUpcomingWidget - Upcoming tasks for the next 7 days
|
||||
* TasksUpcomingWidget - Upcoming tasks for the next 7 days (local-first)
|
||||
*
|
||||
* Reads directly from Todo's IndexedDB via cross-app reader.
|
||||
* Reactive: auto-updates when tasks change (sync, other tabs).
|
||||
*/
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { todoService, type Task } from '$lib/api/services';
|
||||
import { useUpcomingTasks } from '$lib/data/cross-app-queries';
|
||||
import { APP_URLS } from '@manacore/shared-branding';
|
||||
import WidgetSkeleton from '../WidgetSkeleton.svelte';
|
||||
import WidgetError from '../WidgetError.svelte';
|
||||
|
||||
let state = $state<'loading' | 'success' | 'error'>('loading');
|
||||
let data = $state<Task[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let retrying = $state(false);
|
||||
let retryCount = $state(0);
|
||||
const tasks = useUpcomingTasks(7);
|
||||
|
||||
const MAX_DISPLAY = 5;
|
||||
|
||||
|
|
@ -28,32 +24,6 @@
|
|||
low: '#22c55e',
|
||||
};
|
||||
|
||||
async function load() {
|
||||
state = 'loading';
|
||||
retrying = true;
|
||||
|
||||
const result = await todoService.getUpcomingTasks(7);
|
||||
|
||||
if (result.data) {
|
||||
data = result.data;
|
||||
state = 'success';
|
||||
retryCount = 0;
|
||||
} else {
|
||||
error = result.error;
|
||||
state = 'error';
|
||||
|
||||
const isServiceUnavailable = error?.includes('nicht erreichbar');
|
||||
if (!isServiceUnavailable && retryCount < 3) {
|
||||
retryCount++;
|
||||
setTimeout(load, 5000 * retryCount);
|
||||
}
|
||||
}
|
||||
|
||||
retrying = false;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
|
|
@ -72,12 +42,12 @@
|
|||
return date < today;
|
||||
}
|
||||
|
||||
function isToday(dateStr: string): boolean {
|
||||
function isTodayDate(dateStr: string): boolean {
|
||||
return new Date(dateStr).toDateString() === new Date().toDateString();
|
||||
}
|
||||
|
||||
const displayedTasks = $derived(data.slice(0, MAX_DISPLAY));
|
||||
const remainingCount = $derived(Math.max(0, data.length - MAX_DISPLAY));
|
||||
const displayedTasks = $derived((tasks.value ?? []).slice(0, MAX_DISPLAY));
|
||||
const remainingCount = $derived(Math.max(0, (tasks.value ?? []).length - MAX_DISPLAY));
|
||||
</script>
|
||||
|
||||
<div>
|
||||
|
|
@ -86,18 +56,20 @@
|
|||
<span>📅</span>
|
||||
{$_('dashboard.widgets.tasks_upcoming.title')}
|
||||
</h3>
|
||||
{#if data.length > 0}
|
||||
{#if (tasks.value ?? []).length > 0}
|
||||
<span class="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
|
||||
{data.length}
|
||||
{(tasks.value ?? []).length}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if state === 'loading'}
|
||||
<WidgetSkeleton lines={4} />
|
||||
{:else if state === 'error'}
|
||||
<WidgetError {error} onRetry={load} {retrying} />
|
||||
{:else if data.length === 0}
|
||||
{#if tasks.loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(4) as _}
|
||||
<div class="h-8 animate-pulse rounded bg-surface-hover"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if (tasks.value ?? []).length === 0}
|
||||
<div class="py-6 text-center">
|
||||
<div class="mb-2 text-3xl">📭</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
|
|
@ -106,7 +78,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each displayedTasks as task}
|
||||
{#each displayedTasks as task (task.id)}
|
||||
<a
|
||||
href={todoUrl}
|
||||
target="_blank"
|
||||
|
|
@ -122,20 +94,6 @@
|
|||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">{task.title}</p>
|
||||
<!-- Meta row: labels -->
|
||||
{#if task.labels && task.labels.length > 0}
|
||||
<div class="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{#each task.labels.slice(0, 2) as label}
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="inline-block h-2 w-2 rounded-full"
|
||||
style="background-color: {label.color}"
|
||||
></span>
|
||||
{label.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Date badge -->
|
||||
|
|
@ -145,7 +103,7 @@
|
|||
task.dueDate
|
||||
)
|
||||
? 'bg-red-500/10 text-red-500'
|
||||
: isToday(task.dueDate)
|
||||
: isTodayDate(task.dueDate)
|
||||
? 'bg-orange-500/10 text-orange-500'
|
||||
: 'bg-muted text-muted-foreground'}"
|
||||
>
|
||||
|
|
|
|||
114
apps/manacore/apps/web/src/lib/data/cross-app-queries.ts
Normal file
114
apps/manacore/apps/web/src/lib/data/cross-app-queries.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Cross-App Reactive Queries
|
||||
*
|
||||
* Live queries that read directly from other apps' IndexedDB databases.
|
||||
* Auto-update when data changes (local writes, sync, other tabs).
|
||||
* Replaces REST API polling with instant reactive reads.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
|
||||
import {
|
||||
crossTaskCollection,
|
||||
crossEventCollection,
|
||||
crossContactCollection,
|
||||
type CrossAppTask,
|
||||
type CrossAppEvent,
|
||||
type CrossAppContact,
|
||||
} from './cross-app-stores';
|
||||
|
||||
// ─── Todo Queries ───────────────────────────────────────────
|
||||
|
||||
/** All open (incomplete) tasks, sorted by order. */
|
||||
export function useOpenTasks() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await crossTaskCollection.getAll(undefined, {
|
||||
sortBy: 'order',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
return all.filter((t) => !t.isCompleted && !t.deletedAt);
|
||||
}, [] as CrossAppTask[]);
|
||||
}
|
||||
|
||||
/** Tasks due today or overdue. */
|
||||
export function useTodayTasks() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
|
||||
const all = await crossTaskCollection.getAll(undefined, {
|
||||
sortBy: 'order',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
|
||||
return all.filter((t) => {
|
||||
if (t.isCompleted || t.deletedAt) return false;
|
||||
if (!t.dueDate) return false;
|
||||
const due = t.dueDate.slice(0, 10);
|
||||
return due <= todayStr;
|
||||
});
|
||||
}, [] as CrossAppTask[]);
|
||||
}
|
||||
|
||||
/** Tasks upcoming in the next N days. */
|
||||
export function useUpcomingTasks(days = 7) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
|
||||
const future = new Date(today);
|
||||
future.setDate(future.getDate() + days);
|
||||
const futureStr = future.toISOString().slice(0, 10);
|
||||
|
||||
const all = await crossTaskCollection.getAll(undefined, {
|
||||
sortBy: 'dueDate',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
|
||||
return all.filter((t) => {
|
||||
if (t.isCompleted || t.deletedAt) return false;
|
||||
if (!t.dueDate) return false;
|
||||
const due = t.dueDate.slice(0, 10);
|
||||
return due > todayStr && due <= futureStr;
|
||||
});
|
||||
}, [] as CrossAppTask[]);
|
||||
}
|
||||
|
||||
// ─── Calendar Queries ───────────────────────────────────────
|
||||
|
||||
/** Events in the next N days. */
|
||||
export function useUpcomingEvents(days = 7) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const now = new Date();
|
||||
const future = new Date(now);
|
||||
future.setDate(future.getDate() + days);
|
||||
|
||||
const nowStr = now.toISOString();
|
||||
const futureStr = future.toISOString();
|
||||
|
||||
const all = await crossEventCollection.getAll(undefined, {
|
||||
sortBy: 'startDate',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
|
||||
return all.filter((e) => {
|
||||
if (e.deletedAt) return false;
|
||||
return e.startDate >= nowStr && e.startDate <= futureStr;
|
||||
});
|
||||
}, [] as CrossAppEvent[]);
|
||||
}
|
||||
|
||||
// ─── Contacts Queries ───────────────────────────────────────
|
||||
|
||||
/** Favorite contacts. */
|
||||
export function useFavoriteContacts(limit = 5) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await crossContactCollection.getAll(undefined, {
|
||||
sortBy: 'firstName',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
|
||||
return all.filter((c) => c.isFavorite && !c.isArchived && !c.deletedAt).slice(0, limit);
|
||||
}, [] as CrossAppContact[]);
|
||||
}
|
||||
134
apps/manacore/apps/web/src/lib/data/cross-app-stores.ts
Normal file
134
apps/manacore/apps/web/src/lib/data/cross-app-stores.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* Cross-App IndexedDB Readers
|
||||
*
|
||||
* Opens other apps' IndexedDB databases for direct read access.
|
||||
* All apps on the same origin share IndexedDB, so ManaCore can
|
||||
* read from manacore-todo, manacore-calendar, etc. directly.
|
||||
*
|
||||
* Data is reactive via Dexie's liveQuery — updates when any app
|
||||
* writes to the same database (including via sync).
|
||||
*
|
||||
* NOTE: These stores are read-only from ManaCore's perspective.
|
||||
* Writes that need sync should go through the owning app's collections.
|
||||
*/
|
||||
|
||||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
|
||||
// ─── Todo Types ─────────────────────────────────────────────
|
||||
|
||||
export interface CrossAppTask extends BaseRecord {
|
||||
title: string;
|
||||
description?: string;
|
||||
projectId?: string | null;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
isCompleted: boolean;
|
||||
completedAt?: string | null;
|
||||
dueDate?: string | null;
|
||||
dueTime?: string | null;
|
||||
scheduledDate?: string | null;
|
||||
estimatedDuration?: number | null;
|
||||
order: number;
|
||||
subtasks?: { id: string; title: string; isCompleted: boolean; order: number }[] | null;
|
||||
labels?: { id: string; name: string; color: string }[];
|
||||
}
|
||||
|
||||
export interface CrossAppProject extends BaseRecord {
|
||||
name: string;
|
||||
color: string;
|
||||
icon?: string | null;
|
||||
order: number;
|
||||
isArchived: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
// ─── Calendar Types ─────────────────────────────────────────
|
||||
|
||||
export interface CrossAppEvent extends BaseRecord {
|
||||
calendarId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
allDay: boolean;
|
||||
location?: string | null;
|
||||
recurrenceRule?: string | null;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface CrossAppCalendar extends BaseRecord {
|
||||
name: string;
|
||||
color: string;
|
||||
isDefault: boolean;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
// ─── Contacts Types ─────────────────────────────────────────
|
||||
|
||||
export interface CrossAppContact extends BaseRecord {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
jobTitle?: string;
|
||||
photoUrl?: string;
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
// ─── Store Instances ────────────────────────────────────────
|
||||
// These open existing IndexedDB databases created by other apps.
|
||||
// No sync config — ManaCore only reads, the owning app handles sync.
|
||||
|
||||
export const todoReader = createLocalStore({
|
||||
appId: 'todo',
|
||||
collections: [
|
||||
{
|
||||
name: 'tasks',
|
||||
indexes: [
|
||||
'projectId',
|
||||
'dueDate',
|
||||
'isCompleted',
|
||||
'priority',
|
||||
'order',
|
||||
'[isCompleted+order]',
|
||||
'[projectId+order]',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'projects',
|
||||
indexes: ['order', 'isArchived'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const calendarReader = createLocalStore({
|
||||
appId: 'calendar',
|
||||
collections: [
|
||||
{
|
||||
name: 'events',
|
||||
indexes: ['calendarId', 'startDate', 'endDate', 'allDay', '[calendarId+startDate]'],
|
||||
},
|
||||
{
|
||||
name: 'calendars',
|
||||
indexes: ['isDefault', 'isVisible'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const contactsReader = createLocalStore({
|
||||
appId: 'contacts',
|
||||
collections: [
|
||||
{
|
||||
name: 'contacts',
|
||||
indexes: ['firstName', 'lastName', 'email', 'company', 'isFavorite', 'isArchived'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Typed collection accessors
|
||||
export const crossTaskCollection = todoReader.collection<CrossAppTask>('tasks');
|
||||
export const crossProjectCollection = todoReader.collection<CrossAppProject>('projects');
|
||||
export const crossEventCollection = calendarReader.collection<CrossAppEvent>('events');
|
||||
export const crossCalendarCollection = calendarReader.collection<CrossAppCalendar>('calendars');
|
||||
export const crossContactCollection = contactsReader.collection<CrossAppContact>('contacts');
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
||||
import { tagLocalStore, tagMutations, useAllTags } from '$lib/stores/tags.svelte';
|
||||
import { manacoreStore } from '$lib/data/local-store';
|
||||
import { todoReader, calendarReader, contactsReader } from '$lib/data/cross-app-stores';
|
||||
import { dashboardStore } from '$lib/stores/dashboard.svelte';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
|
|
@ -203,7 +204,14 @@
|
|||
}
|
||||
|
||||
// Initialize local-first databases (opens IndexedDB, seeds guest data)
|
||||
await Promise.all([manacoreStore.initialize(), tagLocalStore.initialize()]);
|
||||
await Promise.all([
|
||||
manacoreStore.initialize(),
|
||||
tagLocalStore.initialize(),
|
||||
// Cross-app readers (read-only, no sync — owning apps handle sync)
|
||||
todoReader.initialize(),
|
||||
calendarReader.initialize(),
|
||||
contactsReader.initialize(),
|
||||
]);
|
||||
|
||||
// Start syncing to server
|
||||
const getToken = () => authStore.getValidToken();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue