mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
307f1ae22e
commit
0ecbf69ebc
50 changed files with 5791 additions and 53 deletions
232
apps/contacts/apps/web/src/lib/api/todos.ts
Normal file
232
apps/contacts/apps/web/src/lib/api/todos.ts
Normal 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' },
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
515
apps/contacts/apps/web/src/lib/components/ContactTasks.svelte
Normal file
515
apps/contacts/apps/web/src/lib/components/ContactTasks.svelte
Normal 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>
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
209
apps/contacts/apps/web/src/lib/stores/todos.svelte.ts
Normal file
209
apps/contacts/apps/web/src/lib/stores/todos.svelte.ts
Normal 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,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue