feat: drag-to-provider sync, recurring task editing, resizable areas, search improvements
- Sync tasks to external provider when dragged to a synced Someday list (Google/Outlook/Synology) - Add Synology support to POST /api/tasks/sync endpoint - Edit recurring task series (title, interval, unit, time) all at once via new API - Make Someday and all-day areas vertically resizable with drag handles (height persisted in cookies) - Include Someday tasks in search results with list name display - Instant calendar event appearance after creation (improved optimistic update + immediate cache refresh) - Fix project color not updating on tasks when project color changes - Add database backup script for Supabase LXC v1.39.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
43169c0bfc
commit
eebc8eb9b6
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.38.0",
|
"version": "1.39.0",
|
||||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
32
scripts/backup-db.sh
Executable file
32
scripts/backup-db.sh
Executable file
@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Automated PostgreSQL backup script
|
||||||
|
# Run via cron on the Supabase LXC: 0 */6 * * * /root/backup-db.sh
|
||||||
|
# Keeps 7 days of backups
|
||||||
|
|
||||||
|
BACKUP_DIR="/root/backups"
|
||||||
|
CONTAINER="supabase-db"
|
||||||
|
DB_NAME="postgres"
|
||||||
|
DB_USER="postgres"
|
||||||
|
KEEP_DAYS=7
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_FILE="$BACKUP_DIR/todo_backup_${TIMESTAMP}.sql.gz"
|
||||||
|
|
||||||
|
echo "[$(date)] Starting backup..."
|
||||||
|
|
||||||
|
docker exec "$CONTAINER" pg_dump -U "$DB_USER" "$DB_NAME" --clean --if-exists | gzip > "$BACKUP_FILE"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ] && [ -s "$BACKUP_FILE" ]; then
|
||||||
|
echo "[$(date)] Backup saved: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
|
||||||
|
else
|
||||||
|
echo "[$(date)] ERROR: Backup failed!"
|
||||||
|
rm -f "$BACKUP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove backups older than KEEP_DAYS
|
||||||
|
find "$BACKUP_DIR" -name "todo_backup_*.sql.gz" -mtime +$KEEP_DAYS -delete
|
||||||
|
echo "[$(date)] Cleanup done. Current backups:"
|
||||||
|
ls -lh "$BACKUP_DIR"/todo_backup_*.sql.gz 2>/dev/null
|
||||||
83
src/app/api/tasks/recurring/route.ts
Normal file
83
src/app/api/tasks/recurring/route.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
// PATCH - Update all tasks in a recurring series
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: session.user.email } });
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
// Identify the series by the representative task's current values
|
||||||
|
oldTitle,
|
||||||
|
oldRecurrenceInterval,
|
||||||
|
oldRecurrenceUnit,
|
||||||
|
oldRecurrenceTime,
|
||||||
|
// New values to apply
|
||||||
|
newTitle,
|
||||||
|
newRecurrenceInterval,
|
||||||
|
newRecurrenceUnit,
|
||||||
|
newRecurrenceTime,
|
||||||
|
newRecurrenceEndDate,
|
||||||
|
newRecurrenceDays,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!oldTitle) {
|
||||||
|
return NextResponse.json({ error: 'oldTitle is required to identify the series' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all tasks in this recurring series (matching signature)
|
||||||
|
const seriesTasks = await prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
isRecurring: true,
|
||||||
|
title: oldTitle,
|
||||||
|
recurrenceInterval: oldRecurrenceInterval || 1,
|
||||||
|
recurrenceUnit: oldRecurrenceUnit || 'weeks',
|
||||||
|
...(oldRecurrenceTime ? { recurrenceTime: oldRecurrenceTime } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (seriesTasks.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No recurring tasks found matching this series' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update data — only include fields that were provided
|
||||||
|
const updateData: any = {};
|
||||||
|
if (newTitle !== undefined) updateData.title = newTitle;
|
||||||
|
if (newRecurrenceInterval !== undefined) updateData.recurrenceInterval = parseInt(newRecurrenceInterval) || 1;
|
||||||
|
if (newRecurrenceUnit !== undefined) updateData.recurrenceUnit = newRecurrenceUnit;
|
||||||
|
if (newRecurrenceTime !== undefined) updateData.recurrenceTime = newRecurrenceTime || null;
|
||||||
|
if (newRecurrenceEndDate !== undefined) updateData.recurrenceEndDate = newRecurrenceEndDate ? new Date(newRecurrenceEndDate) : null;
|
||||||
|
if (newRecurrenceDays !== undefined) updateData.recurrenceDays = newRecurrenceDays;
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No update fields provided' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all tasks in the series
|
||||||
|
const taskIds = seriesTasks.map(t => t.id);
|
||||||
|
await prisma.task.updateMany({
|
||||||
|
where: { id: { in: taskIds } },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
updatedCount: taskIds.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating recurring series:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update recurring series' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -660,6 +660,39 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ success: true, task: updatedTask });
|
return NextResponse.json({ success: true, task: updatedTask });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === 'synology') {
|
||||||
|
const { createSynologyTask } = await import('@/lib/synology-tasks');
|
||||||
|
const synoConnection = await prisma.calendarConnection.findFirst({
|
||||||
|
where: { userId: task.userId, provider: 'synology' }
|
||||||
|
});
|
||||||
|
if (!synoConnection?.accessToken || !synoConnection?.refreshToken) {
|
||||||
|
return NextResponse.json({ error: 'Synology credentials not available' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const [synoUsername, synoPassword] = synoConnection.accessToken.split(':');
|
||||||
|
const synoServerUrl = synoConnection.refreshToken;
|
||||||
|
if (!synoUsername || !synoPassword || !synoServerUrl) {
|
||||||
|
return NextResponse.json({ error: 'Synology credentials incomplete' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createSynologyTask(synoServerUrl, synoUsername, synoPassword, listExternalId, {
|
||||||
|
title: task.title,
|
||||||
|
notes: task.description || undefined,
|
||||||
|
due: task.scheduledDate ? task.scheduledDate.toISOString() : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedTask = await prisma.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
externalId: created.id,
|
||||||
|
externalProvider: 'synology',
|
||||||
|
externalListId: listExternalId,
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, task: updatedTask });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: `Provider "${provider}" creation sync not supported yet` }, { status: 400 });
|
return NextResponse.json({ error: `Provider "${provider}" creation sync not supported yet` }, { status: 400 });
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@ -2652,6 +2652,33 @@ h3 {
|
|||||||
background: var(--weekly-bg);
|
background: var(--weekly-bg);
|
||||||
border-top: 1px solid var(--weekly-border);
|
border-top: 1px solid var(--weekly-border);
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resize handle for draggable section borders */
|
||||||
|
.resize-handle {
|
||||||
|
height: 8px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.resize-handle:hover .resize-handle-bar,
|
||||||
|
.resize-handle:active .resize-handle-bar {
|
||||||
|
background: var(--weekly-accent, #3b82f6);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.resize-handle-bar {
|
||||||
|
width: 40px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--weekly-border);
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.all-day-events-header {
|
.all-day-events-header {
|
||||||
|
|||||||
@ -1,38 +1,94 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Repeat, X, Trash2 } from 'lucide-react';
|
import { Repeat, X, Trash2, Pencil, Check } from 'lucide-react';
|
||||||
|
|
||||||
interface RecurringTasksManagerProps {
|
interface RecurringTasksManagerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
tasks: any[];
|
tasks: any[];
|
||||||
onStopRecurring: (task: any) => void;
|
onStopRecurring: (task: any) => void;
|
||||||
|
onSeriesUpdated?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecurringTasksManager({ isOpen, onClose, tasks, onStopRecurring }: RecurringTasksManagerProps) {
|
export default function RecurringTasksManager({ isOpen, onClose, tasks, onStopRecurring, onSeriesUpdated }: RecurringTasksManagerProps) {
|
||||||
|
const [editingSignature, setEditingSignature] = useState<string | null>(null);
|
||||||
|
const [editTitle, setEditTitle] = useState('');
|
||||||
|
const [editInterval, setEditInterval] = useState(1);
|
||||||
|
const [editUnit, setEditUnit] = useState('weeks');
|
||||||
|
const [editTime, setEditTime] = useState('');
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
// Filter and group recurring tasks by series signature
|
// Filter and group recurring tasks by series signature
|
||||||
// We use title + recurrence settings as the identity of a "series"
|
const seriesGroups = new Map<string, { task: any; signature: string }>();
|
||||||
const seriesGroups = new Map<string, any>();
|
|
||||||
|
|
||||||
tasks.filter(t => t.isRecurring).forEach(t => {
|
tasks.filter(t => t.isRecurring).forEach(t => {
|
||||||
// Only include those that aren't already ended
|
|
||||||
if (t.recurrenceEndDate && new Date(t.recurrenceEndDate) < new Date()) {
|
if (t.recurrenceEndDate && new Date(t.recurrenceEndDate) < new Date()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const signature = `${t.title}-${t.recurrenceInterval || 1}-${t.recurrenceUnit || 'weeks'}-${t.recurrenceTime || ''}`;
|
const signature = `${t.title}-${t.recurrenceInterval || 1}-${t.recurrenceUnit || 'weeks'}-${t.recurrenceTime || ''}`;
|
||||||
|
|
||||||
// Keep the latest instance to represent the series
|
|
||||||
const existing = seriesGroups.get(signature);
|
const existing = seriesGroups.get(signature);
|
||||||
if (!existing || (t.scheduledDate && new Date(t.scheduledDate) > new Date(existing.scheduledDate || 0))) {
|
if (!existing || (t.scheduledDate && new Date(t.scheduledDate) > new Date(existing.task.scheduledDate || 0))) {
|
||||||
seriesGroups.set(signature, t);
|
seriesGroups.set(signature, { task: t, signature });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupedTasks = Array.from(seriesGroups.values());
|
const groupedTasks = Array.from(seriesGroups.values());
|
||||||
|
|
||||||
|
const startEditing = (task: any, signature: string) => {
|
||||||
|
setEditingSignature(signature);
|
||||||
|
setEditTitle(task.title);
|
||||||
|
setEditInterval(task.recurrenceInterval || 1);
|
||||||
|
setEditUnit(task.recurrenceUnit || 'weeks');
|
||||||
|
setEditTime(task.recurrenceTime || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setEditingSignature(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEditing = async (task: any) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tasks/recurring', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
oldTitle: task.title,
|
||||||
|
oldRecurrenceInterval: task.recurrenceInterval || 1,
|
||||||
|
oldRecurrenceUnit: task.recurrenceUnit || 'weeks',
|
||||||
|
oldRecurrenceTime: task.recurrenceTime || '',
|
||||||
|
newTitle: editTitle,
|
||||||
|
newRecurrenceInterval: editInterval,
|
||||||
|
newRecurrenceUnit: editUnit,
|
||||||
|
newRecurrenceTime: editTime || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setEditingSignature(null);
|
||||||
|
onSeriesUpdated?.();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(err.error || 'Failed to update series');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update recurring series:', e);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unitLabel = (unit: string, interval: number) => {
|
||||||
|
if (unit === 'days') return interval === 1 ? 'Tag' : 'Tage';
|
||||||
|
if (unit === 'weeks') return interval === 1 ? 'Woche' : 'Wochen';
|
||||||
|
if (unit === 'months') return interval === 1 ? 'Monat' : 'Monate';
|
||||||
|
return interval === 1 ? 'Jahr' : 'Jahre';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-[3000] flex items-center justify-center p-4 animate-fadeIn" onClick={onClose}>
|
<div className="fixed inset-0 bg-black/50 z-[3000] flex items-center justify-center p-4 animate-fadeIn" onClick={onClose}>
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[600px] max-w-full overflow-hidden flex flex-col animate-scaleIn" onClick={e => e.stopPropagation()}>
|
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[600px] max-w-full overflow-hidden flex flex-col animate-scaleIn" onClick={e => e.stopPropagation()}>
|
||||||
@ -60,38 +116,103 @@ export default function RecurringTasksManager({ isOpen, onClose, tasks, onStopRe
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{groupedTasks.map(task => (
|
{groupedTasks.map(({ task, signature }) => (
|
||||||
<div key={task.id} className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-100 dark:border-gray-700/50 hover:border-teal-200 dark:hover:border-teal-900/50 transition-all">
|
<div key={signature} className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-100 dark:border-gray-700/50 hover:border-teal-200 dark:hover:border-teal-900/50 transition-all">
|
||||||
<div className="flex-1 min-w-0 pr-4">
|
{editingSignature === signature ? (
|
||||||
<div className="font-semibold text-gray-900 dark:text-white truncate" title={task.title}>
|
/* Edit mode */
|
||||||
{task.title}
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={e => setEditTitle(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm font-semibold outline-none focus:border-teal-400"
|
||||||
|
placeholder="Aufgabentitel"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-gray-500">Alle</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={99}
|
||||||
|
value={editInterval}
|
||||||
|
onChange={e => setEditInterval(parseInt(e.target.value) || 1)}
|
||||||
|
className="w-14 px-2 py-1 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-xs text-center outline-none"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={editUnit}
|
||||||
|
onChange={e => setEditUnit(e.target.value)}
|
||||||
|
className="px-2 py-1 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-xs outline-none"
|
||||||
|
>
|
||||||
|
<option value="days">Tage</option>
|
||||||
|
<option value="weeks">Wochen</option>
|
||||||
|
<option value="months">Monate</option>
|
||||||
|
<option value="years">Jahre</option>
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-gray-500">um</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={editTime}
|
||||||
|
onChange={e => setEditTime(e.target.value)}
|
||||||
|
className="px-2 py-1 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-xs outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={cancelEditing}
|
||||||
|
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => saveEditing(task)}
|
||||||
|
disabled={isSaving || !editTitle.trim()}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-teal-500 hover:bg-teal-600 text-white rounded-lg text-xs font-bold transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check size={12} />
|
||||||
|
{isSaving ? 'Speichern...' : 'Alle aktualisieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
) : (
|
||||||
<span className="text-xs px-2 py-0.5 bg-teal-50 dark:bg-teal-900/20 text-teal-600 dark:text-teal-400 rounded-md font-medium capitalize">
|
/* View mode */
|
||||||
Alle {task.recurrenceInterval === 1 ? '' : task.recurrenceInterval} {
|
<div className="flex items-center justify-between">
|
||||||
task.recurrenceUnit === 'days' ? 'Tage' :
|
<div className="flex-1 min-w-0 pr-4">
|
||||||
task.recurrenceUnit === 'weeks' ? 'Wochen' :
|
<div className="font-semibold text-gray-900 dark:text-white truncate" title={task.title}>
|
||||||
task.recurrenceUnit === 'months' ? 'Monate' : 'Jahre'
|
{task.title}
|
||||||
}
|
</div>
|
||||||
</span>
|
<div className="flex items-center gap-2 mt-1">
|
||||||
{task.recurrenceTime && (
|
<span className="text-xs px-2 py-0.5 bg-teal-50 dark:bg-teal-900/20 text-teal-600 dark:text-teal-400 rounded-md font-medium">
|
||||||
<span className="text-[10px] text-gray-400">
|
Alle {task.recurrenceInterval === 1 ? '' : `${task.recurrenceInterval} `}{unitLabel(task.recurrenceUnit || 'weeks', task.recurrenceInterval || 1)}
|
||||||
um {task.recurrenceTime} Uhr
|
</span>
|
||||||
</span>
|
{task.recurrenceTime && (
|
||||||
)}
|
<span className="text-[10px] text-gray-400">
|
||||||
|
um {task.recurrenceTime} Uhr
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => startEditing(task, signature)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-blue-50 hover:bg-blue-100 dark:bg-blue-900/10 dark:hover:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg text-xs font-bold transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Serie "${task.title}" wirklich beenden?`)) {
|
||||||
|
onStopRecurring(task);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-red-50 hover:bg-red-100 dark:bg-red-900/10 dark:hover:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-xs font-bold transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
Beenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm(`Serie "${task.title}" wirklich beenden?`)) {
|
|
||||||
onStopRecurring(task);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-red-50 hover:bg-red-100 dark:bg-red-900/10 dark:hover:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-xs font-bold transition-colors group"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} className="group-hover:scale-110 transition-transform" />
|
|
||||||
Serie beenden
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,17 +2,24 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface SomedayList {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
tasks: any[];
|
||||||
|
}
|
||||||
|
|
||||||
interface SearchModalProps {
|
interface SearchModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
tasks: any[];
|
tasks: any[];
|
||||||
events: any[];
|
events: any[];
|
||||||
|
somedayLists?: SomedayList[];
|
||||||
onSelectTask: (date: Date) => void;
|
onSelectTask: (date: Date) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchModal({ isOpen, onClose, tasks, events, onSelectTask }: SearchModalProps) {
|
export default function SearchModal({ isOpen, onClose, tasks, events, somedayLists = [], onSelectTask }: SearchModalProps) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<{ type: 'task' | 'event', item: any }[]>([]);
|
const [results, setResults] = useState<{ type: 'task' | 'event' | 'someday', item: any }[]>([]);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -33,18 +40,25 @@ export default function SearchModal({ isOpen, onClose, tasks, events, onSelectTa
|
|||||||
t.title.toLowerCase().includes(lowerQuery) && !t.somedayListId
|
t.title.toLowerCase().includes(lowerQuery) && !t.somedayListId
|
||||||
).map(t => ({ type: 'task' as const, item: t }));
|
).map(t => ({ type: 'task' as const, item: t }));
|
||||||
|
|
||||||
|
// Include someday tasks from all someday lists
|
||||||
|
const somedayTasks = somedayLists.flatMap(list =>
|
||||||
|
list.tasks
|
||||||
|
.filter(t => t.title.toLowerCase().includes(lowerQuery))
|
||||||
|
.map(t => ({ type: 'someday' as const, item: { ...t, somedayListTitle: list.title } }))
|
||||||
|
);
|
||||||
|
|
||||||
const filteredEvents = events.filter(e =>
|
const filteredEvents = events.filter(e =>
|
||||||
e.title.toLowerCase().includes(lowerQuery)
|
e.title.toLowerCase().includes(lowerQuery)
|
||||||
).map(e => ({ type: 'event' as const, item: e }));
|
).map(e => ({ type: 'event' as const, item: e }));
|
||||||
|
|
||||||
setResults([...filteredTasks, ...filteredEvents].slice(0, 10));
|
setResults([...filteredTasks, ...somedayTasks, ...filteredEvents].slice(0, 15));
|
||||||
}, [query, tasks, events]);
|
}, [query, tasks, events, somedayLists]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center pt-20" onClick={onClose}>
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center pt-20" onClick={onClose}>
|
||||||
<div className="bg-white rounded-lg shadow-2xl w-[600px] max-w-[90%] overflow-hidden" onClick={e => e.stopPropagation()}>
|
<div className="rounded-lg shadow-2xl w-[600px] max-w-[90%] overflow-hidden" style={{ backgroundColor: 'var(--weekly-bg, #ffffff)' }} onClick={e => e.stopPropagation()}>
|
||||||
<div className="p-4 border-b border-gray-100 flex items-center gap-3">
|
<div className="p-4 border-b border-gray-100 flex items-center gap-3">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-400">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-400">
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
@ -72,6 +86,10 @@ export default function SearchModal({ isOpen, onClose, tasks, events, onSelectTa
|
|||||||
key={`${result.type}-${result.item.id}-${idx}`}
|
key={`${result.type}-${result.item.id}-${idx}`}
|
||||||
className="px-4 py-3 hover:bg-gray-50 cursor-pointer flex items-center gap-3 border-b border-gray-50 last:border-0"
|
className="px-4 py-3 hover:bg-gray-50 cursor-pointer flex items-center gap-3 border-b border-gray-50 last:border-0"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (result.type === 'someday') {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const date = result.type === 'task'
|
const date = result.type === 'task'
|
||||||
? (result.item.scheduledDate ? new Date(result.item.scheduledDate) : new Date())
|
? (result.item.scheduledDate ? new Date(result.item.scheduledDate) : new Date())
|
||||||
: (new Date(result.item.startTime));
|
: (new Date(result.item.startTime));
|
||||||
@ -80,8 +98,8 @@ export default function SearchModal({ isOpen, onClose, tasks, events, onSelectTa
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={`p-2 rounded-full ${result.type === 'task' ? 'bg-blue-100 text-blue-600' : 'bg-teal-100 text-teal-600'}`}>
|
<div className={`p-2 rounded-full ${result.type === 'task' ? 'bg-blue-100 text-blue-600' : result.type === 'someday' ? 'bg-amber-100 text-amber-600' : 'bg-teal-100 text-teal-600'}`}>
|
||||||
{result.type === 'task' ? (
|
{result.type === 'task' || result.type === 'someday' ? (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>
|
||||||
) : (
|
) : (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||||
@ -90,15 +108,18 @@ export default function SearchModal({ isOpen, onClose, tasks, events, onSelectTa
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium text-gray-800">{result.item.title}</div>
|
<div className="font-medium text-gray-800">{result.item.title}</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
{format(
|
{result.type === 'someday'
|
||||||
result.type === 'task'
|
? `Someday \u2022 ${result.item.somedayListTitle}`
|
||||||
? (result.item.scheduledDate ? new Date(result.item.scheduledDate) : new Date())
|
: format(
|
||||||
: new Date(result.item.startTime),
|
result.type === 'task'
|
||||||
'PPP'
|
? (result.item.scheduledDate ? new Date(result.item.scheduledDate) : new Date())
|
||||||
)}
|
: new Date(result.item.startTime),
|
||||||
|
'PPP'
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{result.type === 'task' && result.item.completed && (
|
{(result.type === 'task' || result.type === 'someday') && result.item.completed && (
|
||||||
<span className="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded">Completed</span>
|
<span className="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded">Completed</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1623,6 +1623,57 @@ export default function WeeklyView() {
|
|||||||
|
|
||||||
const [somedayExpanded, setSomedayExpanded] = useState(true);
|
const [somedayExpanded, setSomedayExpanded] = useState(true);
|
||||||
const [isAllDayExpanded, setIsAllDayExpanded] = useState(true);
|
const [isAllDayExpanded, setIsAllDayExpanded] = useState(true);
|
||||||
|
const [somedayHeight, setSomedayHeight] = useState<number | null>(() => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const c = document.cookie.match(/somedayHeight=(\d+)/);
|
||||||
|
return c ? parseInt(c[1]) : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const [allDayHeight, setAllDayHeight] = useState<number | null>(() => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const c = document.cookie.match(/allDayHeight=(\d+)/);
|
||||||
|
return c ? parseInt(c[1]) : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const resizingRef = useRef<{ target: 'someday' | 'allday'; startY: number; startHeight: number } | null>(null);
|
||||||
|
|
||||||
|
const startResize = useCallback((e: React.MouseEvent | React.TouchEvent, target: 'someday' | 'allday') => {
|
||||||
|
e.preventDefault();
|
||||||
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||||
|
const section = target === 'someday' ? somedaySectionRef.current : (document.querySelector('.all-day-events-section') as HTMLElement);
|
||||||
|
if (!section) return;
|
||||||
|
resizingRef.current = { target, startY: clientY, startHeight: section.getBoundingClientRect().height };
|
||||||
|
|
||||||
|
const onMove = (ev: MouseEvent | TouchEvent) => {
|
||||||
|
if (!resizingRef.current) return;
|
||||||
|
const y = 'touches' in ev ? ev.touches[0].clientY : ev.clientY;
|
||||||
|
const delta = y - resizingRef.current.startY;
|
||||||
|
const newHeight = Math.max(40, Math.min(600, resizingRef.current.startHeight + delta));
|
||||||
|
if (resizingRef.current.target === 'someday') setSomedayHeight(newHeight);
|
||||||
|
else setAllDayHeight(newHeight);
|
||||||
|
};
|
||||||
|
const onEnd = () => {
|
||||||
|
if (resizingRef.current) {
|
||||||
|
const section2 = resizingRef.current.target === 'someday' ? somedaySectionRef.current : (document.querySelector('.all-day-events-section') as HTMLElement);
|
||||||
|
if (section2) {
|
||||||
|
const h = Math.round(section2.getBoundingClientRect().height);
|
||||||
|
document.cookie = `${resizingRef.current.target === 'someday' ? 'somedayHeight' : 'allDayHeight'}=${h};path=/;max-age=31536000`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resizingRef.current = null;
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onEnd);
|
||||||
|
window.removeEventListener('touchmove', onMove);
|
||||||
|
window.removeEventListener('touchend', onEnd);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onEnd);
|
||||||
|
window.addEventListener('touchmove', onMove);
|
||||||
|
window.addEventListener('touchend', onEnd);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [somedayLists, setSomedayLists] = useState<SomedayList[]>([]);
|
const [somedayLists, setSomedayLists] = useState<SomedayList[]>([]);
|
||||||
const [projects, setProjects] = useState<{ id: string; name: string; icon?: string | null; color?: string | null }[]>([]);
|
const [projects, setProjects] = useState<{ id: string; name: string; icon?: string | null; color?: string | null }[]>([]);
|
||||||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||||||
@ -2316,15 +2367,19 @@ export default function WeeklyView() {
|
|||||||
if (data.event) {
|
if (data.event) {
|
||||||
// Transform API shape (start.dateTime/end.dateTime) to frontend shape (startTime/endTime)
|
// Transform API shape (start.dateTime/end.dateTime) to frontend shape (startTime/endTime)
|
||||||
const ev = data.event;
|
const ev = data.event;
|
||||||
|
// Find calendar info from connections to fill in missing color/title
|
||||||
|
const calInfo = connections.flatMap((c: any) =>
|
||||||
|
(c.calendars || []).map((cal: any) => ({ ...cal, provider: c.provider }))
|
||||||
|
).find((c: any) => c.id === (ev.calendarId || eventData.calendarId));
|
||||||
const frontendEvent: CalendarEvent = {
|
const frontendEvent: CalendarEvent = {
|
||||||
id: ev.id,
|
id: ev.id,
|
||||||
title: ev.title,
|
title: ev.title,
|
||||||
startTime: ev.start?.dateTime || ev.start?.date || ev.startTime || '',
|
startTime: ev.start?.dateTime || ev.start?.date || ev.startTime || '',
|
||||||
endTime: ev.end?.dateTime || ev.end?.date || ev.endTime || '',
|
endTime: ev.end?.dateTime || ev.end?.date || ev.endTime || '',
|
||||||
source: ev.source,
|
source: ev.source || calInfo?.provider || 'google',
|
||||||
calendarId: ev.calendarId,
|
calendarId: ev.calendarId || eventData.calendarId,
|
||||||
calendarTitle: ev.calendarTitle,
|
calendarTitle: ev.calendarTitle || calInfo?.summary || calInfo?.title || '',
|
||||||
calendarColor: ev.backgroundColor || ev.calendarColor,
|
calendarColor: ev.backgroundColor || ev.calendarColor || calInfo?.backgroundColor || calInfo?.color || '#3b82f6',
|
||||||
};
|
};
|
||||||
setRawCalendarEvents(prev => {
|
setRawCalendarEvents(prev => {
|
||||||
if (eventData.id) {
|
if (eventData.id) {
|
||||||
@ -2333,10 +2388,9 @@ export default function WeeklyView() {
|
|||||||
return [...prev, frontendEvent];
|
return [...prev, frontendEvent];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Re-read from cache (not a force-refresh from provider, which could
|
// Re-read from cache to get the canonical version
|
||||||
// overwrite the optimistic update if the provider hasn't propagated yet).
|
// The backend already cached the event via upsertCachedEvent
|
||||||
// The backend already cached the event via upsertCachedEvent.
|
await fetchCalendarEvents(false);
|
||||||
setTimeout(() => fetchCalendarEvents(false), 2000);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error saving event:", error);
|
console.error("Error saving event:", error);
|
||||||
if (error.name === "AbortError") {
|
if (error.name === "AbortError") {
|
||||||
@ -2986,7 +3040,28 @@ export default function WeeklyView() {
|
|||||||
const res = await fetch("/api/projects");
|
const res = await fetch("/api/projects");
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setProjects(data.projects || []);
|
const updatedProjects = data.projects || [];
|
||||||
|
setProjects(updatedProjects);
|
||||||
|
|
||||||
|
// Update project references on tasks so color changes take effect immediately
|
||||||
|
const projectMap = new Map<string, { id: string; name: string; icon?: string | null; color?: string | null }>(
|
||||||
|
updatedProjects.map((p: any) => [p.id, p])
|
||||||
|
);
|
||||||
|
setTasks(prev => prev.map(t => {
|
||||||
|
if (t.projectId && projectMap.has(t.projectId)) {
|
||||||
|
return { ...t, project: projectMap.get(t.projectId) || null };
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}));
|
||||||
|
setSomedayLists(prev => prev.map(list => ({
|
||||||
|
...list,
|
||||||
|
tasks: list.tasks.map(t => {
|
||||||
|
if (t.projectId && projectMap.has(t.projectId)) {
|
||||||
|
return { ...t, project: projectMap.get(t.projectId) || null };
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}),
|
||||||
|
})));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching projects:", error);
|
console.error("Error fetching projects:", error);
|
||||||
@ -4873,6 +4948,47 @@ export default function WeeklyView() {
|
|||||||
startTime: null,
|
startTime: null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If the target list is synced to an external provider and
|
||||||
|
// the task doesn't already exist at that provider/list, create it there
|
||||||
|
const targetList = somedayLists.find((l) => l.id === listId);
|
||||||
|
const needsSync = targetList?.externalId && targetList?.externalProvider && (
|
||||||
|
!draggedTask.externalId ||
|
||||||
|
draggedTask.externalProvider !== targetList.externalProvider ||
|
||||||
|
draggedTask.externalListId !== targetList.externalId
|
||||||
|
);
|
||||||
|
if (needsSync) {
|
||||||
|
try {
|
||||||
|
const syncRes = await fetch("/api/tasks/sync", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ taskId: draggedTask.id }),
|
||||||
|
});
|
||||||
|
if (syncRes.ok) {
|
||||||
|
const syncData = await syncRes.json();
|
||||||
|
// Update local state with external IDs
|
||||||
|
if (syncData.task) {
|
||||||
|
setSomedayLists((prev) =>
|
||||||
|
prev.map((l) => ({
|
||||||
|
...l,
|
||||||
|
tasks: l.tasks.map((t) =>
|
||||||
|
t.id === draggedTask.id
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
externalId: syncData.task.externalId,
|
||||||
|
externalProvider: syncData.task.externalProvider,
|
||||||
|
externalListId: syncData.task.externalListId,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (syncError) {
|
||||||
|
console.error("Failed to sync task to external provider:", syncError);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error moving task to someday slot:", error);
|
console.error("Error moving task to someday slot:", error);
|
||||||
}
|
}
|
||||||
@ -5094,6 +5210,7 @@ export default function WeeklyView() {
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`all-day-events-section ${isAllDayExpanded ? "expanded" : "collapsed"}`}
|
className={`all-day-events-section ${isAllDayExpanded ? "expanded" : "collapsed"}`}
|
||||||
|
style={isAllDayExpanded && allDayHeight ? { maxHeight: `${allDayHeight}px`, overflowY: 'auto' } : undefined}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", flexDirection: "row" }}>
|
<div style={{ display: "flex", flexDirection: "row" }}>
|
||||||
{showTimeGrid && (
|
{showTimeGrid && (
|
||||||
@ -5240,6 +5357,16 @@ export default function WeeklyView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Resize handle */}
|
||||||
|
{isAllDayExpanded && (
|
||||||
|
<div
|
||||||
|
className="resize-handle"
|
||||||
|
onMouseDown={(e) => startResize(e, 'allday')}
|
||||||
|
onTouchStart={(e) => startResize(e, 'allday')}
|
||||||
|
>
|
||||||
|
<div className="resize-handle-bar" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
@ -6947,6 +7074,7 @@ export default function WeeklyView() {
|
|||||||
<section
|
<section
|
||||||
ref={somedaySectionRefCb}
|
ref={somedaySectionRefCb}
|
||||||
className={`weekly-someday ${somedayExpanded ? "expanded" : "collapsed"} transition-colors duration-200`}
|
className={`weekly-someday ${somedayExpanded ? "expanded" : "collapsed"} transition-colors duration-200`}
|
||||||
|
style={somedayExpanded && somedayHeight ? { maxHeight: `${somedayHeight}px`, overflowY: 'auto' } : undefined}
|
||||||
>
|
>
|
||||||
{/* Someday tabs bar */}
|
{/* Someday tabs bar */}
|
||||||
<div className="someday-tabs-bar">
|
<div className="someday-tabs-bar">
|
||||||
@ -7820,6 +7948,16 @@ export default function WeeklyView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* close flex row */}
|
{/* close flex row */}
|
||||||
|
{/* Resize handle */}
|
||||||
|
{somedayExpanded && (
|
||||||
|
<div
|
||||||
|
className="resize-handle"
|
||||||
|
onMouseDown={(e) => startResize(e, 'someday')}
|
||||||
|
onTouchStart={(e) => startResize(e, 'someday')}
|
||||||
|
>
|
||||||
|
<div className="resize-handle-bar" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -7830,6 +7968,7 @@ export default function WeeklyView() {
|
|||||||
onClose={() => setIsSearchOpen(false)}
|
onClose={() => setIsSearchOpen(false)}
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
events={calendarEvents}
|
events={calendarEvents}
|
||||||
|
somedayLists={somedayLists}
|
||||||
onSelectTask={(date) => {
|
onSelectTask={(date) => {
|
||||||
setCurrentWeekStart(getStartOfWeek(date));
|
setCurrentWeekStart(getStartOfWeek(date));
|
||||||
}}
|
}}
|
||||||
@ -7884,6 +8023,7 @@ export default function WeeklyView() {
|
|||||||
console.error("Failed to stop recurring series:", error);
|
console.error("Failed to stop recurring series:", error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onSeriesUpdated={() => fetchTasks()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Recurring Task Delete Confirmation Modal */}
|
{/* Recurring Task Delete Confirmation Modal */}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user