mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 01:01:25 +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
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(',');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
175
apps/todo/apps/web/src/lib/stores/contacts.svelte.ts
Normal file
175
apps/todo/apps/web/src/lib/stores/contacts.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -14,6 +14,9 @@
|
|||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue