chore: change development port to 3000

v1.13.1
This commit is contained in:
mARTin 2026-03-07 17:39:26 +01:00
parent d41bc25fb0
commit 2777601031
10 changed files with 542 additions and 232 deletions

View File

@ -26,7 +26,7 @@ This application can be deployed using Docker and Docker Compose.
```bash
docker-compose up --build
```
3. Access the application at http://localhost:3001
3. Access the application at http://localhost:3000
### Environment Variables

View File

@ -39,7 +39,7 @@ This will create all necessary tables:
npm run dev
```
Visit: **http://localhost:3001**
Visit: **http://localhost:3000**
---
@ -47,14 +47,14 @@ Visit: **http://localhost:3001**
### Sign Up (Email/Password)
1. Go to http://localhost:3001/auth/signup
1. Go to http://localhost:3000/auth/signup
2. Enter email and password (min 8 characters)
3. Click "Sign up"
4. You'll be automatically signed in
### Sign In (Email/Password)
1. Go to http://localhost:3001/auth/login
1. Go to http://localhost:3000/auth/login
2. Enter your credentials
3. Click "Sign in"
@ -82,7 +82,7 @@ To enable "Sign in with Google":
4. Click **Create Credentials** → **OAuth client ID**
5. Choose **Web application**
6. Add these redirect URIs:
- `http://localhost:3001/api/auth/callback/google`
- `http://localhost:3000/api/auth/callback/google`
- `https://todo.martin-bierschenk.de/api/auth/callback/google` (via Reverse Proxy)
- `https://todo.martin-bierschenk.de/api/calendar/google/oauth` (via Reverse Proxy)
7. Copy your **Client ID** and **Client Secret**

View File

@ -41,7 +41,7 @@ Since this is a Next.js application, you can deploy it with:
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3001/health || exit 1
CMD curl -f http://localhost:3000/health || exit 1
# For Next.js, run in production mode
CMD ["npm", "run", "start"]

62
package-lock.json generated
View File

@ -10,6 +10,10 @@
"license": "MIT",
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.2.0",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^5.22.0",
"@tiptap/extension-link": "^3.20.0",
@ -873,6 +877,64 @@
"license": "MIT",
"optional": true
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.2.0.tgz",
"integrity": "sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.2.0.tgz",
"integrity": "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.2.0.tgz",
"integrity": "sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.2.0.tgz",
"integrity": "sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.2.0.tgz",
"integrity": "sha512-E9Gu1hqd6JussVO26EC4WqRZssXMnQr2ol7ZNWkkFOH8jZUaxDJ9Z9WF9wIVkC+kJGXUdY3tlffpDwEKfgQrQw==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~6 || ~7",
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",

View File

