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

@ -57,6 +57,12 @@ export const tasks = pgTable(
dueTime: varchar('due_time', { length: 5 }),
startDate: timestamp('start_date', { withTimezone: true }),
// Time-Blocking (for calendar integration)
scheduledDate: timestamp('scheduled_date', { withTimezone: true }),
scheduledStartTime: varchar('scheduled_start_time', { length: 5 }), // HH:mm
scheduledEndTime: varchar('scheduled_end_time', { length: 5 }), // HH:mm
estimatedDuration: integer('estimated_duration'), // in minutes
// Priority & Status
priority: varchar('priority', { length: 10 }).default('medium').$type<TaskPriority>(),
status: varchar('status', { length: 20 }).default('pending').$type<TaskStatus>(),
@ -90,6 +96,7 @@ export const tasks = pgTable(
projectIdx: index('tasks_project_idx').on(table.projectId),
userIdx: index('tasks_user_idx').on(table.userId),
dueDateIdx: index('tasks_due_date_idx').on(table.dueDate),
scheduledDateIdx: index('tasks_scheduled_date_idx').on(table.scheduledDate),
statusIdx: index('tasks_status_idx').on(table.isCompleted, table.status),
parentIdx: index('tasks_parent_idx').on(table.parentTaskId),
orderIdx: index('tasks_order_idx').on(table.projectId, table.order),

View file

@ -9,6 +9,10 @@ import {
IsDateString,
IsNotEmpty,
ValidateNested,
IsInt,
Min,
Max,
Matches,
} from 'class-validator';
import { Type } from 'class-transformer';
import type { TaskPriority } from '../../db/schema/tasks.schema';
@ -47,6 +51,27 @@ export class CreateTaskDto {
@IsDateString()
startDate?: string | null;
// Time-Blocking fields
@IsOptional()
@IsDateString()
scheduledDate?: string | null;
@IsOptional()
@IsString()
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledStartTime must be in HH:mm format' })
scheduledStartTime?: string | null;
@IsOptional()
@IsString()
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledEndTime must be in HH:mm format' })
scheduledEndTime?: string | null;
@IsOptional()
@IsInt()
@Min(1)
@Max(1440) // Max 24 hours in minutes
estimatedDuration?: number | null;
@IsOptional()
@IsEnum(['low', 'medium', 'high', 'urgent'])
priority?: TaskPriority;

View file

@ -9,6 +9,10 @@ import {
IsObject,
MaxLength,
IsDateString,
IsInt,
Min,
Max,
Matches,
} from 'class-validator';
import type { TaskPriority, TaskStatus, Subtask, TaskMetadata } from '../../db/schema/tasks.schema';
@ -43,6 +47,27 @@ export class UpdateTaskDto {
@IsDateString()
startDate?: string | null;
// Time-Blocking fields
@IsOptional()
@IsDateString()
scheduledDate?: string | null;
@IsOptional()
@IsString()
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledStartTime must be in HH:mm format' })
scheduledStartTime?: string | null;
@IsOptional()
@IsString()
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledEndTime must be in HH:mm format' })
scheduledEndTime?: string | null;
@IsOptional()
@IsInt()
@Min(1)
@Max(1440) // Max 24 hours in minutes
estimatedDuration?: number | null;
@IsOptional()
@IsEnum(['low', 'medium', 'high', 'urgent'])
priority?: TaskPriority;
@ -74,4 +99,9 @@ export class UpdateTaskDto {
@IsOptional()
@IsObject()
metadata?: TaskMetadata | null;
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
labelIds?: string[];
}

View file

@ -42,6 +42,20 @@ export class TaskController {
return result;
}
@Get('by-contact/:contactId')
async getByContact(
@CurrentUser() user: CurrentUserData,
@Param('contactId') contactId: string,
@Query('includeCompleted') includeCompleted?: string
) {
const tasks = await this.taskService.findByContact(
user.userId,
contactId,
includeCompleted === 'true'
);
return { tasks };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const task = await this.taskService.findByIdOrThrow(id, user.userId);

View file

@ -151,8 +151,11 @@ export class TaskService {
await this.projectService.findByIdOrThrow(dto.projectId, userId);
}
// Extract labelIds before spreading dto (it's not a db column)
const { labelIds, ...dtoWithoutLabels } = dto;
const updateData: Partial<NewTask> = {
...dto,
...dtoWithoutLabels,
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
startDate: dto.startDate
? new Date(dto.startDate)
@ -181,6 +184,11 @@ export class TaskService {
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
.returning();
// Update labels if provided
if (labelIds !== undefined) {
await this.updateTaskLabels(id, userId, labelIds);
}
return this.loadTaskLabels(updated);
}
@ -411,6 +419,41 @@ export class TaskService {
return this.findAll(userId, { isCompleted: false });
}
/**
* Finds all tasks where the given contact is either the assignee or involved.
* Searches in metadata->assignee->contactId and metadata->involvedContacts array.
*/
async findByContact(
userId: string,
contactId: string,
includeCompleted: boolean = false
): Promise<TaskWithLabels[]> {
// Build conditions for the query
const conditions: SQL[] = [eq(tasks.userId, userId)];
// Optionally exclude completed tasks
if (!includeCompleted) {
conditions.push(eq(tasks.isCompleted, false));
}
// Search for contactId in metadata->assignee->contactId OR in metadata->involvedContacts array
const contactCondition = or(
// Check if assignee.contactId matches
sql`${tasks.metadata}->>'assignee' IS NOT NULL AND ${tasks.metadata}->'assignee'->>'contactId' = ${contactId}`,
// Check if contactId exists in involvedContacts array
sql`${tasks.metadata}->'involvedContacts' @> ${JSON.stringify([{ contactId }])}::jsonb`
);
conditions.push(contactCondition as SQL);
const result = await this.db.query.tasks.findMany({
where: and(...conditions),
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
});
return this.loadTaskLabelsBatch(result);
}
async getTodayTasks(userId: string): Promise<TaskWithLabels[]> {
const today = new Date();
today.setHours(0, 0, 0, 0);

View file

@ -30,6 +30,7 @@
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-types": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@manacore/shared-tags": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",

View file

@ -7,8 +7,10 @@
EffectiveDuration,
UpdateTaskInput,
} from '@todo/shared';
import type { ContactReference, ContactOrManual } from '@manacore/shared-types';
import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared';
import { projectsStore } from '$lib/stores/projects.svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { format } from 'date-fns';
import SubtaskList from './SubtaskList.svelte';
import {
@ -18,6 +20,7 @@
FunRatingPicker,
TagSelector,
} from './form';
import { ContactSelector } from '@manacore/shared-ui';
interface Props {
task: Task;
@ -45,6 +48,10 @@
let storyPoints = $state<number | null>(null);
let effectiveDuration = $state<EffectiveDuration | null>(null);
let funRating = $state<number | null>(null);
// Contact associations
let assignee = $state<ContactOrManual[]>([]);
let involvedContacts = $state<ContactOrManual[]>([]);
let contactsAvailable = $state<boolean | null>(null);
// UI state
let isLoading = $state(false);
@ -69,7 +76,15 @@
storyPoints = task.metadata?.storyPoints ?? null;
effectiveDuration = task.metadata?.effectiveDuration ?? null;
funRating = task.metadata?.funRating ?? null;
// Contact associations
assignee = task.metadata?.assignee ? [task.metadata.assignee] : [];
involvedContacts = task.metadata?.involvedContacts || [];
showDeleteConfirm = false;
// Check contacts availability
contactsStore.checkAvailability().then((available) => {
contactsAvailable = available;
});
}
});
@ -88,11 +103,26 @@
}
}
// Extract ContactReference from ContactOrManual (filter out manual entries for now)
function toContactReference(contact: ContactOrManual): ContactReference | null {
if ('isManual' in contact && contact.isManual) {
return null; // Manual entries not stored as contacts
}
return contact as ContactReference;
}
async function handleSave() {
if (!title.trim()) return;
isLoading = true;
try {
// Convert assignee array to single ContactReference
const assigneeRef = assignee.length > 0 ? toContactReference(assignee[0]) : null;
// Convert involved contacts to array of ContactReferences
const involvedRefs = involvedContacts
.map(toContactReference)
.filter((c): c is ContactReference => c !== null);
const data: UpdateTaskInput = {
title: title.trim(),
description: description.trim() || null,
@ -110,6 +140,8 @@
storyPoints: storyPoints ?? undefined,
effectiveDuration: effectiveDuration ?? undefined,
funRating: funRating ?? undefined,
assignee: assigneeRef ?? undefined,
involvedContacts: involvedRefs.length > 0 ? involvedRefs : undefined,
},
labelIds: selectedLabelIds,
};
@ -179,6 +211,37 @@
></textarea>
</div>
<!-- Zuständige Person -->
<div class="form-section">
<label class="form-label">Zuständig</label>
<ContactSelector
selectedContacts={assignee}
onContactsChange={(contacts) => (assignee = contacts)}
onSearch={(q) => contactsStore.searchContacts(q)}
singleSelect={true}
allowManualEntry={false}
placeholder="Person zuweisen..."
addLabel="Zuweisen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
/>
</div>
<!-- Beteiligte Personen -->
<div class="form-section">
<label class="form-label">Beteiligte</label>
<ContactSelector
selectedContacts={involvedContacts}
onContactsChange={(contacts) => (involvedContacts = contacts)}
onSearch={(q) => contactsStore.searchContacts(q)}
allowManualEntry={false}
placeholder="Personen hinzufügen..."
addLabel="Person hinzufügen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
/>
</div>
<!-- Zeitplanung -->
<div class="form-section">
<label class="form-label">Zeitplanung</label>

View file

@ -3,6 +3,7 @@
import { format, isToday, isPast, isTomorrow } from 'date-fns';
import { de } from 'date-fns/locale';
import { projectsStore } from '$lib/stores/projects.svelte';
import { ContactAvatar } from '@manacore/shared-ui';
interface Props {
task: Task;
@ -165,6 +166,33 @@
{/if}
</button>
<!-- Assignee and involved contacts -->
{#if task.metadata?.assignee || (task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0)}
<div class="contacts-display">
{#if task.metadata?.assignee}
<div class="assignee-avatar" title="Zuständig: {task.metadata.assignee.displayName}">
<ContactAvatar
name={task.metadata.assignee.displayName}
photoUrl={task.metadata.assignee.photoUrl}
size="xs"
/>
</div>
{/if}
{#if task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0}
<div class="involved-avatars">
{#each task.metadata.involvedContacts.slice(0, 2) as contact}
<div class="involved-avatar" title="Beteiligt: {contact.displayName}">
<ContactAvatar name={contact.displayName} photoUrl={contact.photoUrl} size="xs" />
</div>
{/each}
{#if task.metadata.involvedContacts.length > 2}
<span class="more-contacts">+{task.metadata.involvedContacts.length - 2}</span>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Due date (always on the right) -->
{#if dueDateText()}
<span
@ -424,6 +452,58 @@
font-weight: 500;
}
/* Contacts display */
.contacts-display {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.assignee-avatar {
position: relative;
}
.assignee-avatar::after {
content: '';
position: absolute;
bottom: -1px;
right: -1px;
width: 6px;
height: 6px;
background: #8b5cf6;
border-radius: 50%;
border: 1px solid white;
}
:global(.dark) .assignee-avatar::after {
border-color: rgba(30, 30, 30, 1);
}
.involved-avatars {
display: flex;
align-items: center;
}
.involved-avatar {
margin-left: -0.375rem;
}
.involved-avatar:first-child {
margin-left: 0;
}
.more-contacts {
font-size: 0.625rem;
color: #6b7280;
margin-left: 0.25rem;
font-weight: 500;
}
:global(.dark) .more-contacts {
color: #9ca3af;
}
/* Due date */
.due-date {
font-size: 0.75rem;

View file

@ -28,18 +28,19 @@
// Track which task is being animated for completion
let animatingTaskId = $state<string | null>(null);
// Create a stable key from task IDs to detect real changes
let lastTaskIds = '';
// Create a stable key from task IDs and updatedAt to detect real changes
let lastTaskKey = '';
// Sync items with tasks only when the set of task IDs changes
// Sync items with tasks when IDs change OR when tasks are updated
$effect(() => {
const currentIds = tasks
.map((t) => t.id)
// Include updatedAt in the key to detect task updates
const currentKey = tasks
.map((t) => `${t.id}:${t.updatedAt || ''}`)
.sort()
.join(',');
if (currentIds !== lastTaskIds) {
if (currentKey !== lastTaskKey) {
items = [...tasks];
lastTaskIds = currentIds;
lastTaskKey = currentKey;
}
});
@ -70,10 +71,10 @@
}
}
// Update local state and sync lastTaskIds to prevent $effect from reverting
// Update local state and sync lastTaskKey to prevent $effect from reverting
items = newItems;
lastTaskIds = newItems
.map((t) => t.id)
lastTaskKey = newItems
.map((t) => `${t.id}:${t.updatedAt || ''}`)
.sort()
.join(',');
}

View file

@ -2,7 +2,7 @@
import type { Task } from '@todo/shared';
import { format, isToday, isPast, isTomorrow } from 'date-fns';
import { de } from 'date-fns/locale';
import { ConfirmationModal } from '@manacore/shared-ui';
import { ConfirmationModal, ContactAvatar } from '@manacore/shared-ui';
import TaskEditModal from '../TaskEditModal.svelte';
interface Props {
@ -249,6 +249,33 @@
</div>
{/if}
</div>
<!-- Contacts display -->
{#if task.metadata?.assignee || (task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0)}
<div class="contacts-display">
{#if task.metadata?.assignee}
<div class="assignee-avatar" title="Zuständig: {task.metadata.assignee.displayName}">
<ContactAvatar
name={task.metadata.assignee.displayName}
photoUrl={task.metadata.assignee.photoUrl}
size="xs"
/>
</div>
{/if}
{#if task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0}
<div class="involved-avatars">
{#each task.metadata.involvedContacts.slice(0, 2) as contact}
<div class="involved-avatar" title="Beteiligt: {contact.displayName}">
<ContactAvatar name={contact.displayName} photoUrl={contact.photoUrl} size="xs" />
</div>
{/each}
{#if task.metadata.involvedContacts.length > 2}
<span class="more-contacts">+{task.metadata.involvedContacts.length - 2}</span>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Context Menu -->
@ -500,6 +527,58 @@
font-weight: 500;
}
/* Contacts display */
.contacts-display {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.assignee-avatar {
position: relative;
}
.assignee-avatar::after {
content: '';
position: absolute;
bottom: -1px;
right: -1px;
width: 6px;
height: 6px;
background: #8b5cf6;
border-radius: 50%;
border: 1px solid white;
}
:global(.dark) .assignee-avatar::after {
border-color: rgba(30, 30, 30, 1);
}
.involved-avatars {
display: flex;
align-items: center;
}
.involved-avatar {
margin-left: -0.375rem;
}
.involved-avatar:first-child {
margin-left: 0;
}
.more-contacts {
font-size: 0.625rem;
color: #6b7280;
margin-left: 0.25rem;
font-weight: 500;
}
:global(.dark) .more-contacts {
color: #9ca3af;
}
/* Context Menu */
.context-menu {
position: fixed;

View file

@ -0,0 +1,175 @@
/**
* Contacts Store for Todo App
*
* Provides access to contacts from the Contacts app for task assignment.
*/
import { browser } from '$app/environment';
import { createContactsClient, type ContactsClient } from '@manacore/shared-auth';
import type { ContactSummary } from '@manacore/shared-types';
import { authStore } from './auth.svelte';
// State
let client: ContactsClient | null = null;
let isAvailable = $state<boolean | null>(null);
let isChecking = $state(false);
let lastCheck = $state<number>(0);
// Cache for recent search results
let searchCache = $state<Map<string, { results: ContactSummary[]; timestamp: number }>>(new Map());
const CACHE_TTL = 60000; // 1 minute
// Get contacts API URL dynamically
function getContactsApiUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_CONTACTS_API_URL__?: string })
.__PUBLIC_CONTACTS_API_URL__;
return injectedUrl || 'http://localhost:3015/api/v1';
}
return 'http://localhost:3015/api/v1';
}
// Initialize client lazily
function getClient(): ContactsClient {
if (!client) {
client = createContactsClient({
apiUrl: getContactsApiUrl(),
getAuthToken: async () => authStore.getAccessToken(),
timeout: 5000,
});
}
return client;
}
export const contactsStore = {
// Getters
get isAvailable() {
return isAvailable;
},
get isChecking() {
return isChecking;
},
/**
* Check if the Contacts API is available
* Caches result for 30 seconds
*/
async checkAvailability(): Promise<boolean> {
const now = Date.now();
// Skip if checked recently
if (lastCheck && now - lastCheck < 30000 && isAvailable !== null) {
return isAvailable;
}
isChecking = true;
try {
const available = await getClient().isAvailable();
isAvailable = available;
lastCheck = now;
return available;
} catch {
isAvailable = false;
lastCheck = now;
return false;
} finally {
isChecking = false;
}
},
/**
* Search contacts by query
*/
async searchContacts(query: string): Promise<ContactSummary[]> {
// Check cache first
const cacheKey = query.toLowerCase().trim();
const cached = searchCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.results;
}
// Check availability
if (isAvailable === null) {
await this.checkAvailability();
}
if (!isAvailable) {
return [];
}
try {
const results = await getClient().searchContacts({
query,
limit: 20,
excludeArchived: true,
});
// Cache results
searchCache.set(cacheKey, {
results,
timestamp: Date.now(),
});
return results;
} catch (error) {
console.error('[contactsStore] Search failed:', error);
return [];
}
},
/**
* Get a single contact by ID
*/
async getContact(id: string): Promise<ContactSummary | null> {
if (isAvailable === null) {
await this.checkAvailability();
}
if (!isAvailable) {
return null;
}
try {
return await getClient().getContact(id);
} catch (error) {
console.error(`[contactsStore] Failed to get contact ${id}:`, error);
return null;
}
},
/**
* Get multiple contacts by IDs
*/
async getContacts(ids: string[]): Promise<ContactSummary[]> {
if (ids.length === 0) return [];
if (isAvailable === null) {
await this.checkAvailability();
}
if (!isAvailable) {
return [];
}
try {
return await getClient().getContacts(ids);
} catch (error) {
console.error('[contactsStore] Failed to get contacts:', error);
return [];
}
},
/**
* Clear the search cache
*/
clearCache() {
searchCache.clear();
},
/**
* Reset availability check (force recheck on next call)
*/
resetAvailability() {
isAvailable = null;
lastCheck = 0;
},
};

View file

@ -14,6 +14,9 @@
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/shared-types": "workspace:*"
},
"devDependencies": {
"typescript": "^5.9.3"
}

View file

@ -1,4 +1,5 @@
import type { Label } from './label';
import type { ContactReference } from '@manacore/shared-types';
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
@ -26,6 +27,9 @@ export interface TaskMetadata {
storyPoints?: number | null; // Fibonacci: 1, 2, 3, 5, 8, 13, 21
effectiveDuration?: EffectiveDuration | null; // Actual time spent
funRating?: number | null; // 1-10 scale
// Contact associations
assignee?: ContactReference | null; // Person responsible for the task
involvedContacts?: ContactReference[]; // Other people involved
}
export interface Task {
@ -43,6 +47,12 @@ export interface Task {
dueTime?: string | null; // HH:mm format
startDate?: Date | string | null;
// Time-Blocking (for calendar integration)
scheduledDate?: Date | string | null; // Date when task is scheduled
scheduledStartTime?: string | null; // HH:mm format - when to start
scheduledEndTime?: string | null; // HH:mm format - when to end
estimatedDuration?: number | null; // Duration in minutes
// Priority & Status
priority: TaskPriority;
status: TaskStatus;
@ -84,6 +94,11 @@ export interface CreateTaskInput {
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
// Time-Blocking
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledEndTime?: string | null;
estimatedDuration?: number | null;
priority?: TaskPriority;
recurrenceRule?: string | null;
recurrenceEndDate?: string | null;
@ -100,6 +115,11 @@ export interface UpdateTaskInput {
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
// Time-Blocking
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledEndTime?: string | null;
estimatedDuration?: number | null;
priority?: TaskPriority;
status?: TaskStatus;
isCompleted?: boolean;