feat: add backup/restore, task indicators, and multiple bug fixes

- Add JSON export/import for all tasks, anyday lists, and projects (Settings > Account > Backup & Restore)
- Add merge and replace import modes with confirmation for destructive replace
- Add resizable notes sidebar with drag handle on left edge
- Add subtask count indicator (indigo pill) on tasks that toggles subtask list
- Add note indicator (amber pill) on tasks that toggles inline notes
- Show Account ID in Settings > Account as read-only identifier
- Fix goal save bug: saved goals matching default text are no longer ignored
- Fix calendar week number using Monday within visible range instead of middle day
- Fix calendar events not appearing instantly by delaying sync refresh 3s
- Grey out URL field when creating events on Google/Outlook calendars

v1.12.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-05 15:39:37 +01:00
parent 5dad5aa492
commit 6574eeb65f
8 changed files with 847 additions and 26 deletions

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.11.2",
"version": "1.12.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": {

View File

@ -47,7 +47,7 @@ export async function GET(req: Request) {
},
});
if (goal && goal.text && goal.text !== 'your goal of this week' && goal.text !== user?.goalDefaultSentence) {
if (goal && goal.text) {
return NextResponse.json({ goal: goal.text });
}

View File

@ -0,0 +1,122 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
export async function GET() {
const session = await getServerSession(authOptions);
if (!session || !session.user?.email) {
return new NextResponse('Unauthorized', { status: 401 });
}
try {
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (!user) {
return new NextResponse('User not found', { status: 404 });
}
// Fetch someday lists
const somedayLists = await prisma.somedayList.findMany({
where: { userId: user.id },
orderBy: { order: 'asc' },
select: {
id: true,
title: true,
order: true,
createdAt: true,
updatedAt: true,
},
});
// Fetch projects
const projects = await prisma.project.findMany({
where: { userId: user.id },
orderBy: { order: 'asc' },
select: {
id: true,
name: true,
icon: true,
color: true,
description: true,
order: true,
createdAt: true,
updatedAt: true,
},
});
// Fetch all non-deleted tasks (top-level and subtasks)
const allTasks = await prisma.task.findMany({
where: {
userId: user.id,
deletedAt: null,
},
orderBy: { order: 'asc' },
select: {
id: true,
title: true,
description: true,
markdownContent: true,
completed: true,
isRolling: true,
order: true,
dayOfWeek: true,
scheduledDate: true,
somedayListId: true,
originalDate: true,
startTime: true,
endTime: true,
isRecurring: true,
recurrenceInterval: true,
recurrenceUnit: true,
recurrenceTime: true,
recurrenceEndDate: true,
createdAt: true,
updatedAt: true,
parentTaskId: true,
somedaySlotIndex: true,
projectId: true,
},
});
// Build a tree: nest subtasks under their parents
const taskMap = new Map<string, any>();
const topLevelTasks: any[] = [];
for (const task of allTasks) {
taskMap.set(task.id, { ...task, subTasks: [] });
}
for (const task of allTasks) {
const taskWithSubs = taskMap.get(task.id)!;
if (task.parentTaskId && taskMap.has(task.parentTaskId)) {
taskMap.get(task.parentTaskId)!.subTasks.push(taskWithSubs);
} else {
topLevelTasks.push(taskWithSubs);
}
}
const exportData = {
exportVersion: 1,
exportDate: new Date().toISOString(),
somedayLists,
projects,
tasks: topLevelTasks,
};
const jsonContent = JSON.stringify(exportData, null, 2);
return new NextResponse(jsonContent, {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="weekly_todo_backup_${new Date().toISOString().split('T')[0]}.json"`,
},
});
} catch (error) {
console.error('Export data error:', error);
return new NextResponse('Internal Server Error', { status: 500 });
}
}

View File

@ -0,0 +1,215 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
interface ImportTask {
id?: string;
title: string;
description?: string | null;
markdownContent?: string | null;
completed?: boolean;
isRolling?: boolean;
order?: number;
dayOfWeek?: number | null;
scheduledDate?: string | null;
somedayListId?: string | null;
originalDate?: string | null;
startTime?: string | null;
endTime?: string | null;
isRecurring?: boolean;
recurrenceInterval?: number | null;
recurrenceUnit?: string | null;
recurrenceTime?: string | null;
recurrenceEndDate?: string | null;
createdAt?: string;
updatedAt?: string;
parentTaskId?: string | null;
somedaySlotIndex?: number | null;
projectId?: string | null;
subTasks?: ImportTask[];
}
interface ImportData {
exportVersion: number;
somedayLists?: Array<{
id?: string;
title: string;
order?: number;
createdAt?: string;
updatedAt?: string;
}>;
projects?: Array<{
id?: string;
name: string;
icon?: string | null;
color?: string | null;
description?: string | null;
order?: number;
createdAt?: string;
updatedAt?: string;
}>;
tasks?: ImportTask[];
}
export async function POST(request: Request) {
const session = await getServerSession(authOptions);
if (!session || !session.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const { searchParams } = new URL(request.url);
const mode = searchParams.get('mode') || 'merge';
const body: ImportData = await request.json();
// Validate structure
if (!body.exportVersion || typeof body.exportVersion !== 'number') {
return NextResponse.json({ error: 'Invalid export file: missing exportVersion' }, { status: 400 });
}
const somedayLists = body.somedayLists || [];
const projects = body.projects || [];
const tasks = body.tasks || [];
// ID mapping: old export ID -> new DB ID
const listIdMap = new Map<string, string>();
const projectIdMap = new Map<string, string>();
const taskIdMap = new Map<string, string>();
let importedLists = 0;
let importedProjects = 0;
let importedTasks = 0;
// In replace mode, delete all existing data first
if (mode === 'replace') {
await prisma.task.deleteMany({ where: { userId: user.id } });
await prisma.somedayList.deleteMany({ where: { userId: user.id } });
await prisma.project.deleteMany({ where: { userId: user.id } });
}
// 1. Import someday lists
for (const list of somedayLists) {
const created = await prisma.somedayList.create({
data: {
userId: user.id,
title: list.title,
order: list.order ?? 0,
},
});
if (list.id) {
listIdMap.set(list.id, created.id);
}
importedLists++;
}
// 2. Import projects
for (const project of projects) {
const created = await prisma.project.create({
data: {
userId: user.id,
name: project.name,
icon: project.icon ?? null,
color: project.color ?? null,
description: project.description ?? null,
order: project.order ?? 0,
},
});
if (project.id) {
projectIdMap.set(project.id, created.id);
}
importedProjects++;
}
// 3. Import tasks (two-pass: parents first, then link subtasks)
// Flatten all tasks with their subtasks
const flatTasks: Array<{ task: ImportTask; originalParentId: string | null }> = [];
function flattenTasks(taskList: ImportTask[], parentId: string | null) {
for (const task of taskList) {
flatTasks.push({ task, originalParentId: parentId });
if (task.subTasks && task.subTasks.length > 0) {
flattenTasks(task.subTasks, task.id || null);
}
}
}
flattenTasks(tasks, null);
// Pass 1: Create all tasks without parentTaskId
for (const { task } of flatTasks) {
const resolvedListId = task.somedayListId ? listIdMap.get(task.somedayListId) : null;
const resolvedProjectId = task.projectId ? projectIdMap.get(task.projectId) : null;
const created = await prisma.task.create({
data: {
userId: user.id,
title: task.title,
description: task.description ?? null,
markdownContent: task.markdownContent ?? null,
completed: task.completed ?? false,
isRolling: task.isRolling ?? false,
order: task.order ?? 0,
dayOfWeek: task.dayOfWeek ?? null,
scheduledDate: task.scheduledDate ? new Date(task.scheduledDate) : null,
somedayListId: resolvedListId ?? null,
originalDate: task.originalDate ? new Date(task.originalDate) : null,
startTime: task.startTime ?? null,
endTime: task.endTime ?? null,
isRecurring: task.isRecurring ?? false,
recurrenceInterval: task.recurrenceInterval ?? null,
recurrenceUnit: task.recurrenceUnit ?? null,
recurrenceTime: task.recurrenceTime ?? null,
recurrenceEndDate: task.recurrenceEndDate ? new Date(task.recurrenceEndDate) : null,
somedaySlotIndex: task.somedaySlotIndex ?? null,
projectId: resolvedProjectId ?? null,
},
});
if (task.id) {
taskIdMap.set(task.id, created.id);
}
importedTasks++;
}
// Pass 2: Link subtasks to their parents
for (const { task, originalParentId } of flatTasks) {
if (originalParentId && task.id) {
const newTaskId = taskIdMap.get(task.id);
const newParentId = taskIdMap.get(originalParentId);
if (newTaskId && newParentId) {
await prisma.task.update({
where: { id: newTaskId },
data: { parentTaskId: newParentId },
});
}
}
}
return NextResponse.json({
success: true,
mode,
imported: {
somedayLists: importedLists,
projects: importedProjects,
tasks: importedTasks,
},
});
} catch (error) {
console.error('Import data error:', error);
const message = error instanceof SyntaxError
? 'Invalid JSON file'
: 'Import failed. Please check the file format.';
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -12,6 +12,7 @@ export async function GET(request: NextRequest) {
const user = await (prisma.user as any).findUnique({
where: { email: session.user.email },
select: {
id: true,
name: true,
email: true,
timezone: true,

View File

@ -2848,8 +2848,7 @@ h3 {
position: fixed;
top: 0;
right: 0;
width: 500px;
max-width: 95vw;
max-width: 90vw;
height: 100vh;
background: white;
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.1);

View File

@ -23,6 +23,7 @@ export default function CalendarEventModal({
const availableCalendars = connections
.flatMap(conn => (conn.calendars || []).map((cal: any) => ({
...cal,
provider: conn.provider,
providerName: conn.provider === 'google' ? 'Google Calendar' :
conn.provider === 'apple' ? 'Apple Calendar' :
'Outlook Calendar'
@ -257,16 +258,31 @@ export default function CalendarEventModal({
</div>
{/* URL */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>URL</label>
<input
type="url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="https://..."
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
{(() => {
const selectedCal = availableCalendars.find((c: any) => c.id === calendarId);
const isUrlDisabled = selectedCal && (selectedCal.provider === 'google' || selectedCal.provider === 'outlook');
return (
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>URL</label>
<input
type="url"
value={isUrlDisabled ? '' : url}
onChange={e => setUrl(e.target.value)}
placeholder={isUrlDisabled ? 'Not supported by this calendar provider' : 'https://...'}
disabled={!!isUrlDisabled}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
opacity: isUrlDisabled ? 0.5 : 1,
background: isUrlDisabled ? '#f5f5f5' : undefined,
cursor: isUrlDisabled ? 'not-allowed' : undefined,
}}
/>
</div>
);
})()}
{/* Description */}
<div>

View File

@ -258,6 +258,20 @@ const translations: Record<string, any> = {
alignmentCenter: "Center",
alignmentRight: "Right",
alignmentTight: "Tight",
backupRestore: "Backup & Restore",
backupRestoreDesc: "Export all your tasks, anyday lists, and projects as a JSON file. You can edit the file and import it back.",
exportAllData: "Export All Data (JSON)",
importData: "Import Data",
importMode: "Import Mode",
importModeMerge: "Merge",
importModeMergeDesc: "Add imported data alongside existing tasks",
importModeReplace: "Replace",
importModeReplaceDesc: "Delete all existing data and replace with imported data",
importReplaceWarning: "Warning: This will permanently delete all your current tasks, lists, and projects!",
importSelectFile: "Select JSON file...",
importButton: "Import",
importing: "Importing...",
exporting: "Exporting...",
},
de: {
settings: "Einstellungen",
@ -323,6 +337,20 @@ const translations: Record<string, any> = {
alignmentCenter: "Mitte",
alignmentRight: "Rechts",
alignmentTight: "Eng",
backupRestore: "Sicherung & Wiederherstellung",
backupRestoreDesc: "Exportieren Sie alle Aufgaben, Irgendwann-Listen und Projekte als JSON-Datei. Sie können die Datei bearbeiten und wieder importieren.",
exportAllData: "Alle Daten exportieren (JSON)",
importData: "Daten importieren",
importMode: "Import-Modus",
importModeMerge: "Zusammenführen",
importModeMergeDesc: "Importierte Daten neben bestehenden Aufgaben hinzufügen",
importModeReplace: "Ersetzen",
importModeReplaceDesc: "Alle bestehenden Daten löschen und durch importierte ersetzen",
importReplaceWarning: "Warnung: Dies löscht dauerhaft alle Ihre aktuellen Aufgaben, Listen und Projekte!",
importSelectFile: "JSON-Datei auswählen...",
importButton: "Importieren",
importing: "Importiere...",
exporting: "Exportiere...",
},
};
@ -401,6 +429,7 @@ function getHourFromSlot(slot: string): number {
}
function getWeekNumber(date: Date): number {
// ISO 8601 week number: weeks start on Monday
const d = new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
);
@ -410,6 +439,25 @@ function getWeekNumber(date: Date): number {
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
// Find the Monday within a visible week range to get the correct CW
function getMondayOfVisibleWeek(days: Date[]): Date {
// Look for Monday in visible days
for (const day of days) {
if (day.getDay() === 1) return day;
}
// If no Monday visible (e.g. 5-day view starting Wed), find nearest Monday
if (days.length > 0) {
const first = days[0];
const dayOfWeek = first.getDay();
// Go forward to Monday
const daysUntilMon = (1 - dayOfWeek + 7) % 7;
if (daysUntilMon <= 6) {
return new Date(first.getTime() + daysUntilMon * 86400000);
}
}
return days[Math.floor(days.length / 2)] || new Date();
}
// Check if an event is an all-day event
// Defined outside component to avoid stale closure issues in useCallbacks
const isAllDayEvent = (event: CalendarEvent): boolean => {
@ -1068,8 +1116,9 @@ export default function WeeklyView() {
return [...prev, frontendEvent];
});
}
// Also force-refresh from provider to ensure full sync
fetchCalendarEvents(true);
// Delay the force-refresh to give the provider time to propagate
// This prevents overwriting the optimistic update with stale data
setTimeout(() => fetchCalendarEvents(true), 3000);
} catch (error: any) {
console.error("Error saving event:", error);
if (error.name === "AbortError") {
@ -3752,7 +3801,7 @@ export default function WeeklyView() {
) : syncError ? (
<AlertCircle size={14} className="text-red-500" />
) : null}
<span style={{ whiteSpace: "nowrap", fontSize: "0.8rem" }}>KW{getWeekNumber((() => { const days = getVisibleDays(); const mid = days[Math.floor(days.length / 2)] || currentWeekStart; return mid; })()).toString().padStart(2, "0")}/{(() => { const days = getVisibleDays(); const mid = days[Math.floor(days.length / 2)] || currentWeekStart; return mid; })().getFullYear()}</span>
<span style={{ whiteSpace: "nowrap", fontSize: "0.8rem" }}>KW{getWeekNumber(getMondayOfVisibleWeek(getVisibleDays())).toString().padStart(2, "0")}/{getMondayOfVisibleWeek(getVisibleDays()).getFullYear()}</span>
</div>
{/* Right: Settings + Overflow */}
@ -3963,7 +4012,7 @@ export default function WeeklyView() {
color: adjustColorForDarkMode(profile.cwColor || "#333333", darkMode),
filter: "brightness(var(--weekly-header-brightness, 1))"
}}>
KW {getWeekNumber((() => { const days = getVisibleDays(); const mid = days[Math.floor(days.length / 2)] || currentWeekStart; return mid; })()).toString().padStart(2, "0")}
KW {getWeekNumber(getMondayOfVisibleWeek(getVisibleDays())).toString().padStart(2, "0")}
</span>
<span className="text-gray-400">|</span>
<span
@ -6399,29 +6448,70 @@ function TaskItem({
>
{task.title}
</span>
{/* Subtask indicator - toggles subtask list */}
{task.subTasks && task.subTasks.length > 0 && !isSubTask && (
<button
onClick={(e) => {
e.stopPropagation();
setIsSubTasksOpen(!isSubTasksOpen);
}}
className="focus:outline-none flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
title={isSubTasksOpen ? "Collapse subtasks" : "Expand subtasks"}
className="focus:outline-none flex-shrink-0"
title={isSubTasksOpen ? "Collapse subtasks" : `${task.subTasks.length} subtask${task.subTasks.length > 1 ? "s" : ""}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "2px",
padding: "1px 4px",
borderRadius: "3px",
background: isSubTasksOpen ? "rgba(99, 102, 241, 0.12)" : "rgba(0,0,0,0.05)",
color: isSubTasksOpen ? "#6366f1" : "#888",
border: "none",
cursor: "pointer",
fontSize: "0.7rem",
fontWeight: 600,
lineHeight: 1,
transition: "all 0.15s",
}}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ transform: isSubTasksOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
{task.subTasks.length}
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ transform: isSubTasksOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
)}
{task.markdownContent && (
<span className="task-note-icon flex-shrink-0" data-note={task.markdownContent}>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{/* Note indicator - toggles inline notes */}
{task.markdownContent && task.markdownContent.trim().length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setIsNotesOpen(!isNotesOpen);
}}
className="focus:outline-none flex-shrink-0"
title={isNotesOpen ? "Collapse note" : "Expand note"}
style={{
display: "inline-flex",
alignItems: "center",
padding: "1px 4px",
borderRadius: "3px",
background: isNotesOpen ? "rgba(245, 158, 11, 0.12)" : "rgba(0,0,0,0.05)",
color: isNotesOpen ? "#f59e0b" : "#888",
border: "none",
cursor: "pointer",
lineHeight: 1,
transition: "all 0.15s",
}}
>
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</span>
</button>
)}
</span>
@ -6937,6 +7027,29 @@ interface NotesSidebarProps {
function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) {
const [isVisible, setIsVisible] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [sidebarWidth, setSidebarWidth] = useState(500);
const isResizing = useRef(false);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing.current) return;
const newWidth = window.innerWidth - e.clientX;
setSidebarWidth(Math.max(320, Math.min(newWidth, window.innerWidth * 0.9)));
};
const handleMouseUp = () => {
if (isResizing.current) {
isResizing.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, []);
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 10);
@ -6985,7 +7098,26 @@ function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) {
onClick={handleClose}
style={{ zIndex: 1999 }}
/>
<div className={`weekly-notes-sidebar ${isVisible ? "open" : ""}`}>
<div className={`weekly-notes-sidebar ${isVisible ? "open" : ""}`} style={{ width: `${sidebarWidth}px` }}>
{/* Resize handle */}
<div
onMouseDown={(e) => {
e.preventDefault();
isResizing.current = true;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}}
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '6px',
cursor: 'col-resize',
zIndex: 10,
}}
title="Drag to resize"
/>
<header className="weekly-notes-sidebar-header">
<h2 className="weekly-notes-sidebar-title">Notes: {task.title}</h2>
<button className="weekly-notes-sidebar-close" onClick={handleClose}>
@ -7104,6 +7236,11 @@ function SettingsSidebar({
const [isSyncing, setIsSyncing] = useState(false);
const [exportStartDate, setExportStartDate] = useState("");
const [exportEndDate, setExportEndDate] = useState("");
const [importMode, setImportMode] = useState<"merge" | "replace">("merge");
const [importFile, setImportFile] = useState<File | null>(null);
const [importMsg, setImportMsg] = useState("");
const [isImporting, setIsImporting] = useState(false);
const [isExportingAll, setIsExportingAll] = useState(false);
const [passwords, setPasswords] = useState({ new: "", confirm: "" });
const [accountMsg, setAccountMsg] = useState("");
const [isVisible, setIsVisible] = useState(false);
@ -7209,6 +7346,7 @@ function SettingsSidebar({
showTaskCheckboxes?: boolean;
quoteSourceUrls?: string[];
startDayOffset?: number;
id?: string;
}>({
name: "",
email: "",
@ -7695,6 +7833,81 @@ function SettingsSidebar({
window.open("/api/user/export", "_blank");
};
const handleExportAllData = async () => {
setIsExportingAll(true);
try {
const res = await fetch("/api/user/export-data");
if (!res.ok) throw new Error("Export failed");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `weekly_todo_backup_${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
console.error("Export error:", e);
} finally {
setIsExportingAll(false);
}
};
const handleImportData = async () => {
if (!importFile) return;
if (importMode === "replace") {
const confirmed = confirm(
profile.language === "de"
? "Sind Sie sicher? Alle bestehenden Aufgaben, Listen und Projekte werden gelöscht und durch die importierten Daten ersetzt."
: "Are you sure? All existing tasks, lists, and projects will be deleted and replaced with the imported data."
);
if (!confirmed) return;
}
setIsImporting(true);
setImportMsg("");
try {
const text = await importFile.text();
JSON.parse(text); // validate JSON
const res = await fetch(`/api/user/import-data?mode=${importMode}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: text,
});
const data = await res.json();
if (!res.ok) {
setImportMsg(`${data.error || "Import failed"}`);
return;
}
const { imported } = data;
const parts: string[] = [];
if (imported.tasks > 0) parts.push(`${imported.tasks} ${profile.language === "de" ? "Aufgaben" : "tasks"}`);
if (imported.somedayLists > 0) parts.push(`${imported.somedayLists} ${profile.language === "de" ? "Listen" : "lists"}`);
if (imported.projects > 0) parts.push(`${imported.projects} ${profile.language === "de" ? "Projekte" : "projects"}`);
setImportMsg(`${profile.language === "de" ? "Importiert" : "Imported"}: ${parts.join(", ")}`);
setImportFile(null);
// Reset file input
const fileInput = document.getElementById("import-file-input") as HTMLInputElement;
if (fileInput) fileInput.value = "";
// Reload to reflect imported data
setTimeout(() => window.location.reload(), 1500);
} catch (e) {
setImportMsg(`${profile.language === "de" ? "Ungültige JSON-Datei" : "Invalid JSON file"}`);
} finally {
setIsImporting(false);
}
};
const handleDeleteAccount = async () => {
if (
!confirm(
@ -10438,6 +10651,43 @@ function SettingsSidebar({
}}
/>
</div>
{profile.id && (
<div>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{profile.language === "de" ? "Konto-ID" : "Account ID"}
</label>
<input
type="text"
value={profile.id}
readOnly
onClick={(e) => (e.target as HTMLInputElement).select()}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid #eee",
borderRadius: "4px",
background: "#f5f5f5",
color: "#555",
fontSize: "0.85rem",
fontFamily: "monospace",
cursor: "text",
}}
/>
<span style={{ fontSize: "0.75rem", color: "var(--weekly-settings-label)", opacity: 0.7 }}>
{profile.language === "de"
? "Ihre eindeutige Konto-Kennung"
: "Your unique account identifier"}
</span>
</div>
)}
<div>
<label
style={{
@ -10668,6 +10918,224 @@ function SettingsSidebar({
</a>
</div>
{/* Backup & Restore Section */}
<div
style={{
marginTop: "20px",
paddingTop: "20px",
borderTop: "1px solid var(--weekly-border)",
}}
>
<h4
style={{
marginBottom: "10px",
fontSize: "1rem",
fontWeight: 600,
color: "var(--weekly-settings-title)",
}}
>
{t.backupRestore}
</h4>
<p
style={{
fontSize: "0.9rem",
color: "var(--weekly-settings-label)",
marginBottom: "15px",
}}
>
{t.backupRestoreDesc}
</p>
{/* Export All Data */}
<button
onClick={handleExportAllData}
disabled={isExportingAll}
className="weekly-auth-button w-full justify-center"
style={{
display: "inline-flex",
width: "100%",
padding: "10px",
background: "var(--weekly-settings-item-bg)",
border: "1px solid var(--weekly-settings-input-border)",
color: "var(--weekly-settings-text)",
borderRadius: "4px",
fontWeight: 500,
cursor: isExportingAll ? "wait" : "pointer",
transition: "background-color 0.2s",
marginBottom: "15px",
opacity: isExportingAll ? 0.7 : 1,
}}
>
{isExportingAll ? t.exporting : t.exportAllData}
</button>
{/* Import Section */}
<div
style={{
padding: "12px",
border: "1px solid var(--weekly-border)",
borderRadius: "6px",
background: "var(--weekly-settings-item-bg)",
}}
>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "10px",
color: "var(--weekly-settings-text)",
}}
>
{t.importData}
</label>
{/* Import Mode Toggle */}
<div style={{ marginBottom: "10px" }}>
<label
style={{
display: "block",
fontSize: "0.8rem",
fontWeight: 600,
marginBottom: "6px",
color: "var(--weekly-settings-label)",
}}
>
{t.importMode}
</label>
<div style={{ display: "flex", gap: "8px" }}>
<button
onClick={() => setImportMode("merge")}
style={{
flex: 1,
padding: "8px",
borderRadius: "4px",
border: importMode === "merge"
? "2px solid var(--weekly-teal)"
: "1px solid var(--weekly-settings-input-border)",
background: importMode === "merge"
? "rgba(20, 184, 166, 0.1)"
: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
cursor: "pointer",
fontSize: "0.85rem",
fontWeight: importMode === "merge" ? 600 : 400,
transition: "all 0.2s",
}}
>
<div>{t.importModeMerge}</div>
<div style={{ fontSize: "0.75rem", opacity: 0.7, marginTop: "2px" }}>
{t.importModeMergeDesc}
</div>
</button>
<button
onClick={() => setImportMode("replace")}
style={{
flex: 1,
padding: "8px",
borderRadius: "4px",
border: importMode === "replace"
? "2px solid #ef4444"
: "1px solid var(--weekly-settings-input-border)",
background: importMode === "replace"
? "rgba(239, 68, 68, 0.1)"
: "var(--weekly-settings-input-bg)",
color: importMode === "replace" ? "#ef4444" : "var(--weekly-settings-text)",
cursor: "pointer",
fontSize: "0.85rem",
fontWeight: importMode === "replace" ? 600 : 400,
transition: "all 0.2s",
}}
>
<div>{t.importModeReplace}</div>
<div style={{ fontSize: "0.75rem", opacity: 0.7, marginTop: "2px" }}>
{t.importModeReplaceDesc}
</div>
</button>
</div>
</div>
{importMode === "replace" && (
<div
style={{
padding: "8px 10px",
marginBottom: "10px",
borderRadius: "4px",
background: "rgba(239, 68, 68, 0.08)",
border: "1px solid rgba(239, 68, 68, 0.3)",
fontSize: "0.8rem",
color: "#ef4444",
fontWeight: 500,
}}
>
{t.importReplaceWarning}
</div>
)}
{/* File Input */}
<input
id="import-file-input"
type="file"
accept=".json"
onChange={(e) => {
setImportFile(e.target.files?.[0] || null);
setImportMsg("");
}}
className="weekly-input"
style={{
width: "100%",
padding: "6px",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "4px",
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
marginBottom: "10px",
fontSize: "0.85rem",
}}
/>
<button
onClick={handleImportData}
disabled={!importFile || isImporting}
className="weekly-auth-button w-full justify-center"
style={{
display: "inline-flex",
width: "100%",
padding: "10px",
background: !importFile || isImporting
? "var(--weekly-settings-input-bg)"
: "var(--weekly-teal)",
border: "1px solid var(--weekly-settings-input-border)",
color: !importFile || isImporting
? "var(--weekly-settings-label)"
: "#fff",
borderRadius: "4px",
fontWeight: 600,
cursor: !importFile || isImporting ? "not-allowed" : "pointer",
transition: "background-color 0.2s",
opacity: !importFile || isImporting ? 0.6 : 1,
}}
>
{isImporting ? t.importing : t.importButton}
</button>
{importMsg && (
<p
style={{
marginTop: "10px",
fontSize: "0.85rem",
fontWeight: 600,
color: importMsg.startsWith("✓")
? "#059669"
: "#dc2626",
}}
>
{importMsg}
</p>
)}
</div>
</div>
<div
className="account-danger-zone"
style={{