Skip to content

Commit b0ff279

Browse files
authored
Merge pull request #25 from markrai/ux/minor_aesthetic_usability_fixes
enhancement/dashboard - sorting + new todo creation modal scroll concern
2 parents 5baee11 + edec79f commit b0ff279

20 files changed

Lines changed: 837 additions & 50 deletions

API.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,24 @@ Invalid column keys in `cursorByColumn` or malformed cursors → `VALIDATION_ERR
310310

311311
---
312312

313+
## REST: Dashboard assigned todos (`GET /api/dashboard/todos`)
314+
315+
The web app and other REST clients use this endpoint (separate from MCP). In **full** mode it requires a valid **session cookie** or **`Authorization: Bearer`** API token.
316+
317+
Query parameters:
318+
319+
- **`limit`** (optional): page size; default **20**, maximum **100**.
320+
- **`sort`** (optional): **`activity`** (default) or **`board`**. Invalid or empty values are treated as **`activity`** (backward compatible).
321+
- **`cursor`** (optional): pagination token from the previous JSON response’s **`nextCursor`** field.
322+
323+
**Activity sort** (default): rows are ordered by **`updated_at` DESC, `id` DESC**. The cursor is **`updatedAtMs:id`** (two integers, colon-separated, Unix ms for the todo’s `updated_at`).
324+
325+
**Board sort** (`sort=board`): rows are ordered by **`project_id` ASC, workflow column `position` ASC, `rank` ASC, `id` ASC**, matching board order within each project. Cross-project order follows numeric project id, not name or recency. The cursor is **`projectId:wcPosition:rank:todoId`** (four integers, colon-separated).
326+
327+
A **`cursor`** that does not match the selected **`sort`** (for example, an activity cursor while `sort=board`) is rejected with **HTTP 400** and error code **`VALIDATION_ERROR`**.
328+
329+
---
330+
313331
## Error codes
314332

315333
- **`AUTH_REQUIRED`** - Sign-in required (including some store unauthorized paths mapped from the store layer).

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@
33
> **Upgrades:** No breaking changes in **3.7.x** unless noted below.
44
55

6+
## [3.7.7] - 2026-04-03
7+
8+
### Features
9+
10+
- **Dashboard todo sort** - Sort assigned todos by **Activity** (recently updated, default) or **Board order** (per project: workflow column position, then lane rank). **`GET /api/dashboard/todos`** supports optional query **`sort=activity`** or **`sort=board`**; pagination **`cursor`** is tied to the active sort, and a cursor from the wrong mode is rejected with **400** **`VALIDATION_ERROR`**.
11+
12+
### Improvements
13+
14+
- **Todo dialog (mobile)** - New/edit todo form scrolls inside the modal on narrow viewports so header, fields, and Save stay usable (aligned with Settings-style scrolling).
15+
- **Dashboard sort preference (signed-in)** - Choice is saved under **`user_preferences`** key **`dashboardTodoSort`** and restored after login (still mirrored in **localStorage** for fast defaults). Server hydrate skips applying the stored value when it already matches in-memory state, and does not overwrite a sort the user changed locally before preferences finish loading.
16+
17+
---
18+
619
## [3.7.6] - 2026-04-02
720

821
### Features

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<p align="center">
22
<img width="372" src="internal/httpapi/web/githublogo.png" alt="scrumboy logo" />
33
<br />
4-
<img src="https://img.shields.io/badge/version-v3.7.6-blue" alt="version" />
4+
<img src="https://img.shields.io/badge/version-v3.7.7-blue" alt="version" />
55
<a href="LICENSE">
66
<img src="https://img.shields.io/badge/license-AGPL--v3-orange" alt="license" />
77
</a>

internal/httpapi/dashboard.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,11 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request, rest []
5858
cursor = &rawCursor
5959
}
6060

61-
items, nextCursor, err := s.store.ListDashboardTodos(ctx, userID, limit, cursor)
61+
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
62+
63+
items, nextCursor, err := s.store.ListDashboardTodos(ctx, userID, limit, cursor, sort)
6264
if err != nil {
63-
writeInternal(w, err)
65+
writeStoreErr(w, err, false)
6466
return
6567
}
6668
writeJSON(w, http.StatusOK, dashboardTodosToJSON(items, nextCursor))

internal/httpapi/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ type storeAPI interface {
101101
ListTagCounts(ctx context.Context, pc *store.ProjectContext) ([]store.TagCount, error)
102102
ListTodosForBoardLane(ctx context.Context, projectID int64, columnKey string, limit int, afterRank, afterID int64, tagFilter, searchFilter string, sprintFilter store.SprintFilter) ([]store.Todo, string, bool, error)
103103
GetDashboardSummary(ctx context.Context, userID int64, timezone string) (store.DashboardSummary, error)
104-
ListDashboardTodos(ctx context.Context, userID int64, limit int, cursor *string) ([]store.DashboardTodo, *string, error)
104+
ListDashboardTodos(ctx context.Context, userID int64, limit int, cursor *string, sort string) ([]store.DashboardTodo, *string, error)
105105
GetBacklogSize(ctx context.Context, projectID int64, mode store.Mode) ([]store.BurndownPoint, error)
106106
GetRealBurndown(ctx context.Context, projectID int64, mode store.Mode) ([]store.RealBurndownPoint, error)
107107
GetRealBurndownForSprint(ctx context.Context, projectID, sprintID int64, mode store.Mode) ([]store.RealBurndownPoint, error)

internal/httpapi/web/dist/router.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { apiFetch } from './api.js';
22
import { renderAuth, renderResetPassword, renderProjects, renderDashboard, renderBoard, renderNotFound, stopBoardEvents } from './views/index.js';
33
import { getAuthStatusChecked, getUser, getBootstrapAvailable, getAuthStatusAvailable, getBoard } from './state/selectors.js';
4-
import { setAuthStatusChecked, setAuthStatusAvailable, setUser, setBootstrapAvailable, setRoute, setTag, setSearch, setSlug, setProjectId, setBoard, resetUserScopedState, setTagColors, setOpenTodoSegment } from './state/mutations.js';
4+
import { setAuthStatusChecked, setAuthStatusAvailable, setUser, setBootstrapAvailable, setRoute, setTag, setSearch, setSlug, setProjectId, setBoard, resetUserScopedState, setTagColors, setOpenTodoSegment, hydrateDashboardTodoSortFromServer } from './state/mutations.js';
55
import { loadUserTheme } from './theme.js';
66
let isRouting = false;
77
let rerouteRequested = false;
@@ -118,6 +118,16 @@ async function routeOnce() {
118118
catch (err) {
119119
// Ignore errors
120120
}
121+
try {
122+
const sortResp = await apiFetch('/api/user/preferences?key=dashboardTodoSort');
123+
const v = sortResp?.value;
124+
if (v === 'board' || v === 'activity') {
125+
hydrateDashboardTodoSortFromServer(v);
126+
}
127+
}
128+
catch (err) {
129+
// Ignore errors
130+
}
121131
}
122132
}
123133
const r = parseRoute();

internal/httpapi/web/dist/state/mutations.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import { apiFetch } from '../api.js';
12
import { current } from './state.js';
3+
import { getUser } from './selectors.js';
24
const DEFAULT_LANE_META = () => ({});
5+
/** True after the user changes dashboard sort (not server hydrate). Skips applying stored preference so a fast local change is not overwritten when the GET returns. */
6+
let dashboardTodoSortUserTouched = false;
37
const VALID_ROUTES = new Set(['projects', 'dashboard', 'boardBySlug', 'reset-password', 'notfound']);
48
const VALID_PROJECT_VIEWS = new Set(['list', 'grid']);
59
export function setRoute(name) {
@@ -122,6 +126,38 @@ export function setDashboardNextCursor(cursor) {
122126
export function setDashboardLoading(loading) {
123127
current.dashboardLoading = loading;
124128
}
129+
export function setDashboardTodoSort(sort, opts) {
130+
const next = sort === 'board' ? 'board' : 'activity';
131+
if (current.dashboardTodoSort === next) {
132+
return;
133+
}
134+
if (!opts?.skipRemote) {
135+
dashboardTodoSortUserTouched = true;
136+
}
137+
current.dashboardTodoSort = next;
138+
try {
139+
localStorage.setItem('scrumboy.dashboardTodoSort', next);
140+
}
141+
catch {
142+
/* ignore */
143+
}
144+
if (opts?.skipRemote || !getUser()) {
145+
return;
146+
}
147+
void apiFetch('/api/user/preferences', {
148+
method: 'PUT',
149+
body: JSON.stringify({ key: 'dashboardTodoSort', value: next }),
150+
}).catch(() => {
151+
/* ignore */
152+
});
153+
}
154+
/** Apply stored preference after login only if the user has not already changed sort this session. */
155+
export function hydrateDashboardTodoSortFromServer(sort) {
156+
if (dashboardTodoSortUserTouched) {
157+
return;
158+
}
159+
setDashboardTodoSort(sort, { skipRemote: true });
160+
}
125161
export function resetDashboard() {
126162
current.dashboardSummary = null;
127163
current.dashboardTodos = [];
@@ -150,4 +186,5 @@ export function resetUserScopedState() {
150186
current.dashboardNextCursor = null;
151187
current.dashboardLoading = false;
152188
current.boardLaneMeta = DEFAULT_LANE_META();
189+
dashboardTodoSortUserTouched = false;
153190
}

internal/httpapi/web/dist/state/selectors.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ export function getDashboardNextCursor() {
9494
export function getDashboardLoading() {
9595
return !!current.dashboardLoading;
9696
}
97+
export function getDashboardTodoSort() {
98+
return current.dashboardTodoSort === 'board' ? 'board' : 'activity';
99+
}
97100
export function getBoardLaneMeta() {
98101
return current.boardLaneMeta ?? {
99102
BACKLOG: { hasMore: false, nextCursor: null, loading: false },

internal/httpapi/web/dist/state/state.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ let _current = {
2222
dashboardTodos: [],
2323
dashboardNextCursor: null,
2424
dashboardLoading: false,
25+
dashboardTodoSort: (() => {
26+
try {
27+
if (typeof localStorage !== 'undefined' && localStorage.getItem('scrumboy.dashboardTodoSort') === 'board') {
28+
return 'board';
29+
}
30+
}
31+
catch {
32+
/* ignore */
33+
}
34+
return 'activity';
35+
})(),
2536
boardLaneMeta: { BACKLOG: { hasMore: false, nextCursor: null, loading: false }, NOT_STARTED: { hasMore: false, nextCursor: null, loading: false }, IN_PROGRESS: { hasMore: false, nextCursor: null, loading: false }, TESTING: { hasMore: false, nextCursor: null, loading: false }, DONE: { hasMore: false, nextCursor: null, loading: false } },
2637
};
2738
// DEPRECATED: Direct access to current is deprecated. Use selectors/mutations instead.

internal/httpapi/web/dist/views/dashboard.js

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,40 @@ import { app, settingsDialog } from '../dom/elements.js';
22
import { apiFetch } from '../api.js';
33
import { navigate } from '../router.js';
44
import { escapeHTML, renderUserAvatar, sanitizeHexColor } from '../utils.js';
5-
import { getDashboardLoading, getDashboardNextCursor, getDashboardSummary, getDashboardTodos, getProjects, getUser, } from '../state/selectors.js';
6-
import { appendDashboardTodos, setDashboardLoading, setDashboardNextCursor, setProjectsTab, setSettingsActiveTab, setDashboardSummary, setDashboardTodos, } from '../state/mutations.js';
5+
import { getDashboardLoading, getDashboardNextCursor, getDashboardSummary, getDashboardTodos, getDashboardTodoSort, getProjects, getUser, } from '../state/selectors.js';
6+
import { appendDashboardTodos, setDashboardLoading, setDashboardNextCursor, setDashboardTodoSort, setProjectsTab, setSettingsActiveTab, setDashboardSummary, setDashboardTodos, } from '../state/mutations.js';
77
import { renderSettingsModal } from '../dialogs/settings.js';
88
const BOUND_FLAG = Symbol('bound');
9+
const DASHBOARD_SORT_HINT = "Order matches each project's board: column, then drag order. Projects appear in a fixed order (not alphabetical or by activity).";
10+
function dashboardTodosQueryString() {
11+
let q = 'limit=20';
12+
if (getDashboardTodoSort() === 'board') {
13+
q += '&sort=board';
14+
}
15+
return q;
16+
}
17+
/** Narrow viewports use a shorter board option label so the select can stay compact. */
18+
function boardOrderOptionText() {
19+
return typeof window !== 'undefined' && window.innerWidth <= 767 ? 'Board Order' : 'Board Order (per project)';
20+
}
21+
function renderDashboardPanelHeader() {
22+
const sort = getDashboardTodoSort();
23+
const hint = escapeHTML(DASHBOARD_SORT_HINT);
24+
const titleAttr = escapeHTML(DASHBOARD_SORT_HINT);
25+
const boardLabel = escapeHTML(boardOrderOptionText());
26+
return `
27+
<div class="panel__header panel__header--dashboard">
28+
<div class="panel__title">Dashboard</div>
29+
<div class="dashboard-sort">
30+
<label class="dashboard-sort__label" for="dashboardTodoSort">Sort</label>
31+
<select id="dashboardTodoSort" class="dashboard-sort__select" aria-describedby="dashboardSortHint" title="${titleAttr}">
32+
<option value="activity" ${sort === 'activity' ? 'selected' : ''}>Activity</option>
33+
<option value="board" ${sort === 'board' ? 'selected' : ''}>${boardLabel}</option>
34+
</select>
35+
</div>
36+
</div>
37+
<p id="dashboardSortHint" class="dashboard-sort__hint muted">${hint}</p>`;
38+
}
939
function renderTopTabs() {
1040
const projects = getProjects() || [];
1141
const durableProjects = projects.filter((p) => !p.expiresAt);
@@ -47,9 +77,7 @@ function renderLoadingShell() {
4777
</div>
4878
<div class="container">
4979
<div class="panel">
50-
<div class="panel__header">
51-
<div class="panel__title">Dashboard</div>
52-
</div>
80+
${renderDashboardPanelHeader()}
5381
${renderTopTabs()}
5482
<div class="list">
5583
<div class="list__item"><div class="muted">Loading assigned todos...</div></div>
@@ -61,6 +89,7 @@ function renderLoadingShell() {
6189
</div>
6290
`;
6391
bindTopNav();
92+
bindDashboardSort();
6493
bindAvatarButton();
6594
}
6695
function renderDashboardContent() {
@@ -205,9 +234,7 @@ function renderDashboardContent() {
205234
</div>
206235
<div class="container">
207236
<div class="panel">
208-
<div class="panel__header">
209-
<div class="panel__title">Dashboard</div>
210-
</div>
237+
${renderDashboardPanelHeader()}
211238
${renderTopTabs()}
212239
${contentMarkup}
213240
</div>
@@ -216,6 +243,7 @@ function renderDashboardContent() {
216243
`;
217244
bindTopNav();
218245
bindLoadMore();
246+
bindDashboardSort();
219247
bindAvatarButton();
220248
}
221249
function hexToRgba(hex, alpha) {
@@ -381,6 +409,41 @@ function bindTopNav() {
381409
}
382410
});
383411
}
412+
function bindDashboardSort() {
413+
const sel = document.getElementById('dashboardTodoSort');
414+
if (!sel || sel[BOUND_FLAG]) {
415+
return;
416+
}
417+
sel.addEventListener('change', async () => {
418+
const next = sel.value === 'board' ? 'board' : 'activity';
419+
const prev = getDashboardTodoSort();
420+
if (next === prev) {
421+
return;
422+
}
423+
setDashboardTodoSort(next);
424+
setDashboardLoading(true);
425+
renderDashboardContent();
426+
try {
427+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
428+
const [summary, todosResp] = await Promise.all([
429+
apiFetch(`/api/dashboard/summary?tz=${encodeURIComponent(tz)}`),
430+
apiFetch(`/api/dashboard/todos?${dashboardTodosQueryString()}`),
431+
]);
432+
setDashboardSummary(summary);
433+
setDashboardTodos(todosResp.items || []);
434+
setDashboardNextCursor(todosResp.nextCursor || null);
435+
}
436+
catch (err) {
437+
setDashboardTodoSort(prev);
438+
console.error('Dashboard refetch failed:', err);
439+
}
440+
finally {
441+
setDashboardLoading(false);
442+
renderDashboardContent();
443+
}
444+
});
445+
sel[BOUND_FLAG] = true;
446+
}
384447
function bindLoadMore() {
385448
const loadMoreBtn = document.getElementById('dashboardLoadMoreBtn');
386449
if (!loadMoreBtn || loadMoreBtn[BOUND_FLAG]) {
@@ -394,7 +457,7 @@ function bindLoadMore() {
394457
renderDashboardContent();
395458
try {
396459
const cursor = getDashboardNextCursor();
397-
const resp = await apiFetch(`/api/dashboard/todos?limit=20&cursor=${encodeURIComponent(cursor || '')}`);
460+
const resp = await apiFetch(`/api/dashboard/todos?${dashboardTodosQueryString()}&cursor=${encodeURIComponent(cursor || '')}`);
398461
appendDashboardTodos(resp.items || []);
399462
setDashboardNextCursor(resp.nextCursor || null);
400463
}
@@ -412,7 +475,7 @@ export async function renderDashboard() {
412475
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
413476
const [summary, todosResp] = await Promise.all([
414477
apiFetch(`/api/dashboard/summary?tz=${encodeURIComponent(tz)}`),
415-
apiFetch('/api/dashboard/todos?limit=20'),
478+
apiFetch(`/api/dashboard/todos?${dashboardTodosQueryString()}`),
416479
]);
417480
setDashboardSummary(summary);
418481
setDashboardTodos(todosResp.items || []);

0 commit comments

Comments
 (0)