fix: match webapp style — solid task blocks, fix all-day events, tab-sorted someday

- Fix all-day events: remove externalProvider distinction — ALL tasks with
  scheduledDate but no startTime belong in the GANZ-TAG strip regardless of
  source; was incorrectly routing them to a separate noTime strip that didn't
  show external-provider=null events
- Remove noTime calendar strip: it was a wrong concept; tasks outside the
  time window are simply not shown; someday page handles unscheduled tasks
- Solid task blocks: revert from light+border back to solid fill (matching
  the webapp); use contrastColor() helper to pick white or dark text
  automatically based on background luminance
- All-day strip also uses solid blocks (same style as webapp's ALL DAY bars)
- Someday page: group lists by their tab with a tab header row; query now
  sorts by [tab, order]; lists without a tab get a General/Allgemein header
- Show up to 5 all-day tasks per column (was 4)

v1.95.4
This commit is contained in:
mARTin 2026-04-13 09:36:38 +02:00
parent 3fd738b185
commit da31128121
2 changed files with 98 additions and 178 deletions

View File

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

@ -167,16 +167,24 @@ type TaskItem = {
startTime: string | null;
endTime: string | null;
completed: boolean;
externalProvider: string | null;
project: { name: string; color: string | null } | null;
};
type DayData = {
allDay: TaskItem[]; // external all-day calendar events (showAllDay)
noTime: TaskItem[]; // user tasks with no time (showNoTime)
timed: TaskItem[];
allDay: TaskItem[]; // all tasks without a startTime (shown in GANZ-TAG strip)
timed: TaskItem[]; // tasks with startTime within [startHour, endHour)
};
// Compute a contrasting text color (white or near-black) for a given bg hex
function contrastColor(hex: string): string {
const h = hex.replace('#', '');
const r = parseInt(h.slice(0, 2), 16) / 255;
const g = parseInt(h.slice(2, 4), 16) / 255;
const b = parseInt(h.slice(4, 6), 16) / 255;
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return lum > 0.42 ? '#1e293b' : '#ffffff';
}
type WeatherDay = { maxTemp: number | null; code: number | null; };
// User-specific style settings passed to the PDF
@ -203,6 +211,7 @@ function dayColor(d: Date, isToday: boolean, us: UserStyle): string {
type SomedayList = {
id: string;
title: string;
tab: string | null;
tasks: { id: string; title: string; completed: boolean; project: { name: string; color: string | null } | null }[];
};
@ -244,10 +253,8 @@ function WeekCalendarPDF({
? (parseInt(userStyle.headlineFontWeight) >= 600 ? 700 : 400)
: undefined;
// GRID_H = remaining space after headers and optional strips
const GRID_H = CONTENT_H - DAY_HDR_H
- (showAllDay ? ALL_DAY_H : 0)
- (showNoTime ? NO_TIME_H : 0);
// GRID_H = remaining space after day header and optional all-day strip
const GRID_H = CONTENT_H - DAY_HDR_H - (showAllDay ? ALL_DAY_H : 0);
function minutesToY(mins: number): number {
return Math.max(0, (mins / totalMins) * GRID_H);
@ -267,7 +274,7 @@ function WeekCalendarPDF({
// Adaptive font size — full names always, size scales with available column width
const dayNameSize = numDays <= 2 ? 16 : numDays <= 3 ? 13 : numDays <= 5 ? 10 : 8.5;
const calendarH = DAY_HDR_H + (showAllDay ? ALL_DAY_H : 0) + (showNoTime ? NO_TIME_H : 0) + GRID_H;
const calendarH = DAY_HDR_H + (showAllDay ? ALL_DAY_H : 0) + GRID_H;
const firstDay = days[0];
const lastDay = days[days.length - 1];
@ -358,32 +365,6 @@ function WeekCalendarPDF({
),
] : []),
// Anyday label spacer
...(showNoTime ? [
React.createElement(View, {
style: {
height: NO_TIME_H,
borderBottomWidth: 1,
borderBottomColor: BORDER,
backgroundColor: NOTIME_BG,
alignItems: 'flex-end',
justifyContent: 'flex-start',
paddingRight: 4,
paddingTop: 3,
},
},
React.createElement(Text, {
style: {
fontSize: 5.5,
fontFamily: 'Helvetica-Bold',
color: MUTED,
textTransform: 'uppercase',
letterSpacing: 0.3,
},
}, de ? 'Jederzeit' : 'Any Day'),
),
] : []),
// Hour labels (absolute within container)
React.createElement(View, { style: { height: GRID_H, position: 'relative' } },
...Array.from({ length: numHours + 1 }, (_, i) => {
@ -415,9 +396,8 @@ function WeekCalendarPDF({
? `${de ? (WMO_DE[weather.code] || '') : (WMO_EN[weather.code] || '')}${weather.maxTemp != null ? ` ${Math.round(weather.maxTemp)}°` : ''}`
: null;
const allDayTasks = dayData?.allDay || [];
const noTimeTasks = dayData?.noTime || [];
const timedTasks = dayData?.timed || [];
const allDayTasks = dayData?.allDay || [];
const timedTasks = dayData?.timed || [];
const dayName = weekdayLabel(d, de);
const dateStr = dayDateLabel(d, de);
@ -484,8 +464,9 @@ function WeekCalendarPDF({
overflow: 'hidden',
},
},
...allDayTasks.slice(0, 4).map(t => {
const c = t.completed ? DONE_BG : (t.project?.color || '#3b82f6');
...allDayTasks.slice(0, 5).map(t => {
const bg = t.completed ? DONE_BG : (t.project?.color || userStyle.taskColor);
const fg = t.completed ? DONE_TEXT : contrastColor(bg);
return React.createElement(View, {
key: t.id,
style: {
@ -493,69 +474,25 @@ function WeekCalendarPDF({
paddingVertical: 1.5,
paddingHorizontal: 4,
marginBottom: 2,
backgroundColor: t.completed ? DONE_BG : lighten(c, 0.7),
borderLeftWidth: 3,
borderLeftColor: c,
backgroundColor: bg,
},
},
React.createElement(Text, {
style: {
fontFamily: 'Helvetica-Bold',
fontSize: 6.5,
color: t.completed ? DONE_TEXT : '#1e293b',
color: fg,
},
}, (t.title || '').slice(0, 36)),
);
}),
allDayTasks.length > 4 ? React.createElement(Text, {
style: { fontSize: 6, color: '#3b82f6', marginTop: 1 },
}, `+${allDayTasks.length - 4} ${de ? 'weitere' : 'more'}`) : null,
allDayTasks.length > 5 ? React.createElement(Text, {
style: { fontSize: 5.5, color: '#3b82f6', marginTop: 1 },
}, `+${allDayTasks.length - 5} ${de ? 'weitere' : 'more'}`) : null,
),
] : []),
// ── No-time cell (anyday tasks)
...(showNoTime ? [
React.createElement(View, {
style: {
height: NO_TIME_H,
backgroundColor: NOTIME_BG,
borderBottomWidth: 1,
borderBottomColor: BORDER,
paddingHorizontal: 3,
paddingTop: 3,
overflow: 'hidden',
},
},
...noTimeTasks.slice(0, 4).map(t => {
const c = t.completed ? DONE_BG : (t.project?.color || userStyle.taskColor);
return React.createElement(View, {
key: t.id,
style: {
borderRadius: 2,
paddingVertical: 1.5,
paddingHorizontal: 4,
marginBottom: 2,
backgroundColor: t.completed ? DONE_BG : lighten(c, 0.75),
borderLeftWidth: 3,
borderLeftColor: c,
},
},
React.createElement(Text, {
style: {
fontFamily: 'Helvetica-Bold',
fontSize: 6.5,
color: t.completed ? DONE_TEXT : '#1e293b',
},
}, (t.title || '').slice(0, 36)),
);
}),
noTimeTasks.length > 4 ? React.createElement(Text, {
style: { fontSize: 6, color: MUTED, marginTop: 1 },
}, `+${noTimeTasks.length - 4} ${de ? 'weitere' : 'more'}`) : null,
),
] : []),
// ── Time grid
// ── Time grid (solid colored blocks, matching webapp style)
React.createElement(View, {
style: {
height: GRID_H,
@ -593,7 +530,7 @@ function WeekCalendarPDF({
})
),
// Half-hour tick lines
// Half-hour dashes
...Array.from({ length: numHours }, (_, i) =>
React.createElement(View, {
key: `hh${i}`,
@ -607,8 +544,7 @@ function WeekCalendarPDF({
})
),
// Task blocks (absolute, spanning actual duration)
// Style: light tinted background + colored left border (print-friendly)
// Task blocks — solid fill matching webapp style
...timedTasks.map(t => {
const startMins = parseTimeMinutes(t.startTime)!;
const endMins = t.endTime
@ -625,14 +561,10 @@ function WeekCalendarPDF({
const rawH = minutesToY(ceMin - csMin);
const taskH = Math.max(rawH, 14);
const accentColor = t.completed
? DONE_BG
: (t.project?.color || userStyle.taskColor);
const bgColor = t.completed ? DONE_BG : lighten(accentColor, 0.78);
const textColor = t.completed ? DONE_TEXT : '#1e293b';
const subColor = t.completed ? DONE_TEXT : accentColor;
const hasMinutes = t.startTime && !t.startTime.endsWith(':00');
const isShort = (ceMin - csMin) <= 30;
const bgColor = t.completed ? DONE_BG : (t.project?.color || userStyle.taskColor);
const textColor = t.completed ? DONE_TEXT : contrastColor(bgColor);
const isShort = (ceMin - csMin) <= 30;
const showTime = t.startTime && (!t.startTime.endsWith(':00') || isShort);
return React.createElement(View, {
key: t.id,
@ -644,10 +576,7 @@ function WeekCalendarPDF({
height: taskH - 2,
backgroundColor: bgColor,
borderRadius: 3,
borderLeftWidth: 3,
borderLeftColor: accentColor,
paddingLeft: 4,
paddingRight: 3,
paddingHorizontal: 4,
paddingVertical: 2,
overflow: 'hidden',
},
@ -660,10 +589,10 @@ function WeekCalendarPDF({
lineHeight: 1.25,
},
}, (t.title || '').slice(0, 44)),
(hasMinutes || isShort) && t.startTime
showTime
? React.createElement(Text, {
style: { fontSize: 6, color: subColor, marginTop: 0.5 },
}, fmtTime(t.startTime))
style: { fontSize: 6, color: textColor, opacity: 0.8, marginTop: 0.5 },
}, fmtTime(t.startTime!))
: null,
);
}).filter(Boolean),
@ -743,77 +672,72 @@ function WeekCalendarPDF({
}, userName),
),
// Lists in columns (flex wrap)
React.createElement(View, {
style: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
// Group lists by tab, then render tab sections
...((() => {
// Build ordered tab groups
const tabOrder: string[] = [];
const tabMap = new Map<string, SomedayList[]>();
for (const list of somedayLists) {
const tab = list.tab || (de ? 'Allgemein' : 'General');
if (!tabMap.has(tab)) { tabMap.set(tab, []); tabOrder.push(tab); }
tabMap.get(tab)!.push(list);
}
const renderList = (list: SomedayList) => React.createElement(View, {
key: list.id,
style: { width: '31%', marginBottom: 10 },
},
},
...somedayLists.map(list =>
React.createElement(View, {
key: list.id,
style: {
width: '30%',
marginBottom: 12,
borderLeftWidth: 3,
borderLeftColor: list.tasks[0]?.project?.color || userStyle.weekdayColor,
paddingLeft: 5, marginBottom: 4,
},
},
// List name
React.createElement(Text, {
style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: '#374151', textTransform: 'uppercase', letterSpacing: 0.3 },
}, list.title),
),
...list.tasks.slice(0, 18).map(t =>
React.createElement(View, {
key: t.id,
style: { flexDirection: 'row', alignItems: 'flex-start', paddingVertical: 2, paddingHorizontal: 3, marginBottom: 1, borderRadius: 2, backgroundColor: '#f8fafc' },
},
React.createElement(View, {
style: { width: 5, height: 5, borderRadius: 3, borderWidth: 1, borderColor: t.project?.color || MUTED, marginRight: 4, marginTop: 1.5, flexShrink: 0 },
}),
React.createElement(Text, {
style: { fontSize: 7, color: '#374151', lineHeight: 1.3, flex: 1 },
}, (t.title || '').slice(0, 52)),
)
),
list.tasks.length > 18 ? React.createElement(Text, {
style: { fontSize: 6, color: MUTED, marginTop: 1, paddingLeft: 3 },
}, `+${list.tasks.length - 18} ${de ? 'weitere' : 'more'}`) : null,
);
return tabOrder.map(tab =>
React.createElement(View, { key: tab },
// Tab header
React.createElement(View, {
style: {
borderLeftWidth: 3,
borderLeftColor: list.tasks[0]?.project?.color || userStyle.weekdayColor,
paddingLeft: 6,
marginBottom: 5,
borderBottomWidth: 1, borderBottomColor: BORDER,
marginBottom: 8, marginTop: 4, paddingBottom: 3,
},
},
React.createElement(Text, {
style: {
fontFamily: 'Helvetica-Bold',
fontSize: 8,
color: '#374151',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
}, list.title),
style: { fontFamily: 'Helvetica-Bold', fontSize: 9, color: userStyle.weekdayColor, textTransform: 'uppercase', letterSpacing: 0.5 },
}, tab),
),
// Task rows
...list.tasks.slice(0, 20).map(t =>
React.createElement(View, {
key: t.id,
style: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingVertical: 2.5,
paddingHorizontal: 4,
marginBottom: 1,
borderRadius: 2,
backgroundColor: '#f8fafc',
},
},
React.createElement(View, {
style: {
width: 6, height: 6, borderRadius: 3,
borderWidth: 1,
borderColor: t.project?.color || MUTED,
marginRight: 5,
marginTop: 0.5,
flexShrink: 0,
},
}),
React.createElement(Text, {
style: { fontSize: 7.5, color: '#374151', lineHeight: 1.3, flex: 1 },
}, (t.title || '').slice(0, 55)),
)
// Lists in row
React.createElement(View, {
style: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginBottom: 6 },
},
...tabMap.get(tab)!.map(renderList),
),
list.tasks.length > 20 ? React.createElement(Text, {
style: { fontSize: 6.5, color: MUTED, marginTop: 2, paddingLeft: 4 },
}, `+${list.tasks.length - 20} ${de ? 'weitere' : 'more'}`) : null,
)
),
),
);
})()),
// Footer
React.createElement(View, {
@ -919,29 +843,23 @@ export async function GET(request: Request) {
const dk = localDateStr(task.scheduledDate, userTz);
if (dk < startDate || dk > endDate) continue;
if (!tasksByDay.has(dk)) tasksByDay.set(dk, { allDay: [], noTime: [], timed: [] });
if (!tasksByDay.has(dk)) tasksByDay.set(dk, { allDay: [], timed: [] });
const d = tasksByDay.get(dk)!;
const item: TaskItem = {
id: task.id, title: task.title, startTime: task.startTime,
endTime: task.endTime ?? null, completed: task.completed,
externalProvider: (task as any).externalProvider ?? null,
project: task.project,
};
if (!task.startTime) {
// External calendar all-day events → allDay strip; user tasks → noTime strip
if (item.externalProvider) {
if (showAllDay) d.allDay.push(item);
} else {
if (showNoTime) d.noTime.push(item);
}
// All date-scheduled tasks with no time → all-day strip
if (showAllDay) d.allDay.push(item);
} else {
const h = parseInt(task.startTime.split(':')[0], 10);
if (h >= startHour && h < endHour) {
d.timed.push(item);
} else if (showNoTime) {
d.noTime.push(item); // tasks outside the time range → anyday strip
}
// Tasks outside the time window are simply not shown in the grid
}
}
@ -965,16 +883,18 @@ export async function GET(request: Request) {
type SomedayListWithTasks = {
id: string;
title: string;
tab: string | null;
tasks: { id: string; title: string; completed: boolean; project: { name: string; color: string | null } | null }[];
};
let somedayLists: SomedayListWithTasks[] = [];
if (showNoTime) {
const raw = await prisma.somedayList.findMany({
where: { userId: user.id },
orderBy: { order: 'asc' },
orderBy: [{ tab: 'asc' }, { order: 'asc' }],
select: {
id: true,
title: true,
tab: true,
tasks: {
where: { deletedAt: null, completed: false },
orderBy: { order: 'asc' },