mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 06:19:41 +02:00
Merge pull request #15 from Memo-2023/till-dev
feat: Calendar todo integration, CommandBar improvements, and statistics components
This commit is contained in:
commit
15a29aae5e
102 changed files with 11957 additions and 1835 deletions
|
|
@ -228,6 +228,7 @@ CLOCK_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/clock
|
|||
# ============================================
|
||||
|
||||
TODO_BACKEND_PORT=3018
|
||||
TODO_BACKEND_URL=http://localhost:3018
|
||||
TODO_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/todo
|
||||
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@
|
|||
"dependencies": {
|
||||
"@calendar/shared": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:*",
|
||||
|
|
@ -40,13 +39,16 @@
|
|||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-subscription-ui": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@neodrag/svelte": "^2.3.3",
|
||||
"d3-force": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-svelte": "^0.559.0",
|
||||
"svelte-dnd-action": "^0.9.68",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
|
|
|
|||
370
apps/calendar/apps/web/src/lib/api/todos.ts
Normal file
370
apps/calendar/apps/web/src/lib/api/todos.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
/**
|
||||
* Cross-App API Client for Todo Backend
|
||||
* Allows Calendar app to fetch/manage todos from the Todo service
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const TODO_API_BASE = env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018';
|
||||
|
||||
// ============================================
|
||||
// Types (mirrored from @todo/shared for cross-app use)
|
||||
// ============================================
|
||||
|
||||
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 TaskMetadata {
|
||||
notes?: string;
|
||||
attachments?: string[];
|
||||
linkedCalendarEventId?: string | null;
|
||||
storyPoints?: number | null;
|
||||
effectiveDuration?: {
|
||||
value: number;
|
||||
unit: 'minutes' | 'hours' | 'days';
|
||||
} | null;
|
||||
funRating?: number | null;
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface CreateTaskInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
projectId?: string | null;
|
||||
dueDate?: string | null;
|
||||
dueTime?: string | null;
|
||||
priority?: TaskPriority;
|
||||
labelIds?: string[];
|
||||
subtasks?: Omit<Subtask, 'id'>[];
|
||||
recurrenceRule?: string | null;
|
||||
metadata?: TaskMetadata;
|
||||
}
|
||||
|
||||
export interface UpdateTaskInput {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
projectId?: string | null;
|
||||
dueDate?: string | null;
|
||||
dueTime?: string | null;
|
||||
priority?: TaskPriority;
|
||||
status?: TaskStatus;
|
||||
isCompleted?: boolean;
|
||||
subtasks?: Subtask[] | null;
|
||||
recurrenceRule?: string | null;
|
||||
metadata?: TaskMetadata | null;
|
||||
labelIds?: string[];
|
||||
}
|
||||
|
||||
export interface TaskQuery {
|
||||
projectId?: string;
|
||||
labelId?: string;
|
||||
priority?: TaskPriority;
|
||||
status?: TaskStatus;
|
||||
isCompleted?: boolean;
|
||||
dueDateFrom?: string;
|
||||
dueDateTo?: string;
|
||||
search?: string;
|
||||
sortBy?: 'dueDate' | 'priority' | 'createdAt' | 'order';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API Response Types
|
||||
// ============================================
|
||||
|
||||
interface TasksResponse {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
interface TaskResponse {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
interface ProjectsResponse {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
interface LabelsResponse {
|
||||
labels: Label[];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API Client
|
||||
// ============================================
|
||||
|
||||
type FetchOptions = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
async function fetchTodoApi<T>(
|
||||
endpoint: string,
|
||||
options: FetchOptions = {}
|
||||
): Promise<{ data: T | null; error: Error | null }> {
|
||||
const { method = 'GET', body, token } = options;
|
||||
|
||||
let authToken = token;
|
||||
if (!authToken && browser) {
|
||||
authToken = localStorage.getItem('@auth/appToken') || undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${TODO_API_BASE}/api/v1${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
data: null,
|
||||
error: new Error(errorData.message || `Todo API error: ${response.status}`),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle empty responses (204 No Content)
|
||||
if (response.status === 204) {
|
||||
return { data: null, error: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error : new Error('Failed to connect to Todo service'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
function buildQueryString(query: TaskQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Task API Functions
|
||||
// ============================================
|
||||
|
||||
export async function getTasks(
|
||||
query: TaskQuery = {}
|
||||
): Promise<{ data: Task[] | null; error: Error | null }> {
|
||||
const queryString = buildQueryString(query);
|
||||
const result = await fetchTodoApi<TasksResponse>(`/tasks${queryString}`);
|
||||
return {
|
||||
data: result.data?.tasks || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTask(id: string): Promise<{ data: Task | null; error: Error | null }> {
|
||||
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}`);
|
||||
return {
|
||||
data: result.data?.task || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
data: CreateTaskInput
|
||||
): Promise<{ data: Task | null; error: Error | null }> {
|
||||
const result = await fetchTodoApi<TaskResponse>('/tasks', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
return {
|
||||
data: result.data?.task || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateTask(
|
||||
id: string,
|
||||
data: UpdateTaskInput
|
||||
): Promise<{ data: Task | null; error: Error | null }> {
|
||||
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
return {
|
||||
data: result.data?.task || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteTask(id: string): Promise<{ error: Error | null }> {
|
||||
const result = await fetchTodoApi(`/tasks/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return { error: result.error };
|
||||
}
|
||||
|
||||
export async function completeTask(
|
||||
id: string
|
||||
): Promise<{ data: Task | null; error: Error | null }> {
|
||||
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}/complete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return {
|
||||
data: result.data?.task || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uncompleteTask(
|
||||
id: string
|
||||
): Promise<{ data: Task | null; error: Error | null }> {
|
||||
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}/uncomplete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return {
|
||||
data: result.data?.task || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTodayTasks(): Promise<{ data: Task[] | null; error: Error | null }> {
|
||||
const result = await fetchTodoApi<TasksResponse>('/tasks/today');
|
||||
return {
|
||||
data: result.data?.tasks || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUpcomingTasks(): Promise<{ data: Task[] | null; error: Error | null }> {
|
||||
const result = await fetchTodoApi<TasksResponse>('/tasks/upcoming');
|
||||
return {
|
||||
data: result.data?.tasks || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Project API Functions
|
||||
// ============================================
|
||||
|
||||
export async function getProjects(): Promise<{ data: Project[] | null; error: Error | null }> {
|
||||
const result = await fetchTodoApi<ProjectsResponse>('/projects');
|
||||
return {
|
||||
data: result.data?.projects || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Label API Functions
|
||||
// ============================================
|
||||
|
||||
export async function getLabels(): Promise<{ data: Label[] | null; error: Error | null }> {
|
||||
const result = await fetchTodoApi<LabelsResponse>('/labels');
|
||||
return {
|
||||
data: result.data?.labels || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Priority Colors Helper
|
||||
// ============================================
|
||||
|
||||
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
|
||||
urgent: 'hsl(var(--color-danger))',
|
||||
high: 'hsl(var(--color-warning))',
|
||||
medium: 'hsl(var(--color-accent))',
|
||||
low: 'hsl(var(--color-success))',
|
||||
};
|
||||
|
||||
export const PRIORITY_LABELS: Record<TaskPriority, string> = {
|
||||
urgent: 'Dringend',
|
||||
high: 'Wichtig',
|
||||
medium: 'Normal',
|
||||
low: 'Später',
|
||||
};
|
||||
|
||||
export const PRIORITY_ORDER: Record<TaskPriority, number> = {
|
||||
urgent: 0,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3,
|
||||
};
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
<script lang="ts">
|
||||
import { Calendar, CheckSquare, Filter } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
showEvents: boolean;
|
||||
showTodos: boolean;
|
||||
timeRange: '7' | '30' | 'all';
|
||||
onToggleEvents?: () => void;
|
||||
onToggleTodos?: () => void;
|
||||
onRangeChange?: (range: '7' | '30' | 'all') => void;
|
||||
}
|
||||
|
||||
let {
|
||||
showEvents = true,
|
||||
showTodos = true,
|
||||
timeRange = '30',
|
||||
onToggleEvents,
|
||||
onToggleTodos,
|
||||
onRangeChange,
|
||||
}: Props = $props();
|
||||
|
||||
const rangeOptions = [
|
||||
{ value: '7' as const, label: '7 Tage' },
|
||||
{ value: '30' as const, label: '30 Tage' },
|
||||
{ value: 'all' as const, label: 'Alle' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="agenda-filters">
|
||||
<div class="filter-group type-toggles">
|
||||
<button
|
||||
type="button"
|
||||
class="filter-toggle"
|
||||
class:active={showEvents}
|
||||
onclick={onToggleEvents}
|
||||
aria-pressed={showEvents}
|
||||
>
|
||||
<Calendar size={14} />
|
||||
<span>Events</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-toggle"
|
||||
class:active={showTodos}
|
||||
onclick={onToggleTodos}
|
||||
aria-pressed={showTodos}
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
<span>Aufgaben</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<div class="range-selector">
|
||||
<Filter size={14} />
|
||||
<select
|
||||
value={timeRange}
|
||||
onchange={(e) =>
|
||||
onRangeChange?.((e.target as HTMLSelectElement).value as '7' | '30' | 'all')}
|
||||
>
|
||||
{#each rangeOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.agenda-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.type-toggles {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.filter-toggle:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.filter-toggle.active {
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.range-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.range-selector select {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.range-selector select:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.agenda-filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
<script lang="ts">
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import type { Task } from '$lib/api/todos';
|
||||
import { PRIORITY_COLORS, PRIORITY_LABELS } from '$lib/api/todos';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
|
||||
import PriorityBadge from '$lib/components/todo/PriorityBadge.svelte';
|
||||
import { Calendar, MapPin, Clock } from 'lucide-svelte';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
type ItemType = 'event' | 'todo';
|
||||
|
||||
interface Props {
|
||||
type: ItemType;
|
||||
event?: CalendarEvent;
|
||||
todo?: Task;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { type, event, todo, onclick }: Props = $props();
|
||||
|
||||
let isToggling = $state(false);
|
||||
|
||||
// Event helpers
|
||||
const eventColor = $derived(event ? calendarsStore.getColor(event.calendarId) : undefined);
|
||||
const eventTimeLabel = $derived.by(() => {
|
||||
if (!event) return '';
|
||||
if (event.isAllDay) return 'Ganztägig';
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
||||
return `${format(start, 'HH:mm')} - ${format(end, 'HH:mm')}`;
|
||||
});
|
||||
|
||||
// Todo helpers
|
||||
const todoTimeLabel = $derived.by(() => {
|
||||
if (!todo) return '';
|
||||
if (todo.dueTime) return `Fällig: ${todo.dueTime}`;
|
||||
return 'Heute fällig';
|
||||
});
|
||||
|
||||
async function handleToggleTodo() {
|
||||
if (!todo) return;
|
||||
isToggling = true;
|
||||
await todosStore.toggleComplete(todo.id);
|
||||
isToggling = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if type === 'event' && event}
|
||||
<button type="button" class="agenda-item event" style="--item-color: {eventColor};" {onclick}>
|
||||
<div class="item-indicator">
|
||||
<Calendar size={14} />
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-header">
|
||||
<span class="item-time">{eventTimeLabel}</span>
|
||||
</div>
|
||||
<span class="item-title">{event.title}</span>
|
||||
{#if event.location}
|
||||
<div class="item-meta">
|
||||
<MapPin size={12} />
|
||||
<span>{event.location}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{:else if type === 'todo' && todo}
|
||||
<div
|
||||
class="agenda-item todo"
|
||||
class:completed={todo.isCompleted}
|
||||
style="--item-color: {PRIORITY_COLORS[todo.priority]};"
|
||||
>
|
||||
<div class="item-checkbox">
|
||||
<TodoCheckbox
|
||||
checked={todo.isCompleted}
|
||||
loading={isToggling}
|
||||
size="md"
|
||||
onchange={handleToggleTodo}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" class="item-content" {onclick}>
|
||||
<div class="item-header">
|
||||
<PriorityBadge priority={todo.priority} variant="dot" size="sm" />
|
||||
<span class="item-time">{todoTimeLabel}</span>
|
||||
</div>
|
||||
<span class="item-title">{todo.title}</span>
|
||||
{#if todo.project}
|
||||
<div class="item-meta">
|
||||
<span class="project-tag" style="color: {todo.project.color};">
|
||||
{todo.project.name}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.agenda-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: hsl(var(--color-surface));
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.agenda-item.event {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
border-left: 4px solid var(--item-color);
|
||||
}
|
||||
|
||||
.agenda-item.event:hover {
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.agenda-item.todo {
|
||||
border-left: 3px solid var(--item-color);
|
||||
}
|
||||
|
||||
.agenda-item.todo.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.agenda-item.todo.completed .item-title {
|
||||
text-decoration: line-through;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.item-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--item-color);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-checkbox {
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.todo .item-content {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.todo .item-content:hover .item-title {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.item-time {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.item-meta :global(svg) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-tag {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
background: color-mix(in srgb, currentColor 15%, transparent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import TodoRow from './TodoRow.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
format,
|
||||
|
|
@ -405,6 +407,16 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Todos section -->
|
||||
{#if todosStore.serviceAvailable && todosStore.getTodosForDay(viewStore.currentDate).length > 0}
|
||||
<div class="todos-section">
|
||||
<div class="time-gutter"></div>
|
||||
<div class="todos-content">
|
||||
<TodoRow date={viewStore.currentDate} maxVisible={4} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Time grid -->
|
||||
<div class="time-grid scrollbar-thin">
|
||||
<div class="time-column">
|
||||
|
|
@ -533,6 +545,16 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Todos section */
|
||||
.todos-section {
|
||||
display: flex;
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.todos-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Block-style all-day events (displayed as full-day blocks in the grid) */
|
||||
.all-day-block-event {
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import TodoDayCell from './TodoDayCell.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
format,
|
||||
|
|
@ -265,6 +267,11 @@
|
|||
{format(day, 'd')}
|
||||
</span>
|
||||
|
||||
<!-- Todos for this day -->
|
||||
{#if todosStore.serviceAvailable}
|
||||
<TodoDayCell date={day} maxVisible={2} />
|
||||
{/if}
|
||||
|
||||
<div class="day-events">
|
||||
{#each getEventsForDay(day) as event}
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
<script lang="ts">
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import type { Task } from '$lib/api/todos';
|
||||
import { PRIORITY_COLORS } from '$lib/api/todos';
|
||||
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
|
||||
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
|
||||
|
||||
interface Props {
|
||||
date: Date;
|
||||
maxVisible?: number;
|
||||
}
|
||||
|
||||
let { date, maxVisible = 2 }: Props = $props();
|
||||
|
||||
let selectedTask = $state<Task | null>(null);
|
||||
let togglingIds = $state<Set<string>>(new Set());
|
||||
|
||||
const todosForDay = $derived(todosStore.getTodosForDay(date));
|
||||
const visibleTodos = $derived(todosForDay.slice(0, maxVisible));
|
||||
const overflowCount = $derived(Math.max(0, todosForDay.length - maxVisible));
|
||||
|
||||
async function handleToggle(task: Task, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
togglingIds = new Set([...togglingIds, task.id]);
|
||||
await todosStore.toggleComplete(task.id);
|
||||
togglingIds = new Set([...togglingIds].filter((id) => id !== task.id));
|
||||
}
|
||||
|
||||
function handleTaskClick(task: Task) {
|
||||
selectedTask = task;
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
selectedTask = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if todosForDay.length > 0}
|
||||
<div class="todo-day-cell">
|
||||
{#each visibleTodos as task (task.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="todo-cell-item"
|
||||
class:completed={task.isCompleted}
|
||||
style="--priority-color: {PRIORITY_COLORS[task.priority]};"
|
||||
onclick={() => handleTaskClick(task)}
|
||||
>
|
||||
<span class="priority-dot"></span>
|
||||
<span class="todo-cell-title">{task.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if overflowCount > 0}
|
||||
<span class="overflow-text">+{overflowCount} Aufgaben</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Detail Modal -->
|
||||
{#if selectedTask}
|
||||
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.todo-day-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.todo-cell-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.todo-cell-item:hover {
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
}
|
||||
|
||||
.todo-cell-item.completed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.todo-cell-item.completed .todo-cell-title {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.priority-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--priority-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.todo-cell-title {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.overflow-text {
|
||||
font-size: 0.5625rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
padding: 0 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
<script lang="ts">
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import type { Task } from '$lib/api/todos';
|
||||
import { PRIORITY_COLORS } from '$lib/api/todos';
|
||||
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
|
||||
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
|
||||
import { Check } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
date: Date;
|
||||
maxVisible?: number;
|
||||
}
|
||||
|
||||
let { date, maxVisible = 3 }: Props = $props();
|
||||
|
||||
let selectedTask = $state<Task | null>(null);
|
||||
let togglingIds = $state<Set<string>>(new Set());
|
||||
|
||||
const todosForDay = $derived(todosStore.getTodosForDay(date));
|
||||
const visibleTodos = $derived(todosForDay.slice(0, maxVisible));
|
||||
const overflowCount = $derived(Math.max(0, todosForDay.length - maxVisible));
|
||||
|
||||
async function handleToggle(task: Task) {
|
||||
togglingIds = new Set([...togglingIds, task.id]);
|
||||
await todosStore.toggleComplete(task.id);
|
||||
togglingIds = new Set([...togglingIds].filter((id) => id !== task.id));
|
||||
}
|
||||
|
||||
function handleTaskClick(task: Task, e: MouseEvent) {
|
||||
// Don't open modal if clicking checkbox
|
||||
if ((e.target as HTMLElement).closest('.todo-checkbox')) return;
|
||||
selectedTask = task;
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
selectedTask = null;
|
||||
}
|
||||
|
||||
function handleShowAll() {
|
||||
// Show first todo's modal, or navigate to tasks page
|
||||
if (todosForDay.length > 0) {
|
||||
selectedTask = todosForDay[0];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if todosForDay.length > 0}
|
||||
<div class="todo-row">
|
||||
<span class="todo-row-label">Aufgaben:</span>
|
||||
<div class="todo-pills">
|
||||
{#each visibleTodos as task (task.id)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<button
|
||||
type="button"
|
||||
class="todo-pill"
|
||||
class:completed={task.isCompleted}
|
||||
style="--priority-color: {PRIORITY_COLORS[task.priority]};"
|
||||
onclick={(e) => handleTaskClick(task, e)}
|
||||
>
|
||||
<TodoCheckbox
|
||||
checked={task.isCompleted}
|
||||
loading={togglingIds.has(task.id)}
|
||||
size="sm"
|
||||
onchange={() => handleToggle(task)}
|
||||
/>
|
||||
<span class="todo-pill-title">{task.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if overflowCount > 0}
|
||||
<button type="button" class="overflow-badge" onclick={handleShowAll}>
|
||||
+{overflowCount} mehr
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Detail Modal -->
|
||||
{#if selectedTask}
|
||||
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.todo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: hsl(var(--color-muted) / 0.2);
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.todo-row-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.todo-pills {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.todo-pills::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.todo-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: hsl(var(--color-surface));
|
||||
border-left: 2px solid var(--priority-color);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
flex-shrink: 0;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.todo-pill:hover {
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
}
|
||||
|
||||
.todo-pill.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.todo-pill.completed .todo-pill-title {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.todo-pill-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overflow-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.overflow-badge:hover {
|
||||
background: hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
<script lang="ts">
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import type { Task } from '$lib/api/todos';
|
||||
import TodoItem from '$lib/components/todo/TodoItem.svelte';
|
||||
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
|
||||
import QuickAddTodo from '$lib/components/todo/QuickAddTodo.svelte';
|
||||
import { ChevronDown, ChevronRight, Plus, CheckSquare, AlertTriangle } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
let { maxItems = 5 }: Props = $props();
|
||||
|
||||
let isExpanded = $state(true);
|
||||
let showQuickAdd = $state(false);
|
||||
let selectedTask = $state<Task | null>(null);
|
||||
|
||||
// Derived: combined overdue + today todos
|
||||
const displayTodos = $derived(todosStore.getSidebarTodos(maxItems));
|
||||
const overdueCount = $derived(todosStore.overdueTodos.length);
|
||||
const totalActiveCount = $derived(todosStore.activeTodosCount);
|
||||
|
||||
onMount(async () => {
|
||||
// Fetch todos on mount
|
||||
await todosStore.fetchTodayTodos();
|
||||
await todosStore.fetchUpcomingTodos();
|
||||
});
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
function handleAddClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
showQuickAdd = true;
|
||||
}
|
||||
|
||||
function handleTaskClick(task: Task) {
|
||||
selectedTask = task;
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
selectedTask = null;
|
||||
}
|
||||
|
||||
function handleQuickAddSubmit() {
|
||||
// Keep quick add open for successive adds
|
||||
}
|
||||
|
||||
function handleQuickAddCancel() {
|
||||
showQuickAdd = false;
|
||||
}
|
||||
|
||||
function goToAllTasks() {
|
||||
goto('/tasks');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="todo-sidebar-section">
|
||||
<!-- Header -->
|
||||
<button type="button" class="section-header" onclick={toggleExpanded}>
|
||||
<div class="header-left">
|
||||
{#if isExpanded}
|
||||
<ChevronDown size={16} />
|
||||
{:else}
|
||||
<ChevronRight size={16} />
|
||||
{/if}
|
||||
<CheckSquare size={16} class="section-icon" />
|
||||
<span class="section-title">Aufgaben</span>
|
||||
{#if totalActiveCount > 0}
|
||||
<span class="count-badge">{totalActiveCount}</span>
|
||||
{/if}
|
||||
{#if overdueCount > 0}
|
||||
<span class="overdue-badge" title="{overdueCount} überfällig">
|
||||
<AlertTriangle size={12} />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="add-button"
|
||||
onclick={handleAddClick}
|
||||
aria-label="Aufgabe hinzufügen"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</button>
|
||||
|
||||
<!-- Content -->
|
||||
{#if isExpanded}
|
||||
<div class="section-content">
|
||||
{#if !todosStore.serviceAvailable}
|
||||
<div class="service-unavailable">
|
||||
<AlertTriangle size={16} />
|
||||
<span>Todo-Service nicht erreichbar</span>
|
||||
</div>
|
||||
{:else if todosStore.loading}
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>Laden...</span>
|
||||
</div>
|
||||
{:else if displayTodos.length === 0}
|
||||
<div class="empty-state">
|
||||
<CheckSquare size={20} />
|
||||
<span>Keine offenen Aufgaben</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="todo-list">
|
||||
{#each displayTodos as task (task.id)}
|
||||
<TodoItem
|
||||
{task}
|
||||
variant="compact"
|
||||
showProject={false}
|
||||
onclick={() => handleTaskClick(task)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if totalActiveCount > maxItems}
|
||||
<button type="button" class="show-all-button" onclick={goToAllTasks}>
|
||||
Alle {totalActiveCount} anzeigen
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Quick Add -->
|
||||
{#if showQuickAdd}
|
||||
<div class="quick-add-wrapper">
|
||||
<QuickAddTodo
|
||||
placeholder="Neue Aufgabe..."
|
||||
autofocus
|
||||
showButton={false}
|
||||
onsubmit={handleQuickAddSubmit}
|
||||
oncancel={handleQuickAddCancel}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Detail Modal -->
|
||||
{#if selectedTask}
|
||||
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.todo-sidebar-section {
|
||||
background: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.header-left :global(svg) {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.header-left :global(.section-icon) {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
padding: 1px 6px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.overdue-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--color-danger));
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.todo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.service-unavailable,
|
||||
.loading,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.service-unavailable {
|
||||
color: hsl(var(--color-danger));
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid hsl(var(--color-muted));
|
||||
border-top-color: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 600ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.show-all-button {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-primary));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.show-all-button:hover {
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
|
||||
.quick-add-wrapper {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import TodoRow from './TodoRow.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
format,
|
||||
|
|
@ -499,6 +501,18 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Todos row (shown per day, below all-day events) -->
|
||||
{#if todosStore.serviceAvailable}
|
||||
<div class="todos-row">
|
||||
<div class="time-gutter"></div>
|
||||
{#each days as day}
|
||||
<div class="todos-cell">
|
||||
<TodoRow date={day} maxVisible={2} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Day headers -->
|
||||
<div class="day-headers">
|
||||
<div class="time-gutter"></div>
|
||||
|
|
@ -651,6 +665,18 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Todos row */
|
||||
.todos-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.todos-cell {
|
||||
flex: 1;
|
||||
border-left: 1px solid hsl(var(--color-border));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Block-style all-day events (displayed as full-day blocks in the grid) */
|
||||
.all-day-block-event {
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
<script lang="ts">
|
||||
import type { TaskPriority } from '$lib/api/todos';
|
||||
import { PRIORITY_COLORS, PRIORITY_LABELS } from '$lib/api/todos';
|
||||
|
||||
interface Props {
|
||||
priority: TaskPriority;
|
||||
variant?: 'dot' | 'badge' | 'pill';
|
||||
size?: 'sm' | 'md';
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
let { priority, variant = 'dot', size = 'md', showLabel = false }: Props = $props();
|
||||
|
||||
const color = $derived(PRIORITY_COLORS[priority]);
|
||||
const label = $derived(PRIORITY_LABELS[priority]);
|
||||
</script>
|
||||
|
||||
{#if variant === 'dot'}
|
||||
<span
|
||||
class="priority-dot"
|
||||
class:size-sm={size === 'sm'}
|
||||
style="--priority-color: {color};"
|
||||
title={label}
|
||||
aria-label="Priorität: {label}"
|
||||
></span>
|
||||
{:else if variant === 'badge'}
|
||||
<span
|
||||
class="priority-badge"
|
||||
class:size-sm={size === 'sm'}
|
||||
style="--priority-color: {color};"
|
||||
title={label}
|
||||
>
|
||||
{#if showLabel}
|
||||
{label}
|
||||
{:else}
|
||||
{priority.charAt(0).toUpperCase()}
|
||||
{/if}
|
||||
</span>
|
||||
{:else if variant === 'pill'}
|
||||
<span class="priority-pill" class:size-sm={size === 'sm'} style="--priority-color: {color};">
|
||||
<span class="pill-dot"></span>
|
||||
{#if showLabel}
|
||||
<span class="pill-label">{label}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Dot variant */
|
||||
.priority-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--priority-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.priority-dot.size-sm {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
/* Badge variant */
|
||||
.priority-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--priority-color);
|
||||
color: white;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.priority-badge.size-sm {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
/* Pill variant */
|
||||
.priority-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--priority-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.priority-pill.size-sm {
|
||||
gap: 4px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.pill-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--priority-color);
|
||||
}
|
||||
|
||||
.priority-pill.size-sm .pill-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.pill-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--priority-color);
|
||||
}
|
||||
|
||||
.priority-pill.size-sm .pill-label {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
<script lang="ts">
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import { Plus, X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
onsubmit?: () => void;
|
||||
oncancel?: () => void;
|
||||
autofocus?: boolean;
|
||||
showButton?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
placeholder = 'Neue Aufgabe...',
|
||||
onsubmit,
|
||||
oncancel,
|
||||
autofocus = false,
|
||||
showButton = true,
|
||||
}: Props = $props();
|
||||
|
||||
let title = $state('');
|
||||
let isExpanded = $state(!showButton);
|
||||
let isSubmitting = $state(false);
|
||||
let inputRef: HTMLInputElement | undefined = $state();
|
||||
|
||||
function expand() {
|
||||
isExpanded = true;
|
||||
// Focus input after DOM update
|
||||
setTimeout(() => inputRef?.focus(), 0);
|
||||
}
|
||||
|
||||
function collapse() {
|
||||
isExpanded = false;
|
||||
title = '';
|
||||
oncancel?.();
|
||||
}
|
||||
|
||||
async function handleSubmit(e?: Event) {
|
||||
e?.preventDefault();
|
||||
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle || isSubmitting) return;
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
const result = await todosStore.createTodo({
|
||||
title: trimmedTitle,
|
||||
priority: 'medium',
|
||||
});
|
||||
|
||||
isSubmitting = false;
|
||||
|
||||
if (!result.error) {
|
||||
title = '';
|
||||
onsubmit?.();
|
||||
|
||||
// Keep input focused for quick successive adds
|
||||
inputRef?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
collapse();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Only collapse if empty and showButton is true
|
||||
if (showButton && !title.trim()) {
|
||||
collapse();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showButton && !isExpanded}
|
||||
<button type="button" class="add-button" onclick={expand}>
|
||||
<Plus size={16} />
|
||||
<span>Aufgabe hinzufügen</span>
|
||||
</button>
|
||||
{:else}
|
||||
<form class="quick-add-form" onsubmit={handleSubmit}>
|
||||
<input
|
||||
bind:this={inputRef}
|
||||
bind:value={title}
|
||||
type="text"
|
||||
class="quick-add-input"
|
||||
{placeholder}
|
||||
disabled={isSubmitting}
|
||||
onkeydown={handleKeydown}
|
||||
onblur={handleBlur}
|
||||
autofocus={autofocus || isExpanded}
|
||||
/>
|
||||
|
||||
{#if showButton}
|
||||
<button type="button" class="cancel-button" onclick={collapse} disabled={isSubmitting}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="submit-button"
|
||||
disabled={!title.trim() || isSubmitting}
|
||||
aria-label="Aufgabe erstellen"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<span class="spinner"></span>
|
||||
{:else}
|
||||
<Plus size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
|
||||
.quick-add-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.quick-add-form:focus-within {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.quick-add-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.quick-add-input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.quick-add-input:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cancel-button,
|
||||
.submit-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.cancel-button:hover:not(:disabled) {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: hsl(var(--color-primary) / 0.9);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid hsl(var(--color-primary-foreground) / 0.3);
|
||||
border-top-color: hsl(var(--color-primary-foreground));
|
||||
border-radius: 50%;
|
||||
animation: spin 600ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<script lang="ts">
|
||||
import { Check } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
loading?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
onchange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
let { checked, loading = false, size = 'md', onchange }: Props = $props();
|
||||
|
||||
const sizes = {
|
||||
sm: { box: 14, icon: 10 },
|
||||
md: { box: 18, icon: 12 },
|
||||
lg: { box: 22, icon: 16 },
|
||||
};
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (!loading && onchange) {
|
||||
onchange(!checked);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!loading && onchange) {
|
||||
onchange(!checked);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="todo-checkbox"
|
||||
class:checked
|
||||
class:loading
|
||||
class:size-sm={size === 'sm'}
|
||||
class:size-md={size === 'md'}
|
||||
class:size-lg={size === 'lg'}
|
||||
style="--box-size: {sizes[size].box}px; --icon-size: {sizes[size].icon}px;"
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydown}
|
||||
disabled={loading}
|
||||
aria-checked={checked}
|
||||
aria-label={checked ? 'Als unerledigt markieren' : 'Als erledigt markieren'}
|
||||
role="checkbox"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="spinner"></span>
|
||||
{:else if checked}
|
||||
<Check size={sizes[size].icon} strokeWidth={3} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.todo-checkbox {
|
||||
width: var(--box-size);
|
||||
height: var(--box-size);
|
||||
min-width: var(--box-size);
|
||||
border-radius: 4px;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 150ms ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.todo-checkbox:hover:not(:disabled) {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
|
||||
.todo-checkbox:focus-visible {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.todo-checkbox.checked {
|
||||
background: hsl(var(--color-primary));
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
.todo-checkbox.checked:hover:not(:disabled) {
|
||||
background: hsl(var(--color-primary) / 0.8);
|
||||
border-color: hsl(var(--color-primary) / 0.8);
|
||||
}
|
||||
|
||||
.todo-checkbox:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.todo-checkbox.loading {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: calc(var(--icon-size) - 2px);
|
||||
height: calc(var(--icon-size) - 2px);
|
||||
border: 2px solid hsl(var(--color-muted-foreground) / 0.3);
|
||||
border-top-color: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 600ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Size variants */
|
||||
.size-sm {
|
||||
border-radius: 3px;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
|
||||
.size-lg {
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,625 @@
|
|||
<script lang="ts">
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import type { Task, UpdateTaskInput, TaskPriority } from '$lib/api/todos';
|
||||
import { PRIORITY_LABELS, PRIORITY_COLORS } from '$lib/api/todos';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import TodoCheckbox from './TodoCheckbox.svelte';
|
||||
import PriorityBadge from './PriorityBadge.svelte';
|
||||
import { X, Calendar, Clock, Folder, Tag, Trash2, CheckSquare, AlertCircle } from 'lucide-svelte';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { task: initialTask, onClose }: Props = $props();
|
||||
|
||||
// Local editable state
|
||||
let task = $state<Task>({ ...initialTask });
|
||||
let isEditing = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
let isToggling = $state(false);
|
||||
|
||||
// Form state
|
||||
let title = $state(task.title);
|
||||
let description = $state(task.description || '');
|
||||
let dueDate = $state(task.dueDate ? formatDateForInput(task.dueDate) : '');
|
||||
let dueTime = $state(task.dueTime || '');
|
||||
let priority = $state<TaskPriority>(task.priority);
|
||||
|
||||
function formatDateForInput(date: string | Date | null | undefined): string {
|
||||
if (!date) return '';
|
||||
const d = typeof date === 'string' ? parseISO(date) : date;
|
||||
return format(d, 'yyyy-MM-dd');
|
||||
}
|
||||
|
||||
function formatDisplayDate(date: string | Date | null | undefined): string {
|
||||
if (!date) return 'Kein Datum';
|
||||
const d = typeof date === 'string' ? parseISO(date) : date;
|
||||
return format(d, 'EEEE, d. MMMM yyyy', { locale: de });
|
||||
}
|
||||
|
||||
async function handleToggleComplete() {
|
||||
isToggling = true;
|
||||
const result = await todosStore.toggleComplete(task.id);
|
||||
if (result.data) {
|
||||
task = result.data;
|
||||
} else if (result.error) {
|
||||
toast.error(`Fehler: ${result.error.message}`);
|
||||
}
|
||||
isToggling = false;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.trim()) {
|
||||
toast.error('Titel darf nicht leer sein');
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
const updateData: UpdateTaskInput = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
dueDate: dueDate || null,
|
||||
dueTime: dueTime || null,
|
||||
priority,
|
||||
};
|
||||
|
||||
const result = await todosStore.updateTodo(task.id, updateData);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler beim Speichern: ${result.error.message}`);
|
||||
} else if (result.data) {
|
||||
task = result.data;
|
||||
toast.success('Aufgabe aktualisiert');
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Möchten Sie diese Aufgabe wirklich löschen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting = true;
|
||||
const result = await todosStore.deleteTodo(task.id);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler beim Löschen: ${result.error.message}`);
|
||||
isDeleting = false;
|
||||
} else {
|
||||
toast.success('Aufgabe gelöscht');
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
// Reset form state to current task values
|
||||
title = task.title;
|
||||
description = task.description || '';
|
||||
dueDate = task.dueDate ? formatDateForInput(task.dueDate) : '';
|
||||
dueTime = task.dueTime || '';
|
||||
priority = task.priority;
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (isEditing) {
|
||||
cancelEditing();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
||||
<div class="modal" role="dialog" aria-labelledby="modal-title" aria-modal="true">
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<TodoCheckbox
|
||||
checked={task.isCompleted}
|
||||
loading={isToggling}
|
||||
size="lg"
|
||||
onchange={handleToggleComplete}
|
||||
/>
|
||||
{#if !isEditing}
|
||||
<h2 id="modal-title" class="modal-title" class:completed={task.isCompleted}>
|
||||
{task.title}
|
||||
</h2>
|
||||
{/if}
|
||||
</div>
|
||||
<button type="button" class="close-button" onclick={onClose} aria-label="Schließen">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="modal-content">
|
||||
{#if isEditing}
|
||||
<!-- Edit Mode -->
|
||||
<form
|
||||
class="edit-form"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}
|
||||
>
|
||||
<div class="form-group">
|
||||
<label for="title">Titel</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
bind:value={title}
|
||||
placeholder="Aufgabentitel"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Beschreibung</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder="Beschreibung hinzufügen..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="dueDate">Fälligkeitsdatum</label>
|
||||
<input id="dueDate" type="date" bind:value={dueDate} />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dueTime">Uhrzeit</label>
|
||||
<input id="dueTime" type="time" bind:value={dueTime} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Priorität</label>
|
||||
<div class="priority-options">
|
||||
{#each Object.entries(PRIORITY_LABELS) as [key, label]}
|
||||
<button
|
||||
type="button"
|
||||
class="priority-option"
|
||||
class:selected={priority === key}
|
||||
style="--priority-color: {PRIORITY_COLORS[key as TaskPriority]};"
|
||||
onclick={() => (priority = key as TaskPriority)}
|
||||
>
|
||||
<span class="priority-dot"></span>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- View Mode -->
|
||||
<div class="detail-section">
|
||||
{#if task.description}
|
||||
<p class="description">{task.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="detail-list">
|
||||
<div class="detail-item">
|
||||
<Calendar size={16} />
|
||||
<span>{formatDisplayDate(task.dueDate)}</span>
|
||||
</div>
|
||||
|
||||
{#if task.dueTime}
|
||||
<div class="detail-item">
|
||||
<Clock size={16} />
|
||||
<span>{task.dueTime} Uhr</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="detail-item">
|
||||
<AlertCircle size={16} />
|
||||
<PriorityBadge {priority} variant="pill" showLabel />
|
||||
</div>
|
||||
|
||||
{#if task.project}
|
||||
<div class="detail-item">
|
||||
<Folder size={16} />
|
||||
<span class="project-name" style="color: {task.project.color};">
|
||||
{task.project.name}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if task.labels && task.labels.length > 0}
|
||||
<div class="detail-item labels-row">
|
||||
<Tag size={16} />
|
||||
<div class="labels">
|
||||
{#each task.labels as label}
|
||||
<span class="label-tag" style="--label-color: {label.color};">
|
||||
{label.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if task.subtasks && task.subtasks.length > 0}
|
||||
<div class="subtasks-section">
|
||||
<h3>
|
||||
<CheckSquare size={16} />
|
||||
Unteraufgaben ({task.subtasks.filter((s) => s.isCompleted).length}/{task.subtasks
|
||||
.length})
|
||||
</h3>
|
||||
<ul class="subtask-list">
|
||||
{#each task.subtasks as subtask}
|
||||
<li class:completed={subtask.isCompleted}>
|
||||
<span class="subtask-check">{subtask.isCompleted ? '☑' : '☐'}</span>
|
||||
{subtask.title}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-footer">
|
||||
{#if isEditing}
|
||||
<button type="button" class="btn btn-secondary" onclick={cancelEditing} disabled={isSaving}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick={handleSave} disabled={isSaving}>
|
||||
{#if isSaving}
|
||||
Speichern...
|
||||
{:else}
|
||||
Speichern
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button type="button" class="btn btn-danger" onclick={handleDelete} disabled={isDeleting}>
|
||||
<Trash2 size={16} />
|
||||
{#if isDeleting}
|
||||
Löschen...
|
||||
{:else}
|
||||
Löschen
|
||||
{/if}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick={startEditing}> Bearbeiten </button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
background: hsl(var(--color-background));
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modal-title.completed {
|
||||
text-decoration: line-through;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
/* View Mode */
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.detail-item :global(svg) {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.labels-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.label-tag {
|
||||
font-size: 0.75rem;
|
||||
color: var(--label-color);
|
||||
background: color-mix(in srgb, var(--label-color) 15%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.subtasks-section {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.subtasks-section h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.subtask-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.subtask-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.subtask-list li.completed {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.subtask-check {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Edit Mode */
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='date'],
|
||||
input[type='time'],
|
||||
textarea {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.priority-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.priority-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.priority-option:hover {
|
||||
border-color: var(--priority-color);
|
||||
}
|
||||
|
||||
.priority-option.selected {
|
||||
border-color: var(--priority-color);
|
||||
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
|
||||
}
|
||||
|
||||
.priority-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--priority-color);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: hsl(var(--color-primary) / 0.9);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: hsl(var(--color-muted) / 0.8);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: hsl(var(--color-danger) / 0.1);
|
||||
color: hsl(var(--color-danger));
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: hsl(var(--color-danger));
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
287
apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte
Normal file
287
apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<script lang="ts">
|
||||
import type { Task } from '$lib/api/todos';
|
||||
import { PRIORITY_COLORS } from '$lib/api/todos';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import TodoCheckbox from './TodoCheckbox.svelte';
|
||||
import PriorityBadge from './PriorityBadge.svelte';
|
||||
import { format, parseISO, isToday, isTomorrow, isPast, startOfDay } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
variant?: 'default' | 'compact' | 'minimal';
|
||||
showProject?: boolean;
|
||||
showDueDate?: boolean;
|
||||
showPriority?: boolean;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
task,
|
||||
variant = 'default',
|
||||
showProject = true,
|
||||
showDueDate = true,
|
||||
showPriority = true,
|
||||
onclick,
|
||||
}: Props = $props();
|
||||
|
||||
let isToggling = $state(false);
|
||||
|
||||
const priorityColor = $derived(PRIORITY_COLORS[task.priority]);
|
||||
|
||||
const dueDateLabel = $derived.by(() => {
|
||||
if (!task.dueDate) return null;
|
||||
|
||||
const date = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
|
||||
|
||||
if (isToday(date)) {
|
||||
return task.dueTime ? `Heute, ${task.dueTime}` : 'Heute';
|
||||
}
|
||||
if (isTomorrow(date)) {
|
||||
return task.dueTime ? `Morgen, ${task.dueTime}` : 'Morgen';
|
||||
}
|
||||
if (isPast(startOfDay(date)) && !task.isCompleted) {
|
||||
return format(date, 'd. MMM', { locale: de });
|
||||
}
|
||||
return format(date, 'd. MMM', { locale: de });
|
||||
});
|
||||
|
||||
const isOverdue = $derived.by(() => {
|
||||
if (!task.dueDate || task.isCompleted) return false;
|
||||
const date = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
|
||||
return isPast(startOfDay(date)) && !isToday(date);
|
||||
});
|
||||
|
||||
const subtaskProgress = $derived.by(() => {
|
||||
if (!task.subtasks || task.subtasks.length === 0) return null;
|
||||
const completed = task.subtasks.filter((s) => s.isCompleted).length;
|
||||
return { completed, total: task.subtasks.length };
|
||||
});
|
||||
|
||||
async function handleToggle(checked: boolean) {
|
||||
isToggling = true;
|
||||
await todosStore.toggleComplete(task.id);
|
||||
isToggling = false;
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
// Don't trigger onclick when clicking checkbox
|
||||
if ((e.target as HTMLElement).closest('.todo-checkbox')) return;
|
||||
onclick?.();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && onclick) {
|
||||
onclick();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="todo-item"
|
||||
class:completed={task.isCompleted}
|
||||
class:overdue={isOverdue}
|
||||
class:compact={variant === 'compact'}
|
||||
class:minimal={variant === 'minimal'}
|
||||
class:clickable={!!onclick}
|
||||
style="--priority-color: {priorityColor};"
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydown}
|
||||
role={onclick ? 'button' : 'listitem'}
|
||||
tabindex={onclick ? 0 : -1}
|
||||
>
|
||||
<TodoCheckbox
|
||||
checked={task.isCompleted}
|
||||
loading={isToggling}
|
||||
size={variant === 'minimal' ? 'sm' : 'md'}
|
||||
onchange={handleToggle}
|
||||
/>
|
||||
|
||||
<div class="todo-content">
|
||||
<div class="todo-main">
|
||||
{#if showPriority && variant !== 'minimal'}
|
||||
<PriorityBadge
|
||||
priority={task.priority}
|
||||
variant="dot"
|
||||
size={variant === 'compact' ? 'sm' : 'md'}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<span class="todo-title">{task.title}</span>
|
||||
|
||||
{#if subtaskProgress && variant === 'default'}
|
||||
<span class="subtask-count">
|
||||
{subtaskProgress.completed}/{subtaskProgress.total}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if variant !== 'minimal'}
|
||||
<div class="todo-meta">
|
||||
{#if showDueDate && dueDateLabel}
|
||||
<span class="due-date" class:overdue={isOverdue}>
|
||||
{dueDateLabel}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if showProject && task.project}
|
||||
<span class="project" style="--project-color: {task.project.color};">
|
||||
{task.project.name}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if task.labels && task.labels.length > 0 && variant === 'default'}
|
||||
<div class="labels">
|
||||
{#each task.labels.slice(0, 2) as label}
|
||||
<span class="label" style="--label-color: {label.color};">
|
||||
{label.name}
|
||||
</span>
|
||||
{/each}
|
||||
{#if task.labels.length > 2}
|
||||
<span class="label-more">+{task.labels.length - 2}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.todo-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: hsl(var(--color-surface));
|
||||
border-left: 3px solid var(--priority-color);
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.todo-item.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.todo-item.clickable:hover {
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.todo-item.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.todo-item.completed .todo-title {
|
||||
text-decoration: line-through;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.todo-item.overdue {
|
||||
background: hsl(var(--color-danger) / 0.05);
|
||||
}
|
||||
|
||||
/* Compact variant */
|
||||
.todo-item.compact {
|
||||
padding: 0.5rem 0.625rem;
|
||||
gap: 0.5rem;
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
/* Minimal variant */
|
||||
.todo-item.minimal {
|
||||
padding: 0.375rem 0.5rem;
|
||||
gap: 0.375rem;
|
||||
border-left-width: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.todo-item.minimal:hover {
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
}
|
||||
|
||||
.todo-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.todo-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.todo-title {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compact .todo-title {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.minimal .todo-title {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.subtask-count {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
padding: 1px 6px;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.todo-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.due-date {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.due-date.overdue {
|
||||
color: hsl(var(--color-danger));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--project-color);
|
||||
background: color-mix(in srgb, var(--project-color) 15%, transparent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.labels {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.625rem;
|
||||
color: var(--label-color);
|
||||
background: color-mix(in srgb, var(--label-color) 15%, transparent);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.label-more {
|
||||
font-size: 0.625rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
270
apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts
Normal file
270
apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* Calendar Statistics Store - Calculates calendar statistics using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { CalendarEvent, Calendar } from '@calendar/shared';
|
||||
import {
|
||||
startOfDay,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
subDays,
|
||||
format,
|
||||
differenceInMinutes,
|
||||
isToday,
|
||||
isSameWeek,
|
||||
parseISO,
|
||||
eachDayOfInterval,
|
||||
addDays,
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import type {
|
||||
HeatmapDataPoint,
|
||||
TrendDataPoint,
|
||||
DonutSegment,
|
||||
ProgressItem,
|
||||
} from '@manacore/shared-ui';
|
||||
|
||||
// Types
|
||||
export interface EventStatusBreakdown {
|
||||
status: 'confirmed' | 'tentative' | 'cancelled';
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
confirmed: '#10B981', // green
|
||||
tentative: '#F59E0B', // orange
|
||||
cancelled: '#EF4444', // red
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
confirmed: 'Bestätigt',
|
||||
tentative: 'Vorläufig',
|
||||
cancelled: 'Abgesagt',
|
||||
};
|
||||
|
||||
// State
|
||||
let events = $state<CalendarEvent[]>([]);
|
||||
let calendars = $state<Calendar[]>([]);
|
||||
|
||||
export const calendarStatisticsStore = {
|
||||
// Setters
|
||||
setEvents(newEvents: CalendarEvent[]) {
|
||||
events = newEvents;
|
||||
},
|
||||
|
||||
setCalendars(newCalendars: Calendar[]) {
|
||||
calendars = newCalendars;
|
||||
},
|
||||
|
||||
// Quick Stats
|
||||
get totalEvents() {
|
||||
return events.length;
|
||||
},
|
||||
|
||||
get eventsToday() {
|
||||
return events.filter((e) => {
|
||||
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
|
||||
return isToday(startTime);
|
||||
}).length;
|
||||
},
|
||||
|
||||
get eventsThisWeek() {
|
||||
const now = new Date();
|
||||
return events.filter((e) => {
|
||||
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
|
||||
return isSameWeek(startTime, now, { weekStartsOn: 1 });
|
||||
}).length;
|
||||
},
|
||||
|
||||
get upcomingEvents() {
|
||||
const now = new Date();
|
||||
const nextWeek = addDays(now, 7);
|
||||
return events.filter((e) => {
|
||||
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
|
||||
return startTime > now && startTime <= nextWeek;
|
||||
}).length;
|
||||
},
|
||||
|
||||
get busyHoursThisWeek() {
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
|
||||
const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 });
|
||||
|
||||
let totalMinutes = 0;
|
||||
|
||||
events.forEach((e) => {
|
||||
if (e.isAllDay) return; // Skip all-day events
|
||||
|
||||
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
|
||||
const endTime = typeof e.endTime === 'string' ? parseISO(e.endTime) : e.endTime;
|
||||
|
||||
if (startTime >= weekStart && startTime <= weekEnd) {
|
||||
totalMinutes += differenceInMinutes(endTime, startTime);
|
||||
}
|
||||
});
|
||||
|
||||
return Math.round((totalMinutes / 60) * 10) / 10; // Round to 1 decimal
|
||||
},
|
||||
|
||||
get totalCalendars() {
|
||||
return calendars.length;
|
||||
},
|
||||
|
||||
get averageEventDuration() {
|
||||
const timedEvents = events.filter((e) => !e.isAllDay);
|
||||
if (timedEvents.length === 0) return 0;
|
||||
|
||||
const totalMinutes = timedEvents.reduce((sum, e) => {
|
||||
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
|
||||
const endTime = typeof e.endTime === 'string' ? parseISO(e.endTime) : e.endTime;
|
||||
return sum + differenceInMinutes(endTime, startTime);
|
||||
}, 0);
|
||||
|
||||
return Math.round(totalMinutes / timedEvents.length);
|
||||
},
|
||||
|
||||
// Activity Heatmap (last 6 months) - based on event creation
|
||||
get activityHeatmap(): HeatmapDataPoint[] {
|
||||
const endDate = new Date();
|
||||
const startDate = subDays(endDate, 180);
|
||||
|
||||
// Count events per day based on start time
|
||||
const eventMap = new Map<string, number>();
|
||||
|
||||
events.forEach((e) => {
|
||||
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
|
||||
const dateKey = format(startTime, 'yyyy-MM-dd');
|
||||
eventMap.set(dateKey, (eventMap.get(dateKey) || 0) + 1);
|
||||
});
|
||||
|
||||
// Generate all days
|
||||
const days = eachDayOfInterval({ start: startDate, end: endDate });
|
||||
|
||||
return days.map((day) => {
|
||||
const dateKey = format(day, 'yyyy-MM-dd');
|
||||
return {
|
||||
date: dateKey,
|
||||
count: eventMap.get(dateKey) || 0,
|
||||
dayOfWeek: day.getDay(),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Weekly Trend (last 4 weeks)
|
||||
get weeklyTrend(): TrendDataPoint[] {
|
||||
const endDate = new Date();
|
||||
const startDate = subDays(endDate, 27);
|
||||
|
||||
const eventMap = new Map<string, number>();
|
||||
|
||||
events.forEach((e) => {
|
||||
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
|
||||
if (startTime >= startDate && startTime <= endDate) {
|
||||
const dateKey = format(startTime, 'yyyy-MM-dd');
|
||||
eventMap.set(dateKey, (eventMap.get(dateKey) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const days = eachDayOfInterval({ start: startDate, end: endDate });
|
||||
|
||||
return days.map((day) => {
|
||||
const dateKey = format(day, 'yyyy-MM-dd');
|
||||
return {
|
||||
date: dateKey,
|
||||
count: eventMap.get(dateKey) || 0,
|
||||
label: format(day, 'EEE', { locale: de }),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Status Breakdown (Donut Chart)
|
||||
get statusBreakdown(): DonutSegment[] {
|
||||
const total = events.length;
|
||||
if (total === 0) return [];
|
||||
|
||||
const counts: Record<string, number> = {
|
||||
confirmed: 0,
|
||||
tentative: 0,
|
||||
cancelled: 0,
|
||||
};
|
||||
|
||||
events.forEach((e) => {
|
||||
const status = e.status || 'confirmed';
|
||||
if (counts[status] !== undefined) {
|
||||
counts[status]++;
|
||||
}
|
||||
});
|
||||
|
||||
return (['confirmed', 'tentative', 'cancelled'] as const).map((status) => ({
|
||||
id: status,
|
||||
label: STATUS_LABELS[status],
|
||||
count: counts[status],
|
||||
percentage: total > 0 ? Math.round((counts[status] / total) * 100) : 0,
|
||||
color: STATUS_COLORS[status],
|
||||
}));
|
||||
},
|
||||
|
||||
// Calendar Activity (Progress Bars)
|
||||
get calendarActivity(): ProgressItem[] {
|
||||
const calendarMap = new Map<string, { total: number; thisWeek: number }>();
|
||||
|
||||
// Initialize with all calendars
|
||||
calendars.forEach((c) => {
|
||||
calendarMap.set(c.id, { total: 0, thisWeek: 0 });
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Count events per calendar
|
||||
events.forEach((e) => {
|
||||
const calendarId = e.calendarId;
|
||||
const data = calendarMap.get(calendarId) || { total: 0, thisWeek: 0 };
|
||||
data.total++;
|
||||
|
||||
const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime;
|
||||
if (isSameWeek(startTime, now, { weekStartsOn: 1 })) {
|
||||
data.thisWeek++;
|
||||
}
|
||||
|
||||
calendarMap.set(calendarId, data);
|
||||
});
|
||||
|
||||
// Convert to array
|
||||
const result: ProgressItem[] = [];
|
||||
|
||||
calendarMap.forEach((data, calendarId) => {
|
||||
if (data.total === 0) return;
|
||||
|
||||
const calendar = calendars.find((c) => c.id === calendarId);
|
||||
|
||||
result.push({
|
||||
id: calendarId,
|
||||
name: calendar?.name || 'Unbekannt',
|
||||
color: calendar?.color || '#6B7280',
|
||||
total: data.total,
|
||||
completed: data.thisWeek,
|
||||
percentage: data.total > 0 ? Math.round((data.thisWeek / data.total) * 100) : 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by total events descending
|
||||
return result.sort((a, b) => b.total - a.total);
|
||||
},
|
||||
|
||||
// All-day vs Timed events ratio
|
||||
get allDayRatio() {
|
||||
const allDay = events.filter((e) => e.isAllDay).length;
|
||||
const timed = events.filter((e) => !e.isAllDay).length;
|
||||
return {
|
||||
allDay,
|
||||
timed,
|
||||
allDayPercentage: events.length > 0 ? Math.round((allDay / events.length) * 100) : 0,
|
||||
};
|
||||
},
|
||||
|
||||
// Recurring events count
|
||||
get recurringEventsCount() {
|
||||
return events.filter((e) => e.recurrenceRule).length;
|
||||
},
|
||||
};
|
||||
405
apps/calendar/apps/web/src/lib/stores/todos.svelte.ts
Normal file
405
apps/calendar/apps/web/src/lib/stores/todos.svelte.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
/**
|
||||
* Todos Store - Manages todos from Todo-App using Svelte 5 runes
|
||||
* Cross-app integration with Todo Backend
|
||||
*/
|
||||
|
||||
import * as api from '$lib/api/todos';
|
||||
import type {
|
||||
Task,
|
||||
TaskPriority,
|
||||
CreateTaskInput,
|
||||
UpdateTaskInput,
|
||||
TaskQuery,
|
||||
Project,
|
||||
Label,
|
||||
} from '$lib/api/todos';
|
||||
import { PRIORITY_ORDER } from '$lib/api/todos';
|
||||
import {
|
||||
format,
|
||||
parseISO,
|
||||
isSameDay,
|
||||
isToday,
|
||||
isBefore,
|
||||
startOfDay,
|
||||
addDays,
|
||||
isWithinInterval,
|
||||
} from 'date-fns';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { Task, TaskPriority, CreateTaskInput, UpdateTaskInput, Project, Label };
|
||||
|
||||
// State
|
||||
let todos = $state<Task[]>([]);
|
||||
let projects = $state<Project[]>([]);
|
||||
let labels = $state<Label[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let loadedRange = $state<{ start: Date; end: Date } | null>(null);
|
||||
let serviceAvailable = $state(true);
|
||||
|
||||
export const todosStore = {
|
||||
// ========== Getters ==========
|
||||
get todos() {
|
||||
return todos ?? [];
|
||||
},
|
||||
get projects() {
|
||||
return projects ?? [];
|
||||
},
|
||||
get labels() {
|
||||
return labels ?? [];
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get serviceAvailable() {
|
||||
return serviceAvailable;
|
||||
},
|
||||
|
||||
// ========== Derived Getters ==========
|
||||
|
||||
/**
|
||||
* Get todos for a specific day
|
||||
*/
|
||||
getTodosForDay(date: Date): Task[] {
|
||||
const currentTodos = todos ?? [];
|
||||
if (!Array.isArray(currentTodos)) return [];
|
||||
|
||||
return currentTodos.filter((task) => {
|
||||
if (!task.dueDate || task.isCompleted) return false;
|
||||
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
|
||||
return isSameDay(dueDate, date);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get todos within a date range
|
||||
*/
|
||||
getTodosInRange(start: Date, end: Date): Task[] {
|
||||
const currentTodos = todos ?? [];
|
||||
if (!Array.isArray(currentTodos)) return [];
|
||||
|
||||
return currentTodos.filter((task) => {
|
||||
if (!task.dueDate) return false;
|
||||
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
|
||||
return isWithinInterval(dueDate, { start, end });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get today's uncompleted todos
|
||||
*/
|
||||
get todaysTodos(): Task[] {
|
||||
const currentTodos = todos ?? [];
|
||||
if (!Array.isArray(currentTodos)) return [];
|
||||
|
||||
return currentTodos
|
||||
.filter((task) => {
|
||||
if (task.isCompleted) return false;
|
||||
if (!task.dueDate) return false;
|
||||
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
|
||||
return isToday(dueDate);
|
||||
})
|
||||
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get overdue todos (due before today, not completed)
|
||||
*/
|
||||
get overdueTodos(): Task[] {
|
||||
const currentTodos = todos ?? [];
|
||||
if (!Array.isArray(currentTodos)) return [];
|
||||
|
||||
const today = startOfDay(new Date());
|
||||
|
||||
return currentTodos
|
||||
.filter((task) => {
|
||||
if (task.isCompleted) return false;
|
||||
if (!task.dueDate) return false;
|
||||
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
|
||||
return isBefore(startOfDay(dueDate), today);
|
||||
})
|
||||
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get upcoming todos (next 7 days, not including today)
|
||||
*/
|
||||
get upcomingTodos(): Task[] {
|
||||
const currentTodos = todos ?? [];
|
||||
if (!Array.isArray(currentTodos)) return [];
|
||||
|
||||
const tomorrow = startOfDay(addDays(new Date(), 1));
|
||||
const weekFromNow = startOfDay(addDays(new Date(), 7));
|
||||
|
||||
return currentTodos
|
||||
.filter((task) => {
|
||||
if (task.isCompleted) return false;
|
||||
if (!task.dueDate) return false;
|
||||
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
|
||||
return isWithinInterval(startOfDay(dueDate), { start: tomorrow, end: weekFromNow });
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// First sort by date
|
||||
const dateA = a.dueDate ? parseISO(a.dueDate as string) : new Date();
|
||||
const dateB = b.dueDate ? parseISO(b.dueDate as string) : new Date();
|
||||
const dateDiff = dateA.getTime() - dateB.getTime();
|
||||
if (dateDiff !== 0) return dateDiff;
|
||||
// Then by priority
|
||||
return PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get todos without due date
|
||||
*/
|
||||
get unscheduledTodos(): Task[] {
|
||||
const currentTodos = todos ?? [];
|
||||
if (!Array.isArray(currentTodos)) return [];
|
||||
|
||||
return currentTodos
|
||||
.filter((task) => !task.isCompleted && !task.dueDate)
|
||||
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get completed todos
|
||||
*/
|
||||
get completedTodos(): Task[] {
|
||||
const currentTodos = todos ?? [];
|
||||
if (!Array.isArray(currentTodos)) return [];
|
||||
|
||||
return currentTodos.filter((task) => task.isCompleted);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get combined sidebar todos (overdue + today, sorted by priority)
|
||||
* Limited to show in sidebar
|
||||
*/
|
||||
getSidebarTodos(limit = 5): Task[] {
|
||||
const overdue = this.overdueTodos;
|
||||
const today = this.todaysTodos;
|
||||
|
||||
// Combine and sort: overdue first, then today, both by priority
|
||||
const combined = [...overdue, ...today];
|
||||
|
||||
return combined.slice(0, limit);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get total count of active todos (not completed)
|
||||
*/
|
||||
get activeTodosCount(): number {
|
||||
const currentTodos = todos ?? [];
|
||||
if (!Array.isArray(currentTodos)) return 0;
|
||||
|
||||
return currentTodos.filter((task) => !task.isCompleted).length;
|
||||
},
|
||||
|
||||
// ========== API Methods ==========
|
||||
|
||||
/**
|
||||
* Fetch todos for a date range
|
||||
*/
|
||||
async fetchTodos(startDate?: Date, endDate?: Date) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const query: TaskQuery = {
|
||||
isCompleted: false,
|
||||
};
|
||||
|
||||
if (startDate) {
|
||||
query.dueDateFrom = format(startDate, 'yyyy-MM-dd');
|
||||
}
|
||||
if (endDate) {
|
||||
query.dueDateTo = format(endDate, 'yyyy-MM-dd');
|
||||
}
|
||||
|
||||
const result = await api.getTasks(query);
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
serviceAvailable = false;
|
||||
} else {
|
||||
todos = result.data || [];
|
||||
serviceAvailable = true;
|
||||
if (startDate && endDate) {
|
||||
loadedRange = { start: startDate, end: endDate };
|
||||
}
|
||||
}
|
||||
|
||||
loading = false;
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch today's todos (shortcut)
|
||||
*/
|
||||
async fetchTodayTodos() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await api.getTodayTasks();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
serviceAvailable = false;
|
||||
} else {
|
||||
// Merge with existing todos (avoid duplicates)
|
||||
const newTodos = result.data || [];
|
||||
const existingIds = new Set(todos.map((t) => t.id));
|
||||
const uniqueNew = newTodos.filter((t) => !existingIds.has(t.id));
|
||||
todos = [...todos, ...uniqueNew];
|
||||
serviceAvailable = true;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch upcoming todos (shortcut)
|
||||
*/
|
||||
async fetchUpcomingTodos() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await api.getUpcomingTasks();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
serviceAvailable = false;
|
||||
} else {
|
||||
// Merge with existing todos (avoid duplicates)
|
||||
const newTodos = result.data || [];
|
||||
const existingIds = new Set(todos.map((t) => t.id));
|
||||
const uniqueNew = newTodos.filter((t) => !existingIds.has(t.id));
|
||||
todos = [...todos, ...uniqueNew];
|
||||
serviceAvailable = true;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch projects
|
||||
*/
|
||||
async fetchProjects() {
|
||||
const result = await api.getProjects();
|
||||
|
||||
if (!result.error && result.data) {
|
||||
projects = result.data;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch labels
|
||||
*/
|
||||
async fetchLabels() {
|
||||
const result = await api.getLabels();
|
||||
|
||||
if (!result.error && result.data) {
|
||||
labels = result.data;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new todo
|
||||
*/
|
||||
async createTodo(data: CreateTaskInput) {
|
||||
const result = await api.createTask(data);
|
||||
|
||||
if (result.data) {
|
||||
todos = [...todos, result.data];
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a todo
|
||||
*/
|
||||
async updateTodo(id: string, data: UpdateTaskInput) {
|
||||
const result = await api.updateTask(id, data);
|
||||
|
||||
if (result.data) {
|
||||
todos = todos.map((t) => (t.id === id ? result.data! : t));
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a todo
|
||||
*/
|
||||
async deleteTodo(id: string) {
|
||||
const result = await api.deleteTask(id);
|
||||
|
||||
if (!result.error) {
|
||||
todos = todos.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle todo completion
|
||||
*/
|
||||
async toggleComplete(id: string) {
|
||||
const todo = todos.find((t) => t.id === id);
|
||||
if (!todo) return { data: null, error: new Error('Todo not found') };
|
||||
|
||||
const result = todo.isCompleted ? await api.uncompleteTask(id) : await api.completeTask(id);
|
||||
|
||||
if (result.data) {
|
||||
todos = todos.map((t) => (t.id === id ? result.data! : t));
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get todo by ID
|
||||
*/
|
||||
getById(id: string): Task | undefined {
|
||||
const currentTodos = todos ?? [];
|
||||
if (!Array.isArray(currentTodos)) return undefined;
|
||||
|
||||
return currentTodos.find((t) => t.id === id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get project by ID
|
||||
*/
|
||||
getProjectById(id: string): Project | undefined {
|
||||
const currentProjects = projects ?? [];
|
||||
if (!Array.isArray(currentProjects)) return undefined;
|
||||
|
||||
return currentProjects.find((p) => p.id === id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear todos cache
|
||||
*/
|
||||
clear() {
|
||||
todos = [];
|
||||
loadedRange = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if Todo service is available
|
||||
*/
|
||||
async checkServiceHealth(): Promise<boolean> {
|
||||
const result = await api.getTasks({ limit: 1 });
|
||||
serviceAvailable = !result.error;
|
||||
return serviceAvailable;
|
||||
},
|
||||
};
|
||||
261
apps/calendar/apps/web/src/lib/utils/event-parser.ts
Normal file
261
apps/calendar/apps/web/src/lib/utils/event-parser.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* Event Parser for Calendar App
|
||||
*
|
||||
* Extends the base parser with event-specific patterns:
|
||||
* - Calendar: @CalendarName
|
||||
* - Duration: für 2 Stunden, 30 min
|
||||
* - Location: in Berlin, bei Firma XY
|
||||
*/
|
||||
|
||||
import {
|
||||
parseBaseInput,
|
||||
extractAtReference,
|
||||
combineDateAndTime,
|
||||
formatDatePreview,
|
||||
formatTimePreview,
|
||||
} from '@manacore/shared-utils';
|
||||
|
||||
export interface ParsedEvent {
|
||||
title: string;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
calendarName?: string;
|
||||
location?: string;
|
||||
tagNames: string[];
|
||||
isAllDay: boolean;
|
||||
}
|
||||
|
||||
interface Calendar {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ParsedEventWithIds {
|
||||
title: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
calendarId?: string;
|
||||
tagIds: string[];
|
||||
location?: string;
|
||||
isAllDay: boolean;
|
||||
}
|
||||
|
||||
// Duration patterns (event-specific)
|
||||
const DURATION_PATTERNS: { pattern: RegExp; getMinutes: (match: RegExpMatchArray) => number }[] = [
|
||||
// "für X Stunden" or "X Stunden"
|
||||
{
|
||||
pattern: /(?:für\s+)?(\d+(?:[.,]\d+)?)\s*(?:stunde?n?|h)\b/i,
|
||||
getMinutes: (match) => Math.round(parseFloat(match[1].replace(',', '.')) * 60),
|
||||
},
|
||||
// "für X Minuten" or "X min"
|
||||
{
|
||||
pattern: /(?:für\s+)?(\d+)\s*(?:minuten?|min)\b/i,
|
||||
getMinutes: (match) => parseInt(match[1], 10),
|
||||
},
|
||||
// "1,5h" or "1.5h"
|
||||
{
|
||||
pattern: /(\d+[.,]\d+)\s*h\b/i,
|
||||
getMinutes: (match) => Math.round(parseFloat(match[1].replace(',', '.')) * 60),
|
||||
},
|
||||
];
|
||||
|
||||
// Location patterns (event-specific)
|
||||
const LOCATION_PATTERNS: RegExp[] = [
|
||||
/\bin\s+([^@#!]+?)(?=\s+(?:@|#|!|\d{1,2}[:.]\d{2}|um\s+\d|\d{1,2}\s*uhr)|$)/i,
|
||||
/\bbei\s+([^@#!]+?)(?=\s+(?:@|#|!|\d{1,2}[:.]\d{2}|um\s+\d|\d{1,2}\s*uhr)|$)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract duration from text
|
||||
*/
|
||||
function extractDuration(text: string): { minutes?: number; remaining: string } {
|
||||
for (const { pattern, getMinutes } of DURATION_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
return {
|
||||
minutes: getMinutes(match),
|
||||
remaining: text.replace(pattern, '').trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { minutes: undefined, remaining: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract location from text
|
||||
*/
|
||||
function extractLocation(text: string): { location?: string; remaining: string } {
|
||||
for (const pattern of LOCATION_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
return {
|
||||
location: match[1].trim(),
|
||||
remaining: text.replace(pattern, '').trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { location: undefined, remaining: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse natural language event input
|
||||
*
|
||||
* Examples:
|
||||
* - "Meeting morgen 14 Uhr für 1 Stunde @Arbeit in Büro #wichtig"
|
||||
* - "Arzttermin Montag 10:30 30 min bei Dr. Müller"
|
||||
* - "Geburtstag 15.12. ganztägig #privat"
|
||||
*/
|
||||
export function parseEventInput(input: string): ParsedEvent {
|
||||
let text = input.trim();
|
||||
|
||||
// Check for all-day indicator first
|
||||
const allDayPattern = /\bganztägig\b|\ball[- ]?day\b/i;
|
||||
const isAllDay = allDayPattern.test(text);
|
||||
text = text.replace(allDayPattern, '').trim();
|
||||
|
||||
// Extract calendar (@CalendarName) - event-specific
|
||||
const calendarResult = extractAtReference(text);
|
||||
text = calendarResult.remaining;
|
||||
const calendarName = calendarResult.value;
|
||||
|
||||
// Extract duration first (before base parser)
|
||||
const durationResult = extractDuration(text);
|
||||
text = durationResult.remaining;
|
||||
const durationMinutes = durationResult.minutes;
|
||||
|
||||
// Extract location (before base parser to avoid conflicts)
|
||||
const locationResult = extractLocation(text);
|
||||
text = locationResult.remaining;
|
||||
const location = locationResult.location;
|
||||
|
||||
// Use base parser for common patterns (date, time, tags)
|
||||
const base = parseBaseInput(text);
|
||||
|
||||
// Combine date and time for start
|
||||
const startTime = combineDateAndTime(base.date, base.time);
|
||||
|
||||
// Calculate end time based on duration (default 1 hour)
|
||||
let endTime: Date | undefined;
|
||||
if (startTime && !isAllDay) {
|
||||
const duration = durationMinutes || 60; // Default 1 hour
|
||||
endTime = new Date(startTime.getTime() + duration * 60 * 1000);
|
||||
} else if (startTime && isAllDay) {
|
||||
// All-day events: end time is end of day
|
||||
endTime = new Date(startTime);
|
||||
endTime.setHours(23, 59, 59, 999);
|
||||
}
|
||||
|
||||
return {
|
||||
title: base.title,
|
||||
startTime,
|
||||
endTime,
|
||||
calendarName,
|
||||
location,
|
||||
tagNames: base.tagNames,
|
||||
isAllDay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve calendar and tag names to IDs
|
||||
*/
|
||||
export function resolveEventIds(
|
||||
parsed: ParsedEvent,
|
||||
calendars: Calendar[],
|
||||
tags: Tag[]
|
||||
): ParsedEventWithIds {
|
||||
let calendarId: string | undefined;
|
||||
const tagIds: string[] = [];
|
||||
|
||||
// Find calendar by name (case-insensitive)
|
||||
if (parsed.calendarName) {
|
||||
const calendar = calendars.find(
|
||||
(c) => c.name.toLowerCase() === parsed.calendarName!.toLowerCase()
|
||||
);
|
||||
if (calendar) {
|
||||
calendarId = calendar.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Use default calendar if none specified
|
||||
if (!calendarId && calendars.length > 0) {
|
||||
const defaultCalendar = calendars.find((c: any) => c.isDefault) || calendars[0];
|
||||
calendarId = defaultCalendar.id;
|
||||
}
|
||||
|
||||
// Find tags by name (case-insensitive)
|
||||
for (const tagName of parsed.tagNames) {
|
||||
const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase());
|
||||
if (tag) {
|
||||
tagIds.push(tag.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: parsed.title,
|
||||
startTime: parsed.startTime?.toISOString(),
|
||||
endTime: parsed.endTime?.toISOString(),
|
||||
calendarId,
|
||||
tagIds,
|
||||
location: parsed.location,
|
||||
isAllDay: parsed.isAllDay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format parsed event for preview display
|
||||
*/
|
||||
export function formatParsedEventPreview(parsed: ParsedEvent): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (parsed.startTime) {
|
||||
let dateStr = `📅 ${formatDatePreview(parsed.startTime)}`;
|
||||
|
||||
if (!parsed.isAllDay && parsed.startTime.getHours() !== 0) {
|
||||
dateStr += ` ${formatTimePreview({
|
||||
hours: parsed.startTime.getHours(),
|
||||
minutes: parsed.startTime.getMinutes(),
|
||||
})}`;
|
||||
|
||||
// Add duration if end time differs
|
||||
if (parsed.endTime) {
|
||||
const durationMs = parsed.endTime.getTime() - parsed.startTime.getTime();
|
||||
const durationMins = Math.round(durationMs / 60000);
|
||||
if (durationMins > 0 && durationMins !== 60) {
|
||||
if (durationMins >= 60) {
|
||||
const hours = Math.floor(durationMins / 60);
|
||||
const mins = durationMins % 60;
|
||||
dateStr += mins > 0 ? ` (${hours}h ${mins}min)` : ` (${hours}h)`;
|
||||
} else {
|
||||
dateStr += ` (${durationMins}min)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.isAllDay) {
|
||||
dateStr += ' (Ganztägig)';
|
||||
}
|
||||
|
||||
parts.push(dateStr);
|
||||
}
|
||||
|
||||
if (parsed.location) {
|
||||
parts.push(`📍 ${parsed.location}`);
|
||||
}
|
||||
|
||||
if (parsed.calendarName) {
|
||||
parts.push(`📆 ${parsed.calendarName}`);
|
||||
}
|
||||
|
||||
if (parsed.tagNames.length > 0) {
|
||||
parts.push(`🏷️ ${parsed.tagNames.join(', ')}`);
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
|
@ -9,12 +9,15 @@
|
|||
PillDropdownItem,
|
||||
CommandBarItem,
|
||||
QuickAction,
|
||||
CreatePreview,
|
||||
} from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
|
|
@ -22,6 +25,7 @@
|
|||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import { filterHiddenNavItems } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
|
|
@ -32,6 +36,11 @@
|
|||
import { searchEvents } from '$lib/api/events';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import {
|
||||
parseEventInput,
|
||||
resolveEventIds,
|
||||
formatParsedEventPreview,
|
||||
} from '$lib/utils/event-parser';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('calendar');
|
||||
|
|
@ -51,6 +60,7 @@
|
|||
onclick: () => viewStore.goToToday(),
|
||||
},
|
||||
{ id: 'agenda', label: 'Agenda anzeigen', icon: 'list', href: '/agenda' },
|
||||
{ id: 'tasks', label: 'Aufgaben anzeigen', icon: 'check-square', href: '/tasks' },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
|
||||
];
|
||||
|
||||
|
|
@ -72,6 +82,54 @@
|
|||
goto(`/event/${item.id}`);
|
||||
}
|
||||
|
||||
// CommandBar Quick-Create handlers
|
||||
function handleCommandBarParseCreate(query: string): CreatePreview | null {
|
||||
if (!query.trim()) return null;
|
||||
|
||||
const parsed = parseEventInput(query);
|
||||
if (!parsed.title) return null;
|
||||
|
||||
return {
|
||||
title: parsed.title,
|
||||
subtitle: formatParsedEventPreview(parsed),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCommandBarCreate(query: string): Promise<void> {
|
||||
const parsed = parseEventInput(query);
|
||||
if (!parsed.title) return;
|
||||
|
||||
// Resolve calendar and tag names to IDs
|
||||
const calendars = calendarsStore.calendars.map((c) => ({ id: c.id, name: c.name }));
|
||||
const tags = eventTagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
|
||||
const resolved = resolveEventIds(parsed, calendars, tags);
|
||||
|
||||
// Ensure we have a calendar
|
||||
if (!resolved.calendarId) {
|
||||
console.error('No calendar available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure we have start and end times
|
||||
if (!resolved.startTime) {
|
||||
// Default to now + 1 hour
|
||||
const now = new Date();
|
||||
resolved.startTime = now.toISOString();
|
||||
const end = new Date(now.getTime() + 60 * 60 * 1000);
|
||||
resolved.endTime = end.toISOString();
|
||||
}
|
||||
|
||||
await eventsStore.createEvent({
|
||||
calendarId: resolved.calendarId,
|
||||
title: resolved.title,
|
||||
startTime: resolved.startTime,
|
||||
endTime: resolved.endTime || resolved.startTime,
|
||||
isAllDay: resolved.isAllDay,
|
||||
location: resolved.location,
|
||||
tagIds: resolved.tagIds,
|
||||
});
|
||||
}
|
||||
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
|
|
@ -122,18 +180,25 @@
|
|||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
|
||||
// Navigation items for Calendar
|
||||
const navItems: PillNavItem[] = [
|
||||
// Base navigation items for Calendar
|
||||
const baseNavItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Kalender', icon: 'calendar' },
|
||||
{ href: '/agenda', label: 'Agenda', icon: 'list' },
|
||||
{ href: '/tasks', label: 'Aufgaben', icon: 'check-square' },
|
||||
{ href: '/tags', label: 'Tags', icon: 'tag' },
|
||||
{ href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' },
|
||||
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-4)
|
||||
const navRoutes = navItems.map((item) => item.href);
|
||||
// Navigation items filtered by visibility settings
|
||||
const navItems = $derived(
|
||||
filterHiddenNavItems('calendar', baseNavItems, userSettings.nav.hiddenNavItems)
|
||||
);
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-4) - use base items for consistent shortcuts
|
||||
const navRoutes = baseNavItems.map((item) => item.href);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
|
@ -200,8 +265,9 @@
|
|||
// Initialize view state
|
||||
viewStore.initialize();
|
||||
|
||||
// Load calendars and user settings
|
||||
// Load calendars, tags, and user settings
|
||||
await calendarsStore.fetchCalendars();
|
||||
await eventTagsStore.fetchTags();
|
||||
await userSettings.load();
|
||||
|
||||
// Redirect to start page if on root and a custom start page is set
|
||||
|
|
@ -283,9 +349,13 @@
|
|||
onSearch={handleCommandBarSearch}
|
||||
onSelect={handleCommandBarSelect}
|
||||
quickActions={commandBarQuickActions}
|
||||
placeholder="Termin suchen..."
|
||||
placeholder="Termin suchen oder erstellen..."
|
||||
emptyText="Keine Termine gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCommandBarCreate}
|
||||
onParseCreate={handleCommandBarParseCreate}
|
||||
createText="Als Termin erstellen"
|
||||
createShortcut="⌘↵"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
import YearView from '$lib/components/calendar/YearView.svelte';
|
||||
import MiniCalendar from '$lib/components/calendar/MiniCalendar.svelte';
|
||||
import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte';
|
||||
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
|
||||
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
|
||||
import EventDetailModal from '$lib/components/event/EventDetailModal.svelte';
|
||||
import { CalendarViewSkeleton } from '$lib/components/skeletons';
|
||||
|
|
@ -130,6 +131,8 @@
|
|||
<MiniCalendar selectedDate={viewStore.currentDate} onDateSelect={handleDateSelect} />
|
||||
|
||||
<CalendarSidebar />
|
||||
|
||||
<TodoSidebarSection maxItems={5} />
|
||||
</aside>
|
||||
|
||||
<!-- FAB when sidebar is collapsed -->
|
||||
|
|
|
|||
287
apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte
Normal file
287
apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { calendarStatisticsStore } from '$lib/stores/statistics.svelte';
|
||||
import {
|
||||
StatsGrid,
|
||||
ActivityHeatmap,
|
||||
TrendLineChart,
|
||||
DonutChart,
|
||||
ProgressBars,
|
||||
StatisticsSkeleton,
|
||||
type StatItem,
|
||||
} from '@manacore/shared-ui';
|
||||
import {
|
||||
BarChart3,
|
||||
CalendarDays,
|
||||
Calendar,
|
||||
Clock,
|
||||
CalendarCheck,
|
||||
Hourglass,
|
||||
} from 'lucide-svelte';
|
||||
import { subDays, addDays } from 'date-fns';
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
// Update statistics when events change
|
||||
$effect(() => {
|
||||
calendarStatisticsStore.setEvents(eventsStore.events);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
calendarStatisticsStore.setCalendars(calendarsStore.calendars);
|
||||
});
|
||||
|
||||
// Build stats items for StatsGrid
|
||||
let statsItems = $derived<StatItem[]>([
|
||||
{
|
||||
id: 'eventsToday',
|
||||
label: 'Heute',
|
||||
value: calendarStatisticsStore.eventsToday,
|
||||
icon: CalendarDays,
|
||||
variant: 'success',
|
||||
},
|
||||
{
|
||||
id: 'eventsThisWeek',
|
||||
label: 'Diese Woche',
|
||||
value: calendarStatisticsStore.eventsThisWeek,
|
||||
icon: Calendar,
|
||||
variant: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'upcoming',
|
||||
label: 'Anstehend (7 Tage)',
|
||||
value: calendarStatisticsStore.upcomingEvents,
|
||||
icon: CalendarCheck,
|
||||
variant: 'info',
|
||||
},
|
||||
{
|
||||
id: 'busyHours',
|
||||
label: 'Stunden/Woche',
|
||||
value: `${calendarStatisticsStore.busyHoursThisWeek}h`,
|
||||
icon: Clock,
|
||||
variant: 'neutral',
|
||||
},
|
||||
{
|
||||
id: 'calendars',
|
||||
label: 'Kalender',
|
||||
value: calendarStatisticsStore.totalCalendars,
|
||||
icon: Calendar,
|
||||
variant: 'accent',
|
||||
},
|
||||
{
|
||||
id: 'avgDuration',
|
||||
label: 'Ø Dauer (Min)',
|
||||
value: calendarStatisticsStore.averageEventDuration,
|
||||
icon: Hourglass,
|
||||
variant: 'info',
|
||||
},
|
||||
]);
|
||||
|
||||
onMount(async () => {
|
||||
// Fetch events for the last 6 months + next month for statistics
|
||||
const startDate = subDays(new Date(), 180);
|
||||
const endDate = addDays(new Date(), 30);
|
||||
|
||||
await Promise.all([
|
||||
eventsStore.fetchEvents(startDate, endDate),
|
||||
calendarsStore.fetchCalendars(),
|
||||
]);
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Statistiken - Kalender</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="statistics-page">
|
||||
<header class="page-header">
|
||||
<div class="header-icon">
|
||||
<BarChart3 size={28} />
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1>Statistiken</h1>
|
||||
<p class="header-subtitle">Dein Kalender im Überblick</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<StatisticsSkeleton statCards={6} legendItems={3} />
|
||||
{:else}
|
||||
<!-- Quick Stats -->
|
||||
<section class="stats-section">
|
||||
<StatsGrid items={statsItems} columns={6} />
|
||||
</section>
|
||||
|
||||
<!-- Charts Grid -->
|
||||
<div class="charts-grid">
|
||||
<!-- Activity Heatmap -->
|
||||
<section class="chart-section heatmap-section">
|
||||
<ActivityHeatmap
|
||||
data={calendarStatisticsStore.activityHeatmap}
|
||||
itemName="Event"
|
||||
itemNamePlural="Events"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Weekly Trend + Status Donut -->
|
||||
<div class="charts-row">
|
||||
<section class="chart-section trend-section">
|
||||
<TrendLineChart
|
||||
data={calendarStatisticsStore.weeklyTrend}
|
||||
itemName="Event"
|
||||
itemNamePlural="Events"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="chart-section donut-section">
|
||||
<DonutChart
|
||||
data={calendarStatisticsStore.statusBreakdown}
|
||||
title="Status"
|
||||
centerLabel="Events"
|
||||
centerValue={calendarStatisticsStore.totalEvents}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Activity -->
|
||||
<section class="chart-section calendars-section">
|
||||
<ProgressBars
|
||||
data={calendarStatisticsStore.calendarActivity}
|
||||
title="Kalender-Aktivität"
|
||||
emptyMessage="Keine Kalender mit Events"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
<div class="additional-stats">
|
||||
<div class="stat-card-small">
|
||||
<span class="stat-label">Ganztägige Events</span>
|
||||
<span class="stat-value">
|
||||
{calendarStatisticsStore.allDayRatio.allDay}
|
||||
<span class="stat-percentage"
|
||||
>({calendarStatisticsStore.allDayRatio.allDayPercentage}%)</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-card-small">
|
||||
<span class="stat-label">Wiederkehrende Events</span>
|
||||
<span class="stat-value">{calendarStatisticsStore.recurringEventsCount}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-card-small">
|
||||
<span class="stat-label">Events gesamt</span>
|
||||
<span class="stat-value">{calendarStatisticsStore.totalEvents}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.statistics-page {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: hsl(var(--primary) / 0.15);
|
||||
color: hsl(var(--primary));
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0.25rem 0 0 0;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.charts-row {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.additional-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card-small {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
:global(.dark) .stat-card-small {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.stat-card-small .stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.stat-card-small .stat-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.stat-percentage {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
486
apps/calendar/apps/web/src/routes/(app)/tasks/+page.svelte
Normal file
486
apps/calendar/apps/web/src/routes/(app)/tasks/+page.svelte
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import type { Task } from '$lib/api/todos';
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import AgendaItem from '$lib/components/agenda/AgendaItem.svelte';
|
||||
import AgendaFilters from '$lib/components/agenda/AgendaFilters.svelte';
|
||||
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
|
||||
import QuickAddTodo from '$lib/components/todo/QuickAddTodo.svelte';
|
||||
import { AgendaSkeleton } from '$lib/components/skeletons';
|
||||
import {
|
||||
format,
|
||||
parseISO,
|
||||
isToday,
|
||||
isTomorrow,
|
||||
addDays,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
isBefore,
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { CheckSquare, AlertTriangle, Plus } from 'lucide-svelte';
|
||||
|
||||
// State
|
||||
let loading = $state(true);
|
||||
let showEvents = $state(true);
|
||||
let showTodos = $state(true);
|
||||
let timeRange = $state<'7' | '30' | 'all'>('30');
|
||||
let selectedTask = $state<Task | null>(null);
|
||||
let showQuickAdd = $state(false);
|
||||
|
||||
// Combined and grouped items
|
||||
type AgendaGroup = {
|
||||
date: Date;
|
||||
items: Array<{ type: 'event' | 'todo'; event?: CalendarEvent; todo?: Task }>;
|
||||
};
|
||||
|
||||
let groupedItems = $derived.by(() => {
|
||||
const groups = new Map<string, AgendaGroup['items']>();
|
||||
const today = startOfDay(new Date());
|
||||
|
||||
// Add events
|
||||
if (showEvents) {
|
||||
const currentEvents = eventsStore.events ?? [];
|
||||
if (Array.isArray(currentEvents)) {
|
||||
for (const event of currentEvents) {
|
||||
const start =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const dateKey = format(start, 'yyyy-MM-dd');
|
||||
|
||||
if (!groups.has(dateKey)) {
|
||||
groups.set(dateKey, []);
|
||||
}
|
||||
groups.get(dateKey)!.push({ type: 'event', event });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add todos
|
||||
if (showTodos) {
|
||||
const currentTodos = todosStore.todos ?? [];
|
||||
if (Array.isArray(currentTodos)) {
|
||||
for (const todo of currentTodos) {
|
||||
if (todo.isCompleted) continue; // Skip completed todos
|
||||
|
||||
let dateKey: string;
|
||||
if (todo.dueDate) {
|
||||
const dueDate =
|
||||
typeof todo.dueDate === 'string' ? parseISO(todo.dueDate) : todo.dueDate;
|
||||
// Group overdue todos under today
|
||||
if (isBefore(startOfDay(dueDate), today)) {
|
||||
dateKey = format(today, 'yyyy-MM-dd');
|
||||
} else {
|
||||
dateKey = format(dueDate, 'yyyy-MM-dd');
|
||||
}
|
||||
} else {
|
||||
// Todos without due date go under today
|
||||
dateKey = format(today, 'yyyy-MM-dd');
|
||||
}
|
||||
|
||||
if (!groups.has(dateKey)) {
|
||||
groups.set(dateKey, []);
|
||||
}
|
||||
groups.get(dateKey)!.push({ type: 'todo', todo });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort groups by date and items within each group
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dateKey, items]) => ({
|
||||
date: parseISO(dateKey),
|
||||
items: items.sort((a, b) => {
|
||||
// Todos before events
|
||||
if (a.type !== b.type) return a.type === 'todo' ? -1 : 1;
|
||||
|
||||
// Sort events by time
|
||||
if (a.type === 'event' && b.type === 'event' && a.event && b.event) {
|
||||
const aStart =
|
||||
typeof a.event.startTime === 'string'
|
||||
? parseISO(a.event.startTime)
|
||||
: a.event.startTime;
|
||||
const bStart =
|
||||
typeof b.event.startTime === 'string'
|
||||
? parseISO(b.event.startTime)
|
||||
: b.event.startTime;
|
||||
return aStart.getTime() - bStart.getTime();
|
||||
}
|
||||
|
||||
// Sort todos by priority
|
||||
if (a.type === 'todo' && b.type === 'todo' && a.todo && b.todo) {
|
||||
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 };
|
||||
return priorityOrder[a.todo.priority] - priorityOrder[b.todo.priority];
|
||||
}
|
||||
|
||||
return 0;
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
// Stats
|
||||
const overdueCount = $derived(todosStore.overdueTodos.length);
|
||||
const todayCount = $derived(todosStore.todaysTodos.length);
|
||||
const totalActiveCount = $derived(todosStore.activeTodosCount);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch data based on time range
|
||||
await fetchData();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function fetchData() {
|
||||
const start = startOfDay(new Date());
|
||||
const days = timeRange === '7' ? 7 : timeRange === '30' ? 30 : 90;
|
||||
const end = endOfDay(addDays(start, days));
|
||||
|
||||
await Promise.all([
|
||||
eventsStore.fetchEvents(start, end),
|
||||
todosStore.fetchTodos(start, end),
|
||||
todosStore.fetchTodayTodos(),
|
||||
]);
|
||||
}
|
||||
|
||||
function formatDateHeader(date: Date) {
|
||||
if (isToday(date)) {
|
||||
return 'Heute';
|
||||
}
|
||||
if (isTomorrow(date)) {
|
||||
return 'Morgen';
|
||||
}
|
||||
return format(date, 'EEEE, d. MMMM', { locale: de });
|
||||
}
|
||||
|
||||
function handleEventClick(eventId: string) {
|
||||
goto(`/?event=${eventId}`);
|
||||
}
|
||||
|
||||
function handleTodoClick(task: Task) {
|
||||
selectedTask = task;
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
selectedTask = null;
|
||||
}
|
||||
|
||||
function toggleEvents() {
|
||||
showEvents = !showEvents;
|
||||
}
|
||||
|
||||
function toggleTodos() {
|
||||
showTodos = !showTodos;
|
||||
}
|
||||
|
||||
function handleRangeChange(range: '7' | '30' | 'all') {
|
||||
timeRange = range;
|
||||
loading = true;
|
||||
fetchData().then(() => (loading = false));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Aufgaben | Kalender</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="tasks-page">
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-icon">
|
||||
<CheckSquare size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Aufgaben</h1>
|
||||
<p class="subtitle">Ihre Termine und Aufgaben auf einen Blick</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
{#if overdueCount > 0}
|
||||
<span class="stat overdue">
|
||||
<AlertTriangle size={14} />
|
||||
{overdueCount} überfällig
|
||||
</span>
|
||||
{/if}
|
||||
<span class="stat">{todayCount} heute</span>
|
||||
<span class="stat">{totalActiveCount} gesamt</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Filters -->
|
||||
<AgendaFilters
|
||||
{showEvents}
|
||||
{showTodos}
|
||||
{timeRange}
|
||||
onToggleEvents={toggleEvents}
|
||||
onToggleTodos={toggleTodos}
|
||||
onRangeChange={handleRangeChange}
|
||||
/>
|
||||
|
||||
<!-- Quick Add -->
|
||||
<div class="quick-add-section">
|
||||
{#if showQuickAdd}
|
||||
<QuickAddTodo
|
||||
placeholder="Neue Aufgabe hinzufügen..."
|
||||
autofocus
|
||||
showButton={false}
|
||||
onsubmit={() => (showQuickAdd = false)}
|
||||
oncancel={() => (showQuickAdd = false)}
|
||||
/>
|
||||
{:else}
|
||||
<button type="button" class="quick-add-button" onclick={() => (showQuickAdd = true)}>
|
||||
<Plus size={16} />
|
||||
<span>Neue Aufgabe</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
{#if loading}
|
||||
<AgendaSkeleton />
|
||||
{:else if !todosStore.serviceAvailable}
|
||||
<div class="error-state card">
|
||||
<AlertTriangle size={24} />
|
||||
<p>Todo-Service ist nicht erreichbar</p>
|
||||
<p class="hint">Bitte versuchen Sie es später erneut</p>
|
||||
</div>
|
||||
{:else if groupedItems.length === 0}
|
||||
<div class="empty-state card">
|
||||
<CheckSquare size={32} />
|
||||
<p>Keine Einträge gefunden</p>
|
||||
<p class="hint">
|
||||
{#if !showEvents && !showTodos}
|
||||
Aktivieren Sie mindestens einen Filter
|
||||
{:else}
|
||||
Erstellen Sie eine neue Aufgabe oder ändern Sie den Zeitraum
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="item-list">
|
||||
{#each groupedItems as group}
|
||||
<div class="date-group">
|
||||
<h2 class="date-header" class:today={isToday(group.date)}>
|
||||
{formatDateHeader(group.date)}
|
||||
<span class="item-count">({group.items.length})</span>
|
||||
</h2>
|
||||
|
||||
<div class="items">
|
||||
{#each group.items as item}
|
||||
{#if item.type === 'event' && item.event}
|
||||
<AgendaItem
|
||||
type="event"
|
||||
event={item.event}
|
||||
onclick={() => handleEventClick(item.event!.id)}
|
||||
/>
|
||||
{:else if item.type === 'todo' && item.todo}
|
||||
<AgendaItem
|
||||
type="todo"
|
||||
todo={item.todo}
|
||||
onclick={() => handleTodoClick(item.todo!)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Detail Modal -->
|
||||
{#if selectedTask}
|
||||
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tasks-page {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.stat.overdue {
|
||||
color: hsl(var(--color-danger));
|
||||
background: hsl(var(--color-danger) / 0.1);
|
||||
}
|
||||
|
||||
.quick-add-section {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.quick-add-button:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
|
||||
.item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.date-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date-header {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date-header.today {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.item-count {
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.empty-state :global(svg),
|
||||
.error-state :global(svg) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: hsl(var(--color-danger));
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tasks-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.stats {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import { filterHiddenNavItems } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
|
|
@ -78,8 +79,8 @@
|
|||
);
|
||||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
// Navigation items for Chat (settings moved to user dropdown)
|
||||
const navItems: PillNavItem[] = [
|
||||
// Base navigation items for Chat (settings moved to user dropdown)
|
||||
const baseNavItems: PillNavItem[] = [
|
||||
{ href: '/chat', label: 'Chat', icon: 'home' },
|
||||
{ href: '/templates', label: 'Templates', icon: 'document' },
|
||||
{ href: '/spaces', label: 'Spaces', icon: 'building' },
|
||||
|
|
@ -88,14 +89,19 @@
|
|||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
||||
// Navigation items filtered by visibility settings
|
||||
const navItems = $derived(
|
||||
filterHiddenNavItems('chat', baseNavItems, userSettings.nav.hiddenNavItems)
|
||||
);
|
||||
|
||||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email);
|
||||
|
||||
// Check if current page is a chat page (needs full-width layout)
|
||||
let isChatPage = $derived($page.url.pathname.startsWith('/chat'));
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-5)
|
||||
const navRoutes = navItems.map((item) => item.href);
|
||||
// Navigation shortcuts (Ctrl+1-5) - use base items for consistent shortcuts
|
||||
const navRoutes = baseNavItems.map((item) => item.href);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import { filterHiddenNavItems } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
|
|
@ -161,8 +162,8 @@
|
|||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
|
||||
// Navigation items for Clock
|
||||
const navItems: PillNavItem[] = [
|
||||
// Base navigation items for Clock
|
||||
const baseNavItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Übersicht', icon: 'home' },
|
||||
{ href: '/alarms', label: 'Wecker', icon: 'bell' },
|
||||
{ href: '/timers', label: 'Timer', icon: 'timer' },
|
||||
|
|
@ -174,8 +175,13 @@
|
|||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-9)
|
||||
const navRoutes = navItems.map((item) => item.href);
|
||||
// Navigation items filtered by visibility settings
|
||||
const navItems = $derived(
|
||||
filterHiddenNavItems('clock', baseNavItems, userSettings.nav.hiddenNavItems)
|
||||
);
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-9) - use base items for consistent shortcuts
|
||||
const navRoutes = baseNavItems.map((item) => item.href);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
|
|
|||
275
apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts
Normal file
275
apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
/**
|
||||
* Contacts Statistics Store - Calculates contact statistics using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { Contact } from '$lib/api/contacts';
|
||||
import { subDays, format, parseISO, isWithinInterval, getMonth, eachDayOfInterval } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import type {
|
||||
HeatmapDataPoint,
|
||||
TrendDataPoint,
|
||||
DonutSegment,
|
||||
ProgressItem,
|
||||
} from '@manacore/shared-ui';
|
||||
|
||||
// Types
|
||||
export interface ContactTag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// State
|
||||
let contacts = $state<Contact[]>([]);
|
||||
let tags = $state<ContactTag[]>([]);
|
||||
|
||||
export const contactsStatisticsStore = {
|
||||
// Setters
|
||||
setContacts(newContacts: Contact[]) {
|
||||
contacts = newContacts;
|
||||
},
|
||||
|
||||
setTags(newTags: ContactTag[]) {
|
||||
tags = newTags;
|
||||
},
|
||||
|
||||
// Quick Stats
|
||||
get totalContacts() {
|
||||
return contacts.length;
|
||||
},
|
||||
|
||||
get favoriteContacts() {
|
||||
return contacts.filter((c) => c.isFavorite).length;
|
||||
},
|
||||
|
||||
get archivedContacts() {
|
||||
return contacts.filter((c) => c.isArchived).length;
|
||||
},
|
||||
|
||||
get activeContacts() {
|
||||
return contacts.filter((c) => !c.isArchived).length;
|
||||
},
|
||||
|
||||
get recentlyAdded() {
|
||||
const weekAgo = subDays(new Date(), 7);
|
||||
return contacts.filter((c) => {
|
||||
const createdAt =
|
||||
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
|
||||
return createdAt >= weekAgo;
|
||||
}).length;
|
||||
},
|
||||
|
||||
get birthdaysThisMonth() {
|
||||
const currentMonth = getMonth(new Date());
|
||||
return contacts.filter((c) => {
|
||||
if (!c.birthday) return false;
|
||||
const birthday = typeof c.birthday === 'string' ? parseISO(c.birthday) : new Date(c.birthday);
|
||||
return getMonth(birthday) === currentMonth;
|
||||
}).length;
|
||||
},
|
||||
|
||||
get contactsWithEmail() {
|
||||
return contacts.filter((c) => c.email).length;
|
||||
},
|
||||
|
||||
get contactsWithPhone() {
|
||||
return contacts.filter((c) => c.phone || c.mobile).length;
|
||||
},
|
||||
|
||||
// Completeness rate (contacts with email AND phone)
|
||||
get completenessRate() {
|
||||
if (contacts.length === 0) return 0;
|
||||
const complete = contacts.filter((c) => c.email && (c.phone || c.mobile)).length;
|
||||
return Math.round((complete / contacts.length) * 100);
|
||||
},
|
||||
|
||||
// Activity Heatmap (last 6 months) - based on contact creation
|
||||
get activityHeatmap(): HeatmapDataPoint[] {
|
||||
const endDate = new Date();
|
||||
const startDate = subDays(endDate, 180);
|
||||
|
||||
// Count contacts created per day
|
||||
const creationMap = new Map<string, number>();
|
||||
|
||||
contacts.forEach((c) => {
|
||||
const createdAt =
|
||||
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
|
||||
if (createdAt >= startDate && createdAt <= endDate) {
|
||||
const dateKey = format(createdAt, 'yyyy-MM-dd');
|
||||
creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate all days
|
||||
const days = eachDayOfInterval({ start: startDate, end: endDate });
|
||||
|
||||
return days.map((day) => {
|
||||
const dateKey = format(day, 'yyyy-MM-dd');
|
||||
return {
|
||||
date: dateKey,
|
||||
count: creationMap.get(dateKey) || 0,
|
||||
dayOfWeek: day.getDay(),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Weekly Trend (last 4 weeks)
|
||||
get weeklyTrend(): TrendDataPoint[] {
|
||||
const endDate = new Date();
|
||||
const startDate = subDays(endDate, 27);
|
||||
|
||||
const creationMap = new Map<string, number>();
|
||||
|
||||
contacts.forEach((c) => {
|
||||
const createdAt =
|
||||
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
|
||||
if (createdAt >= startDate && createdAt <= endDate) {
|
||||
const dateKey = format(createdAt, 'yyyy-MM-dd');
|
||||
creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const days = eachDayOfInterval({ start: startDate, end: endDate });
|
||||
|
||||
return days.map((day) => {
|
||||
const dateKey = format(day, 'yyyy-MM-dd');
|
||||
return {
|
||||
date: dateKey,
|
||||
count: creationMap.get(dateKey) || 0,
|
||||
label: format(day, 'EEE', { locale: de }),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Contact Status Breakdown (Donut Chart) - Favorites / Active / Archived
|
||||
get statusBreakdown(): DonutSegment[] {
|
||||
const total = contacts.length;
|
||||
if (total === 0) return [];
|
||||
|
||||
const favorites = contacts.filter((c) => c.isFavorite && !c.isArchived).length;
|
||||
const archived = contacts.filter((c) => c.isArchived).length;
|
||||
const regular = contacts.filter((c) => !c.isFavorite && !c.isArchived).length;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'favorites',
|
||||
label: 'Favoriten',
|
||||
count: favorites,
|
||||
percentage: Math.round((favorites / total) * 100),
|
||||
color: '#F59E0B', // amber
|
||||
},
|
||||
{
|
||||
id: 'regular',
|
||||
label: 'Aktiv',
|
||||
count: regular,
|
||||
percentage: Math.round((regular / total) * 100),
|
||||
color: '#10B981', // green
|
||||
},
|
||||
{
|
||||
id: 'archived',
|
||||
label: 'Archiviert',
|
||||
count: archived,
|
||||
percentage: Math.round((archived / total) * 100),
|
||||
color: '#6B7280', // gray
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// Tags Progress (Progress Bars)
|
||||
get tagProgress(): ProgressItem[] {
|
||||
// Count contacts per tag
|
||||
const tagCountMap = new Map<string, number>();
|
||||
|
||||
// This requires contacts to have a tags array - we'll estimate from the tag data
|
||||
// For now, we'll show tags with placeholder counts
|
||||
// In a real implementation, we'd need contactTags relation data
|
||||
|
||||
const result: ProgressItem[] = tags.map((tag) => ({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
color: tag.color || '#6B7280',
|
||||
total: contacts.length, // Total contacts as reference
|
||||
completed: 0, // Would need contact-tag relation to calculate
|
||||
percentage: 0,
|
||||
}));
|
||||
|
||||
return result.sort((a, b) => b.completed - a.completed);
|
||||
},
|
||||
|
||||
// Info completeness breakdown
|
||||
get infoBreakdown(): DonutSegment[] {
|
||||
const total = contacts.length;
|
||||
if (total === 0) return [];
|
||||
|
||||
const withEmail = contacts.filter((c) => c.email).length;
|
||||
const withPhone = contacts.filter((c) => c.phone || c.mobile).length;
|
||||
const withCompany = contacts.filter((c) => c.company).length;
|
||||
const withBirthday = contacts.filter((c) => c.birthday).length;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'email',
|
||||
label: 'Mit E-Mail',
|
||||
count: withEmail,
|
||||
percentage: Math.round((withEmail / total) * 100),
|
||||
color: '#3B82F6', // blue
|
||||
},
|
||||
{
|
||||
id: 'phone',
|
||||
label: 'Mit Telefon',
|
||||
count: withPhone,
|
||||
percentage: Math.round((withPhone / total) * 100),
|
||||
color: '#10B981', // green
|
||||
},
|
||||
{
|
||||
id: 'company',
|
||||
label: 'Mit Firma',
|
||||
count: withCompany,
|
||||
percentage: Math.round((withCompany / total) * 100),
|
||||
color: '#8B5CF6', // violet
|
||||
},
|
||||
{
|
||||
id: 'birthday',
|
||||
label: 'Mit Geburtstag',
|
||||
count: withBirthday,
|
||||
percentage: Math.round((withBirthday / total) * 100),
|
||||
color: '#EC4899', // pink
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// Country breakdown
|
||||
get countryBreakdown(): ProgressItem[] {
|
||||
const countryMap = new Map<string, number>();
|
||||
|
||||
contacts.forEach((c) => {
|
||||
const country = c.country || 'Unbekannt';
|
||||
countryMap.set(country, (countryMap.get(country) || 0) + 1);
|
||||
});
|
||||
|
||||
const result: ProgressItem[] = [];
|
||||
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#6B7280'];
|
||||
let colorIndex = 0;
|
||||
|
||||
countryMap.forEach((count, country) => {
|
||||
if (country !== 'Unbekannt' || count > 0) {
|
||||
result.push({
|
||||
id: country,
|
||||
name: country,
|
||||
color: colors[colorIndex % colors.length],
|
||||
total: contacts.length,
|
||||
completed: count,
|
||||
percentage: Math.round((count / contacts.length) * 100),
|
||||
});
|
||||
colorIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
return result.sort((a, b) => b.completed - a.completed).slice(0, 8);
|
||||
},
|
||||
|
||||
// Total tags count
|
||||
get totalTags() {
|
||||
return tags.length;
|
||||
},
|
||||
};
|
||||
227
apps/contacts/apps/web/src/lib/utils/contact-parser.ts
Normal file
227
apps/contacts/apps/web/src/lib/utils/contact-parser.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* Contact Parser for Contacts App
|
||||
*
|
||||
* Extends the base parser with contact-specific patterns:
|
||||
* - Company: @CompanyName or bei CompanyName
|
||||
* - Email: Recognizes email addresses
|
||||
* - Phone: Recognizes phone numbers
|
||||
* - Name: First and last name extraction
|
||||
*/
|
||||
|
||||
import { extractTags, extractAtReference } from '@manacore/shared-utils';
|
||||
|
||||
export interface ParsedContact {
|
||||
displayName: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
company?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
tagNames: string[];
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ParsedContactWithIds {
|
||||
displayName: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
company?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
tagIds: string[];
|
||||
}
|
||||
|
||||
// Email pattern
|
||||
const EMAIL_PATTERN = /\b([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/;
|
||||
|
||||
// Phone patterns (various formats)
|
||||
const PHONE_PATTERNS: RegExp[] = [
|
||||
// International format: +49 123 456789, +49-123-456789
|
||||
/\+\d{1,3}[-\s]?\d{2,4}[-\s]?\d{3,}[-\s]?\d*/,
|
||||
// German format: 0123 456789, 0123/456789
|
||||
/\b0\d{2,4}[-\s/]?\d{3,}[-\s]?\d*/,
|
||||
// Simple format: 123456789 (at least 6 digits)
|
||||
/\b\d{6,}\b/,
|
||||
];
|
||||
|
||||
// Company patterns (alternative to @company)
|
||||
const COMPANY_PATTERNS: RegExp[] = [
|
||||
/\bbei\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i,
|
||||
/\bvon\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract email from text
|
||||
*/
|
||||
function extractEmail(text: string): { email?: string; remaining: string } {
|
||||
const match = text.match(EMAIL_PATTERN);
|
||||
if (match) {
|
||||
return {
|
||||
email: match[1],
|
||||
remaining: text.replace(EMAIL_PATTERN, '').trim(),
|
||||
};
|
||||
}
|
||||
return { email: undefined, remaining: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract phone number from text
|
||||
*/
|
||||
function extractPhone(text: string): { phone?: string; remaining: string } {
|
||||
for (const pattern of PHONE_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
return {
|
||||
phone: match[0].trim(),
|
||||
remaining: text.replace(pattern, '').trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { phone: undefined, remaining: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract company from text (bei/von patterns)
|
||||
*/
|
||||
function extractCompanyPattern(text: string): { company?: string; remaining: string } {
|
||||
for (const pattern of COMPANY_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
return {
|
||||
company: match[1].trim(),
|
||||
remaining: text.replace(pattern, '').trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { company: undefined, remaining: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract first and last name from display name
|
||||
*/
|
||||
function parseNames(displayName: string): { firstName?: string; lastName?: string } {
|
||||
const parts = displayName.trim().split(/\s+/);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (parts.length === 1) {
|
||||
return { firstName: parts[0] };
|
||||
}
|
||||
|
||||
// First part is first name, rest is last name
|
||||
return {
|
||||
firstName: parts[0],
|
||||
lastName: parts.slice(1).join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse natural language contact input
|
||||
*
|
||||
* Examples:
|
||||
* - "Max Mustermann @ACME Corp max@example.com #kunde #wichtig"
|
||||
* - "Anna Schmidt bei Google +49 123 456789"
|
||||
* - "Peter Müller peter@mail.de #privat"
|
||||
*/
|
||||
export function parseContactInput(input: string): ParsedContact {
|
||||
let text = input.trim();
|
||||
|
||||
// Extract tags first (#tag1 #tag2)
|
||||
const tagsResult = extractTags(text);
|
||||
text = tagsResult.remaining;
|
||||
const tagNames = tagsResult.value || [];
|
||||
|
||||
// Extract company via @CompanyName
|
||||
const atRefResult = extractAtReference(text);
|
||||
text = atRefResult.remaining;
|
||||
let company = atRefResult.value;
|
||||
|
||||
// If no @company, try bei/von patterns
|
||||
if (!company) {
|
||||
const companyPatternResult = extractCompanyPattern(text);
|
||||
text = companyPatternResult.remaining;
|
||||
company = companyPatternResult.company;
|
||||
}
|
||||
|
||||
// Extract email
|
||||
const emailResult = extractEmail(text);
|
||||
text = emailResult.remaining;
|
||||
const email = emailResult.email;
|
||||
|
||||
// Extract phone
|
||||
const phoneResult = extractPhone(text);
|
||||
text = phoneResult.remaining;
|
||||
const phone = phoneResult.phone;
|
||||
|
||||
// Clean up multiple spaces and get display name
|
||||
const displayName = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Parse first and last name
|
||||
const { firstName, lastName } = parseNames(displayName);
|
||||
|
||||
return {
|
||||
displayName,
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
email,
|
||||
phone,
|
||||
tagNames,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve tag names to IDs
|
||||
*/
|
||||
export function resolveContactIds(parsed: ParsedContact, tags: Tag[]): ParsedContactWithIds {
|
||||
const tagIds: string[] = [];
|
||||
|
||||
// Find tags by name (case-insensitive)
|
||||
for (const tagName of parsed.tagNames) {
|
||||
const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase());
|
||||
if (tag) {
|
||||
tagIds.push(tag.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
displayName: parsed.displayName,
|
||||
firstName: parsed.firstName,
|
||||
lastName: parsed.lastName,
|
||||
company: parsed.company,
|
||||
email: parsed.email,
|
||||
phone: parsed.phone,
|
||||
tagIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format parsed contact for preview display
|
||||
*/
|
||||
export function formatParsedContactPreview(parsed: ParsedContact): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (parsed.company) {
|
||||
parts.push(`🏢 ${parsed.company}`);
|
||||
}
|
||||
|
||||
if (parsed.email) {
|
||||
parts.push(`📧 ${parsed.email}`);
|
||||
}
|
||||
|
||||
if (parsed.phone) {
|
||||
parts.push(`📞 ${parsed.phone}`);
|
||||
}
|
||||
|
||||
if (parsed.tagNames.length > 0) {
|
||||
parts.push(`🏷️ ${parsed.tagNames.join(', ')}`);
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
PillDropdownItem,
|
||||
CommandBarItem,
|
||||
QuickAction,
|
||||
CreatePreview,
|
||||
} from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
|
@ -19,6 +20,7 @@
|
|||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import { filterHiddenNavItems } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
|
|
@ -28,13 +30,21 @@
|
|||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { contactsApi } from '$lib/api/contacts';
|
||||
import { contactsApi, tagsApi } from '$lib/api/contacts';
|
||||
import { viewModeStore } from '$lib/stores/view-mode.svelte';
|
||||
import { contactsSettings } from '$lib/stores/settings.svelte';
|
||||
import {
|
||||
parseContactInput,
|
||||
resolveContactIds,
|
||||
formatParsedContactPreview,
|
||||
} from '$lib/utils/contact-parser';
|
||||
|
||||
// Search modal state
|
||||
let searchModalOpen = $state(false);
|
||||
|
||||
// Tags state for Quick-Create
|
||||
let availableTags = $state<{ id: string; name: string }[]>([]);
|
||||
|
||||
// Check if we're on a contact detail route
|
||||
const contactDetailMatch = $derived($page.url.pathname.match(/^\/contacts\/([0-9a-f-]{36})$/i));
|
||||
const showContactModal = $derived(!!contactDetailMatch);
|
||||
|
|
@ -97,19 +107,25 @@
|
|||
// User email for user dropdown (fallback to 'Menü' when not logged in)
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
|
||||
// Navigation items for Contacts
|
||||
const navItems: PillNavItem[] = [
|
||||
// Base navigation items for Contacts
|
||||
const baseNavItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Kontakte', icon: 'users' },
|
||||
{ href: '/tags', label: 'Tags', icon: 'tag' },
|
||||
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
|
||||
{ href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' },
|
||||
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
{ href: '/help', label: 'Hilfe', icon: 'help-circle' },
|
||||
];
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-5)
|
||||
const navRoutes = navItems.map((item) => item.href);
|
||||
// Navigation items filtered by visibility settings
|
||||
const navItems = $derived(
|
||||
filterHiddenNavItems('contacts', baseNavItems, userSettings.nav.hiddenNavItems)
|
||||
);
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-5) - use base items for consistent shortcuts
|
||||
const navRoutes = baseNavItems.map((item) => item.href);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
|
@ -193,6 +209,47 @@
|
|||
goto(`/contacts/${item.id}`);
|
||||
}
|
||||
|
||||
// CommandBar Quick-Create handlers
|
||||
function handleCommandBarParseCreate(query: string): CreatePreview | null {
|
||||
if (!query.trim()) return null;
|
||||
|
||||
const parsed = parseContactInput(query);
|
||||
if (!parsed.displayName) return null;
|
||||
|
||||
return {
|
||||
title: parsed.displayName,
|
||||
subtitle: formatParsedContactPreview(parsed),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCommandBarCreate(query: string): Promise<void> {
|
||||
const parsed = parseContactInput(query);
|
||||
if (!parsed.displayName) return;
|
||||
|
||||
// Resolve tag names to IDs
|
||||
const resolved = resolveContactIds(parsed, availableTags);
|
||||
|
||||
try {
|
||||
const contact = await contactsStore.createContact({
|
||||
displayName: resolved.displayName,
|
||||
firstName: resolved.firstName,
|
||||
lastName: resolved.lastName,
|
||||
company: resolved.company,
|
||||
email: resolved.email,
|
||||
phone: resolved.phone,
|
||||
});
|
||||
|
||||
// Add tags to the created contact
|
||||
if (resolved.tagIds.length > 0 && contact) {
|
||||
for (const tagId of resolved.tagIds) {
|
||||
await tagsApi.addToContact(tagId, contact.id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create contact:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// CommandBar quick actions
|
||||
const commandBarQuickActions: QuickAction[] = [
|
||||
{
|
||||
|
|
@ -214,9 +271,17 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Load user settings
|
||||
// Load user settings and tags
|
||||
await userSettings.load();
|
||||
|
||||
// Load tags for Quick-Create
|
||||
try {
|
||||
const tagsResult = await tagsApi.list();
|
||||
availableTags = (tagsResult.tags || []).map((t) => ({ id: t.id, name: t.name }));
|
||||
} catch (e) {
|
||||
console.error('Failed to load tags:', e);
|
||||
}
|
||||
|
||||
// Initialize contacts settings and view mode
|
||||
contactsSettings.initialize();
|
||||
viewModeStore.initialize();
|
||||
|
|
@ -302,9 +367,13 @@
|
|||
onSearch={handleCommandBarSearch}
|
||||
onSelect={handleCommandBarSelect}
|
||||
quickActions={commandBarQuickActions}
|
||||
placeholder="Kontakt suchen..."
|
||||
placeholder="Kontakt suchen oder erstellen..."
|
||||
emptyText="Keine Kontakte gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCommandBarCreate}
|
||||
onParseCreate={handleCommandBarParseCreate}
|
||||
createText="Als Kontakt erstellen"
|
||||
createShortcut="⌘↵"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
280
apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte
Normal file
280
apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { contactsStatisticsStore } from '$lib/stores/statistics.svelte';
|
||||
import { tagsApi } from '$lib/api/tags';
|
||||
import {
|
||||
StatsGrid,
|
||||
ActivityHeatmap,
|
||||
TrendLineChart,
|
||||
DonutChart,
|
||||
ProgressBars,
|
||||
StatisticsSkeleton,
|
||||
type StatItem,
|
||||
} from '@manacore/shared-ui';
|
||||
import { BarChart3, Users, Star, UserPlus, Cake, Mail, CheckCircle } from 'lucide-svelte';
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
// Update statistics when contacts change
|
||||
$effect(() => {
|
||||
contactsStatisticsStore.setContacts(contactsStore.contacts);
|
||||
});
|
||||
|
||||
// Build stats items for StatsGrid
|
||||
let statsItems = $derived<StatItem[]>([
|
||||
{
|
||||
id: 'total',
|
||||
label: 'Gesamt',
|
||||
value: contactsStatisticsStore.totalContacts,
|
||||
icon: Users,
|
||||
variant: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'favorites',
|
||||
label: 'Favoriten',
|
||||
value: contactsStatisticsStore.favoriteContacts,
|
||||
icon: Star,
|
||||
variant: 'accent',
|
||||
},
|
||||
{
|
||||
id: 'recentlyAdded',
|
||||
label: 'Neu (7 Tage)',
|
||||
value: contactsStatisticsStore.recentlyAdded,
|
||||
icon: UserPlus,
|
||||
variant: 'success',
|
||||
},
|
||||
{
|
||||
id: 'birthdays',
|
||||
label: 'Geburtstage',
|
||||
value: contactsStatisticsStore.birthdaysThisMonth,
|
||||
icon: Cake,
|
||||
variant: 'info',
|
||||
},
|
||||
{
|
||||
id: 'withEmail',
|
||||
label: 'Mit E-Mail',
|
||||
value: contactsStatisticsStore.contactsWithEmail,
|
||||
icon: Mail,
|
||||
variant: 'neutral',
|
||||
},
|
||||
{
|
||||
id: 'completeness',
|
||||
label: 'Vollständigkeit',
|
||||
value: `${contactsStatisticsStore.completenessRate}%`,
|
||||
icon: CheckCircle,
|
||||
variant: contactsStatisticsStore.completenessRate >= 70 ? 'success' : 'danger',
|
||||
},
|
||||
]);
|
||||
|
||||
onMount(async () => {
|
||||
// Fetch all contacts (without filters for statistics)
|
||||
await contactsStore.loadContacts({ isArchived: false });
|
||||
|
||||
// Also load archived for complete statistics
|
||||
const allContacts = [...contactsStore.contacts];
|
||||
|
||||
// Fetch tags
|
||||
try {
|
||||
const tagsResult = await tagsApi.list();
|
||||
contactsStatisticsStore.setTags(tagsResult);
|
||||
} catch (e) {
|
||||
console.error('Failed to load tags:', e);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Statistiken - Kontakte</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="statistics-page">
|
||||
<header class="page-header">
|
||||
<div class="header-icon">
|
||||
<BarChart3 size={28} />
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1>Statistiken</h1>
|
||||
<p class="header-subtitle">Deine Kontakte im Überblick</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<StatisticsSkeleton statCards={6} legendItems={4} />
|
||||
{:else}
|
||||
<!-- Quick Stats -->
|
||||
<section class="stats-section">
|
||||
<StatsGrid items={statsItems} columns={6} />
|
||||
</section>
|
||||
|
||||
<!-- Charts Grid -->
|
||||
<div class="charts-grid">
|
||||
<!-- Activity Heatmap -->
|
||||
<section class="chart-section heatmap-section">
|
||||
<ActivityHeatmap
|
||||
data={contactsStatisticsStore.activityHeatmap}
|
||||
itemName="Kontakt"
|
||||
itemNamePlural="Kontakte"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Weekly Trend + Status Donut -->
|
||||
<div class="charts-row">
|
||||
<section class="chart-section trend-section">
|
||||
<TrendLineChart
|
||||
data={contactsStatisticsStore.weeklyTrend}
|
||||
itemName="Kontakt"
|
||||
itemNamePlural="Kontakte"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="chart-section donut-section">
|
||||
<DonutChart
|
||||
data={contactsStatisticsStore.statusBreakdown}
|
||||
title="Status"
|
||||
centerLabel="Kontakte"
|
||||
centerValue={contactsStatisticsStore.totalContacts}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Info Completeness -->
|
||||
<div class="charts-row">
|
||||
<section class="chart-section info-section">
|
||||
<DonutChart
|
||||
data={contactsStatisticsStore.infoBreakdown}
|
||||
title="Informationen"
|
||||
centerLabel="Kontakte"
|
||||
centerValue={contactsStatisticsStore.totalContacts}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="chart-section country-section">
|
||||
<ProgressBars
|
||||
data={contactsStatisticsStore.countryBreakdown}
|
||||
title="Nach Land"
|
||||
emptyMessage="Keine Länder angegeben"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
<div class="additional-stats">
|
||||
<div class="stat-card-small">
|
||||
<span class="stat-label">Aktive Kontakte</span>
|
||||
<span class="stat-value">{contactsStatisticsStore.activeContacts}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-card-small">
|
||||
<span class="stat-label">Archivierte Kontakte</span>
|
||||
<span class="stat-value">{contactsStatisticsStore.archivedContacts}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-card-small">
|
||||
<span class="stat-label">Tags</span>
|
||||
<span class="stat-value">{contactsStatisticsStore.totalTags}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.statistics-page {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: hsl(var(--primary) / 0.15);
|
||||
color: hsl(var(--primary));
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0.25rem 0 0 0;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.charts-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.additional-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card-small {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
:global(.dark) .stat-card-small {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.stat-card-small .stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.stat-card-small .stat-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import { filterHiddenNavItems } from '@manacore/shared-theme';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
|
|
@ -33,13 +34,18 @@
|
|||
// Get theme state
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
// Navigation items for ManaDeck (Mana and Profile are in user dropdown)
|
||||
const navItems: PillNavItem[] = [
|
||||
// Base navigation items for ManaDeck (Mana and Profile are in user dropdown)
|
||||
const baseNavItems: PillNavItem[] = [
|
||||
{ href: '/decks', label: 'Decks', icon: 'archive' },
|
||||
{ href: '/explore', label: 'Explore', icon: 'search' },
|
||||
{ href: '/progress', label: 'Progress', icon: 'chart' },
|
||||
];
|
||||
|
||||
// Navigation items filtered by visibility settings
|
||||
const navItems = $derived(
|
||||
filterHiddenNavItems('manadeck', baseNavItems, userSettings.nav.hiddenNavItems)
|
||||
);
|
||||
|
||||
// Get pinned themes from user settings (extended themes only)
|
||||
let pinnedThemes = $derived<ThemeVariant[]>(
|
||||
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import { filterHiddenNavItems } from '@manacore/shared-theme';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
|
|
@ -93,8 +94,8 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Navigation items (Mana is in user dropdown via manaHref)
|
||||
const navItems: PillNavItem[] = [
|
||||
// Base navigation items (Mana is in user dropdown via manaHref)
|
||||
const baseNavItems: PillNavItem[] = [
|
||||
{ href: '/app/gallery', label: 'Galerie', icon: 'home' },
|
||||
{ href: '/app/board', label: 'Moodboards', icon: 'grid' },
|
||||
{ href: '/app/explore', label: 'Entdecken', icon: 'search' },
|
||||
|
|
@ -104,6 +105,11 @@
|
|||
{ href: '/app/archive', label: 'Archiv', icon: 'archive' },
|
||||
];
|
||||
|
||||
// Navigation items filtered by visibility settings
|
||||
const navItems = $derived(
|
||||
filterHiddenNavItems('picture', baseNavItems, userSettings.nav.hiddenNavItems)
|
||||
);
|
||||
|
||||
// View mode options for tab group
|
||||
const viewModeOptions = [
|
||||
{ id: 'single', icon: 'list', title: 'Liste (1)' },
|
||||
|
|
|
|||
16
apps/todo/apps/backend/jest.config.js
Normal file
16
apps/todo/apps/backend/jest.config.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
coverageDirectory: '../coverage',
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'^@todo/shared$': '<rootDir>/../../packages/shared/src',
|
||||
'^@manacore/shared-nestjs-auth$': '<rootDir>/../../../../../packages/shared-nestjs-auth/src',
|
||||
},
|
||||
};
|
||||
|
|
@ -9,33 +9,41 @@
|
|||
"start": "nest start",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@todo/shared": "workspace:*",
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@nestjs/common": "^10.4.9",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.9",
|
||||
"@nestjs/platform-express": "^10.4.9",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@todo/shared": "workspace:*",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rrule": "^2.8.1",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^11.1.9",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.15.21",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"jest": "^30.2.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
import { IsString, IsOptional, MaxLength, MinLength, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class CreateLabelDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsNotEmpty({ message: 'Name darf nicht leer sein' })
|
||||
@MinLength(1, { message: 'Name muss mindestens 1 Zeichen haben' })
|
||||
@MaxLength(100, { message: 'Name darf maximal 100 Zeichen haben' })
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { tasks, labels, taskLabels, projects } from '../db/schema';
|
||||
|
|
@ -54,21 +54,32 @@ export class NetworkService {
|
|||
|
||||
const projectMap = new Map(userProjects.map((p) => [p.id, p.name]));
|
||||
|
||||
// 3. Get labels for each task
|
||||
// 3. Get all labels for all tasks in a single batch query (fix N+1)
|
||||
const taskIds = userTasks.map(({ task }) => task.id);
|
||||
const taskLabelsMap = new Map<string, { id: string; name: string; color: string | null }[]>();
|
||||
|
||||
for (const { task } of userTasks) {
|
||||
const taskLabelRows = await this.db
|
||||
if (taskIds.length > 0) {
|
||||
const allTaskLabels = await this.db
|
||||
.select({
|
||||
id: labels.id,
|
||||
name: labels.name,
|
||||
color: labels.color,
|
||||
taskId: taskLabels.taskId,
|
||||
labelId: labels.id,
|
||||
labelName: labels.name,
|
||||
labelColor: labels.color,
|
||||
})
|
||||
.from(taskLabels)
|
||||
.innerJoin(labels, eq(taskLabels.labelId, labels.id))
|
||||
.where(eq(taskLabels.taskId, task.id));
|
||||
.where(inArray(taskLabels.taskId, taskIds));
|
||||
|
||||
taskLabelsMap.set(task.id, taskLabelRows);
|
||||
// Group labels by taskId
|
||||
for (const row of allTaskLabels) {
|
||||
const existing = taskLabelsMap.get(row.taskId) || [];
|
||||
existing.push({
|
||||
id: row.labelId,
|
||||
name: row.labelName,
|
||||
color: row.labelColor,
|
||||
});
|
||||
taskLabelsMap.set(row.taskId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Filter tasks that have at least one label
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
import { IsString, IsOptional, IsBoolean, MaxLength, IsObject } from 'class-validator';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
IsObject,
|
||||
IsNotEmpty,
|
||||
} from 'class-validator';
|
||||
import type { ProjectSettings } from '../../db/schema/projects.schema';
|
||||
|
||||
export class CreateProjectDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsNotEmpty({ message: 'Name darf nicht leer sein' })
|
||||
@MinLength(1, { message: 'Name muss mindestens 1 Zeichen haben' })
|
||||
@MaxLength(255, { message: 'Name darf maximal 255 Zeichen haben' })
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
|
|
|
|||
515
apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts
Normal file
515
apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { TaskService } from '../task.service';
|
||||
import { ProjectService } from '../../project/project.service';
|
||||
import { DATABASE_CONNECTION } from '../../db/database.module';
|
||||
|
||||
// Mock database
|
||||
const mockSelectFrom = jest.fn().mockReturnThis();
|
||||
const mockSelectWhere = jest.fn();
|
||||
|
||||
const mockDb = {
|
||||
query: {
|
||||
tasks: {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
taskLabels: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
labels: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
select: jest.fn().mockReturnValue({
|
||||
from: mockSelectFrom,
|
||||
where: mockSelectWhere,
|
||||
}),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock ProjectService
|
||||
const mockProjectService = {
|
||||
findByIdOrThrow: jest.fn(),
|
||||
};
|
||||
|
||||
describe('TaskService', () => {
|
||||
let service: TaskService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TaskService,
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useValue: mockDb,
|
||||
},
|
||||
{
|
||||
provide: ProjectService,
|
||||
useValue: mockProjectService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TaskService>(TaskService);
|
||||
|
||||
// Reset all mocks before each test
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return all tasks for a user', async () => {
|
||||
const userId = 'user-123';
|
||||
const mockTasks = [
|
||||
{ id: 'task-1', title: 'Task 1', userId },
|
||||
{ id: 'task-2', title: 'Task 2', userId },
|
||||
];
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.findAll(userId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].labels).toEqual([]);
|
||||
expect(result[1].labels).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter by projectId when provided', async () => {
|
||||
const userId = 'user-123';
|
||||
const projectId = 'project-1';
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue([]);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll(userId, { projectId });
|
||||
|
||||
expect(mockDb.query.tasks.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should filter by priority when provided', async () => {
|
||||
const userId = 'user-123';
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue([]);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll(userId, { priority: 'high' });
|
||||
|
||||
expect(mockDb.query.tasks.findMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return a task when found', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const mockTask = { id: taskId, title: 'Test Task', userId };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(mockTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.findById(taskId, userId);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe(taskId);
|
||||
expect(result?.labels).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return null when task not found', async () => {
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findById('non-existent', 'user-123');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIdOrThrow', () => {
|
||||
it('should return a task when found', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const mockTask = { id: taskId, title: 'Test Task', userId };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(mockTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.findByIdOrThrow(taskId, userId);
|
||||
|
||||
expect(result.id).toBe(taskId);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when task not found', async () => {
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findByIdOrThrow('non-existent', 'user-123')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a task with basic fields', async () => {
|
||||
const userId = 'user-123';
|
||||
const dto = { title: 'New Task' };
|
||||
const createdTask = { id: 'task-new', title: 'New Task', userId, order: 0 };
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue([]);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([createdTask]);
|
||||
|
||||
const result = await service.create(userId, dto);
|
||||
|
||||
expect(result.title).toBe('New Task');
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should verify project belongs to user when projectId is provided', async () => {
|
||||
const userId = 'user-123';
|
||||
const projectId = 'project-1';
|
||||
const dto = { title: 'New Task', projectId };
|
||||
const createdTask = { id: 'task-new', title: 'New Task', userId, projectId, order: 0 };
|
||||
|
||||
mockProjectService.findByIdOrThrow.mockResolvedValue({ id: projectId, userId });
|
||||
mockDb.query.tasks.findMany.mockResolvedValue([]);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([createdTask]);
|
||||
|
||||
await service.create(userId, dto);
|
||||
|
||||
expect(mockProjectService.findByIdOrThrow).toHaveBeenCalledWith(projectId, userId);
|
||||
});
|
||||
|
||||
it('should calculate order based on existing tasks', async () => {
|
||||
const userId = 'user-123';
|
||||
const dto = { title: 'New Task' };
|
||||
const existingTasks = [
|
||||
{ id: 'task-1', order: 0 },
|
||||
{ id: 'task-2', order: 1 },
|
||||
{ id: 'task-3', order: 2 },
|
||||
];
|
||||
const createdTask = { id: 'task-new', title: 'New Task', userId, order: 3 };
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(existingTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([createdTask]);
|
||||
|
||||
const result = await service.create(userId, dto);
|
||||
|
||||
expect(result.order).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update a task', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const dto = { title: 'Updated Title' };
|
||||
const existingTask = { id: taskId, title: 'Original', userId };
|
||||
const updatedTask = { id: taskId, title: 'Updated Title', userId };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([updatedTask]);
|
||||
|
||||
const result = await service.update(taskId, userId, dto);
|
||||
|
||||
expect(result.title).toBe('Updated Title');
|
||||
});
|
||||
|
||||
it('should throw when task does not exist', async () => {
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('non-existent', 'user-123', { title: 'Test' })).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a task', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const existingTask = { id: taskId, userId };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.delete(taskId, userId);
|
||||
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when task does not exist', async () => {
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete('non-existent', 'user-123')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete', () => {
|
||||
it('should mark a task as completed', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const existingTask = { id: taskId, title: 'Test', userId, recurrenceRule: null };
|
||||
const completedTask = { ...existingTask, isCompleted: true, status: 'completed' };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([completedTask]);
|
||||
|
||||
const result = await service.complete(taskId, userId);
|
||||
|
||||
expect(result.isCompleted).toBe(true);
|
||||
expect(result.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('should create next occurrence for recurring task', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const existingTask = {
|
||||
id: taskId,
|
||||
title: 'Daily Task',
|
||||
userId,
|
||||
recurrenceRule: 'FREQ=DAILY',
|
||||
dueDate: new Date(),
|
||||
labels: [],
|
||||
};
|
||||
|
||||
const completedTask = {
|
||||
...existingTask,
|
||||
isCompleted: true,
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
lastOccurrence: new Date(),
|
||||
};
|
||||
|
||||
const newTask = {
|
||||
id: 'task-new',
|
||||
title: 'Daily Task',
|
||||
userId,
|
||||
recurrenceRule: 'FREQ=DAILY',
|
||||
dueDate: tomorrow,
|
||||
isCompleted: false,
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
// First call for findByIdOrThrow
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
// For completing the task
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([newTask]) // For creating new occurrence
|
||||
.mockResolvedValueOnce([completedTask]); // For completing original
|
||||
|
||||
const result = await service.complete(taskId, userId);
|
||||
|
||||
expect(result.isCompleted).toBe(true);
|
||||
// Verify that a new task was created
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uncomplete', () => {
|
||||
it('should mark a task as not completed', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const existingTask = { id: taskId, title: 'Test', userId, isCompleted: true };
|
||||
const uncompletedTask = { ...existingTask, isCompleted: false, status: 'pending' };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([uncompletedTask]);
|
||||
|
||||
const result = await service.uncomplete(taskId, userId);
|
||||
|
||||
expect(result.isCompleted).toBe(false);
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('move', () => {
|
||||
it('should move a task to a different project', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const newProjectId = 'project-2';
|
||||
const existingTask = { id: taskId, title: 'Test', userId, projectId: 'project-1' };
|
||||
const movedTask = { ...existingTask, projectId: newProjectId };
|
||||
|
||||
mockProjectService.findByIdOrThrow.mockResolvedValue({ id: newProjectId, userId });
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.tasks.findMany.mockResolvedValue([]);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([movedTask]);
|
||||
|
||||
const result = await service.move(taskId, userId, newProjectId);
|
||||
|
||||
expect(result.projectId).toBe(newProjectId);
|
||||
expect(mockProjectService.findByIdOrThrow).toHaveBeenCalledWith(newProjectId, userId);
|
||||
});
|
||||
|
||||
it('should move a task to inbox (null project)', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const existingTask = { id: taskId, title: 'Test', userId, projectId: 'project-1' };
|
||||
const movedTask = { ...existingTask, projectId: null };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.tasks.findMany.mockResolvedValue([]);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([movedTask]);
|
||||
|
||||
const result = await service.move(taskId, userId, null);
|
||||
|
||||
expect(result.projectId).toBeNull();
|
||||
expect(mockProjectService.findByIdOrThrow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInboxTasks', () => {
|
||||
it('should return incomplete tasks', async () => {
|
||||
const userId = 'user-123';
|
||||
const mockTasks = [
|
||||
{ id: 'task-1', title: 'Task 1', userId, isCompleted: false },
|
||||
{ id: 'task-2', title: 'Task 2', userId, isCompleted: false },
|
||||
];
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getInboxTasks(userId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((t) => t.isCompleted === false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTodayTasks', () => {
|
||||
it('should return tasks due today', async () => {
|
||||
const userId = 'user-123';
|
||||
const today = new Date();
|
||||
const mockTasks = [{ id: 'task-1', title: 'Today Task', userId, dueDate: today }];
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getTodayTasks(userId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCompletedTasks', () => {
|
||||
it('should return completed tasks with pagination info', async () => {
|
||||
const userId = 'user-123';
|
||||
const mockTasks = Array(50)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
id: `task-${i}`,
|
||||
title: `Task ${i}`,
|
||||
userId,
|
||||
isCompleted: true,
|
||||
}));
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockSelectWhere.mockResolvedValue([{ count: 75 }]);
|
||||
|
||||
const result = await service.getCompletedTasks(userId);
|
||||
|
||||
expect(result.tasks).toHaveLength(50);
|
||||
expect(result.total).toBe(75);
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect custom limit and offset', async () => {
|
||||
const userId = 'user-123';
|
||||
const mockTasks = Array(10)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
id: `task-${i}`,
|
||||
title: `Task ${i}`,
|
||||
userId,
|
||||
isCompleted: true,
|
||||
}));
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockSelectWhere.mockResolvedValue([{ count: 25 }]);
|
||||
|
||||
const result = await service.getCompletedTasks(userId, 10, 10);
|
||||
|
||||
expect(result.tasks).toHaveLength(10);
|
||||
expect(result.total).toBe(25);
|
||||
expect(result.hasMore).toBe(true); // offset 10 + limit 10 = 20 < 25
|
||||
});
|
||||
|
||||
it('should enforce max limit of 100', async () => {
|
||||
const userId = 'user-123';
|
||||
const mockTasks = Array(100)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
id: `task-${i}`,
|
||||
title: `Task ${i}`,
|
||||
userId,
|
||||
isCompleted: true,
|
||||
}));
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockSelectWhere.mockResolvedValue([{ count: 200 }]);
|
||||
|
||||
// Request 500 tasks, should be capped at 100
|
||||
const result = await service.getCompletedTasks(userId, 500, 0);
|
||||
|
||||
expect(result.tasks).toHaveLength(100);
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTaskLabelsBatch', () => {
|
||||
it('should batch load labels for multiple tasks', async () => {
|
||||
const userId = 'user-123';
|
||||
const mockTasks = [
|
||||
{ id: 'task-1', title: 'Task 1', userId },
|
||||
{ id: 'task-2', title: 'Task 2', userId },
|
||||
];
|
||||
|
||||
const mockTaskLabels = [
|
||||
{ taskId: 'task-1', labelId: 'label-1' },
|
||||
{ taskId: 'task-1', labelId: 'label-2' },
|
||||
{ taskId: 'task-2', labelId: 'label-1' },
|
||||
];
|
||||
|
||||
const mockLabels = [
|
||||
{ id: 'label-1', name: 'Important', color: '#ff0000' },
|
||||
{ id: 'label-2', name: 'Work', color: '#0000ff' },
|
||||
];
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue(mockTaskLabels);
|
||||
mockDb.query.labels.findMany.mockResolvedValue(mockLabels);
|
||||
|
||||
const result = await service.findAll(userId);
|
||||
|
||||
expect(result[0].labels).toHaveLength(2);
|
||||
expect(result[1].labels).toHaveLength(1);
|
||||
// Should only make 2 queries for labels (taskLabels + labels), not N+1
|
||||
expect(mockDb.query.taskLabels.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(mockDb.query.labels.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -4,15 +4,22 @@ import {
|
|||
IsUUID,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
IsObject,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import type { TaskPriority, Subtask, TaskMetadata } from '../../db/schema/tasks.schema';
|
||||
import { Type } from 'class-transformer';
|
||||
import type { TaskPriority } from '../../db/schema/tasks.schema';
|
||||
import { CreateSubtaskDto } from './subtask.dto';
|
||||
import { TaskMetadataDto } from './metadata.dto';
|
||||
|
||||
export class CreateTaskDto {
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
@IsNotEmpty({ message: 'Titel darf nicht leer sein' })
|
||||
@MinLength(1, { message: 'Titel muss mindestens 1 Zeichen haben' })
|
||||
@MaxLength(500, { message: 'Titel darf maximal 500 Zeichen haben' })
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
|
|
@ -54,7 +61,9 @@ export class CreateTaskDto {
|
|||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
subtasks?: Omit<Subtask, 'id'>[];
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateSubtaskDto)
|
||||
subtasks?: CreateSubtaskDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
|
|
@ -62,6 +71,7 @@ export class CreateTaskDto {
|
|||
labelIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: TaskMetadata;
|
||||
@ValidateNested()
|
||||
@Type(() => TaskMetadataDto)
|
||||
metadata?: TaskMetadataDto;
|
||||
}
|
||||
|
|
|
|||
58
apps/todo/apps/backend/src/task/dto/metadata.dto.ts
Normal file
58
apps/todo/apps/backend/src/task/dto/metadata.dto.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsArray,
|
||||
IsUUID,
|
||||
IsEnum,
|
||||
Min,
|
||||
Max,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class EffectiveDurationDto {
|
||||
@IsNumber()
|
||||
@Min(1, { message: 'Dauer muss mindestens 1 sein' })
|
||||
@Max(9999, { message: 'Dauer darf maximal 9999 sein' })
|
||||
value: number;
|
||||
|
||||
@IsEnum(['minutes', 'hours', 'days'], { message: 'Ungültige Zeiteinheit' })
|
||||
unit: 'minutes' | 'hours' | 'days';
|
||||
}
|
||||
|
||||
export class TaskMetadataDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10000, { message: 'Notizen dürfen maximal 10000 Zeichen haben' })
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@MaxLength(500, { each: true })
|
||||
attachments?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
linkedCalendarEventId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@IsEnum([1, 2, 3, 5, 8, 13, 21], {
|
||||
message: 'Storypoints müssen Fibonacci-Zahlen sein (1,2,3,5,8,13,21)',
|
||||
})
|
||||
storyPoints?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => EffectiveDurationDto)
|
||||
effectiveDuration?: EffectiveDurationDto | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1, { message: 'Spaß-Faktor muss mindestens 1 sein' })
|
||||
@Max(10, { message: 'Spaß-Faktor darf maximal 10 sein' })
|
||||
funRating?: number | null;
|
||||
}
|
||||
48
apps/todo/apps/backend/src/task/dto/subtask.dto.ts
Normal file
48
apps/todo/apps/backend/src/task/dto/subtask.dto.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
IsString,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Min,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
|
||||
export class SubtaskDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'Subtask-Titel darf nicht leer sein' })
|
||||
@MaxLength(500, { message: 'Subtask-Titel darf maximal 500 Zeichen haben' })
|
||||
title: string;
|
||||
|
||||
@IsBoolean()
|
||||
isCompleted: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
completedAt?: string | null;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
order: number;
|
||||
}
|
||||
|
||||
export class CreateSubtaskDto {
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'Subtask-Titel darf nicht leer sein' })
|
||||
@MaxLength(500, { message: 'Subtask-Titel darf maximal 500 Zeichen haben' })
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isCompleted?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
order?: number;
|
||||
}
|
||||
|
|
@ -33,9 +33,13 @@ export class TaskController {
|
|||
}
|
||||
|
||||
@Get('completed')
|
||||
async getCompleted(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) {
|
||||
const tasks = await this.taskService.getCompletedTasks(user.userId, limit ?? 50);
|
||||
return { tasks };
|
||||
async getCompleted(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('offset') offset?: number
|
||||
) {
|
||||
const result = await this.taskService.getCompletedTasks(user.userId, limit ?? 50, offset ?? 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL } from 'drizzle-orm';
|
||||
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL, sql } from 'drizzle-orm';
|
||||
import { RRule, RRuleSet, rrulestr } from 'rrule';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { tasks, taskLabels, labels, type Task, type NewTask, type Subtask } from '../db/schema';
|
||||
import { ProjectService } from '../project/project.service';
|
||||
import { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from './dto';
|
||||
|
||||
// Extended Task type that includes labels (populated after loading from DB)
|
||||
type TaskWithLabels = Task & { labels: (typeof labels.$inferSelect)[] };
|
||||
|
||||
@Injectable()
|
||||
export class TaskService {
|
||||
constructor(
|
||||
|
|
@ -13,7 +17,7 @@ export class TaskService {
|
|||
private projectService: ProjectService
|
||||
) {}
|
||||
|
||||
async findAll(userId: string, query: QueryTasksDto = {}): Promise<Task[]> {
|
||||
async findAll(userId: string, query: QueryTasksDto = {}): Promise<TaskWithLabels[]> {
|
||||
const conditions: SQL[] = [eq(tasks.userId, userId)];
|
||||
|
||||
if (query.projectId) {
|
||||
|
|
@ -73,11 +77,11 @@ export class TaskService {
|
|||
offset: query.offset,
|
||||
});
|
||||
|
||||
// Load labels for each task
|
||||
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
|
||||
// Batch load labels for all tasks (2 queries instead of N+1)
|
||||
return this.loadTaskLabelsBatch(result);
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Task | null> {
|
||||
async findById(id: string, userId: string): Promise<TaskWithLabels | null> {
|
||||
const result = await this.db.query.tasks.findFirst({
|
||||
where: and(eq(tasks.id, id), eq(tasks.userId, userId)),
|
||||
});
|
||||
|
|
@ -86,7 +90,7 @@ export class TaskService {
|
|||
return this.loadTaskLabels(result);
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<Task> {
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<TaskWithLabels> {
|
||||
const task = await this.findById(id, userId);
|
||||
if (!task) {
|
||||
throw new NotFoundException(`Task with id ${id} not found`);
|
||||
|
|
@ -94,7 +98,7 @@ export class TaskService {
|
|||
return task;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateTaskDto): Promise<Task> {
|
||||
async create(userId: string, dto: CreateTaskDto): Promise<TaskWithLabels> {
|
||||
// Verify project belongs to user if provided
|
||||
if (dto.projectId) {
|
||||
await this.projectService.findByIdOrThrow(dto.projectId, userId);
|
||||
|
|
@ -139,7 +143,7 @@ export class TaskService {
|
|||
return this.loadTaskLabels(created);
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateTaskDto): Promise<Task> {
|
||||
async update(id: string, userId: string, dto: UpdateTaskDto): Promise<TaskWithLabels> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
|
||||
// Verify project belongs to user if changing project
|
||||
|
|
@ -185,13 +189,28 @@ export class TaskService {
|
|||
await this.db.delete(tasks).where(and(eq(tasks.id, id), eq(tasks.userId, userId)));
|
||||
}
|
||||
|
||||
async complete(id: string, userId: string): Promise<Task> {
|
||||
async complete(id: string, userId: string): Promise<TaskWithLabels> {
|
||||
const task = await this.findByIdOrThrow(id, userId);
|
||||
|
||||
// If task has recurrence, create next occurrence instead of completing
|
||||
if (task.recurrenceRule) {
|
||||
// TODO: Implement recurrence handling
|
||||
// For now, just mark as complete
|
||||
const nextOccurrence = await this.createNextOccurrence(task, userId);
|
||||
if (nextOccurrence) {
|
||||
// Mark current task as completed and update lastOccurrence
|
||||
const [completed] = await this.db
|
||||
.update(tasks)
|
||||
.set({
|
||||
isCompleted: true,
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
lastOccurrence: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return this.loadTaskLabels(completed);
|
||||
}
|
||||
}
|
||||
|
||||
return this.update(id, userId, {
|
||||
|
|
@ -200,14 +219,155 @@ export class TaskService {
|
|||
});
|
||||
}
|
||||
|
||||
async uncomplete(id: string, userId: string): Promise<Task> {
|
||||
/**
|
||||
* Validates an RRULE string to prevent abuse (DoS, excessive occurrences).
|
||||
* Returns true if valid, false if invalid or too complex.
|
||||
*/
|
||||
private validateRRule(rruleString: string): boolean {
|
||||
// Basic length check
|
||||
if (!rruleString || rruleString.length > 500) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const rule = rrulestr(rruleString);
|
||||
|
||||
// Get occurrences for the next 10 years with a limit
|
||||
// Daily tasks = ~3650/10yrs, hourly would be ~87600 (reject)
|
||||
const maxOccurrences = 5000;
|
||||
const tenYearsFromNow = new Date();
|
||||
tenYearsFromNow.setFullYear(tenYearsFromNow.getFullYear() + 10);
|
||||
|
||||
const occurrences = rule.between(new Date(), tenYearsFromNow, true, (_, count) => {
|
||||
// Stop iteration early if we exceed limit
|
||||
return count < maxOccurrences;
|
||||
});
|
||||
|
||||
// Reject if too many occurrences (prevents hourly/minutely abuse)
|
||||
if (occurrences.length >= maxOccurrences) {
|
||||
console.warn(`RRULE rejected: too many occurrences (${occurrences.length})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the next occurrence of a recurring task based on its RRULE.
|
||||
* Returns the newly created task, or null if no more occurrences should be created.
|
||||
*/
|
||||
private async createNextOccurrence(
|
||||
task: TaskWithLabels,
|
||||
userId: string
|
||||
): Promise<TaskWithLabels | null> {
|
||||
if (!task.recurrenceRule) return null;
|
||||
|
||||
// Validate RRULE complexity before parsing
|
||||
if (!this.validateRRule(task.recurrenceRule)) {
|
||||
console.warn(`Invalid or too complex RRULE for task ${task.id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the RRULE string
|
||||
const rule = rrulestr(task.recurrenceRule);
|
||||
const now = new Date();
|
||||
|
||||
// Get the next occurrence after now
|
||||
const nextDate = rule.after(now, false);
|
||||
|
||||
// Check if we've exceeded the recurrence end date
|
||||
if (task.recurrenceEndDate) {
|
||||
const endDate = new Date(task.recurrenceEndDate);
|
||||
if (!nextDate || nextDate > endDate) {
|
||||
return null; // No more occurrences
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextDate) {
|
||||
return null; // No more occurrences according to RRULE
|
||||
}
|
||||
|
||||
// Reset subtasks (mark all as incomplete)
|
||||
const resetSubtasks: Subtask[] | undefined = task.subtasks?.map((s) => ({
|
||||
...s,
|
||||
isCompleted: false,
|
||||
completedAt: null,
|
||||
}));
|
||||
|
||||
// Create new task for the next occurrence
|
||||
const newTask: NewTask = {
|
||||
userId,
|
||||
projectId: task.projectId,
|
||||
parentTaskId: task.parentTaskId,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
dueDate: nextDate,
|
||||
dueTime: task.dueTime,
|
||||
startDate: task.startDate
|
||||
? this.calculateNextStartDate(task.startDate, task.dueDate, nextDate)
|
||||
: null,
|
||||
priority: task.priority ?? 'medium',
|
||||
status: 'pending',
|
||||
isCompleted: false,
|
||||
recurrenceRule: task.recurrenceRule,
|
||||
recurrenceEndDate: task.recurrenceEndDate,
|
||||
subtasks: resetSubtasks,
|
||||
metadata: task.metadata,
|
||||
order: task.order,
|
||||
columnId: task.columnId,
|
||||
columnOrder: task.columnOrder,
|
||||
};
|
||||
|
||||
const [created] = await this.db.insert(tasks).values(newTask).returning();
|
||||
|
||||
// Copy labels from original task
|
||||
if (task.labels && task.labels.length > 0) {
|
||||
await this.db.insert(taskLabels).values(
|
||||
task.labels.map((label) => ({
|
||||
taskId: created.id,
|
||||
labelId: label.id,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return this.loadTaskLabels(created);
|
||||
} catch (error) {
|
||||
// If RRULE parsing fails, log and return null
|
||||
console.error('Failed to parse recurrence rule:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the new start date based on the offset between original start and due dates.
|
||||
*/
|
||||
private calculateNextStartDate(
|
||||
originalStartDate: Date | string | null,
|
||||
originalDueDate: Date | string | null,
|
||||
nextDueDate: Date
|
||||
): Date | null {
|
||||
if (!originalStartDate || !originalDueDate) return null;
|
||||
|
||||
const start = new Date(originalStartDate);
|
||||
const due = new Date(originalDueDate);
|
||||
const diffMs = due.getTime() - start.getTime();
|
||||
|
||||
// New start date maintains the same offset from the new due date
|
||||
return new Date(nextDueDate.getTime() - diffMs);
|
||||
}
|
||||
|
||||
async uncomplete(id: string, userId: string): Promise<TaskWithLabels> {
|
||||
return this.update(id, userId, {
|
||||
isCompleted: false,
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
|
||||
async move(id: string, userId: string, projectId: string | null): Promise<Task> {
|
||||
async move(id: string, userId: string, projectId: string | null): Promise<TaskWithLabels> {
|
||||
// Verify new project if provided
|
||||
if (projectId) {
|
||||
await this.projectService.findByIdOrThrow(projectId, userId);
|
||||
|
|
@ -247,11 +407,11 @@ export class TaskService {
|
|||
}
|
||||
}
|
||||
|
||||
async getInboxTasks(userId: string): Promise<Task[]> {
|
||||
async getInboxTasks(userId: string): Promise<TaskWithLabels[]> {
|
||||
return this.findAll(userId, { isCompleted: false });
|
||||
}
|
||||
|
||||
async getTodayTasks(userId: string): Promise<Task[]> {
|
||||
async getTodayTasks(userId: string): Promise<TaskWithLabels[]> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
|
|
@ -270,10 +430,10 @@ export class TaskService {
|
|||
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
|
||||
});
|
||||
|
||||
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
|
||||
return this.loadTaskLabelsBatch(result);
|
||||
}
|
||||
|
||||
async getUpcomingTasks(userId: string, days: number = 7): Promise<Task[]> {
|
||||
async getUpcomingTasks(userId: string, days: number = 7): Promise<TaskWithLabels[]> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const endDate = new Date(today);
|
||||
|
|
@ -289,20 +449,45 @@ export class TaskService {
|
|||
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
|
||||
});
|
||||
|
||||
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
|
||||
return this.loadTaskLabelsBatch(result);
|
||||
}
|
||||
|
||||
async getCompletedTasks(userId: string, limit: number = 50): Promise<Task[]> {
|
||||
const result = await this.db.query.tasks.findMany({
|
||||
where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)),
|
||||
orderBy: [desc(tasks.completedAt)],
|
||||
limit,
|
||||
});
|
||||
async getCompletedTasks(
|
||||
userId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<{ tasks: TaskWithLabels[]; total: number; hasMore: boolean }> {
|
||||
// Enforce max limit to prevent abuse
|
||||
const safeLimit = Math.min(limit, 100);
|
||||
|
||||
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
|
||||
const [result, countResult] = await Promise.all([
|
||||
this.db.query.tasks.findMany({
|
||||
where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)),
|
||||
orderBy: [desc(tasks.completedAt)],
|
||||
limit: safeLimit,
|
||||
offset,
|
||||
}),
|
||||
this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.userId, userId), eq(tasks.isCompleted, true))),
|
||||
]);
|
||||
|
||||
const total = countResult[0]?.count ?? 0;
|
||||
const tasksWithLabels = await this.loadTaskLabelsBatch(result);
|
||||
|
||||
return {
|
||||
tasks: tasksWithLabels,
|
||||
total,
|
||||
hasMore: offset + safeLimit < total,
|
||||
};
|
||||
}
|
||||
|
||||
async reorder(userId: string, taskIds: string[], projectId?: string | null): Promise<Task[]> {
|
||||
async reorder(
|
||||
userId: string,
|
||||
taskIds: string[],
|
||||
projectId?: string | null
|
||||
): Promise<TaskWithLabels[]> {
|
||||
// Update order for each task
|
||||
const updates = taskIds.map((id, index) =>
|
||||
this.db
|
||||
|
|
@ -316,22 +501,66 @@ export class TaskService {
|
|||
return this.findAll(userId, { projectId: projectId ?? undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads labels for a single task (used for single task operations).
|
||||
* For multiple tasks, use loadTaskLabelsBatch instead.
|
||||
*/
|
||||
private async loadTaskLabels(
|
||||
task: Task
|
||||
): Promise<Task & { labels: (typeof labels.$inferSelect)[] }> {
|
||||
const taskLabelRows = await this.db.query.taskLabels.findMany({
|
||||
where: eq(taskLabels.taskId, task.id),
|
||||
});
|
||||
const [result] = await this.loadTaskLabelsBatch([task]);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (taskLabelRows.length === 0) {
|
||||
return { ...task, labels: [] };
|
||||
/**
|
||||
* Batch loads labels for multiple tasks in just 2 queries (instead of N+1).
|
||||
* This significantly improves performance when loading task lists.
|
||||
*/
|
||||
private async loadTaskLabelsBatch(
|
||||
taskList: Task[]
|
||||
): Promise<(Task & { labels: (typeof labels.$inferSelect)[] })[]> {
|
||||
if (taskList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const labelIds = taskLabelRows.map((tl) => tl.labelId);
|
||||
const taskLabelsData = await this.db.query.labels.findMany({
|
||||
where: or(...labelIds.map((id) => eq(labels.id, id))),
|
||||
const taskIds = taskList.map((t) => t.id);
|
||||
|
||||
// Single query to get all task-label relationships
|
||||
const allTaskLabels = await this.db.query.taskLabels.findMany({
|
||||
where: or(...taskIds.map((id) => eq(taskLabels.taskId, id))),
|
||||
});
|
||||
|
||||
return { ...task, labels: taskLabelsData };
|
||||
if (allTaskLabels.length === 0) {
|
||||
// No labels for any task - return tasks with empty labels array
|
||||
return taskList.map((task) => ({ ...task, labels: [] }));
|
||||
}
|
||||
|
||||
// Get unique label IDs
|
||||
const uniqueLabelIds = [...new Set(allTaskLabels.map((tl) => tl.labelId))];
|
||||
|
||||
// Single query to get all labels
|
||||
const allLabels = await this.db.query.labels.findMany({
|
||||
where: or(...uniqueLabelIds.map((id) => eq(labels.id, id))),
|
||||
});
|
||||
|
||||
// Create a map of labelId -> label for fast lookup
|
||||
const labelMap = new Map(allLabels.map((l) => [l.id, l]));
|
||||
|
||||
// Create a map of taskId -> labelIds for fast lookup
|
||||
const taskLabelMap = new Map<string, string[]>();
|
||||
for (const tl of allTaskLabels) {
|
||||
const existing = taskLabelMap.get(tl.taskId) || [];
|
||||
existing.push(tl.labelId);
|
||||
taskLabelMap.set(tl.taskId, existing);
|
||||
}
|
||||
|
||||
// Combine tasks with their labels
|
||||
return taskList.map((task) => {
|
||||
const labelIds = taskLabelMap.get(task.id) || [];
|
||||
const taskLabelsData = labelIds
|
||||
.map((id) => labelMap.get(id))
|
||||
.filter((l): l is typeof labels.$inferSelect => l !== undefined);
|
||||
return { ...task, labels: taskLabelsData };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@
|
|||
<meta name="theme-color" content="#8b5cf6" />
|
||||
<meta name="msapplication-TileColor" content="#8b5cf6" />
|
||||
|
||||
<!-- Apple iOS PWA -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<!-- PWA -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Todo" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon.svg" />
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
<button
|
||||
type="button"
|
||||
onclick={() => (isOpen = !isOpen)}
|
||||
class="section-header glass-pill w-full flex items-center gap-3 px-4 py-3 rounded-full cursor-pointer transition-all duration-200"
|
||||
class="section-header w-full flex items-center gap-2 py-2 cursor-pointer transition-all duration-200"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<span class="icon-wrapper {iconColors[variant]}">
|
||||
|
|
@ -77,36 +77,8 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
/* Glass pill effect matching PillNavigation */
|
||||
.glass-pill {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:global(.dark) .glass-pill {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.glass-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .glass-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
.section-header:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||
import type { TaskPriority } from '@todo/shared';
|
||||
import { PRIORITY_OPTIONS } from '@todo/shared';
|
||||
import { format, addDays } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
|
|
@ -21,14 +22,6 @@
|
|||
let showPriorityPicker = $state(false);
|
||||
let showProjectPicker = $state(false);
|
||||
|
||||
// Priority options
|
||||
const priorities: { value: TaskPriority; label: string; color: string }[] = [
|
||||
{ value: 'low', label: 'Niedrig', color: '#22c55e' },
|
||||
{ value: 'medium', label: 'Mittel', color: '#eab308' },
|
||||
{ value: 'high', label: 'Hoch', color: '#f97316' },
|
||||
{ value: 'urgent', label: 'Dringend', color: '#ef4444' },
|
||||
];
|
||||
|
||||
// Quick date options
|
||||
const dateOptions = [
|
||||
{ label: 'Heute', date: new Date() },
|
||||
|
|
@ -38,7 +31,7 @@
|
|||
];
|
||||
|
||||
// Derived values
|
||||
let currentPriority = $derived(priorities.find((p) => p.value === selectedPriority)!);
|
||||
let currentPriority = $derived(PRIORITY_OPTIONS.find((p) => p.value === selectedPriority)!);
|
||||
let selectedProject = $derived(
|
||||
selectedProjectId ? projectsStore.getById(selectedProjectId) : undefined
|
||||
);
|
||||
|
|
@ -81,11 +74,14 @@
|
|||
if (viewStore.currentView !== 'project') {
|
||||
selectedProjectId = undefined;
|
||||
}
|
||||
inputRef?.focus();
|
||||
} catch (error) {
|
||||
console.error('Failed to create task:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
// Focus after isLoading is reset (input is no longer disabled)
|
||||
requestAnimationFrame(() => {
|
||||
inputRef?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +228,7 @@
|
|||
|
||||
{#if showPriorityPicker}
|
||||
<div class="dropdown" onclick={(e) => e.stopPropagation()} role="menu">
|
||||
{#each priorities as priority}
|
||||
{#each PRIORITY_OPTIONS as priority}
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
|
|
|
|||
|
|
@ -4,19 +4,26 @@
|
|||
Subtask,
|
||||
TaskPriority,
|
||||
TaskStatus,
|
||||
DurationUnit,
|
||||
EffectiveDuration,
|
||||
UpdateTaskInput,
|
||||
} from '@todo/shared';
|
||||
import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared';
|
||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||
import { labelsStore } from '$lib/stores/labels.svelte';
|
||||
import { format } from 'date-fns';
|
||||
import SubtaskList from './SubtaskList.svelte';
|
||||
import {
|
||||
PrioritySelector,
|
||||
StorypointsSelector,
|
||||
DurationPicker,
|
||||
FunRatingPicker,
|
||||
TagSelector,
|
||||
} from './form';
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: Partial<Task>) => void;
|
||||
onSave: (data: UpdateTaskInput) => void;
|
||||
onDelete: (taskId: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -36,70 +43,13 @@
|
|||
let recurrenceRule = $state('');
|
||||
let notes = $state('');
|
||||
let storyPoints = $state<number | null>(null);
|
||||
let effectiveDurationValue = $state<number | null>(null);
|
||||
let effectiveDurationUnit = $state<DurationUnit>('hours');
|
||||
let effectiveDuration = $state<EffectiveDuration | null>(null);
|
||||
let funRating = $state<number | null>(null);
|
||||
let showCustomDuration = $state(false);
|
||||
|
||||
// UI state
|
||||
let showLabelDropdown = $state(false);
|
||||
let isLoading = $state(false);
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
// Priority options
|
||||
const priorities: { value: TaskPriority; label: string; color: string }[] = [
|
||||
{ value: 'low', label: 'Niedrig', color: '#22c55e' },
|
||||
{ value: 'medium', label: 'Mittel', color: '#eab308' },
|
||||
{ value: 'high', label: 'Hoch', color: '#f97316' },
|
||||
{ value: 'urgent', label: 'Dringend', color: '#ef4444' },
|
||||
];
|
||||
|
||||
// Status options
|
||||
const statuses: { value: TaskStatus; label: string }[] = [
|
||||
{ value: 'pending', label: 'Ausstehend' },
|
||||
{ value: 'in_progress', label: 'In Bearbeitung' },
|
||||
{ value: 'completed', label: 'Abgeschlossen' },
|
||||
{ value: 'cancelled', label: 'Abgebrochen' },
|
||||
];
|
||||
|
||||
// Recurrence options
|
||||
const recurrenceOptions = [
|
||||
{ value: '', label: 'Keine Wiederholung' },
|
||||
{ value: 'FREQ=DAILY', label: 'Täglich' },
|
||||
{ value: 'FREQ=WEEKLY', label: 'Wöchentlich' },
|
||||
{ value: 'FREQ=WEEKLY;INTERVAL=2', label: 'Alle 2 Wochen' },
|
||||
{ value: 'FREQ=MONTHLY', label: 'Monatlich' },
|
||||
{ value: 'FREQ=YEARLY', label: 'Jährlich' },
|
||||
];
|
||||
|
||||
// Storypoints options (Fibonacci)
|
||||
const storyPointOptions = [1, 2, 3, 5, 8, 13, 21];
|
||||
|
||||
// Quick duration options
|
||||
const durationOptions: { label: string; value: number; unit: DurationUnit }[] = [
|
||||
{ label: '15m', value: 15, unit: 'minutes' },
|
||||
{ label: '30m', value: 30, unit: 'minutes' },
|
||||
{ label: '1h', value: 1, unit: 'hours' },
|
||||
{ label: '2h', value: 2, unit: 'hours' },
|
||||
{ label: '4h', value: 4, unit: 'hours' },
|
||||
{ label: '1d', value: 1, unit: 'days' },
|
||||
{ label: '2d', value: 2, unit: 'days' },
|
||||
];
|
||||
|
||||
// Duration unit options
|
||||
const durationUnitOptions: { value: DurationUnit; label: string }[] = [
|
||||
{ value: 'minutes', label: 'Minuten' },
|
||||
{ value: 'hours', label: 'Stunden' },
|
||||
{ value: 'days', label: 'Tage' },
|
||||
];
|
||||
|
||||
// Fun rating color helper
|
||||
function getFunRatingColor(rating: number): string {
|
||||
if (rating <= 3) return '#ef4444'; // red
|
||||
if (rating <= 6) return '#eab308'; // yellow
|
||||
return '#22c55e'; // green
|
||||
}
|
||||
|
||||
// Initialize form when task changes or modal opens
|
||||
$effect(() => {
|
||||
if (open && task) {
|
||||
|
|
@ -115,23 +65,9 @@
|
|||
subtasks = task.subtasks ? [...task.subtasks] : [];
|
||||
recurrenceRule = task.recurrenceRule || '';
|
||||
notes = task.metadata?.notes || '';
|
||||
// New metadata fields
|
||||
// Metadata fields
|
||||
storyPoints = task.metadata?.storyPoints ?? null;
|
||||
if (task.metadata?.effectiveDuration) {
|
||||
effectiveDurationValue = task.metadata.effectiveDuration.value;
|
||||
effectiveDurationUnit = task.metadata.effectiveDuration.unit;
|
||||
// Check if it's a custom value not in quick options
|
||||
const isQuickOption = durationOptions.some(
|
||||
(opt) =>
|
||||
opt.value === task.metadata?.effectiveDuration?.value &&
|
||||
opt.unit === task.metadata?.effectiveDuration?.unit
|
||||
);
|
||||
showCustomDuration = !isQuickOption;
|
||||
} else {
|
||||
effectiveDurationValue = null;
|
||||
effectiveDurationUnit = 'hours';
|
||||
showCustomDuration = false;
|
||||
}
|
||||
effectiveDuration = task.metadata?.effectiveDuration ?? null;
|
||||
funRating = task.metadata?.funRating ?? null;
|
||||
showDeleteConfirm = false;
|
||||
}
|
||||
|
|
@ -157,16 +93,7 @@
|
|||
|
||||
isLoading = true;
|
||||
try {
|
||||
// Build effective duration object
|
||||
let effectiveDuration: EffectiveDuration | null = null;
|
||||
if (effectiveDurationValue !== null && effectiveDurationValue > 0) {
|
||||
effectiveDuration = {
|
||||
value: effectiveDurationValue,
|
||||
unit: effectiveDurationUnit,
|
||||
};
|
||||
}
|
||||
|
||||
const data: Partial<Task> = {
|
||||
const data: UpdateTaskInput = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
||||
|
|
@ -184,11 +111,9 @@
|
|||
effectiveDuration: effectiveDuration ?? undefined,
|
||||
funRating: funRating ?? undefined,
|
||||
},
|
||||
labelIds: selectedLabelIds,
|
||||
};
|
||||
|
||||
// Include labelIds for the update
|
||||
(data as any).labelIds = selectedLabelIds;
|
||||
|
||||
onSave(data);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
|
|
@ -203,37 +128,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
function toggleLabel(labelId: string) {
|
||||
if (selectedLabelIds.includes(labelId)) {
|
||||
selectedLabelIds = selectedLabelIds.filter((id) => id !== labelId);
|
||||
} else {
|
||||
selectedLabelIds = [...selectedLabelIds, labelId];
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubtasksChange(newSubtasks: Subtask[]) {
|
||||
subtasks = newSubtasks;
|
||||
}
|
||||
|
||||
function selectQuickDuration(opt: { value: number; unit: DurationUnit }) {
|
||||
effectiveDurationValue = opt.value;
|
||||
effectiveDurationUnit = opt.unit;
|
||||
showCustomDuration = false;
|
||||
}
|
||||
|
||||
function isQuickDurationSelected(opt: { value: number; unit: DurationUnit }): boolean {
|
||||
return (
|
||||
effectiveDurationValue === opt.value &&
|
||||
effectiveDurationUnit === opt.unit &&
|
||||
!showCustomDuration
|
||||
);
|
||||
}
|
||||
|
||||
function clearDuration() {
|
||||
effectiveDurationValue = null;
|
||||
effectiveDurationUnit = 'hours';
|
||||
showCustomDuration = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
|
@ -304,27 +201,14 @@
|
|||
<!-- Priorität -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">Priorität</label>
|
||||
<div class="priority-buttons">
|
||||
{#each priorities as p}
|
||||
<button
|
||||
type="button"
|
||||
class="priority-btn"
|
||||
class:selected={priority === p.value}
|
||||
style="--priority-color: {p.color}"
|
||||
onclick={() => (priority = p.value)}
|
||||
>
|
||||
<span class="priority-dot" style="background-color: {p.color}"></span>
|
||||
{p.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<PrioritySelector value={priority} onChange={(p) => (priority = p)} />
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="form-section">
|
||||
<label class="form-label" for="task-status">Status</label>
|
||||
<select id="task-status" class="form-select" bind:value={status}>
|
||||
{#each statuses as s}
|
||||
{#each STATUS_OPTIONS as s}
|
||||
<option value={s.value}>{s.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
|
@ -343,71 +227,13 @@
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Labels -->
|
||||
<!-- Tags -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">Labels</label>
|
||||
<div class="label-selector">
|
||||
<button
|
||||
type="button"
|
||||
class="label-trigger"
|
||||
onclick={() => (showLabelDropdown = !showLabelDropdown)}
|
||||
>
|
||||
{#if selectedLabelIds.length === 0}
|
||||
<span class="text-muted">Labels auswählen...</span>
|
||||
{:else}
|
||||
<div class="selected-labels">
|
||||
{#each selectedLabelIds.slice(0, 3) as labelId}
|
||||
{@const label = labelsStore.getById(labelId)}
|
||||
{#if label}
|
||||
<span class="label-tag" style="--label-color: {label.color}">
|
||||
{label.name}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if selectedLabelIds.length > 3}
|
||||
<span class="label-more">+{selectedLabelIds.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<svg class="dropdown-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showLabelDropdown}
|
||||
<div class="label-dropdown">
|
||||
{#each labelsStore.labels as label}
|
||||
<button
|
||||
type="button"
|
||||
class="label-option"
|
||||
class:selected={selectedLabelIds.includes(label.id)}
|
||||
onclick={() => toggleLabel(label.id)}
|
||||
>
|
||||
<span class="label-dot" style="background-color: {label.color}"></span>
|
||||
<span class="label-name">{label.name}</span>
|
||||
{#if selectedLabelIds.includes(label.id)}
|
||||
<svg class="check-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if labelsStore.labels.length === 0}
|
||||
<div class="no-labels">Keine Labels vorhanden</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<label class="form-label">Tags</label>
|
||||
<TagSelector
|
||||
selectedIds={selectedLabelIds}
|
||||
onChange={(ids) => (selectedLabelIds = ids)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Subtasks -->
|
||||
|
|
@ -420,7 +246,7 @@
|
|||
<div class="form-section">
|
||||
<label class="form-label" for="task-recurrence">Wiederholung</label>
|
||||
<select id="task-recurrence" class="form-select" bind:value={recurrenceRule}>
|
||||
{#each recurrenceOptions as option}
|
||||
{#each RECURRENCE_OPTIONS as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
|
@ -441,140 +267,22 @@
|
|||
<!-- Storypoints -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">Storypoints</label>
|
||||
<div class="storypoint-buttons">
|
||||
{#each storyPointOptions as sp}
|
||||
<button
|
||||
type="button"
|
||||
class="storypoint-btn"
|
||||
class:selected={storyPoints === sp}
|
||||
onclick={() => (storyPoints = storyPoints === sp ? null : sp)}
|
||||
>
|
||||
{sp}
|
||||
</button>
|
||||
{/each}
|
||||
{#if storyPoints !== null}
|
||||
<button
|
||||
type="button"
|
||||
class="storypoint-clear"
|
||||
onclick={() => (storyPoints = null)}
|
||||
title="Zurücksetzen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<StorypointsSelector value={storyPoints} onChange={(v) => (storyPoints = v)} />
|
||||
</div>
|
||||
|
||||
<!-- Effektive Dauer -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">Effektive Dauer</label>
|
||||
<div class="duration-buttons">
|
||||
{#each durationOptions as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="duration-btn"
|
||||
class:selected={isQuickDurationSelected(opt)}
|
||||
onclick={() => selectQuickDuration(opt)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="duration-btn"
|
||||
class:selected={showCustomDuration}
|
||||
onclick={() => (showCustomDuration = !showCustomDuration)}
|
||||
>
|
||||
...
|
||||
</button>
|
||||
{#if effectiveDurationValue !== null}
|
||||
<button
|
||||
type="button"
|
||||
class="duration-clear"
|
||||
onclick={clearDuration}
|
||||
title="Zurücksetzen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showCustomDuration}
|
||||
<div class="duration-custom">
|
||||
<input
|
||||
type="number"
|
||||
class="form-input-sm duration-input"
|
||||
bind:value={effectiveDurationValue}
|
||||
placeholder="Wert"
|
||||
min="1"
|
||||
/>
|
||||
<select class="form-select duration-unit" bind:value={effectiveDurationUnit}>
|
||||
{#each durationUnitOptions as unit}
|
||||
<option value={unit.value}>{unit.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
<DurationPicker value={effectiveDuration} onChange={(v) => (effectiveDuration = v)} />
|
||||
</div>
|
||||
|
||||
<!-- Spaß-Faktor -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">
|
||||
Spaß-Faktor{#if funRating !== null}: <span
|
||||
class="fun-rating-value"
|
||||
style="color: {getFunRatingColor(funRating)}">{funRating}</span
|
||||
Spaß-Faktor{#if funRating !== null}: <span class="fun-rating-value">{funRating}</span
|
||||
>{/if}
|
||||
</label>
|
||||
<div class="fun-rating">
|
||||
{#each Array(10) as _, i}
|
||||
{@const rating = i + 1}
|
||||
<button
|
||||
type="button"
|
||||
class="fun-rating-dot"
|
||||
class:filled={funRating !== null && rating <= funRating}
|
||||
style="--dot-color: {getFunRatingColor(rating)}"
|
||||
onclick={() => (funRating = funRating === rating ? null : rating)}
|
||||
title={rating}
|
||||
>
|
||||
<span class="dot"></span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if funRating !== null}
|
||||
<button
|
||||
type="button"
|
||||
class="fun-rating-clear"
|
||||
onclick={() => (funRating = null)}
|
||||
title="Zurücksetzen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="fun-rating-labels">
|
||||
<span>1</span>
|
||||
<span>5</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
<FunRatingPicker value={funRating} onChange={(v) => (funRating = v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -788,184 +496,6 @@
|
|||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* Priority buttons */
|
||||
.priority-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.priority-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .priority-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.priority-btn:hover {
|
||||
border-color: var(--priority-color);
|
||||
}
|
||||
|
||||
.priority-btn.selected {
|
||||
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
|
||||
border-color: var(--priority-color);
|
||||
color: var(--priority-color);
|
||||
}
|
||||
|
||||
.priority-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* Label selector */
|
||||
.label-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.label-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .label-trigger {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.label-trigger:hover {
|
||||
border-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.selected-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.label-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--label-color) 15%, transparent);
|
||||
color: var(--label-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.label-more {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.label-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:global(.dark) .label-dropdown {
|
||||
background: rgba(40, 40, 40, 0.95);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.label-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.label-option:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .label-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.label-option.selected {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.label-dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label-name {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .label-name {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.no-labels {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
|
|
@ -1056,164 +586,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Storypoints */
|
||||
.storypoint-buttons {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.storypoint-btn {
|
||||
min-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .storypoint-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.storypoint-btn:hover {
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.storypoint-btn.selected {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
border-color: #8b5cf6;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.storypoint-clear,
|
||||
.duration-clear,
|
||||
.fun-rating-clear {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.storypoint-clear:hover,
|
||||
.duration-clear:hover,
|
||||
.fun-rating-clear:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Duration */
|
||||
.duration-buttons {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.duration-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .duration-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.duration-btn:hover {
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.duration-btn.selected {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
border-color: #8b5cf6;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.duration-custom {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.duration-input {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.duration-unit {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
/* Fun Rating */
|
||||
.fun-rating {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fun-rating-dot {
|
||||
padding: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.fun-rating-dot:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.fun-rating-dot .dot {
|
||||
display: block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .fun-rating-dot .dot {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.fun-rating-dot.filled .dot {
|
||||
background: var(--dot-color);
|
||||
}
|
||||
|
||||
.fun-rating-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.fun-rating-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,44 @@
|
|||
interface Props {
|
||||
task: Task;
|
||||
showCompleted?: boolean;
|
||||
animateComplete?: boolean;
|
||||
onToggleComplete: () => void;
|
||||
onDelete: () => void;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
let { task, showCompleted = false, onToggleComplete, onDelete, onEdit }: Props = $props();
|
||||
let {
|
||||
task,
|
||||
showCompleted = false,
|
||||
animateComplete = false,
|
||||
onToggleComplete,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: Props = $props();
|
||||
|
||||
// Animation state for completing
|
||||
let isAnimatingComplete = $state(false);
|
||||
|
||||
// External animation trigger
|
||||
$effect(() => {
|
||||
if (animateComplete && !task.isCompleted) {
|
||||
isAnimatingComplete = true;
|
||||
}
|
||||
});
|
||||
|
||||
function handleToggleClick() {
|
||||
if (!task.isCompleted) {
|
||||
// Animate before completing
|
||||
isAnimatingComplete = true;
|
||||
setTimeout(() => {
|
||||
isAnimatingComplete = false;
|
||||
onToggleComplete();
|
||||
}, 500);
|
||||
} else {
|
||||
// Uncomplete immediately
|
||||
onToggleComplete();
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentClick() {
|
||||
if (onEdit) {
|
||||
|
|
@ -58,7 +90,18 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<div class="task-item group" class:completed={task.isCompleted}>
|
||||
<div
|
||||
class="task-item group"
|
||||
class:completed={task.isCompleted}
|
||||
class:completing={isAnimatingComplete}
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div class="drag-handle">
|
||||
<svg class="drag-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Priority indicator -->
|
||||
<div
|
||||
class="priority-dot"
|
||||
|
|
@ -66,9 +109,20 @@
|
|||
></div>
|
||||
|
||||
<!-- Checkbox -->
|
||||
<button class="task-checkbox" class:checked={task.isCompleted} onclick={onToggleComplete}>
|
||||
{#if task.isCompleted}
|
||||
<svg class="check-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<button
|
||||
class="task-checkbox"
|
||||
class:checked={task.isCompleted}
|
||||
class:animating={isAnimatingComplete}
|
||||
onclick={handleToggleClick}
|
||||
>
|
||||
{#if task.isCompleted || isAnimatingComplete}
|
||||
<svg
|
||||
class="check-icon"
|
||||
class:animate-check={isAnimatingComplete}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
|
|
@ -80,27 +134,9 @@
|
|||
{task.title}
|
||||
</span>
|
||||
|
||||
<!-- Meta info inline -->
|
||||
{#if dueDateText() || subtaskProgress() || (task.labels && task.labels.length > 0)}
|
||||
<!-- Labels and subtasks below title -->
|
||||
{#if subtaskProgress() || (task.labels && task.labels.length > 0)}
|
||||
<div class="task-meta">
|
||||
{#if dueDateText()}
|
||||
<span
|
||||
class="meta-item date"
|
||||
class:overdue={isOverdue()}
|
||||
class:today={isToday(new Date(task.dueDate || 0))}
|
||||
>
|
||||
<svg class="meta-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{dueDateText()}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if subtaskProgress()}
|
||||
<span class="meta-item">
|
||||
<svg class="meta-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
|
@ -129,6 +165,17 @@
|
|||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Due date (always on the right) -->
|
||||
{#if dueDateText()}
|
||||
<span
|
||||
class="due-date"
|
||||
class:overdue={isOverdue()}
|
||||
class:today={task.dueDate && isToday(new Date(task.dueDate))}
|
||||
>
|
||||
{dueDateText()}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Project indicator -->
|
||||
{#if projectColor()}
|
||||
<div class="project-dot" style="background-color: {projectColor()}"></div>
|
||||
|
|
@ -151,18 +198,16 @@
|
|||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 9999px;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
:global(.dark) .task-item {
|
||||
|
|
@ -172,11 +217,8 @@
|
|||
|
||||
.task-item:hover {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:global(.dark) .task-item:hover {
|
||||
|
|
@ -188,6 +230,54 @@
|
|||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Completing animation */
|
||||
.task-item.completing {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
:global(.dark) .task-item.completing {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
/* Drag handle */
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem;
|
||||
margin-left: -0.25rem;
|
||||
}
|
||||
|
||||
.task-item:hover .drag-handle {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
opacity: 0.7 !important;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.drag-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
/* During drag, disable pointer events on interactive elements */
|
||||
:global([aria-grabbed='true']) .task-checkbox,
|
||||
:global([aria-grabbed='true']) .task-content,
|
||||
:global([aria-grabbed='true']) .delete-btn {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Priority dot */
|
||||
.priority-dot {
|
||||
width: 0.5rem;
|
||||
|
|
@ -226,12 +316,48 @@
|
|||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.task-checkbox.animating {
|
||||
background: #22c55e;
|
||||
border-color: #22c55e;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.check-icon.animate-check {
|
||||
animation: drawCheck 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.check-icon.animate-check path {
|
||||
stroke-dasharray: 24;
|
||||
stroke-dashoffset: 24;
|
||||
animation: drawPath 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes drawPath {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drawCheck {
|
||||
0% {
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.task-content {
|
||||
flex: 1;
|
||||
|
|
@ -284,14 +410,6 @@
|
|||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.meta-item.date.overdue {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.meta-item.date.today {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
|
|
@ -306,6 +424,26 @@
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Due date */
|
||||
.due-date {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.dark) .due-date {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.due-date.overdue {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.due-date.today {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
/* Project dot */
|
||||
.project-dot {
|
||||
width: 0.5rem;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
|
||||
import type { Task } from '@todo/shared';
|
||||
import TaskItem from './TaskItem.svelte';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
|
|
@ -6,10 +7,76 @@
|
|||
interface Props {
|
||||
tasks: Task[];
|
||||
showCompleted?: boolean;
|
||||
enableDragDrop?: boolean;
|
||||
dropTargetDate?: Date | 'completed' | 'overdue';
|
||||
onEditTask?: (task: Task) => void;
|
||||
onTaskDrop?: (taskId: string, targetDate: Date | 'completed' | 'overdue') => void;
|
||||
}
|
||||
|
||||
let { tasks, showCompleted = false, onEditTask }: Props = $props();
|
||||
let {
|
||||
tasks,
|
||||
showCompleted = false,
|
||||
enableDragDrop = false,
|
||||
dropTargetDate,
|
||||
onEditTask,
|
||||
onTaskDrop,
|
||||
}: Props = $props();
|
||||
|
||||
// Local mutable state for dnd-zone
|
||||
let items = $state<Task[]>([]);
|
||||
|
||||
// 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 = '';
|
||||
|
||||
// Sync items with tasks only when the set of task IDs changes
|
||||
$effect(() => {
|
||||
const currentIds = tasks
|
||||
.map((t) => t.id)
|
||||
.sort()
|
||||
.join(',');
|
||||
if (currentIds !== lastTaskIds) {
|
||||
items = [...tasks];
|
||||
lastTaskIds = currentIds;
|
||||
}
|
||||
});
|
||||
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function handleDndConsider(e: CustomEvent<{ items: Task[] }>) {
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
function handleDndFinalize(e: CustomEvent<{ items: Task[]; info: { id: string } }>) {
|
||||
const newItems = e.detail.items.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID);
|
||||
const movedTaskId = e.detail.info.id;
|
||||
|
||||
// Check if this task came from another list (dropped INTO this list)
|
||||
const wasInThisList = tasks.some((t) => t.id === movedTaskId);
|
||||
|
||||
if (!wasInThisList && dropTargetDate && onTaskDrop) {
|
||||
// If dropping into completed section, animate first
|
||||
if (dropTargetDate === 'completed') {
|
||||
animatingTaskId = movedTaskId;
|
||||
setTimeout(() => {
|
||||
animatingTaskId = null;
|
||||
onTaskDrop(movedTaskId, dropTargetDate);
|
||||
}, 500);
|
||||
} else {
|
||||
// Task moved FROM another section TO this section
|
||||
onTaskDrop(movedTaskId, dropTargetDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Update local state and sync lastTaskIds to prevent $effect from reverting
|
||||
items = newItems;
|
||||
lastTaskIds = newItems
|
||||
.map((t) => t.id)
|
||||
.sort()
|
||||
.join(',');
|
||||
}
|
||||
|
||||
async function handleToggleComplete(task: Task) {
|
||||
if (task.isCompleted) {
|
||||
|
|
@ -24,14 +91,89 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="task-list">
|
||||
{#each tasks as task (task.id)}
|
||||
<TaskItem
|
||||
{task}
|
||||
{showCompleted}
|
||||
onToggleComplete={() => handleToggleComplete(task)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
onEdit={onEditTask ? () => onEditTask(task) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if enableDragDrop}
|
||||
<div
|
||||
class="task-list"
|
||||
class:empty={items.length === 0}
|
||||
use:dndzone={{
|
||||
items,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: {},
|
||||
dropTargetClasses: ['task-drop-target'],
|
||||
type: 'homepage-tasks',
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
>
|
||||
{#each items.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID) as task (task.id)}
|
||||
<TaskItem
|
||||
{task}
|
||||
{showCompleted}
|
||||
animateComplete={animatingTaskId === task.id}
|
||||
onToggleComplete={() => handleToggleComplete(task)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
onEdit={onEditTask ? () => onEditTask(task) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
{#if items.length === 0}
|
||||
<div class="empty-placeholder">
|
||||
<span>Aufgabe hierher ziehen</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="task-list">
|
||||
{#each tasks as task (task.id)}
|
||||
<TaskItem
|
||||
{task}
|
||||
{showCompleted}
|
||||
animateComplete={animatingTaskId === task.id}
|
||||
onToggleComplete={() => handleToggleComplete(task)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
onEdit={onEditTask ? () => onEditTask(task) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.task-list {
|
||||
min-height: 60px;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.task-list.empty {
|
||||
border: 2px dashed rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:global(.dark) .task-list.empty {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
color: var(--color-muted-foreground, #9ca3af);
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:global(.task-drop-target) {
|
||||
outline: 2px dashed #8b5cf6 !important;
|
||||
outline-offset: -2px;
|
||||
background: rgba(139, 92, 246, 0.08) !important;
|
||||
}
|
||||
|
||||
:global(.task-drop-target) .empty-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:global(.dark .task-drop-target) {
|
||||
background: rgba(139, 92, 246, 0.15) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
238
apps/todo/apps/web/src/lib/components/form/DurationPicker.svelte
Normal file
238
apps/todo/apps/web/src/lib/components/form/DurationPicker.svelte
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
<script lang="ts">
|
||||
import type { DurationUnit, EffectiveDuration } from '@todo/shared';
|
||||
|
||||
interface Props {
|
||||
value: EffectiveDuration | null;
|
||||
onChange: (value: EffectiveDuration | null) => void;
|
||||
}
|
||||
|
||||
let { value, onChange }: Props = $props();
|
||||
|
||||
let showCustom = $state(false);
|
||||
let customValue = $state<number | null>(null);
|
||||
let customUnit = $state<DurationUnit>('hours');
|
||||
|
||||
// Quick duration options
|
||||
const quickOptions: { label: string; value: number; unit: DurationUnit }[] = [
|
||||
{ label: '15m', value: 15, unit: 'minutes' },
|
||||
{ label: '30m', value: 30, unit: 'minutes' },
|
||||
{ label: '1h', value: 1, unit: 'hours' },
|
||||
{ label: '2h', value: 2, unit: 'hours' },
|
||||
{ label: '4h', value: 4, unit: 'hours' },
|
||||
{ label: '1d', value: 1, unit: 'days' },
|
||||
{ label: '2d', value: 2, unit: 'days' },
|
||||
];
|
||||
|
||||
const unitOptions: { value: DurationUnit; label: string }[] = [
|
||||
{ value: 'minutes', label: 'Minuten' },
|
||||
{ value: 'hours', label: 'Stunden' },
|
||||
{ value: 'days', label: 'Tage' },
|
||||
];
|
||||
|
||||
// Sync custom inputs with value prop
|
||||
$effect(() => {
|
||||
if (value) {
|
||||
const isQuickOption = quickOptions.some(
|
||||
(opt) => opt.value === value.value && opt.unit === value.unit
|
||||
);
|
||||
if (!isQuickOption) {
|
||||
showCustom = true;
|
||||
customValue = value.value;
|
||||
customUnit = value.unit;
|
||||
} else {
|
||||
showCustom = false;
|
||||
}
|
||||
} else {
|
||||
showCustom = false;
|
||||
customValue = null;
|
||||
customUnit = 'hours';
|
||||
}
|
||||
});
|
||||
|
||||
function selectQuick(opt: { value: number; unit: DurationUnit }) {
|
||||
showCustom = false;
|
||||
onChange({ value: opt.value, unit: opt.unit });
|
||||
}
|
||||
|
||||
function isQuickSelected(opt: { value: number; unit: DurationUnit }): boolean {
|
||||
return value !== null && value.value === opt.value && value.unit === opt.unit && !showCustom;
|
||||
}
|
||||
|
||||
function toggleCustom() {
|
||||
showCustom = !showCustom;
|
||||
if (showCustom && customValue && customValue > 0) {
|
||||
onChange({ value: customValue, unit: customUnit });
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomChange() {
|
||||
if (customValue && customValue > 0) {
|
||||
onChange({ value: customValue, unit: customUnit });
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
showCustom = false;
|
||||
customValue = null;
|
||||
customUnit = 'hours';
|
||||
onChange(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="duration-picker">
|
||||
<div class="duration-buttons">
|
||||
{#each quickOptions as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="duration-btn"
|
||||
class:selected={isQuickSelected(opt)}
|
||||
onclick={() => selectQuick(opt)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
<button type="button" class="duration-btn" class:selected={showCustom} onclick={toggleCustom}>
|
||||
...
|
||||
</button>
|
||||
{#if value !== null}
|
||||
<button type="button" class="duration-clear" onclick={clear} title="Zurücksetzen">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showCustom}
|
||||
<div class="duration-custom">
|
||||
<input
|
||||
type="number"
|
||||
class="duration-input"
|
||||
bind:value={customValue}
|
||||
oninput={handleCustomChange}
|
||||
placeholder="Wert"
|
||||
min="1"
|
||||
/>
|
||||
<select class="duration-unit" bind:value={customUnit} onchange={handleCustomChange}>
|
||||
{#each unitOptions as unit}
|
||||
<option value={unit.value}>{unit.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.duration-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.duration-buttons {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.duration-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .duration-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.duration-btn:hover {
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.duration-btn.selected {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
border-color: #8b5cf6;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.duration-clear {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.duration-clear:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.duration-custom {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.duration-input {
|
||||
width: 80px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .duration-input {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.duration-input:focus {
|
||||
outline: none;
|
||||
border-color: #8b5cf6;
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.duration-unit {
|
||||
width: 120px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .duration-unit {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.duration-unit:focus {
|
||||
outline: none;
|
||||
border-color: #8b5cf6;
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
}
|
||||
|
||||
let { value, onChange }: Props = $props();
|
||||
|
||||
function getRatingColor(rating: number): string {
|
||||
if (rating <= 3) return '#ef4444'; // red
|
||||
if (rating <= 6) return '#eab308'; // yellow
|
||||
return '#22c55e'; // green
|
||||
}
|
||||
|
||||
function handleSelect(rating: number) {
|
||||
onChange(value === rating ? null : rating);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
onChange(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fun-rating-picker">
|
||||
<div class="fun-rating">
|
||||
{#each Array(10) as _, i}
|
||||
{@const rating = i + 1}
|
||||
<button
|
||||
type="button"
|
||||
class="fun-rating-dot"
|
||||
class:filled={value !== null && rating <= value}
|
||||
style="--dot-color: {getRatingColor(rating)}"
|
||||
onclick={() => handleSelect(rating)}
|
||||
title={String(rating)}
|
||||
>
|
||||
<span class="dot"></span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if value !== null}
|
||||
<button type="button" class="fun-rating-clear" onclick={handleClear} title="Zurücksetzen">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="fun-rating-labels">
|
||||
<span>1</span>
|
||||
<span>5</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fun-rating-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.fun-rating {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fun-rating-dot {
|
||||
padding: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.fun-rating-dot:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.fun-rating-dot .dot {
|
||||
display: block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .fun-rating-dot .dot {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.fun-rating-dot.filled .dot {
|
||||
background: var(--dot-color);
|
||||
}
|
||||
|
||||
.fun-rating-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.fun-rating-clear {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.fun-rating-clear:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<script lang="ts">
|
||||
import type { TaskPriority } from '@todo/shared';
|
||||
import { PRIORITY_OPTIONS } from '@todo/shared';
|
||||
|
||||
interface Props {
|
||||
value: TaskPriority;
|
||||
onChange: (priority: TaskPriority) => void;
|
||||
}
|
||||
|
||||
let { value, onChange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="priority-buttons">
|
||||
{#each PRIORITY_OPTIONS as p}
|
||||
<button
|
||||
type="button"
|
||||
class="priority-btn"
|
||||
class:selected={value === p.value}
|
||||
style="--priority-color: {p.color}"
|
||||
onclick={() => onChange(p.value)}
|
||||
>
|
||||
<span class="priority-dot" style="background-color: {p.color}"></span>
|
||||
{p.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.priority-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.priority-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .priority-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.priority-btn:hover {
|
||||
border-color: var(--priority-color);
|
||||
}
|
||||
|
||||
.priority-btn.selected {
|
||||
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
|
||||
border-color: var(--priority-color);
|
||||
color: var(--priority-color);
|
||||
}
|
||||
|
||||
.priority-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
}
|
||||
|
||||
let { value, onChange }: Props = $props();
|
||||
|
||||
// Fibonacci sequence for story points
|
||||
const options = [1, 2, 3, 5, 8, 13, 21];
|
||||
|
||||
function handleSelect(sp: number) {
|
||||
onChange(value === sp ? null : sp);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
onChange(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="storypoint-buttons">
|
||||
{#each options as sp}
|
||||
<button
|
||||
type="button"
|
||||
class="storypoint-btn"
|
||||
class:selected={value === sp}
|
||||
onclick={() => handleSelect(sp)}
|
||||
>
|
||||
{sp}
|
||||
</button>
|
||||
{/each}
|
||||
{#if value !== null}
|
||||
<button type="button" class="storypoint-clear" onclick={handleClear} title="Zurücksetzen">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.storypoint-buttons {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.storypoint-btn {
|
||||
min-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .storypoint-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.storypoint-btn:hover {
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.storypoint-btn.selected {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
border-color: #8b5cf6;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.storypoint-clear {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.storypoint-clear:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
</style>
|
||||
223
apps/todo/apps/web/src/lib/components/form/TagSelector.svelte
Normal file
223
apps/todo/apps/web/src/lib/components/form/TagSelector.svelte
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<script lang="ts">
|
||||
import { labelsStore } from '$lib/stores/labels.svelte';
|
||||
|
||||
interface Props {
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
let { selectedIds, onChange }: Props = $props();
|
||||
|
||||
let showDropdown = $state(false);
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
if (selectedIds.includes(tagId)) {
|
||||
onChange(selectedIds.filter((id) => id !== tagId));
|
||||
} else {
|
||||
onChange([...selectedIds, tagId]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTriggerClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
showDropdown = !showDropdown;
|
||||
}
|
||||
|
||||
function handleWindowClick() {
|
||||
showDropdown = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<div class="tag-selector">
|
||||
<button type="button" class="tag-trigger" onclick={handleTriggerClick}>
|
||||
{#if selectedIds.length === 0}
|
||||
<span class="text-muted">Tags auswählen...</span>
|
||||
{:else}
|
||||
<div class="selected-tags">
|
||||
{#each selectedIds.slice(0, 3) as tagId}
|
||||
{@const tag = labelsStore.getById(tagId)}
|
||||
{#if tag}
|
||||
<span class="tag-chip" style="--tag-color: {tag.color}">
|
||||
{tag.name}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if selectedIds.length > 3}
|
||||
<span class="tag-more">+{selectedIds.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<svg class="dropdown-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showDropdown}
|
||||
<div class="tag-dropdown" onclick={(e) => e.stopPropagation()} role="listbox">
|
||||
{#each labelsStore.labels as tag}
|
||||
<button
|
||||
type="button"
|
||||
class="tag-option"
|
||||
class:selected={selectedIds.includes(tag.id)}
|
||||
onclick={() => toggleTag(tag.id)}
|
||||
role="option"
|
||||
aria-selected={selectedIds.includes(tag.id)}
|
||||
>
|
||||
<span class="tag-dot" style="background-color: {tag.color}"></span>
|
||||
<span class="tag-name">{tag.name}</span>
|
||||
{#if selectedIds.includes(tag.id)}
|
||||
<svg class="check-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if labelsStore.labels.length === 0}
|
||||
<div class="no-tags">Keine Tags vorhanden</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tag-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tag-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .tag-trigger {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tag-trigger:hover {
|
||||
border-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.selected-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--tag-color) 15%, transparent);
|
||||
color: var(--tag-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-more {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.tag-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:global(.dark) .tag-dropdown {
|
||||
background: rgba(40, 40, 40, 0.95);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tag-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tag-option:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .tag-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tag-option.selected {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.tag-dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .tag-name {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.no-tags {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
5
apps/todo/apps/web/src/lib/components/form/index.ts
Normal file
5
apps/todo/apps/web/src/lib/components/form/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { default as PrioritySelector } from './PrioritySelector.svelte';
|
||||
export { default as StorypointsSelector } from './StorypointsSelector.svelte';
|
||||
export { default as DurationPicker } from './DurationPicker.svelte';
|
||||
export { default as FunRatingPicker } from './FunRatingPicker.svelte';
|
||||
export { default as TagSelector } from './TagSelector.svelte';
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
|
||||
import type { KanbanColumn, Task, TaskPriority } from '@todo/shared';
|
||||
import { ConfirmationModal } from '@manacore/shared-ui';
|
||||
import KanbanColumnComponent from './KanbanColumn.svelte';
|
||||
import AddColumnButton from './AddColumnButton.svelte';
|
||||
import { kanbanStore } from '$lib/stores/kanban.svelte';
|
||||
|
|
@ -24,6 +25,8 @@
|
|||
|
||||
// Local columns state for drag and drop
|
||||
let localColumns = $state<KanbanColumn[]>([]);
|
||||
let showDeleteConfirm = $state(false);
|
||||
let columnToDelete = $state<string | null>(null);
|
||||
|
||||
// Sync with store
|
||||
$effect(() => {
|
||||
|
|
@ -55,10 +58,17 @@
|
|||
await kanbanStore.updateColumn(columnId, data);
|
||||
}
|
||||
|
||||
async function handleDeleteColumn(columnId: string) {
|
||||
if (confirm('Spalte wirklich löschen? Alle Aufgaben werden in die erste Spalte verschoben.')) {
|
||||
await kanbanStore.deleteColumn(columnId);
|
||||
function handleDeleteColumn(columnId: string) {
|
||||
columnToDelete = columnId;
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async function confirmDeleteColumn() {
|
||||
if (columnToDelete) {
|
||||
await kanbanStore.deleteColumn(columnToDelete);
|
||||
}
|
||||
showDeleteConfirm = false;
|
||||
columnToDelete = null;
|
||||
}
|
||||
|
||||
async function handleTasksReorder(columnId: string, taskIds: string[]) {
|
||||
|
|
@ -167,6 +177,21 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete column confirmation modal -->
|
||||
<ConfirmationModal
|
||||
visible={showDeleteConfirm}
|
||||
onClose={() => {
|
||||
showDeleteConfirm = false;
|
||||
columnToDelete = null;
|
||||
}}
|
||||
onConfirm={confirmDeleteColumn}
|
||||
variant="danger"
|
||||
title="Spalte löschen?"
|
||||
message="Alle Aufgaben dieser Spalte werden in die erste Spalte verschoben."
|
||||
confirmLabel="Löschen"
|
||||
cancelLabel="Abbrechen"
|
||||
/>
|
||||
|
||||
<style>
|
||||
.kanban-board {
|
||||
min-height: 400px;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
|
||||
import type { KanbanColumn, Task } from '@todo/shared';
|
||||
import type { KanbanColumn, Task, UpdateTaskInput } from '@todo/shared';
|
||||
import KanbanTaskCard from './KanbanTaskCard.svelte';
|
||||
import KanbanColumnHeader from './KanbanColumnHeader.svelte';
|
||||
import QuickAddTaskInline from './QuickAddTaskInline.svelte';
|
||||
|
|
@ -71,9 +71,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function handleSaveTask(task: Task, data: Partial<Task>) {
|
||||
async function handleSaveTask(task: Task, data: UpdateTaskInput) {
|
||||
// Transform data to match updateTask API (convert null to undefined)
|
||||
const updateData: Parameters<typeof tasksStore.updateTask>[1] = {};
|
||||
const updateData: UpdateTaskInput = {};
|
||||
if (data.title !== undefined) updateData.title = data.title;
|
||||
if (data.description !== undefined) updateData.description = data.description ?? undefined;
|
||||
if (data.projectId !== undefined) updateData.projectId = data.projectId;
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
if (data.recurrenceRule !== undefined)
|
||||
updateData.recurrenceRule = data.recurrenceRule ?? undefined;
|
||||
if (data.metadata !== undefined) updateData.metadata = data.metadata;
|
||||
if ((data as any).labelIds !== undefined) (updateData as any).labelIds = (data as any).labelIds;
|
||||
if (data.labelIds !== undefined) updateData.labelIds = data.labelIds;
|
||||
|
||||
await tasksStore.updateTask(task.id, updateData);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,19 +36,19 @@
|
|||
},
|
||||
{
|
||||
value: 'high',
|
||||
label: 'Hoch',
|
||||
label: 'Wichtig',
|
||||
color: 'text-orange-600 dark:text-orange-400',
|
||||
bgColor: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
value: 'medium',
|
||||
label: 'Mittel',
|
||||
label: 'Normal',
|
||||
color: 'text-yellow-600 dark:text-yellow-400',
|
||||
bgColor: 'bg-yellow-500',
|
||||
},
|
||||
{
|
||||
value: 'low',
|
||||
label: 'Niedrig',
|
||||
label: 'Später',
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-500',
|
||||
},
|
||||
|
|
@ -184,10 +184,9 @@
|
|||
|
||||
<div class="h-6 w-px bg-border hidden sm:block"></div>
|
||||
|
||||
<!-- Labels filter -->
|
||||
<!-- Tags filter -->
|
||||
<div class="filter-group flex items-center gap-2 relative">
|
||||
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Labels</span
|
||||
>
|
||||
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Tags</span>
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-background border border-border rounded-lg hover:border-primary/50 hover:bg-muted/50 transition-all"
|
||||
onclick={() => (showLabelsDropdown = !showLabelsDropdown)}
|
||||
|
|
@ -234,7 +233,7 @@
|
|||
class="absolute top-full left-0 mt-2 z-50 min-w-[220px] bg-popover border border-border rounded-xl shadow-lg p-2 animate-in fade-in slide-in-from-top-2 duration-150"
|
||||
>
|
||||
{#if labelsStore.labels.length === 0}
|
||||
<p class="text-sm text-muted-foreground p-3 text-center">Keine Labels vorhanden</p>
|
||||
<p class="text-sm text-muted-foreground p-3 text-center">Keine Tags vorhanden</p>
|
||||
{:else}
|
||||
<div class="max-h-[200px] overflow-y-auto">
|
||||
{#each labelsStore.labels as label}
|
||||
|
|
|
|||
|
|
@ -2,6 +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 TaskEditModal from '../TaskEditModal.svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
|
||||
// Modal state
|
||||
let showModal = $state(false);
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
// Inline edit state
|
||||
let isEditingTitle = $state(false);
|
||||
|
|
@ -129,9 +131,12 @@
|
|||
|
||||
function handleContextDelete() {
|
||||
showContextMenu = false;
|
||||
if (confirm('Aufgabe wirklich löschen?')) {
|
||||
onDelete?.();
|
||||
}
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
showDeleteConfirm = false;
|
||||
onDelete?.();
|
||||
}
|
||||
|
||||
// Modal handlers
|
||||
|
|
@ -308,6 +313,18 @@
|
|||
onDelete={handleModalDelete}
|
||||
/>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<ConfirmationModal
|
||||
visible={showDeleteConfirm}
|
||||
onClose={() => (showDeleteConfirm = false)}
|
||||
onConfirm={confirmDelete}
|
||||
variant="danger"
|
||||
title="Aufgabe löschen?"
|
||||
message="Diese Aufgabe wird unwiderruflich gelöscht."
|
||||
confirmLabel="Löschen"
|
||||
cancelLabel="Abbrechen"
|
||||
/>
|
||||
|
||||
<style>
|
||||
.kanban-card {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@
|
|||
|
||||
// Priority labels
|
||||
const PRIORITY_LABELS: Record<TaskPriority, string> = {
|
||||
low: 'Niedrig',
|
||||
medium: 'Mittel',
|
||||
high: 'Hoch',
|
||||
low: 'Später',
|
||||
medium: 'Normal',
|
||||
high: 'Wichtig',
|
||||
urgent: 'Dringend',
|
||||
};
|
||||
|
||||
|
|
@ -98,49 +98,48 @@
|
|||
<div class="donut-container">
|
||||
<h3 class="donut-title">Prioritäten</h3>
|
||||
|
||||
<div class="donut-content">
|
||||
<div class="donut-chart">
|
||||
<svg viewBox="0 0 {SIZE} {SIZE}" class="donut-svg">
|
||||
{#each arcs as arc}
|
||||
<path
|
||||
d={arc.path}
|
||||
fill={arc.color}
|
||||
class="arc-segment"
|
||||
class:hovered={hoveredSegment === arc.priority}
|
||||
onmouseenter={() => (hoveredSegment = arc.priority)}
|
||||
onmouseleave={() => (hoveredSegment = null)}
|
||||
role="graphics-symbol"
|
||||
aria-label="{PRIORITY_LABELS[arc.priority]}: {arc.count}"
|
||||
>
|
||||
<title>{PRIORITY_LABELS[arc.priority]}: {arc.count} ({arc.percentage}%)</title>
|
||||
</path>
|
||||
{/each}
|
||||
|
||||
<!-- Center text -->
|
||||
<text x={CENTER} y={CENTER - 8} class="center-count">
|
||||
{total}
|
||||
</text>
|
||||
<text x={CENTER} y={CENTER + 12} class="center-label"> Aktiv </text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="donut-legend">
|
||||
{#each data as item}
|
||||
<div
|
||||
class="legend-item"
|
||||
class:active={hoveredSegment === item.priority}
|
||||
onmouseenter={() => (hoveredSegment = item.priority)}
|
||||
<!-- Chart centered -->
|
||||
<div class="donut-chart">
|
||||
<svg viewBox="0 0 {SIZE} {SIZE}" class="donut-svg">
|
||||
{#each arcs as arc}
|
||||
<path
|
||||
d={arc.path}
|
||||
fill={arc.color}
|
||||
class="arc-segment"
|
||||
class:hovered={hoveredSegment === arc.priority}
|
||||
onmouseenter={() => (hoveredSegment = arc.priority)}
|
||||
onmouseleave={() => (hoveredSegment = null)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
role="graphics-symbol"
|
||||
aria-label="{PRIORITY_LABELS[arc.priority]}: {arc.count}"
|
||||
>
|
||||
<span class="legend-color" style="background-color: {item.color}"></span>
|
||||
<span class="legend-label">{PRIORITY_LABELS[item.priority]}</span>
|
||||
<span class="legend-count">{item.count}</span>
|
||||
</div>
|
||||
<title>{PRIORITY_LABELS[arc.priority]}: {arc.count} ({arc.percentage}%)</title>
|
||||
</path>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Center text -->
|
||||
<text x={CENTER} y={CENTER - 8} class="center-count">
|
||||
{total}
|
||||
</text>
|
||||
<text x={CENTER} y={CENTER + 12} class="center-label"> Aktiv </text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Legend as horizontal grid below -->
|
||||
<div class="donut-legend">
|
||||
{#each data as item}
|
||||
<div
|
||||
class="legend-item"
|
||||
class:active={hoveredSegment === item.priority}
|
||||
onmouseenter={() => (hoveredSegment = item.priority)}
|
||||
onmouseleave={() => (hoveredSegment = null)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="legend-color" style="background-color: {item.color}"></span>
|
||||
<span class="legend-label">{PRIORITY_LABELS[item.priority]}</span>
|
||||
<span class="legend-count">{item.count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -166,20 +165,10 @@
|
|||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.donut-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.donut-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.donut-chart {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.donut-svg {
|
||||
|
|
@ -215,11 +204,9 @@
|
|||
}
|
||||
|
||||
.donut-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
|
|
@ -238,20 +225,23 @@
|
|||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--foreground));
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.legend-count {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,6 @@ export const tasksStore = {
|
|||
try {
|
||||
// Fetch all tasks without filter - let frontend handle filtering
|
||||
const allTasks = await tasksApi.getTasks({});
|
||||
console.log('API response - all tasks:', allTasks.length);
|
||||
tasks = allTasks;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch all tasks';
|
||||
|
|
@ -239,6 +238,47 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update task optimistically (for drag and drop)
|
||||
* Updates local state immediately, then syncs with server
|
||||
*/
|
||||
async updateTaskOptimistic(
|
||||
id: string,
|
||||
data: {
|
||||
dueDate?: string | null;
|
||||
isCompleted?: boolean;
|
||||
}
|
||||
) {
|
||||
// Optimistic update - immediately update local state
|
||||
const originalTask = tasks.find((t) => t.id === id);
|
||||
if (!originalTask) return;
|
||||
|
||||
tasks = tasks.map((t) => (t.id === id ? { ...t, ...data } : t));
|
||||
|
||||
try {
|
||||
// Handle completion state change first
|
||||
if (data.isCompleted !== undefined && data.isCompleted !== originalTask.isCompleted) {
|
||||
if (data.isCompleted) {
|
||||
const updatedTask = await tasksApi.completeTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
} else {
|
||||
const updatedTask = await tasksApi.uncompleteTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle due date change
|
||||
if (data.dueDate !== undefined) {
|
||||
const updatedTask = await tasksApi.updateTask(id, { dueDate: data.dueDate });
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
}
|
||||
} catch (e) {
|
||||
// Rollback on error
|
||||
console.error('Failed to update task:', e);
|
||||
tasks = tasks.map((t) => (t.id === id ? originalTask : t));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a task
|
||||
*/
|
||||
|
|
|
|||
181
apps/todo/apps/web/src/lib/utils/task-parser.ts
Normal file
181
apps/todo/apps/web/src/lib/utils/task-parser.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Task Parser for Todo App
|
||||
*
|
||||
* Extends the base parser with task-specific patterns:
|
||||
* - Priority: !hoch, !!, !!!, !dringend
|
||||
* - Project: @ProjectName
|
||||
*/
|
||||
|
||||
import {
|
||||
parseBaseInput,
|
||||
extractAtReference,
|
||||
combineDateAndTime,
|
||||
formatDatePreview,
|
||||
formatTimePreview,
|
||||
} from '@manacore/shared-utils';
|
||||
import type { TaskPriority } from '@todo/shared';
|
||||
|
||||
export interface ParsedTask {
|
||||
title: string;
|
||||
dueDate?: Date;
|
||||
priority?: TaskPriority;
|
||||
projectName?: string;
|
||||
labelNames: string[];
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Label {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ParsedTaskWithIds {
|
||||
title: string;
|
||||
dueDate?: string;
|
||||
priority?: TaskPriority;
|
||||
projectId?: string;
|
||||
labelIds: string[];
|
||||
}
|
||||
|
||||
// Priority patterns (task-specific)
|
||||
// Supports: später, normal, wichtig, dringend (with or without !) and shortcuts !, !!, !!!
|
||||
const PRIORITY_PATTERNS: { pattern: RegExp; priority: TaskPriority }[] = [
|
||||
{ pattern: /!{3,}|!?dringend\b/i, priority: 'urgent' },
|
||||
{ pattern: /!{2}|!?wichtig\b/i, priority: 'high' },
|
||||
{ pattern: /!?normal\b/i, priority: 'medium' },
|
||||
{ pattern: /!?sp[aä]ter\b/i, priority: 'low' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract priority from text
|
||||
*/
|
||||
function extractPriority(text: string): { priority?: TaskPriority; remaining: string } {
|
||||
for (const { pattern, priority } of PRIORITY_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
return {
|
||||
priority,
|
||||
remaining: text.replace(pattern, '').trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { priority: undefined, remaining: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse natural language task input
|
||||
*
|
||||
* Examples:
|
||||
* - "Meeting morgen 14 Uhr !hoch @Arbeit #wichtig"
|
||||
* - "Einkaufen heute #privat"
|
||||
* - "Report in 3 Tagen !!"
|
||||
*/
|
||||
export function parseTaskInput(input: string): ParsedTask {
|
||||
let text = input.trim();
|
||||
|
||||
// Extract priority first (task-specific)
|
||||
const priorityResult = extractPriority(text);
|
||||
text = priorityResult.remaining;
|
||||
const priority = priorityResult.priority;
|
||||
|
||||
// Extract project (@ProjectName) - task-specific
|
||||
const projectResult = extractAtReference(text);
|
||||
text = projectResult.remaining;
|
||||
const projectName = projectResult.value;
|
||||
|
||||
// Use base parser for common patterns (date, time, tags)
|
||||
const base = parseBaseInput(text);
|
||||
|
||||
// Combine date and time
|
||||
const dueDate = combineDateAndTime(base.date, base.time);
|
||||
|
||||
return {
|
||||
title: base.title,
|
||||
dueDate,
|
||||
priority,
|
||||
projectName,
|
||||
labelNames: base.tagNames,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve project and label names to IDs
|
||||
*/
|
||||
export function resolveTaskIds(
|
||||
parsed: ParsedTask,
|
||||
projects: Project[],
|
||||
labels: Label[]
|
||||
): ParsedTaskWithIds {
|
||||
let projectId: string | undefined;
|
||||
const labelIds: string[] = [];
|
||||
|
||||
// Find project by name (case-insensitive)
|
||||
if (parsed.projectName) {
|
||||
const project = projects.find(
|
||||
(p) => p.name.toLowerCase() === parsed.projectName!.toLowerCase()
|
||||
);
|
||||
if (project) {
|
||||
projectId = project.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Find labels by name (case-insensitive)
|
||||
for (const labelName of parsed.labelNames) {
|
||||
const label = labels.find((l) => l.name.toLowerCase() === labelName.toLowerCase());
|
||||
if (label) {
|
||||
labelIds.push(label.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: parsed.title,
|
||||
dueDate: parsed.dueDate?.toISOString(),
|
||||
priority: parsed.priority,
|
||||
projectId,
|
||||
labelIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format parsed task for preview display
|
||||
*/
|
||||
export function formatParsedTaskPreview(parsed: ParsedTask): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (parsed.dueDate) {
|
||||
let dateStr = `📅 ${formatDatePreview(parsed.dueDate)}`;
|
||||
|
||||
// Add time if not midnight
|
||||
if (parsed.dueDate.getHours() !== 0 || parsed.dueDate.getMinutes() !== 0) {
|
||||
dateStr += ` ${formatTimePreview({
|
||||
hours: parsed.dueDate.getHours(),
|
||||
minutes: parsed.dueDate.getMinutes(),
|
||||
})}`;
|
||||
}
|
||||
|
||||
parts.push(dateStr);
|
||||
}
|
||||
|
||||
if (parsed.priority) {
|
||||
const priorityLabels: Record<TaskPriority, string> = {
|
||||
low: '🟢 Später',
|
||||
medium: '🟡 Normal',
|
||||
high: '🟠 Wichtig',
|
||||
urgent: '🔴 Dringend',
|
||||
};
|
||||
parts.push(priorityLabels[parsed.priority]);
|
||||
}
|
||||
|
||||
if (parsed.projectName) {
|
||||
parts.push(`📁 ${parsed.projectName}`);
|
||||
}
|
||||
|
||||
if (parsed.labelNames.length > 0) {
|
||||
parts.push(`🏷️ ${parsed.labelNames.join(', ')}`);
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
|
@ -9,11 +9,13 @@
|
|||
PillDropdownItem,
|
||||
CommandBarItem,
|
||||
QuickAction,
|
||||
CreatePreview,
|
||||
} from '@manacore/shared-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||
import { labelsStore } from '$lib/stores/labels.svelte';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
|
|
@ -25,9 +27,11 @@
|
|||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import { filterHiddenNavItems } from '@manacore/shared-theme';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { getTasks } from '$lib/api/tasks';
|
||||
import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from '$lib/utils/task-parser';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('todo');
|
||||
|
|
@ -69,6 +73,35 @@
|
|||
goto(`/task/${item.id}`);
|
||||
}
|
||||
|
||||
// CommandBar create - parse input and show preview
|
||||
function handleCommandBarParseCreate(query: string): CreatePreview | null {
|
||||
if (!query.trim()) return null;
|
||||
|
||||
const parsed = parseTaskInput(query);
|
||||
const preview = formatParsedTaskPreview(parsed);
|
||||
|
||||
return {
|
||||
title: `"${parsed.title}" als Aufgabe erstellen`,
|
||||
subtitle: preview || 'Neue Aufgabe',
|
||||
};
|
||||
}
|
||||
|
||||
// CommandBar create - actually create the task
|
||||
async function handleCommandBarCreate(query: string): Promise<void> {
|
||||
if (!query.trim()) return;
|
||||
|
||||
const parsed = parseTaskInput(query);
|
||||
const resolved = resolveTaskIds(parsed, projectsStore.projects, labelsStore.labels);
|
||||
|
||||
await tasksStore.createTask({
|
||||
title: resolved.title,
|
||||
dueDate: resolved.dueDate,
|
||||
priority: resolved.priority,
|
||||
projectId: resolved.projectId,
|
||||
labelIds: resolved.labelIds,
|
||||
});
|
||||
}
|
||||
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
|
|
@ -124,31 +157,33 @@
|
|||
{ href: '/', label: 'Aufgaben', icon: 'list' },
|
||||
{ href: '/kanban', label: 'Kanban', icon: 'columns' },
|
||||
{ href: '/statistics', label: 'Statistiken', icon: 'chart' },
|
||||
{ href: '/labels', label: 'Labels', icon: 'tag' },
|
||||
{ href: '/tags', label: 'Tags', icon: 'tag' },
|
||||
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
||||
// Navigation items (base items + dynamic label items in sidebar mode)
|
||||
// Navigation items (base items + dynamic label items in sidebar mode, filtered by visibility settings)
|
||||
const navItems = $derived.by(() => {
|
||||
// In sidebar mode, add labels as sub-items if available
|
||||
// Start with base items, filter out hidden ones
|
||||
let items = filterHiddenNavItems('todo', baseNavItems, userSettings.nav.hiddenNavItems);
|
||||
|
||||
// In sidebar mode, add tags as sub-items if available
|
||||
if (isSidebarMode && labelsStore.labels.length > 0) {
|
||||
const labelItems: PillNavItem[] = labelsStore.labels.slice(0, 5).map((label) => ({
|
||||
href: `/label/${label.id}`,
|
||||
const tagItems: PillNavItem[] = labelsStore.labels.slice(0, 5).map((label) => ({
|
||||
href: `/tag/${label.id}`,
|
||||
label: label.name,
|
||||
icon: 'tag',
|
||||
}));
|
||||
|
||||
// Insert label items after "Labels" nav item
|
||||
const items = [...baseNavItems];
|
||||
const labelsIndex = items.findIndex((i) => i.href === '/labels');
|
||||
if (labelsIndex !== -1 && labelItems.length > 0) {
|
||||
items.splice(labelsIndex + 1, 0, ...labelItems);
|
||||
// Insert tag items after "Tags" nav item
|
||||
const tagsIndex = items.findIndex((i) => i.href === '/tags');
|
||||
if (tagsIndex !== -1 && tagItems.length > 0) {
|
||||
items = [...items];
|
||||
items.splice(tagsIndex + 1, 0, ...tagItems);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
return baseNavItems;
|
||||
return items;
|
||||
});
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-6) - use base items for consistent shortcuts
|
||||
|
|
@ -183,16 +218,20 @@
|
|||
function handleModeChange(isSidebar: boolean) {
|
||||
isSidebarMode = isSidebar;
|
||||
sidebarModeStore.set(isSidebar);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('todo-nav-sidebar', String(isSidebar));
|
||||
try {
|
||||
localStorage?.setItem('todo-nav-sidebar', String(isSidebar));
|
||||
} catch {
|
||||
// localStorage not available or quota exceeded
|
||||
}
|
||||
}
|
||||
|
||||
function handleCollapsedChange(collapsed: boolean) {
|
||||
isCollapsed = collapsed;
|
||||
collapsedStore.set(collapsed);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('todo-nav-collapsed', String(collapsed));
|
||||
try {
|
||||
localStorage?.setItem('todo-nav-collapsed', String(collapsed));
|
||||
} catch {
|
||||
// localStorage not available or quota exceeded
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -231,18 +270,26 @@
|
|||
goto(userSettings.startPage, { replaceState: true });
|
||||
}
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('todo-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
isSidebarMode = true;
|
||||
sidebarModeStore.set(true);
|
||||
// Initialize sidebar mode from localStorage (with error handling for private browsing)
|
||||
try {
|
||||
const savedSidebar = localStorage?.getItem('todo-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
isSidebarMode = true;
|
||||
sidebarModeStore.set(true);
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available (private browsing, quota exceeded, etc.)
|
||||
}
|
||||
|
||||
// Initialize collapsed state from localStorage
|
||||
const savedCollapsed = localStorage.getItem('todo-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
isCollapsed = true;
|
||||
collapsedStore.set(true);
|
||||
try {
|
||||
const savedCollapsed = localStorage?.getItem('todo-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
isCollapsed = true;
|
||||
collapsedStore.set(true);
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
|
||||
// Register Service Worker for PWA
|
||||
|
|
@ -326,9 +373,13 @@
|
|||
onSearch={handleCommandBarSearch}
|
||||
onSelect={handleCommandBarSelect}
|
||||
quickActions={commandBarQuickActions}
|
||||
placeholder="Aufgabe suchen..."
|
||||
placeholder="Aufgabe suchen oder erstellen..."
|
||||
emptyText="Keine Aufgaben gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCommandBarCreate}
|
||||
onParseCreate={handleCommandBarParseCreate}
|
||||
createText="Als Aufgabe erstellen"
|
||||
createShortcut="⌘↵"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { format, addDays, startOfDay } from 'date-fns';
|
||||
import { format, addDays, subDays, startOfDay } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { ListChecks } from '@manacore/shared-icons';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
|
||||
import TaskEditModal from '$lib/components/TaskEditModal.svelte';
|
||||
import { TaskListSkeleton } from '$lib/components/skeletons';
|
||||
import type { Task } from '@todo/shared';
|
||||
import type { Task, UpdateTaskInput } from '@todo/shared';
|
||||
|
||||
let isLoading = $state(true);
|
||||
let editingTask = $state<Task | null>(null);
|
||||
|
|
@ -39,13 +39,24 @@
|
|||
let todayTasks = $derived(tasksStore.todayTasks);
|
||||
let completedTasks = $derived(tasksStore.completedTasks);
|
||||
|
||||
// Group upcoming tasks by day
|
||||
// Tomorrow's tasks
|
||||
let tomorrowDate = $derived(addDays(startOfDay(new Date()), 1));
|
||||
let dayAfterTomorrowDate = $derived(addDays(startOfDay(new Date()), 2));
|
||||
let tomorrowTasks = $derived(
|
||||
tasksStore.tasks.filter((task) => {
|
||||
if (!task.dueDate || task.isCompleted) return false;
|
||||
const taskDate = startOfDay(new Date(task.dueDate));
|
||||
return taskDate.getTime() === tomorrowDate.getTime();
|
||||
})
|
||||
);
|
||||
|
||||
// Group upcoming tasks by day (starting from day after tomorrow)
|
||||
let groupedUpcomingTasks = $derived(() => {
|
||||
const groups: { date: Date; label: string; tasks: Task[] }[] = [];
|
||||
const today = startOfDay(new Date());
|
||||
|
||||
// Start from tomorrow (day 1) through day 7
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
// Start from day after tomorrow (day 2) through day 7
|
||||
for (let i = 2; i <= 7; i++) {
|
||||
const date = addDays(today, i);
|
||||
const dayTasks = tasksStore.tasks.filter((task) => {
|
||||
if (!task.dueDate || task.isCompleted) return false;
|
||||
|
|
@ -54,13 +65,7 @@
|
|||
});
|
||||
|
||||
if (dayTasks.length > 0) {
|
||||
let label: string;
|
||||
if (i === 1) {
|
||||
label = 'Morgen';
|
||||
} else {
|
||||
label = format(date, 'EEEE, d. MMMM', { locale: de });
|
||||
}
|
||||
|
||||
const label = format(date, 'EEEE, d. MMMM', { locale: de });
|
||||
groups.push({ date, label, tasks: dayTasks });
|
||||
}
|
||||
}
|
||||
|
|
@ -68,7 +73,7 @@
|
|||
return groups;
|
||||
});
|
||||
|
||||
// Total upcoming count
|
||||
// Total upcoming count (excluding tomorrow)
|
||||
let upcomingCount = $derived(
|
||||
groupedUpcomingTasks().reduce((sum, group) => sum + group.tasks.length, 0)
|
||||
);
|
||||
|
|
@ -77,6 +82,7 @@
|
|||
let allEmpty = $derived(
|
||||
overdueTasks.length === 0 &&
|
||||
todayTasks.length === 0 &&
|
||||
tomorrowTasks.length === 0 &&
|
||||
upcomingCount === 0 &&
|
||||
completedTasks.length === 0
|
||||
);
|
||||
|
|
@ -90,7 +96,7 @@
|
|||
editingTask = null;
|
||||
}
|
||||
|
||||
async function handleSaveTask(data: Partial<Task>) {
|
||||
async function handleSaveTask(data: UpdateTaskInput) {
|
||||
if (!editingTask) return;
|
||||
|
||||
try {
|
||||
|
|
@ -98,8 +104,8 @@
|
|||
await tasksStore.updateTask(editingTask.id, data);
|
||||
|
||||
// Update labels if provided
|
||||
if ('labelIds' in data) {
|
||||
await tasksStore.updateLabels(editingTask.id, (data as any).labelIds);
|
||||
if (data.labelIds !== undefined) {
|
||||
await tasksStore.updateLabels(editingTask.id, data.labelIds);
|
||||
}
|
||||
|
||||
closeEditModal();
|
||||
|
|
@ -116,6 +122,32 @@
|
|||
console.error('Failed to delete task:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop handler - uses optimistic updates for smooth UX
|
||||
function handleTaskDrop(taskId: string, targetDate: Date | 'completed' | 'overdue') {
|
||||
const task = tasksStore.tasks.find((t) => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
if (targetDate === 'completed') {
|
||||
// Mark task as completed (optimistic)
|
||||
if (!task.isCompleted) {
|
||||
tasksStore.updateTaskOptimistic(taskId, { isCompleted: true });
|
||||
}
|
||||
} else if (targetDate === 'overdue') {
|
||||
// Set to yesterday (optimistic)
|
||||
const yesterday = subDays(startOfDay(new Date()), 1);
|
||||
tasksStore.updateTaskOptimistic(taskId, {
|
||||
dueDate: yesterday.toISOString(),
|
||||
isCompleted: task.isCompleted ? false : undefined,
|
||||
});
|
||||
} else {
|
||||
// Set to specific date (optimistic)
|
||||
tasksStore.updateTaskOptimistic(taskId, {
|
||||
dueDate: targetDate.toISOString(),
|
||||
isCompleted: task.isCompleted ? false : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -155,7 +187,13 @@
|
|||
variant="warning"
|
||||
defaultOpen={true}
|
||||
>
|
||||
<TaskList tasks={overdueTasks} onEditTask={openEditModal} />
|
||||
<TaskList
|
||||
tasks={overdueTasks}
|
||||
enableDragDrop
|
||||
dropTargetDate="overdue"
|
||||
onTaskDrop={handleTaskDrop}
|
||||
onEditTask={openEditModal}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
{/if}
|
||||
|
||||
|
|
@ -167,13 +205,30 @@
|
|||
variant="default"
|
||||
defaultOpen={true}
|
||||
>
|
||||
{#if todayTasks.length === 0}
|
||||
<div class="text-center py-6 text-muted-foreground">
|
||||
<p>Keine Aufgaben für heute</p>
|
||||
</div>
|
||||
{:else}
|
||||
<TaskList tasks={todayTasks} onEditTask={openEditModal} />
|
||||
{/if}
|
||||
<TaskList
|
||||
tasks={todayTasks}
|
||||
enableDragDrop
|
||||
dropTargetDate={startOfDay(new Date())}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
onEditTask={openEditModal}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Tomorrow Section -->
|
||||
<CollapsibleSection
|
||||
title="Morgen"
|
||||
count={tomorrowTasks.length}
|
||||
icon="upcoming"
|
||||
variant="default"
|
||||
defaultOpen={true}
|
||||
>
|
||||
<TaskList
|
||||
tasks={tomorrowTasks}
|
||||
enableDragDrop
|
||||
dropTargetDate={tomorrowDate}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
onEditTask={openEditModal}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Upcoming Section -->
|
||||
|
|
@ -184,39 +239,49 @@
|
|||
variant="default"
|
||||
defaultOpen={true}
|
||||
>
|
||||
{#if upcomingCount === 0}
|
||||
<div class="text-center py-6 text-muted-foreground">
|
||||
<p>Keine anstehenden Aufgaben</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each groupedUpcomingTasks() as group}
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-muted-foreground mb-2 pl-2">
|
||||
{group.label} ({group.tasks.length})
|
||||
</h3>
|
||||
<TaskList tasks={group.tasks} onEditTask={openEditModal} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-4">
|
||||
{#each groupedUpcomingTasks() as group}
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-muted-foreground mb-2 pl-2">
|
||||
{group.label} ({group.tasks.length})
|
||||
</h3>
|
||||
<TaskList
|
||||
tasks={group.tasks}
|
||||
enableDragDrop
|
||||
dropTargetDate={group.date}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
onEditTask={openEditModal}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if upcomingCount === 0}
|
||||
<!-- Empty drop zone for day after tomorrow -->
|
||||
<TaskList
|
||||
tasks={[]}
|
||||
enableDragDrop
|
||||
dropTargetDate={dayAfterTomorrowDate}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Completed Section - collapsed by default -->
|
||||
<!-- Completed Section -->
|
||||
<CollapsibleSection
|
||||
title="Erledigt"
|
||||
count={completedTasks.length}
|
||||
icon="completed"
|
||||
variant="success"
|
||||
defaultOpen={false}
|
||||
defaultOpen={true}
|
||||
>
|
||||
{#if completedTasks.length === 0}
|
||||
<div class="text-center py-6 text-muted-foreground">
|
||||
<p>Noch keine erledigten Aufgaben</p>
|
||||
</div>
|
||||
{:else}
|
||||
<TaskList tasks={completedTasks} showCompleted onEditTask={openEditModal} />
|
||||
{/if}
|
||||
<TaskList
|
||||
tasks={completedTasks}
|
||||
enableDragDrop
|
||||
dropTargetDate="completed"
|
||||
onTaskDrop={handleTaskDrop}
|
||||
showCompleted
|
||||
onEditTask={openEditModal}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,314 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { TagList, TagEditModal, type Tag } from '@manacore/shared-ui';
|
||||
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
|
||||
import { labelsStore } from '$lib/stores/labels.svelte';
|
||||
import type { Label } from '@todo/shared';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let showModal = $state(false);
|
||||
let editingLabel = $state<Label | null>(null);
|
||||
|
||||
const filteredLabels = $derived.by(() => {
|
||||
if (!searchQuery.trim()) return labelsStore.labels;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return labelsStore.labels.filter((l) => l.name.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
// Convert Label to Tag type for shared-ui components
|
||||
function labelToTag(label: Label): Tag {
|
||||
return {
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
};
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingLabel = null;
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEditModal(tag: Tag) {
|
||||
const label = labelsStore.labels.find((l) => l.id === tag.id);
|
||||
if (label) {
|
||||
editingLabel = label;
|
||||
showModal = true;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal = false;
|
||||
editingLabel = null;
|
||||
}
|
||||
|
||||
async function handleSave(name: string, color: string) {
|
||||
try {
|
||||
if (editingLabel) {
|
||||
await labelsStore.updateLabel(editingLabel.id, { name, color });
|
||||
} else {
|
||||
await labelsStore.createLabel({ name, color });
|
||||
}
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.error('Failed to save label:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!editingLabel) return;
|
||||
|
||||
try {
|
||||
await labelsStore.deleteLabel(editingLabel.id);
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete label:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteFromList(tag: Tag) {
|
||||
if (!confirm(`Label "${tag.name}" wirklich löschen?`)) return;
|
||||
|
||||
try {
|
||||
await labelsStore.deleteLabel(tag.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete label:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLabelClick(tag: Tag) {
|
||||
goto(`/label/${tag.id}`);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (labelsStore.labels.length === 0) {
|
||||
labelsStore.fetchLabels();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Labels - Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<a href="/" class="back-button" aria-label="Zurück">
|
||||
<CaretLeft size={20} weight="bold" />
|
||||
</a>
|
||||
<h1 class="title">Labels</h1>
|
||||
<button onclick={openCreateModal} class="add-button" aria-label="Neues Label">
|
||||
<Plus size={20} weight="bold" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-wrapper">
|
||||
<MagnifyingGlass size={20} class="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Labels durchsuchen..."
|
||||
bind:value={searchQuery}
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if labelsStore.error}
|
||||
<div class="error-banner" role="alert">
|
||||
<span>{labelsStore.error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Label List using shared component -->
|
||||
<TagList
|
||||
tags={filteredLabels.map(labelToTag)}
|
||||
loading={labelsStore.loading}
|
||||
onEdit={openEditModal}
|
||||
onDelete={handleDeleteFromList}
|
||||
onClick={handleLabelClick}
|
||||
emptyMessage={searchQuery ? 'Keine Labels gefunden' : 'Keine Labels vorhanden'}
|
||||
emptyDescription={searchQuery
|
||||
? `Kein Label für "${searchQuery}" gefunden`
|
||||
: 'Erstelle dein erstes Label'}
|
||||
/>
|
||||
|
||||
{#if !labelsStore.loading && labelsStore.labels.length > 0}
|
||||
<p class="labels-count">
|
||||
{labelsStore.labels.length}
|
||||
{labelsStore.labels.length === 1 ? 'Label' : 'Labels'}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if !labelsStore.loading && labelsStore.labels.length === 0 && !searchQuery}
|
||||
<div class="empty-cta">
|
||||
<button onclick={openCreateModal} class="btn btn-primary">
|
||||
<Plus size={16} weight="bold" />
|
||||
Neues Label
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal using shared component -->
|
||||
<TagEditModal
|
||||
tag={editingLabel ? labelToTag(editingLabel) : null}
|
||||
isOpen={showModal}
|
||||
onClose={closeModal}
|
||||
onSave={handleSave}
|
||||
onDelete={editingLabel ? handleDelete : undefined}
|
||||
title={editingLabel ? 'Label bearbeiten' : 'Neues Label'}
|
||||
saveLabel={editingLabel ? 'Speichern' : 'Erstellen'}
|
||||
deleteLabel="Löschen"
|
||||
cancelLabel="Abbrechen"
|
||||
namePlaceholder="Label Name"
|
||||
colorLabel="Farbe"
|
||||
previewLabel="Vorschau"
|
||||
deleteConfirmMessage={`Label "${editingLabel?.name || ''}" wirklich löschen?`}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 2rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.2);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-wrapper :global(.search-icon) {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem 0.875rem 3rem;
|
||||
border: 1.5px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.9375rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
border: 1px solid hsl(0 84% 60% / 0.3);
|
||||
border-radius: 0.75rem;
|
||||
color: hsl(0 84% 60%);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Count */
|
||||
.labels-count {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Empty CTA */
|
||||
.empty-cta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.625rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
import { todoSettings, type TodoView, type KanbanCardSize } from '$lib/stores/settings.svelte';
|
||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||
import type { TaskPriority } from '@todo/shared';
|
||||
import { PRIORITY_OPTIONS } from '@todo/shared';
|
||||
import {
|
||||
SettingsPage,
|
||||
SettingsSection,
|
||||
|
|
@ -20,13 +21,8 @@
|
|||
GlobalSettingsSection,
|
||||
} from '@manacore/shared-ui';
|
||||
|
||||
// Options for selects
|
||||
const priorityOptions = [
|
||||
{ value: 'low', label: 'Niedrig' },
|
||||
{ value: 'medium', label: 'Mittel' },
|
||||
{ value: 'high', label: 'Hoch' },
|
||||
{ value: 'urgent', label: 'Dringend' },
|
||||
];
|
||||
// Use shared priority options (without color)
|
||||
const priorityOptions = PRIORITY_OPTIONS.map((p) => ({ value: p.value, label: p.label }));
|
||||
|
||||
const viewOptions = [
|
||||
{ value: 'inbox', label: 'Inbox' },
|
||||
|
|
@ -129,7 +125,20 @@
|
|||
</SettingsSection>
|
||||
|
||||
<!-- Global Settings Section (synced across all apps) -->
|
||||
<GlobalSettingsSection {userSettings} appId="todo" />
|
||||
<GlobalSettingsSection
|
||||
{userSettings}
|
||||
appId="todo"
|
||||
navItems={[
|
||||
{ href: '/', label: 'Aufgaben', icon: 'list' },
|
||||
{ href: '/kanban', label: 'Kanban', icon: 'columns' },
|
||||
{ href: '/statistics', label: 'Statistiken', icon: 'chart' },
|
||||
{ href: '/tags', label: 'Tags', icon: 'tag' },
|
||||
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
]}
|
||||
alwaysVisibleHrefs={['/', '/settings']}
|
||||
/>
|
||||
|
||||
<!-- Task Behavior Section -->
|
||||
<SettingsSection title="Task-Verhalten">
|
||||
|
|
|
|||
|
|
@ -15,16 +15,16 @@
|
|||
let editingTask = $state<Task | null>(null);
|
||||
let showEditModal = $state(false);
|
||||
|
||||
// Get label ID from URL
|
||||
const labelId = $derived($page.params.id ?? '');
|
||||
// Get tag ID from URL
|
||||
const tagId = $derived($page.params.id ?? '');
|
||||
|
||||
// Get label from store
|
||||
const label = $derived(labelsStore.getById(labelId));
|
||||
// Get tag from store
|
||||
const tag = $derived(labelsStore.getById(tagId));
|
||||
|
||||
// Get tasks with this label
|
||||
const labelTasks = $derived(labelId ? tasksStore.getTasksByLabel(labelId) : []);
|
||||
const incompleteTasks = $derived(labelTasks.filter((t) => !t.isCompleted));
|
||||
const completedTasks = $derived(labelTasks.filter((t) => t.isCompleted));
|
||||
// Get tasks with this tag
|
||||
const tagTasks = $derived(tagId ? tasksStore.getTasksByLabel(tagId) : []);
|
||||
const incompleteTasks = $derived(tagTasks.filter((t) => !t.isCompleted));
|
||||
const completedTasks = $derived(tagTasks.filter((t) => t.isCompleted));
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
|
|
@ -33,12 +33,12 @@
|
|||
}
|
||||
|
||||
try {
|
||||
// Ensure labels are loaded
|
||||
// Ensure tags are loaded
|
||||
if (labelsStore.labels.length === 0) {
|
||||
await labelsStore.fetchLabels();
|
||||
}
|
||||
|
||||
// Fetch all tasks to filter by label
|
||||
// Fetch all tasks to filter by tag
|
||||
await tasksStore.fetchAllTasks();
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
|
|
@ -88,7 +88,7 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{label?.name || 'Label'} - Todo</title>
|
||||
<title>{tag?.name || 'Tag'} - Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-container">
|
||||
|
|
@ -98,39 +98,39 @@
|
|||
<CaretLeft size={20} weight="bold" />
|
||||
</a>
|
||||
<div class="header-content">
|
||||
{#if label}
|
||||
<div class="label-icon" style="background-color: {label.color}20">
|
||||
<div class="label-dot" style="background-color: {label.color}"></div>
|
||||
{#if tag}
|
||||
<div class="tag-icon" style="background-color: {tag.color}20">
|
||||
<div class="tag-dot" style="background-color: {tag.color}"></div>
|
||||
</div>
|
||||
<h1 class="title">{label.name}</h1>
|
||||
<h1 class="title">{tag.name}</h1>
|
||||
{:else}
|
||||
<h1 class="title">Label</h1>
|
||||
<h1 class="title">Tag</h1>
|
||||
{/if}
|
||||
</div>
|
||||
<a href="/labels" class="manage-button" aria-label="Labels verwalten">
|
||||
<a href="/tags" class="manage-button" aria-label="Tags verwalten">
|
||||
<Tag size={20} weight="bold" />
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{#if isLoading}
|
||||
<TaskListSkeleton />
|
||||
{:else if !label}
|
||||
{:else if !tag}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<Tag size={40} weight="light" />
|
||||
</div>
|
||||
<h2 class="empty-title">Label nicht gefunden</h2>
|
||||
<p class="empty-description">Dieses Label existiert nicht mehr.</p>
|
||||
<a href="/labels" class="btn btn-primary">Zu den Labels</a>
|
||||
<h2 class="empty-title">Tag nicht gefunden</h2>
|
||||
<p class="empty-description">Dieser Tag existiert nicht mehr.</p>
|
||||
<a href="/tags" class="btn btn-primary">Zu den Tags</a>
|
||||
</div>
|
||||
{:else if labelTasks.length === 0}
|
||||
{:else if tagTasks.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon" style="background-color: {label.color}20">
|
||||
<Tag size={40} weight="light" style="color: {label.color}" />
|
||||
<div class="empty-icon" style="background-color: {tag.color}20">
|
||||
<Tag size={40} weight="light" style="color: {tag.color}" />
|
||||
</div>
|
||||
<h2 class="empty-title">Keine Aufgaben</h2>
|
||||
<p class="empty-description">
|
||||
Es gibt keine Aufgaben mit dem Label "{label.name}".
|
||||
Es gibt keine Aufgaben mit dem Tag "{tag.name}".
|
||||
</p>
|
||||
<a href="/" class="btn btn-primary">Aufgabe erstellen</a>
|
||||
</div>
|
||||
|
|
@ -156,8 +156,8 @@
|
|||
{/if}
|
||||
|
||||
<p class="task-count">
|
||||
{labelTasks.length}
|
||||
{labelTasks.length === 1 ? 'Aufgabe' : 'Aufgaben'}
|
||||
{tagTasks.length}
|
||||
{tagTasks.length === 1 ? 'Aufgabe' : 'Aufgaben'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -196,7 +196,7 @@
|
|||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.label-icon {
|
||||
.tag-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.625rem;
|
||||
|
|
@ -205,7 +205,7 @@
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.label-dot {
|
||||
.tag-dot {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
502
apps/todo/apps/web/src/routes/(app)/tags/+page.svelte
Normal file
502
apps/todo/apps/web/src/routes/(app)/tags/+page.svelte
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { TagList, TagEditModal, ConfirmationModal, type Tag } from '@manacore/shared-ui';
|
||||
import { MagnifyingGlass, Plus, CaretLeft, Check } from '@manacore/shared-icons';
|
||||
import { labelsStore } from '$lib/stores/labels.svelte';
|
||||
import type { Label } from '@todo/shared';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let showModal = $state(false);
|
||||
let editingLabel = $state<Label | null>(null);
|
||||
let showDeleteConfirm = $state(false);
|
||||
let labelToDelete = $state<Tag | null>(null);
|
||||
|
||||
// Inline create state
|
||||
let newTagName = $state('');
|
||||
let newTagColor = $state('#8b5cf6');
|
||||
let isCreating = $state(false);
|
||||
let newTagInputRef = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// Predefined color palette
|
||||
const colorPalette = [
|
||||
'#ef4444', // red
|
||||
'#f97316', // orange
|
||||
'#eab308', // yellow
|
||||
'#22c55e', // green
|
||||
'#14b8a6', // teal
|
||||
'#3b82f6', // blue
|
||||
'#8b5cf6', // violet
|
||||
'#ec4899', // pink
|
||||
'#6b7280', // gray
|
||||
];
|
||||
|
||||
const filteredLabels = $derived.by(() => {
|
||||
if (!searchQuery.trim()) return labelsStore.labels;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return labelsStore.labels.filter((l) => l.name.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
// Convert Label to Tag type for shared-ui components
|
||||
function labelToTag(label: Label): Tag {
|
||||
return {
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
};
|
||||
}
|
||||
|
||||
// Inline create handlers
|
||||
async function handleInlineCreate() {
|
||||
if (!newTagName.trim() || isCreating) return;
|
||||
|
||||
isCreating = true;
|
||||
try {
|
||||
await labelsStore.createLabel({ name: newTagName.trim(), color: newTagColor });
|
||||
newTagName = '';
|
||||
newTagColor = '#8b5cf6';
|
||||
} catch (e) {
|
||||
console.error('Failed to create tag:', e);
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInlineKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && newTagName.trim()) {
|
||||
e.preventDefault();
|
||||
handleInlineCreate();
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(tag: Tag) {
|
||||
const label = labelsStore.labels.find((l) => l.id === tag.id);
|
||||
if (label) {
|
||||
editingLabel = label;
|
||||
showModal = true;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal = false;
|
||||
editingLabel = null;
|
||||
}
|
||||
|
||||
async function handleSave(name: string, color: string) {
|
||||
try {
|
||||
if (editingLabel) {
|
||||
await labelsStore.updateLabel(editingLabel.id, { name, color });
|
||||
}
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.error('Failed to save label:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!editingLabel) return;
|
||||
|
||||
try {
|
||||
await labelsStore.deleteLabel(editingLabel.id);
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete label:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteFromList(tag: Tag) {
|
||||
labelToDelete = tag;
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async function confirmDeleteLabel() {
|
||||
if (!labelToDelete) return;
|
||||
|
||||
try {
|
||||
await labelsStore.deleteLabel(labelToDelete.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete label:', e);
|
||||
} finally {
|
||||
showDeleteConfirm = false;
|
||||
labelToDelete = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTagClick(tag: Tag) {
|
||||
goto(`/tag/${tag.id}`);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (labelsStore.labels.length === 0) {
|
||||
labelsStore.fetchLabels();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tags - Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<a href="/" class="back-button" aria-label="Zurück">
|
||||
<CaretLeft size={20} weight="bold" />
|
||||
</a>
|
||||
<h1 class="title">Tags</h1>
|
||||
</header>
|
||||
|
||||
<!-- Inline Create Form -->
|
||||
<div class="inline-create-form">
|
||||
<div class="create-input-row">
|
||||
<!-- Name input -->
|
||||
<input
|
||||
bind:this={newTagInputRef}
|
||||
type="text"
|
||||
placeholder="Neuer Tag..."
|
||||
bind:value={newTagName}
|
||||
onkeydown={handleInlineKeydown}
|
||||
class="create-input"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
|
||||
<!-- Submit button -->
|
||||
<button
|
||||
type="button"
|
||||
class="create-button"
|
||||
onclick={handleInlineCreate}
|
||||
disabled={!newTagName.trim() || isCreating}
|
||||
aria-label="Tag erstellen"
|
||||
>
|
||||
{#if isCreating}
|
||||
<div class="spinner"></div>
|
||||
{:else}
|
||||
<Plus size={18} weight="bold" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Color palette (always visible) -->
|
||||
<div class="color-row">
|
||||
<div class="color-palette">
|
||||
{#each colorPalette as color}
|
||||
<button
|
||||
type="button"
|
||||
class="color-option"
|
||||
class:active={newTagColor === color}
|
||||
style="background-color: {color}"
|
||||
onclick={() => (newTagColor = color)}
|
||||
aria-label="Farbe {color}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
{#if newTagName.trim()}
|
||||
<div class="create-preview">
|
||||
<span class="preview-tag" style="--tag-color: {newTagColor}">
|
||||
{newTagName}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-wrapper">
|
||||
<MagnifyingGlass size={20} class="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tags durchsuchen..."
|
||||
bind:value={searchQuery}
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if labelsStore.error}
|
||||
<div class="error-banner" role="alert">
|
||||
<span>{labelsStore.error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tag List using shared component -->
|
||||
<TagList
|
||||
tags={filteredLabels.map(labelToTag)}
|
||||
loading={labelsStore.loading}
|
||||
onEdit={openEditModal}
|
||||
onDelete={handleDeleteFromList}
|
||||
onClick={handleTagClick}
|
||||
emptyMessage={searchQuery ? 'Keine Tags gefunden' : 'Keine Tags vorhanden'}
|
||||
emptyDescription={searchQuery
|
||||
? `Kein Tag für "${searchQuery}" gefunden`
|
||||
: 'Erstelle deinen ersten Tag'}
|
||||
/>
|
||||
|
||||
{#if !labelsStore.loading && labelsStore.labels.length > 0}
|
||||
<p class="tags-count">
|
||||
{labelsStore.labels.length}
|
||||
{labelsStore.labels.length === 1 ? 'Tag' : 'Tags'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal using shared component -->
|
||||
<TagEditModal
|
||||
tag={editingLabel ? labelToTag(editingLabel) : null}
|
||||
isOpen={showModal}
|
||||
onClose={closeModal}
|
||||
onSave={handleSave}
|
||||
onDelete={editingLabel ? handleDelete : undefined}
|
||||
title={editingLabel ? 'Tag bearbeiten' : 'Neuer Tag'}
|
||||
saveLabel={editingLabel ? 'Speichern' : 'Erstellen'}
|
||||
deleteLabel="Löschen"
|
||||
cancelLabel="Abbrechen"
|
||||
namePlaceholder="Tag Name"
|
||||
colorLabel="Farbe"
|
||||
previewLabel="Vorschau"
|
||||
deleteConfirmMessage={`Tag "${editingLabel?.name || ''}" wirklich löschen?`}
|
||||
/>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<ConfirmationModal
|
||||
visible={showDeleteConfirm}
|
||||
onClose={() => {
|
||||
showDeleteConfirm = false;
|
||||
labelToDelete = null;
|
||||
}}
|
||||
onConfirm={confirmDeleteLabel}
|
||||
variant="danger"
|
||||
title="Tag löschen?"
|
||||
message={`Der Tag "${labelToDelete?.name ?? ''}" wird unwiderruflich gelöscht.`}
|
||||
confirmLabel="Löschen"
|
||||
cancelLabel="Abbrechen"
|
||||
/>
|
||||
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 2rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.2);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Inline Create Form */
|
||||
.inline-create-form {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border: 1.5px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.create-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Color Row */
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.color-palette {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-option {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.color-option:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.color-option.active {
|
||||
border-color: hsl(var(--foreground));
|
||||
box-shadow: 0 0 0 2px hsl(var(--background));
|
||||
}
|
||||
|
||||
/* Create Input */
|
||||
.create-input {
|
||||
flex: 1;
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1.5px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.9375rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.create-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.create-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.create-input::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Create Button */
|
||||
.create-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.create-button:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
.create-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid hsl(var(--primary-foreground) / 0.3);
|
||||
border-top-color: hsl(var(--primary-foreground));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Preview */
|
||||
.create-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border-radius: 9999px;
|
||||
background: var(--tag-color);
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-wrapper :global(.search-icon) {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem 0.875rem 3rem;
|
||||
border: 1.5px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.9375rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
border: 1px solid hsl(0 84% 60% / 0.3);
|
||||
border-radius: 0.75rem;
|
||||
color: hsl(0 84% 60%);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Count */
|
||||
.tags-count {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
const CACHE_NAME = 'todo-v1';
|
||||
const CACHE_NAME = 'todo-v2';
|
||||
const OFFLINE_URL = '/offline.html';
|
||||
|
||||
// Assets, die immer gecacht werden sollen
|
||||
|
|
@ -8,23 +8,16 @@ const STATIC_CACHE_URLS = ['/', '/offline.html', '/icons/icon.svg', '/manifest.j
|
|||
const CACHE_STRATEGIES = {
|
||||
// Netzwerk zuerst, dann Cache (für HTML/Navigation)
|
||||
networkFirst: [/\/$/, /\.html$/, /^\/kanban/, /^\/settings/, /^\/mana/, /^\/feedback/],
|
||||
// Cache zuerst, dann Netzwerk (für Assets)
|
||||
// Cache zuerst, dann Netzwerk (für Assets) - nur für gebaute Assets, nicht /src/
|
||||
cacheFirst: [
|
||||
/\.css$/,
|
||||
/\.js$/,
|
||||
/\/_app\//, // SvelteKit gebaute Assets
|
||||
/\.woff2?$/,
|
||||
/\.ttf$/,
|
||||
/\.otf$/,
|
||||
/\.svg$/,
|
||||
/\.png$/,
|
||||
/\.jpg$/,
|
||||
/\.jpeg$/,
|
||||
/\.webp$/,
|
||||
/\.ico$/,
|
||||
/\/_app\//,
|
||||
],
|
||||
// Nur Netzwerk (für API-Calls)
|
||||
networkOnly: [/\/api\//, /localhost:3018/],
|
||||
// Nur Netzwerk (für API-Calls und Dev-Server)
|
||||
networkOnly: [/\/api\//, /localhost:3018/, /^\/src\//, /^\/@/, /^\/node_modules\//],
|
||||
};
|
||||
|
||||
// Service Worker Installation
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ export const REMINDER_PRESETS = [
|
|||
{ label: '1 week before', minutes: 10080 },
|
||||
] as const;
|
||||
|
||||
// Re-export task-specific constants (German localized versions)
|
||||
export * from './task';
|
||||
|
||||
// View types
|
||||
export type ViewType =
|
||||
| 'inbox'
|
||||
|
|
|
|||
55
apps/todo/packages/shared/src/constants/task.ts
Normal file
55
apps/todo/packages/shared/src/constants/task.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { TaskPriority, TaskStatus } from '../types/task';
|
||||
|
||||
export interface PriorityOption {
|
||||
value: TaskPriority;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface StatusOption {
|
||||
value: TaskStatus;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface RecurrenceOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const PRIORITY_OPTIONS: PriorityOption[] = [
|
||||
{ value: 'low', label: 'Später', color: '#22c55e' },
|
||||
{ value: 'medium', label: 'Normal', color: '#eab308' },
|
||||
{ value: 'high', label: 'Wichtig', color: '#f97316' },
|
||||
{ value: 'urgent', label: 'Dringend', color: '#ef4444' },
|
||||
];
|
||||
|
||||
export const STATUS_OPTIONS: StatusOption[] = [
|
||||
{ value: 'pending', label: 'Offen' },
|
||||
{ value: 'in_progress', label: 'In Arbeit' },
|
||||
{ value: 'completed', label: 'Erledigt' },
|
||||
{ value: 'cancelled', label: 'Abgebrochen' },
|
||||
];
|
||||
|
||||
export const RECURRENCE_OPTIONS: RecurrenceOption[] = [
|
||||
{ value: '', label: 'Keine Wiederholung' },
|
||||
{ value: 'FREQ=DAILY', label: 'Täglich' },
|
||||
{ value: 'FREQ=WEEKLY', label: 'Wöchentlich' },
|
||||
{ value: 'FREQ=WEEKLY;INTERVAL=2', label: 'Alle 2 Wochen' },
|
||||
{ value: 'FREQ=MONTHLY', label: 'Monatlich' },
|
||||
{ value: 'FREQ=YEARLY', label: 'Jährlich' },
|
||||
];
|
||||
|
||||
// Fibonacci sequence for story points
|
||||
export const STORYPOINT_OPTIONS = [1, 2, 3, 5, 8, 13, 21] as const;
|
||||
|
||||
// Helper to get priority label
|
||||
export function getPriorityLabel(priority: TaskPriority): string {
|
||||
const option = PRIORITY_OPTIONS.find((p) => p.value === priority);
|
||||
return option?.label ?? priority;
|
||||
}
|
||||
|
||||
// Helper to get status label
|
||||
export function getStatusLabel(status: TaskStatus): string {
|
||||
const option = STATUS_OPTIONS.find((s) => s.value === status);
|
||||
return option?.label ?? status;
|
||||
}
|
||||
|
|
@ -108,6 +108,7 @@ export interface UpdateTaskInput {
|
|||
recurrenceEndDate?: string | null;
|
||||
subtasks?: Subtask[] | null;
|
||||
metadata?: TaskMetadata | null;
|
||||
labelIds?: string[];
|
||||
}
|
||||
|
||||
export interface QueryTasksInput {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import { filterHiddenNavItems } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
|
|
@ -77,8 +78,8 @@
|
|||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
|
||||
// Navigation items for Zitare
|
||||
const navItems: PillNavItem[] = [
|
||||
// Base navigation items for Zitare
|
||||
const baseNavItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Zitate', icon: 'document' },
|
||||
{ href: '/search', label: 'Suche', icon: 'search' },
|
||||
{ href: '/authors', label: 'Autoren', icon: 'users' },
|
||||
|
|
@ -87,8 +88,13 @@
|
|||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-6)
|
||||
const navRoutes = navItems.map((item) => item.href);
|
||||
// Navigation items filtered by visibility settings
|
||||
const navItems = $derived(
|
||||
filterHiddenNavItems('zitare', baseNavItems, userSettings.nav.hiddenNavItems)
|
||||
);
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-6) - use base items for consistent shortcuts
|
||||
const navRoutes = baseNavItems.map((item) => item.href);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ Dokumentation des Git-Workflows für das ManaCore Monorepo.
|
|||
|
||||
## Branch-Struktur
|
||||
|
||||
| Branch | Zweck |
|
||||
|--------|-------|
|
||||
| `main` | Produktion - stabile Releases |
|
||||
| `dev` | Entwicklung - Integration aller Features |
|
||||
| `till-dev`, `{name}-dev` | Persönliche Entwicklungs-Branches |
|
||||
| Branch | Zweck |
|
||||
| ------------------------------ | ---------------------------------------- |
|
||||
| `main` | Produktion - stabile Releases |
|
||||
| `dev` | Entwicklung - Integration aller Features |
|
||||
| `till-dev`, `{name}-dev` | Persönliche Entwicklungs-Branches |
|
||||
|
||||
## Workflow-Übersicht
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ main (Produktion)
|
|||
│
|
||||
dev (Integration)
|
||||
↑
|
||||
│ PR (squashed)
|
||||
│ PR (einzelne Commits behalten)
|
||||
│
|
||||
till-dev (Feature-Entwicklung)
|
||||
```
|
||||
|
|
@ -32,37 +32,16 @@ till-dev (Feature-Entwicklung)
|
|||
# Sicherstellen, dass du auf deinem Branch bist
|
||||
git checkout till-dev
|
||||
|
||||
# Änderungen committen (viele kleine Commits sind OK)
|
||||
# Änderungen committen - kleine, aussagekräftige Commits
|
||||
git add .
|
||||
git commit -m "feat(app): add feature X"
|
||||
git commit -m "fix(app): fix bug Y"
|
||||
git commit -m "refactor(app): cleanup Z"
|
||||
```
|
||||
|
||||
### 2. Vor dem PR: Commits squashen
|
||||
### 2. Regelmäßig mit dev synchronisieren
|
||||
|
||||
Wenn viele Commits angesammelt sind, sollten diese vor dem PR gequasht werden, um:
|
||||
- Merge-Konflikte zu minimieren
|
||||
- Die Git-History sauber zu halten
|
||||
- Code-Reviews zu vereinfachen
|
||||
|
||||
```bash
|
||||
# Anzahl der Commits seit dev zählen
|
||||
git log --oneline origin/dev..HEAD | wc -l
|
||||
|
||||
# Alle Commits seit dev zu einem squashen
|
||||
git reset --soft origin/dev
|
||||
|
||||
# Einen neuen, zusammengefassten Commit erstellen
|
||||
git commit -m "feat: descriptive summary of all changes"
|
||||
|
||||
# Force-Push zum Remote (überschreibt alte Commits)
|
||||
git push --force-with-lease
|
||||
```
|
||||
|
||||
### 3. Rebase mit dev (falls nötig)
|
||||
|
||||
Falls `dev` sich geändert hat, muss rebased werden:
|
||||
Halte deinen Branch aktuell, um große Konflikte zu vermeiden:
|
||||
|
||||
```bash
|
||||
# Neuesten Stand von dev holen
|
||||
|
|
@ -71,16 +50,17 @@ git fetch origin dev
|
|||
# Rebase durchführen
|
||||
git rebase origin/dev
|
||||
|
||||
# Bei Konflikten:
|
||||
# 1. Konflikte lösen
|
||||
# Bei Konflikten: Jeden Commit einzeln lösen
|
||||
# 1. Konflikte in den angezeigten Dateien lösen
|
||||
# 2. git add <resolved-files>
|
||||
# 3. git rebase --continue
|
||||
# 4. Wiederholen bis alle Commits durchlaufen sind
|
||||
|
||||
# Nach erfolgreichem Rebase pushen
|
||||
git push --force-with-lease
|
||||
```
|
||||
|
||||
### 4. Pull Request erstellen
|
||||
### 3. Pull Request erstellen
|
||||
|
||||
```bash
|
||||
gh pr create --base dev --head till-dev \
|
||||
|
|
@ -94,45 +74,37 @@ gh pr create --base dev --head till-dev \
|
|||
- [ ] Test case 2"
|
||||
```
|
||||
|
||||
## Squash-Strategie
|
||||
## Konflikt-Lösung beim Rebase
|
||||
|
||||
### Wann squashen?
|
||||
### Allgemeiner Ablauf
|
||||
|
||||
| Situation | Aktion |
|
||||
|-----------|--------|
|
||||
| Vor jedem PR | Immer squashen |
|
||||
| Bei vielen kleinen Commits (10+) | Squashen empfohlen |
|
||||
| Bei Rebase-Konflikten | Erst squashen, dann rebasen |
|
||||
| Tägliche Arbeit | Kleine Commits OK |
|
||||
Bei einem Rebase werden Commits einzeln auf den neuen Base-Branch angewendet. Konflikte müssen für jeden Commit separat gelöst werden - das gibt mehr Kontext und macht die Lösung einfacher.
|
||||
|
||||
### Squash-Commit-Message Format
|
||||
```bash
|
||||
# Rebase starten
|
||||
git rebase origin/dev
|
||||
|
||||
# Bei Konflikt: Status prüfen
|
||||
git status # Zeigt konfliktbehaftete Dateien
|
||||
|
||||
# Konflikte lösen, dann:
|
||||
git add <resolved-files>
|
||||
git rebase --continue
|
||||
|
||||
# Nächster Commit wird angewendet...
|
||||
# Wiederholen bis fertig
|
||||
```
|
||||
feat: kurze Zusammenfassung (max 50 Zeichen)
|
||||
|
||||
## Neue Features
|
||||
- Feature 1: Beschreibung
|
||||
- Feature 2: Beschreibung
|
||||
|
||||
## Bug Fixes
|
||||
- Fix 1: Beschreibung
|
||||
|
||||
## Breaking Changes (falls vorhanden)
|
||||
- Breaking Change 1
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
## Konflikt-Lösung
|
||||
|
||||
### Einfache Konflikte
|
||||
|
||||
```bash
|
||||
# Konflikt in einer Datei
|
||||
git checkout --ours path/to/file # Unsere Version behalten
|
||||
git checkout --theirs path/to/file # Ihre Version behalten
|
||||
# Unsere Version behalten (die aus dem Feature-Branch)
|
||||
git checkout --ours path/to/file
|
||||
|
||||
# Ihre Version behalten (die aus dev)
|
||||
git checkout --theirs path/to/file
|
||||
|
||||
# Nach der Wahl:
|
||||
git add path/to/file
|
||||
git rebase --continue
|
||||
```
|
||||
|
|
@ -142,7 +114,7 @@ git rebase --continue
|
|||
Diese Datei sollte nie manuell gemerged werden:
|
||||
|
||||
```bash
|
||||
# Ihre Version nehmen und neu installieren
|
||||
# Version aus dev nehmen und neu installieren
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
pnpm install --frozen-lockfile=false
|
||||
git add pnpm-lock.yaml
|
||||
|
|
@ -169,15 +141,15 @@ git rebase --abort # Zurück zum Zustand vor dem Rebase
|
|||
|
||||
Verwende [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
| Prefix | Verwendung |
|
||||
|--------|------------|
|
||||
| `feat` | Neue Features |
|
||||
| `fix` | Bug Fixes |
|
||||
| `docs` | Dokumentation |
|
||||
| `style` | Formatting (kein Code-Change) |
|
||||
| `refactor` | Code-Refactoring |
|
||||
| `test` | Tests hinzufügen/ändern |
|
||||
| `chore` | Build, CI, Dependencies |
|
||||
| Prefix | Verwendung |
|
||||
| ---------- | ----------------------------- |
|
||||
| `feat` | Neue Features |
|
||||
| `fix` | Bug Fixes |
|
||||
| `docs` | Dokumentation |
|
||||
| `style` | Formatting (kein Code-Change) |
|
||||
| `refactor` | Code-Refactoring |
|
||||
| `test` | Tests hinzufügen/ändern |
|
||||
| `chore` | Build, CI, Dependencies |
|
||||
|
||||
### Scope (optional)
|
||||
|
||||
|
|
@ -187,6 +159,13 @@ fix(calendar): fix event drag and drop
|
|||
docs(readme): update installation guide
|
||||
```
|
||||
|
||||
### Kleine, fokussierte Commits
|
||||
|
||||
- Ein Commit = eine logische Änderung
|
||||
- Aussagekräftige Commit Messages
|
||||
- Leichter zu reviewen und bei Problemen zu debuggen
|
||||
- Einzelne Commits können bei Bedarf reverted werden
|
||||
|
||||
### Branch-Hygiene
|
||||
|
||||
```bash
|
||||
|
|
@ -202,23 +181,23 @@ git fetch --prune
|
|||
```bash
|
||||
# 1. Feature entwickeln
|
||||
git checkout till-dev
|
||||
# ... viele Commits über mehrere Tage ...
|
||||
git commit -m "feat(network): add D3 force simulation"
|
||||
git commit -m "feat(network): add zoom and pan controls"
|
||||
git commit -m "fix(network): fix node positioning on load"
|
||||
git commit -m "docs(network): add keyboard shortcuts help"
|
||||
|
||||
# 2. Vor PR: Status prüfen
|
||||
# 2. Vor PR: Mit dev synchronisieren
|
||||
git fetch origin dev
|
||||
git log --oneline origin/dev..HEAD # 54 commits
|
||||
git rebase origin/dev
|
||||
# Konflikte einzeln lösen falls nötig...
|
||||
|
||||
# 3. Squashen
|
||||
git reset --soft origin/dev
|
||||
git commit -m "feat: major update with network graphs, themes, and more"
|
||||
|
||||
# 4. Pushen
|
||||
# 3. Pushen
|
||||
git push --force-with-lease
|
||||
|
||||
# 5. PR erstellen
|
||||
gh pr create --base dev --head till-dev --title "feat: major update"
|
||||
# 4. PR erstellen
|
||||
gh pr create --base dev --head till-dev --title "feat(network): add network graph visualization"
|
||||
|
||||
# 6. Nach Merge: Branch aktualisieren
|
||||
# 5. Nach Merge: Branch aktualisieren
|
||||
git fetch origin dev
|
||||
git checkout till-dev
|
||||
git reset --hard origin/dev
|
||||
|
|
@ -301,6 +280,14 @@ git reflog
|
|||
git reset --hard HEAD@{2}
|
||||
```
|
||||
|
||||
### Viele Konflikte beim Rebase
|
||||
|
||||
Wenn zu viele Konflikte auftreten:
|
||||
|
||||
1. `git rebase --abort` - Rebase abbrechen
|
||||
2. Regelmäßiger rebasen (täglich/wöchentlich)
|
||||
3. Bei sehr alten Branches: Mit dem Team absprechen
|
||||
|
||||
---
|
||||
|
||||
*Zuletzt aktualisiert: 10.12.2025*
|
||||
|
|
|
|||
272
docs/pr-reviews/PR-014-major-update.md
Normal file
272
docs/pr-reviews/PR-014-major-update.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# Code Review: PR #14
|
||||
|
||||
**Title:** feat: major update with network graphs, themes, todo extensions, and more
|
||||
**Author:** Till-JS
|
||||
**Date:** 2025-12-10
|
||||
**Status:** OPEN
|
||||
**URL:** https://github.com/Memo-2023/manacore-monorepo/pull/14
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Files Changed | 382 |
|
||||
| Additions | +39,514 |
|
||||
| Deletions | -6,251 |
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This is a **major feature release** introducing:
|
||||
|
||||
1. **Network Graph Visualization** - D3.js force-directed graphs for Contacts, Calendar, and Todo apps
|
||||
2. **Central Tags API** - Unified tagging system in mana-core-auth
|
||||
3. **Custom Themes System** - Theme editor, community gallery, and sharing
|
||||
4. **Todo App Extensions** - Kanban boards, statistics, settings page, PWA support
|
||||
5. **Contacts App Features** - Duplicate detection, photo upload, batch operations, favorites views
|
||||
6. **Help System** - Shared packages for content, UI, and types (`shared-help-content`, `shared-help-ui`, `shared-help-types`, `shared-help-mobile`)
|
||||
7. **Skeleton Loaders** - Better loading states across apps
|
||||
8. **CommandBar** - Global search (Cmd+K)
|
||||
9. **Bug Fixes** - Network graph simulation fixes, database schema TEXT for user_id
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Analysis
|
||||
|
||||
### Strengths
|
||||
|
||||
#### 1. Excellent Architecture
|
||||
- Clean separation of concerns with shared packages (`shared-ui`, `shared-theme`, `shared-tags`, `shared-help-*`)
|
||||
- Proper Svelte 5 runes usage (`$state`, `$derived`, `$effect`)
|
||||
- Good TypeScript typing throughout
|
||||
|
||||
#### 2. NetworkGraph Component (`packages/shared-ui/src/organisms/network/`)
|
||||
- Well-structured D3.js integration with `d3-zoom` and `d3-selection`
|
||||
- Proper zoom/pan handling
|
||||
- Keyboard shortcuts implemented:
|
||||
- `+`/`-` for zoom in/out
|
||||
- `0` to reset zoom
|
||||
- `Esc` to deselect
|
||||
- `F` to focus on selected node
|
||||
- `/` to focus search
|
||||
- Accessible with `role="button"`, `aria-label`, `tabindex`
|
||||
- Efficient re-rendering with proper state management
|
||||
|
||||
#### 3. Tags Service (`services/mana-core-auth/src/tags/`)
|
||||
- Proper validation (duplicate name check before create/update)
|
||||
- Good use of Drizzle's `returning()` for immediate results
|
||||
- User-scoped queries with proper authorization (`userId` checks)
|
||||
- Default tags created for new users
|
||||
|
||||
#### 4. Custom Themes Store (`packages/shared-theme/src/custom-themes-store.svelte.ts`)
|
||||
- Clean API design with factory function pattern
|
||||
- Proper state management with Svelte 5 runes
|
||||
- Good separation of public/authenticated API calls
|
||||
- CSS variable application for runtime theming
|
||||
|
||||
---
|
||||
|
||||
### Suggestions for Improvement
|
||||
|
||||
#### 1. Hardcoded German Strings
|
||||
|
||||
**Location:** `packages/shared-ui/src/organisms/network/NetworkGraph.svelte:440-442`
|
||||
|
||||
```svelte
|
||||
<p class="empty-title">Keine Verbindungen gefunden</p>
|
||||
<p class="empty-description">Elemente werden verbunden, wenn sie gemeinsame Tags haben.</p>
|
||||
```
|
||||
|
||||
**Recommendation:** Use i18n for user-facing strings to maintain consistency across the monorepo.
|
||||
|
||||
---
|
||||
|
||||
#### 2. Default Tags in German Only
|
||||
|
||||
**Location:** `services/mana-core-auth/src/tags/tags.service.ts:10-15`
|
||||
|
||||
```typescript
|
||||
const DEFAULT_TAGS = [
|
||||
{ name: 'Arbeit', color: '#3B82F6', icon: 'Briefcase' },
|
||||
{ name: 'Persönlich', color: '#10B981', icon: 'User' },
|
||||
{ name: 'Familie', color: '#EC4899', icon: 'Heart' },
|
||||
{ name: 'Wichtig', color: '#EF4444', icon: 'Star' },
|
||||
];
|
||||
```
|
||||
|
||||
**Recommendation:** Consider locale-aware default tags or use English defaults that users can customize.
|
||||
|
||||
---
|
||||
|
||||
#### 3. Database Connection Pattern
|
||||
|
||||
**Location:** `services/mana-core-auth/src/tags/tags.service.ts:21-24`
|
||||
|
||||
```typescript
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** Using `!` assertion is less safe.
|
||||
|
||||
**Recommendation:** Inject the database connection via NestJS dependency injection instead of calling `getDb()` on every method call.
|
||||
|
||||
---
|
||||
|
||||
#### 4. Missing Error Boundary Handling
|
||||
|
||||
The NetworkGraph component handles empty states but doesn't have explicit error handling for malformed node/link data.
|
||||
|
||||
**Recommendation:** Add defensive checks for invalid data structures.
|
||||
|
||||
---
|
||||
|
||||
### Potential Issues
|
||||
|
||||
#### 1. PR Size
|
||||
|
||||
- 382 files is extremely large for a single PR
|
||||
- Makes code review difficult and increases risk
|
||||
- Consider splitting into feature branches for easier review and rollback
|
||||
|
||||
#### 2. Database Schema Consistency
|
||||
|
||||
**Location:** `services/mana-core-auth/src/db/schema/tags.schema.ts:11`
|
||||
|
||||
```typescript
|
||||
userId: text('user_id').notNull(),
|
||||
```
|
||||
|
||||
This uses `TEXT` type for user_id. Verify this aligns with how user IDs are stored in other tables (some use `UUID`).
|
||||
|
||||
#### 3. Missing Test Coverage
|
||||
|
||||
This major PR adds significant functionality without visible test changes. Consider adding:
|
||||
- Unit tests for `TagsService`
|
||||
- Component tests for `NetworkGraph`
|
||||
- Integration tests for the themes API
|
||||
|
||||
---
|
||||
|
||||
### Security Considerations
|
||||
|
||||
#### Authorization Checks ✅
|
||||
|
||||
- Tag operations properly scope by `userId`
|
||||
- Custom themes store requires authentication for write operations
|
||||
- Community theme browsing allows public access (appropriate)
|
||||
|
||||
#### Input Validation
|
||||
|
||||
- DTOs should be reviewed for proper validation (max lengths, format checks)
|
||||
- Tag color field accepts any 7-char string - consider validating hex format (`/^#[0-9A-Fa-f]{6}$/`)
|
||||
|
||||
#### Environment Files ✅
|
||||
|
||||
- `.env.development` only removes 6 lines, no secrets added
|
||||
- No credentials exposed in the diff
|
||||
|
||||
---
|
||||
|
||||
## New Shared Packages
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `@manacore/shared-tags` | Client for central tags API |
|
||||
| `@manacore/shared-help-content` | Markdown content loader and search |
|
||||
| `@manacore/shared-help-ui` | Svelte help page components |
|
||||
| `@manacore/shared-help-types` | TypeScript types for help system |
|
||||
| `@manacore/shared-help-mobile` | React Native help components |
|
||||
| `@manacore/shared-theme-ui` | Theme editor and community gallery |
|
||||
|
||||
---
|
||||
|
||||
## Files Changed by Category
|
||||
|
||||
| Category | Count | Notable Changes |
|
||||
|----------|-------|-----------------|
|
||||
| Contacts App | ~40 | Duplicates, batch ops, network, favorites |
|
||||
| Todo App | ~30 | Kanban, statistics, settings, PWA |
|
||||
| Calendar App | ~25 | Event tags, network graph |
|
||||
| Shared UI | ~30 | NetworkGraph, skeleton loaders, tags |
|
||||
| Shared Theme | ~15 | Custom themes store, editor |
|
||||
| Shared Help | ~35 | Content, UI, types, mobile |
|
||||
| mana-core-auth | ~15 | Tags API, themes API |
|
||||
| Archived Apps | ~100 | Documentation cleanup |
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### 1. Split Future PRs
|
||||
|
||||
Consider creating separate PRs for major features:
|
||||
- Network Graph feature
|
||||
- Central Tags API
|
||||
- Custom Themes System
|
||||
- Help System packages
|
||||
|
||||
### 2. Add i18n
|
||||
|
||||
Replace hardcoded German strings in shared components.
|
||||
|
||||
### 3. Add Tests
|
||||
|
||||
At minimum, add unit tests for:
|
||||
- `TagsService` (create, update, delete, defaults)
|
||||
- `ThemesService` (publish, download, rate)
|
||||
- `NetworkGraph` (props, events, accessibility)
|
||||
|
||||
### 4. Database Migration Plan
|
||||
|
||||
Ensure `tags` and `themes` table migrations are coordinated across environments.
|
||||
|
||||
### 5. Documentation Updates
|
||||
|
||||
The CLAUDE.md files are helpful. Ensure README updates for new packages.
|
||||
|
||||
---
|
||||
|
||||
## Rating Summary
|
||||
|
||||
| Aspect | Rating | Notes |
|
||||
|--------|--------|-------|
|
||||
| Code Quality | ⭐⭐⭐⭐ | Clean, well-structured code |
|
||||
| Architecture | ⭐⭐⭐⭐⭐ | Excellent use of shared packages |
|
||||
| Test Coverage | ⭐⭐ | Missing tests for new features |
|
||||
| PR Size | ⭐⭐ | Too large for single review |
|
||||
| Security | ⭐⭐⭐⭐ | Good authorization patterns |
|
||||
| i18n | ⭐⭐⭐ | Some hardcoded German strings |
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
This is solid, well-architected code with good separation of concerns. The main concerns are:
|
||||
|
||||
1. **PR size** - Makes review difficult
|
||||
2. **Missing tests** - New features lack test coverage
|
||||
3. **Hardcoded strings** - Some German text in shared components
|
||||
|
||||
Consider splitting future releases of this scale into smaller, focused PRs.
|
||||
|
||||
---
|
||||
|
||||
## Test Plan Checklist
|
||||
|
||||
From the PR description:
|
||||
|
||||
- [ ] Verify network graph loads correctly in Contacts, Calendar, Todo
|
||||
- [ ] Test theme editor and community themes page
|
||||
- [ ] Check Todo app new features (kanban, statistics, settings)
|
||||
- [ ] Verify contacts duplicate detection and batch operations
|
||||
- [ ] Test skeleton loaders appear during loading states
|
||||
|
||||
---
|
||||
|
||||
*Review by Claude Code - 2025-12-10*
|
||||
|
|
@ -25,7 +25,8 @@
|
|||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "eslint ."
|
||||
"lint": "eslint .",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
|
|
|
|||
|
|
@ -6,15 +6,17 @@
|
|||
*/
|
||||
|
||||
/**
|
||||
* Route definition with i18n label
|
||||
* Route definition with label
|
||||
*/
|
||||
export interface AppRoute {
|
||||
/** Route path (e.g., '/stopwatch') */
|
||||
path: string;
|
||||
/** i18n key for the label (e.g., 'nav.stopwatch') */
|
||||
labelKey: string;
|
||||
/** Display label for the route (e.g., 'Stoppuhr') */
|
||||
label: string;
|
||||
/** Optional icon name */
|
||||
icon?: string;
|
||||
/** If true, this route cannot be hidden (e.g., Settings, Home) */
|
||||
alwaysVisible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -37,13 +39,14 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
|
|||
appId: 'clock',
|
||||
defaultRoute: '/',
|
||||
availableRoutes: [
|
||||
{ path: '/', labelKey: 'nav.dashboard', icon: 'home' },
|
||||
{ path: '/alarms', labelKey: 'nav.alarms', icon: 'alarm' },
|
||||
{ path: '/timers', labelKey: 'nav.timers', icon: 'timer' },
|
||||
{ path: '/stopwatch', labelKey: 'nav.stopwatch', icon: 'stopwatch' },
|
||||
{ path: '/pomodoro', labelKey: 'nav.pomodoro', icon: 'target' },
|
||||
{ path: '/world-clock', labelKey: 'nav.worldClock', icon: 'globe' },
|
||||
{ path: '/life', labelKey: 'nav.lifeClock', icon: 'heart' },
|
||||
{ path: '/', label: 'Dashboard', icon: 'home', alwaysVisible: true },
|
||||
{ path: '/alarms', label: 'Wecker', icon: 'alarm' },
|
||||
{ path: '/timers', label: 'Timer', icon: 'timer' },
|
||||
{ path: '/stopwatch', label: 'Stoppuhr', icon: 'stopwatch' },
|
||||
{ path: '/pomodoro', label: 'Pomodoro', icon: 'target' },
|
||||
{ path: '/world-clock', label: 'Weltuhr', icon: 'globe' },
|
||||
{ path: '/life', label: 'Lebenszeit', icon: 'heart' },
|
||||
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
|
||||
],
|
||||
},
|
||||
|
||||
|
|
@ -51,8 +54,11 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
|
|||
appId: 'calendar',
|
||||
defaultRoute: '/',
|
||||
availableRoutes: [
|
||||
{ path: '/', labelKey: 'nav.month', icon: 'calendar' },
|
||||
{ path: '/agenda', labelKey: 'nav.agenda', icon: 'list' },
|
||||
{ path: '/', label: 'Kalender', icon: 'calendar', alwaysVisible: true },
|
||||
{ path: '/agenda', label: 'Agenda', icon: 'list' },
|
||||
{ path: '/tags', label: 'Tags', icon: 'tag' },
|
||||
{ path: '/network', label: 'Netzwerk', icon: 'share' },
|
||||
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
|
||||
],
|
||||
},
|
||||
|
||||
|
|
@ -60,20 +66,14 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
|
|||
appId: 'contacts',
|
||||
defaultRoute: '/',
|
||||
availableRoutes: [
|
||||
{ path: '/', labelKey: 'nav.contacts', icon: 'users' },
|
||||
{ path: '/groups', labelKey: 'nav.groups', icon: 'folder' },
|
||||
{ path: '/favorites', labelKey: 'nav.favorites', icon: 'star' },
|
||||
],
|
||||
},
|
||||
|
||||
mail: {
|
||||
appId: 'mail',
|
||||
defaultRoute: '/',
|
||||
availableRoutes: [
|
||||
{ path: '/', labelKey: 'nav.inbox', icon: 'inbox' },
|
||||
{ path: '/sent', labelKey: 'nav.sent', icon: 'send' },
|
||||
{ path: '/drafts', labelKey: 'nav.drafts', icon: 'file' },
|
||||
{ path: '/starred', labelKey: 'nav.starred', icon: 'star' },
|
||||
{ path: '/', label: 'Kontakte', icon: 'users', alwaysVisible: true },
|
||||
{ path: '/favorites', label: 'Favoriten', icon: 'star' },
|
||||
{ path: '/tags', label: 'Tags', icon: 'tag' },
|
||||
{ path: '/archive', label: 'Archiv', icon: 'archive' },
|
||||
{ path: '/duplicates', label: 'Duplikate', icon: 'copy' },
|
||||
{ path: '/data', label: 'Import/Export', icon: 'download' },
|
||||
{ path: '/network', label: 'Netzwerk', icon: 'share' },
|
||||
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
|
||||
],
|
||||
},
|
||||
|
||||
|
|
@ -81,21 +81,12 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
|
|||
appId: 'todo',
|
||||
defaultRoute: '/',
|
||||
availableRoutes: [
|
||||
{ path: '/', labelKey: 'nav.all', icon: 'list' },
|
||||
{ path: '/today', labelKey: 'nav.today', icon: 'calendar' },
|
||||
{ path: '/upcoming', labelKey: 'nav.upcoming', icon: 'clock' },
|
||||
{ path: '/completed', labelKey: 'nav.completed', icon: 'check' },
|
||||
],
|
||||
},
|
||||
|
||||
storage: {
|
||||
appId: 'storage',
|
||||
defaultRoute: '/',
|
||||
availableRoutes: [
|
||||
{ path: '/', labelKey: 'nav.home', icon: 'home' },
|
||||
{ path: '/files', labelKey: 'nav.files', icon: 'folder' },
|
||||
{ path: '/favorites', labelKey: 'nav.favorites', icon: 'star' },
|
||||
{ path: '/shared', labelKey: 'nav.shared', icon: 'share' },
|
||||
{ path: '/', label: 'Aufgaben', icon: 'list', alwaysVisible: true },
|
||||
{ path: '/kanban', label: 'Kanban', icon: 'grid' },
|
||||
{ path: '/labels', label: 'Labels', icon: 'tag' },
|
||||
{ path: '/statistics', label: 'Statistiken', icon: 'chart' },
|
||||
{ path: '/network', label: 'Netzwerk', icon: 'share' },
|
||||
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
|
||||
],
|
||||
},
|
||||
|
||||
|
|
@ -103,10 +94,13 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
|
|||
appId: 'chat',
|
||||
defaultRoute: '/chat',
|
||||
availableRoutes: [
|
||||
{ path: '/chat', labelKey: 'nav.chat', icon: 'message' },
|
||||
{ path: '/spaces', labelKey: 'nav.spaces', icon: 'folder' },
|
||||
{ path: '/templates', labelKey: 'nav.templates', icon: 'file' },
|
||||
{ path: '/documents', labelKey: 'nav.documents', icon: 'document' },
|
||||
{ path: '/chat', label: 'Chat', icon: 'message', alwaysVisible: true },
|
||||
{ path: '/templates', label: 'Vorlagen', icon: 'file' },
|
||||
{ path: '/spaces', label: 'Spaces', icon: 'folder' },
|
||||
{ path: '/documents', label: 'Dokumente', icon: 'document' },
|
||||
{ path: '/archive', label: 'Archiv', icon: 'archive' },
|
||||
{ path: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
|
||||
],
|
||||
},
|
||||
|
||||
|
|
@ -114,10 +108,14 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
|
|||
appId: 'picture',
|
||||
defaultRoute: '/app/gallery',
|
||||
availableRoutes: [
|
||||
{ path: '/app/gallery', labelKey: 'nav.gallery', icon: 'image' },
|
||||
{ path: '/app/generate', labelKey: 'nav.generate', icon: 'sparkle' },
|
||||
{ path: '/app/board', labelKey: 'nav.board', icon: 'grid' },
|
||||
{ path: '/app/explore', labelKey: 'nav.explore', icon: 'compass' },
|
||||
{ path: '/app/gallery', label: 'Galerie', icon: 'image', alwaysVisible: true },
|
||||
{ path: '/app/board', label: 'Moodboards', icon: 'grid' },
|
||||
{ path: '/app/explore', label: 'Entdecken', icon: 'compass' },
|
||||
{ path: '/app/generate', label: 'Generieren', icon: 'sparkle' },
|
||||
{ path: '/app/upload', label: 'Upload', icon: 'upload' },
|
||||
{ path: '/app/tags', label: 'Tags', icon: 'tag' },
|
||||
{ path: '/app/archive', label: 'Archiv', icon: 'archive' },
|
||||
{ path: '/app/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
|
||||
],
|
||||
},
|
||||
|
||||
|
|
@ -125,9 +123,10 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
|
|||
appId: 'manadeck',
|
||||
defaultRoute: '/decks',
|
||||
availableRoutes: [
|
||||
{ path: '/decks', labelKey: 'nav.decks', icon: 'layers' },
|
||||
{ path: '/explore', labelKey: 'nav.explore', icon: 'compass' },
|
||||
{ path: '/progress', labelKey: 'nav.progress', icon: 'trending' },
|
||||
{ path: '/decks', label: 'Decks', icon: 'layers', alwaysVisible: true },
|
||||
{ path: '/explore', label: 'Entdecken', icon: 'compass' },
|
||||
{ path: '/progress', label: 'Fortschritt', icon: 'trending' },
|
||||
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
|
||||
],
|
||||
},
|
||||
|
||||
|
|
@ -135,24 +134,23 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
|
|||
appId: 'zitare',
|
||||
defaultRoute: '/',
|
||||
availableRoutes: [
|
||||
{ path: '/', labelKey: 'nav.home', icon: 'home' },
|
||||
{ path: '/quotes', labelKey: 'nav.quotes', icon: 'quote' },
|
||||
{ path: '/favorites', labelKey: 'nav.favorites', icon: 'star' },
|
||||
{ path: '/authors', labelKey: 'nav.authors', icon: 'users' },
|
||||
{ path: '/lists', labelKey: 'nav.lists', icon: 'list' },
|
||||
{ path: '/', label: 'Zitate', icon: 'quote', alwaysVisible: true },
|
||||
{ path: '/search', label: 'Suche', icon: 'search' },
|
||||
{ path: '/authors', label: 'Autoren', icon: 'users' },
|
||||
{ path: '/favorites', label: 'Favoriten', icon: 'star' },
|
||||
{ path: '/lists', label: 'Listen', icon: 'list' },
|
||||
{ path: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
|
||||
],
|
||||
},
|
||||
|
||||
presi: {
|
||||
appId: 'presi',
|
||||
defaultRoute: '/',
|
||||
availableRoutes: [{ path: '/', labelKey: 'nav.home', icon: 'home' }],
|
||||
},
|
||||
|
||||
manacore: {
|
||||
appId: 'manacore',
|
||||
defaultRoute: '/',
|
||||
availableRoutes: [{ path: '/', labelKey: 'nav.dashboard', icon: 'home' }],
|
||||
availableRoutes: [
|
||||
{ path: '/', label: 'Dashboard', icon: 'home', alwaysVisible: true },
|
||||
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -199,3 +197,46 @@ export function getAvailableRoutes(appId: string): AppRoute[] {
|
|||
export function getDefaultRoute(appId: string): string {
|
||||
return APP_ROUTES[appId]?.defaultRoute ?? '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter hidden navigation items from a list of nav items
|
||||
* @param appId The app identifier
|
||||
* @param items Array of nav items with href property
|
||||
* @param hiddenNavItems Hidden items config (appId -> hidden paths)
|
||||
* @returns Filtered array with hidden items removed
|
||||
*/
|
||||
export function filterHiddenNavItems<T extends { href: string }>(
|
||||
appId: string,
|
||||
items: T[],
|
||||
hiddenNavItems: Record<string, string[]> = {}
|
||||
): T[] {
|
||||
const hidden = hiddenNavItems[appId] || [];
|
||||
return items.filter((item) => !hidden.includes(item.href));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get routes that can be hidden for a specific app
|
||||
* (excludes routes marked as alwaysVisible)
|
||||
* @param appId The app identifier
|
||||
* @returns Array of routes that can be hidden
|
||||
*/
|
||||
export function getHideableRoutes(appId: string): AppRoute[] {
|
||||
const config = APP_ROUTES[appId];
|
||||
return config?.availableRoutes.filter((r) => !r.alwaysVisible) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route is hidden for a specific app
|
||||
* @param appId The app identifier
|
||||
* @param path The route path
|
||||
* @param hiddenNavItems Hidden items config
|
||||
* @returns True if the route is hidden
|
||||
*/
|
||||
export function isRouteHidden(
|
||||
appId: string,
|
||||
path: string,
|
||||
hiddenNavItems: Record<string, string[]> = {}
|
||||
): boolean {
|
||||
const hidden = hiddenNavItems[appId] || [];
|
||||
return hidden.includes(path);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,4 +117,12 @@ export {
|
|||
|
||||
// App Routes
|
||||
export type { AppRoute, AppRouteConfig } from './app-routes';
|
||||
export { APP_ROUTES, getStartPage, getAvailableRoutes, getDefaultRoute } from './app-routes';
|
||||
export {
|
||||
APP_ROUTES,
|
||||
getStartPage,
|
||||
getAvailableRoutes,
|
||||
getDefaultRoute,
|
||||
filterHiddenNavItems,
|
||||
getHideableRoutes,
|
||||
isRouteHidden,
|
||||
} from './app-routes';
|
||||
|
|
|
|||
|
|
@ -240,6 +240,8 @@ export interface NavSettings {
|
|||
desktopPosition: NavPosition;
|
||||
/** Whether sidebar is collapsed */
|
||||
sidebarCollapsed: boolean;
|
||||
/** Hidden navigation items per app (appId -> list of hidden paths) */
|
||||
hiddenNavItems?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -323,7 +325,7 @@ export const DEFAULT_GENERAL_SETTINGS: GeneralSettings = {
|
|||
* Default global settings
|
||||
*/
|
||||
export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
nav: { desktopPosition: 'top', sidebarCollapsed: false },
|
||||
nav: { desktopPosition: 'top', sidebarCollapsed: false, hiddenNavItems: {} },
|
||||
theme: { mode: 'system', colorScheme: 'ocean', pinnedThemes: [] },
|
||||
locale: 'de',
|
||||
general: DEFAULT_GENERAL_SETTINGS,
|
||||
|
|
@ -364,6 +366,12 @@ export interface UserSettingsStore {
|
|||
setStartPage: (appId: string, path: string) => Promise<void>;
|
||||
/** Update general settings */
|
||||
updateGeneral: (settings: Partial<GeneralSettings>) => Promise<void>;
|
||||
/** Get hidden nav items for a specific app */
|
||||
getHiddenNavItemsForApp: (appId: string) => string[];
|
||||
/** Toggle visibility of a navigation item */
|
||||
toggleNavItemVisibility: (appId: string, href: string) => Promise<void>;
|
||||
/** Set hidden nav items for an app */
|
||||
setHiddenNavItems: (appId: string, hiddenHrefs: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -314,6 +314,46 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hidden nav items for a specific app
|
||||
*/
|
||||
function getHiddenNavItemsForApp(targetAppId: string): string[] {
|
||||
return globalSettings.nav.hiddenNavItems?.[targetAppId] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility of a navigation item for an app
|
||||
*/
|
||||
async function toggleNavItemVisibility(targetAppId: string, href: string): Promise<void> {
|
||||
const currentHidden = getHiddenNavItemsForApp(targetAppId);
|
||||
const isHidden = currentHidden.includes(href);
|
||||
|
||||
const newHidden = isHidden ? currentHidden.filter((h) => h !== href) : [...currentHidden, href];
|
||||
|
||||
await setHiddenNavItems(targetAppId, newHidden);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set hidden nav items for an app
|
||||
*/
|
||||
async function setHiddenNavItems(targetAppId: string, hiddenHrefs: string[]): Promise<void> {
|
||||
const newHiddenNavItems = {
|
||||
...globalSettings.nav.hiddenNavItems,
|
||||
[targetAppId]: hiddenHrefs,
|
||||
};
|
||||
|
||||
// Remove empty arrays
|
||||
if (hiddenHrefs.length === 0) {
|
||||
delete newHiddenNavItems[targetAppId];
|
||||
}
|
||||
|
||||
await updateGlobal({
|
||||
nav: {
|
||||
hiddenNavItems: newHiddenNavItems,
|
||||
},
|
||||
} as Partial<GlobalSettings>);
|
||||
}
|
||||
|
||||
return {
|
||||
get nav() {
|
||||
return nav;
|
||||
|
|
@ -349,5 +389,8 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe
|
|||
removeAppOverride,
|
||||
setStartPage,
|
||||
updateGeneral,
|
||||
getHiddenNavItemsForApp,
|
||||
toggleNavItemVisibility,
|
||||
setHiddenNavItems,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"d3-selection": "^3.0.0",
|
||||
"d3-transition": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-svelte": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
294
packages/shared-ui/src/charts/ActivityHeatmap.svelte
Normal file
294
packages/shared-ui/src/charts/ActivityHeatmap.svelte
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
<script lang="ts">
|
||||
import { format, parseISO, getMonth } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import type { HeatmapDataPoint } from './types';
|
||||
|
||||
interface Props {
|
||||
data: HeatmapDataPoint[];
|
||||
title?: string;
|
||||
/** Number of days to display (default: 180) */
|
||||
daysCount?: number;
|
||||
/** Custom tooltip formatter */
|
||||
tooltipFormatter?: (point: HeatmapDataPoint) => string;
|
||||
/** Item name for tooltip (e.g., "Aufgabe", "Event", "Kontakt") */
|
||||
itemName?: string;
|
||||
/** Plural item name for tooltip (e.g., "Aufgaben", "Events", "Kontakte") */
|
||||
itemNamePlural?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
title = 'Aktivität',
|
||||
daysCount = 180,
|
||||
tooltipFormatter,
|
||||
itemName = 'Aufgabe',
|
||||
itemNamePlural = 'Aufgaben',
|
||||
}: Props = $props();
|
||||
|
||||
// Constants
|
||||
const CELL_SIZE = 12;
|
||||
const CELL_GAP = 3;
|
||||
const DAY_LABELS = ['Mo', '', 'Mi', '', 'Fr', '', 'So'];
|
||||
|
||||
// Calculate max for color scaling
|
||||
let maxCount = $derived(Math.max(...data.map((d) => d.count), 1));
|
||||
|
||||
// Get color intensity based on count (uses CSS variable --primary)
|
||||
function getColorClass(count: number): string {
|
||||
if (count === 0) return 'intensity-0';
|
||||
const ratio = count / maxCount;
|
||||
if (ratio <= 0.25) return 'intensity-1';
|
||||
if (ratio <= 0.5) return 'intensity-2';
|
||||
if (ratio <= 0.75) return 'intensity-3';
|
||||
return 'intensity-4';
|
||||
}
|
||||
|
||||
// Group data by weeks
|
||||
let weeks = $derived.by(() => {
|
||||
const result: HeatmapDataPoint[][] = [];
|
||||
let currentWeek: HeatmapDataPoint[] = [];
|
||||
|
||||
// Adjust for Monday start
|
||||
const adjustedData = [...data];
|
||||
|
||||
// Fill initial gap if first day isn't Monday
|
||||
if (adjustedData.length > 0) {
|
||||
const firstDay = adjustedData[0];
|
||||
// Convert Sunday (0) to 6, Monday (1) to 0, etc.
|
||||
const adjustedDayOfWeek = firstDay.dayOfWeek === 0 ? 6 : firstDay.dayOfWeek - 1;
|
||||
|
||||
for (let i = 0; i < adjustedDayOfWeek; i++) {
|
||||
currentWeek.push({ date: '', count: 0, dayOfWeek: i });
|
||||
}
|
||||
}
|
||||
|
||||
adjustedData.forEach((day) => {
|
||||
// Convert to Monday-based index
|
||||
const adjustedDayOfWeek = day.dayOfWeek === 0 ? 6 : day.dayOfWeek - 1;
|
||||
|
||||
if (adjustedDayOfWeek === 0 && currentWeek.length > 0) {
|
||||
result.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
currentWeek.push({ ...day, dayOfWeek: adjustedDayOfWeek });
|
||||
});
|
||||
|
||||
if (currentWeek.length > 0) {
|
||||
result.push(currentWeek);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Calculate month labels
|
||||
let monthLabels = $derived.by(() => {
|
||||
const labels: { month: string; weekIndex: number }[] = [];
|
||||
let lastMonth = -1;
|
||||
|
||||
weeks.forEach((week, weekIndex) => {
|
||||
const validDay = week.find((d) => d.date);
|
||||
if (validDay) {
|
||||
const date = parseISO(validDay.date);
|
||||
const month = getMonth(date);
|
||||
if (month !== lastMonth) {
|
||||
labels.push({
|
||||
month: format(date, 'MMM', { locale: de }),
|
||||
weekIndex,
|
||||
});
|
||||
lastMonth = month;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return labels;
|
||||
});
|
||||
|
||||
// Calculate SVG dimensions
|
||||
let svgWidth = $derived(weeks.length * (CELL_SIZE + CELL_GAP) + 30);
|
||||
let svgHeight = 7 * (CELL_SIZE + CELL_GAP) + 30;
|
||||
|
||||
function formatTooltip(day: HeatmapDataPoint): string {
|
||||
if (!day.date) return '';
|
||||
if (tooltipFormatter) return tooltipFormatter(day);
|
||||
const date = format(parseISO(day.date), 'EEEE, d. MMMM yyyy', { locale: de });
|
||||
const name = day.count === 1 ? itemName : itemNamePlural;
|
||||
return `${day.count} ${name} am ${date}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="heatmap-container">
|
||||
<h3 class="heatmap-title">{title}</h3>
|
||||
|
||||
<div class="heatmap-scroll">
|
||||
<svg
|
||||
width={svgWidth}
|
||||
height={svgHeight}
|
||||
viewBox="0 0 {svgWidth} {svgHeight}"
|
||||
class="heatmap-svg"
|
||||
>
|
||||
<!-- Month labels -->
|
||||
{#each monthLabels as label}
|
||||
<text x={30 + label.weekIndex * (CELL_SIZE + CELL_GAP)} y={10} class="month-label">
|
||||
{label.month}
|
||||
</text>
|
||||
{/each}
|
||||
|
||||
<!-- Day labels -->
|
||||
{#each DAY_LABELS as label, i}
|
||||
{#if label}
|
||||
<text x={0} y={22 + i * (CELL_SIZE + CELL_GAP) + CELL_SIZE / 2 + 4} class="day-label">
|
||||
{label}
|
||||
</text>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Cells -->
|
||||
{#each weeks as week, weekIndex}
|
||||
{#each week as day, dayIndex}
|
||||
{#if day.date}
|
||||
<rect
|
||||
x={30 + weekIndex * (CELL_SIZE + CELL_GAP)}
|
||||
y={20 + dayIndex * (CELL_SIZE + CELL_GAP)}
|
||||
width={CELL_SIZE}
|
||||
height={CELL_SIZE}
|
||||
rx={2}
|
||||
class="cell {getColorClass(day.count)}"
|
||||
>
|
||||
<title>{formatTooltip(day)}</title>
|
||||
</rect>
|
||||
{:else}
|
||||
<rect
|
||||
x={30 + weekIndex * (CELL_SIZE + CELL_GAP)}
|
||||
y={20 + dayIndex * (CELL_SIZE + CELL_GAP)}
|
||||
width={CELL_SIZE}
|
||||
height={CELL_SIZE}
|
||||
rx={2}
|
||||
class="cell empty"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="legend">
|
||||
<span class="legend-label">Weniger</span>
|
||||
<div class="legend-cells">
|
||||
<div class="legend-cell intensity-0"></div>
|
||||
<div class="legend-cell intensity-1"></div>
|
||||
<div class="legend-cell intensity-2"></div>
|
||||
<div class="legend-cell intensity-3"></div>
|
||||
<div class="legend-cell intensity-4"></div>
|
||||
</div>
|
||||
<span class="legend-label">Mehr</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.heatmap-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
:global(.dark) .heatmap-container {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.heatmap-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.heatmap-scroll {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.heatmap-svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
font-size: 10px;
|
||||
fill: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.day-label {
|
||||
font-size: 10px;
|
||||
fill: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.cell {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.cell:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.cell.empty {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
:global(.dark) .cell.empty {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.legend-cells {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.legend-cell {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Intensity classes using theme primary color */
|
||||
.intensity-0 {
|
||||
fill: hsl(var(--muted) / 0.3);
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
.intensity-1 {
|
||||
fill: hsl(var(--primary) / 0.3);
|
||||
background: hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
.intensity-2 {
|
||||
fill: hsl(var(--primary) / 0.5);
|
||||
background: hsl(var(--primary) / 0.5);
|
||||
}
|
||||
|
||||
.intensity-3 {
|
||||
fill: hsl(var(--primary) / 0.7);
|
||||
background: hsl(var(--primary) / 0.7);
|
||||
}
|
||||
|
||||
.intensity-4 {
|
||||
fill: hsl(var(--primary));
|
||||
background: hsl(var(--primary));
|
||||
}
|
||||
</style>
|
||||
260
packages/shared-ui/src/charts/DonutChart.svelte
Normal file
260
packages/shared-ui/src/charts/DonutChart.svelte
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
<script lang="ts">
|
||||
import type { DonutSegment } from './types';
|
||||
|
||||
interface Props {
|
||||
data: DonutSegment[];
|
||||
title?: string;
|
||||
centerLabel?: string;
|
||||
centerValue?: number | string;
|
||||
showLegend?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
title = 'Verteilung',
|
||||
centerLabel = 'Gesamt',
|
||||
centerValue,
|
||||
showLegend = true,
|
||||
}: Props = $props();
|
||||
|
||||
// Chart settings
|
||||
const SIZE = 200;
|
||||
const CENTER = SIZE / 2;
|
||||
const RADIUS = 80;
|
||||
const INNER_RADIUS = 50;
|
||||
|
||||
// Total count
|
||||
let total = $derived(centerValue ?? data.reduce((sum, d) => sum + d.count, 0));
|
||||
|
||||
// Generate arc paths
|
||||
let arcs = $derived.by(() => {
|
||||
const totalCount = data.reduce((sum, d) => sum + d.count, 0);
|
||||
if (totalCount === 0) return [];
|
||||
|
||||
const result: Array<{
|
||||
path: string;
|
||||
color: string;
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}> = [];
|
||||
|
||||
let currentAngle = -90; // Start at top
|
||||
|
||||
data.forEach((segment) => {
|
||||
if (segment.count === 0) return;
|
||||
|
||||
const angle = (segment.count / totalCount) * 360;
|
||||
const startAngle = currentAngle;
|
||||
const endAngle = currentAngle + angle;
|
||||
|
||||
// Convert angles to radians
|
||||
const startRad = (startAngle * Math.PI) / 180;
|
||||
const endRad = (endAngle * Math.PI) / 180;
|
||||
|
||||
// Calculate points
|
||||
const x1 = CENTER + RADIUS * Math.cos(startRad);
|
||||
const y1 = CENTER + RADIUS * Math.sin(startRad);
|
||||
const x2 = CENTER + RADIUS * Math.cos(endRad);
|
||||
const y2 = CENTER + RADIUS * Math.sin(endRad);
|
||||
const x3 = CENTER + INNER_RADIUS * Math.cos(endRad);
|
||||
const y3 = CENTER + INNER_RADIUS * Math.sin(endRad);
|
||||
const x4 = CENTER + INNER_RADIUS * Math.cos(startRad);
|
||||
const y4 = CENTER + INNER_RADIUS * Math.sin(startRad);
|
||||
|
||||
const largeArc = angle > 180 ? 1 : 0;
|
||||
|
||||
// Create arc path
|
||||
const path = [
|
||||
`M ${x1} ${y1}`,
|
||||
`A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${x2} ${y2}`,
|
||||
`L ${x3} ${y3}`,
|
||||
`A ${INNER_RADIUS} ${INNER_RADIUS} 0 ${largeArc} 0 ${x4} ${y4}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
|
||||
result.push({
|
||||
path,
|
||||
color: segment.color,
|
||||
id: segment.id,
|
||||
label: segment.label,
|
||||
count: segment.count,
|
||||
percentage: segment.percentage,
|
||||
});
|
||||
|
||||
currentAngle = endAngle;
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Hover state
|
||||
let hoveredSegment = $state<string | null>(null);
|
||||
</script>
|
||||
|
||||
<div class="donut-container">
|
||||
<h3 class="donut-title">{title}</h3>
|
||||
|
||||
<div class="donut-content">
|
||||
<div class="donut-chart">
|
||||
<svg viewBox="0 0 {SIZE} {SIZE}" class="donut-svg">
|
||||
{#each arcs as arc}
|
||||
<path
|
||||
d={arc.path}
|
||||
fill={arc.color}
|
||||
class="arc-segment"
|
||||
class:hovered={hoveredSegment === arc.id}
|
||||
onmouseenter={() => (hoveredSegment = arc.id)}
|
||||
onmouseleave={() => (hoveredSegment = null)}
|
||||
role="graphics-symbol"
|
||||
aria-label="{arc.label}: {arc.count}"
|
||||
>
|
||||
<title>{arc.label}: {arc.count} ({arc.percentage}%)</title>
|
||||
</path>
|
||||
{/each}
|
||||
|
||||
<!-- Center text -->
|
||||
<text x={CENTER} y={CENTER - 8} class="center-count">
|
||||
{total}
|
||||
</text>
|
||||
<text x={CENTER} y={CENTER + 12} class="center-label">
|
||||
{centerLabel}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
{#if showLegend}
|
||||
<div class="donut-legend">
|
||||
{#each data as item}
|
||||
<div
|
||||
class="legend-item"
|
||||
class:active={hoveredSegment === item.id}
|
||||
onmouseenter={() => (hoveredSegment = item.id)}
|
||||
onmouseleave={() => (hoveredSegment = null)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="legend-color" style="background-color: {item.color}"></span>
|
||||
<span class="legend-label">{item.label}</span>
|
||||
<span class="legend-count">{item.count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.donut-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
:global(.dark) .donut-container {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.donut-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.donut-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.donut-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.donut-chart {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.donut-svg {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.arc-segment {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
transform-origin: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.arc-segment:hover,
|
||||
.arc-segment.hovered {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.center-count {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
fill: hsl(var(--foreground));
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.center-label {
|
||||
font-size: 12px;
|
||||
fill: hsl(var(--muted-foreground));
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.donut-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.legend-item:hover,
|
||||
.legend-item.active {
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.legend-count {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
192
packages/shared-ui/src/charts/ProgressBars.svelte
Normal file
192
packages/shared-ui/src/charts/ProgressBars.svelte
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<script lang="ts">
|
||||
import type { ProgressItem } from './types';
|
||||
|
||||
interface Props {
|
||||
data: ProgressItem[];
|
||||
title?: string;
|
||||
maxItems?: number;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
title = 'Fortschritt',
|
||||
maxItems = 8,
|
||||
emptyMessage = 'Keine Daten vorhanden',
|
||||
}: Props = $props();
|
||||
|
||||
// Sort by total (descending) and limit to maxItems
|
||||
let sortedData = $derived(data.slice(0, maxItems));
|
||||
</script>
|
||||
|
||||
<div class="progress-container">
|
||||
<h3 class="progress-title">{title}</h3>
|
||||
|
||||
{#if sortedData.length === 0}
|
||||
<p class="no-data">{emptyMessage}</p>
|
||||
{:else}
|
||||
<div class="progress-list">
|
||||
{#each sortedData as item (item.id)}
|
||||
<div class="progress-row">
|
||||
<div class="progress-header">
|
||||
<div class="progress-name">
|
||||
<span class="progress-dot" style="background-color: {item.color}"></span>
|
||||
<span class="name-text">{item.name}</span>
|
||||
</div>
|
||||
<span class="progress-stats">
|
||||
{item.completed}/{item.total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar">
|
||||
<!-- Completed segment -->
|
||||
{#if item.completed > 0}
|
||||
<div
|
||||
class="progress-segment completed"
|
||||
style="width: {(item.completed / item.total) *
|
||||
100}%; background-color: {item.color}"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- In Progress segment -->
|
||||
{#if item.inProgress && item.inProgress > 0}
|
||||
<div
|
||||
class="progress-segment in-progress"
|
||||
style="width: {(item.inProgress / item.total) *
|
||||
100}%; background-color: {item.color}; opacity: 0.4"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="percentage">{item.percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.progress-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
:global(.dark) .progress-container {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.progress-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.progress-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name-text {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-stats {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
:global(.dark) .progress-bar {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.progress-segment {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-segment.completed {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.progress-segment.in-progress {
|
||||
/* Striped pattern for in-progress */
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 4px,
|
||||
rgba(255, 255, 255, 0.3) 4px,
|
||||
rgba(255, 255, 255, 0.3) 8px
|
||||
);
|
||||
}
|
||||
|
||||
.percentage {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
width: 36px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
272
packages/shared-ui/src/charts/StatisticsSkeleton.svelte
Normal file
272
packages/shared-ui/src/charts/StatisticsSkeleton.svelte
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* StatisticsSkeleton - Skeleton for statistics page loading
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '../molecules';
|
||||
|
||||
interface Props {
|
||||
/** Number of stat cards to show (default: 6) */
|
||||
statCards?: number;
|
||||
/** Number of progress items to show (default: 4) */
|
||||
progressItems?: number;
|
||||
/** Number of legend items for donut chart (default: 4) */
|
||||
legendItems?: number;
|
||||
/** Show additional stats section (default: true) */
|
||||
showAdditionalStats?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
statCards = 6,
|
||||
progressItems = 4,
|
||||
legendItems = 4,
|
||||
showAdditionalStats = true,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="statistics-skeleton" role="status" aria-label="Statistiken werden geladen...">
|
||||
<!-- Stats Overview Cards -->
|
||||
<div class="stats-overview">
|
||||
{#each Array(statCards) as _, i}
|
||||
<div class="stat-card" style="opacity: {Math.max(0.5, 1 - i * 0.08)};">
|
||||
<SkeletonBox width="40px" height="40px" borderRadius="10px" />
|
||||
<div class="stat-content">
|
||||
<SkeletonBox width="48px" height="28px" />
|
||||
<SkeletonBox width="80px" height="14px" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Charts Grid -->
|
||||
<div class="charts-grid">
|
||||
<!-- Activity Heatmap -->
|
||||
<div class="chart-card heatmap">
|
||||
<div class="chart-header">
|
||||
<SkeletonBox width="140px" height="20px" />
|
||||
</div>
|
||||
<div class="heatmap-grid">
|
||||
{#each Array(7) as _}
|
||||
<div class="heatmap-row">
|
||||
{#each Array(12) as _}
|
||||
<SkeletonBox width="16px" height="16px" borderRadius="3px" />
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="charts-row">
|
||||
<!-- Weekly Trend Chart -->
|
||||
<div class="chart-card trend">
|
||||
<div class="chart-header">
|
||||
<SkeletonBox width="120px" height="20px" />
|
||||
</div>
|
||||
<div class="trend-bars">
|
||||
{#each Array(7) as _, i}
|
||||
<div class="bar-wrapper">
|
||||
<SkeletonBox width="32px" height="{40 + Math.random() * 60}px" borderRadius="4px" />
|
||||
<SkeletonBox width="24px" height="12px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Donut Chart -->
|
||||
<div class="chart-card donut">
|
||||
<div class="chart-header">
|
||||
<SkeletonBox width="100px" height="20px" />
|
||||
</div>
|
||||
<div class="donut-wrapper">
|
||||
<SkeletonBox width="140px" height="140px" borderRadius="50%" />
|
||||
</div>
|
||||
<div class="legend">
|
||||
{#each Array(legendItems) as _}
|
||||
<div class="legend-item">
|
||||
<SkeletonBox width="12px" height="12px" borderRadius="3px" />
|
||||
<SkeletonBox width="60px" height="14px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Progress -->
|
||||
<div class="chart-card projects">
|
||||
<div class="chart-header">
|
||||
<SkeletonBox width="130px" height="20px" />
|
||||
</div>
|
||||
<div class="progress-bars">
|
||||
{#each Array(progressItems) as _, i}
|
||||
<div class="progress-item" style="opacity: {Math.max(0.4, 1 - i * 0.15)};">
|
||||
<div class="progress-header">
|
||||
<SkeletonBox width="{100 + i * 20}px" height="16px" />
|
||||
<SkeletonBox width="40px" height="14px" />
|
||||
</div>
|
||||
<SkeletonBox width="100%" height="8px" borderRadius="4px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
{#if showAdditionalStats}
|
||||
<div class="additional-stats">
|
||||
{#each Array(3) as _}
|
||||
<div class="small-stat">
|
||||
<SkeletonBox width="120px" height="12px" />
|
||||
<SkeletonBox width="80px" height="18px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.statistics-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Stats Overview */
|
||||
.stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Charts Grid */
|
||||
.charts-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Heatmap */
|
||||
.heatmap-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.heatmap-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Charts Row */
|
||||
.charts-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.charts-row {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Trend Chart */
|
||||
.trend-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
height: 120px;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.bar-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Donut Chart */
|
||||
.donut-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Project Progress */
|
||||
.progress-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.progress-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Additional Stats */
|
||||
.additional-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.small-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
}
|
||||
</style>
|
||||
136
packages/shared-ui/src/charts/StatsGrid.svelte
Normal file
136
packages/shared-ui/src/charts/StatsGrid.svelte
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<script lang="ts">
|
||||
import type { StatItem } from './types';
|
||||
import { STAT_VARIANT_COLORS } from './types';
|
||||
|
||||
interface Props {
|
||||
items: StatItem[];
|
||||
columns?: 2 | 3 | 4 | 6;
|
||||
}
|
||||
|
||||
let { items, columns = 6 }: Props = $props();
|
||||
|
||||
// Filter items based on showCondition
|
||||
let visibleItems = $derived(items.filter((item) => item.showCondition !== false));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="stats-grid"
|
||||
class:cols-2={columns === 2}
|
||||
class:cols-3={columns === 3}
|
||||
class:cols-4={columns === 4}
|
||||
class:cols-6={columns === 6}
|
||||
>
|
||||
{#each visibleItems as item (item.id)}
|
||||
<div class="stat-card">
|
||||
<div
|
||||
class="stat-icon"
|
||||
style="background-color: {STAT_VARIANT_COLORS[item.variant]
|
||||
.bg}; color: {STAT_VARIANT_COLORS[item.variant].color}"
|
||||
>
|
||||
<item.icon size={24} />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-value">{item.value}</span>
|
||||
<span class="stat-label">{item.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Default responsive behavior for 6 columns */
|
||||
.stats-grid.cols-6 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.stats-grid.cols-6 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.stats-grid.cols-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.stats-grid.cols-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.stats-grid.cols-6 {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
.stats-grid.cols-4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid.cols-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.stats-grid.cols-3 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 1rem;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .stat-card {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
240
packages/shared-ui/src/charts/TrendLineChart.svelte
Normal file
240
packages/shared-ui/src/charts/TrendLineChart.svelte
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
<script lang="ts">
|
||||
import type { TrendDataPoint } from './types';
|
||||
|
||||
interface Props {
|
||||
data: TrendDataPoint[];
|
||||
title?: string;
|
||||
height?: number;
|
||||
/** Item name for tooltip (e.g., "Aufgabe", "Event", "Kontakt") */
|
||||
itemName?: string;
|
||||
/** Plural item name for tooltip (e.g., "Aufgaben", "Events", "Kontakte") */
|
||||
itemNamePlural?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
title = 'Trend (letzte 4 Wochen)',
|
||||
height = 200,
|
||||
itemName = 'Aufgabe',
|
||||
itemNamePlural = 'Aufgaben',
|
||||
}: Props = $props();
|
||||
|
||||
// Chart dimensions
|
||||
const WIDTH = 600;
|
||||
const PADDING = { top: 20, right: 20, bottom: 30, left: 40 };
|
||||
|
||||
let chartWidth = WIDTH - PADDING.left - PADDING.right;
|
||||
let chartHeight = height - PADDING.top - PADDING.bottom;
|
||||
|
||||
// Calculate max for scaling
|
||||
let maxCount = $derived(Math.max(...data.map((d) => d.count), 1));
|
||||
|
||||
// Scale functions
|
||||
function scaleX(index: number): number {
|
||||
if (data.length <= 1) return PADDING.left;
|
||||
return PADDING.left + (index / (data.length - 1)) * chartWidth;
|
||||
}
|
||||
|
||||
function scaleY(value: number): number {
|
||||
return PADDING.top + chartHeight - (value / maxCount) * chartHeight;
|
||||
}
|
||||
|
||||
// Generate path for the line
|
||||
let linePath = $derived.by(() => {
|
||||
if (data.length === 0) return '';
|
||||
|
||||
const points = data.map((d, i) => ({
|
||||
x: scaleX(i),
|
||||
y: scaleY(d.count),
|
||||
}));
|
||||
|
||||
// Create smooth curve using cubic bezier
|
||||
let path = `M ${points[0].x} ${points[0].y}`;
|
||||
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1];
|
||||
const curr = points[i];
|
||||
const cpX = (prev.x + curr.x) / 2;
|
||||
path += ` C ${cpX} ${prev.y}, ${cpX} ${curr.y}, ${curr.x} ${curr.y}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
});
|
||||
|
||||
// Generate path for the area fill
|
||||
let areaPath = $derived.by(() => {
|
||||
if (data.length === 0) return '';
|
||||
|
||||
const baseline = PADDING.top + chartHeight;
|
||||
return `${linePath} L ${scaleX(data.length - 1)} ${baseline} L ${scaleX(0)} ${baseline} Z`;
|
||||
});
|
||||
|
||||
// Y-axis ticks
|
||||
let yTicks = $derived.by(() => {
|
||||
const tickCount = 4;
|
||||
const step = maxCount / tickCount;
|
||||
return Array.from({ length: tickCount + 1 }, (_, i) => Math.round(i * step));
|
||||
});
|
||||
|
||||
// X-axis labels (show every 7th day for weekly labels)
|
||||
let xLabels = $derived.by(() => {
|
||||
const labels: { index: number; label: string }[] = [];
|
||||
const step = Math.max(1, Math.floor(data.length / 4));
|
||||
|
||||
for (let i = 0; i < data.length; i += step) {
|
||||
if (data[i]) {
|
||||
labels.push({ index: i, label: data[i].date.slice(5) }); // MM-DD format
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
});
|
||||
|
||||
// Generate unique gradient ID
|
||||
let gradientId = $derived(`areaGradient-${Math.random().toString(36).slice(2, 9)}`);
|
||||
|
||||
function formatTooltip(point: TrendDataPoint): string {
|
||||
const name = point.count === 1 ? itemName : itemNamePlural;
|
||||
return `${point.count} ${name} am ${point.date}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chart-container">
|
||||
<h3 class="chart-title">{title}</h3>
|
||||
|
||||
<svg viewBox="0 0 {WIDTH} {height}" class="chart-svg" preserveAspectRatio="xMidYMid meet">
|
||||
<!-- Grid lines -->
|
||||
{#each yTicks as tick}
|
||||
<line
|
||||
x1={PADDING.left}
|
||||
y1={scaleY(tick)}
|
||||
x2={WIDTH - PADDING.right}
|
||||
y2={scaleY(tick)}
|
||||
class="grid-line"
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Area fill with gradient -->
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" class="gradient-start" />
|
||||
<stop offset="100%" class="gradient-end" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path d={areaPath} fill="url(#{gradientId})" class="area-path" />
|
||||
|
||||
<!-- Line -->
|
||||
<path d={linePath} class="line-path" />
|
||||
|
||||
<!-- Data points -->
|
||||
{#each data as point, i}
|
||||
<circle cx={scaleX(i)} cy={scaleY(point.count)} r={4} class="data-point">
|
||||
<title>{formatTooltip(point)}</title>
|
||||
</circle>
|
||||
{/each}
|
||||
|
||||
<!-- Y-axis labels -->
|
||||
{#each yTicks as tick}
|
||||
<text x={PADDING.left - 8} y={scaleY(tick) + 4} class="y-label">
|
||||
{tick}
|
||||
</text>
|
||||
{/each}
|
||||
|
||||
<!-- X-axis labels -->
|
||||
{#each xLabels as label}
|
||||
<text x={scaleX(label.index)} y={height - 8} class="x-label">
|
||||
{label.label}
|
||||
</text>
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chart-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
:global(.dark) .chart-container {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.grid-line {
|
||||
stroke: hsl(var(--muted) / 0.3);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
|
||||
:global(.dark) .grid-line {
|
||||
stroke: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.area-path {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.gradient-start {
|
||||
stop-color: hsl(var(--primary));
|
||||
stop-opacity: 0.3;
|
||||
}
|
||||
|
||||
.gradient-end {
|
||||
stop-color: hsl(var(--primary));
|
||||
stop-opacity: 0.05;
|
||||
}
|
||||
|
||||
.line-path {
|
||||
fill: none;
|
||||
stroke: hsl(var(--primary));
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.data-point {
|
||||
fill: hsl(var(--primary));
|
||||
stroke: white;
|
||||
stroke-width: 2;
|
||||
cursor: pointer;
|
||||
transition: r 0.15s ease;
|
||||
}
|
||||
|
||||
.data-point:hover {
|
||||
r: 6;
|
||||
}
|
||||
|
||||
:global(.dark) .data-point {
|
||||
stroke: #1e1e1e;
|
||||
}
|
||||
|
||||
.y-label {
|
||||
font-size: 10px;
|
||||
fill: hsl(var(--muted-foreground));
|
||||
text-anchor: end;
|
||||
}
|
||||
|
||||
.x-label {
|
||||
font-size: 10px;
|
||||
fill: hsl(var(--muted-foreground));
|
||||
text-anchor: middle;
|
||||
}
|
||||
</style>
|
||||
20
packages/shared-ui/src/charts/index.ts
Normal file
20
packages/shared-ui/src/charts/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Charts - Statistics Visualization Components
|
||||
export { default as StatsGrid } from './StatsGrid.svelte';
|
||||
export { default as ActivityHeatmap } from './ActivityHeatmap.svelte';
|
||||
export { default as TrendLineChart } from './TrendLineChart.svelte';
|
||||
export { default as DonutChart } from './DonutChart.svelte';
|
||||
export { default as ProgressBars } from './ProgressBars.svelte';
|
||||
export { default as StatisticsSkeleton } from './StatisticsSkeleton.svelte';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
StatVariant,
|
||||
StatItem,
|
||||
HeatmapDataPoint,
|
||||
TrendDataPoint,
|
||||
DonutSegment,
|
||||
ProgressItem,
|
||||
} from './types';
|
||||
|
||||
// Constants
|
||||
export { STAT_VARIANT_COLORS } from './types';
|
||||
62
packages/shared-ui/src/charts/types.ts
Normal file
62
packages/shared-ui/src/charts/types.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Shared Types for Chart Components
|
||||
*/
|
||||
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
// Stat card variant colors
|
||||
export type StatVariant = 'success' | 'primary' | 'neutral' | 'danger' | 'info' | 'accent';
|
||||
|
||||
export const STAT_VARIANT_COLORS: Record<StatVariant, { bg: string; color: string }> = {
|
||||
success: { bg: 'rgba(16, 185, 129, 0.15)', color: '#10B981' },
|
||||
primary: { bg: 'rgba(139, 92, 246, 0.15)', color: '#8B5CF6' },
|
||||
neutral: { bg: 'rgba(107, 114, 128, 0.15)', color: '#6B7280' },
|
||||
danger: { bg: 'rgba(239, 68, 68, 0.15)', color: '#EF4444' },
|
||||
info: { bg: 'rgba(59, 130, 246, 0.15)', color: '#3B82F6' },
|
||||
accent: { bg: 'rgba(236, 72, 153, 0.15)', color: '#EC4899' },
|
||||
};
|
||||
|
||||
// StatsGrid types
|
||||
export interface StatItem {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon: Component;
|
||||
variant: StatVariant;
|
||||
/** Optional: only show this stat if condition is true */
|
||||
showCondition?: boolean;
|
||||
}
|
||||
|
||||
// ActivityHeatmap types
|
||||
export interface HeatmapDataPoint {
|
||||
date: string; // YYYY-MM-DD format
|
||||
count: number;
|
||||
dayOfWeek: number; // 0-6 (Sunday-Saturday)
|
||||
}
|
||||
|
||||
// TrendLineChart types
|
||||
export interface TrendDataPoint {
|
||||
date: string; // YYYY-MM-DD format
|
||||
count: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// DonutChart types
|
||||
export interface DonutSegment {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// ProgressBars types
|
||||
export interface ProgressItem {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
total: number;
|
||||
completed: number;
|
||||
inProgress?: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
|
@ -1,6 +1,47 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// Syntax highlighting patterns for command keywords
|
||||
interface HighlightPattern {
|
||||
pattern: RegExp;
|
||||
className: string;
|
||||
}
|
||||
|
||||
const HIGHLIGHT_PATTERNS: HighlightPattern[] = [
|
||||
// Priority keywords (Todo) - with specific colors per level
|
||||
{ pattern: /(!{3,}|!?dringend)\b/gi, className: 'hl-priority-urgent' },
|
||||
{ pattern: /(!{2}|!?wichtig)\b/gi, className: 'hl-priority-high' },
|
||||
{ pattern: /!?normal\b/gi, className: 'hl-priority-medium' },
|
||||
{ pattern: /!?sp[aä]ter\b/gi, className: 'hl-priority-low' },
|
||||
// Tags
|
||||
{ pattern: /#\w+/g, className: 'hl-tag' },
|
||||
// Projects/Calendars/Companies (@reference)
|
||||
{ pattern: /@\w+/g, className: 'hl-reference' },
|
||||
// Date keywords
|
||||
{
|
||||
pattern:
|
||||
/\b(heute|morgen|übermorgen|montag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag|nächsten?\s+\w+|in\s+\d+\s+tagen?)\b/gi,
|
||||
className: 'hl-date',
|
||||
},
|
||||
// Time patterns
|
||||
{ pattern: /\b(\d{1,2}:\d{2}|um\s+\d{1,2}(\s*uhr)?|\d{1,2}\s*uhr)\b/gi, className: 'hl-time' },
|
||||
];
|
||||
|
||||
function highlightText(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
let result = text;
|
||||
// Escape HTML first
|
||||
result = result.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// Apply highlights (process in order, avoiding double-highlighting)
|
||||
for (const { pattern, className } of HIGHLIGHT_PATTERNS) {
|
||||
result = result.replace(pattern, (match) => `<span class="${className}">${match}</span>`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface CommandBarItem {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -19,6 +60,11 @@
|
|||
onclick?: () => void;
|
||||
}
|
||||
|
||||
export interface CreatePreview {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -28,6 +74,11 @@
|
|||
placeholder?: string;
|
||||
emptyText?: string;
|
||||
searchingText?: string;
|
||||
// New: Task creation support
|
||||
onCreate?: (query: string) => Promise<void>;
|
||||
onParseCreate?: (query: string) => CreatePreview | null;
|
||||
createText?: string;
|
||||
createShortcut?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -39,21 +90,38 @@
|
|||
placeholder = 'Suchen...',
|
||||
emptyText = 'Keine Ergebnisse gefunden',
|
||||
searchingText = 'Suche...',
|
||||
onCreate,
|
||||
onParseCreate,
|
||||
createText = 'Als Eintrag erstellen',
|
||||
createShortcut = '⌘↵',
|
||||
}: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let results = $state<CommandBarItem[]>([]);
|
||||
let loading = $state(false);
|
||||
let creating = $state(false);
|
||||
let selectedIndex = $state(0);
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
let inputElement: HTMLInputElement;
|
||||
|
||||
// Computed create preview
|
||||
let createPreview = $derived(
|
||||
searchQuery.trim() && onParseCreate ? onParseCreate(searchQuery) : null
|
||||
);
|
||||
|
||||
// Highlighted text for overlay
|
||||
let highlightedQuery = $derived(highlightText(searchQuery));
|
||||
|
||||
// Check if create option is selected (it's always first when available)
|
||||
let isCreateSelected = $derived(selectedIndex === 0 && createPreview !== null);
|
||||
|
||||
// Reset state when modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
searchQuery = '';
|
||||
results = [];
|
||||
selectedIndex = 0;
|
||||
creating = false;
|
||||
setTimeout(() => inputElement?.focus(), 50);
|
||||
}
|
||||
});
|
||||
|
|
@ -82,6 +150,20 @@
|
|||
}, 150);
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!onCreate || !searchQuery.trim() || creating) return;
|
||||
|
||||
creating = true;
|
||||
try {
|
||||
await onCreate(searchQuery);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Create error:', error);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
|
|
@ -89,10 +171,23 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Enter to create directly
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
if (onCreate && searchQuery.trim()) {
|
||||
handleCreate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
const maxIndex = searchQuery.trim() ? results.length - 1 : quickActions.length - 1;
|
||||
selectedIndex = Math.min(selectedIndex + 1, maxIndex);
|
||||
// Calculate max index including create option
|
||||
const hasCreate = createPreview !== null;
|
||||
const maxIndex = searchQuery.trim()
|
||||
? (hasCreate ? 1 : 0) + results.length - 1
|
||||
: quickActions.length - 1;
|
||||
selectedIndex = Math.min(selectedIndex + 1, Math.max(0, maxIndex));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -104,8 +199,17 @@
|
|||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (searchQuery.trim() && results.length > 0) {
|
||||
selectItem(results[selectedIndex]);
|
||||
if (searchQuery.trim()) {
|
||||
// If create option is selected
|
||||
if (isCreateSelected && onCreate) {
|
||||
handleCreate();
|
||||
} else if (results.length > 0) {
|
||||
// Adjust index for results (subtract 1 if create option exists)
|
||||
const resultIndex = createPreview !== null ? selectedIndex - 1 : selectedIndex;
|
||||
if (resultIndex >= 0 && resultIndex < results.length) {
|
||||
selectItem(results[resultIndex]);
|
||||
}
|
||||
}
|
||||
} else if (!searchQuery.trim() && quickActions.length > 0) {
|
||||
const action = quickActions[selectedIndex];
|
||||
if (action.href) {
|
||||
|
|
@ -160,7 +264,7 @@
|
|||
onkeydown={handleKeydown}
|
||||
>
|
||||
<div class="command-modal">
|
||||
<!-- Search Input -->
|
||||
<!-- Search Input with Syntax Highlighting -->
|
||||
<div class="command-input-wrapper">
|
||||
<svg class="command-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
|
@ -170,37 +274,84 @@
|
|||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
type="text"
|
||||
{placeholder}
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
class="command-input"
|
||||
/>
|
||||
<div class="input-highlight-container">
|
||||
<!-- Highlight backdrop (shows colored keywords) -->
|
||||
<div class="input-highlight-backdrop" aria-hidden="true">
|
||||
{@html highlightedQuery}
|
||||
</div>
|
||||
<!-- Actual input (transparent text, visible caret) -->
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
type="text"
|
||||
{placeholder}
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
class="command-input"
|
||||
/>
|
||||
</div>
|
||||
<kbd class="command-shortcut">ESC</kbd>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
{#if searchQuery.trim()}
|
||||
<div class="command-results">
|
||||
<!-- Create option (always first when available) -->
|
||||
{#if createPreview && onCreate}
|
||||
<button
|
||||
type="button"
|
||||
class="command-result create-option"
|
||||
class:selected={selectedIndex === 0}
|
||||
onclick={handleCreate}
|
||||
onmouseenter={() => (selectedIndex = 0)}
|
||||
disabled={creating}
|
||||
>
|
||||
<div class="result-avatar create-avatar">
|
||||
{#if creating}
|
||||
<div class="loading-spinner-small"></div>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="result-info">
|
||||
<div class="result-name">{createPreview.title}</div>
|
||||
{#if createPreview.subtitle}
|
||||
<div class="result-details">
|
||||
<span>{createPreview.subtitle}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<kbd class="create-shortcut">{createShortcut}</kbd>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="command-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>{searchingText}</span>
|
||||
</div>
|
||||
{:else if results.length === 0}
|
||||
{:else if results.length === 0 && !createPreview}
|
||||
<div class="command-empty">
|
||||
<span>{emptyText}</span>
|
||||
</div>
|
||||
{:else}
|
||||
{:else if results.length > 0}
|
||||
<div class="results-divider">
|
||||
<span>Suchergebnisse</span>
|
||||
</div>
|
||||
{#each results as item, index (item.id)}
|
||||
{@const adjustedIndex = createPreview ? index + 1 : index}
|
||||
<button
|
||||
type="button"
|
||||
class="command-result"
|
||||
class:selected={index === selectedIndex}
|
||||
class:selected={adjustedIndex === selectedIndex}
|
||||
onclick={() => selectItem(item)}
|
||||
onmouseenter={() => (selectedIndex = index)}
|
||||
onmouseenter={() => (selectedIndex = adjustedIndex)}
|
||||
>
|
||||
<div class="result-avatar">
|
||||
{#if item.imageUrl}
|
||||
|
|
@ -329,6 +480,9 @@
|
|||
<div class="footer-hints">
|
||||
<span><kbd>↑↓</kbd> Navigation</span>
|
||||
<span><kbd>↵</kbd> Öffnen</span>
|
||||
{#if onCreate}
|
||||
<span><kbd>{createShortcut}</kbd> Erstellen</span>
|
||||
{/if}
|
||||
<span><kbd>ESC</kbd> Schließen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -345,9 +499,9 @@
|
|||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
background: hsl(var(--color-background) / 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
|
|
@ -364,15 +518,15 @@
|
|||
width: 100%;
|
||||
max-width: 560px;
|
||||
margin: 0 1rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
background: hsl(var(--color-surface-elevated));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
0 25px 50px -12px hsl(var(--color-background) / 0.5),
|
||||
0 0 0 1px hsl(var(--color-border) / 0.5);
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.2s ease;
|
||||
color: #e5e5e5;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
|
|
@ -391,37 +545,103 @@
|
|||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #333;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.command-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: #888;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.command-input {
|
||||
/* Input with syntax highlighting overlay */
|
||||
.input-highlight-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.input-highlight-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
white-space: pre;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-foreground));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.command-input {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
color: transparent;
|
||||
caret-color: hsl(var(--color-foreground));
|
||||
outline: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.command-input::placeholder {
|
||||
color: #666;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Syntax highlighting colors - Priority levels with matching UI colors */
|
||||
.input-highlight-backdrop :global(.hl-priority-urgent) {
|
||||
color: #ef4444; /* red - Dringend */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input-highlight-backdrop :global(.hl-priority-high) {
|
||||
color: #f97316; /* orange - Wichtig */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input-highlight-backdrop :global(.hl-priority-medium) {
|
||||
color: #eab308; /* yellow - Normal */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input-highlight-backdrop :global(.hl-priority-low) {
|
||||
color: #22c55e; /* green - Später */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input-highlight-backdrop :global(.hl-tag) {
|
||||
color: hsl(var(--color-primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-highlight-backdrop :global(.hl-reference) {
|
||||
color: hsl(var(--color-success));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-highlight-backdrop :global(.hl-date) {
|
||||
color: hsl(262 83% 58%);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-highlight-backdrop :global(.hl-time) {
|
||||
color: hsl(262 83% 58%);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.command-shortcut {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: inherit;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.command-results {
|
||||
|
|
@ -436,15 +656,24 @@
|
|||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
color: #888;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid #333;
|
||||
border-top-color: #3b82f6;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
border-top-color: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-spinner-small {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
border-top-color: hsl(var(--color-success));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
|
@ -455,6 +684,40 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Create option styles */
|
||||
.create-option {
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.create-option.selected,
|
||||
.create-option:hover {
|
||||
background: hsl(var(--color-success) / 0.1);
|
||||
}
|
||||
|
||||
.create-avatar {
|
||||
background: hsl(var(--color-success));
|
||||
}
|
||||
|
||||
.create-shortcut {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-family: inherit;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 4px;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.results-divider {
|
||||
padding: 0.5rem 1.25rem 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.command-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -466,12 +729,12 @@
|
|||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s ease;
|
||||
color: #e5e5e5;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.command-result:hover,
|
||||
.command-result.selected {
|
||||
background: #2a2a2a;
|
||||
background: hsl(var(--color-surface-hover));
|
||||
}
|
||||
|
||||
.result-avatar {
|
||||
|
|
@ -479,8 +742,8 @@
|
|||
height: 40px;
|
||||
min-width: 40px;
|
||||
border-radius: 9999px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -495,7 +758,7 @@
|
|||
|
||||
.result-name {
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
color: hsl(var(--color-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
|
@ -505,7 +768,7 @@
|
|||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #888;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.result-details span {
|
||||
|
|
@ -517,7 +780,7 @@
|
|||
.result-favorite {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #ef4444;
|
||||
color: hsl(var(--color-error));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
@ -532,7 +795,7 @@
|
|||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
color: #e5e5e5;
|
||||
color: hsl(var(--color-foreground));
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
|
@ -542,13 +805,13 @@
|
|||
|
||||
.quick-action:hover,
|
||||
.quick-action.selected {
|
||||
background: #2a2a2a;
|
||||
background: hsl(var(--color-surface-hover));
|
||||
}
|
||||
|
||||
.quick-action-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: #888;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.quick-action span {
|
||||
|
|
@ -560,30 +823,30 @@
|
|||
padding: 0.125rem 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-family: inherit;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.command-footer {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid #333;
|
||||
background: #141414;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.footer-hints {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.footer-hints kbd {
|
||||
padding: 0.125rem 0.25rem;
|
||||
font-family: inherit;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
background: hsl(var(--color-surface-elevated));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 3px;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { default as CommandBar } from './CommandBar.svelte';
|
||||
export type { CommandBarItem, QuickAction } from './CommandBar.svelte';
|
||||
export type { CommandBarItem, QuickAction, CreatePreview } from './CommandBar.svelte';
|
||||
|
|
|
|||
|
|
@ -106,7 +106,26 @@ export {
|
|||
|
||||
// Command Bar
|
||||
export { CommandBar } from './command-bar';
|
||||
export type { CommandBarItem, QuickAction } from './command-bar';
|
||||
export type { CommandBarItem, QuickAction, CreatePreview } from './command-bar';
|
||||
|
||||
// Pages
|
||||
export { default as AppsPage } from './pages/AppsPage.svelte';
|
||||
|
||||
// Charts - Statistics Visualization
|
||||
export {
|
||||
StatsGrid,
|
||||
ActivityHeatmap,
|
||||
TrendLineChart,
|
||||
DonutChart,
|
||||
ProgressBars,
|
||||
StatisticsSkeleton,
|
||||
STAT_VARIANT_COLORS,
|
||||
} from './charts';
|
||||
export type {
|
||||
StatVariant,
|
||||
StatItem,
|
||||
HeatmapDataPoint,
|
||||
TrendDataPoint,
|
||||
DonutSegment,
|
||||
ProgressItem,
|
||||
} from './charts';
|
||||
|
|
|
|||
|
|
@ -8,14 +8,27 @@
|
|||
import { getAvailableRoutes, getDefaultRoute } from '@manacore/shared-theme';
|
||||
import SettingsSection from './SettingsSection.svelte';
|
||||
import SettingsCard from './SettingsCard.svelte';
|
||||
import NavVisibilitySettings from './NavVisibilitySettings.svelte';
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** User settings store instance */
|
||||
userSettings: UserSettingsStore;
|
||||
/** App ID for start page selection */
|
||||
appId?: string;
|
||||
/** Navigation items for visibility settings */
|
||||
navItems?: NavItem[];
|
||||
/** Items that should always be visible (e.g., home route) */
|
||||
alwaysVisibleHrefs?: string[];
|
||||
/** Whether to show navigation settings */
|
||||
showNavigation?: boolean;
|
||||
/** Whether to show nav visibility settings */
|
||||
showNavVisibility?: boolean;
|
||||
/** Whether to show theme settings */
|
||||
showTheme?: boolean;
|
||||
/** Whether to show language settings */
|
||||
|
|
@ -33,7 +46,10 @@
|
|||
let {
|
||||
userSettings,
|
||||
appId,
|
||||
navItems = [],
|
||||
alwaysVisibleHrefs = [],
|
||||
showNavigation = true,
|
||||
showNavVisibility = true,
|
||||
showTheme = true,
|
||||
showLanguage = true,
|
||||
showGeneral = true,
|
||||
|
|
@ -205,10 +221,21 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showNavVisibility && appId && navItems.length > 0}
|
||||
<!-- Navigation Visibility Settings -->
|
||||
<div
|
||||
class="space-y-4 {showNavigation ? 'pt-4 border-t border-[hsl(var(--border))]' : ''}"
|
||||
>
|
||||
<NavVisibilitySettings {userSettings} {appId} {navItems} {alwaysVisibleHrefs} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showTheme}
|
||||
<!-- Theme Settings -->
|
||||
<div
|
||||
class="space-y-4 {showNavigation ? 'pt-4 border-t border-[hsl(var(--border))]' : ''}"
|
||||
class="space-y-4 {showNavigation || (showNavVisibility && appId)
|
||||
? 'pt-4 border-t border-[hsl(var(--border))]'
|
||||
: ''}"
|
||||
>
|
||||
<h3
|
||||
class="text-xs font-semibold text-[hsl(var(--muted-foreground))] uppercase tracking-wider"
|
||||
|
|
@ -266,7 +293,7 @@
|
|||
{#if showLanguage}
|
||||
<!-- Language Settings -->
|
||||
<div
|
||||
class="space-y-4 {showTheme || showNavigation
|
||||
class="space-y-4 {showTheme || showNavigation || (showNavVisibility && appId)
|
||||
? 'pt-4 border-t border-[hsl(var(--border))]'
|
||||
: ''}"
|
||||
>
|
||||
|
|
@ -303,7 +330,10 @@
|
|||
{#if showGeneral}
|
||||
<!-- General Settings -->
|
||||
<div
|
||||
class="space-y-4 {showLanguage || showTheme || showNavigation
|
||||
class="space-y-4 {showLanguage ||
|
||||
showTheme ||
|
||||
showNavigation ||
|
||||
(showNavVisibility && appId)
|
||||
? 'pt-4 border-t border-[hsl(var(--border))]'
|
||||
: ''}"
|
||||
>
|
||||
|
|
@ -329,7 +359,7 @@
|
|||
>
|
||||
{#each availableRoutes as route}
|
||||
<option value={route.path}>
|
||||
{t(route.labelKey)}
|
||||
{route.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
|
|
|||
175
packages/shared-ui/src/settings/NavVisibilitySettings.svelte
Normal file
175
packages/shared-ui/src/settings/NavVisibilitySettings.svelte
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<script lang="ts">
|
||||
import type { UserSettingsStore } from '@manacore/shared-theme';
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** User settings store instance */
|
||||
userSettings: UserSettingsStore;
|
||||
/** Current app ID */
|
||||
appId: string;
|
||||
/** Navigation items from the app layout */
|
||||
navItems: NavItem[];
|
||||
/** Items that should always be visible (e.g., home route) */
|
||||
alwaysVisibleHrefs?: string[];
|
||||
}
|
||||
|
||||
let { userSettings, appId, navItems, alwaysVisibleHrefs = [] }: Props = $props();
|
||||
|
||||
// Filter to only show hideable items (exclude always visible)
|
||||
const hideableItems = $derived(
|
||||
navItems.filter((item) => !alwaysVisibleHrefs.includes(item.href))
|
||||
);
|
||||
|
||||
// Check if there are any routes to configure
|
||||
const hasRoutes = $derived(hideableItems.length > 0);
|
||||
|
||||
function isRouteHidden(href: string): boolean {
|
||||
const hidden = userSettings.getHiddenNavItemsForApp(appId);
|
||||
return hidden.includes(href);
|
||||
}
|
||||
|
||||
async function handleToggle(href: string): Promise<void> {
|
||||
await userSettings.toggleNavItemVisibility(appId, href);
|
||||
}
|
||||
|
||||
// Icon SVG paths (same as PillNavigation)
|
||||
const icons: Record<string, string> = {
|
||||
// Clock app icons
|
||||
bell: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
|
||||
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
timer: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
stopwatch: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0zM9 3h6m-3-1v2',
|
||||
activity: 'M13 10V3L4 14h7v7l9-11h-7z',
|
||||
target:
|
||||
'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z',
|
||||
globe:
|
||||
'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||
// Todo app icons
|
||||
inbox:
|
||||
'M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4',
|
||||
check: 'M5 13l4 4L19 7',
|
||||
checkCircle: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
plus: 'M12 4v16m8-8H4',
|
||||
columns:
|
||||
'M9 4H5a1 1 0 00-1 1v14a1 1 0 001 1h4a1 1 0 001-1V5a1 1 0 00-1-1zM19 4h-4a1 1 0 00-1 1v14a1 1 0 001 1h4a1 1 0 001-1V5a1 1 0 00-1-1z',
|
||||
kanban:
|
||||
'M9 4H5a1 1 0 00-1 1v14a1 1 0 001 1h4a1 1 0 001-1V5a1 1 0 00-1-1zM19 4h-4a1 1 0 00-1 1v14a1 1 0 001 1h4a1 1 0 001-1V5a1 1 0 00-1-1z',
|
||||
// Common icons
|
||||
mic: 'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z',
|
||||
calendar:
|
||||
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
|
||||
folder: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
|
||||
archive: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4',
|
||||
upload: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12',
|
||||
tag: 'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z',
|
||||
document:
|
||||
'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
||||
chart:
|
||||
'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
|
||||
settings:
|
||||
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
|
||||
home: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
|
||||
users:
|
||||
'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
|
||||
user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
||||
building:
|
||||
'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
|
||||
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
||||
heart:
|
||||
'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z',
|
||||
star: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z',
|
||||
list: 'M4 6h16M4 10h16M4 14h16M4 18h16',
|
||||
compass:
|
||||
'M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22ZM16.24 7.76L14.12 14.12L7.76 16.24L9.88 9.88L16.24 7.76Z',
|
||||
moon: 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z',
|
||||
sun: 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z',
|
||||
palette:
|
||||
'M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01',
|
||||
chat: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z',
|
||||
'share-2':
|
||||
'M18 8a3 3 0 100-6 3 3 0 000 6zM6 15a3 3 0 100-6 3 3 0 000 6zM18 22a3 3 0 100-6 3 3 0 000 6zM8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98',
|
||||
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
|
||||
grid: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z',
|
||||
mana: 'M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z',
|
||||
quote:
|
||||
'M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z',
|
||||
image:
|
||||
'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z',
|
||||
copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
|
||||
download: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4',
|
||||
layers:
|
||||
'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
|
||||
trending: 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6',
|
||||
sparkle:
|
||||
'M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z',
|
||||
};
|
||||
|
||||
function getIconPath(name: string): string {
|
||||
return icons[name] || '';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if hasRoutes}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3
|
||||
class="text-xs font-semibold text-[hsl(var(--muted-foreground))] uppercase tracking-wider"
|
||||
>
|
||||
Navigation anpassen
|
||||
</h3>
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))] mt-1">
|
||||
Versteckte Seiten bleiben über die URL erreichbar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
{#each hideableItems as item (item.href)}
|
||||
{@const hidden = isRouteHidden(item.href)}
|
||||
{@const iconPath = item.icon ? getIconPath(item.icon) : ''}
|
||||
<label
|
||||
class="flex items-center justify-between py-2.5 px-3 rounded-lg hover:bg-[hsl(var(--muted))]/50 cursor-pointer transition-colors border border-transparent hover:border-[hsl(var(--border))]"
|
||||
>
|
||||
<span
|
||||
class="flex items-center gap-3 text-sm {hidden
|
||||
? 'text-[hsl(var(--muted-foreground))]'
|
||||
: 'text-[hsl(var(--foreground))]'}"
|
||||
>
|
||||
{#if iconPath}
|
||||
<svg
|
||||
class="w-4 h-4 flex-shrink-0 {hidden ? 'opacity-50' : ''}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d={iconPath}></path>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class={hidden ? 'line-through' : ''}>{item.label}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors {!hidden
|
||||
? 'bg-[hsl(var(--primary))]'
|
||||
: 'bg-gray-200 dark:bg-gray-700'}"
|
||||
onclick={() => handleToggle(item.href)}
|
||||
aria-label={hidden ? 'Einblenden' : 'Ausblenden'}
|
||||
>
|
||||
<span
|
||||
class="inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform shadow-sm {!hidden
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0.5'}"
|
||||
></span>
|
||||
</button>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -10,3 +10,4 @@ export { default as SettingsTimeInput } from './SettingsTimeInput.svelte';
|
|||
export { default as SettingsDangerZone } from './SettingsDangerZone.svelte';
|
||||
export { default as SettingsDangerButton } from './SettingsDangerButton.svelte';
|
||||
export { default as GlobalSettingsSection } from './GlobalSettingsSection.svelte';
|
||||
export { default as NavVisibilitySettings } from './NavVisibilitySettings.svelte';
|
||||
|
|
|
|||
|
|
@ -22,3 +22,6 @@ export * from './keyboard';
|
|||
|
||||
// IndexedDB Cache
|
||||
export * from './cache';
|
||||
|
||||
// Natural Language Parsers
|
||||
export * from './parsers';
|
||||
|
|
|
|||
320
packages/shared-utils/src/parsers/base-parser.ts
Normal file
320
packages/shared-utils/src/parsers/base-parser.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
/**
|
||||
* Base Natural Language Parser
|
||||
*
|
||||
* Shared parsing utilities for date, time, and tags across all apps.
|
||||
* App-specific parsers (task-parser, event-parser, contact-parser) extend this.
|
||||
*/
|
||||
|
||||
import {
|
||||
addDays,
|
||||
nextMonday,
|
||||
nextTuesday,
|
||||
nextWednesday,
|
||||
nextThursday,
|
||||
nextFriday,
|
||||
nextSaturday,
|
||||
nextSunday,
|
||||
setHours,
|
||||
setMinutes,
|
||||
} from 'date-fns';
|
||||
|
||||
export interface BaseParsedInput {
|
||||
title: string;
|
||||
date?: Date;
|
||||
time?: { hours: number; minutes: number };
|
||||
tagNames: string[];
|
||||
rawInput: string;
|
||||
}
|
||||
|
||||
export interface ExtractResult<T> {
|
||||
value: T | undefined;
|
||||
remaining: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Date Extraction
|
||||
// ============================================================================
|
||||
|
||||
interface DatePattern {
|
||||
pattern: RegExp;
|
||||
getDate: (match?: RegExpMatchArray) => Date;
|
||||
}
|
||||
|
||||
const DATE_PATTERNS: DatePattern[] = [
|
||||
{ pattern: /\bheute\b/i, getDate: () => new Date() },
|
||||
{ pattern: /\bmorgen\b/i, getDate: () => addDays(new Date(), 1) },
|
||||
{ pattern: /\bübermorgen\b/i, getDate: () => addDays(new Date(), 2) },
|
||||
{ pattern: /\bnächste[nr]?\s*woche\b/i, getDate: () => addDays(new Date(), 7) },
|
||||
{ pattern: /\bnächste[nr]?\s*montag\b/i, getDate: () => nextMonday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*dienstag\b/i, getDate: () => nextTuesday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*mittwoch\b/i, getDate: () => nextWednesday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*donnerstag\b/i, getDate: () => nextThursday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*freitag\b/i, getDate: () => nextFriday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*samstag\b/i, getDate: () => nextSaturday(new Date()) },
|
||||
{ pattern: /\bnächste[nr]?\s*sonntag\b/i, getDate: () => nextSunday(new Date()) },
|
||||
{ pattern: /\bmontag\b/i, getDate: () => nextMonday(new Date()) },
|
||||
{ pattern: /\bdienstag\b/i, getDate: () => nextTuesday(new Date()) },
|
||||
{ pattern: /\bmittwoch\b/i, getDate: () => nextWednesday(new Date()) },
|
||||
{ pattern: /\bdonnerstag\b/i, getDate: () => nextThursday(new Date()) },
|
||||
{ pattern: /\bfreitag\b/i, getDate: () => nextFriday(new Date()) },
|
||||
{ pattern: /\bsamstag\b/i, getDate: () => nextSaturday(new Date()) },
|
||||
{ pattern: /\bsonntag\b/i, getDate: () => nextSunday(new Date()) },
|
||||
];
|
||||
|
||||
// Pattern for "in X Tagen"
|
||||
const IN_DAYS_PATTERN = /\bin\s*(\d+)\s*tage?n?\b/i;
|
||||
|
||||
// Pattern for specific date (DD.MM. or DD.MM.YYYY)
|
||||
const SPECIFIC_DATE_PATTERN = /\b(\d{1,2})\.(\d{1,2})\.?(\d{2,4})?\b/;
|
||||
|
||||
/**
|
||||
* Extract date from text
|
||||
*/
|
||||
export function extractDate(text: string): ExtractResult<Date> {
|
||||
let remaining = text;
|
||||
let date: Date | undefined;
|
||||
|
||||
// Try "in X Tagen" pattern first
|
||||
const inDaysMatch = remaining.match(IN_DAYS_PATTERN);
|
||||
if (inDaysMatch) {
|
||||
const days = parseInt(inDaysMatch[1], 10);
|
||||
date = addDays(new Date(), days);
|
||||
remaining = remaining.replace(IN_DAYS_PATTERN, '').trim();
|
||||
return { value: date, remaining };
|
||||
}
|
||||
|
||||
// Try specific date (DD.MM. or DD.MM.YYYY)
|
||||
const specificDateMatch = remaining.match(SPECIFIC_DATE_PATTERN);
|
||||
if (specificDateMatch) {
|
||||
const day = parseInt(specificDateMatch[1], 10);
|
||||
const month = parseInt(specificDateMatch[2], 10) - 1;
|
||||
const year = specificDateMatch[3]
|
||||
? parseInt(specificDateMatch[3], 10) < 100
|
||||
? 2000 + parseInt(specificDateMatch[3], 10)
|
||||
: parseInt(specificDateMatch[3], 10)
|
||||
: new Date().getFullYear();
|
||||
|
||||
date = new Date(year, month, day);
|
||||
remaining = remaining.replace(SPECIFIC_DATE_PATTERN, '').trim();
|
||||
return { value: date, remaining };
|
||||
}
|
||||
|
||||
// Try relative date patterns
|
||||
for (const { pattern, getDate } of DATE_PATTERNS) {
|
||||
if (pattern.test(remaining)) {
|
||||
date = getDate();
|
||||
remaining = remaining.replace(pattern, '').trim();
|
||||
return { value: date, remaining };
|
||||
}
|
||||
}
|
||||
|
||||
return { value: undefined, remaining };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Time Extraction
|
||||
// ============================================================================
|
||||
|
||||
// Pattern for time (um 14 Uhr, 14:00, etc.)
|
||||
const TIME_PATTERN = /\b(?:um\s*)?(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?\b/i;
|
||||
|
||||
/**
|
||||
* Extract time from text
|
||||
*/
|
||||
export function extractTime(text: string): ExtractResult<{ hours: number; minutes: number }> {
|
||||
const match = text.match(TIME_PATTERN);
|
||||
|
||||
if (match) {
|
||||
const hours = parseInt(match[1], 10);
|
||||
const minutes = match[2] ? parseInt(match[2], 10) : 0;
|
||||
|
||||
// Validate time
|
||||
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
|
||||
const remaining = text.replace(TIME_PATTERN, '').trim();
|
||||
return { value: { hours, minutes }, remaining };
|
||||
}
|
||||
}
|
||||
|
||||
return { value: undefined, remaining: text };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tag Extraction
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract tags (#tag1 #tag2) from text
|
||||
*/
|
||||
export function extractTags(text: string): ExtractResult<string[]> {
|
||||
const tags: string[] = [];
|
||||
const tagRegex = /#(\S+)/g;
|
||||
let match;
|
||||
|
||||
while ((match = tagRegex.exec(text)) !== null) {
|
||||
tags.push(match[1]);
|
||||
}
|
||||
|
||||
const remaining = text.replace(/#\S+/g, '').trim();
|
||||
return { value: tags, remaining };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// @ Reference Extraction (Projects, Calendars, Companies)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract @reference from text
|
||||
*/
|
||||
export function extractAtReference(text: string): ExtractResult<string> {
|
||||
const match = text.match(/@(\S+)/);
|
||||
|
||||
if (match) {
|
||||
const remaining = text.replace(/@\S+/, '').trim();
|
||||
return { value: match[1], remaining };
|
||||
}
|
||||
|
||||
return { value: undefined, remaining: text };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Combined Date + Time
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Combine date and time into a single Date object
|
||||
*/
|
||||
export function combineDateAndTime(
|
||||
date?: Date,
|
||||
time?: { hours: number; minutes: number }
|
||||
): Date | undefined {
|
||||
if (!date) return undefined;
|
||||
|
||||
if (time) {
|
||||
return setHours(setMinutes(date, time.minutes), time.hours);
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Preview Formatting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format date for preview display
|
||||
*/
|
||||
export function formatDatePreview(date: Date): string {
|
||||
const now = new Date();
|
||||
const tomorrow = addDays(now, 1);
|
||||
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return 'Heute';
|
||||
}
|
||||
if (date.toDateString() === tomorrow.toDateString()) {
|
||||
return 'Morgen';
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for preview display
|
||||
*/
|
||||
export function formatTimePreview(time: { hours: number; minutes: number }): string {
|
||||
return `${time.hours.toString().padStart(2, '0')}:${time.minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date and time for preview
|
||||
*/
|
||||
export function formatDateTimePreview(
|
||||
date?: Date,
|
||||
time?: { hours: number; minutes: number }
|
||||
): string {
|
||||
if (!date) return '';
|
||||
|
||||
let result = formatDatePreview(date);
|
||||
|
||||
if (time) {
|
||||
result += ` ${formatTimePreview(time)}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Parser Function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse base input - extracts common patterns (date, time, tags, @reference)
|
||||
*
|
||||
* App-specific parsers should call this first, then extract their own patterns.
|
||||
*/
|
||||
export function parseBaseInput(input: string): BaseParsedInput {
|
||||
let text = input.trim();
|
||||
const rawInput = text;
|
||||
|
||||
// Extract tags first (they're clearly delimited)
|
||||
const tagsResult = extractTags(text);
|
||||
text = tagsResult.remaining;
|
||||
const tagNames = tagsResult.value || [];
|
||||
|
||||
// Extract date
|
||||
const dateResult = extractDate(text);
|
||||
text = dateResult.remaining;
|
||||
const date = dateResult.value;
|
||||
|
||||
// Extract time
|
||||
const timeResult = extractTime(text);
|
||||
text = timeResult.remaining;
|
||||
const time = timeResult.value;
|
||||
|
||||
// If we got time but no date, assume today
|
||||
const finalDate = time && !date ? new Date() : date;
|
||||
|
||||
// Clean up multiple spaces
|
||||
const title = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return {
|
||||
title,
|
||||
date: finalDate,
|
||||
time,
|
||||
tagNames,
|
||||
rawInput,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility: Clean title from all patterns
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Remove all recognized patterns from text to get clean title
|
||||
*/
|
||||
export function cleanTitle(text: string): string {
|
||||
let result = text;
|
||||
|
||||
// Remove tags
|
||||
result = result.replace(/#\S+/g, '');
|
||||
|
||||
// Remove @references
|
||||
result = result.replace(/@\S+/g, '');
|
||||
|
||||
// Remove dates
|
||||
result = result.replace(IN_DAYS_PATTERN, '');
|
||||
result = result.replace(SPECIFIC_DATE_PATTERN, '');
|
||||
for (const { pattern } of DATE_PATTERNS) {
|
||||
result = result.replace(pattern, '');
|
||||
}
|
||||
|
||||
// Remove time
|
||||
result = result.replace(TIME_PATTERN, '');
|
||||
|
||||
// Clean up
|
||||
return result.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
26
packages/shared-utils/src/parsers/index.ts
Normal file
26
packages/shared-utils/src/parsers/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Natural Language Parsers
|
||||
*
|
||||
* Base parser with common patterns, extended by app-specific parsers.
|
||||
*/
|
||||
|
||||
export {
|
||||
// Types
|
||||
type BaseParsedInput,
|
||||
type ExtractResult,
|
||||
// Extraction functions
|
||||
extractDate,
|
||||
extractTime,
|
||||
extractTags,
|
||||
extractAtReference,
|
||||
// Combination
|
||||
combineDateAndTime,
|
||||
// Preview formatting
|
||||
formatDatePreview,
|
||||
formatTimePreview,
|
||||
formatDateTimePreview,
|
||||
// Main parser
|
||||
parseBaseInput,
|
||||
// Utilities
|
||||
cleanTitle,
|
||||
} from './base-parser';
|
||||
603
pnpm-lock.yaml
generated
603
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -426,6 +426,8 @@ const APP_CONFIGS = [
|
|||
vars: {
|
||||
PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CALENDAR_BACKEND_PORT || '3014'}`,
|
||||
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
PUBLIC_TODO_BACKEND_URL: (env) =>
|
||||
env.TODO_BACKEND_URL || `http://localhost:${env.TODO_BACKEND_PORT || '3018'}`,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue