feat: add someday list tabs and side navigation arrows

Add tab system for someday lists allowing categorization (e.g. Private,
Work, Family). Lists can be assigned to tabs via tag icon dropdown in
list headers. Tabs appear in the someday label column for filtering.
Double-click tab name to rename. Tabs persist in DB via new SomedayList.tab field.

Also add hover-overlay prev/next day/week navigation arrows on left and
right sides of the weekly grid, and fix first hour label clipping.

i18n: tab translations for EN, DE, FR, ES, IT.

v1.25.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-10 21:15:46 +01:00
parent fd2d4e5ed3
commit 3016293cd9
6 changed files with 536 additions and 161 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.24.0", "version": "1.25.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -0,0 +1,2 @@
-- AlterTable: Add tab field to SomedayList
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "tab" TEXT;

View File

@ -195,6 +195,7 @@ model SomedayList {
userId String userId String
title String title String
order Int @default(0) order Int @default(0)
tab String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
externalId String? externalId String?

View File

@ -185,12 +185,12 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
// Handle Single Update (Title) // Handle Single Update (Title and/or Tab)
const { id, title } = body; const { id, title, tab } = body;
if (!id || !title) { if (!id) {
return NextResponse.json( return NextResponse.json(
{ error: 'ID and Title are required' }, { error: 'ID is required' },
{ status: 400 } { status: 400 }
); );
} }
@ -207,9 +207,13 @@ export async function PATCH(request: NextRequest) {
); );
} }
const data: Record<string, any> = {};
if (title !== undefined) data.title = title;
if (tab !== undefined) data.tab = tab;
const list = await prisma.somedayList.update({ const list = await prisma.somedayList.update({
where: { id }, where: { id },
data: { title } data
}); });
return NextResponse.json({ list }); return NextResponse.json({ list });

View File

