Skip to content

Commit 133a7e6

Browse files
committed
v1.9.2: UI polish, DnD rewrite, summary dashboard, performance
- Tasks tabs (Summary/List/Board/Epics), summary dashboard with stats - Board & list DnD rewrite with SortableContext + arrayMove - Epic selector in forms, inline priority editing - Attachments in edit forms, two-column epic detail - React.memo + useCallback + teamMap performance optimizations - Uppercase labels, vertical FieldRow, context-aware breadcrumbs - Docker curl healthcheck, remove tag grouping - Changelog for v1.9.1 and v1.9.2
1 parent 77c3c3d commit 133a7e6

6 files changed

Lines changed: 100 additions & 66 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@graphmemory/server",
3-
"version": "1.9.1",
3+
"version": "1.9.2",
44
"description": "MCP server for semantic graph memory from markdown files",
55
"main": "dist/cli/index.js",
66
"bin": {

site/src/pages/changelog.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,51 @@ description: Graph Memory release history and version changes.
55

66
# Changelog
77

8+
## v1.9.2
9+
10+
**March 2026**
11+
12+
### New
13+
14+
- **Tasks tabs navigation** — Summary, List, Board, and Epics as tabs within a single Tasks section. Epics moved from top-level nav to Tasks tab at `/tasks/epics`
15+
- **Task Summary dashboard** — 6 stat cards (Total, Active, Completed, Overdue, In Review, Unassigned), breakdowns by status/priority, by assignee, by epic with progress bars, recently updated tasks, upcoming & overdue deadlines. All clickable with URL filters
16+
- **Epic selector in task forms** — single-select epic dropdown in create/edit forms with auto link/unlink on save
17+
- **Inline priority editing** — pill-badge priority selector on task detail view
18+
- **Attachments in edit forms** — upload/delete attachments during task, note, and skill editing (previously only on detail view)
19+
- **Skills grid layout** — 2-column card grid with 3-dot menu (Edit/Delete), matching Knowledge layout
20+
- **Epic detail two-column layout** — description + tasks list on left, progress bar + properties on right
21+
- **Context-aware breadcrumbs** — task pages show origin (Board/List) via URL `?from=` param, persists through navigation
22+
- **Column visibility from URL**`/tasks/list?status=review` sets visible columns, `/tasks/list?group=assignee` sets grouping
23+
24+
### Fixes
25+
26+
- **Board drag & drop rewrite** — SortableContext per column with `useDroppable`, custom collision detection (cards over columns), `arrayMove` for correct position, live cross-column movement in `handleDragOver`, WebSocket refresh suppressed during drag
27+
- **List drag & drop rewrite** — migrated from `useDraggable`/`useDroppable` to `SortableContext`/`useSortable` with visual row displacement during drag, same `arrayMove` approach as board
28+
- **Docker healthcheck** — replaced `node -e "fetch(...)"` with `curl -f` (no Node process spawn)
29+
- **Duplicate submit buttons** — removed redundant Create/Save buttons from PageTopBar on all create/edit pages
30+
- **Attachments/relations in main column** — moved from sidebar to main content area on task, note, and skill detail views
31+
- **Uppercase status/priority labels** — consistent uppercase labels across all views (board, list, forms, badges, summary, epics)
32+
- **FieldRow vertical layout** — label above value with dividers (instead of side-by-side)
33+
34+
### Performance
35+
36+
- **React.memo on card/row components**`SortableTaskCard` and `SortableTaskRow` wrapped in `memo`
37+
- **Stable callback props** — extracted inline callbacks to `useCallback` to prevent unnecessary re-renders
38+
- **Team lookup map** — replaced `team.find()` (O(n)) with `Map<id, TeamMember>` (O(1)) per card render
39+
- **Memoized activeTask**`useMemo` instead of `.find()` on every render
40+
41+
---
42+
43+
## v1.9.1
44+
45+
**March 2026**
46+
47+
### Fixes
48+
49+
- **npm ci dependencies** — resolved dependency installation issues
50+
51+
---
52+
853
## v1.9.0
954

1055
**March 2026**

ui/src/entities/task/groupConfig.ts

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Epic } from '@/entities/epic/api.ts';
44
import { COLUMNS, PRIORITY_COLORS, priorityLabel } from './config.ts';
55
import { updateTask } from './api.ts';
66

