- Add Google Tasks integration and Apple Reminders support - Add task import/export with list management APIs - Add goal API for weekly goals - Add German holidays library - Add ImportListModal component - Enhance WeeklyView with major UI improvements - Enhance CalendarSettings with new connection options - Add external task fields to database schema - Add Playwright test suites for auth and tasks - Add iCloud reminders Python scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import {
|
|
format,
|
|
addMonths,
|
|
subMonths,
|
|
startOfMonth,
|
|
endOfMonth,
|
|
startOfWeek,
|
|
endOfWeek,
|
|
addDays,
|
|
isSameMonth,
|
|
isSameDay,
|
|
setMonth,
|
|
setYear,
|
|
getYear,
|
|
getMonth
|
|
} from 'date-fns';
|
|
import { enUS, de } from 'date-fns/locale';
|
|
|
|
interface DatePickerProps {
|
|
selected: Date;
|
|
onSelect: (date: Date) => void;
|
|
onClose: () => void;
|
|
language?: string;
|
|
}
|
|
|
|
export default function DatePicker({ selected, onSelect, onClose, language = 'en' }: DatePickerProps) {
|
|
const [currentMonth, setCurrentMonth] = useState(new Date(selected));
|
|
const [view, setView] = useState<'calendar' | 'month-year'>('calendar');
|
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
|
|
const locale = language === 'de' ? de : enUS;
|
|
|
|
// Close on click outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
|
onClose();
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [onClose]);
|
|
|
|
// Calendar Header
|
|
const renderHeader = () => {
|
|
return (
|
|
<div className="datepicker-header">
|
|
<button onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="15 18 9 12 15 6"></polyline>
|
|
</svg>
|
|
</button>
|
|
<div
|
|
className="datepicker-current-month"
|
|
onClick={() => setView(view === 'calendar' ? 'month-year' : 'calendar')}
|
|
>
|
|
{format(currentMonth, 'MMMM yyyy', { locale })}
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: '6px', opacity: 0.5 }}>
|
|
<polyline points="6 9 12 15 18 9"></polyline>
|
|
</svg>
|
|
</div>
|
|
<button onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="9 18 15 12 9 6"></polyline>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Days of Week Header
|
|
const renderDays = () => {
|
|
const days = [];
|
|
const startDate = startOfWeek(currentMonth, { weekStartsOn: 1 }); // Monday start
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
days.push(
|
|
<div className="datepicker-day-name" key={i}>
|
|
{format(addDays(startDate, i), 'EEEEEE', { locale })}
|
|
</div>
|
|
);
|
|
}
|
|
return <div className="datepicker-days-row">{days}</div>;
|
|
};
|
|
|
|
// Calendar Cells
|
|
const renderCells = () => {
|
|
const monthStart = startOfMonth(currentMonth);
|
|
const monthEnd = endOfMonth(monthStart);
|
|
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 });
|
|
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
|
|
|
const dateFormat = 'd';
|
|
const rows = [];
|
|
let days = [];
|
|
let day = startDate;
|
|
let formattedDate = '';
|
|
|
|
while (day <= endDate) {
|
|
for (let i = 0; i < 7; i++) {
|
|
formattedDate = format(day, dateFormat);
|
|
const cloneDay = day;
|
|
|
|
days.push(
|
|
<div
|
|
className={`datepicker-cell ${!isSameMonth(day, monthStart)
|
|
? 'disabled'
|
|
: isSameDay(day, selected)
|
|
? 'selected'
|
|
: ''
|
|
} ${isSameDay(day, new Date()) ? 'today' : ''}`}
|
|
key={day.toString()}
|
|
onClick={() => {
|
|
onSelect(cloneDay);
|
|
onClose();
|
|
}}
|
|
>
|
|
<span className="number">{formattedDate}</span>
|
|
</div>
|
|
);
|
|
day = addDays(day, 1);
|
|
}
|
|
rows.push(
|
|
<div className="datepicker-row" key={day.toString()}>
|
|
{days}
|
|
</div>
|
|
);
|
|
days = [];
|
|
}
|
|
return <div className="datepicker-body">{rows}</div>;
|
|
};
|
|
|
|
// Month/Year Selection View
|
|
const renderMonthYearSelector = () => {
|
|
const years = [];
|
|
const currentYear = getYear(new Date());
|
|
for (let y = currentYear - 5; y <= currentYear + 5; y++) {
|
|
years.push(y);
|
|
}
|
|
|
|
const months = Array.from({ length: 12 }, (_, i) => {
|
|
return format(setMonth(new Date(), i), 'MMM', { locale });
|
|
});
|
|
|
|
return (
|
|
<div className="datepicker-month-year-view">
|
|
<div className="datepicker-years">
|
|
{years.map(year => (
|
|
<div
|
|
key={year}
|
|
className={`datepicker-year-option ${getYear(currentMonth) === year ? 'selected' : ''}`}
|
|
onClick={() => setCurrentMonth(setYear(currentMonth, year))}
|
|
>
|
|
{year}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="datepicker-months">
|
|
{months.map((month, index) => (
|
|
<div
|
|
key={month}
|
|
className={`datepicker-month-option ${getMonth(currentMonth) === index ? 'selected' : ''}`}
|
|
onClick={() => {
|
|
setCurrentMonth(setMonth(currentMonth, index));
|
|
setView('calendar');
|
|
}}
|
|
>
|
|
{month}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="datepicker-modal" ref={modalRef}>
|
|
{renderHeader()}
|
|
{view === 'calendar' ? (
|
|
<>
|
|
{renderDays()}
|
|
{renderCells()}
|
|
</>
|
|
) : (
|
|
renderMonthYearSelector()
|
|
)}
|
|
<style jsx>{`
|
|
.datepicker-modal {
|
|
position: absolute;
|
|
top: 100%;
|
|
right: 0;
|
|
margin-top: 8px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
border-bottom: 5px solid #00ceb0; /* Weekly Teal/Green */
|
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
|
padding: 20px;
|
|
z-index: 1000;
|
|
width: 320px;
|
|
font-family: var(--font-inter, sans-serif);
|
|
user-select: none;
|
|
animation: fadeIn 0.2s ease-out;
|
|
}
|
|
.datepicker-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
.datepicker-header button {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-size: 1.5rem;
|
|
color: #999;
|
|
padding: 0 8px;
|
|
line-height: 1;
|
|
font-weight: 300;
|
|
}
|
|
.datepicker-header button:hover {
|
|
color: #333;
|
|
}
|
|
.datepicker-current-month {
|
|
font-weight: 800;
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
.datepicker-days-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr); /* Force 7 columns */
|
|
margin-bottom: 12px;
|
|
justify-items: center; /* Center content */
|
|
}
|
|
.datepicker-day-name {
|
|
width: 40px;
|
|
text-align: center;
|
|
font-size: 0.85rem;
|
|
color: #000;
|
|
font-weight: 700;
|
|
}
|
|
.datepicker-body {
|
|
display: flex; /* Still flex col for rows, OR better yet, just one grid */
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
/* Alternative: Use one big grid for body cells? */
|
|
/* But current implementation renders rows. Let's keep rows but make them grids. */
|
|
.datepicker-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr);
|
|
gap: 0; /* Gap handled by padding or just empty space? Current had space-between */
|
|
justify-items: center;
|
|
}
|
|
/* Actually, space-between in flex meant they spread out. Grid 1fr means equal width. */
|
|
/* This is much better for alignment. */
|
|
|
|
.datepicker-cell {
|
|
width: 40px;
|
|
height: 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
transition: all 0.1s ease;
|
|
}
|
|
.datepicker-cell:hover:not(.disabled) {
|
|
background-color: #f5f5f5;
|
|
}
|
|
.datepicker-cell.selected {
|
|
background-color: #f0f0f0;
|
|
color: #000;
|
|
font-weight: 900;
|
|
}
|
|
.datepicker-cell.today {
|
|
color: #00ceb0;
|
|
}
|
|
.datepicker-cell.disabled {
|
|
color: #e0e0e0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.datepicker-month-year-view {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
height: 280px;
|
|
}
|
|
.datepicker-years {
|
|
display: flex;
|
|
overflow-x: auto;
|
|
gap: 8px;
|
|
padding-bottom: 8px;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
.datepicker-year-option {
|
|
padding: 4px 8px;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
font-weight: 500;
|
|
}
|
|
.datepicker-year-option.selected {
|
|
background: #000;
|
|
color: white;
|
|
}
|
|
.datepicker-months {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 8px;
|
|
}
|
|
.datepicker-month-option {
|
|
padding: 12px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
font-weight: 600;
|
|
}
|
|
.datepicker-month-option:hover {
|
|
background: #f0f0f0;
|
|
}
|
|
.datepicker-month-option.selected {
|
|
background: #000;
|
|
color: white;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(-5px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
`}</style>
|
|
</div >
|
|
);
|
|
}
|