fix: align time column header, calendar events, all-day events, and swap view icons

- Fix time-column-header height to dynamically match weekly-day-header
- Fix all-day events column alignment (width 50px → 55px to match time column)
- Fix calendar events overflowing weekday columns (remove negative left/right)
- Swap Simple/Calendar view icons in header switcher
- Uncomment headerCustomFormat in API select for instant toggle
- Add response error checking in updateTaskFields for kanban persistence
- Add font/view dependencies to header height ResizeObserver

v1.33.2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-14 18:32:09 +01:00
parent 58de23a18f
commit ebfe911b81
6 changed files with 662 additions and 740 deletions

View File

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

@ -79,6 +79,7 @@ model User {
dateAlignment String @default("center")
dateVerticalAlign String? @default("middle")
headerDisplay String? @default("kw")
headerCustomFormat String? @default("KW WW | YYYY")
hourLabelFormat String @default("short")
showSubHourSlots Boolean @default(true)
allDayPosition String @default("above")

View File

@ -46,6 +46,7 @@ export async function GET(request: NextRequest) {
mobileDateLayout: true,
dateVerticalAlign: true,
headerDisplay: true,
headerCustomFormat: true,
headlineFont: true,
headlineFontSize: true,
headlineFontWeight: true,
@ -130,7 +131,7 @@ export async function PATCH(request: NextRequest) {
weekdayColor, dateColor, taskColor, todayHighlightColor,
pastDayColor, goalFallbackType, goalDefaultSentence,
goalFontFamily, goalFontSize, goalFontWeight, goalScope,
dateLayout, mobileDateLayout, dateVerticalAlign, headerDisplay,
dateLayout, mobileDateLayout, dateVerticalAlign, headerDisplay, headerCustomFormat,
hourLabelFormat, showSubHourSlots, allDayPosition,
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
@ -196,6 +197,7 @@ export async function PATCH(request: NextRequest) {
...(mobileDateLayout !== undefined && { mobileDateLayout }),
...(dateVerticalAlign !== undefined && { dateVerticalAlign }),
...(headerDisplay !== undefined && { headerDisplay }),
...(headerCustomFormat !== undefined && { headerCustomFormat }),
...(hourLabelFormat !== undefined && { hourLabelFormat }),
...(showSubHourSlots !== undefined && { showSubHourSlots }),
...(allDayPosition !== undefined && { allDayPosition }),
@ -289,6 +291,7 @@ export async function PATCH(request: NextRequest) {
mobileDateLayout: true,
dateVerticalAlign: true,
headerDisplay: true,
headerCustomFormat: true,
cwFontFamily: true,
cwFontSize: true,
cwFontWeight: true,

View File

@ -2324,15 +2324,15 @@ h3 {
/* Side Navigation Arrows (hover overlays) */
.side-nav {
position: sticky;
top: 50px; /* Below header height if needed, or 0 */
height: 0; /* Let it be a point of origin */
position: absolute;
top: 0;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.35rem 0.15rem;
gap: 0.1rem;
padding: 2px;
gap: 2px;
z-index: 30;
pointer-events: none; /* Let clicks pass through to grid except buttons */
}
@ -2352,6 +2352,7 @@ h3 {
.side-nav-left {
left: 0;
width: 100%;
}
.side-nav-right {
@ -2372,43 +2373,50 @@ h3 {
}
.side-nav-btn:hover {
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.06));
background: var(--weekly-bg); /* Use background over hover to hide header text */
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
color: var(--weekly-text, #333);
}
/* Time Column (Hours) */
.time-column {
width: 50px;
width: 55px; /* Slightly wider for labels */
flex-shrink: 0;
border-right: 1px solid var(--weekly-border);
background: var(--weekly-bg);
display: flex;
flex-direction: column;
/* Flush to left */
margin-left: -1px;
}
.time-column-header {
padding: 1rem 0.5rem 0.75rem;
min-height: 50px !important;
padding: 1rem 1rem 0.75rem; /* Match weekly-day-header padding */
min-height: 50px; /* Match weekly-day-header min-height */
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
.time-column-slots {
flex: 1;
overflow-y: visible; /* Unify with parent scroll */
position: relative;
padding-top: 0.4rem;
padding-top: 0; /* Flush with header */
}
.time-slot-label {
display: flex;
align-items: flex-start; /* Aligns to top so translateY(-50%) centers on the line */
align-items: flex-start;
justify-content: flex-end;
padding: 0 0.5rem;
font-size: 0.65rem;
color: var(--weekly-text-light);
box-sizing: border-box;
position: relative;
z-index: 10;
}
.time-slot-label.hour-start {
@ -2444,7 +2452,7 @@ h3 {
flex: 1;
overflow: visible; /* Changed from auto to unify scroll */
overflow-x: visible;
padding-top: 0.4rem; /* Aligned with .time-column-slots */
padding-top: 0; /* Flush with header */
}
.time-slot {
@ -2549,8 +2557,9 @@ h3 {
border-radius: 0 4px 4px 0;
margin: 1px 0;
overflow: hidden;
left: -10px;
right: -15px;
left: 0;
right: 0;
box-sizing: border-box;
}
.time-slot-event .event-title-row {
@ -2666,6 +2675,7 @@ h3 {
min-height: 28px;
border-right: 1px solid var(--weekly-border);
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}

View File

@ -1,4 +1,8 @@
import React, { useState, useEffect, lazy, Suspense } from 'react';
import React, { useState, useEffect, lazy, Suspense, useRef } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
import { faServer } from '@fortawesome/free-solid-svg-icons';
const RichTextEditor = lazy(() => import('./RichTextEditor'));
@ -78,6 +82,31 @@ export default function CalendarEventModal({
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState('');
const [isCalendarSelectorOpen, setIsCalendarSelectorOpen] = useState(false);
const calendarSelectorRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (calendarSelectorRef.current && !calendarSelectorRef.current.contains(event.target as Node)) {
setIsCalendarSelectorOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const selectedCal = availableCalendars.find((c: any) => c.id === calendarId);
const supportsURL = selectedCal && (selectedCal.provider !== 'google' && selectedCal.provider !== 'outlook');
const getProviderIcon = (provider: string) => {
switch (provider) {
case 'google': return <FontAwesomeIcon icon={faGoogle} style={{ opacity: 0.8 }} />;
case 'apple': return <FontAwesomeIcon icon={faApple} style={{ opacity: 0.8 }} />;
case 'outlook': return <FontAwesomeIcon icon={faMicrosoft} style={{ opacity: 0.8 }} />;
case 'synology': return <FontAwesomeIcon icon={faServer} style={{ opacity: 0.8, fontSize: '0.8rem' }} />;
default: return null;
}
};
const handleSubmit = async () => {
if (!title.trim()) {
@ -155,186 +184,313 @@ export default function CalendarEventModal({
}
};
return (
<div className="weekly-modal-overlay" onClick={onClose}>
<div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '500px' }}>
<h3 style={{ marginBottom: '1.5rem' }}>
{event ? 'Edit Event' : 'New Event'}
</h3>
<div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{
maxWidth: '450px',
padding: '20px',
borderRadius: '12px',
boxShadow: '0 10px 25px rgba(0,0,0,0.15)',
border: '1px solid var(--weekly-border)'
}}>
{error && <div style={{ color: '#ef4444', marginBottom: '1rem', fontSize: '0.85rem', textAlign: 'center' }}>{error}</div>}
{error && <div style={{ color: 'red', marginBottom: '1rem' }}>{error}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* Title Input */}
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="New Event"
autoFocus
style={{
width: '100%',
padding: '12px 16px',
fontSize: '1.25rem',
fontWeight: 600,
border: 'none',
borderBottom: '1px solid var(--weekly-border)',
borderRadius: '0',
background: 'transparent',
outline: 'none',
marginBottom: '4px'
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
{/* Location Input */}
<input
type="text"
value={location}
onChange={e => setLocation(e.target.value)}
placeholder="Location or Video Call"
style={{
width: '100%',
padding: '8px 16px',
border: 'none',
borderBottom: '1px solid var(--weekly-border)',
borderRadius: '0',
background: 'transparent',
outline: 'none',
fontSize: '0.95rem'
}}
/>
{/* Title */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Title</label>
{/* Calendar row: Label + Custom Selector */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 16px', position: 'relative' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Calendar</span>
<div ref={calendarSelectorRef} style={{ position: 'relative', flex: 1, display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
onClick={() => !event && setIsCalendarSelectorOpen(!isCalendarSelectorOpen)}
disabled={!!event}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
background: 'transparent',
border: 'none',
fontWeight: 500,
cursor: event ? 'default' : 'pointer',
outline: 'none',
color: 'var(--weekly-text)',
fontSize: '0.95rem',
padding: '4px 0',
maxWidth: '200px',
justifyContent: 'flex-end'
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedCal?.summary || selectedCal?.title || 'Select Calendar'}
</span>
<div style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: selectedCal?.backgroundColor || selectedCal?.color || '#3b82f6'
}} />
{!event && (isCalendarSelectorOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
</button>
{isCalendarSelectorOpen && (
<div style={{
position: 'absolute',
top: '100%',
right: 0,
zIndex: 100,
minWidth: '220px',
backgroundColor: 'var(--weekly-bg-popover, #ffffff)',
borderRadius: '10px',
boxShadow: '0 4px 15px rgba(0,0,0,0.1)',
border: '1px solid var(--weekly-border)',
marginTop: '5px',
padding: '6px',
maxHeight: '250px',
overflowY: 'auto'
}}>
{availableCalendars.map((cal: any) => (
<div
key={cal.id}
onClick={() => {
setCalendarId(cal.id);
setIsCalendarSelectorOpen(false);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '8px 12px',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.9rem',
color: 'var(--weekly-text)',
backgroundColor: calendarId === cal.id ? 'var(--weekly-selection, rgba(59, 130, 246, 0.1))' : 'transparent',
transition: 'background 0.2s',
textAlign: 'left'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--weekly-hover, rgba(0,0,0,0.05))'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = calendarId === cal.id ? 'var(--weekly-selection, rgba(59, 130, 246, 0.1))' : 'transparent'}
>
<div style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: cal.backgroundColor || cal.color || '#3b82f6', flexShrink: 0 }} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{cal.summary || cal.title}
</span>
<span style={{ color: 'var(--weekly-text-light)', opacity: 0.6 }}>
{getProviderIcon(cal.provider)}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* All Day row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 16px' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>All Day</span>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Event Title"
autoFocus
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px', fontSize: '1rem' }}
type="checkbox"
checked={allDay}
onChange={e => setAllDay(e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
</div>
{/* Calendar Selection */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Calendar</label>
<select
value={calendarId}
onChange={e => setCalendarId(e.target.value)}
disabled={!!event} // Usually can't move events between calendars easily in basic implementation
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
{availableCalendars.length === 0 && <option value="">No editable calendars</option>}
{availableCalendars.map((cal: any) => (
<option key={cal.id} value={cal.id}>
{cal.summary || cal.title} ({cal.providerName})
</option>
))}
</select>
</div>
{/* All Day Toggle */}
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
<input
type="checkbox"
checked={allDay}
onChange={e => setAllDay(e.target.checked)}
/>
All Day
</label>
</div>
{/* Date/Time */}
<div style={{ display: 'flex', gap: '15px' }}>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Start</label>
{/* Date/Time rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '0 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Starts</span>
<input
type={allDay ? "date" : "datetime-local"}
value={allDay ? startDate.toISOString().split('T')[0] : toLocalISOString(startDate)}
onChange={e => handleStartDateChange(e.target.value)}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
style={{
padding: '4px 8px',
border: 'none',
borderRadius: '6px',
background: 'var(--weekly-bg-secondary, #f3f4f6)',
fontSize: '0.9rem',
textAlign: 'center'
}}
/>
</div>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>End</label>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Ends</span>
<input
type={allDay ? "date" : "datetime-local"}
value={allDay ? endDate.toISOString().split('T')[0] : toLocalISOString(endDate)}
onChange={e => setEndDate(new Date(e.target.value))}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
style={{
padding: '4px 8px',
border: 'none',
borderRadius: '6px',
background: 'var(--weekly-bg-secondary, #f3f4f6)',
fontSize: '0.9rem',
textAlign: 'center'
}}
/>
</div>
</div>
{/* Recurrence */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Repeat</label>
<select
value={recurrence}
onChange={e => setRecurrence(e.target.value)}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="none">Never</option>
<option value="daily">Every Day</option>
<option value="weekly">Every Week</option>
<option value="biweekly">Every 2 Weeks</option>
<option value="monthly">Every Month</option>
<option value="yearly">Every Year</option>
</select>
</div>
{/* Meta Fields Group */}
<div style={{ padding: '8px 16px', display: 'flex', flexDirection: 'column', gap: '12px', marginTop: '4px' }}>
{/* Recurrence */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', fontWeight: 500 }}>Repeat</span>
<select
value={recurrence}
onChange={e => setRecurrence(e.target.value)}
style={{
background: 'transparent',
border: 'none',
textAlign: 'right',
fontSize: '0.9rem',
cursor: 'pointer',
outline: 'none',
color: 'var(--weekly-text)'
}}
>
<option value="none">Never</option>
<option value="daily">Every Day</option>
<option value="weekly">Every Week</option>
<option value="monthly">Every Month</option>
<option value="yearly">Every Year</option>
</select>
</div>
{/* Location */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Location</label>
<input
type="text"
value={location}
onChange={e => setLocation(e.target.value)}
placeholder="Location (optional)"
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
{/* URL (Conditional) */}
{supportsURL && (
<input
type="url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="URL"
style={{
width: '100%',
padding: '6px 0',
border: 'none',
borderBottom: '1px solid var(--weekly-border)',
background: 'transparent',
fontSize: '0.9rem',
outline: 'none'
}}
/>
)}
{/* URL */}
{(() => {
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,
}}
{/* Notes */}
<div style={{ marginTop: '4px' }}>
<Suspense fallback={
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Notes"
rows={3}
style={{ width: '100%', padding: '8px 0', border: 'none', borderBottom: '1px solid var(--weekly-border)', background: 'transparent', resize: 'none', fontSize: '0.9rem', outline: 'none' }}
/>
</div>
);
})()}
{/* Description - Rich text */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Notes</label>
<Suspense fallback={
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Notes..."
rows={3}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px', resize: 'vertical' }}
/>
}>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Notes..."
minHeight="120px"
/>
</Suspense>
}>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Notes"
minHeight="80px"
/>
</Suspense>
</div>
</div>
</div>
<div className="weekly-modal-actions" style={{ marginTop: '2rem', display: 'flex', justifyContent: 'space-between' }}>
<div>
<div className="weekly-modal-actions" style={{ marginTop: '20px', display: 'flex', justifyContent: 'space-between', borderTop: '1px solid var(--weekly-border)', paddingTop: '15px' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
{event && onDelete && (
<button
onClick={handleDelete}
disabled={isSaving || isDeleting}
style={{
padding: '10px 20px',
background: isDeleteConfirming ? '#d32f2f' : 'transparent',
color: isDeleteConfirming ? 'white' : '#d32f2f',
border: '1px solid #d32f2f',
borderRadius: '4px',
fontSize: '1rem',
padding: '8px 12px',
background: 'none',
color: '#ef4444',
border: 'none',
borderRadius: '6px',
fontSize: '0.9rem',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s',
width: isDeleteConfirming ? 'auto' : 'initial' // Expand if needed
opacity: isSaving || isDeleting ? 0.5 : 1
}}
>
{isDeleting ? 'Deleting...' : isDeleteConfirming ? 'Confirm Delete' : 'Delete'}
</button>
)}
{!event && (
<button className="weekly-btn-ghost" onClick={onClose} style={{ padding: '8px 12px', fontSize: '0.9rem', color: 'var(--weekly-text-light)', border: 'none', background: 'none', cursor: 'pointer' }}>
Cancel
</button>
)}
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="weekly-btn weekly-btn-secondary" onClick={onClose} disabled={isSaving || isDeleting}>Cancel</button>
<button className="weekly-btn weekly-btn-primary" onClick={handleSubmit} disabled={isSaving || isDeleting}>
<div style={{ display: 'flex', gap: '8px' }}>
{event && (
<button className="weekly-btn-ghost" onClick={onClose} style={{ padding: '8px 12px', fontSize: '0.9rem', color: 'var(--weekly-text-light)', border: 'none', background: 'none', cursor: 'pointer' }}>
Cancel
</button>
)}
<button
className="weekly-btn-primary"
onClick={handleSubmit}
disabled={isSaving || isDeleting}
style={{
padding: '8px 20px',
borderRadius: '8px',
fontSize: '0.95rem',
fontWeight: 600,
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
cursor: 'pointer',
opacity: isSaving || isDeleting ? 0.7 : 1
}}
>
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>

File diff suppressed because it is too large Load Diff