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 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-02 21:07:45 +01:00
1 parent 0b60ae6298
commit 18709353cf
3 files changed
+153 -19

No files matched your search

+41 -4
View File
@@ -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<HTMLDivElement>(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<Partial<Record<ActiveTab, number>>>({});
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) */}
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
<nav data-mobile-nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
{/* Col 1: Triage */}
<button
type="button"
+84 -10
View File
@@ -77,6 +77,10 @@ interface TasksViewProps {
const KANBAN_WIDE_KEY = 'mindsift:kanban_wide';
// Desktop Kanban columns never get lower than this: room for the header and about three cards
const KANBAN_MIN_COL_HEIGHT = 340;
const KANBAN_MIN_PHONE_COL_HEIGHT = 280;
// First row of a Kanban board with several rows: the full height minus what is still visible of the bar
// above the board (--kb-top at the start, shrinking with the scroll position --kb-scroll)
const KANBAN_FIRST_ROW_HEIGHT = `max(${KANBAN_MIN_COL_HEIGHT}px, calc(var(--kb-full) - max(0px, calc(var(--kb-top) - var(--kb-scroll, 0px)))))`;
// Wide Kanban: a column grows to at most this width, the board stays centred
const KANBAN_COL_MAX = 380;
const KANBAN_GAP = 12; // gap-3
@@ -544,36 +548,63 @@ export const TasksView: React.FC<TasksViewProps> = ({
// Desktop Kanban: every column, in every row, is as high as the room from the board's top to the bottom of
// the scroll area, so the first row fits completely on opening and each further row fills one screen;
// more cards scroll inside the column. At least room for about three cards (the page scrolls below that).
// One row: the page itself does not scroll (like the workspace). Several rows: the wheel moves row by row
// One row: the page itself does not scroll (like the workspace). Several rows: the first row grows while
// the bar above the board scrolls away, so a row always fills the screen; the wheel moves row by row
// (below).
const kanbanBoardRef = useRef<HTMLDivElement>(null);
const [kanbanColHeight, setKanbanColHeight] = useState<number | null>(null);
const [kanbanMultiRow, setKanbanMultiRow] = useState(false);
const [kanbanPerRow, setKanbanPerRow] = useState(0);
// Phones: one column per screen (between header and bottom bar) and the page snaps column by column;
// kanbanPhoneTop is where a snapped column starts (below the header)
const [kanbanPhoneSnap, setKanbanPhoneSnap] = useState(false);
const [kanbanPhoneTop, setKanbanPhoneTop] = useState(0);
// The first column is lower by the bar above the board, so it shows completely at the page top (its
// snap point); a swipe then goes on to the second one
const [kanbanPhoneFirstHeight, setKanbanPhoneFirstHeight] = useState(0);
const isKanban = viewMode === 'kanban';
useEffect(() => {
if (!isKanban) {
setKanbanColHeight(null);
setKanbanMultiRow(false);
setKanbanPhoneSnap(false);
return;
}
const fit = () => {
const el = kanbanBoardRef.current;
if (!el || isPhoneWidth()) {
if (!el) {
setKanbanColHeight(null);
setKanbanMultiRow(false);
setKanbanPhoneSnap(false);
return;
}
if (isPhoneWidth()) {
const headerBottom = document.querySelector('header')?.getBoundingClientRect().bottom ?? 0;
const navTop = document.querySelector('[data-mobile-nav]')?.getBoundingClientRect().top ?? window.innerHeight;
setKanbanColHeight(Math.max(KANBAN_MIN_PHONE_COL_HEIGHT, navTop - headerBottom - 24));
setKanbanPhoneTop(Math.round(headerBottom + 12));
const boardTop = el.getBoundingClientRect().top + window.scrollY;
setKanbanPhoneFirstHeight(Math.max(KANBAN_MIN_PHONE_COL_HEIGHT, Math.round(navTop - boardTop - 12)));
setKanbanMultiRow(false);
setKanbanPhoneSnap(true);
return;
}
setKanbanPhoneSnap(false);
const area = el.closest<HTMLElement>('[data-app-scroll]');
const areaTop = area ? area.getBoundingClientRect().top - area.scrollTop : -window.scrollY;
const areaHeight = area ? area.clientHeight : window.innerHeight;
const top = el.getBoundingClientRect().top - areaTop;
setKanbanColHeight(Math.max(KANBAN_MIN_COL_HEIGHT, areaHeight - top - 16));
// Several rows: a row fills the whole area (--kb-full); the first one grows into the room the bar
// above the board frees while scrolling (--kb-top - --kb-scroll, see the scroll effect)
el.style.setProperty('--kb-full', `${Math.max(KANBAN_MIN_COL_HEIGHT, areaHeight - 24)}px`);
el.style.setProperty('--kb-top', `${Math.max(0, top - 12)}px`);
requestAnimationFrame(() => {
const tops = new Set(
Array.from(el.querySelectorAll<HTMLElement>('[data-kanban-col]')).map((c) => c.offsetTop)
);
const colTops = Array.from(el.querySelectorAll<HTMLElement>('[data-kanban-col]')).map((c) => c.offsetTop);
const tops = new Set(colTops);
const multi = tops.size > 1;
setKanbanMultiRow(multi);
setKanbanPerRow(colTops.filter((t) => t === Math.min(...colTops)).length);
if (multi) return;
// One row: take back what still overflows (paddings), so the page does not scroll
const over = area ? area.scrollHeight - area.clientHeight : document.documentElement.scrollHeight - window.innerHeight;
@@ -631,8 +662,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
lockedUntil = Date.now() + 450;
area.scrollTo({ top: next, behavior: 'smooth' });
};
// The first row grows while the bar above the board scrolls away
const onScroll = () => board.style.setProperty('--kb-scroll', `${area.scrollTop}px`);
onScroll();
area.addEventListener('wheel', onWheel, { passive: false });
return () => area.removeEventListener('wheel', onWheel);
area.addEventListener('scroll', onScroll, { passive: true });
return () => {
area.removeEventListener('wheel', onWheel);
area.removeEventListener('scroll', onScroll);
};
}, [kanbanMultiRow]);
const safeTasks = useMemo(() => {
@@ -1421,6 +1459,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
};
// Page positions of the phone board's snap points: the page top (bar and first column) and the start of
// every further column
const phoneSnapPoints = useCallback(() => {
const points = new Set<number>([0]); // page top: bar and first column
kanbanBoardRef.current?.querySelectorAll<HTMLElement>('[data-kanban-col]').forEach((c, i) => {
if (i > 0) points.add(Math.max(0, Math.round(c.getBoundingClientRect().top + window.scrollY - kanbanPhoneTop)));
});
return Array.from(points).sort((a, b) => a - b);
}, [kanbanPhoneTop]);
const kanbanTouch = useKanbanTouchDrag({
enabled: !canDragTasks && viewMode === 'kanban',
onStart: (taskId) => setDraggingTaskId(taskId),
@@ -1438,8 +1485,22 @@ export const TasksView: React.FC<TasksViewProps> = ({
else void moveTaskToColumn(taskId, ownColId);
},
onCancel: clearDragState,
snapPoints: kanbanPhoneSnap ? phoneSnapPoints : undefined,
});
// Phones: the page snaps to its top and to each column; while a card is dragged it does not (the drag
// moves the page from column to column itself, see useKanbanTouchDrag)
useEffect(() => {
if (!kanbanPhoneSnap) return;
const root = document.documentElement;
root.style.scrollPaddingTop = `${kanbanPhoneTop}px`;
root.style.scrollSnapType = kanbanTouch.dragging ? 'none' : 'y mandatory';
return () => {
root.style.scrollSnapType = '';
root.style.scrollPaddingTop = '';
};
}, [kanbanPhoneSnap, kanbanPhoneTop, kanbanTouch.dragging]);
const handleCreateTaskList = async (e: React.FormEvent) => {
e.preventDefault();
const title = newListTitle.trim();
@@ -1970,7 +2031,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
};
return (
<div className="w-full space-y-3">
// Phone Kanban: the page top is the first snap point (bar and first column)
<div className={`w-full space-y-3 ${kanbanPhoneSnap ? 'snap-start' : ''}`}>
{/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
{/* Task Lists selector tabs */}
@@ -2813,7 +2875,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
}`}
style={kanbanBoardStyle(isKanbanWide, boardColumns.length)}
>
{boardColumns.map((col) => {
{boardColumns.map((col, colIndex) => {
const colTasks = kanbanTasks.filter((t) => inBoardColumn(t, col));
// While dragging: a column the card's list does not have is greyed out and takes nothing
const blockedForDrag =
@@ -2830,8 +2892,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
onDragLeave={(e) => handleKanbanColDragLeave(e, col.id)}
onDrop={(e) => handleKanbanDrop(e, col.id, colTasks)}
data-kanban-col={col.id}
style={kanbanColHeight ? { height: kanbanColHeight } : undefined}
className={`bg-canvas border border-border flex flex-col overflow-hidden transition-all ${
style={
!kanbanColHeight
? undefined
: kanbanPhoneSnap && colIndex === 0
? { height: kanbanPhoneFirstHeight }
: !kanbanMultiRow
? { height: kanbanColHeight }
: colIndex < kanbanPerRow
? { height: KANBAN_FIRST_ROW_HEIGHT }
: { height: 'var(--kb-full)' }
}
className={`bg-canvas border border-border flex flex-col overflow-hidden transition-[opacity,background-color,box-shadow] ${
kanbanPhoneSnap && colIndex > 0 ? 'snap-start snap-always' : ''
} ${
kanbanColHeight ? '' : 'min-h-[420px]'
} ${dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''} ${blockedForDrag ? 'opacity-40' : ''}`}
title={blockedForDrag ? 'Diese Spalte gibt es in der Liste der Aufgabe nicht' : undefined}
@@ -9,6 +9,7 @@ import { createPortal } from 'react-dom';
const HOLD_MS = 350;
const MOVE_TOLERANCE = 8; // px a finger may wander during the hold before it counts as scrolling
const EDGE = 90; // px near the top/bottom edge that scroll the page while dragging
const EDGE_DWELL_MS = 450; // with snap points: how long a card waits at the edge before the page moves on
export type KanbanDropTarget = { taskId: string; position: 'before' | 'after' } | null;
@@ -18,6 +19,9 @@ interface Options {
onHover: (colId: string | null, target: KanbanDropTarget) => void;
onDrop: (taskId: string, colId: string | null, target: KanbanDropTarget) => void;
onCancel: () => void;
// Page positions the board snaps to (one column per screen on phones). With them, holding a card at
// the top/bottom edge moves the page to the previous/next one instead of scrolling slowly.
snapPoints?: () => number[];
}
interface Drag {
@@ -30,14 +34,14 @@ interface Drag {
width: number;
}
export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel }: Options) {
export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel, snapPoints }: Options) {
const [drag, setDrag] = useState<Drag | null>(null);
const holdRef = useRef<{ timer: number; x: number; y: number } | null>(null);
const dragRef = useRef<Drag | null>(null);
const hoverRef = useRef<{ col: string | null; target: KanbanDropTarget }>({ col: null, target: null });
const suppressClickRef = useRef(false);
const callbacks = useRef({ onStart, onHover, onDrop, onCancel });
callbacks.current = { onStart, onHover, onDrop, onCancel };
const callbacks = useRef({ onStart, onHover, onDrop, onCancel, snapPoints });
callbacks.current = { onStart, onHover, onDrop, onCancel, snapPoints };
const cancelHold = () => {
if (holdRef.current) window.clearTimeout(holdRef.current.timer);
@@ -66,11 +70,30 @@ export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel
useEffect(() => {
if (!drag) return;
let frame = 0;
let edgeSince = 0;
let movingUntil = 0;
const scroll = () => {
const d = dragRef.current;
if (d) {
if (d.y < EDGE) window.scrollBy(0, -Math.ceil((EDGE - d.y) / 6));
else if (d.y > window.innerHeight - EDGE) window.scrollBy(0, Math.ceil((d.y - (window.innerHeight - EDGE)) / 6));
const up = d.y < EDGE;
const down = d.y > window.innerHeight - EDGE;
const points = callbacks.current.snapPoints?.();
if (points && (up || down)) {
// Snapping board: after a short wait at the edge, on to the previous/next column (again after
// another wait while the card stays there)
const now = Date.now();
if (!edgeSince) edgeSince = now;
if (now - edgeSince > EDGE_DWELL_MS && now > movingUntil) {
const cur = window.scrollY;
const next = down ? points.find((p) => p > cur + 2) : [...points].reverse().find((p) => p < cur - 2);
if (next !== undefined) window.scrollTo({ top: next, behavior: 'smooth' });
movingUntil = now + 1000;
edgeSince = now;
}
} else if (points) {
edgeSince = 0;
} else if (up) window.scrollBy(0, -Math.ceil((EDGE - d.y) / 6));
else if (down) window.scrollBy(0, Math.ceil((d.y - (window.innerHeight - EDGE)) / 6));
hoverAt(d.x, d.y);
}
frame = requestAnimationFrame(scroll);