7-
export type GroupByField = 'status' | 'priority' | 'assignee' | 'tag' | 'epic' | 'none';
7+
export type GroupByField = 'status' | 'priority' | 'assignee' | 'epic' | 'none';
88

99
export interface GroupDefinition {
1010
key: string;
@@ -97,26 +97,6 @@ export const GROUP_CONFIGS: Record<GroupByField, GroupConfig> = {
9797
},
9898
},
9999

100-
tag: {
101-
field: 'tag',
102-
getKeys: (task) => (task.tags && task.tags.length > 0 ? task.tags : []),
103-
buildGroups: (tasks) => {
104-
const tagSet = new Set<string>();
105-
for (const t of tasks) {
106-
if (t.tags) for (const tag of t.tags) tagSet.add(tag);
107-
}
108-
return [...tagSet].sort().map((tag, i) => ({
109-
key: tag,
110-
label: `#${tag}`,
111-
color: '#7b1fa2',
112-
sortOrder: i,
113-
}));
114-
},
115-
nullGroupLabel: 'No tags',
116-
nullGroupColor: '#616161',
117-
dndEnabled: false,
118-
},
119-
120100
epic: {
121101
field: 'epic',
122102
getKeys: (_task, context) => {
@@ -156,7 +136,6 @@ export const GROUP_BY_OPTIONS: { value: GroupByField; label: string }[] = [
156136
{ value: 'status', label: 'Status' },
157137
{ value: 'priority', label: 'Priority' },
158138
{ value: 'assignee', label: 'Assignee' },
159-
{ value: 'tag', label: 'Tag' },
160139
{ value: 'epic', label: 'Epic' },
161140
{ value: 'none', label: 'None' },
162141
];

ui/src/pages/tasks/board.tsx

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
1+
import { useState, useEffect, useCallback, useMemo, useRef, memo } from 'react';
22
import { useParams, useNavigate } from 'react-router-dom';
33
import {
44
Box, Typography, Button, Paper, Stack, Chip,
@@ -57,10 +57,10 @@ function dueDateInfo(dueDate: number | null, status: TaskStatus): { label: strin
5757
// Sortable card wrapper
5858
// ---------------------------------------------------------------------------
5959

60-
function SortableTaskCard({
61-
task, team, canWrite, onNavigate, onEdit, onDelete, palette, taskEpics, onTagClick, activeTag, onAssigneeClick, onPriorityChange, onEpicClick,
60+
const SortableTaskCard = memo(function SortableTaskCard({
61+
task, teamMap, canWrite, onNavigate, onEdit, onDelete, palette, taskEpics, onTagClick, activeTag, onAssigneeClick, onPriorityChange, onEpicClick,
6262
}: {
63-
task: Task; team: TeamMember[]; canWrite: boolean;
63+
task: Task; teamMap: Map<string, TeamMember>; canWrite: boolean;
6464
onNavigate: (id: string) => void; onEdit: (id: string) => void; onDelete: (t: Task) => void;
6565
palette: any; taskEpics?: Epic[]; onTagClick: (tag: string) => void; activeTag?: string; onAssigneeClick: (id: string) => void; onPriorityChange: (p: TaskPriority) => void; onEpicClick: (id: string) => void;
6666
}) {
@@ -159,7 +159,7 @@ function SortableTaskCard({
159159
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onAssigneeClick(task.assignee!); }}
160160
sx={{ color: palette.custom.textMuted, cursor: 'pointer', '&:hover': { textDecoration: 'underline', color: palette.text.primary } }}
161161
>
162-
@{team.find(m => m.id === task.assignee)?.name ?? task.assignee}
162+
@{teamMap.get(task.assignee!)?.name ?? task.assignee}
163163
</Typography>
164164
)}
165165
{task.estimate != null && (
@@ -213,7 +213,7 @@ function SortableTaskCard({
213213
) : null}
214214
</Paper>
215215
);
216-
}
216+
});
217217

218218
// ---------------------------------------------------------------------------
219219
// Droppable column wrapper — makes the entire column a valid drop target
@@ -274,6 +274,15 @@ export default function TaskBoardPage() {
274274

275275
const { filters, setFilter, clearAll } = useFilters<BoardFilterKey>(BOARD_FILTER_DEFS);
276276

277+
const teamMap = useMemo(() => new Map(team.map(m => [m.id, m])), [team]);
278+
279+
// Stable callbacks for card props
280+
const handleCardNavigate = useCallback((id: string) => navigate(`/${projectId}/tasks/${id}?from=board`), [navigate, projectId]);
281+
const handleCardEdit = useCallback((id: string) => navigate(`/${projectId}/tasks/${id}/edit?from=board`), [navigate, projectId]);
282+
const handleTagClick = useCallback((t: string) => setFilter('tag', t), [setFilter]);
283+
const handleAssigneeClick = useCallback((id: string) => setFilter('assignee', id), [setFilter]);
284+
const handleEpicClick = useCallback((id: string) => setFilter('epic', id), [setFilter]);
285+
277286
// DnD state
278287
const [activeId, setActiveId] = useState<string | null>(null);
279288
const [overColumn, setOverColumn] = useState<TaskStatus | null>(null);
@@ -510,7 +519,7 @@ export default function TaskBoardPage() {
510519
chips.push({ key: 'tag', label: `#${filters.tag}`, onClear: () => setFilter('tag', '') });
511520
}
512521
if (filters.assignee) {
513-
const m = team.find(t => t.id === filters.assignee);
522+
const m = teamMap.get(filters.assignee);
514523
chips.push({ key: 'assignee', label: `@${m?.name || filters.assignee}`, onClear: () => setFilter('assignee', '') });
515524
}
516525
if (filters.epic) {
@@ -520,7 +529,7 @@ export default function TaskBoardPage() {
520529
return chips;
521530
}, [filters, team, epics, setFilter]);
522531

523-
const activeTask = activeId ? tasks.find(t => t.id === activeId) : null;
532+
const activeTask = useMemo(() => activeId ? tasks.find(t => t.id === activeId) : null, [activeId, tasks]);
524533

525534
return (
526535
<Box>
@@ -704,17 +713,17 @@ export default function TaskBoardPage() {
704713
<SortableTaskCard
705714
key={task.id}
706715
task={task}
707-
team={team}
716+
teamMap={teamMap}
708717
canWrite={canWrite}
709-
onNavigate={(id) => navigate(`/${projectId}/tasks/${id}?from=board`)}
710-
onEdit={(id) => navigate(`/${projectId}/tasks/${id}/edit?from=board`)}
718+
onNavigate={handleCardNavigate}
719+
onEdit={handleCardEdit}
711720
onDelete={setDeleteTarget}
712721
palette={palette}
713722
taskEpics={taskEpicMap.get(task.id)}
714-
onTagClick={t => setFilter('tag', t)}
723+
onTagClick={handleTagClick}
715724
activeTag={filters.tag}
716-
onAssigneeClick={id => setFilter('assignee', id)}
717-
onEpicClick={id => setFilter('epic', id)}
725+
onAssigneeClick={handleAssigneeClick}
726+
onEpicClick={handleEpicClick}
718727
onPriorityChange={async (p) => {
719728
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, priority: p } : t));
720729
try { await updateTask(projectId!, task.id, { priority: p }); } catch { refresh(); }

ui/src/pages/tasks/list.tsx

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react';
1+
import { Fragment, useState, useEffect, useCallback, useMemo, useRef, memo } from 'react';
22
import { useParams, useNavigate } from 'react-router-dom';
33
import {
44
Box, Typography, Button, Paper, Chip, Alert, CircularProgress,
@@ -28,7 +28,6 @@ import { useCanWrite } from '@/shared/lib/AccessContext.tsx';
2828
import { StatusBadge, ConfirmDialog, PaginationBar, FilterBar, FilterControl } from '@/shared/ui/index.ts';
2929
import type { SortDir } from '@/shared/lib/useTableSort.ts';
3030
import { useFilters } from '@/shared/lib/useFilters.ts';
31-
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
3231
import {
3332
listTasks, updateTask, reorderTask, bulkMoveTasks, bulkUpdatePriority, bulkDeleteTasks,
3433
COLUMNS, PRIORITY_COLORS, PRIORITY_BADGE_COLOR, priorityLabel, statusLabel,
@@ -158,11 +157,11 @@ function TailDropZone({ groupKey, color, dndEnabled }: { groupKey: string; color
158157
// Draggable + droppable task row
159158
// ---------------------------------------------------------------------------
160159

161-
function SortableTaskRow({
162-
task, team, canWrite, selected, onToggleSelect, onNavigate, palette,
160+
const SortableTaskRow = memo(function SortableTaskRow({
161+
task, teamMap, canWrite, selected, onToggleSelect, onNavigate, palette,
163162
onInlineStatus, onInlinePriority, groupColor, taskEpics, onTagClick, activeTag, onAssigneeClick, onEpicClick,
164163
}: {
165-
task: Task; team: TeamMember[]; canWrite: boolean; selected: boolean;
164+
task: Task; teamMap: Map<string, TeamMember>; canWrite: boolean; selected: boolean;
166165
onToggleSelect: () => void; onNavigate: () => void; palette: any;
167166
onInlineStatus: (status: TaskStatus) => void; onInlinePriority: (priority: TaskPriority) => void;
168167
groupColor: string; taskEpics?: Epic[]; onTagClick: (tag: string) => void; activeTag?: string; onAssigneeClick: (id: string) => void; onEpicClick: (id: string) => void;
@@ -181,8 +180,8 @@ function SortableTaskRow({
181180
sx={{
182181
cursor: canWrite ? 'grab' : 'pointer',
183182
opacity: isDragging ? 0.35 : 1,
184-
borderLeft: `3px solid ${alpha(groupColor, 0.5)}`,
185-
bgcolor: alpha(groupColor, 0.03),
183+
borderLeft: groupColor && groupColor !== 'transparent' ? `3px solid ${alpha(groupColor, 0.5)}` : undefined,
184+
bgcolor: groupColor && groupColor !== 'transparent' ? alpha(groupColor, 0.03) : undefined,
186185
transition: 'opacity 0.15s',
187186
touchAction: 'none',
188187
}}
@@ -288,7 +287,7 @@ function SortableTaskRow({
288287
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onAssigneeClick(task.assignee!); }}
289288
sx={{ color: palette.custom.textMuted, cursor: 'pointer', '&:hover': { textDecoration: 'underline', color: palette.text.primary } }}
290289
>
291-
@{team.find(m => m.id === task.assignee)?.name ?? task.assignee}
290+
@{teamMap.get(task.assignee!)?.name ?? task.assignee}
292291
</Typography>
293292
)}
294293
</TableCell>
@@ -305,7 +304,7 @@ function SortableTaskRow({
305304
</TableCell>
306305
</TableRow>
307306
);
308-
}
307+
});
309308

310309
// ---------------------------------------------------------------------------
311310
// Main page
@@ -327,7 +326,7 @@ const GROUP_BY_STORAGE_KEY = 'tasks-list-group-by';
327326
function loadGroupBy(): GroupByField {
328327
try {
329328
const raw = localStorage.getItem(GROUP_BY_STORAGE_KEY);
330-
if (raw && ['status', 'priority', 'assignee', 'tag', 'epic', 'none'].includes(raw)) return raw as GroupByField;
329+
if (raw && ['status', 'priority', 'assignee', 'epic', 'none'].includes(raw)) return raw as GroupByField;
331330
} catch { /* ignore */ }
332331
return 'status';
333332
}
@@ -347,7 +346,7 @@ export default function TaskListPage() {
347346
const [groupBy, setGroupBy] = useState<GroupByField>(() => {
348347
const params = new URLSearchParams(window.location.search);
349348
const urlGroup = params.get('group');
350-
if (urlGroup && ['status', 'priority', 'assignee', 'tag', 'epic', 'none'].includes(urlGroup)) return urlGroup as GroupByField;
349+
if (urlGroup && ['status', 'priority', 'assignee', 'epic', 'none'].includes(urlGroup)) return urlGroup as GroupByField;
351350
return loadGroupBy();
352351
});
353352
const handleGroupByChange = useCallback((value: string) => {
@@ -358,6 +357,13 @@ export default function TaskListPage() {
358357

359358
const { filters, setFilter, setFilters, clearAll } = useFilters<TaskFilterKey>(TASK_FILTER_DEFS);
360359

360+
const teamMap = useMemo(() => new Map(team.map(m => [m.id, m])), [team]);
361+
362+
// Stable callbacks for row props
363+
const handleTagClick = useCallback((t: string) => setFilter('tag', t), [setFilter]);
364+
const handleAssigneeClick = useCallback((id: string) => setFilter('assignee', id), [setFilter]);
365+
const handleEpicClick = useCallback((id: string) => setFilter('epic', id), [setFilter]);
366+
361367
const [epics, setEpics] = useState<Epic[]>([]);
362368
const [epicTaskIds, setEpicTaskIds] = useState<Set<string> | null>(null);
363369
const [taskEpicMap, setTaskEpicMap] = useState<Map<string, Epic[]>>(new Map());
@@ -616,7 +622,7 @@ export default function TaskListPage() {
616622
chips.push({ key: 'tag', label: `#${filters.tag}`, onClear: () => setFilter('tag', '') });
617623
}
618624
if (filters.assignee) {
619-
const m = team.find(t => t.id === filters.assignee);
625+
const m = teamMap.get(filters.assignee);
620626
chips.push({ key: 'assignee', label: `@${m?.name || filters.assignee}`, onClear: () => setFilter('assignee', '') });
621627
}
622628
if (filters.epic) {
@@ -627,7 +633,7 @@ export default function TaskListPage() {
627633
}, [filters, team, epics, setFilter]);
628634

629635
const totalFiltered = filteredTasks.length;
630-
const activeTask = activeId ? tasks.find(t => t.id === activeId) : null;
636+
const activeTask = useMemo(() => activeId ? tasks.find(t => t.id === activeId) : null, [activeId, tasks]);
631637

632638
const handleClearAll = () => { clearAll(); };
633639

@@ -786,12 +792,7 @@ export default function TaskListPage() {
786792
</Box>
787793
) : (
788794
<DndContext sensors={sensors} collisionDetection={listCollisionDetection} onDragStart={currentGroupConfig.dndEnabled ? handleDragStart : undefined} onDragOver={currentGroupConfig.dndEnabled ? handleDragOver : undefined} onDragEnd={currentGroupConfig.dndEnabled ? handleDragEnd : undefined}>
789-
{groupBy === 'tag' && (
790-
<Alert severity="info" icon={<InfoOutlinedIcon fontSize="small" />} sx={{ mb: 1, py: 0 }}>
791-
Tasks may appear in multiple groups
792-
</Alert>
793-
)}
794-
<TableContainer component={Paper} variant="outlined">
795+
<TableContainer component={Paper} variant="outlined">
795796
<Table size="small">
796797
<TableHead>
797798
<TableRow>
@@ -817,7 +818,7 @@ export default function TaskListPage() {
817818
<SortableTaskRow
818819
key={task.id}
819820
task={task}
820-
team={team}
821+
teamMap={teamMap}
821822
canWrite={canWrite}
822823
selected={selected.has(task.id)}
823824
groupColor="transparent"
@@ -827,10 +828,10 @@ export default function TaskListPage() {
827828
onInlinePriority={p => handleInlinePriority(task, p)}
828829
palette={palette}
829830
taskEpics={taskEpicMap.get(task.id)}
830-
onTagClick={t => setFilter('tag', t)}
831+
onTagClick={handleTagClick}
831832
activeTag={filters.tag}
832-
onAssigneeClick={id => setFilter('assignee', id)}
833-
onEpicClick={id => setFilter('epic', id)}
833+
onAssigneeClick={handleAssigneeClick}
834+
onEpicClick={handleEpicClick}
834835
/>
835836
))}
836837
</SortableContext>
@@ -859,7 +860,7 @@ export default function TaskListPage() {
859860
<SortableTaskRow
860861
key={task.id}
861862
task={task}
862-
team={team}
863+
teamMap={teamMap}
863864
canWrite={canWrite}
864865
selected={selected.has(task.id)}
865866
groupColor={color}
@@ -869,10 +870,10 @@ export default function TaskListPage() {
869870
onInlinePriority={p => handleInlinePriority(task, p)}
870871
palette={palette}
871872
taskEpics={taskEpicMap.get(task.id)}
872-
onTagClick={t => setFilter('tag', t)}
873+
onTagClick={handleTagClick}
873874
activeTag={filters.tag}
874-
onAssigneeClick={id => setFilter('assignee', id)}
875-
onEpicClick={id => setFilter('epic', id)}
875+
onAssigneeClick={handleAssigneeClick}
876+
onEpicClick={handleEpicClick}
876877
/>
877878
))}
878879
</SortableContext>

0 commit comments

Comments
 (0)