@ -1,10 +1,10 @@
{
"name": "my-weekly-todo-list",
"version": "1.13.0",
"version": "1.13.1",
"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": {
"dev": "echo \"\\n ✦ My Weekly ToDo List v$(node -p \"require('./package.json').version\") — dev mode\\n\" && next dev -H 0.0.0.0 -p 3002",
"dev": "echo \"\\n ✦ My Weekly ToDo List v$(node -p \"require('./package.json').version\") — dev mode\\n\" && next dev -H 0.0.0.0 -p 3000",
"build": "next build",
"start": "echo \"\\n ✦ My Weekly ToDo List v$(node -p \"require('./package.json').version\") — production\\n\" && next start",
"lint": "next lint",
@ -22,6 +22,10 @@
"license": "MIT",
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.2.0",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^5.22.0",
"@tiptap/extension-link": "^3.20.0",

View File

@ -7,6 +7,7 @@ import { createGoogleClient, createGoogleTaskList } from '@/lib/google-tasks';
import { createMsTodoList } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
import { createAppleReminderList } from '@/lib/apple-reminders';
import { createSynologyReminderList } from '@/lib/synology-tasks';
const prisma = new PrismaClient();
@ -66,6 +67,18 @@ export async function POST(req: NextRequest) {
const newList = await createAppleReminderList(email, password, title);
externalId = newList.id;
externalProvider = 'apple';
} else if (provider === 'synology') {
const connection = await prisma.calendarConnection.findFirst({
where: { userId: user.id, provider: 'synology' }
});
if (!connection || !connection.accessToken || !connection.refreshToken) {
return NextResponse.json({ error: 'Synology account not connected or missing server URL' }, { status: 400 });
}
const [username, password] = connection.accessToken.split(':');
const serverUrl = connection.refreshToken;
const newList = await createSynologyReminderList(serverUrl, username, password, title);
externalId = newList.id;
externalProvider = 'synology';
}
// Create local SomedayList

View File

@ -1191,7 +1191,7 @@ h3 {
.weekly-someday.expanded {
max-height: none;
overflow: hidden;
overflow: visible;
}
.weekly-someday-bar {
@ -1257,12 +1257,13 @@ h3 {
flex-direction: row !important;
flex-wrap: nowrap !important;
gap: 1.5rem;
width: max-content; /* Allow grid to grow beyond parent width */
width: max-content;
min-width: 100%;
padding-bottom: 1rem;
align-items: flex-start;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
overflow: visible; /* Let parent container handle scrolling */
}
/* Scrollbar Styling for Someday Container */
@ -1318,7 +1319,7 @@ h3 {
max-width: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-y: visible;
scrollbar-width: none; /* Hide scrollbar Firefox */
-ms-overflow-style: none; /* Hide scrollbar IE/Edge */
}
@ -1414,6 +1415,12 @@ h3 {
padding: 6px 1rem;
display: flex;
align-items: flex-start;
overflow: visible !important;
position: relative;
}
.weekly-someday-list .weekly-task-item:hover {
z-index: 50;
}
.weekly-container.dark-mode .weekly-someday-list .weekly-task-item {
@ -1421,7 +1428,6 @@ h3 {
}
.weekly-someday-list .weekly-task-text {
font-size: var(--base-font-size);
line-height: 1.4;
word-break: break-word;
overflow: visible;

View File

@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from "react";
import { Repeat } from "lucide-react";
import { Repeat, Circle, X } from "lucide-react";
import { Task } from "./WeeklyView";
interface GridTaskBlockProps {
@ -30,6 +30,8 @@ interface GridTaskBlockProps {
onSetEditingTaskId?: (id: string | null) => void;
workingHoursStart: number;
showTaskCheckboxes?: boolean;
projects?: any[];
onProjectAssign?: (taskId: string, projectId: string | null) => void;
}
export function GridTaskBlock({
@ -59,15 +61,19 @@ export function GridTaskBlock({
deleteSubTask,
onSetEditingTaskId,
workingHoursStart,
showTaskCheckboxes
showTaskCheckboxes,
projects,
onProjectAssign
}: GridTaskBlockProps) {
const [isNotesOpen, setIsNotesOpen] = useState(false);
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
const [isSubTasksOpen, setIsSubTasksOpen] = useState(false);
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
const [showProjectPicker, setShowProjectPicker] = useState(false);
const notesRef = useRef<HTMLTextAreaElement>(null);
const subTaskInputRef = useRef<HTMLInputElement>(null);
const projectPickerRef = useRef<HTMLDivElement>(null);
// Touch: tap-to-reveal actions
const [touchActive, setTouchActive] = useState(false);
@ -189,8 +195,9 @@ export function GridTaskBlock({
zIndex: isResizing || isNotesOpen || isSubTasksOpen ? 10 : 5,
background: (isNotesOpen || isSubTasksOpen || isResizing) ? (darkMode ? "#2a2a2a" : "#ffffff") : "transparent",
border: (isNotesOpen || isSubTasksOpen || isResizing) ? `1px solid ${darkMode ? "#404040" : "#e0e0e0"}` : "none",
borderLeft: task.project?.color ? `4px solid ${task.project.color}` : (isNotesOpen || isSubTasksOpen || isResizing) ? `1px solid ${darkMode ? "#404040" : "#e0e0e0"}` : "none",
borderRadius: (isNotesOpen || isSubTasksOpen || isResizing) ? "4px" : "0",
padding: "2px 4px",
padding: task.project?.color ? "2px 4px 2px 8px" : "2px 4px",
boxShadow: (isNotesOpen || isSubTasksOpen || isResizing) ? "0 1px 3px rgba(0,0,0,0.05)" : "none",
display: "flex",
flexDirection: "column",
@ -262,7 +269,73 @@ export function GridTaskBlock({
style={{ width: "12px", height: "12px", margin: 0, cursor: "pointer", position: "relative", top: "3px", left: "-2px", accentColor: "#FFF" }}
/>
)}
<span style={task.completed && showTaskCheckboxes ? { opacity: 0.5, flex: 1 } : { flex: 1 }}>{task.title}</span>
<span style={task.completed && showTaskCheckboxes ? { opacity: 0.5, flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" } : { flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" }}>
<span style={{ marginRight: "4px" }}>{task.title}</span>
{/* Subtask indicator */}
{task.subTasks && task.subTasks.length > 0 && (() => {
const completed = task.subTasks.filter(s => s.completed).length;
const total = task.subTasks.length;
const expanded = isSubTasksOpen || isSubTaskInputOpen;
return (
<div
onClick={(e) => {
e.stopPropagation();
setIsSubTasksOpen(!isSubTasksOpen);
}}
className="subtask-indicator-badge"
title={expanded ? "Collapse subtasks" : `${completed}/${total} subtasks done`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "2px",
padding: "1px 5px",
borderRadius: "8px",
background: expanded ? "rgba(99, 102, 241, 0.15)" : (completed === total) ? "rgba(34, 197, 94, 0.15)" : "rgba(0,0,0,0.06)",
color: expanded ? "#6366f1" : (completed === total) ? "#22c55e" : (darkMode ? "#aaa" : "#666"),
border: "none",
cursor: "pointer",
fontSize: "0.65rem",
fontWeight: 600,
lineHeight: 1,
flexShrink: 0,
whiteSpace: "nowrap",
}}
>
<svg viewBox="0 0 24 24" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
<polyline points="9 18 15 12 9 6" />
</svg>
{completed}/{total}
</div>
);
})()}
{/* Note indicator */}
{task.markdownContent && task.markdownContent.trim().length > 0 && (
<div
onClick={(e) => {
e.stopPropagation();
setIsNotesOpen(!isNotesOpen);
}}
title={isNotesOpen ? "Collapse note" : "Expand note"}
style={{
display: "inline-flex",
alignItems: "center",
padding: "1px 3px",
borderRadius: "3px",
background: isNotesOpen ? "rgba(245, 158, 11, 0.12)" : "rgba(0,0,0,0.04)",
color: isNotesOpen ? "#f59e0b" : (darkMode ? "#888" : "#999"),
cursor: "pointer",
marginLeft: "3px"
}}
>
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</div>
)}
</span>
</span>
)}
</div>
@ -324,6 +397,57 @@ export function GridTaskBlock({
<Repeat size={12} />
</button>
)}
{/* Project Assignment */}
{projects && projects.length > 0 && onProjectAssign && (
<div className="relative" ref={projectPickerRef}>
<button
className={`task-action-btn ${task.project ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
setShowProjectPicker(!showProjectPicker);
}}
title={task.project ? task.project.name : "Assign project"}
>
<Circle
size={12}
fill={task.project?.color || "none"}
stroke={task.project?.color || "currentColor"}
strokeWidth={2}
/>
</button>
{showProjectPicker && (
<div className="absolute z-[100] top-full left-1/2 -translate-x-1/2 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-[140px]" style={{ whiteSpace: "nowrap" }}>
{task.projectId && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500"
onClick={(e) => {
e.stopPropagation();
onProjectAssign(task.id, null);
setShowProjectPicker(false);
}}
>
<X size={10} /> Remove
</button>
)}
{projects.map((p) => (
<button
key={p.id}
className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 ${task.projectId === p.id ? "font-bold" : ""}`}
onClick={(e) => {
e.stopPropagation();
onProjectAssign(task.id, task.projectId === p.id ? null : p.id);
setShowProjectPicker(false);
}}
>
<Circle size={10} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} />
{p.name}
</button>
))}
</div>
)}
</div>
)}
<button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>

View File

@ -12,6 +12,9 @@ import { useSession, signOut } from "next-auth/react";
import CalendarEventModal from "./CalendarEventModal";
import TaskRecurrenceModal from "./RecurrenceModal";
import { GridTaskBlock } from "./GridTaskBlock";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faApple, faGoogle, faMicrosoft } from "@fortawesome/free-brands-svg-icons";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import FocusModeOverlay from "./FocusModeOverlay";
import {
@ -53,6 +56,8 @@ import {
FolderOpen,
Circle,
X,
Cable,
Link,
} from "lucide-react";
// Types
@ -638,6 +643,7 @@ export default function WeeklyView() {
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const [draggingListId, setDraggingListId] = useState<string | null>(null);
const [dropTargetListIndex, setDropTargetListIndex] = useState<number | null>(null);
const [activeAddSlot, setActiveAddSlot] = useState<{ listId: string; slotIdx: number } | null>(null);
const isDragFromHandle = useRef(false);
// Undo/Redo state
@ -3505,6 +3511,32 @@ export default function WeeklyView() {
setDropPreview(null);
};
// Added: Horizontal scroll with mouse wheel for someday area
useEffect(() => {
const somedayGrid = somedayGridRef.current;
if (!somedayGrid) return;
const handleWheel = (e: WheelEvent) => {
// Only handle if there is horizontal overflow
if (somedayGrid.scrollWidth > somedayGrid.clientWidth) {
// Dominant vertical scroll -> horizontal scroll
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
// Check if we can scroll further in the requested direction
const isAtLeft = somedayGrid.scrollLeft === 0;
const isAtRight = Math.abs(somedayGrid.scrollWidth - somedayGrid.clientWidth - somedayGrid.scrollLeft) < 1;
if ((e.deltaY < 0 && !isAtLeft) || (e.deltaY > 0 && !isAtRight)) {
e.preventDefault();
somedayGrid.scrollLeft += e.deltaY;
}
}
}
};
somedayGrid.addEventListener("wheel", handleWheel, { passive: false });
return () => somedayGrid.removeEventListener("wheel", handleWheel);
}, [somedayExpanded]);
const handleSomedayDragOver = (e: React.DragEvent, listId: string, slotIdx: number) => {
e.preventDefault();
setDropPreview({ listId, slotIdx });
@ -5011,6 +5043,8 @@ export default function WeeklyView() {
onSetEditingTaskId={setEditingTaskId}
workingHoursStart={workingHoursStart}
showTaskCheckboxes={profile.showTaskCheckboxes}
projects={projects}
onProjectAssign={assignProject}
/>
))}
{visibleSlots.map((slot) => {
@ -5455,10 +5489,9 @@ export default function WeeklyView() {
</button>
</div>
)}
<div style={{ flex: 1, minWidth: 0, overflowX: "auto" }}>
<div ref={somedayGridRef} style={{ flex: 1, minWidth: 0, overflowX: "auto" }}>
{somedayExpanded && (
<div
ref={somedayGridRef}
className={`weekly-someday-lists-grid cols-${Math.min(7, Math.max(1, viewDays))}`}
style={{ display: "flex", flexDirection: "row", flexWrap: "nowrap" }}
>
@ -5489,14 +5522,14 @@ export default function WeeklyView() {
display: "flex",
flexDirection: "column",
}}
onClick={(e) => {
// Focus the add input if clicking empty area or the list container
// Only if not clicking a task item or other interactive element
onMouseDown={(e) => {
// Only focus bottom input if clicking background area, not slots
const target = e.target as HTMLElement;
if (
target.closest(".weekly-task-item") ||
target.tagName === "INPUT" ||
target.tagName === "BUTTON"
target.closest(".task-list-slot") ||
target.closest(".someday-list-header") ||
target.closest("button") ||
target.tagName === "INPUT"
) {
return;
}
@ -5661,16 +5694,18 @@ export default function WeeklyView() {
{list.externalProvider && (
<span
title={`Synced with ${list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider}`}
style={{ display: "inline-flex", alignItems: "center", marginLeft: "4px", opacity: 0.5, flexShrink: 0 }}
style={{ display: "inline-flex", alignItems: "center", marginLeft: "8px", opacity: 0.8, flexShrink: 0 }}
>
{list.externalProvider === "outlook" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="18" rx="2" /><path d="M7 3v18" /><path d="M7 8h10" /><path d="M7 13h10" /></svg>
<FontAwesomeIcon icon={faMicrosoft} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#0078D4] dark:hover:text-[#00A4EF] transition-colors" />
) : list.externalProvider === "google" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 8v8" /><path d="M8 12h8" /></svg>
<FontAwesomeIcon icon={faGoogle} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#4285F4] dark:hover:text-[#8AB4F8] transition-colors" />
) : list.externalProvider === "apple" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83" /><path d="M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11" /></svg>
<FontAwesomeIcon icon={faApple} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#555] dark:hover:text-[#ccc] transition-colors" />
) : list.externalProvider === "synology" ? (
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#007AFF] dark:hover:text-[#3A9CFF] transition-colors" />
) : (
<RefreshCcw size={12} />
<RefreshCcw size={14} className="text-zinc-400" />
)}
</span>
)}
@ -5719,121 +5754,187 @@ export default function WeeklyView() {
position: "relative",
}}
>
{Array.from({ length: getSomedaySlotCount(list.tasks) }).map((_, slotIdx) => {
const taskInSlot = list.tasks.find(t => t.somedaySlotIndex === slotIdx);
const isTarget = dropPreview?.listId === list.id && dropPreview?.slotIdx === slotIdx;
{(() => {
const slotCount = getSomedaySlotCount(list.tasks);
const indexedTasks = list.tasks.filter(t => t.somedaySlotIndex !== null && t.somedaySlotIndex !== undefined && t.somedaySlotIndex < slotCount);
const unindexedTasks = list.tasks.filter(t => t.somedaySlotIndex === null || t.somedaySlotIndex === undefined || t.somedaySlotIndex >= slotCount);
// Fill indexed slots and put unindexed tasks in empty slots starting from top
const slots = Array.from({ length: slotCount }, (_, i) => ({ index: i, task: indexedTasks.find(t => t.somedaySlotIndex === i) || null }));
let unindexedIdx = 0;
const finalSlots = slots.map(slot => {
if (!slot.task && unindexedIdx < unindexedTasks.length) {
return { ...slot, task: unindexedTasks[unindexedIdx++] };
}
return slot;
});
// Any remaining unindexed tasks that didn't fit in slots
const remainingTasks = unindexedTasks.slice(unindexedIdx);
return (
<div
key={slotIdx}
className={`task-list-slot ${isTarget ? 'drop-target' : ''}`}
onDragOver={(e) => handleSomedayDragOver(e, list.id, slotIdx)}
onDrop={(e) => handleSomedayDrop(e, list.id, slotIdx)}
onDragLeave={() => setDropPreview(null)}
>
{taskInSlot && (
<TaskItem
key={taskInSlot.id}
task={taskInSlot}
isEditing={editingTaskId === taskInSlot.id}
onToggle={() => toggleTask(taskInSlot.id)}
onEdit={() => setEditingTaskId(taskInSlot.id)}
onUpdate={(title) => updateTask(taskInSlot.id, title)}
onDelete={() => deleteTask(taskInSlot.id)}
onNotes={() => setSelectedTaskForNotes(taskInSlot)}
onRollToggle={() => toggleTaskRolling(taskInSlot.id)}
onRecurrence={() => setSelectedTaskForRecurrence(taskInSlot)}
onDragStart={(e, t) => handleDragStart(e, t)}
onDragEnd={handleDragEnd}
variant="minimal"
isSomeday={true}
onAddSubTask={addSubTask}
onToggleSubTask={toggleSubTask}
onDeleteSubTask={deleteSubTask}
onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes}
projects={projects}
onProjectAssign={assignProject}
/>
)}
</div>
);
})}
<>
<SomedayAddTask
listId={list.id}
onAdd={async (title) => {
const occupiedSlots = list.tasks.map(t => t.somedaySlotIndex).filter(s => s !== null && s !== undefined) as number[];
let nextFreeSlot = 0;
while (occupiedSlots.includes(nextFreeSlot)) {
nextFreeSlot++;
}
{/* Legacy / unindexed tasks */}
{list.tasks.filter(t => t.somedaySlotIndex === null || t.somedaySlotIndex === undefined || t.somedaySlotIndex >= getSomedaySlotCount(list.tasks)).map(task => (
<div key={task.id} className="task-list-slot">
<TaskItem
key={task.id}
task={task}
isEditing={editingTaskId === task.id}
onToggle={() => toggleTask(task.id)}
onEdit={() => setEditingTaskId(task.id)}
onUpdate={(title) => updateTask(task.id, title)}
onDelete={() => deleteTask(task.id)}
onNotes={() => setSelectedTaskForNotes(task)}
onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() => setSelectedTaskForRecurrence(task)}
onDragStart={(e, t) => handleDragStart(e, t)}
onDragEnd={handleDragEnd}
variant="minimal"
isSomeday={true}
onAddSubTask={addSubTask}
onToggleSubTask={toggleSubTask}
onDeleteSubTask={deleteSubTask}
onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes}
projects={projects}
onProjectAssign={assignProject}
/>
</div>
))}
<SomedayAddTask
listId={list.id}
onAdd={async (title) => {
const occupiedSlots = list.tasks.map(t => t.somedaySlotIndex).filter(s => s !== null && s !== undefined) as number[];
let nextFreeSlot = 0;
while (occupiedSlots.includes(nextFreeSlot)) {
nextFreeSlot++;
}
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
somedayListId: list.id,
somedaySlotIndex: nextFreeSlot
}),
});
if (res.ok) {
const data = await res.json();
const newTask = {
...data.task,
createdAt: new Date(data.task.createdAt),
updatedAt: new Date(data.task.updatedAt),
};
setSomedayLists((prev) =>
prev.map((l) =>
l.id === list.id
? { ...l, tasks: [...l.tasks, newTask] }
: l,
),
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
somedayListId: list.id,
somedaySlotIndex: nextFreeSlot
}),
});
if (res.ok) {
const data = await res.json();
const newTask = {
...data.task,
createdAt: new Date(data.task.createdAt),
updatedAt: new Date(data.task.updatedAt),
};
setSomedayLists((prev) =>
prev.map((l) =>
l.id === list.id
? { ...l, tasks: [...l.tasks, newTask] }
: l,
),
);
}
} catch (e) {
console.error(e);
}
}}
/>
{finalSlots.map((slot) => {
const isTarget = dropPreview?.listId === list.id && dropPreview?.slotIdx === slot.index;
const task = slot.task;
return (
<div
key={slot.index}
className={`task-list-slot ${isTarget ? 'drop-target' : ''}`}
onDragOver={(e) => handleSomedayDragOver(e, list.id, slot.index)}
onDrop={(e) => handleSomedayDrop(e, list.id, slot.index)}
onDragLeave={() => setDropPreview(null)}
onMouseDown={(e) => {
e.stopPropagation();
if (!task) {
setActiveAddSlot({ listId: list.id, slotIdx: slot.index });
}
}}
style={{ minHeight: "24px", cursor: task ? "default" : "text" }}
>
{task ? (
<TaskItem
key={task.id}
task={task}
isEditing={editingTaskId === task.id}
onToggle={() => toggleTask(task.id)}
onEdit={() => setEditingTaskId(task.id)}
onUpdate={(title) => updateTask(task.id, title)}
onDelete={() => deleteTask(task.id)}
onNotes={() => setSelectedTaskForNotes(task)}
onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() => setSelectedTaskForRecurrence(task)}
onDragStart={(e, t) => handleDragStart(e, t)}
onDragEnd={handleDragEnd}
variant="minimal"
isSomeday={true}
onAddSubTask={addSubTask}
onToggleSubTask={toggleSubTask}
onDeleteSubTask={deleteSubTask}
onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes}
projects={projects}
onProjectAssign={assignProject}
/>
) : (
activeAddSlot?.listId === list.id && activeAddSlot?.slotIdx === slot.index && (
<SomedayAddTask
listId={list.id}
slotIdx={slot.index}
onAdd={async (title) => {
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
somedayListId: list.id,
somedaySlotIndex: slot.index
}),
});
if (res.ok) {
const data = await res.json();
const newTask = {
...data.task,
createdAt: new Date(data.task.createdAt),
updatedAt: new Date(data.task.updatedAt),
};
setSomedayLists((prev) =>
prev.map((l) =>
l.id === list.id
? { ...l, tasks: [...l.tasks, newTask] }
: l,
),
);
}
} catch (e) {
console.error(e);
}
setActiveAddSlot(null);
}}
onCancel={() => setActiveAddSlot(null)}
/>
)
)}
</div>
);
}
} catch (e) {
console.error(e);
}
}}
/>
})}
{remainingTasks.map((task) => (
<div key={task.id} className="task-list-slot">
<TaskItem
key={task.id}
task={task}
isEditing={editingTaskId === task.id}
onToggle={() => toggleTask(task.id)}
onEdit={() => setEditingTaskId(task.id)}
onUpdate={(title) => updateTask(task.id, title)}
onDelete={() => deleteTask(task.id)}
onNotes={() => setSelectedTaskForNotes(task)}
onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() => setSelectedTaskForRecurrence(task)}
onDragStart={(e, t) => handleDragStart(e, t)}
onDragEnd={handleDragEnd}
variant="minimal"
isSomeday={true}
onAddSubTask={addSubTask}
onToggleSubTask={toggleSubTask}
onDeleteSubTask={deleteSubTask}
onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes}
projects={projects}
onProjectAssign={assignProject}
/>
</div>
))}
</>
);
})()}
</div>
</div>
))}
@ -5893,7 +5994,10 @@ export default function WeeklyView() {
<label className="block text-xs font-black text-zinc-400 dark:text-zinc-500 mb-3 uppercase tracking-tighter">
Sync with External Provider (Optional)
</label>
<div className="grid grid-cols-4 gap-2">
<div
className="grid gap-2"
style={{ gridTemplateColumns: `repeat(${1 + (connections?.some(c => c.provider === "google") ? 1 : 0) + (connections?.some(c => c.provider === "outlook") ? 1 : 0) + (connections?.some(c => c.provider === "synology") ? 1 : 0)}, minmax(0, 1fr))` }}
>
<button
onClick={() => setSelectedSomedayProvider(null)}
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${!selectedSomedayProvider ? "border-zinc-900 bg-zinc-900 text-white" : "border-zinc-100 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600"}`}
@ -5905,78 +6009,51 @@ export default function WeeklyView() {
Local
</span>
</button>
<button
onClick={() =>
setSelectedSomedayProvider("google")
}
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "google" ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" : "border-zinc-100 dark:border-zinc-800 hover:border-blue-200 dark:hover:border-blue-800/40"}`}
>
<div className="w-6 h-6 flex items-center justify-center mb-1">
<svg
viewBox="0 0 24 24"
className="w-4 h-4"
fill="currentColor"
>
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.9 3.32-2.18 4.36-1.52 1.2-3.8 2.36-7.66 2.36-6.4 0-11.64-5.16-11.64-11.56S3.96 5.8 10.36 5.8c3.48 0 6.08 1.36 8 3.16l2.32-2.32C18.4 4.56 14.88 3 10.36 3 4.2 3 0 7.12 0 12.32S4.2 21.64 10.36 21.64c3.28 0 5.84-1.08 7.84-3.16 2.08-2.08 2.76-4.96 2.76-7.36 0-.72-.04-1.4-.16-2.2h-8.32z" />
</svg>
</div>
<span className="text-[10px] font-black uppercase">
Google
</span>
</button>
<button
onClick={() =>
setSelectedSomedayProvider("outlook")
}
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "outlook" ? "border-blue-600 bg-blue-100 dark:bg-blue-900/30" : "border-zinc-100 dark:border-zinc-800 hover:border-blue-300 dark:hover:border-blue-800/50"}`}
>
<div className="w-6 h-6 flex items-center justify-center mb-1">
<svg
viewBox="0 0 24 24"
className="w-4 h-4"
fill="currentColor"
>
<path
d="M1.5 6L11.5 1L21.5 6V18L11.5 23L1.5 18V6Z"
fill="#0078D4"
/>
<path
d="M11.5 12.5V23L21.5 18V6L11.5 12.5Z"
fill="#005A9E"
/>
<path
d="M11.5 12.5L1.5 6V18L11.5 23V12.5Z"
fill="#0078D4"
/>
<path
d="M16.5 7.5L6.5 2.5V14.5L16.5 19.5V7.5Z"
fill="#28A8EA"
/>
</svg>
</div>
<span className="text-[10px] font-black uppercase">
Outlook
</span>
</button>
<button
onClick={() =>
setSelectedSomedayProvider("apple")
}
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "apple" ? "border-zinc-900 bg-zinc-50 dark:bg-zinc-800" : "border-zinc-100 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600"}`}
>
<div className="w-6 h-6 flex items-center justify-center mb-1">
<svg
viewBox="0 0 24 24"
className="w-4 h-4"
fill="currentColor"
>
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.69 3.33 1.66-3.04 1.77-2.54 5.92.51 7.14-.58 1.51-1.32 3.03-2.45 4.21zM11.95 7.21c-.08-2.67 2.2-4.96 4.79-5.11.31 2.94-2.81 5.34-4.79 5.11z" />
</svg>
</div>
<span className="text-[10px] font-black uppercase">
iCloud
</span>
</button>
{connections?.some(c => c.provider === "google") && (
<button
onClick={() =>
setSelectedSomedayProvider("google")
}
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "google" ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" : "border-zinc-100 dark:border-zinc-800 hover:border-blue-200 dark:hover:border-blue-800/40"}`}
>
<div className="w-6 h-6 flex items-center justify-center mb-1">
<FontAwesomeIcon icon={faGoogle} className="w-4 h-4 text-[#4285F4]" />
</div>
<span className="text-[10px] font-black uppercase">
Google
</span>
</button>
)}
{connections?.some(c => c.provider === "outlook") && (
<button
onClick={() =>
setSelectedSomedayProvider("outlook")
}
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "outlook" ? "border-blue-600 bg-blue-100 dark:bg-blue-900/30" : "border-zinc-100 dark:border-zinc-800 hover:border-blue-300 dark:hover:border-blue-800/50"}`}
>
<div className="w-6 h-6 flex items-center justify-center mb-1">
<FontAwesomeIcon icon={faMicrosoft} className="w-4 h-4 text-[#00A4EF]" />
</div>
<span className="text-[10px] font-black uppercase">
Outlook
</span>
</button>
)}
{connections?.some(c => c.provider === "synology") && (
<button
onClick={() =>
setSelectedSomedayProvider("synology")
}
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "synology" ? "border-zinc-900 bg-zinc-50 dark:bg-zinc-800" : "border-zinc-100 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600"}`}
>
<div className="w-6 h-6 flex items-center justify-center mb-1">
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-500" />
</div>
<span className="text-[10px] font-black uppercase">
Synology
</span>
</button>
)}
</div>
</div>
@ -6488,9 +6565,13 @@ function TaskInput({ onAddTask, onDragOver, onDrop }: TaskInputProps) {
function SomedayAddTask({
listId,
onAdd,
onCancel,
slotIdx,
}: {
listId: string;
onAdd: (title: string) => void;
onCancel?: () => void;
slotIdx?: number;
}) {
const [title, setTitle] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
@ -6499,14 +6580,24 @@ function SomedayAddTask({
if (title.trim()) {
onAdd(title.trim());
setTitle("");
} else if (onCancel) {
onCancel();
}
};
useEffect(() => {
if (slotIdx !== undefined && inputRef.current) {
inputRef.current.focus();
}
}, [slotIdx]);
return (
<li
className="weekly-task-item minimal"
style={{
margin: "0 0.5rem",
margin: slotIdx !== undefined ? "0" : "0 0.5rem",
listStyle: "none",
width: "100%"
}}
>
<form
@ -6525,7 +6616,8 @@ function SomedayAddTask({
onKeyDown={(e) => {
if (e.key === "Escape") {
setTitle("");
e.currentTarget.blur();
if (onCancel) onCancel();
else e.currentTarget.blur();
}
}}
className="weekly-task-text"
@ -6534,13 +6626,13 @@ function SomedayAddTask({
border: "none",
background: "transparent",
padding: "0 0",
fontSize: "0.8rem",
fontSize: "0.9375rem",
outline: "none",
height: "24px",
display: "block", // Height match filler
height: slotIdx !== undefined ? "auto" : "24px",
display: "block",
}}
placeholder=""
data-someday-add-input={listId}
placeholder={slotIdx !== undefined ? "" : "Add task..."}
data-someday-add-input={slotIdx !== undefined ? undefined : listId}
/>
</form>
</li>
@ -6793,7 +6885,7 @@ function TaskItem({
)}
<div style={{ width: "100%", position: "relative", transform: swipeX !== 0 ? `translateX(${swipeX}px)` : undefined, transition: swipeX === 0 ? "transform 0.2s ease" : "none", background: "inherit" }}>
{/* Visual Indicator for Rolling Tasks */}
{task.isRolling && !task.completed && (
{task.isRolling && !task.completed && !isSomeday && (
<div className="rolling-icon-indicator" title="Auto-rolling task">
<svg
viewBox="0 0 24 24"
@ -8558,7 +8650,7 @@ function SettingsSidebar({
>
{([
{ key: "general", icon: <Settings size={18} />, label: t.general },
{ key: "calendar", icon: <Calendar size={18} />, label: t.calendar },
{ key: "calendar", icon: <Link size={18} />, label: t.calendar },
{ key: "account", icon: <User size={18} />, label: t.account },
{ key: "styling", icon: <Palette size={18} />, label: "Styling" },
{ key: "motivation", icon: <Sparkles size={18} />, label: "Motivation" },
@ -9393,12 +9485,12 @@ function SettingsSidebar({
>
<span>
{conn.provider === "google"
? "📅"
? <FontAwesomeIcon icon={faGoogle} />
: conn.provider === "apple"
? "🍎"
? <FontAwesomeIcon icon={faApple} />
: conn.provider === "synology"
? "💾"
: "📧"}
? <FontAwesomeIcon icon={faServer} />
: <FontAwesomeIcon icon={faMicrosoft} />}
</span>
{conn.provider === "google"
? "Google Calendar"
@ -9648,25 +9740,25 @@ function SettingsSidebar({
onClick={handleGoogleConnect}
className="calendar-connect-btn"
>
<span>📅</span> {t.connectGoogle}
<FontAwesomeIcon icon={faGoogle} className="mr-2" /> {t.connectGoogle}
</button>
<button
onClick={handleAppleCalendarConnect}
className="calendar-connect-btn"
>
<span>🍎</span> {t.connectApple}
<FontAwesomeIcon icon={faApple} className="mr-2" /> {t.connectApple}
</button>
<button
onClick={handleOutlookConnect}
className="calendar-connect-btn"
>
<span>📧</span> Connect Outlook
<FontAwesomeIcon icon={faMicrosoft} className="mr-2" /> Connect Outlook
</button>
<button
onClick={handleSynologyCalendarConnect}
className="calendar-connect-btn"
>
<span>💾</span> {t.connectSynology || "Connect Synology"}
<FontAwesomeIcon icon={faServer} className="mr-2" /> {t.connectSynology || "Connect Synology"}
</button>
</div>
@ -9721,7 +9813,7 @@ function SettingsSidebar({
}}
>
<span>
{conn.provider === "google" ? "📅" : conn.provider === "synology" ? "💾" : "📧"}
{conn.provider === "google" ? <FontAwesomeIcon icon={faGoogle} /> : conn.provider === "synology" ? <FontAwesomeIcon icon={faServer} /> : <FontAwesomeIcon icon={faMicrosoft} />}
</span>
{conn.provider === "google"
? "Google Tasks"

View File

@ -58,14 +58,17 @@ export const fetchSynologyReminderLists = async (serverUrl: string, username: st
*/
export const createSynologyReminderList = async (serverUrl: string, username: string, password: string, title: string): Promise<SynologyReminderList> => {
try {
console.log('[SYNOLOGY] Creating reminder list:', title, 'at', serverUrl);
const client = createClient(serverUrl, username, password);
await client.login();
const calendars = await client.fetchCalendars();
if (calendars.length === 0) {
console.error('[SYNOLOGY] No calendars found to derive base URL');
throw new Error('No existing Synology calendars found to derive base URL');
}
// Deriving the base URL for new collections
const firstCalUrl = calendars[0].url;
const urlParts = firstCalUrl.split('/');
if (firstCalUrl.endsWith('/')) urlParts.pop();
@ -74,23 +77,29 @@ export const createSynologyReminderList = async (serverUrl: string, username: st
const newId = crypto.randomUUID();
const newCalendarUrl = `${baseWebDavUrl}${newId}/`;
console.log('[SYNOLOGY] Proposed new calendar URL:', newCalendarUrl);
// Create the calendar collection with explicit namespaces if possible
// Synology requires specific properties to recognize it as a Task list
await (client as any).makeCalendar({
url: newCalendarUrl,
props: {
'displayname': title,
'calendar-description': 'Created by My Weekly ToDo List',
'calendar-color': '#007AFF',
'supported-calendar-component-set': {
'comp': { _attributes: { name: 'VTODO' } }
}
}
});
console.log('[SYNOLOGY] List created successfully');
return {
id: newCalendarUrl,
title: title,
};
} catch (error) {
console.error('Error creating Synology reminder list:', error);
console.error('[SYNOLOGY] Error creating reminder list:', error);
throw error;
}
};