Skip to content

Commit c76c6a3

Browse files
committed
feat(desktop): redesign the Timeline view and make it an opt-in feature (#1111)
1 parent 3fa616e commit c76c6a3

26 files changed

Lines changed: 537 additions & 135 deletions

apps/desktop/src/App.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,29 @@ describe('App', () => {
9494
expect(getByRole('heading', { name: 'Focus' })).toBeInTheDocument();
9595
});
9696

97+
it('falls back to the default view for ?view=timeline while Timeline is off (#1111)', async () => {
98+
window.history.replaceState(null, '', '?view=timeline');
99+
100+
const { getByRole } = renderWithProviders(<App />);
101+
102+
// Same landing as an unknown view name: Timeline is opt-in, so a stored
103+
// view or a link to it must not leave the screen blank.
104+
await waitFor(() => {
105+
expect(getByRole('heading', { name: 'Focus' })).toBeInTheDocument();
106+
});
107+
});
108+
109+
it('opens ?view=timeline once the Timeline feature is switched on (#1111)', async () => {
110+
useTaskStore.setState((state) => ({ ...state, settings: { features: { timeline: true } } }));
111+
window.history.replaceState(null, '', '?view=timeline');
112+
113+
const { getByRole } = renderWithProviders(<App />);
114+
115+
await waitFor(() => {
116+
expect(getByRole('heading', { name: 'Timeline' })).toBeInTheDocument();
117+
}, { timeout: 5000 });
118+
});
119+
97120
it('writes the resolved initial view back into the URL on a fresh load with no ?view= param (#931 follow-up)', async () => {
98121
window.history.replaceState(null, '', '/');
99122
expect(window.location.search).toBe('');

apps/desktop/src/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ function App() {
285285
const fetchData = useTaskStore((state) => state.fetchData);
286286
const seedGettingStarted = useTaskStore((state) => state.seedGettingStarted);
287287
const isLoading = useTaskStore((state) => state.isLoading);
288+
const timelineEnabled = useTaskStore((state) => resolveFeatureFlags(state.settings).timeline);
288289
const visibleDataCount = useTaskStore((state) => (
289290
state.tasks.length + state.projects.length + state.sections.length + state.areas.length
290291
));
@@ -1158,7 +1159,13 @@ function App() {
11581159
const savedSearchId = activeView.replace('savedSearch:', '');
11591160
return <SearchView savedSearchId={savedSearchId} />;
11601161
}
1161-
switch (activeView) {
1162+
// Timeline is opt-in (#1111). Resolving it here rather than in the
1163+
// restore/URL readers is what lets it see loaded settings, and it
1164+
// covers every way in at once: a stored last view, ?view=timeline, a
1165+
// keybinding and a stale nav click all land on the default view while
1166+
// the flag is off, exactly like an unknown view name does.
1167+
const view = activeView === 'timeline' && !timelineEnabled ? DEFAULT_DESKTOP_VIEW : activeView;
1168+
switch (view) {
11621169
case 'inbox':
11631170
return <ListView title={t('list.inbox')} statusFilter="inbox" />;
11641171
case 'agenda':

apps/desktop/src/components/KeybindingHelpModal.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function KeybindingHelpModal({
3131
}: KeybindingHelpModalProps) {
3232
const titleId = useId();
3333
const prioritiesEnabled = useTaskStore((state) => resolveFeatureFlags(state.settings).priorities);
34+
const timelineEnabled = useTaskStore((state) => resolveFeatureFlags(state.settings).timeline);
3435
const isMac = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform);
3536
const quickAddShortcutDisplay = formatGlobalQuickAddShortcutForDisplay(quickAddShortcut, isMac);
3637
const sharedGlobal: HelpItem[] = [
@@ -63,7 +64,7 @@ export function KeybindingHelpModal({
6364
{ keys: 'ge', labelKey: 'keybindings.goReference' },
6465
{ keys: 'gl', labelKey: 'keybindings.goCalendar' },
6566
{ keys: 'gb', labelKey: 'keybindings.goBoard' },
66-
{ keys: 'gt', labelKey: 'keybindings.goTimeline' },
67+
...(timelineEnabled ? [{ keys: 'gt', labelKey: 'keybindings.goTimeline' }] : []),
6768
{ keys: 'gd', labelKey: 'keybindings.goDone' },
6869
{ keys: 'ga', labelKey: 'keybindings.goArchived' },
6970
{ keys: '1-9 / Shift+A 1-9', labelKey: 'keybindings.switchArea', fallbackLabel: 'Switch to Area 1-9' },
@@ -117,7 +118,7 @@ export function KeybindingHelpModal({
117118
{ keys: 'Alt-e', labelKey: 'keybindings.goReference' },
118119
{ keys: 'Alt-l', labelKey: 'keybindings.goCalendar' },
119120
{ keys: 'Alt-b', labelKey: 'keybindings.goBoard' },
120-
{ keys: 'Alt-t', labelKey: 'keybindings.goTimeline' },
121+
...(timelineEnabled ? [{ keys: 'Alt-t', labelKey: 'keybindings.goTimeline' }] : []),
121122
{ keys: 'Alt-d', labelKey: 'keybindings.goDone' },
122123
{ keys: 'Alt-A', labelKey: 'keybindings.goArchived' },
123124
{ keys: '1-9 / Shift+A 1-9', labelKey: 'keybindings.switchArea', fallbackLabel: 'Switch to Area 1-9' },

apps/desktop/src/components/Layout.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
type LucideIcon,
2727
} from 'lucide-react';
2828
import { cn } from '../lib/utils';
29-
import { shallow, useTaskStore, safeFormatDate, tFallback, isAllowedInsecureUrl, formatTaskMovedMessage, isSyncFileLockUnavailableError } from '@mindwtr/core';
29+
import { shallow, useTaskStore, resolveFeatureFlags, safeFormatDate, tFallback, isAllowedInsecureUrl, formatTaskMovedMessage, isSyncFileLockUnavailableError } from '@mindwtr/core';
3030
import type { StoreActionResult, TaskStatus } from '@mindwtr/core';
3131
import { showUndoToast } from '../lib/undo-registry';
3232
import { useLanguage } from '../contexts/language-context';
@@ -130,6 +130,8 @@ export function Layout({ children, currentView, onViewChange, onOpenSyncSettings
130130
const isFocusMode = useUiStore((state) => state.isFocusMode);
131131
const showToast = useUiStore((state) => state.showToast);
132132
const isObsidianEnabled = useObsidianStore((state) => state.config.enabled);
133+
// Timeline is opt-in (#1111): hidden from navigation until it is switched on.
134+
const isTimelineEnabled = resolveFeatureFlags(settings).timeline;
133135
const [syncStatus, setSyncStatus] = useState(() => SyncService.getSyncStatus());
134136
const [isManualSyncing, setIsManualSyncing] = useState(false);
135137
const [isOnline, setIsOnline] = useState(() => (typeof navigator !== 'undefined' ? navigator.onLine : true));
@@ -314,7 +316,6 @@ export function Layout({ children, currentView, onViewChange, onOpenSyncSettings
314316
const isWideView = wideViews.has(currentView);
315317
const fullWidthViews = new Set([
316318
'board',
317-
'timeline',
318319
'projects',
319320
'contexts',
320321
'obsidian',
@@ -352,7 +353,9 @@ export function Layout({ children, currentView, onViewChange, onOpenSyncSettings
352353
? [{ id: 'obsidian', labelKey: 'nav.obsidian', fallbackLabel: 'Obsidian', icon: BookOpen }]
353354
: []),
354355
{ id: 'board', labelKey: 'nav.board', icon: Kanban },
355-
{ id: 'timeline', labelKey: 'nav.timeline', fallbackLabel: 'Timeline', icon: GanttChartSquare },
356+
...(isTimelineEnabled
357+
? [{ id: 'timeline', labelKey: 'nav.timeline', fallbackLabel: 'Timeline', icon: GanttChartSquare }]
358+
: []),
356359
],
357360
},
358361
{
@@ -364,7 +367,7 @@ export function Layout({ children, currentView, onViewChange, onOpenSyncSettings
364367
{ id: 'trash', labelKey: 'nav.trash', icon: Trash2, tone: 'recessed' },
365368
],
366369
},
367-
]), [inboxCount, isObsidianEnabled, t]);
370+
]), [inboxCount, isObsidianEnabled, isTimelineEnabled, t]);
368371

369372
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(() => loadCollapsedSections());
370373

@@ -1027,7 +1030,8 @@ export function Layout({ children, currentView, onViewChange, onOpenSyncSettings
10271030
? "w-full max-w-none"
10281031
// The week/month grids want more room than a list does, but going
10291032
// edge-to-edge looks wrong, so the calendar keeps its side margins (#966).
1030-
: currentView === 'calendar'
1033+
// The timeline is the same shape of chart and takes the same box (#1111).
1034+
: currentView === 'calendar' || currentView === 'timeline'
10311035
? "w-full max-w-screen-2xl"
10321036
: isWideView
10331037
? "w-full max-w-6xl"

apps/desktop/src/components/views/TimelineView.test.tsx

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { beforeEach, describe, it, expect } from 'vitest';
22
import { render, screen } from '@testing-library/react';
3-
import { TimelineView } from './TimelineView';
3+
import { TimelineView, resolveTimelineTrack } from './TimelineView';
44
import { LanguageProvider } from '../../contexts/language-context';
55
import { useTaskStore, type Area, type Project, type Task } from '@mindwtr/core';
66

@@ -42,6 +42,26 @@ const renderTimeline = () => render(
4242

4343
const bars = () => Array.from(document.querySelectorAll('[data-testid="timeline-bar"]')) as HTMLElement[];
4444
const barFor = (taskId: string) => document.querySelector(`[data-testid="timeline-bar"][data-task-id="${taskId}"]`) as HTMLElement | null;
45+
// Group headings and bar titles in one pass, in the order they are laid out.
46+
const rowLabels = () => Array.from(
47+
document.querySelectorAll('[data-testid="timeline-group"], [data-testid="timeline-bar"]'),
48+
).map((node) => node.textContent);
49+
const axisLabels = (tier: 'major' | 'minor') => Array.from(
50+
document.querySelectorAll(`[data-testid="timeline-axis-${tier}"]`),
51+
).map((node) => node.textContent ?? '');
52+
53+
describe('resolveTimelineTrack (#1111)', () => {
54+
it('stretches a range that fits the pane and scrolls one that does not', () => {
55+
// 30 days at the month zoom's 4px minimum is 120px in a 1200px pane.
56+
expect(resolveTimelineTrack(30, 4, 1200)).toEqual({ dayWidth: 40, trackWidth: 1200, fitted: true });
57+
// 400 days at 4px overflows, so the minimum stands and the track scrolls.
58+
expect(resolveTimelineTrack(400, 4, 1200)).toEqual({ dayWidth: 4, trackWidth: 1600, fitted: false });
59+
// Exactly full is still fitted, and the pre-measure paint uses the minimum.
60+
expect(resolveTimelineTrack(100, 12, 1200).fitted).toBe(true);
61+
expect(resolveTimelineTrack(30, 4, 0)).toEqual({ dayWidth: 4, trackWidth: 120, fitted: false });
62+
expect(resolveTimelineTrack(0, 4, 1200)).toEqual({ dayWidth: 4, trackWidth: 0, fitted: false });
63+
});
64+
});
4565

4666
describe('TimelineView (#1111)', () => {
4767
beforeEach(() => {
@@ -71,7 +91,7 @@ describe('TimelineView (#1111)', () => {
7191
renderTimeline();
7292
expect(barFor('start-only')?.dataset.variant).toBe('mini');
7393
expect(barFor('due-only')?.dataset.variant).toBe('mini');
74-
expect(barFor('start-only')?.style.width).toBe('10px');
94+
expect(barFor('start-only')?.style.width).toBe('56px');
7595
});
7696

7797
it('leaves out undated, done and deleted tasks', () => {
@@ -87,11 +107,14 @@ describe('TimelineView (#1111)', () => {
87107
expect(bars().map((bar) => bar.dataset.taskId)).toEqual(['dated']);
88108
});
89109

90-
it("colors a bar with its project's area color, falling back to the project color", () => {
110+
it('colors a bar with the same accent the calendar gives that task', () => {
91111
setStore({
92112
tasks: [
93113
makeTask({ id: 'in-area', title: 'In area', projectId: 'p1', startTime: iso(0), dueDate: iso(1) }),
94114
makeTask({ id: 'plain', title: 'Plain', projectId: 'p2', startTime: iso(0), dueDate: iso(1) }),
115+
// No project, area straight on the task: colored on the calendar,
116+
// so colored here too.
117+
makeTask({ id: 'loose', title: 'Loose', areaId: 'a1', startTime: iso(0), dueDate: iso(1) }),
95118
],
96119
projects: [
97120
{ id: 'p1', title: 'Area project', status: 'active', areaId: 'a1', createdAt: iso(-60), updatedAt: iso(-60) } as Project,
@@ -102,6 +125,7 @@ describe('TimelineView (#1111)', () => {
102125
renderTimeline();
103126
expect(barFor('in-area')?.style.backgroundColor).toBe('rgb(255, 0, 0)');
104127
expect(barFor('plain')?.style.backgroundColor).toBe('rgb(0, 255, 0)');
128+
expect(barFor('loose')?.style.backgroundColor).toBe('rgb(255, 0, 0)');
105129
});
106130

107131
it('groups rows by project with unassigned tasks last', () => {
@@ -113,8 +137,27 @@ describe('TimelineView (#1111)', () => {
113137
projects: [{ id: 'p1', title: 'Area project', status: 'active', createdAt: iso(-60), updatedAt: iso(-60) } as Project],
114138
});
115139
renderTimeline();
116-
const labels = Array.from(document.querySelectorAll('span.truncate')).map((node) => node.textContent);
117-
expect(labels).toEqual(['Area project', 'Owned task', 'No project', 'Loose task']);
140+
expect(rowLabels()).toEqual(['Area project', 'Owned task', 'No project', 'Loose task']);
141+
});
142+
143+
it('splits the month-zoom axis into a year tier and month ticks, and floors thin bars', () => {
144+
// The shipped axis printed "MMM yyyy" on every month start, which
145+
// collided at 4px per day; the year moves to the top tier instead.
146+
window.localStorage.setItem('mindwtr:view:timeline:v1', JSON.stringify({ zoom: 'month' }));
147+
setStore({
148+
tasks: [
149+
makeTask({ id: 'long', title: 'Long haul', startTime: iso(-60), dueDate: iso(90) }),
150+
makeTask({ id: 'oneday', title: 'One day', startTime: iso(5), dueDate: iso(5) }),
151+
],
152+
});
153+
renderTimeline();
154+
155+
expect(axisLabels('major').every((label) => /^\d{4}$/.test(label))).toBe(true);
156+
const minor = axisLabels('minor');
157+
expect(minor.length).toBeGreaterThan(2);
158+
expect(minor.every((label) => /^[A-Za-z]+$/.test(label))).toBe(true);
159+
// One day is 4px at month zoom; a bar never renders as a sliver.
160+
expect(barFor('oneday')?.style.width).toBe('10px');
118161
});
119162

120163
it('marks today and shows the empty state when nothing is dated', () => {

0 commit comments

Comments
 (0)