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
This commit is contained in:
mARTin 2026-04-07 09:27:02 +02:00
parent f9b27fe56c
commit 51e2b13ec6
3 changed files with 250 additions and 127 deletions

View File

@ -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": {

View File

@ -3,48 +3,94 @@ 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);
@ -53,25 +99,21 @@ export async function GET(request: Request) {
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<ExportFieldKey> = 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<string, typeof tasks>();
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) {

View File

@ -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<Set<ExportFieldKey>>(
() => new Set(EXPORT_FIELDS.filter(f => f.defaultOn).map(f => f.key))
);
const [importMode, setImportMode] = useState<"merge" | "replace">("merge");
const [importFile, setImportFile] = useState<File | null>(null);
const [importMsg, setImportMsg] = useState("");
@ -4236,94 +4240,153 @@ function SettingsSidebar({
>
{profile.language === "de" ? "Datenexport" : "Data Export"}
</h3>
<p
style={{
fontSize: "0.9rem",
color: "var(--weekly-settings-label)",
marginBottom: "15px",
}}
>
<p style={{ fontSize: "0.85rem", color: "var(--weekly-settings-label)", marginBottom: "14px" }}>
{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."}
</p>
<div
style={{ display: "flex", gap: "10px", marginBottom: "15px" }}
>
{/* Date range */}
<div style={{ display: "flex", gap: "10px", marginBottom: "14px" }}>
<div style={{ flex: 1 }}>
<label
style={{
display: "block",
fontSize: "0.8rem",
fontWeight: 600,
marginBottom: "4px",
color: "var(--weekly-settings-label)",
}}
>
Start
<label style={{ display: "block", fontSize: "0.78rem", fontWeight: 600, marginBottom: "4px", color: "var(--weekly-settings-label)" }}>
{profile.language === "de" ? "Von" : "From"}
</label>
<input
type="date"
value={exportStartDate}
onChange={(e) => setExportStartDate(e.target.value)}
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)",
}}
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)" }}
/>
</div>
<div style={{ flex: 1 }}>
<label
style={{
display: "block",
fontSize: "0.8rem",
fontWeight: 600,
marginBottom: "4px",
color: "var(--weekly-settings-label)",
}}
>
End
<label style={{ display: "block", fontSize: "0.78rem", fontWeight: 600, marginBottom: "4px", color: "var(--weekly-settings-label)" }}>
{profile.language === "de" ? "Bis" : "To"}
</label>
<input
type="date"
value={exportEndDate}
onChange={(e) => setExportEndDate(e.target.value)}
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)",
}}
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)" }}
/>
</div>
</div>
{/* Quick date presets */}
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap", marginBottom: "16px" }}>
{[
{ 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 }) => (
<button
key={label}
onClick={() => {
const end = new Date();
if (offset) end.setDate(end.getDate() - offset);
const start = new Date(end);
start.setDate(start.getDate() - days + (offset ? 0 : 0));
// Simpler: this month = first day of month
if (label.includes("Monat") || label.includes("month")) {
if (offset) {
// last month
const now = new Date();
const s = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const e = new Date(now.getFullYear(), now.getMonth(), 0);
setExportStartDate(s.toISOString().split("T")[0]);
setExportEndDate(e.toISOString().split("T")[0]);
return;
} else {
const now = new Date();
const s = new Date(now.getFullYear(), now.getMonth(), 1);
setExportStartDate(s.toISOString().split("T")[0]);
setExportEndDate(new Date().toISOString().split("T")[0]);
return;
}
}
if (label.includes("Jahr") || label.includes("year")) {
const now = new Date();
setExportStartDate(`${now.getFullYear()}-01-01`);
setExportEndDate(now.toISOString().split("T")[0]);
return;
}
// This week
const now = new Date();
const day = now.getDay() || 7;
const mon = new Date(now); mon.setDate(now.getDate() - day + 1);
setExportStartDate(mon.toISOString().split("T")[0]);
setExportEndDate(now.toISOString().split("T")[0]);
}}
style={{ fontSize: "0.75rem", padding: "3px 9px", borderRadius: "5px", border: "1px solid var(--weekly-settings-input-border)", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-label)", cursor: "pointer" }}
>
{label}
</button>
))}
</div>
{/* Field picker */}
<div style={{ marginBottom: "14px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "8px" }}>
<span style={{ fontSize: "0.78rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
{profile.language === "de" ? "Spalten auswählen:" : "Select columns:"}
</span>
<div style={{ display: "flex", gap: "6px" }}>
<button
onClick={() => setExportFields(new Set(EXPORT_FIELDS.map(f => f.key)))}
style={{ fontSize: "0.72rem", padding: "2px 7px", borderRadius: "4px", border: "1px solid var(--weekly-settings-input-border)", background: "transparent", color: "var(--weekly-settings-label)", cursor: "pointer" }}
>
{profile.language === "de" ? "Alle" : "All"}
</button>
<button
onClick={() => setExportFields(new Set(EXPORT_FIELDS.filter(f => f.defaultOn).map(f => f.key)))}
style={{ fontSize: "0.72rem", padding: "2px 7px", borderRadius: "4px", border: "1px solid var(--weekly-settings-input-border)", background: "transparent", color: "var(--weekly-settings-label)", cursor: "pointer" }}
>
{profile.language === "de" ? "Standard" : "Default"}
</button>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "5px 12px" }}>
{EXPORT_FIELDS.map(f => {
const checked = exportFields.has(f.key);
const label = profile.language === "de" ? f.labelDe : f.labelEn;
return (
<label
key={f.key}
style={{ display: "flex", alignItems: "center", gap: "7px", fontSize: "0.82rem", color: "var(--weekly-settings-text)", cursor: "pointer", padding: "3px 0" }}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setExportFields(prev => {
const next = new Set(prev);
if (next.has(f.key)) next.delete(f.key);
else next.add(f.key);
return next;
});
}}
style={{ accentColor: "#6366f1", width: "14px", height: "14px", flexShrink: 0 }}
/>
{label}
</label>
);
})}
</div>
</div>
{/* Download button */}
<a
href={`/api/user/export?startDate=${exportStartDate}&endDate=${exportEndDate}&lang=${profile.language || 'de'}`}
href={`/api/user/export?startDate=${exportStartDate}&endDate=${exportEndDate}&lang=${profile.language || 'de'}&fields=${Array.from(exportFields).join(',')}`}
target="_blank"
className="weekly-auth-button w-full justify-center"
style={{
display: "inline-flex",
textDecoration: "none",
background: "var(--weekly-settings-item-bg)",
border: "1px solid var(--weekly-settings-input-border)",
color: "var(--weekly-settings-text)",
padding: "10px",
borderRadius: "4px",
fontWeight: 500,
transition: "background-color 0.2s",
}}
style={{ display: "inline-flex", textDecoration: "none", background: "var(--weekly-settings-item-bg)", border: "1px solid var(--weekly-settings-input-border)", color: "var(--weekly-settings-text)", padding: "10px", borderRadius: "4px", fontWeight: 500, transition: "background-color 0.2s" }}
>
{profile.language === "de"
? "Arbeitsbericht exportieren (CSV)"
: "Export Work Report (CSV)"}
? `Arbeitsbericht exportieren (${exportFields.size} Spalten)`
: `Export Work Report (${exportFields.size} columns)`}
</a>
</div>