-
Notifications
You must be signed in to change notification settings - Fork 282
Expand file tree
/
Copy pathpage.tsx
More file actions
338 lines (305 loc) · 11.9 KB
/
Copy pathpage.tsx
File metadata and controls
338 lines (305 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
'use client';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
Suspense,
} from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import EmptyState from '../../components/EmptyState';
import MilestonesList from '../../components/MilestonesList';
import MilestoneFilter, {
type MilestoneStatusFilter,
} from '../../components/milestones/MilestoneFilter';
import { MilestoneCreationForm } from '../../components/milestones/MilestoneCreationForm';
import { useOptimisticMilestoneMutation } from '@/hooks/useOptimisticMilestoneMutation';
import { listMilestones } from '@/lib/repository';
import { listMilestones, saveMilestone, updateMilestone } from '@/lib/repository';
import { getItem, setItem } from '@/lib/safeStorage';
import { useToast } from '@/components/toast/toast-provider';
import SafeBoundary from '@/components/SafeBoundary';
import { downloadMilestonesICS } from '@/lib/icsExport';
import { KbdHint } from '@/components/KbdHint';
import { useMilestonesKeyboardShortcuts } from '@/hooks/useMilestonesKeyboardShortcuts';
import { SAMPLE_MILESTONES, SAMPLE_DISMISSED_KEY } from './constants';
import type { Milestone } from '@/types/domain';
const UNPAGINATED_LIST_SIZE = 9999;
const VALID_STATUSES: MilestoneStatusFilter[] = [
'All',
'Pending',
'Completed',
'Paid',
'Disputed',
];
function getValidStatus(param: string | null): MilestoneStatusFilter {
return param && (VALID_STATUSES as string[]).includes(param)
? (param as MilestoneStatusFilter)
: 'All';
}
type MilestoneSortOption = 'newest' | 'oldest';
const VALID_SORT_OPTIONS: MilestoneSortOption[] = ['newest', 'oldest'];
function getValidSortOption(param: string | null): MilestoneSortOption {
return param && (VALID_SORT_OPTIONS as string[]).includes(param)
? (param as MilestoneSortOption)
: 'newest';
}
const MilestonesContent: React.FC = () => {
const [milestones, setMilestones] = useState<Milestone[]>(SAMPLE_MILESTONES);
const [isDismissed, setIsDismissed] = useState<boolean>(false);
const searchParams = useSearchParams();
const router = useRouter();
const headingRef = useRef<HTMLHeadingElement | null>(null);
const startFromScratchRef = useRef<HTMLButtonElement | null>(null);
const initialStatus = getValidStatus(searchParams.get('status'));
const [statusFilter, setStatusFilter] =
useState<MilestoneStatusFilter>(initialStatus);
const [sortOrder, setSortOrder] = useState<MilestoneSortOption>(
getValidSortOption(searchParams.get('sort')),
);
const [showForm, setShowForm] = useState(false);
const { showError } = useToast();
useEffect(() => {
setStatusFilter(getValidStatus(searchParams.get('status')));
setSortOrder(getValidSortOption(searchParams.get('sort')));
}, [searchParams]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
const params = new URLSearchParams(searchParams.toString());
if (statusFilter !== 'All') {
params.set('status', statusFilter);
} else {
params.delete('status');
}
if (sortOrder !== 'newest') {
params.set('sort', sortOrder);
} else {
params.delete('sort');
}
const query = params.toString();
router.replace(query ? `?${query}` : '?');
}, 150);
return () => window.clearTimeout(timeoutId);
}, [statusFilter, sortOrder, router, searchParams]);
useEffect(() => {
const persisted = listMilestones();
if (persisted.length > 0) {
setMilestones(persisted);
setIsDismissed(true);
} else {
try {
const dismissed = getItem(SAMPLE_DISMISSED_KEY) === 'true';
setIsDismissed(dismissed);
} catch {
setIsDismissed(true);
}
setMilestones(SAMPLE_MILESTONES);
}
}, []);
const handleDismissSampleBanner = useCallback(() => {
try {
setItem(SAMPLE_DISMISSED_KEY, 'true');
} catch {
// safeStorage resilience
}
setIsDismissed(true);
setMilestones([]);
setTimeout(() => {
headingRef.current?.focus();
}, 0);
}, []);
const isUsingSampleData = milestones === SAMPLE_MILESTONES;
const showSampleBanner = isUsingSampleData && !isDismissed;
const displayMilestones = isUsingSampleData && isDismissed ? [] : milestones;
const filtered = useMemo(() => {
if (statusFilter === 'All') return displayMilestones;
return displayMilestones.filter((m) => m.status === statusFilter);
}, [displayMilestones, statusFilter]);
const sortedMilestones = useMemo(() => {
const nextMilestones = [...filtered];
if (sortOrder === 'oldest') {
nextMilestones.sort((left, right) => {
const leftTime = left.dueDate ? Date.parse(left.dueDate) : Number.POSITIVE_INFINITY;
const rightTime = right.dueDate ? Date.parse(right.dueDate) : Number.POSITIVE_INFINITY;
return leftTime - rightTime;
});
} else {
nextMilestones.sort((left, right) => {
const leftTime = left.dueDate ? Date.parse(left.dueDate) : Number.NEGATIVE_INFINITY;
const rightTime = right.dueDate ? Date.parse(right.dueDate) : Number.NEGATIVE_INFINITY;
return rightTime - leftTime;
});
}
return nextMilestones;
}, [filtered, sortOrder]);
const handleAddMilestone = useCallback(() => {
setShowForm(true);
}, []);
const handleSubmitMilestone = useCallback((milestone: Milestone) => {
setShowForm(false);
saveMilestone(milestone);
setIsDismissed(true);
setMilestones((prev) => [...prev, milestone]);
}, [optimisticCreate, showError]);
}, []);
const handleCancelForm = useCallback(() => {
setShowForm(false);
}, []);
const handleAddToCalendar = useCallback(() => {
downloadMilestonesICS(sortedMilestones);
}, [sortedMilestones]);
useMilestonesKeyboardShortcuts({
onAddMilestone: handleAddMilestone,
onAddToCalendar: handleAddToCalendar,
enabled: !showForm,
});
const handleUpdateMilestone = useCallback(
(id: string, patch: Partial<Milestone>): boolean => {
try {
updateMilestone(id, patch);
setMilestones((prev) =>
prev.map((item) => (item.id === id ? { ...item, ...patch } : item)),
);
return true;
} catch {
showError({
title: 'Unable to update milestone',
description: 'Your milestone could not be saved. Please try again.',
});
return false;
}
},
[showError],
);
return (
<div className="min-h-screen p-8">
<h1 ref={headingRef} tabIndex={-1} className="text-2xl font-bold mb-6 focus:outline-none">
Milestones
</h1>
{showSampleBanner && (
<div
data-testid="sample-data-banner"
role="status"
aria-label="Sample data notice"
className="mb-6 rounded-2xl border border-blue-100 bg-blue-50 p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-blue-900">
You're viewing sample data
</p>
<p className="mt-1 text-sm text-blue-700">
These are example milestones to help you get started.
</p>
<button
ref={startFromScratchRef}
data-testid="start-from-scratch-btn"
type="button"
onClick={handleDismissSampleBanner}
className="mt-3 rounded-xl bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-blue-700 focus-visible:outline focus-visible:outline-4 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
Start from scratch
</button>
</div>
<button
type="button"
onClick={handleDismissSampleBanner}
aria-label="Dismiss sample data notice"
className="rounded-sm text-blue-500 hover:text-blue-700 focus-visible:outline focus-visible:outline-4 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
×
</button>
</div>
</div>
)}
{displayMilestones.length === 0 ? (
<EmptyState
illustration="milestones"
title="No milestones tracked"
description="Track your progress by adding milestones to your contracts. Milestones help you stay organized and ensure timely delivery."
actionLabel="Add Milestone"
onAction={handleAddMilestone}
/>
) : (
<>
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<MilestoneFilter
selected={statusFilter}
onChange={setStatusFilter}
resultCount={sortedMilestones.length}
/>
<div className="flex flex-wrap items-center gap-3">
<label
htmlFor="milestone-sort"
className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 shadow-sm"
>
<span className="font-medium text-slate-700">Sort</span>
<select
id="milestone-sort"
aria-label="Sort milestones"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value as MilestoneSortOption)}
className="rounded-xl border border-slate-200 bg-transparent px-2 py-1 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</label>
<button
type="button"
aria-label="Add Milestone"
onClick={handleAddMilestone}
className="flex-shrink-0 rounded-2xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 focus-visible:outline focus-visible:outline-4 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
Add Milestone
</button>
<button
type="button"
onClick={handleAddToCalendar}
aria-label="Add to calendar"
className="flex-shrink-0 rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-100 focus-visible:outline focus-visible:outline-4 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
<span aria-hidden="true" className="mr-1">📅</span>
Add to Calendar
</button>
<div className="flex flex-wrap items-center gap-3">
<KbdHint keys={['Ctrl', 'Shift', 'N']} label="add milestone" />
<KbdHint keys={['Ctrl', 'Shift', 'C']} label="add to calendar" />
</div>
</div>
</div>
{sortedMilestones.length === 0 ? (
<EmptyState
illustration="milestones"
title="No milestones match this filter"
description={`There are no ${statusFilter.toLowerCase()} milestones at the moment. Try a different filter or add a new milestone.`}
actionLabel="Add Milestone"
onAction={handleAddMilestone}
/>
) : (
<MilestonesList
milestones={sortedMilestones}
onUpdateMilestone={handleUpdateMilestone}
pageSize={UNPAGINATED_LIST_SIZE}
/>
)}
</>
)}
{showForm && (
<MilestoneCreationForm
onSubmit={handleSubmitMilestone}
onCancel={handleCancelForm}
/>
)}
</div>
);
};
const MilestonesPage: React.FC = () => (
<SafeBoundary fallbackTitle="Milestones failed to load.">
<Suspense fallback={null}>
<MilestonesContent />
</Suspense>
</SafeBoundary>
);
export default MilestonesPage;