From b820672c0237a52caf31fb5c290294a577af4ffb Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 16 Jun 2026 13:48:19 -0700 Subject: [PATCH 1/2] test(mobile): mobile-views spec reproduces sideways-scroll failures (Table/Kanban/Gantt/Activity) + Calendar agenda-default Adds tests/e2e/mobile-views.spec.ts (@mobile, 390px): each non-graph view must be explorable by scrolling DOWN, never sideways. Currently RED for Table, Kanban, Gantt, Activity (horizontally-scrollable content) and Calendar (cramped month grid instead of an agenda list). data-testid=view-content added to anchor the probe. Fixes follow. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web/src/components/ViewManager.tsx | 2 +- tests/e2e/mobile-views.spec.ts | 89 +++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/mobile-views.spec.ts diff --git a/packages/web/src/components/ViewManager.tsx b/packages/web/src/components/ViewManager.tsx index 08b5778e..31d85a36 100644 --- a/packages/web/src/components/ViewManager.tsx +++ b/packages/web/src/components/ViewManager.tsx @@ -703,7 +703,7 @@ const ViewManager: React.FC = ({ viewMode }) => { {/* Main Content Container */}
{/* Main Content */} -
+
{renderView()}
diff --git a/tests/e2e/mobile-views.spec.ts b/tests/e2e/mobile-views.spec.ts new file mode 100644 index 00000000..9b3a8525 --- /dev/null +++ b/tests/e2e/mobile-views.spec.ts @@ -0,0 +1,89 @@ +import { test, expect, Page } from '@playwright/test'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * Mobile usability of the non-graph views. On a phone a user should be able to + * READ and EXPLORE each view by scrolling DOWN — never by scrolling sideways to + * reach content. These tests reproduce the "the other views don't work on mobile" + * experience: any view whose content needs horizontal scrolling (a wide table, + * a row of board columns, a timeline) fails here until it gets a phone layout. + */ +test.use({ viewport: { width: 390, height: 844 } }); + +const VIEWS = [ + { name: 'Dashboard', tab: 'Dashboard View' }, + { name: 'Table', tab: 'Table View' }, + { name: 'Card', tab: 'Card View' }, + { name: 'Kanban', tab: 'Kanban View' }, + { name: 'Gantt', tab: 'Gantt Chart' }, + { name: 'Calendar', tab: 'Calendar View' }, + { name: 'Activity', tab: 'Activity Feed' }, +]; + +async function openView(page: Page, tab: string) { + const t = page.locator(`button[title="${tab}"]`); + await t.scrollIntoViewIfNeeded(); + await t.click(); + await page.waitForTimeout(2000); +} + +test.describe('mobile views are explorable by scrolling down, not sideways @mobile', () => { + for (const v of VIEWS) { + test(`${v.name} view needs no horizontal scrolling on a phone`, async ({ page }) => { + const pageErrors: string[] = []; + page.on('pageerror', (e) => pageErrors.push(e.message)); + + await login(page, TEST_USERS.ADMIN); + await openView(page, v.tab); + + const probe = await page.evaluate(() => { + const el = document.querySelector('[data-testid="view-content"]'); + if (!el) return { found: false, offenders: [] as any[], docOverflow: 0 }; + // Any element whose content is wider than its box is something the user + // must scroll sideways to see — the failure we hunt for. + const offenders: { tag: string; cls: string; scrollW: number; clientW: number }[] = []; + el.querySelectorAll('*').forEach((d) => { + const e = d as HTMLElement; + const ox = getComputedStyle(e).overflowX; + // Only horizontally-scrollable boxes count: those force the user to + // swipe sideways. (Plain `truncate` text clips with an ellipsis and is + // fine.) + const scrollable = ox === 'auto' || ox === 'scroll'; + if (scrollable && e.clientWidth > 0 && e.scrollWidth > e.clientWidth + 16) { + offenders.push({ + tag: e.tagName, + cls: (e.className?.toString?.() || '').slice(0, 48), + scrollW: e.scrollWidth, + clientW: e.clientWidth, + }); + } + }); + return { + found: true, + offenders: offenders.slice(0, 6), + docOverflow: document.documentElement.scrollWidth - window.innerWidth, + }; + }); + + expect(probe.found, 'view content container present').toBe(true); + expect(probe.docOverflow, 'page itself must not overflow sideways').toBeLessThanOrEqual(1); + expect( + probe.offenders, + `${v.name}: these elements force horizontal scrolling on a phone` + ).toEqual([]); + expect(pageErrors, `${v.name}: no uncaught JS errors`).toEqual([]); + }); + } + + test('Calendar defaults to the agenda list on a phone (not the cramped month grid)', async ({ page }) => { + await login(page, TEST_USERS.ADMIN); + await openView(page, 'Calendar View'); + const agendaActive = await page.evaluate(() => { + const btn = [...document.querySelectorAll('button')].find( + (b) => (b.textContent || '').trim() === 'Agenda' + ); + return !!btn && /bg-green/.test(btn.className); + }); + expect(agendaActive, 'Calendar should land on Agenda on a phone').toBe(true); + }); +}); From 6753567e7e2c73d87503d791de4277727a8f0edc Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 16 Jun 2026 13:54:49 -0700 Subject: [PATCH 2/2] feat(mobile): make Table/Kanban/Gantt/Calendar/Activity usable on phones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-graph views forced horizontal scrolling on a phone (wide table, a row of board columns, a timeline) or rendered a cramped grid — you couldn't explore them by scrolling down. Each now has a phone layout; desktop (≥ sm) is unchanged. - TableView: phones get a stacked card per work item with labeled fields (title, type, status, priority, contributor, connections, due date, tags); the wide table is `hidden sm:block`. - KanbanView: columns stack vertically and scroll down on phones (`flex-col`, no overflow-x); the horizontal board returns at sm:. - GanttChart: phones get a readable schedule list (task + status + progress + start–end dates); the interactive timeline is `hidden sm:block`. - CalendarView: defaults to the Agenda list on phones (matchMedia) instead of the unreadable 7-column month grid; Month/Week still selectable. - ActivityFeed: rows wrap on phones (min-w-0 + flex-wrap, p-3) so the timestamp pill no longer pushes content past the viewport. Driven by tests/e2e/mobile-views.spec.ts (committed first, was RED): each view must be explorable by scrolling DOWN, never sideways. Now 8/8 green; mobile- experience 2/2; smoke gate 5/5; web typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web/src/components/ActivityFeed.tsx | 22 ++-- packages/web/src/components/CalendarView.tsx | 76 +++++++++++- packages/web/src/components/GanttChart.tsx | 45 ++++++- packages/web/src/components/KanbanView.tsx | 6 +- packages/web/src/components/TableView.tsx | 117 +++++++++++++++++-- 5 files changed, 241 insertions(+), 25 deletions(-) diff --git a/packages/web/src/components/ActivityFeed.tsx b/packages/web/src/components/ActivityFeed.tsx index e24b74c9..34ec1d42 100644 --- a/packages/web/src/components/ActivityFeed.tsx +++ b/packages/web/src/components/ActivityFeed.tsx @@ -594,7 +594,7 @@ const ActivityFeed: React.FC = ({ filteredNodes }) => {
{/* Activity List */} -
+
{paginatedActivities.length === 0 ? (
@@ -630,8 +630,8 @@ const ActivityFeed: React.FC = ({ filteredNodes }) => {
-
-
+
+

{activity.title}

{(() => { const priorityConfig = PRIORITY_OPTIONS.find(opt => opt.value === activity.priority); @@ -662,18 +662,18 @@ const ActivityFeed: React.FC = ({ filteredNodes }) => {

{activity.description}

-
-
-
- - {activity.user} +
+
+
+ + {activity.user}
-
- +
+ {activity.nodeTitle}
-
+
{activity.timestamp.toLocaleString()}
diff --git a/packages/web/src/components/CalendarView.tsx b/packages/web/src/components/CalendarView.tsx index b471f13b..45f220d1 100644 --- a/packages/web/src/components/CalendarView.tsx +++ b/packages/web/src/components/CalendarView.tsx @@ -41,7 +41,9 @@ const getStatusColor = (status: string) => { const CalendarViewComponent: React.FC = ({ filteredNodes }) => { const [currentDate, setCurrentDate] = useState(new Date()); const [selectedDate, setSelectedDate] = useState(null); - const [viewMode, setViewMode] = useState<'month' | 'week' | 'agenda'>('month'); + const [viewMode, setViewMode] = useState<'month' | 'week' | 'agenda'>(() => + typeof window !== 'undefined' && window.matchMedia('(max-width: 639px)').matches ? 'agenda' : 'month' + ); const [filterPriority, setFilterPriority] = useState('all'); const [filterType, setFilterType] = useState('all'); const [filterStatus, setFilterStatus] = useState('all'); @@ -110,6 +112,14 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = return grouped; }, [filteredNodes, showCompleted, filterPriority, filterType, filterStatus, searchQuery]); + // Flatten grouped nodes into a date-sorted list for the agenda view + const agendaDays = useMemo(() => { + return Object.keys(nodesByDate) + .filter(dateKey => nodesByDate[dateKey].length > 0) + .sort((a, b) => new Date(a).getTime() - new Date(b).getTime()) + .map(dateKey => ({ date: new Date(dateKey), nodes: nodesByDate[dateKey] })); + }, [nodesByDate]); + // Generate calendar days const calendarDays = useMemo(() => { const year = currentDate.getFullYear(); @@ -420,7 +430,66 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) =
+ {/* Agenda List */} + {viewMode === 'agenda' && ( +
+ {agendaDays.length === 0 ? ( +
+ +

No scheduled tasks

+
+ ) : ( + agendaDays.map(({ date, nodes }) => ( +
+
+ + + {date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })} + +
+
+ {nodes.map((node, index) => { + const statusConfig = getStatusConfig(node.status as WorkItemStatus); + const typeConfig = getTypeConfig(node.type as WorkItemType); + const priority = node.priority || 0; + const priorityConfig = getPriorityConfig(priority); + + return ( +
setSelectedTask(selectedTask === node.id ? null : node.id)} + className="flex items-start justify-between gap-2 p-3 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors cursor-pointer" + > +
+
+
+
{node.title}
+
+ {React.createElement(typeConfig.icon as any, { className: 'h-3 w-3 inline mr-1' })} {node.type} + {priorityConfig.label} + {node.assignedTo && ( + {node.assignedTo.name} + )} +
+
+
+ + {node.status.replace('_', ' ')} + +
+ ); + })} +
+
+ )) + )} +
+ )} + {/* Days of Week Header */} + {viewMode !== 'agenda' && (
{['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].map((day, index) => (
= ({ filteredNodes }) =
))}
+ )} {/* Calendar Grid */} + {viewMode !== 'agenda' && (
{calendarDays.map((day, index) => { @@ -528,8 +599,9 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = })}
+ )}
- + {/* Selected Date Details Panel */} {selectedDate && (
diff --git a/packages/web/src/components/GanttChart.tsx b/packages/web/src/components/GanttChart.tsx index 70aeb474..15409a96 100644 --- a/packages/web/src/components/GanttChart.tsx +++ b/packages/web/src/components/GanttChart.tsx @@ -452,7 +452,50 @@ const GanttChart: React.FC = ({ filteredNodes }) => { {/* Main Content */}
-
+ {/* Mobile Schedule List */} +
+ {timelineData.length === 0 ? ( +
No tasks to display
+ ) : ( + timelineData.map(item => { + const statusConfig = getStatusConfig(item.status as WorkItemStatus); + const priorityConfig = getPriorityConfig(item.priority); + return ( +
setSelectedTask(selectedTask === item.id ? null : item.id)} + > +
+
+
{item.title}
+
+
+ + {item.status.replace('_', ' ')} + + {item.progress}% +
+
+ {item.startDate.toLocaleDateString()} – {item.endDate.toLocaleDateString()} + {item.duration}d +
+
+ ); + }) + )} +
+ + {/* Interactive Gantt Timeline */} +
diff --git a/packages/web/src/components/KanbanView.tsx b/packages/web/src/components/KanbanView.tsx index d67aab40..04c50ca8 100644 --- a/packages/web/src/components/KanbanView.tsx +++ b/packages/web/src/components/KanbanView.tsx @@ -122,13 +122,13 @@ const KanbanView: React.FC = ({ filteredNodes, handleEditNode, }, {} as Record); return ( -
+
{statuses.map((status) => { const nodes = nodesByStatus[status] || []; const config = getStatusConfig(status); - + return ( -
+
diff --git a/packages/web/src/components/TableView.tsx b/packages/web/src/components/TableView.tsx index c19c5ae0..d9515eda 100644 --- a/packages/web/src/components/TableView.tsx +++ b/packages/web/src/components/TableView.tsx @@ -110,9 +110,115 @@ const getContributorAvatar = (contributor?: string) => { }; const TableView: React.FC = ({ filteredNodes, handleEditNode, edges }) => { + const sortedNodes = [...filteredNodes].sort((a, b) => { + const dateA = new Date(a.updatedAt || a.createdAt).getTime(); + const dateB = new Date(b.updatedAt || b.createdAt).getTime(); + return dateB - dateA; // Most recent first + }); + return ( -
-
+
+
+ {sortedNodes.map((node) => { + const { incomingCount, outgoingCount, totalCount } = getConnectionDetails(node, edges); + return ( +
handleEditNode(node)} + className={`${getNodeTypeRowBackground(node.type)} rounded-xl shadow-lg cursor-pointer active:brightness-125 transition-all duration-200`} + style={{ + borderLeft: `4px solid ${getNodeTypeBorderColor(node.type)}`, + borderRight: `2px solid ${getNodeTypeBorderColor(node.type)}` + }} + > +
+
+
{node.title}
+ {node.description && ( +
{node.description}
+ )} +
+
+ + {getTypeIconElement(node.type as WorkItemType, "w-3 h-3")} + {formatLabel(node.type)} + + + + {getStatusIconElement(node.status as WorkItemStatus, "h-4 w-4")} + + + {formatLabel(node.status)} + + +
+
+
+
Priority
+ +
+
+
Contributor
+ {node.assignedTo ? ( + {node.assignedTo.name} + ) : ( + Available + )} +
+
+
+
Connections
+ {totalCount === 0 ? ( +
+ + None +
+ ) : ( +
+
+ + {totalCount} +
+ {incomingCount > 0 && ( +
+ + {incomingCount} +
+ )} + {outgoingCount > 0 && ( +
+ + {outgoingCount} +
+ )} +
+ )} +
+
+
Due Date
+ {node.dueDate ? ( +
+ {new Date(node.dueDate).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + })} +
+ ) : ( + No due date + )} +
+ +
+
+ ); + })} +
+ +
@@ -127,12 +233,7 @@ const TableView: React.FC = ({ filteredNodes, handleEditNode, ed - {[...filteredNodes] - .sort((a, b) => { - const dateA = new Date(a.updatedAt || a.createdAt).getTime(); - const dateB = new Date(b.updatedAt || b.createdAt).getTime(); - return dateB - dateA; // Most recent first - }) + {sortedNodes .map((node) => { return (