From 18709353cf84a635e97b717efe995175c4e30927 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 21:07:45 +0100 Subject: [PATCH 1/2] feat(tasks): Kanban fills the screen row by row, on phones too; pages keep their scroll position Desktop, several rows: the first row grows while the bar above the board scrolls away (CSS variables follow the scroll position), so every row fills the screen once snapped. Phones: each column is as high as the room between header and bottom bar; the page snaps column by column (the first column is lower by the bar above the board and snaps at the page top). While a card is dragged snapping is off; holding it at the top/bottom edge moves the page to the previous/next column (useKanbanTouchDrag snapPoints). Each app page (Triage, Workspace, Aufgaben, Listen) keeps its scroll position when switching pages; it comes back once the page has loaded. Co-Authored-By: Claude Opus 5.5 --- frontend/src/App.tsx | 45 ++++++++- frontend/src/features/triage/TasksView.tsx | 94 +++++++++++++++++-- .../features/triage/useKanbanTouchDrag.tsx | 33 ++++++- 3 files changed, 153 insertions(+), 19 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 071770e..aa0e614 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -208,10 +208,47 @@ export const App: React.FC = () => { // Desktop: the window does not scroll; this area under the header does (phones keep the window scroll) const scrollAreaRef = useRef(null); - // A new page starts at its top (the desktop scroll area would otherwise keep the old page's position) + // Each page keeps its scroll position when switching pages. The new page is often still loading and + // shorter than before: the position comes back as soon as the content is high enough (or the user + // scrolls first, or after 3 s). Scroll events meanwhile do not count as the page's position. + const scrollPositionsRef = useRef>>({}); useEffect(() => { - scrollAreaRef.current?.scrollTo?.({ top: 0 }); - }, [activeTab]); + const area = scrollAreaRef.current; + if (!area) return; + const target = scrollPositionsRef.current[activeTab] ?? 0; + let restoring = true; + let observer: ResizeObserver | null = null; + const finish = () => { + if (!restoring) return; + restoring = false; + observer?.disconnect(); + window.clearTimeout(timeout); + area.removeEventListener('wheel', finish); + area.removeEventListener('pointerdown', finish); + area.removeEventListener('keydown', finish); + }; + const tryRestore = () => { + area.scrollTop = target; + if (Math.abs(area.scrollTop - target) < 2) finish(); + }; + const timeout = window.setTimeout(finish, 3000); + area.addEventListener('wheel', finish, { passive: true }); + area.addEventListener('pointerdown', finish); + area.addEventListener('keydown', finish); + tryRestore(); + if (restoring && typeof ResizeObserver !== 'undefined' && area.firstElementChild) { + observer = new ResizeObserver(tryRestore); + observer.observe(area.firstElementChild); + } + const onScroll = () => { + if (!restoring) scrollPositionsRef.current[activeTab] = area.scrollTop; + }; + area.addEventListener('scroll', onScroll, { passive: true }); + return () => { + finish(); + area.removeEventListener('scroll', onScroll); + }; + }, [activeTab, user?.id]); const [showAbout, setShowAbout] = useState(false); const [showTriageTrash, setShowTriageTrash] = useState(false); const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck(); @@ -1413,7 +1450,7 @@ export const App: React.FC = () => { /> {/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */} -