From 51e2b13ec6fdf176f35b31cc1b9b2bf9a256057a Mon Sep 17 00:00:00 2001 From: mARTin Date: Tue, 7 Apr 2026 09:27:02 +0200 Subject: [PATCH] feat: configurable field picker for work report CSV export Users can now choose exactly which columns appear in the CSV: - Title (always included) - Calendar week grouping header - Completed date, Scheduled date, Created date - Project, List - Priority (Eisenhower label or ABCDE grade) - Link / URL - Notes, Description - Delegated to, Delegation note - Start time, Kanban stage UI additions in Settings > Data Export: - 2-column checkbox grid with Select All / Default reset buttons - Quick date presets: This week / This month / Last month / This year - Download button shows the active column count Default-on fields: week, completed date, scheduled date, project, list, priority. All others opt-in. API: fields passed as comma-separated ?fields= param; falls back to defaults when omitted. Column headers are language-aware (de/en). v1.92.0 --- package.json | 2 +- src/app/api/user/export/route.ts | 188 +++++++++++++++++++---------- src/components/SettingsSidebar.tsx | 187 ++++++++++++++++++---------- 3 files changed, 250 insertions(+), 127 deletions(-) diff --git a/package.json b/package.json index 27979dd..478e4d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.91.0", + "version": "1.92.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": { diff --git a/src/app/api/user/export/route.ts b/src/app/api/user/export/route.ts index 41dca67..2ecef89 100644 --- a/src/app/api/user/export/route.ts +++ b/src/app/api/user/export/route.ts @@ -3,75 +3,117 @@ import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; import { prisma } from '@/lib/prisma'; -/** Return ISO week string like "2026-W14" */ +// ── Field registry ───────────────────────────────────────────────────────── +// All optional columns the user can include. "key" is what the UI sends in +// the ?fields= query param. "always" fields are always present. +export type ExportFieldKey = + | 'completedDate' + | 'scheduledDate' + | 'createdDate' + | 'project' + | 'list' + | 'priority' + | 'url' + | 'notes' + | 'description' + | 'delegatedTo' + | 'delegationNote' + | 'startTime' + | 'kanbanStage' + | 'week'; + +export const EXPORT_FIELDS: { + key: ExportFieldKey; + labelDe: string; + labelEn: string; + defaultOn: boolean; +}[] = [ + { key: 'week', labelDe: 'Kalenderwoche', labelEn: 'Calendar week', defaultOn: true }, + { key: 'completedDate', labelDe: 'Erledigt am', labelEn: 'Completed', defaultOn: true }, + { key: 'scheduledDate', labelDe: 'Geplant für', labelEn: 'Scheduled for', defaultOn: true }, + { key: 'createdDate', labelDe: 'Erstellt am', labelEn: 'Created', defaultOn: false }, + { key: 'project', labelDe: 'Projekt', labelEn: 'Project', defaultOn: true }, + { key: 'list', labelDe: 'Liste', labelEn: 'List', defaultOn: true }, + { key: 'priority', labelDe: 'Priorität', labelEn: 'Priority', defaultOn: true }, + { key: 'url', labelDe: 'Link / URL', labelEn: 'Link / URL', defaultOn: false }, + { key: 'notes', labelDe: 'Notiz', labelEn: 'Notes', defaultOn: false }, + { key: 'description', labelDe: 'Beschreibung', labelEn: 'Description', defaultOn: false }, + { key: 'delegatedTo', labelDe: 'Delegiert an', labelEn: 'Delegated to', defaultOn: false }, + { key: 'delegationNote', labelDe: 'Delegationsnotiz', labelEn: 'Delegation note', defaultOn: false }, + { key: 'startTime', labelDe: 'Uhrzeit', labelEn: 'Start time', defaultOn: false }, + { key: 'kanbanStage', labelDe: 'Kanban-Stage', labelEn: 'Kanban stage', defaultOn: false }, +]; + +// ── Helpers ──────────────────────────────────────────────────────────────── + function isoWeek(date: Date): string { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); - const dayNum = d.getUTCDay() || 7; // Mon=1 … Sun=7 + const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; } -function weekLabel(date: Date): string { - // "KW 14 — 2026 (Mon 30 Mar – Sun 5 Apr)" +function weekLabel(date: Date, de: boolean): string { const week = isoWeek(date); const [year, w] = week.split('-W'); - // Find Monday of that week const d = new Date(Date.UTC(parseInt(year), 0, 1)); d.setUTCDate(d.getUTCDate() + (parseInt(w) - 1) * 7 - (d.getUTCDay() || 7) + 1); const sun = new Date(d); sun.setUTCDate(d.getUTCDate() + 6); - const fmt = (x: Date) => x.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: 'UTC' }); - return `KW ${w} — ${year} (${fmt(d)} – ${fmt(sun)})`; + const fmt = (x: Date) => x.toLocaleDateString(de ? 'de-DE' : 'en-GB', { day: '2-digit', month: 'short', timeZone: 'UTC' }); + return de + ? `KW ${w} — ${year} (${fmt(d)} – ${fmt(sun)})` + : `Week ${w} — ${year} (${fmt(d)} – ${fmt(sun)})`; } -function fmtDate(d: Date | null | undefined): string { +function fmtDate(d: Date | null | undefined, de: boolean): string { if (!d) return ''; - return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + return de + ? d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + : d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); } -function priorityLabel(task: any): string { +function priorityLabel(task: any, de: boolean): string { if (task.urgency != null && task.importance != null) { - if (task.urgency && task.importance) return 'Sofort erledigen'; - if (!task.urgency && task.importance) return 'Planen'; - if (task.urgency && !task.importance) return 'Delegieren'; - return 'Eliminieren'; + if (task.urgency && task.importance) return de ? 'Sofort erledigen' : 'Do now'; + if (!task.urgency && task.importance) return de ? 'Planen' : 'Schedule'; + if (task.urgency && !task.importance) return de ? 'Delegieren' : 'Delegate'; + return de ? 'Eliminieren' : 'Eliminate'; } - if (task.priority) return task.priority; + if (task.priority) return task.priority; // A / B / C / D / E return ''; } function csvCell(value: string): string { - return `"${(value || '').replace(/"/g, '""').replace(/\n/g, ' ')}"`; + return `"${(value ?? '').replace(/"/g, '""').replace(/\n/g, ' ')}"`; } +// ── Route ────────────────────────────────────────────────────────────────── + export async function GET(request: Request) { const session = await getServerSession(authOptions); const { searchParams } = new URL(request.url); - const startDate = searchParams.get('startDate'); - const endDate = searchParams.get('endDate'); - const lang = searchParams.get('lang') || 'de'; - const de = lang === 'de'; + const startDate = searchParams.get('startDate'); + const endDate = searchParams.get('endDate'); + const lang = searchParams.get('lang') || 'de'; + const de = lang === 'de'; + + // Parse selected fields; fall back to all defaultOn fields + const fieldsParam = searchParams.get('fields'); + const selectedFields: Set = fieldsParam + ? new Set(fieldsParam.split(',') as ExportFieldKey[]) + : new Set(EXPORT_FIELDS.filter(f => f.defaultOn).map(f => f.key)); 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 }); - } - - const where: any = { - userId: user.id, - completed: true, - deletedAt: null, - }; + const user = await prisma.user.findUnique({ where: { email: session.user.email } }); + if (!user) return new NextResponse('User not found', { status: 404 }); + const where: any = { userId: user.id, completed: true, deletedAt: null }; if (startDate || endDate) { where.updatedAt = {}; if (startDate) where.updatedAt.gte = new Date(startDate); @@ -86,11 +128,21 @@ export async function GET(request: Request) { where, orderBy: { updatedAt: 'desc' }, include: { - project: { select: { name: true, color: true } }, + project: { select: { name: true } }, somedayList: { select: { title: true } }, }, }); + // Build header row — Title is always first + const headerRow: string[] = [de ? 'Aufgabe' : 'Task']; + for (const f of EXPORT_FIELDS) { + if (selectedFields.has(f.key) && f.key !== 'week') { + headerRow.push(de ? f.labelDe : f.labelEn); + } + } + + const rows: string[][] = [headerRow]; + // Group by ISO week (most recent first) const byWeek = new Map(); for (const task of tasks) { @@ -99,50 +151,58 @@ export async function GET(request: Request) { byWeek.get(key)!.push(task); } - const headers = de - ? ['Woche', 'Erledigt am', 'Geplant für', 'Erstellt am', 'Aufgabe', 'Projekt', 'Liste', 'Priorität', 'Delegiert an', 'Notiz'] - : ['Week', 'Completed', 'Scheduled', 'Created', 'Task', 'Project', 'List', 'Priority', 'Delegated to', 'Notes']; - - const rows: string[][] = [headers]; - - for (const [weekKey, weekTasks] of byWeek) { - // Section header row (empty for all columns except Week) - const weekDisplay = weekLabel(weekTasks[0].updatedAt); - rows.push([weekDisplay, '', '', '', '', '', '', '', '', '']); + for (const [, weekTasks] of byWeek) { + // Section header row when week column is selected + if (selectedFields.has('week')) { + const label = weekLabel(weekTasks[0].updatedAt, de); + // Fill entire row width with the label in first cell, rest blank + rows.push([label, ...Array(headerRow.length - 1).fill('')]); + } for (const t of weekTasks) { - rows.push([ - '', // no week label on task rows (grouped visually) - fmtDate(t.updatedAt), - fmtDate(t.scheduledDate ?? undefined), - fmtDate(t.createdAt), - t.title || '', - t.project?.name || '', - t.somedayList?.title || '', - priorityLabel(t), - t.delegatedTo || '', - (t.markdownContent || t.description || '').slice(0, 500), - ]); + const row: string[] = [t.title || '']; + + for (const f of EXPORT_FIELDS) { + if (!selectedFields.has(f.key) || f.key === 'week') continue; + switch (f.key) { + case 'completedDate': row.push(fmtDate(t.updatedAt, de)); break; + case 'scheduledDate': row.push(fmtDate(t.scheduledDate ?? undefined, de)); break; + case 'createdDate': row.push(fmtDate(t.createdAt, de)); break; + case 'project': row.push(t.project?.name || ''); break; + case 'list': row.push(t.somedayList?.title || ''); break; + case 'priority': row.push(priorityLabel(t, de)); break; + case 'url': row.push(t.url || ''); break; + case 'notes': row.push((t.markdownContent || '').slice(0, 1000)); break; + case 'description': row.push((t.description || '').slice(0, 500)); break; + case 'delegatedTo': row.push(t.delegatedTo || ''); break; + case 'delegationNote': row.push(t.delegationNote || ''); break; + case 'startTime': row.push(t.startTime || ''); break; + case 'kanbanStage': row.push(t.kanbanStage || ''); break; + } + } + + rows.push(row); } // Blank spacer between weeks - rows.push(Array(headers.length).fill('')); + rows.push(Array(headerRow.length).fill('')); } - const csvContent = rows - .map((row) => row.map(csvCell).join(',')) - .join('\r\n'); - - // Prepend BOM so Excel opens UTF-8 correctly + const csvContent = rows.map(row => row.map(csvCell).join(',')).join('\r\n'); const bom = '\uFEFF'; + const period = startDate && endDate - ? `_${startDate}_bis_${endDate}` + ? `_${startDate}_${endDate}` : startDate ? `_ab_${startDate}` : endDate ? `_bis_${endDate}` : ''; + const filename = de + ? `arbeitsbericht${period}_${new Date().toISOString().split('T')[0]}.csv` + : `work-report${period}_${new Date().toISOString().split('T')[0]}.csv`; + return new NextResponse(bom + csvContent, { headers: { 'Content-Type': 'text/csv; charset=utf-8', - 'Content-Disposition': `attachment; filename="arbeitsbericht${period}_${new Date().toISOString().split('T')[0]}.csv"`, + 'Content-Disposition': `attachment; filename="${filename}"`, }, }); } catch (error) { diff --git a/src/components/SettingsSidebar.tsx b/src/components/SettingsSidebar.tsx index 91cb4ac..4528486 100644 --- a/src/components/SettingsSidebar.tsx +++ b/src/components/SettingsSidebar.tsx @@ -10,6 +10,7 @@ import { ViewStyle, KanbanStage, Task } from "./WeeklyView"; import { AVAILABLE_FONTS, isCustomFont } from "../lib/fontConstants"; import { translations } from "../lib/weeklyViewTranslations"; import { WeatherDisplayKey, WEATHER_DISPLAY_DEFAULTS } from "../lib/weeklyViewConstants"; +import { EXPORT_FIELDS, type ExportFieldKey } from "../app/api/user/export/route"; import { ArrowLeftRight, Calendar, @@ -331,6 +332,9 @@ function SettingsSidebar({ const [isSyncing, setIsSyncing] = useState(false); const [exportStartDate, setExportStartDate] = useState(""); const [exportEndDate, setExportEndDate] = useState(""); + const [exportFields, setExportFields] = useState>( + () => new Set(EXPORT_FIELDS.filter(f => f.defaultOn).map(f => f.key)) + ); const [importMode, setImportMode] = useState<"merge" | "replace">("merge"); const [importFile, setImportFile] = useState(null); const [importMsg, setImportMsg] = useState(""); @@ -4236,94 +4240,153 @@ function SettingsSidebar({ > {profile.language === "de" ? "Datenexport" : "Data Export"} -

