feat(contacts): integrate contacts into Todo and Calendar apps

- Add ContactSelector, ContactBadge, ContactAvatar to shared-ui
- Add ContactsClient API service to shared-auth
- Add ContactReference, ContactSummary types to shared-types
- Todo: Add assignee and involvedContacts to tasks with UI in TaskEditModal
- Todo: Display contacts in TaskItem and KanbanTaskCard
- Calendar: Add AttendeeSelector with RSVP status support
- Calendar: Integrate attendees in EventForm
- Calendar: Add task drag-drop to calendar views (Day/Week/MultiDay)
- Contacts: Add ContactTasks component to show related tasks
- Backend: Add findByContact endpoint to Todo task service
- UI polish: glassmorphism styling, keyboard navigation, auto-focus

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-11 16:00:08 +01:00 committed by Wuesteon
parent 307f1ae22e
commit 0ecbf69ebc
50 changed files with 5791 additions and 53 deletions

View file

@ -0,0 +1,232 @@
/**
* Cross-App API Client for Todo Backend
* Allows Contacts app to fetch tasks related to a contact
*/
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
// Types mirrored from @todo/shared
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
export interface Subtask {
id: string;
title: string;
isCompleted: boolean;
completedAt?: string | null;
order: number;
}
export interface Label {
id: string;
userId: string;
name: string;
color: string;
createdAt: string;
updatedAt: string;
}
export interface Project {
id: string;
userId: string;
name: string;
description?: string | null;
color: string;
icon?: string | null;
order: number;
isArchived: boolean;
isDefault: boolean;
createdAt: string;
updatedAt: string;
}
export interface ContactReference {
contactId: string;
displayName: string;
email?: string;
photoUrl?: string;
company?: string;
fetchedAt: string;
}
export interface TaskMetadata {
notes?: string;
attachments?: string[];
linkedCalendarEventId?: string | null;
storyPoints?: number | null;
effectiveDuration?: {
value: number;
unit: 'minutes' | 'hours' | 'days';
} | null;
funRating?: number | null;
assignee?: ContactReference | null;
involvedContacts?: ContactReference[];
}
export interface Task {
id: string;
projectId?: string | null;
userId: string;
parentTaskId?: string | null;
title: string;
description?: string | null;
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledEndTime?: string | null;
estimatedDuration?: number | null;
priority: TaskPriority;
status: TaskStatus;
isCompleted: boolean;
completedAt?: string | null;
order: number;
columnId?: string | null;
columnOrder?: number;
recurrenceRule?: string | null;
recurrenceEndDate?: string | null;
lastOccurrence?: string | null;
subtasks?: Subtask[] | null;
metadata?: TaskMetadata | null;
labels?: Label[];
project?: Project | null;
createdAt: string;
updatedAt: string;
}
// API Configuration
function getTodoApiBase(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_TODO_BACKEND_URL__?: string })
.__PUBLIC_TODO_BACKEND_URL__;
return injectedUrl || 'http://localhost:3018';
}
return 'http://localhost:3018';
}
interface ApiResult<T> {
data: T | null;
error: Error | null;
}
async function fetchTodoApi<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResult<T>> {
const token = await authStore.getAccessToken();
const baseUrl = getTodoApiBase();
try {
const response = await fetch(`${baseUrl}/api/v1${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers || {}),
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `API error: ${response.status}`),
};
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
}
// API Functions
/**
* Get tasks related to a specific contact (assigned or involved)
*/
export async function getTasksByContact(
contactId: string,
includeCompleted: boolean = false
): Promise<ApiResult<Task[]>> {
const params = new URLSearchParams();
if (includeCompleted) {
params.set('includeCompleted', 'true');
}
const query = params.toString();
const result = await fetchTodoApi<{ tasks: Task[] }>(
`/tasks/by-contact/${contactId}${query ? `?${query}` : ''}`
);
return {
data: result.data?.tasks || null,
error: result.error,
};
}
/**
* Complete a task
*/
export async function completeTask(taskId: string): Promise<ApiResult<Task>> {
const result = await fetchTodoApi<{ task: Task }>(`/tasks/${taskId}/complete`, {
method: 'POST',
});
return {
data: result.data?.task || null,
error: result.error,
};
}
/**
* Uncomplete a task
*/
export async function uncompleteTask(taskId: string): Promise<ApiResult<Task>> {
const result = await fetchTodoApi<{ task: Task }>(`/tasks/${taskId}/uncomplete`, {
method: 'POST',
});
return {
data: result.data?.task || null,
error: result.error,
};
}
/**
* Check if the Todo service is available
*/
export async function checkTodoServiceAvailable(): Promise<boolean> {
try {
const baseUrl = getTodoApiBase();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${baseUrl}/api/v1/health`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
return response.ok;
} catch {
return false;
}
}
// Priority styling helpers
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
urgent: 'var(--color-danger, #ef4444)',
high: 'var(--color-warning, #f59e0b)',
medium: 'var(--color-accent, #3b82f6)',
low: 'var(--color-success, #22c55e)',
};
export const PRIORITY_LABELS: Record<TaskPriority, { de: string; en: string }> = {
urgent: { de: 'Dringend', en: 'Urgent' },
high: { de: 'Wichtig', en: 'High' },
medium: { de: 'Normal', en: 'Medium' },
low: { de: 'Niedrig', en: 'Low' },
};

