feat: filters in all priority mode right columns + work report CSV

Priority View: filter panel (project / list / timespan) is now always
visible in the right column of every priority mode — Eisenhower,
ABCDE, Ivy Lee, and 80/20. Removed the collapse toggle. An 'active'
indicator in the header shows when any filter is set. On mobile the
filter appears above the main content.

Work Report CSV (Settings > Data Export):
- Columns: Week / Completed / Scheduled / Created / Task / Project /
  List / Priority / Delegated to / Notes
- Rows grouped by calendar week (ISO week, most recent first), with a
  section header row showing "KW 14 — 2026 (30 Mär – 05 Apr)"
- Priority shows Eisenhower label (Sofort erledigen / Planen /
  Delegieren / Eliminieren) or ABCDE grade
- Includes project name, Someday list name, delegation info and notes
- UTF-8 BOM prepended so Excel opens German umlauts correctly
- Language-aware column headers (de/en) via ?lang= param
- Filename: arbeitsbericht_YYYY-MM-DD_bis_YYYY-MM-DD_date.csv

v1.91.0
This commit is contained in:
mARTin 2026-04-07 00:03:09 +02:00
parent 3ee5bf33b5
commit 6836cf58bc
4 changed files with 132 additions and 47 deletions

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.90.0",
"version": "1.91.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,11 +3,55 @@ import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
/** Return ISO week string like "2026-W14" */
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
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)"
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)})`;
}
function fmtDate(d: Date | null | undefined): string {
if (!d) return '';
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function priorityLabel(task: any): 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.priority) return task.priority;
return '';
}
function csvCell(value: string): string {
return `"${(value || '').replace(/"/g, '""').replace(/\n/g, ' ')}"`;
}
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';
if (!session || !session.user?.email) {
return new NextResponse('Unauthorized', { status: 401 });
@ -25,6 +69,7 @@ export async function GET(request: Request) {
const where: any = {
userId: user.id,
completed: true,
deletedAt: null,
};
if (startDate || endDate) {
@ -37,32 +82,67 @@ export async function GET(request: Request) {
}
}
// Fetch filtered tasks
const tasks = await prisma.task.findMany({
where,
orderBy: {
updatedAt: 'desc',
orderBy: { updatedAt: 'desc' },
include: {
project: { select: { name: true, color: true } },
somedayList: { select: { title: true } },
},
});
// Generate CSV
const headers = ['Title', 'Description', 'Completed Date', 'Created Date'];
const rows = tasks.map((task: any) => [
task.title,
task.description || '',
task.updatedAt.toISOString(),
task.createdAt.toISOString(),
]);
// Group by ISO week (most recent first)
const byWeek = new Map<string, typeof tasks>();
for (const task of tasks) {
const key = isoWeek(task.updatedAt);
if (!byWeek.has(key)) byWeek.set(key, []);
byWeek.get(key)!.push(task);
}
const csvContent = [
headers.join(','),
...rows.map((row: string[]) => row.map((cell: string) => `"${(cell || '').replace(/"/g, '""')}"`).join(','))
].join('\n');
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'];
return new NextResponse(csvContent, {
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 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),
]);
}
// Blank spacer between weeks
rows.push(Array(headers.length).fill(''));
}
const csvContent = rows
.map((row) => row.map(csvCell).join(','))
.join('\r\n');
// Prepend BOM so Excel opens UTF-8 correctly
const bom = '\uFEFF';
const period = startDate && endDate
? `_${startDate}_bis_${endDate}`
: startDate ? `_ab_${startDate}` : endDate ? `_bis_${endDate}` : '';
return new NextResponse(bom + csvContent, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="completed_tasks_${new Date().toISOString().split('T')[0]}.csv"`,
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="arbeitsbericht${period}_${new Date().toISOString().split('T')[0]}.csv"`,
},
});
} catch (error) {

View File

@ -280,7 +280,7 @@ export default function PriorityView({
{ key: "pareto", label: "80/20", icon: <BarChart2 size={14} /> },
];
// Filter panel — rendered in Eisenhower's right column or above content in other modes
// Filter panel — always rendered in the right column of every mode
const filterPanel = (
<div style={{ background: cardBg, border: `1px solid ${border}`, borderRadius: "10px", padding: "10px", display: "flex", flexWrap: "wrap", gap: "8px" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "3px", flex: "1 1 120px" }}>
@ -352,27 +352,15 @@ export default function PriorityView({
{de ? "Prioritäten" : "Priority View"}
</span>
</div>
{/* Filter toggle — only for non-Eisenhower modes (Eisenhower shows filters in its right panel) */}
{method !== "eisenhower" && (
<button
onClick={() => setShowFilters((v) => !v)}
style={{ display: "flex", alignItems: "center", gap: "4px", padding: "5px 10px", borderRadius: "7px", border: `1px solid ${border}`, background: showFilters ? accentColor : cardBg, color: showFilters ? "#fff" : textSecondary, cursor: "pointer", fontSize: "0.8rem" }}
>
<SlidersHorizontal size={13} />
{de ? "Filter" : "Filter"}
{(filterProject || filterList || filterTimespan !== "all") && (
<span style={{ background: "#ef4444", color: "#fff", borderRadius: "99px", fontSize: "0.65rem", padding: "0 5px", fontWeight: 700 }}>!</span>
)}
</button>
{/* Filter active indicator — filters are always in the right column */}
{(filterProject || filterList || filterTimespan !== "all") && (
<span style={{ display: "flex", alignItems: "center", gap: "4px", fontSize: "0.75rem", color: accentColor, fontWeight: 600 }}>
<SlidersHorizontal size={12} />
{de ? "Filter aktiv" : "Filter active"}
</span>
)}
</div>
{/* ─── Filters Panel (non-Eisenhower modes only) ─── */}
{method !== "eisenhower" && showFilters && (
<div style={{ marginBottom: "12px" }}>
{filterPanel}
</div>
)}
{/* ─── Method Tabs ─── */}
<div style={{ display: "flex", gap: "4px", marginBottom: "14px", background: darkMode ? "#1f2937" : "#e5e7eb", borderRadius: "9px", padding: "3px" }}>
@ -447,6 +435,7 @@ export default function PriorityView({
textPrimary={textPrimary}
textSecondary={textSecondary}
isMobile={isMobile}
filterPanel={filterPanel}
/>
)}
@ -467,6 +456,7 @@ export default function PriorityView({
textPrimary={textPrimary}
textSecondary={textSecondary}
isMobile={isMobile}
filterPanel={filterPanel}
/>
)}
@ -486,6 +476,7 @@ export default function PriorityView({
textPrimary={textPrimary}
textSecondary={textSecondary}
isMobile={isMobile}
filterPanel={filterPanel}
/>
)}
@ -955,9 +946,10 @@ interface AbcdeViewProps {
textPrimary: string;
textSecondary: string;
isMobile?: boolean;
filterPanel?: React.ReactNode;
}
function AbcdeView({ groups, allTasks, darkMode, de, onSetGrade, onDelegate, onToggleComplete, onUpdateTask, projects, cardBg, border, textPrimary, textSecondary, isMobile }: AbcdeViewProps) {
function AbcdeView({ groups, allTasks, darkMode, de, onSetGrade, onDelegate, onToggleComplete, onUpdateTask, projects, cardBg, border, textPrimary, textSecondary, isMobile, filterPanel }: AbcdeViewProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({ none: true });
const [dragOver, setDragOver] = useState<string | null>(null);
@ -1038,6 +1030,8 @@ function AbcdeView({ groups, allTasks, darkMode, de, onSetGrade, onDelegate, onT
// Ungraded tasks panel (right column)
const hasUngraded = (groups.none || []).length > 0;
const ungradedPanel = (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{filterPanel}
<div style={{ background: darkMode ? "#1f2937" : "#f3f4f6", borderRadius: "10px", border: `1px solid ${border}`, overflow: "hidden" }}>
<div
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "9px 14px", cursor: "pointer", borderBottom: collapsed.none ? "none" : `1px solid ${border}` }}
@ -1070,6 +1064,7 @@ function AbcdeView({ groups, allTasks, darkMode, de, onSetGrade, onDelegate, onT
</div>
)}
</div>
</div>
);
if (isMobile) {
@ -1107,9 +1102,10 @@ interface IvyLeeViewProps {
textPrimary: string;
textSecondary: string;
isMobile?: boolean;
filterPanel?: React.ReactNode;
}
function IvyLeeView({ tasks, selected, darkMode, de, onToggle, onDelegate, onToggleComplete, onUpdateTask, projects, cardBg, border, textPrimary, textSecondary, isMobile }: IvyLeeViewProps) {
function IvyLeeView({ tasks, selected, darkMode, de, onToggle, onDelegate, onToggleComplete, onUpdateTask, projects, cardBg, border, textPrimary, textSecondary, isMobile, filterPanel }: IvyLeeViewProps) {
const accentColor = "#6366f1";
const selectedTasks = tasks.filter((t) => selected.has(t.id));
const unselected = tasks.filter((t) => !selected.has(t.id));
@ -1185,7 +1181,9 @@ function IvyLeeView({ tasks, selected, darkMode, de, onToggle, onDelegate, onTog
// Available tasks pool (right column)
const availablePool = (
<div>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{filterPanel}
<div>
<p style={{ fontSize: "0.78rem", fontWeight: 600, color: textSecondary, marginBottom: "8px" }}>
{unselected.length > 0 ? (de ? "Verfügbare Aufgaben:" : "Available tasks:") : (de ? "Alle Aufgaben ausgewählt ✓" : "All tasks selected ✓")}
</p>
@ -1214,6 +1212,7 @@ function IvyLeeView({ tasks, selected, darkMode, de, onToggle, onDelegate, onTog
</div>
))}
</div>
</div>
</div>
);
@ -1221,6 +1220,7 @@ function IvyLeeView({ tasks, selected, darkMode, de, onToggle, onDelegate, onTog
return (
<div>
{infoBanner}
{filterPanel && <div style={{ marginBottom: "12px" }}>{filterPanel}</div>}
{top6Panel}
{unselected.length > 0 && <div style={{ marginTop: "16px" }}>{availablePool}</div>}
</div>
@ -1255,9 +1255,10 @@ interface ParetoViewProps {
textPrimary: string;
textSecondary: string;
isMobile?: boolean;
filterPanel?: React.ReactNode;
}
function ParetoView({ important, rest, darkMode, de, onDelegate, onToggleComplete, onUpdateTask, projects, cardBg, border, textPrimary, textSecondary, isMobile }: ParetoViewProps) {
function ParetoView({ important, rest, darkMode, de, onDelegate, onToggleComplete, onUpdateTask, projects, cardBg, border, textPrimary, textSecondary, isMobile, filterPanel }: ParetoViewProps) {
const total = important.length + rest.length;
const pct = total > 0 ? Math.round((important.length / total) * 100) : 0;
@ -1311,6 +1312,8 @@ function ParetoView({ important, rest, darkMode, de, onDelegate, onToggleComplet
// Remaining tasks (right column — always visible, no toggle)
const remainingPanel = (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{filterPanel}
<div>
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "10px" }}>
<span style={{ fontSize: "0.78rem", fontWeight: 600, color: textSecondary, textTransform: "uppercase", letterSpacing: "0.05em" }}>
@ -1335,12 +1338,14 @@ function ParetoView({ important, rest, darkMode, de, onDelegate, onToggleComplet
</div>
)}
</div>
</div>
);
if (isMobile) {
return (
<div>
{header}
{filterPanel && <div style={{ marginBottom: "12px" }}>{filterPanel}</div>}
{importantPanel}
<div style={{ marginTop: "16px" }}>{remainingPanel}</div>
</div>

View File

@ -4244,8 +4244,8 @@ function SettingsSidebar({
}}
>
{profile.language === "de"
? "Laden Sie eine CSV-Datei Ihrer erledigten Aufgaben herunter."
: "Download a CSV file of your completed tasks."}
? "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."}
</p>
<div
style={{ display: "flex", gap: "10px", marginBottom: "15px" }}
@ -4306,7 +4306,7 @@ function SettingsSidebar({
</div>
</div>
<a
href={`/api/user/export?startDate=${exportStartDate}&endDate=${exportEndDate}`}
href={`/api/user/export?startDate=${exportStartDate}&endDate=${exportEndDate}&lang=${profile.language || 'de'}`}
target="_blank"
className="weekly-auth-button w-full justify-center"
style={{
@ -4322,8 +4322,8 @@ function SettingsSidebar({
}}
>
{profile.language === "de"
? "Erledigte Aufgaben exportieren (CSV)"
: "Export Completed Tasks (CSV)"}
? "Arbeitsbericht exportieren (CSV)"
: "Export Work Report (CSV)"}
</a>
</div>