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:
mARTin 2026-03-17 00:50:42 +01:00
parent 43169c0bfc
commit eebc8eb9b6
8 changed files with 520 additions and 63 deletions

View File

@ -1,6 +1,6 @@
{
"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",
"main": "index.js",
"scripts": {

32
scripts/backup-db.sh Executable file
View 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

View 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 });
}
}

View File

@ -660,6 +660,39 @@ export async function POST(req: NextRequest) {
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 });
} catch (error: unknown) {

View File

@ -2652,6 +2652,33 @@ h3 {
background: var(--weekly-bg);
border-top: 1px solid var(--weekly-border);
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 {

View File

@ -1,38 +1,94 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from 'react';
import { Repeat, X, Trash2 } from 'lucide-react';
import React, { useState } from 'react';
import { Repeat, X, Trash2, Pencil, Check } from 'lucide-react';
interface RecurringTasksManagerProps {
isOpen: boolean;
onClose: () => void;
tasks: any[];
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;
// Filter and group recurring tasks by series signature
// We use title + recurrence settings as the identity of a "series"
const seriesGroups = new Map<string, any>();
const seriesGroups = new Map<string, { task: any; signature: string }>();
tasks.filter(t => t.isRecurring).forEach(t => {
// Only include those that aren't already ended
if (t.recurrenceEndDate && new Date(t.recurrenceEndDate) < new Date()) {
return;
}
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);
if (!existing || (t.scheduledDate && new Date(t.scheduledDate) > new Date(existing.scheduledDate || 0))) {
seriesGroups.set(signature, t);
if (!existing || (t.scheduledDate && new Date(t.scheduledDate) > new Date(existing.task.scheduledDate || 0))) {
seriesGroups.set(signature, { task: t, signature });
}
});
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 (
<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()}>
@ -60,38 +116,103 @@ export default function RecurringTasksManager({ isOpen, onClose, tasks, onStopRe
</div>
) : (
<div className="space-y-3">
{groupedTasks.map(task => (
<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 className="flex-1 min-w-0 pr-4">
<div className="font-semibold text-gray-900 dark:text-white truncate" title={task.title}>
{task.title}
{groupedTasks.map(({ task, signature }) => (
<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">
{editingSignature === signature ? (
/* Edit mode */
<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 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">
Alle {task.recurrenceInterval === 1 ? '' : task.recurrenceInterval} {
task.recurrenceUnit === 'days' ? 'Tage' :
task.recurrenceUnit === 'weeks' ? 'Wochen' :
task.recurrenceUnit === 'months' ? 'Monate' : 'Jahre'
}
</span>
{task.recurrenceTime && (
<span className="text-[10px] text-gray-400">
um {task.recurrenceTime} Uhr
</span>
)}
) : (
/* View mode */
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0 pr-4">
<div className="font-semibold text-gray-900 dark:text-white truncate" title={task.title}>
{task.title}
</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">
Alle {task.recurrenceInterval === 1 ? '' : `${task.recurrenceInterval} `}{unitLabel(task.recurrenceUnit || 'weeks', task.recurrenceInterval || 1)}
</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>
<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>

View File

@ -2,17 +2,24 @@
import React, { useState, useEffect, useRef } from 'react';
import { format } from 'date-fns';
interface SomedayList {
id: string;
title: string;
tasks: any[];
}
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
tasks: any[];
events: any[];
somedayLists?: SomedayList[];
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 [results, setResults] = useState<{ type: 'task' | 'event', item: any }[]>([]);
const [results, setResults] = useState<{ type: 'task' | 'event' | 'someday', item: any }[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@ -33,18 +40,25 @@ export default function SearchModal({ isOpen, onClose, tasks, events, onSelectTa
t.title.toLowerCase().includes(lowerQuery) && !t.somedayListId
).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 =>
e.title.toLowerCase().includes(lowerQuery)
).map(e => ({ type: 'event' as const, item: e }));
setResults([...filteredTasks, ...filteredEvents].slice(0, 10));
}, [query, tasks, events]);
setResults([...filteredTasks, ...somedayTasks, ...filteredEvents].slice(0, 15));
}, [query, tasks, events, somedayLists]);
if (!isOpen) return null;
return (
<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">
<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>
@ -72,6 +86,10 @@ export default function SearchModal({ isOpen, onClose, tasks, events, onSelectTa
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"
onClick={() => {
if (result.type === 'someday') {
onClose();
return;
}
const date = result.type === 'task'
? (result.item.scheduledDate ? new Date(result.item.scheduledDate) : new Date())
: (new Date(result.item.startTime));
@ -80,8 +98,8 @@ export default function SearchModal({ isOpen, onClose, tasks, events, onSelectTa
onClose();
}}
>
<div className={`p-2 rounded-full ${result.type === 'task' ? 'bg-blue-100 text-blue-600' : 'bg-teal-100 text-teal-600'}`}>
{result.type === 'task' ? (
<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 === '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"><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="font-medium text-gray-800">{result.item.title}</div>
<div className="text-xs text-gray-500">
{format(
result.type === 'task'
? (result.item.scheduledDate ? new Date(result.item.scheduledDate) : new Date())
: new Date(result.item.startTime),
'PPP'
)}
{result.type === 'someday'
? `Someday \u2022 ${result.item.somedayListTitle}`
: format(
result.type === 'task'
? (result.item.scheduledDate ? new Date(result.item.scheduledDate) : new Date())
: new Date(result.item.startTime),
'PPP'
)
}
</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>
)}
</div>

View File

@ -1623,6 +1623,57 @@ export default function WeeklyView() {
const [somedayExpanded, setSomedayExpanded] = 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 [projects, setProjects] = useState<{ id: string; name: string; icon?: string | null; color?: string | null }[]>([]);
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
@ -2316,15 +2367,19 @@ export default function WeeklyView() {
if (data.event) {
// Transform API shape (start.dateTime/end.dateTime) to frontend shape (startTime/endTime)
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 = {
id: ev.id,
title: ev.title,
startTime: ev.start?.dateTime || ev.start?.date || ev.startTime || '',
endTime: ev.end?.dateTime || ev.end?.date || ev.endTime || '',
source: ev.source,
calendarId: ev.calendarId,
calendarTitle: ev.calendarTitle,
calendarColor: ev.backgroundColor || ev.calendarColor,
source: ev.source || calInfo?.provider || 'google',
calendarId: ev.calendarId || eventData.calendarId,
calendarTitle: ev.calendarTitle || calInfo?.summary || calInfo?.title || '',
calendarColor: ev.backgroundColor || ev.calendarColor || calInfo?.backgroundColor || calInfo?.color || '#3b82f6',
};
setRawCalendarEvents(prev => {
if (eventData.id) {
@ -2333,10 +2388,9 @@ export default function WeeklyView() {
return [...prev, frontendEvent];
});
}
// Re-read from cache (not a force-refresh from provider, which could
// overwrite the optimistic update if the provider hasn't propagated yet).
// The backend already cached the event via upsertCachedEvent.
setTimeout(() => fetchCalendarEvents(false), 2000);
// Re-read from cache to get the canonical version
// The backend already cached the event via upsertCachedEvent
await fetchCalendarEvents(false);
} catch (error: any) {
console.error("Error saving event:", error);
if (error.name === "AbortError") {
@ -2986,7 +3040,28 @@ export default function WeeklyView() {
const res = await fetch("/api/projects");
if (res.ok) {
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) {
console.error("Error fetching projects:", error);
@ -4873,6 +4948,47 @@ export default function WeeklyView() {
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) {
console.error("Error moving task to someday slot:", error);
}
@ -5094,6 +5210,7 @@ export default function WeeklyView() {
return (
<section
className={`all-day-events-section ${isAllDayExpanded ? "expanded" : "collapsed"}`}
style={isAllDayExpanded && allDayHeight ? { maxHeight: `${allDayHeight}px`, overflowY: 'auto' } : undefined}
>
<div style={{ display: "flex", flexDirection: "row" }}>
{showTimeGrid && (
@ -5240,6 +5357,16 @@ export default function WeeklyView() {
</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>
);
})();
@ -6947,6 +7074,7 @@ export default function WeeklyView() {
<section
ref={somedaySectionRefCb}
className={`weekly-someday ${somedayExpanded ? "expanded" : "collapsed"} transition-colors duration-200`}
style={somedayExpanded && somedayHeight ? { maxHeight: `${somedayHeight}px`, overflowY: 'auto' } : undefined}
>
{/* Someday tabs bar */}
<div className="someday-tabs-bar">
@ -7820,6 +7948,16 @@ export default function WeeklyView() {
</div>
</div>
{/* 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>
)
}
@ -7830,6 +7968,7 @@ export default function WeeklyView() {
onClose={() => setIsSearchOpen(false)}
tasks={tasks}
events={calendarEvents}
somedayLists={somedayLists}
onSelectTask={(date) => {
setCurrentWeekStart(getStartOfWeek(date));
}}
@ -7884,6 +8023,7 @@ export default function WeeklyView() {
console.error("Failed to stop recurring series:", error);
}
}}
onSeriesUpdated={() => fetchTasks()}
/>
{/* Recurring Task Delete Confirmation Modal */}