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

+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);