View file

@ -3,6 +3,7 @@
import { onMount } from 'svelte';
import { contactsApi, photoApi, type Contact } from '$lib/api/contacts';
import ContactNotes from './ContactNotes.svelte';
import ContactTasks from './ContactTasks.svelte';
import { ContactDetailSkeleton } from '$lib/components/skeletons';
interface Props {
@ -841,6 +842,9 @@
<!-- Contact Notes (separate from contact.notes field) -->
<ContactNotes {contactId} />
<!-- Tasks related to this contact -->
<ContactTasks {contactId} />
</div>
{/if}
{/if}

View file

@ -0,0 +1,515 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { todosStore } from '$lib/stores/todos.svelte';
import { PRIORITY_COLORS, type Task } from '$lib/api/todos';
interface Props {
contactId: string;
}
let { contactId }: Props = $props();
let loading = $state(true);
let error = $state<string | null>(null);
let showCompleted = $state(false);
let displayLimit = $state(10);
// Categorized tasks
let assignedTasks = $state<Task[]>([]);
let involvedTasks = $state<Task[]>([]);
// Derived values
const visibleAssigned = $derived.by(() => {
const filtered = showCompleted ? assignedTasks : assignedTasks.filter((t) => !t.isCompleted);
return filtered.slice(0, displayLimit);
});
const visibleInvolved = $derived.by(() => {
const filtered = showCompleted ? involvedTasks : involvedTasks.filter((t) => !t.isCompleted);
return filtered.slice(0, displayLimit);
});
const totalVisible = $derived(visibleAssigned.length + visibleInvolved.length);
const totalTasks = $derived(assignedTasks.length + involvedTasks.length);
const hasMore = $derived(totalVisible < totalTasks);
async function loadTasks() {
loading = true;
error = null;
try {
// Always load with completed to get full list, filter in UI
await todosStore.loadTasksForContact(contactId, true);
const categorized = todosStore.categorizeTasksForContact(contactId);
assignedTasks = categorized.assigned;
involvedTasks = categorized.involved;
} catch (e) {
error = e instanceof Error ? e.message : $_('contact.tasks.error');
} finally {
loading = false;
}
}
async function handleToggleComplete(task: Task) {
const success = await todosStore.toggleTaskCompletion(task.id, contactId);
if (success) {
// Refresh categorization
const categorized = todosStore.categorizeTasksForContact(contactId);
assignedTasks = categorized.assigned;
involvedTasks = categorized.involved;
}
}
function formatDueDate(dueDate: string | null | undefined): {
text: string;
status: 'overdue' | 'today' | 'upcoming' | 'none';
} {
if (!dueDate) return { text: '', status: 'none' };
const due = new Date(dueDate);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
const diffDays = Math.floor((dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return { text: $_('contact.tasks.overdue'), status: 'overdue' };
} else if (diffDays === 0) {
return { text: $_('contact.tasks.dueToday'), status: 'today' };
} else if (diffDays === 1) {
return { text: $_('contact.tasks.tomorrow'), status: 'upcoming' };
} else if (diffDays < 7) {
return { text: due.toLocaleDateString('de-DE', { weekday: 'short' }), status: 'upcoming' };
} else {
return {
text: due.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' }),
status: 'upcoming',
};
}
}
function showMore() {
displayLimit += 10;
}
onMount(loadTasks);
</script>
<section class="tasks-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
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>
</div>
<h3 class="section-title">{$_('contact.tasks.title')}</h3>
<label class="show-completed-toggle">
<input type="checkbox" bind:checked={showCompleted} />
<span>{$_('contact.tasks.showCompleted')}</span>
</label>
</div>
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if loading}
<div class="loading">
<span class="spinner"></span>
</div>
{:else if !todosStore.serviceAvailable}
<div class="service-unavailable">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<p>{$_('contact.tasks.serviceUnavailable')}</p>
</div>
{:else if totalTasks === 0}
<div class="empty-tasks">
<p>{$_('contact.tasks.empty')}</p>
</div>
{:else}
<!-- Assigned Tasks -->
{#if assignedTasks.length > 0}
<div class="task-group">
<div class="group-header">
<span class="group-title">{$_('contact.tasks.assigned')}</span>
<span class="group-count">{visibleAssigned.length}</span>
</div>
<div class="tasks-list">
{#each visibleAssigned as task (task.id)}
{@const dueInfo = formatDueDate(task.dueDate)}
<div class="task-item" class:completed={task.isCompleted}>
<button
class="task-checkbox"
onclick={() => handleToggleComplete(task)}
aria-label={task.isCompleted
? $_('contact.tasks.markIncomplete')
: $_('contact.tasks.markComplete')}
style="--priority-color: {PRIORITY_COLORS[task.priority]}"
>
{#if task.isCompleted}
<svg fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</svg>
{/if}
</button>
<div class="task-content">
<span class="task-title" class:completed={task.isCompleted}>{task.title}</span>
{#if task.project}
<span class="task-project" style="--project-color: {task.project.color}"
>{task.project.name}</span
>
{/if}
</div>
{#if dueInfo.status !== 'none' && !task.isCompleted}
<span
class="task-due"
class:overdue={dueInfo.status === 'overdue'}
class:today={dueInfo.status === 'today'}
>
{dueInfo.text}
</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Involved Tasks -->
{#if involvedTasks.length > 0}
<div class="task-group">
<div class="group-header">
<span class="group-title">{$_('contact.tasks.involved')}</span>
<span class="group-count">{visibleInvolved.length}</span>
</div>
<div class="tasks-list">
{#each visibleInvolved as task (task.id)}
{@const dueInfo = formatDueDate(task.dueDate)}
<div class="task-item" class:completed={task.isCompleted}>
<button
class="task-checkbox"
onclick={() => handleToggleComplete(task)}
aria-label={task.isCompleted
? $_('contact.tasks.markIncomplete')
: $_('contact.tasks.markComplete')}
style="--priority-color: {PRIORITY_COLORS[task.priority]}"
>
{#if task.isCompleted}
<svg fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</svg>
{/if}
</button>
<div class="task-content">
<span class="task-title" class:completed={task.isCompleted}>{task.title}</span>
{#if task.project}
<span class="task-project" style="--project-color: {task.project.color}"
>{task.project.name}</span
>
{/if}
</div>
{#if dueInfo.status !== 'none' && !task.isCompleted}
<span
class="task-due"
class:overdue={dueInfo.status === 'overdue'}
class:today={dueInfo.status === 'today'}
>
{dueInfo.text}
</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Show More Button -->
{#if hasMore}
<button class="show-more-btn" onclick={showMore}>
{$_('contact.tasks.showMore', { values: { count: totalTasks - totalVisible } })}
</button>
{/if}
{/if}
</section>
<style>
.tasks-section {
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.875rem;
padding: 1rem;
}
.section-header {
display: flex;
align-items: center;
gap: 0.625rem;
padding-bottom: 0.625rem;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
margin-bottom: 0.75rem;
}
.section-icon {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
background: hsl(var(--color-accent) / 0.1);
color: hsl(var(--color-accent));
display: flex;
align-items: center;
justify-content: center;
}
.section-icon svg {
width: 1rem;
height: 1rem;
}
.section-title {
flex: 1;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.show-completed-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
}
.show-completed-toggle input {
width: 0.875rem;
height: 0.875rem;
accent-color: hsl(var(--color-primary));
}
.error-message {
padding: 0.5rem 0.75rem;
background: hsl(var(--color-error) / 0.1);
border-radius: 0.5rem;
color: hsl(var(--color-error));
font-size: 0.8125rem;
margin-bottom: 0.75rem;
}
.loading {
display: flex;
justify-content: center;
padding: 1.5rem;
}
.spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid hsl(var(--color-muted));
border-top-color: hsl(var(--color-accent));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.service-unavailable {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
color: hsl(var(--color-muted-foreground));
}
.service-unavailable svg {
width: 1.5rem;
height: 1.5rem;
color: hsl(var(--color-warning));
}
.service-unavailable p {
font-size: 0.8125rem;
text-align: center;
}
.empty-tasks {
text-align: center;
padding: 1.5rem 1rem;
}
.empty-tasks p {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
/* Task Groups */
.task-group {
margin-bottom: 0.75rem;
}
.task-group:last-of-type {
margin-bottom: 0;
}
.group-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.group-title {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.group-count {
font-size: 0.625rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
}
/* Tasks List */
.tasks-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.task-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
background: hsl(var(--color-muted) / 0.3);
border-radius: 0.5rem;
transition: all 0.2s ease;
}
.task-item:hover {
background: hsl(var(--color-muted) / 0.5);
}
.task-item.completed {
opacity: 0.6;
}
.task-checkbox {
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
border: 2px solid var(--priority-color, hsl(var(--color-border)));
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
flex-shrink: 0;
}
.task-checkbox:hover {
background: hsl(var(--color-muted));
}
.task-item.completed .task-checkbox {
background: var(--priority-color, hsl(var(--color-primary)));
border-color: var(--priority-color, hsl(var(--color-primary)));
}
.task-checkbox svg {
width: 0.75rem;
height: 0.75rem;
color: white;
}
.task-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.task-title {
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-title.completed {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.task-project {
font-size: 0.6875rem;
color: var(--project-color, hsl(var(--color-muted-foreground)));
}
.task-due {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
flex-shrink: 0;
}
.task-due.overdue {
background: hsl(var(--color-error) / 0.1);
color: hsl(var(--color-error));
}
.task-due.today {
background: hsl(var(--color-warning) / 0.1);
color: hsl(var(--color-warning));
}
/* Show More Button */
.show-more-btn {
width: 100%;
padding: 0.5rem;
margin-top: 0.5rem;
border: none;
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.3);
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.show-more-btn:hover {
background: hsl(var(--color-muted) / 0.5);
color: hsl(var(--color-foreground));
}
</style>

View file

@ -102,7 +102,22 @@
"country": "Land",
"website": "Website",
"birthday": "Geburtstag",
"notes": "Notizen"
"notes": "Notizen",
"tasks": {
"title": "Aufgaben",
"assigned": "Zugewiesen",
"involved": "Beteiligt",
"empty": "Keine Aufgaben für diesen Kontakt",
"serviceUnavailable": "Todo-Service nicht erreichbar",
"error": "Fehler beim Laden der Aufgaben",
"overdue": "Überfällig",
"dueToday": "Heute",
"tomorrow": "Morgen",
"showCompleted": "Erledigte",
"showMore": "{count} weitere anzeigen",
"markComplete": "Als erledigt markieren",
"markIncomplete": "Als unerledigt markieren"
}
},
"groups": {
"title": "Gruppen",

View file

@ -102,7 +102,22 @@
"country": "Country",
"website": "Website",
"birthday": "Birthday",
"notes": "Notes"
"notes": "Notes",
"tasks": {
"title": "Tasks",
"assigned": "Assigned",
"involved": "Involved",
"empty": "No tasks for this contact",
"serviceUnavailable": "Todo service unavailable",
"error": "Failed to load tasks",
"overdue": "Overdue",
"dueToday": "Today",
"tomorrow": "Tomorrow",
"showCompleted": "Completed",
"showMore": "Show {count} more",
"markComplete": "Mark as complete",
"markIncomplete": "Mark as incomplete"
}
},
"groups": {
"title": "Groups",

View file

@ -0,0 +1,209 @@
/**
* Todo Store for Contacts App
* Manages tasks related to contacts from the Todo service
*/
import { browser } from '$app/environment';
import {
getTasksByContact,
completeTask,
uncompleteTask,
checkTodoServiceAvailable,
type Task,
} from '$lib/api/todos';
// State
let tasksByContact = $state<Map<string, Task[]>>(new Map());
let loadingContacts = $state<Set<string>>(new Set());
let serviceAvailable = $state<boolean | null>(null);
let lastAvailabilityCheck = $state<number>(0);
// Cache TTL in milliseconds (5 minutes)
const CACHE_TTL = 5 * 60 * 1000;
// Availability check interval (30 seconds)
const AVAILABILITY_CHECK_INTERVAL = 30 * 1000;
// Cache timestamps
const cacheTimestamps = new Map<string, number>();
/**
* Check if cached data is still valid
*/
function isCacheValid(contactId: string): boolean {
const timestamp = cacheTimestamps.get(contactId);
if (!timestamp) return false;
return Date.now() - timestamp < CACHE_TTL;
}
/**
* Check if the Todo service is available (with caching)
*/
async function checkAvailability(): Promise<boolean> {
if (!browser) return false;
const now = Date.now();
if (serviceAvailable !== null && now - lastAvailabilityCheck < AVAILABILITY_CHECK_INTERVAL) {
return serviceAvailable;
}
const available = await checkTodoServiceAvailable();
serviceAvailable = available;
lastAvailabilityCheck = now;
return available;
}
/**
* Load tasks for a specific contact
*/
async function loadTasksForContact(
contactId: string,
includeCompleted: boolean = false,
forceRefresh: boolean = false
): Promise<Task[]> {
if (!browser) return [];
// Check cache first
if (!forceRefresh && isCacheValid(contactId)) {
return tasksByContact.get(contactId) || [];
}
// Check service availability
const available = await checkAvailability();
if (!available) {
return [];
}
// Mark as loading
loadingContacts = new Set([...loadingContacts, contactId]);
try {
const { data, error } = await getTasksByContact(contactId, includeCompleted);
if (error) {
console.error(`Failed to load tasks for contact ${contactId}:`, error);
return [];
}
const tasks = data || [];
// Update cache
tasksByContact = new Map(tasksByContact).set(contactId, tasks);
cacheTimestamps.set(contactId, Date.now());
return tasks;
} finally {
// Remove from loading set
const newLoading = new Set(loadingContacts);
newLoading.delete(contactId);
loadingContacts = newLoading;
}
}
/**
* Get cached tasks for a contact (does not fetch)
*/
function getTasksForContact(contactId: string): Task[] {
return tasksByContact.get(contactId) || [];
}
/**
* Check if tasks are currently loading for a contact
*/
function isLoading(contactId: string): boolean {
return loadingContacts.has(contactId);
}
/**
* Toggle task completion
*/
async function toggleTaskCompletion(taskId: string, contactId: string): Promise<boolean> {
const tasks = tasksByContact.get(contactId) || [];
const task = tasks.find((t) => t.id === taskId);
if (!task) return false;
const { data, error } = task.isCompleted
? await uncompleteTask(taskId)
: await completeTask(taskId);
if (error || !data) {
console.error('Failed to toggle task completion:', error);
return false;
}
// Update local state
const updatedTasks = tasks.map((t) => (t.id === taskId ? data : t));
tasksByContact = new Map(tasksByContact).set(contactId, updatedTasks);
return true;
}
/**
* Clear cache for a specific contact
*/
function clearCacheForContact(contactId: string): void {
const newMap = new Map(tasksByContact);
newMap.delete(contactId);
tasksByContact = newMap;
cacheTimestamps.delete(contactId);
}
/**
* Clear all cached data
*/
function clearCache(): void {
tasksByContact = new Map();
cacheTimestamps.clear();
}
/**
* Categorize tasks by their relation to the contact
*/
function categorizeTasksForContact(contactId: string): { assigned: Task[]; involved: Task[] } {
const tasks = tasksByContact.get(contactId) || [];
const assigned: Task[] = [];
const involved: Task[] = [];
for (const task of tasks) {
const isAssignee = task.metadata?.assignee?.contactId === contactId;
const isInvolved = task.metadata?.involvedContacts?.some((c) => c.contactId === contactId);
if (isAssignee) {
assigned.push(task);
} else if (isInvolved) {
involved.push(task);
}
}
// Sort by due date (overdue first, then by date)
const sortByDueDate = (a: Task, b: Task): number => {
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
};
assigned.sort(sortByDueDate);
involved.sort(sortByDueDate);
return { assigned, involved };
}
// Export store
export const todosStore = {
// Getters (reactive)
get serviceAvailable() {
return serviceAvailable;
},
// Methods
checkAvailability,
loadTasksForContact,
getTasksForContact,
isLoading,
toggleTaskCompletion,
clearCacheForContact,
clearCache,
categorizeTasksForContact,
};