fix(calendar): fix Todo integration and show all tasks in sidebar

- Fix /tasks/upcoming endpoint 500 error (Invalid time value)
- Use inArray instead of or() for batch label loading
- Show all todos in sidebar (overdue + today + upcoming)
- Make todo list scrollable with max-height
- Improve error handling: don't mark service unavailable if today tasks loaded

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-10 20:34:25 +01:00
parent 812a6f5090
commit 1040b54058
5 changed files with 90 additions and 81 deletions

View file

@ -84,7 +84,6 @@ export function createApiClient(config: ApiClientConfig) {
return { data, error: null };
} catch (error) {
clearTimeout(timeoutId);
console.error('[BaseClient] Fetch error:', error);
if (error instanceof Error && error.name === 'AbortError') {
return {

View file

@ -73,6 +73,11 @@ export interface Task {
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
// Time-Blocking (for calendar integration)
scheduledDate?: string | null;
scheduledStartTime?: string | null; // HH:mm format
scheduledEndTime?: string | null; // HH:mm format
estimatedDuration?: number | null; // Duration in minutes
priority: TaskPriority;
status: TaskStatus;
isCompleted: boolean;
@ -97,6 +102,11 @@ export interface CreateTaskInput {
projectId?: string | null;
dueDate?: string | null;
dueTime?: string | null;
// Time-Blocking
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledEndTime?: string | null;
estimatedDuration?: number | null;
priority?: TaskPriority;
labelIds?: string[];
subtasks?: Omit<Subtask, 'id'>[];
@ -110,6 +120,11 @@ export interface UpdateTaskInput {
projectId?: string | null;
dueDate?: string | null;
dueTime?: string | null;
// Time-Blocking
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledEndTime?: string | null;
estimatedDuration?: number | null;
priority?: TaskPriority;
status?: TaskStatus;
isCompleted?: boolean;
@ -242,9 +257,7 @@ export async function uncompleteTask(
}
export async function getTodayTasks(): Promise<{ data: Task[] | null; error: Error | null }> {
console.log('[TodoAPI] Fetching /tasks/today from:', TODO_API_BASE);
const result = await fetchTodoApi<TasksResponse>('/tasks/today');
console.log('[TodoAPI] Response:', result);
return {
data: result.data?.tasks || null,
error: result.error,

View file

@ -5,21 +5,14 @@
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));
// Derived: all active todos (overdue + today + upcoming)
const displayTodos = $derived(todosStore.getSidebarTodos());
const overdueCount = $derived(todosStore.overdueTodos.length);
const totalActiveCount = $derived(todosStore.activeTodosCount);
@ -53,10 +46,6 @@
function handleQuickAddCancel() {
showQuickAdd = false;
}
function goToAllTasks() {
goto('/tasks');
}
</script>
<div class="todo-sidebar-section">
@ -118,12 +107,6 @@
/>
{/each}
</div>
{#if totalActiveCount > maxItems}
<button type="button" class="show-all-button" onclick={goToAllTasks}>
Alle {totalActiveCount} anzeigen
</button>
{/if}
{/if}
<!-- Quick Add -->
@ -236,6 +219,8 @@
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 300px;
overflow-y: auto;
}
.service-unavailable,
@ -269,24 +254,6 @@
}
}
.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;

View file

@ -61,7 +61,7 @@ export const todosStore = {
// ========== Derived Getters ==========
/**
* Get todos for a specific day
* Get todos for a specific day (by dueDate)
*/
getTodosForDay(date: Date): Task[] {
const currentTodos = todos ?? [];
@ -74,6 +74,48 @@ export const todosStore = {
});
},
/**
* Get scheduled tasks for a specific day (by scheduledDate - for time-blocking)
*/
getScheduledTasksForDay(date: Date): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
return currentTodos.filter((task) => {
if (!task.scheduledDate || task.isCompleted) return false;
const scheduledDate =
typeof task.scheduledDate === 'string' ? parseISO(task.scheduledDate) : task.scheduledDate;
return isSameDay(scheduledDate, date);
});
},
/**
* Get scheduled tasks within a date range (for time-blocking)
*/
getScheduledTasksInRange(start: Date, end: Date): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
return currentTodos.filter((task) => {
if (!task.scheduledDate) return false;
const scheduledDate =
typeof task.scheduledDate === 'string' ? parseISO(task.scheduledDate) : task.scheduledDate;
return isWithinInterval(scheduledDate, { start, end });
});
},
/**
* Get unscheduled tasks (no scheduledDate - for sidebar drag source)
*/
get unscheduledForTimeBlocking(): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
return currentTodos
.filter((task) => !task.isCompleted && !task.scheduledDate)
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
},
/**
* Get todos within a date range
*/
@ -175,17 +217,15 @@ export const todosStore = {
},
/**
* Get combined sidebar todos (overdue + today, sorted by priority)
* Limited to show in sidebar
* Get combined sidebar todos (overdue + today + upcoming, sorted by date then priority)
*/
getSidebarTodos(limit = 5): Task[] {
getSidebarTodos(): Task[] {
const overdue = this.overdueTodos;
const today = this.todaysTodos;
const upcoming = this.upcomingTodos;
// Combine and sort: overdue first, then today, both by priority
const combined = [...overdue, ...today];
return combined.slice(0, limit);
// Combine: overdue first, then today, then upcoming
return [...overdue, ...today, ...upcoming];
},
/**
@ -242,12 +282,9 @@ export const todosStore = {
loading = true;
error = null;
console.log('[TodoStore] Fetching today todos...');
const result = await api.getTodayTasks();
console.log('[TodoStore] Result:', result);
if (result.error) {
console.error('[TodoStore] Error fetching todos:', result.error);
error = result.error.message;
serviceAvailable = false;
} else {
@ -270,12 +307,9 @@ export const todosStore = {
loading = true;
error = null;
console.log('[TodoStore] Fetching upcoming todos...');
const result = await api.getUpcomingTasks();
console.log('[TodoStore] Upcoming result:', result);
if (result.error) {
console.error('[TodoStore] Error fetching upcoming:', result.error);
error = result.error.message;
// Only set serviceAvailable to false if we have no todos yet
// (if fetchTodayTodos succeeded, we should still show the service as available)

View file

@ -1,5 +1,5 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL, sql } from 'drizzle-orm';
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL, sql, inArray } from 'drizzle-orm';
import { RRule, RRuleSet, rrulestr } from 'rrule';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
@ -125,6 +125,11 @@ export class TaskService {
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
dueTime: dto.dueTime,
startDate: dto.startDate ? new Date(dto.startDate) : null,
// Time-Blocking fields
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
scheduledStartTime: dto.scheduledStartTime,
scheduledEndTime: dto.scheduledEndTime,
estimatedDuration: dto.estimatedDuration,
priority: dto.priority ?? 'medium',
recurrenceRule: dto.recurrenceRule,
recurrenceEndDate: dto.recurrenceEndDate ? new Date(dto.recurrenceEndDate) : null,
@ -159,6 +164,12 @@ export class TaskService {
: dto.startDate === null
? null
: undefined,
// Time-Blocking fields
scheduledDate: dto.scheduledDate
? new Date(dto.scheduledDate)
: dto.scheduledDate === null
? null
: undefined,
recurrenceEndDate: dto.recurrenceEndDate
? new Date(dto.recurrenceEndDate)
: dto.recurrenceEndDate === null
@ -434,21 +445,20 @@ export class TaskService {
}
async getUpcomingTasks(userId: string, days: number = 7): Promise<TaskWithLabels[]> {
// Ensure days is a valid number
const daysNum = typeof days === 'number' && !isNaN(days) ? days : 7;
const today = new Date();
today.setHours(0, 0, 0, 0);
const endDate = new Date(today);
endDate.setDate(endDate.getDate() + days);
// Use SQL date strings for more reliable comparison
const todayStr = today.toISOString();
const endDateStr = endDate.toISOString();
const endDate = new Date(today.getTime());
endDate.setDate(endDate.getDate() + daysNum);
const result = await this.db.query.tasks.findMany({
where: and(
eq(tasks.userId, userId),
eq(tasks.isCompleted, false),
gte(tasks.dueDate, todayStr),
lte(tasks.dueDate, endDateStr)
gte(tasks.dueDate, today),
lte(tasks.dueDate, endDate)
),
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
});
@ -529,17 +539,11 @@ export class TaskService {
const taskIds = taskList.map((t) => t.id);
// Single query to get all task-label relationships
// Use inArray for better performance with multiple IDs
// Single query to get all task-label relationships using inArray
const allTaskLabels = await this.db
.select()
.from(taskLabels)
.where(
sql`${taskLabels.taskId} IN (${sql.join(
taskIds.map((id) => sql`${id}`),
sql`, `
)})`
);
.where(inArray(taskLabels.taskId, taskIds));
if (allTaskLabels.length === 0) {
// No labels for any task - return tasks with empty labels array
@ -549,16 +553,8 @@ export class TaskService {
// Get unique label IDs
const uniqueLabelIds = [...new Set(allTaskLabels.map((tl) => tl.labelId))];
// Single query to get all labels using IN clause
const allLabels = await this.db
.select()
.from(labels)
.where(
sql`${labels.id} IN (${sql.join(
uniqueLabelIds.map((id) => sql`${id}`),
sql`, `
)})`
);
// Single query to get all labels using inArray
const allLabels = await this.db.select().from(labels).where(inArray(labels.id, uniqueLabelIds));
// Create a map of labelId -> label for fast lookup
const labelMap = new Map(allLabels.map((l) => [l.id, l]));