+

{profile.language === "de" - ? "CSV-Arbeitsbericht mit erledigten Aufgaben, Projekten und Prioritäten — nach Kalenderwochen gruppiert. Ideal für Freelancer und Mitarbeiter." - : "CSV work report of completed tasks with project, list, priority and notes — grouped by calendar week. Perfect for freelancers and employees."} + ? "CSV-Arbeitsbericht erledigter Aufgaben — nach Kalenderwochen gruppiert. Wähle Felder, Zeitraum und lade die Datei herunter." + : "CSV work report of completed tasks grouped by calendar week. Choose fields, date range and download."}

-
+ + {/* Date range */} +
-
-
+ + {/* Quick date presets */} +
+ {[ + { label: profile.language === "de" ? "Diese Woche" : "This week", days: 7 }, + { label: profile.language === "de" ? "Dieser Monat" : "This month", days: 30 }, + { label: profile.language === "de" ? "Letzter Monat" : "Last month", days: 60, offset: 30 }, + { label: profile.language === "de" ? "Dieses Jahr" : "This year", days: 365 }, + ].map(({ label, days, offset }) => ( + + ))} +
+ + {/* Field picker */} +
+
+ + {profile.language === "de" ? "Spalten auswählen:" : "Select columns:"} + +
+ + +
+
+
+ {EXPORT_FIELDS.map(f => { + const checked = exportFields.has(f.key); + const label = profile.language === "de" ? f.labelDe : f.labelEn; + return ( + + ); + })} +
+
+ + {/* Download button */} {profile.language === "de" - ? "Arbeitsbericht exportieren (CSV)" - : "Export Work Report (CSV)"} + ? `Arbeitsbericht exportieren (${exportFields.size} Spalten)` + : `Export Work Report (${exportFields.size} columns)`}