@ -1443,6 +1443,7 @@ h3 {
min-height: 56px; min-height: 56px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px;
border-bottom: 1px solid var(--weekly-border); border-bottom: 1px solid var(--weekly-border);
} }
@ -1492,6 +1493,72 @@ h3 {
color: #666; color: #666;
} }
/* Someday Tab Buttons (vertical in label column) */
.someday-tab-btn {
background: none;
border: none;
cursor: pointer;
color: var(--weekly-text-light, #999);
font-size: 0.45rem;
font-weight: 500;
padding: 3px 4px;
border-radius: 3px;
text-align: center;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
transition: background 0.15s, color 0.15s;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.someday-tab-btn:hover {
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.06));
color: var(--weekly-text, #333);
}
.someday-tab-btn.active {
background: var(--weekly-accent, #6366f1);
color: #fff;
}
/* Someday Tab Buttons (horizontal in non-time-grid) */
.someday-tab-btn-h {
background: none;
border: none;
cursor: pointer;
color: var(--weekly-text-light, #999);
font-size: 0.6rem;
font-weight: 500;
padding: 2px 8px;
border-radius: 3px;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.someday-tab-btn-h:hover {
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.06));
color: var(--weekly-text, #333);
}
.someday-tab-btn-h.active {
background: var(--weekly-accent, #6366f1);
color: #fff;
}
/* Someday Tab Select (in list headers) */
.someday-tab-select {
appearance: none;
-webkit-appearance: none;
outline: none;
}
.someday-tab-select:hover {
color: var(--weekly-text, #333);
}
/* Preferences Slide-in Panel */ /* Preferences Slide-in Panel */
.preferences-panel { .preferences-panel {
position: fixed; position: fixed;
@ -1857,6 +1924,11 @@ h3 {
.time-slot { .time-slot {
min-height: 28px; min-height: 28px;
} }
/* Hide side nav on mobile (swipe navigation is used instead) */
.side-nav {
display: none;
}
} }
/* === Phone portrait (≤ 480px): 1-day view === */ /* === Phone portrait (≤ 480px): 1-day view === */
@ -1927,6 +1999,55 @@ h3 {
display: flex; display: flex;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
position: relative;
}
/* Side Navigation Arrows (hover overlays) */
.side-nav {
position: absolute;
top: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.35rem 0.15rem;
gap: 0.1rem;
z-index: 30;
}
.side-nav .side-nav-btn {
opacity: 0;
transition: opacity 0.2s, background 0.15s, color 0.15s;
}
.side-nav:hover .side-nav-btn {
opacity: 1;
}
.side-nav-left {
left: 0;
}
.side-nav-right {
right: 0;
}
.side-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: transparent;
color: var(--weekly-text-light, #999);
cursor: pointer;
border-radius: 6px;
}
.side-nav-btn:hover {
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.06));
color: var(--weekly-text, #333);
} }
/* Time Column (Hours) */ /* Time Column (Hours) */
@ -1949,6 +2070,7 @@ h3 {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
position: relative; position: relative;
padding-top: 0.4rem;
} }

View File

@ -59,6 +59,7 @@ import {
Cable, Cable,
Link, Link,
Globe, Globe,
Tag,
} from "lucide-react"; } from "lucide-react";
// Types // Types
@ -144,6 +145,7 @@ interface SomedayList {
id: string; id: string;
title: string; title: string;
tasks: Task[]; tasks: Task[];
tab?: string | null;
externalProvider?: string | null; externalProvider?: string | null;
externalId?: string | null; externalId?: string | null;
externalListId?: string | null; externalListId?: string | null;
@ -254,6 +256,11 @@ const translations: Record<string, any> = {
confirmPassword: "Confirm Password", confirmPassword: "Confirm Password",
someday: "SOMEDAY", someday: "SOMEDAY",
lists: "Lists", lists: "Lists",
allTabs: "All",
newTab: "New tab",
newTabName: "New tab name:",
assignTab: "Assign to tab",
renameTab: "Double-click to rename",
loading: "Loading your tasks...", loading: "Loading your tasks...",
sycing: "Syncing...", sycing: "Syncing...",
synced: "Synced", synced: "Synced",
@ -436,6 +443,11 @@ const translations: Record<string, any> = {
confirmPassword: "Passwort bestätigen", confirmPassword: "Passwort bestätigen",
someday: "IRGENDWANN", someday: "IRGENDWANN",
lists: "Listen", lists: "Listen",
allTabs: "Alle",
newTab: "Neuer Tab",
newTabName: "Neuer Tab-Name:",
assignTab: "Tab zuweisen",
renameTab: "Doppelklick zum Umbenennen",
loading: "Lade Aufgaben...", loading: "Lade Aufgaben...",
syncing: "Synchronisiere...", syncing: "Synchronisiere...",
synced: "Synchronisiert", synced: "Synchronisiert",
@ -617,6 +629,11 @@ const translations: Record<string, any> = {
confirmPassword: "Confirmer le mot de passe", confirmPassword: "Confirmer le mot de passe",
someday: "UN JOUR", someday: "UN JOUR",
lists: "Listes", lists: "Listes",
allTabs: "Tous",
newTab: "Nouvel onglet",
newTabName: "Nom du nouvel onglet :",
assignTab: "Assigner à un onglet",
renameTab: "Double-cliquez pour renommer",
loading: "Chargement de vos tâches…", loading: "Chargement de vos tâches…",
sycing: "Synchronisation…", sycing: "Synchronisation…",
synced: "Synchronisé", synced: "Synchronisé",
@ -798,6 +815,11 @@ const translations: Record<string, any> = {
confirmPassword: "Confirmar contraseña", confirmPassword: "Confirmar contraseña",
someday: "ALGÚN DÍA", someday: "ALGÚN DÍA",
lists: "Listas", lists: "Listas",
allTabs: "Todas",
newTab: "Nueva pestaña",
newTabName: "Nombre de nueva pestaña:",
assignTab: "Asignar a pestaña",
renameTab: "Doble clic para renombrar",
loading: "Cargando tus tareas…", loading: "Cargando tus tareas…",
sycing: "Sincronizando…", sycing: "Sincronizando…",
synced: "Sincronizado", synced: "Sincronizado",
@ -979,6 +1001,11 @@ const translations: Record<string, any> = {
confirmPassword: "Conferma password", confirmPassword: "Conferma password",
someday: "UN GIORNO", someday: "UN GIORNO",
lists: "Liste", lists: "Liste",
allTabs: "Tutte",
newTab: "Nuova scheda",
newTabName: "Nome nuova scheda:",
assignTab: "Assegna a scheda",
renameTab: "Doppio clic per rinominare",
loading: "Caricamento delle attività…", loading: "Caricamento delle attività…",
sycing: "Sincronizzazione…", sycing: "Sincronizzazione…",
synced: "Sincronizzato", synced: "Sincronizzato",
@ -1398,6 +1425,64 @@ export default function WeeklyView() {
const [projects, setProjects] = useState<{ id: string; name: string; icon?: string | null; color?: string | null }[]>([]); const [projects, setProjects] = useState<{ id: string; name: string; icon?: string | null; color?: string | null }[]>([]);
const [editingTaskId, setEditingTaskId] = useState<string | null>(null); const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const [draggingListId, setDraggingListId] = useState<string | null>(null); const [draggingListId, setDraggingListId] = useState<string | null>(null);
const [listToDelete, setListToDelete] = useState<string | null>(null);
const [activeSomedayTab, setActiveSomedayTab] = useState<string | null>(null);
const [editingTabName, setEditingTabName] = useState<string | null>(null);
const [renamingTabValue, setRenamingTabValue] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("weekly_active_someday_tab");
if (saved) setActiveSomedayTab(saved === "__all__" ? null : saved);
}
}, []);
const somedayTabs = useMemo(() => {
const tabs = new Set<string>();
somedayLists.forEach(l => { if (l.tab) tabs.add(l.tab); });
return Array.from(tabs).sort();
}, [somedayLists]);
const setSomedayTab = (tab: string | null) => {
setActiveSomedayTab(tab);
localStorage.setItem("weekly_active_someday_tab", tab ?? "__all__");
};
const assignListToTab = async (listId: string, tab: string | null) => {
setSomedayLists(prev => prev.map(l => l.id === listId ? { ...l, tab } : l));
try {
await fetch("/api/someday-lists", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: listId, tab }),
});
} catch (e) {
console.error("Failed to update list tab:", e);
}
};
const renameTab = async (oldName: string, newName: string) => {
if (!newName.trim() || newName === oldName) return;
const listsToUpdate = somedayLists.filter(l => l.tab === oldName);
setSomedayLists(prev => prev.map(l => l.tab === oldName ? { ...l, tab: newName.trim() } : l));
if (activeSomedayTab === oldName) setSomedayTab(newName.trim());
for (const list of listsToUpdate) {
try {
await fetch("/api/someday-lists", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: list.id, tab: newName.trim() }),
});
} catch (e) {
console.error("Failed to rename tab for list:", e);
}
}
};
const filteredSomedayLists = useMemo(() => {
if (activeSomedayTab === null) return somedayLists;
return somedayLists.filter(l => (l.tab || null) === activeSomedayTab);
}, [somedayLists, activeSomedayTab]);
const [dropTargetListIndex, setDropTargetListIndex] = useState<number | null>(null); const [dropTargetListIndex, setDropTargetListIndex] = useState<number | null>(null);
const [activeAddSlot, setActiveAddSlot] = useState<{ listId: string; slotIdx: number } | null>(null); const [activeAddSlot, setActiveAddSlot] = useState<{ listId: string; slotIdx: number } | null>(null);
const isDragFromHandle = useRef(false); const isDragFromHandle = useRef(false);
@ -5142,7 +5227,7 @@ export default function WeeklyView() {
)} )}
{/* Desktop Header: Left, Center, Right */} {/* Desktop Header: Left, Center, Right */}
<header className="group flex items-center justify-between w-full px-4 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white transition-colors duration-200" style={isMobile ? { display: "none" } : {}}> <header className="group relative flex items-center justify-between w-full px-4 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white transition-colors duration-200" style={isMobile ? { display: "none" } : {}}>
{/* LEFT SECTION: Slot Duration & Days to Show */} {/* LEFT SECTION: Slot Duration & Days to Show */}
<div className="weekly-header-controls flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}> <div className="weekly-header-controls flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
{/* Slot Duration */} {/* Slot Duration */}
@ -5235,51 +5320,81 @@ export default function WeeklyView() {
</div> </div>
{/* CENTER SECTION: Week/Year, Goal, Focus Mode - Reveal on Hover */} {/* CENTER SECTION: Week/Year, Goal, Focus Mode - Reveal on Hover */}
<div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2 group" style={{ zIndex: 0 }}> <div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2 group z-10 opacity-100" style={{ pointerEvents: "auto" }}>
{/* Week & Year */} {/* Week & Year */}
<div className="whitespace-nowrap flex items-center gap-2"> <div className="whitespace-nowrap flex items-center gap-2">
{/* Date Picker Toggle - Moved to front */}
<div className="relative">
<button
ref={datePickerBtnRef}
className={`p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors ${showDatePicker ? "text-teal-600 bg-teal-50 opacity-100" : "text-gray-500 hover:text-black opacity-0 group-hover:opacity-100"}`}
onClick={() => setShowDatePicker(!showDatePicker)}
title="Jump to date"
>
<Calendar size={18} />
</button>
{showDatePicker && (
<SimpleDatePicker
selected={currentWeekStart}
onSelect={(date) => {
setCurrentWeekStart(getStartOfWeek(date));
setShowDatePicker(false);
}}
onClose={() => setShowDatePicker(false)}
language={language}
anchorRef={datePickerBtnRef}
/>
)}
</div>
{/* Clickable Week & Year */}
<div
className="flex items-center gap-2 cursor-pointer hover:opacity-80"
onClick={() => setShowDatePicker(!showDatePicker)}
title="Jump to date"
>
<span style={{
fontFamily: profile.cwFontFamily || "Inter",
fontSize: profile.cwFontSize || "1.125rem",
fontWeight: Number(profile.cwFontWeight || "700"),
color: adjustColorForDarkMode(profile.cwColor || "#333333", darkMode),
filter: "brightness(var(--weekly-header-brightness, 1))"
}}>
KW {getWeekNumber(getCWReferenceDate(getVisibleDays())).toString().padStart(2, "0")}
</span>
<span className="text-gray-400">|</span>
<span
style={{
fontFamily: profile.yearFontFamily || "Inter",
fontSize: profile.yearFontSize || "1.125rem",
fontWeight: Number(profile.yearFontWeight || "700"),
color: adjustColorForDarkMode(profile.yearColor || "#333333", darkMode),
filter: "brightness(var(--weekly-header-brightness, 1))"
}}
>
{currentWeekStart.getFullYear()}
</span>
</div>
</div>
{/* Goal */}
<div className="flex items-center text-sm">
{syncError ? ( {syncError ? (
<div className="flex items-center gap-1 text-red-500" title={syncError}> <div className="flex items-center gap-1 text-red-500 mr-2" title={syncError}>
<AlertCircle size={14} /> <AlertCircle size={14} />
<span className="text-xs">{syncError}</span> <span className="text-xs">{syncError}</span>
</div> </div>
) : (isLoading || isSyncing || syncStatus === "syncing") ? ( ) : (isLoading || isSyncing || syncStatus === "syncing") ? (
<div className="weekly-spinner" title="Syncing..."></div> <div className="weekly-spinner mr-2" title="Syncing..."></div>
) : ( ) : (
<button <button
onClick={() => { fetchCalendarEvents(true); fetchTasks(); }} onClick={() => { fetchCalendarEvents(true); fetchTasks(); }}
className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100" className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 mr-1"
title="Refresh Calendar & Tasks" title="Refresh Calendar & Tasks"
> >
<RefreshCcw size={14} /> <RefreshCcw size={14} />
</button> </button>
)} )}
<span style={{
fontFamily: profile.cwFontFamily || "Inter",
fontSize: profile.cwFontSize || "1.125rem",
fontWeight: Number(profile.cwFontWeight || "700"),
color: adjustColorForDarkMode(profile.cwColor || "#333333", darkMode),
filter: "brightness(var(--weekly-header-brightness, 1))"
}}>
KW {getWeekNumber(getCWReferenceDate(getVisibleDays())).toString().padStart(2, "0")}
</span>
<span className="text-gray-400">|</span>
<span
style={{
fontFamily: profile.yearFontFamily || "Inter",
fontSize: profile.yearFontSize || "1.125rem",
fontWeight: Number(profile.yearFontWeight || "700"),
color: adjustColorForDarkMode(profile.yearColor || "#333333", darkMode),
filter: "brightness(var(--weekly-header-brightness, 1))"
}}
>
{currentWeekStart.getFullYear()}
</span>
</div>
{/* Goal */}
<div className="flex items-center text-sm">
<span className="text-gray-300 mx-2">-</span>
{isEditingGoal ? ( {isEditingGoal ? (
<input <input
type="text" type="text"
@ -5314,7 +5429,7 @@ export default function WeeklyView() {
fontWeight: profile.goalFontWeight || undefined, fontWeight: profile.goalFontWeight || undefined,
color: adjustColorForDarkMode((profile.goalFallbackType === "quote" ? profile.taskColor : undefined) || "#333333", darkMode), color: adjustColorForDarkMode((profile.goalFallbackType === "quote" ? profile.taskColor : undefined) || "#333333", darkMode),
filter: "brightness(var(--weekly-goal-brightness, 1))", filter: "brightness(var(--weekly-goal-brightness, 1))",
maxWidth: "500px", maxWidth: "800px",
textAlign: "center" as const, textAlign: "center" as const,
overflow: "hidden", overflow: "hidden",
display: "-webkit-box", display: "-webkit-box",
@ -5354,7 +5469,6 @@ export default function WeeklyView() {
: (goal || (profile.goalFallbackType === "quote" ? motivationalQuote : goal))} : (goal || (profile.goalFallbackType === "quote" ? motivationalQuote : goal))}
</span> </span>
)} )}
<span className="text-gray-300 mx-2">-</span>
</div> </div>
</div> </div>
@ -5476,29 +5590,6 @@ export default function WeeklyView() {
</button> </button>
</div> </div>
{/* Date Picker Toggle */}
<div>
<button
ref={datePickerBtnRef}
className={`p-1.5 hover:bg-gray-100 rounded-md transition-colors ${showDatePicker ? "text-teal-600 bg-teal-50" : "text-gray-500 hover:text-black"}`}
onClick={() => setShowDatePicker(!showDatePicker)}
title="Jump to date"
>
<Calendar size={18} />
</button>
{showDatePicker && (
<SimpleDatePicker
selected={currentWeekStart}
onSelect={(date) => {
setCurrentWeekStart(getStartOfWeek(date));
setShowDatePicker(false);
}}
onClose={() => setShowDatePicker(false)}
language={language}
anchorRef={datePickerBtnRef}
/>
)}
</div>
{/* Search */} {/* Search */}
<button <button
@ -5548,6 +5639,15 @@ export default function WeeklyView() {
{/* Main Grid with Time Column */} {/* Main Grid with Time Column */}
<div className="time-grid-wrapper"> <div className="time-grid-wrapper">
{/* Side Navigation Arrows (hover overlays) */}
<div className="side-nav side-nav-left">
<button onClick={goToPrevDay} title="Previous Day" className="side-nav-btn">
<ChevronLeft size={16} />
</button>
<button onClick={goToPrevWeek} title="Previous Week" className="side-nav-btn">
<ChevronsLeft size={16} />
</button>
</div>
{/* Time Column */} {/* Time Column */}
{showTimeGrid && ( {showTimeGrid && (
<div className="time-column"> <div className="time-column">
@ -6139,6 +6239,16 @@ export default function WeeklyView() {
); );
})} })}
</main> </main>
{/* Right Navigation Arrows (after grid so it paints on top) */}
<div className="side-nav side-nav-right">
<button onClick={goToNextDay} title="Next Day" className="side-nav-btn">
<ChevronLeft size={16} className="rotate-180" />
</button>
<button onClick={goToNextWeek} title="Next Week" className="side-nav-btn">
<ChevronsLeft size={16} className="rotate-180" />
</button>
</div>
</div> </div>
{/* All-Day Events Section (below position) */} {/* All-Day Events Section (below position) */}
@ -6227,6 +6337,69 @@ export default function WeeklyView() {
> >
+ +
</button> </button>
{/* Someday Tabs */}
{somedayTabs.length > 0 && (
<div className="someday-tabs" style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "1px",
width: "100%",
marginTop: "4px",
borderTop: "1px solid var(--weekly-border)",
paddingTop: "4px",
}}>
<button
className={`someday-tab-btn ${activeSomedayTab === null ? "active" : ""}`}
onClick={() => setSomedayTab(null)}
title={t.allTabs}
>
{t.allTabs}
</button>
{somedayTabs.map(tab => (
editingTabName === tab ? (
<input
key={tab}
className="someday-tab-rename-input"
value={renamingTabValue}
onChange={(e) => setRenamingTabValue(e.target.value)}
onBlur={() => {
renameTab(tab, renamingTabValue);
setEditingTabName(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") setEditingTabName(null);
}}
autoFocus
style={{
width: "100%",
fontSize: "0.5rem",
textAlign: "center",
border: "1px solid var(--weekly-border)",
borderRadius: "3px",
padding: "2px",
background: "var(--weekly-bg)",
color: "var(--weekly-text)",
}}
/>
) : (
<button
key={tab}
className={`someday-tab-btn ${activeSomedayTab === tab ? "active" : ""}`}
onClick={() => setSomedayTab(tab)}
onDoubleClick={() => {
setEditingTabName(tab);
setRenamingTabValue(tab);
}}
title={t.renameTab}
>
{tab}
</button>
)
))}
</div>
)}
</div> </div>
)} )}
{!showTimeGrid && ( {!showTimeGrid && (
@ -6290,6 +6463,26 @@ export default function WeeklyView() {
> >
+ +
</button> </button>
{/* Horizontal tabs for non-time-grid */}
{somedayTabs.length > 0 && (
<>
<button
className={`someday-tab-btn-h ${activeSomedayTab === null ? "active" : ""}`}
onClick={() => setSomedayTab(null)}
>{t.allTabs}</button>
{somedayTabs.map(tab => (
<button
key={tab}
className={`someday-tab-btn-h ${activeSomedayTab === tab ? "active" : ""}`}
onClick={() => setSomedayTab(tab)}
onDoubleClick={() => {
setEditingTabName(tab);
setRenamingTabValue(tab);
}}
>{tab}</button>
))}
</>
)}
</div> </div>
)} )}
<div ref={somedayGridRef} style={{ flex: 1, minWidth: 0, overflowX: "auto" }}> <div ref={somedayGridRef} style={{ flex: 1, minWidth: 0, overflowX: "auto" }}>
@ -6305,10 +6498,10 @@ export default function WeeklyView() {
}} }}
> >
{(() => { {(() => {
const baseLists = somedayLists.length > 0 const baseLists = filteredSomedayLists.length > 0
? somedayLists ? filteredSomedayLists
: [{ id: "default", title: "LISTE", tasks: [] as Task[] }]; : [{ id: "default", title: "LISTE", tasks: [] as Task[] }];
return baseLists.slice(0, Math.max(somedayLists.length, viewDays)); return baseLists.slice(0, Math.max(filteredSomedayLists.length, viewDays));
})() })()
.flatMap((list, listIdx, arr) => { .flatMap((list, listIdx, arr) => {
const indicator = draggingListId && dropTargetListIndex === listIdx && draggingListId !== list.id ? ( const indicator = draggingListId && dropTargetListIndex === listIdx && draggingListId !== list.id ? (
@ -6465,110 +6658,162 @@ export default function WeeklyView() {
alignItems: "center", alignItems: "center",
}} }}
> >
<div {listToDelete === list.id ? (
className="someday-drag-handle" <div style={{ display: "flex", flexDirection: "column", width: "100%", gap: "8px", padding: "4px" }}>
title="Drag to reorder" <span style={{ fontSize: "0.9rem", fontWeight: "bold" }}>Delete this list?</span>
onMouseDown={() => { isDragFromHandle.current = true; }} {list.externalProvider && <span style={{ fontSize: "0.75rem", color: "#888" }}>Note: This list is not deleted from {list.externalProvider}, just from this view.</span>}
onMouseUp={() => { isDragFromHandle.current = false; }} <div style={{ display: "flex", gap: "8px", marginTop: "4px" }}>
> <button onClick={(e) => {
<GripVertical size={14} /> e.stopPropagation();
</div> setListToDelete(null);
<input }} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#eee", color: "#333", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>Cancel</button>
type="text" <button onClick={async (e) => {
defaultValue={list.title} e.stopPropagation();
className="weekly-someday-list-title-input dark:bg-transparent dark:text-white" try {
onBlur={async (e) => { await fetch(`/api/someday-lists?id=${list.id}`, { method: "DELETE" });
const newTitle = e.target.value.trim(); setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
if (newTitle && newTitle !== list.title) { setListToDelete(null);
try { } catch (err) { console.error(err); }
await fetch("/api/someday-lists", { }} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#dc2626", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>Delete</button>
method: "PATCH", </div>
headers: { </div>
"Content-Type": "application/json", ) : (
}, <>
body: JSON.stringify({ <div
id: list.id, className="someday-drag-handle"
title: newTitle, title="Drag to reorder"
}), onMouseDown={() => { isDragFromHandle.current = true; }}
}); onMouseUp={() => { isDragFromHandle.current = false; }}
setSomedayLists((prev) => >
prev.map((l) => <GripVertical size={14} />
l.id === list.id </div>
? { ...l, title: newTitle } <input
: l, type="text"
), defaultValue={list.title}
); className="weekly-someday-list-title-input dark:bg-transparent dark:text-white"
} catch (err) { onBlur={async (e) => {
console.error(err); const newTitle = e.target.value.trim();
e.target.value = list.title; if (newTitle && newTitle !== list.title) {
} try {
} await fetch("/api/someday-lists", {
}} method: "PATCH",
onKeyDown={(e) => { headers: {
if (e.key === "Enter") e.currentTarget.blur(); "Content-Type": "application/json",
}} },
/> body: JSON.stringify({
{list.externalProvider && ( id: list.id,
<span title: newTitle,
title={`Synced with ${list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider}`} }),
style={{ display: "inline-flex", alignItems: "center", marginLeft: "8px", opacity: 0.8, flexShrink: 0 }} });
> setSomedayLists((prev) =>
{list.externalProvider === "outlook" ? ( prev.map((l) =>
<FontAwesomeIcon icon={faMicrosoft} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#0078D4] dark:hover:text-[#00A4EF] transition-colors" /> l.id === list.id
) : list.externalProvider === "google" ? ( ? { ...l, title: newTitle }
<FontAwesomeIcon icon={faGoogle} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#4285F4] dark:hover:text-[#8AB4F8] transition-colors" /> : l,
) : list.externalProvider === "apple" ? ( ),
<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" ? ( } catch (err) {
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#007AFF] dark:hover:text-[#3A9CFF] transition-colors" /> console.error(err);
) : ( e.target.value = list.title;
<RefreshCcw size={14} className="text-zinc-400" /> }
)} }
</span> }}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
/>
{list.externalProvider && (() => {
const providerUrl = list.externalProvider === "google" ? "https://tasks.google.com/tasks/" :
list.externalProvider === "outlook" ? "https://to-do.live.com/tasks/" :
list.externalProvider === "apple" ? "https://www.icloud.com/reminders/" : null;
const iconContent = (
<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: "8px", opacity: 0.8, flexShrink: 0, cursor: providerUrl ? "pointer" : "default" }}
>
{list.externalProvider === "outlook" ? (
<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" ? (
<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" ? (
<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={14} className="text-zinc-400" />
)}
</span>
);
return providerUrl ? (
<a href={providerUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
{iconContent}
</a>
) : iconContent;
})()}
<div className="someday-tab-assign" style={{ marginLeft: "auto", position: "relative", display: "flex", alignItems: "center" }}>
<Tag size={13} style={{ color: list.tab ? "var(--weekly-accent, #6366f1)" : "#bbb", flexShrink: 0 }} />
<select
className="someday-tab-select"
value={list.tab || ""}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const val = e.target.value;
if (val === "__new__") {
const name = prompt(t.newTabName || "New tab name:");
if (name?.trim()) assignListToTab(list.id, name.trim());
} else {
assignListToTab(list.id, val || null);
}
}}
style={{
position: "absolute",
inset: 0,
opacity: 0,
cursor: "pointer",
width: "100%",
}}
title={t.assignTab || "Assign to tab"}
>
<option value=""></option>
{somedayTabs.map(tab => (
<option key={tab} value={tab}>{tab}</option>
))}
<option value="__new__">+ {t.newTab || "New tab"}</option>
</select>
</div>
<button
className="someday-list-delete-btn"
onClick={(e) => {
e.stopPropagation();
setListToDelete(list.id);
}}
style={{
border: "none",
background: "none",
cursor: "pointer",
color: "#ccc",
marginLeft: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "4px"
}}
title="Delete List"
>
<Trash2 size={16} />
</button>
</>
)} )}
<button
className="someday-list-delete-btn"
onClick={async (e) => {
e.stopPropagation();
if (confirm("Delete this list?")) {
try {
await fetch(
`/api/someday-lists?id=${list.id}`,
{ method: "DELETE" },
);
setSomedayLists((prev) =>
prev.filter((l) => l.id !== list.id),
);
} catch (err) {
console.error(err);
}
}
}}
style={{
border: "none",
background: "none",
cursor: "pointer",
fontSize: "1rem",
color: "#ccc",
marginLeft: "auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
title="Delete List"
>
×
</button>
</div> </div>
<div <div
className="weekly-task-list" className="weekly-task-list"
style={{ style={{
flex: 1, flex: 1,
display: "flex",
flexDirection: "column", flexDirection: "column",
justifyContent: "flex-start", justifyContent: "flex-start",
position: "relative", position: "relative",
display: "flex",
}} }}
> >
{(() => { {(() => {
@ -9165,7 +9410,7 @@ function SettingsSidebar({
setShowAppleCalendarModal(false); setShowAppleCalendarModal(false);
showConnMsg("success", "Apple Calendar connected successfully!"); showConnMsg("success", "Apple Calendar connected successfully!");
setTimeout(() => window.location.reload(), 1200); setTimeout(() => { window.location.href = window.location.pathname + "?calendar=apple_connected&openSettings=calendars"; }, 1200);
} catch (err: any) { } catch (err: any) {
setAppleCalError(err.message || "Connection failed"); setAppleCalError(err.message || "Connection failed");
} finally { } finally {
@ -9210,7 +9455,7 @@ function SettingsSidebar({
setShowSynologyCalendarModal(false); setShowSynologyCalendarModal(false);
showConnMsg("success", "Synology Calendar connected successfully!"); showConnMsg("success", "Synology Calendar connected successfully!");
setTimeout(() => window.location.reload(), 1200); setTimeout(() => { window.location.href = window.location.pathname + "?calendar=synology_connected&openSettings=calendars"; }, 1200);
} catch (err: any) { } catch (err: any) {
setSynologyCalError(err.message || "Connection failed"); setSynologyCalError(err.message || "Connection failed");
} finally { } finally {
@ -9498,6 +9743,7 @@ function SettingsSidebar({
style={{ style={{
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
flexWrap: "wrap",
gap: "4px", gap: "4px",
borderBottom: "1px solid var(--weekly-border, #eee)", borderBottom: "1px solid var(--weekly-border, #eee)",
padding: "0 24px", padding: "0 24px",