From 3016293cd942a639d75c4434c922619930563a5d Mon Sep 17 00:00:00 2001 From: mARTin Date: Tue, 10 Mar 2026 21:15:46 +0100 Subject: [PATCH] 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 --- package.json | 2 +- .../20260310_add_someday_tab/migration.sql | 2 + prisma/schema.prisma | 1 + src/app/api/someday-lists/route.ts | 14 +- src/app/globals.css | 122 ++++ src/components/WeeklyView.tsx | 556 +++++++++++++----- 6 files changed, 536 insertions(+), 161 deletions(-) create mode 100644 prisma/migrations/20260310_add_someday_tab/migration.sql diff --git a/package.json b/package.json index 1d985b0..b824823 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "main": "index.js", "scripts": { diff --git a/prisma/migrations/20260310_add_someday_tab/migration.sql b/prisma/migrations/20260310_add_someday_tab/migration.sql new file mode 100644 index 0000000..c341828 --- /dev/null +++ b/prisma/migrations/20260310_add_someday_tab/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable: Add tab field to SomedayList +ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "tab" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6e37dd6..dd71248 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -195,6 +195,7 @@ model SomedayList { userId String title String order Int @default(0) + tab String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt externalId String? diff --git a/src/app/api/someday-lists/route.ts b/src/app/api/someday-lists/route.ts index 9fbfe60..a9632cf 100644 --- a/src/app/api/someday-lists/route.ts +++ b/src/app/api/someday-lists/route.ts @@ -185,12 +185,12 @@ export async function PATCH(request: NextRequest) { return NextResponse.json({ success: true }); } - // Handle Single Update (Title) - const { id, title } = body; + // Handle Single Update (Title and/or Tab) + const { id, title, tab } = body; - if (!id || !title) { + if (!id) { return NextResponse.json( - { error: 'ID and Title are required' }, + { error: 'ID is required' }, { status: 400 } ); } @@ -207,9 +207,13 @@ export async function PATCH(request: NextRequest) { ); } + const data: Record = {}; + if (title !== undefined) data.title = title; + if (tab !== undefined) data.tab = tab; + const list = await prisma.somedayList.update({ where: { id }, - data: { title } + data }); return NextResponse.json({ list }); diff --git a/src/app/globals.css b/src/app/globals.css index 4eed357..5b25730 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1443,6 +1443,7 @@ h3 { min-height: 56px; display: flex; align-items: center; + gap: 6px; border-bottom: 1px solid var(--weekly-border); } @@ -1492,6 +1493,72 @@ h3 { 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-panel { position: fixed; @@ -1857,6 +1924,11 @@ h3 { .time-slot { min-height: 28px; } + + /* Hide side nav on mobile (swipe navigation is used instead) */ + .side-nav { + display: none; + } } /* === Phone portrait (≤ 480px): 1-day view === */ @@ -1927,6 +1999,55 @@ h3 { display: flex; flex: 1; 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) */ @@ -1949,6 +2070,7 @@ h3 { flex: 1; overflow-y: auto; position: relative; + padding-top: 0.4rem; } diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 88a7a7d..7ec6944 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -59,6 +59,7 @@ import { Cable, Link, Globe, + Tag, } from "lucide-react"; // Types @@ -144,6 +145,7 @@ interface SomedayList { id: string; title: string; tasks: Task[]; + tab?: string | null; externalProvider?: string | null; externalId?: string | null; externalListId?: string | null; @@ -254,6 +256,11 @@ const translations: Record = { confirmPassword: "Confirm Password", someday: "SOMEDAY", lists: "Lists", + allTabs: "All", + newTab: "New tab", + newTabName: "New tab name:", + assignTab: "Assign to tab", + renameTab: "Double-click to rename", loading: "Loading your tasks...", sycing: "Syncing...", synced: "Synced", @@ -436,6 +443,11 @@ const translations: Record = { confirmPassword: "Passwort bestätigen", someday: "IRGENDWANN", lists: "Listen", + allTabs: "Alle", + newTab: "Neuer Tab", + newTabName: "Neuer Tab-Name:", + assignTab: "Tab zuweisen", + renameTab: "Doppelklick zum Umbenennen", loading: "Lade Aufgaben...", syncing: "Synchronisiere...", synced: "Synchronisiert", @@ -617,6 +629,11 @@ const translations: Record = { confirmPassword: "Confirmer le mot de passe", someday: "UN JOUR", 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…", sycing: "Synchronisation…", synced: "Synchronisé", @@ -798,6 +815,11 @@ const translations: Record = { confirmPassword: "Confirmar contraseña", someday: "ALGÚN DÍA", 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…", sycing: "Sincronizando…", synced: "Sincronizado", @@ -979,6 +1001,11 @@ const translations: Record = { confirmPassword: "Conferma password", someday: "UN GIORNO", lists: "Liste", + allTabs: "Tutte", + newTab: "Nuova scheda", + newTabName: "Nome nuova scheda:", + assignTab: "Assegna a scheda", + renameTab: "Doppio clic per rinominare", loading: "Caricamento delle attività…", sycing: "Sincronizzazione…", 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 [editingTaskId, setEditingTaskId] = useState(null); const [draggingListId, setDraggingListId] = useState(null); + const [listToDelete, setListToDelete] = useState(null); + const [activeSomedayTab, setActiveSomedayTab] = useState(null); + const [editingTabName, setEditingTabName] = useState(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(); + 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(null); const [activeAddSlot, setActiveAddSlot] = useState<{ listId: string; slotIdx: number } | null>(null); const isDragFromHandle = useRef(false); @@ -5142,7 +5227,7 @@ export default function WeeklyView() { )} {/* Desktop Header: Left, Center, Right */} -
+
{/* LEFT SECTION: Slot Duration & Days to Show */}
{/* Slot Duration */} @@ -5235,51 +5320,81 @@ export default function WeeklyView() {
{/* CENTER SECTION: Week/Year, Goal, Focus Mode - Reveal on Hover */} -
+
{/* Week & Year */}
+ {/* Date Picker Toggle - Moved to front */} +
+ + {showDatePicker && ( + { + setCurrentWeekStart(getStartOfWeek(date)); + setShowDatePicker(false); + }} + onClose={() => setShowDatePicker(false)} + language={language} + anchorRef={datePickerBtnRef} + /> + )} +
+ + {/* Clickable Week & Year */} +
setShowDatePicker(!showDatePicker)} + title="Jump to date" + > + + KW {getWeekNumber(getCWReferenceDate(getVisibleDays())).toString().padStart(2, "0")} + + | + + {currentWeekStart.getFullYear()} + +
+
+ + {/* Goal */} +
{syncError ? ( -
+
{syncError}
) : (isLoading || isSyncing || syncStatus === "syncing") ? ( -
+
) : ( )} - - KW {getWeekNumber(getCWReferenceDate(getVisibleDays())).toString().padStart(2, "0")} - - | - - {currentWeekStart.getFullYear()} - -
- - {/* Goal */} -
- - {isEditingGoal ? ( )} - -
@@ -5476,29 +5590,6 @@ export default function WeeklyView() {
- {/* Date Picker Toggle */} -
- - {showDatePicker && ( - { - setCurrentWeekStart(getStartOfWeek(date)); - setShowDatePicker(false); - }} - onClose={() => setShowDatePicker(false)} - language={language} - anchorRef={datePickerBtnRef} - /> - )} -
{/* Search */} + +
{/* Time Column */} {showTimeGrid && (
@@ -6139,6 +6239,16 @@ export default function WeeklyView() { ); })} + + {/* Right Navigation Arrows (after grid so it paints on top) */} +
+ + +
{/* All-Day Events Section (below position) */} @@ -6227,6 +6337,69 @@ export default function WeeklyView() { > + + {/* Someday Tabs */} + {somedayTabs.length > 0 && ( +
+ + {somedayTabs.map(tab => ( + editingTabName === tab ? ( + 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)", + }} + /> + ) : ( + + ) + ))} +
+ )} )} {!showTimeGrid && ( @@ -6290,6 +6463,26 @@ export default function WeeklyView() { > + + {/* Horizontal tabs for non-time-grid */} + {somedayTabs.length > 0 && ( + <> + + {somedayTabs.map(tab => ( + + ))} + + )} )}
@@ -6305,10 +6498,10 @@ export default function WeeklyView() { }} > {(() => { - const baseLists = somedayLists.length > 0 - ? somedayLists + const baseLists = filteredSomedayLists.length > 0 + ? filteredSomedayLists : [{ 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) => { const indicator = draggingListId && dropTargetListIndex === listIdx && draggingListId !== list.id ? ( @@ -6465,110 +6658,162 @@ export default function WeeklyView() { alignItems: "center", }} > -
{ isDragFromHandle.current = true; }} - onMouseUp={() => { isDragFromHandle.current = false; }} - > - -
- { - const newTitle = e.target.value.trim(); - if (newTitle && newTitle !== list.title) { - try { - await fetch("/api/someday-lists", { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - id: list.id, - title: newTitle, - }), - }); - setSomedayLists((prev) => - prev.map((l) => - l.id === list.id - ? { ...l, title: newTitle } - : l, - ), - ); - } catch (err) { - console.error(err); - e.target.value = list.title; - } - } - }} - onKeyDown={(e) => { - if (e.key === "Enter") e.currentTarget.blur(); - }} - /> - {list.externalProvider && ( - - {list.externalProvider === "outlook" ? ( - - ) : list.externalProvider === "google" ? ( - - ) : list.externalProvider === "apple" ? ( - - ) : list.externalProvider === "synology" ? ( - - ) : ( - - )} - + {listToDelete === list.id ? ( +
+ Delete this list? + {list.externalProvider && Note: This list is not deleted from {list.externalProvider}, just from this view.} +
+ + +
+
+ ) : ( + <> +
{ isDragFromHandle.current = true; }} + onMouseUp={() => { isDragFromHandle.current = false; }} + > + +
+ { + const newTitle = e.target.value.trim(); + if (newTitle && newTitle !== list.title) { + try { + await fetch("/api/someday-lists", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: list.id, + title: newTitle, + }), + }); + setSomedayLists((prev) => + prev.map((l) => + l.id === list.id + ? { ...l, title: newTitle } + : l, + ), + ); + } catch (err) { + console.error(err); + e.target.value = list.title; + } + } + }} + 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 = ( + + {list.externalProvider === "outlook" ? ( + + ) : list.externalProvider === "google" ? ( + + ) : list.externalProvider === "apple" ? ( + + ) : list.externalProvider === "synology" ? ( + + ) : ( + + )} + + ); + return providerUrl ? ( + e.stopPropagation()}> + {iconContent} + + ) : iconContent; + })()} +
+ + +
+ + )} -
{(() => { @@ -9165,7 +9410,7 @@ function SettingsSidebar({ setShowAppleCalendarModal(false); 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) { setAppleCalError(err.message || "Connection failed"); } finally { @@ -9210,7 +9455,7 @@ function SettingsSidebar({ setShowSynologyCalendarModal(false); 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) { setSynologyCalError(err.message || "Connection failed"); } finally { @@ -9498,6 +9743,7 @@ function SettingsSidebar({ style={{ display: "flex", justifyContent: "center", + flexWrap: "wrap", gap: "4px", borderBottom: "1px solid var(--weekly-border, #eee)", padding: "0 24px",