1// ═══════════════════════════════════════════════════════════════════════════════
2// Homepage Global State (Svelte 5 Runes)
3// Single module-level $state with exported store object
4// ═══════════════════════════════════════════════════════════════════════════════
5
6import type {
7 HistoryEntry,
8 TimelineGroup,
9 DomainStat,
10 DailyVisitCount,
11 HourlyDistribution,
12 Favorite,
13 Folder,
14 Tag,
15 Analytics,
16 PanelType,
17 HistoryCleanupRange,
18} from './types';
19
20// ═══════════════════════════════════════════════════════════════════════════════
21// HISTORY STATE
22// ═══════════════════════════════════════════════════════════════════════════════
23
24let history = $state<HistoryEntry[]>([]);
25let timeline = $state<TimelineGroup[]>([]);
26let historyLoading = $state(true);
27let historyOffset = $state(0);
28let hasMoreHistory = $state(true);
29let historySearchQuery = $state('');
30let historySearchResults = $state<HistoryEntry[]>([]);
31let historySearching = $state(false);
32
33// ═══════════════════════════════════════════════════════════════════════════════
34// FAVORITES STATE
35// ═══════════════════════════════════════════════════════════════════════════════
36
37let favorites = $state<Favorite[]>([]);
38let folders = $state<Folder[]>([]);
39let tags = $state<Tag[]>([]);
40let favoritesLoading = $state(true);
41let selectedFolderId = $state<number | null>(null);
42let selectedTagIds = $state<number[]>([]);
43let editingFavorite = $state<Favorite | null>(null);
44
45// ═══════════════════════════════════════════════════════════════════════════════
46// ANALYTICS STATE
47// ═══════════════════════════════════════════════════════════════════════════════
48
49let analytics = $state<Analytics | null>(null);
50let analyticsLoading = $state(true);
51let domainStats = $state<DomainStat[]>([]);
52let dailyVisits = $state<DailyVisitCount[]>([]);
53let hourlyDistribution = $state<HourlyDistribution[]>([]);
54
55// ═══════════════════════════════════════════════════════════════════════════════
56// UI STATE
57// ═══════════════════════════════════════════════════════════════════════════════
58
59let activePanel = $state<PanelType>('history');
60let commandPaletteOpen = $state(false);
61let commandPaletteQuery = $state('');
62let focusedIndex = $state(-1); // -1 means no item focused (focus on search input)
63let confirmModalOpen = $state(false);
64let confirmModalMessage = $state('');
65let confirmModalAction = $state<(() => void) | null>(null);
66let cleanupModalOpen = $state(false);
67let cleanupRange = $state<HistoryCleanupRange>('hour');
68let pendingKeyPrefix = $state<string | null>(null);
69
70// ═══════════════════════════════════════════════════════════════════════════════
71// HELPER FUNCTIONS
72// ═══════════════════════════════════════════════════════════════════════════════
73
74function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] {
75 const groups = new Map<string, HistoryEntry[]>();
76 const today = new Date();
77 today.setHours(0, 0, 0, 0);
78 const yesterday = new Date(today);
79 yesterday.setDate(yesterday.getDate() - 1);
80
81 for (const entry of entries) {
82 const date = new Date(entry.last_visited);
83 const dateKey = date.toISOString().split('T')[0] ?? '';
84 if (!groups.has(dateKey)) {
85 groups.set(dateKey, []);
86 }
87 groups.get(dateKey)!.push(entry);
88 }
89
90 const result: TimelineGroup[] = [];
91 for (const [dateKey, items] of groups) {
92 const date = new Date(dateKey);
93 let label: string;
94
95 if (date >= today) {
96 label = 'Today';
97 } else if (date >= yesterday) {
98 label = 'Yesterday';
99 } else {
100 label = date.toLocaleDateString(undefined, {
101 weekday: 'short',
102 month: 'short',
103 day: 'numeric',
104 });
105 }
106
107 result.push({ date: dateKey, label, entries: items });
108 }
109
110 return result.sort((a, b) => b.date.localeCompare(a.date));
111}
112
113// ═══════════════════════════════════════════════════════════════════════════════
114// EXPORTED STORE
115// ═══════════════════════════════════════════════════════════════════════════════
116
117export const homepageState = {
118 // ─────────────────────────────────────────────────────────────────────────────
119 // History Getters
120 // ─────────────────────────────────────────────────────────────────────────────
121 get history() { return history; },
122 get timeline() { return timeline; },
123 get historyLoading() { return historyLoading; },
124 get historyOffset() { return historyOffset; },
125 get hasMoreHistory() { return hasMoreHistory; },
126 get historySearchQuery() { return historySearchQuery; },
127 get historySearchResults() { return historySearchResults; },
128 get historySearching() { return historySearching; },
129
130 // ─────────────────────────────────────────────────────────────────────────────
131 // Favorites Getters
132 // ─────────────────────────────────────────────────────────────────────────────
133 get favorites() { return favorites; },
134 get folders() { return folders; },
135 get tags() { return tags; },
136 get favoritesLoading() { return favoritesLoading; },
137 get selectedFolderId() { return selectedFolderId; },
138 get selectedTagIds() { return selectedTagIds; },
139 get editingFavorite() { return editingFavorite; },
140
141 // ─────────────────────────────────────────────────────────────────────────────
142 // Analytics Getters
143 // ─────────────────────────────────────────────────────────────────────────────
144 get analytics() { return analytics; },
145 get analyticsLoading() { return analyticsLoading; },
146 get domainStats() { return domainStats; },
147 get dailyVisits() { return dailyVisits; },
148 get hourlyDistribution() { return hourlyDistribution; },
149
150 // ─────────────────────────────────────────────────────────────────────────────
151 // UI Getters
152 // ─────────────────────────────────────────────────────────────────────────────
153 get activePanel() { return activePanel; },
154 get commandPaletteOpen() { return commandPaletteOpen; },
155 get commandPaletteQuery() { return commandPaletteQuery; },
156 get focusedIndex() { return focusedIndex; },
157 get confirmModalOpen() { return confirmModalOpen; },
158 get confirmModalMessage() { return confirmModalMessage; },
159 get cleanupModalOpen() { return cleanupModalOpen; },
160 get cleanupRange() { return cleanupRange; },
161 get pendingKeyPrefix() { return pendingKeyPrefix; },
162
163 // ─────────────────────────────────────────────────────────────────────────────
164 // Computed Properties
165 // ─────────────────────────────────────────────────────────────────────────────
166 get filteredFavorites(): Favorite[] {
167 let result = favorites;
168
169 // Filter by folder
170 if (selectedFolderId !== null) {
171 result = result.filter(f => f.folder_id === selectedFolderId);
172 }
173
174 // Filter by tags
175 if (selectedTagIds.length > 0) {
176 result = result.filter(f =>
177 f.tags?.some(t => selectedTagIds.includes(t.id))
178 );
179 }
180
181 return result;
182 },
183
184 get currentList(): HistoryEntry[] | Favorite[] {
185 switch (activePanel) {
186 case 'history':
187 return historySearchQuery ? historySearchResults : history;
188 case 'favorites':
189 return this.filteredFavorites;
190 default:
191 return [];
192 }
193 },
194
195 get currentListLength(): number {
196 return this.currentList.length;
197 },
198
199 // ─────────────────────────────────────────────────────────────────────────────
200 // History Actions
201 // ─────────────────────────────────────────────────────────────────────────────
202 setHistory(items: HistoryEntry[]) {
203 history = items;
204 timeline = groupHistoryByDate(items);
205 },
206
207 appendHistory(items: HistoryEntry[]) {
208 history = [...history, ...items];
209 timeline = groupHistoryByDate(history);
210 },
211
212 setHistoryLoading(loading: boolean) {
213 historyLoading = loading;
214 },
215
216 setHistoryOffset(offset: number) {
217 historyOffset = offset;
218 },
219
220 setHasMoreHistory(hasMore: boolean) {
221 hasMoreHistory = hasMore;
222 },
223
224 deleteHistoryEntry(id: number) {
225 history = history.filter(h => h.id !== id);
226 timeline = groupHistoryByDate(history);
227 historySearchResults = historySearchResults.filter(h => h.id !== id);
228 },
229
230 clearHistory() {
231 history = [];
232 timeline = [];
233 historySearchResults = [];
234 historyOffset = 0;
235 hasMoreHistory = true;
236 },
237
238 setHistorySearchQuery(q: string) {
239 historySearchQuery = q;
240 if (!q) {
241 historySearchResults = [];
242 }
243 },
244
245 setHistorySearchResults(results: HistoryEntry[]) {
246 historySearchResults = results;
247 },
248
249 setHistorySearching(searching: boolean) {
250 historySearching = searching;
251 },
252
253 // ─────────────────────────────────────────────────────────────────────────────
254 // Favorites Actions
255 // ─────────────────────────────────────────────────────────────────────────────
256 setFavorites(items: Favorite[]) {
257 favorites = items;
258 },
259
260 setFolders(items: Folder[]) {
261 folders = items;
262 },
263
264 setTags(items: Tag[]) {
265 tags = items;
266 },
267
268 setFavoritesLoading(loading: boolean) {
269 favoritesLoading = loading;
270 },
271
272 selectFolder(id: number | null) {
273 selectedFolderId = id;
274 focusedIndex = 0;
275 },
276
277 toggleTag(id: number) {
278 if (selectedTagIds.includes(id)) {
279 selectedTagIds = selectedTagIds.filter(t => t !== id);
280 } else {
281 selectedTagIds = [...selectedTagIds, id];
282 }
283 focusedIndex = 0;
284 },
285
286 clearTagSelection() {
287 selectedTagIds = [];
288 focusedIndex = 0;
289 },
290
291 setEditingFavorite(fav: Favorite | null) {
292 editingFavorite = fav;
293 },
294
295 updateFavorite(updated: Favorite) {
296 favorites = favorites.map(f => f.id === updated.id ? updated : f);
297 },
298
299 deleteFavorite(id: number) {
300 favorites = favorites.filter(f => f.id !== id);
301 },
302
303 addFolder(folder: Folder) {
304 folders = [...folders, folder];
305 },
306
307 updateFolder(updated: Folder) {
308 folders = folders.map(f => f.id === updated.id ? updated : f);
309 },
310
311 deleteFolder(id: number) {
312 folders = folders.filter(f => f.id !== id);
313 // Clear folder selection if deleted folder was selected
314 if (selectedFolderId === id) {
315 selectedFolderId = null;
316 }
317 // Update favorites that were in this folder
318 favorites = favorites.map(f =>
319 f.folder_id === id ? { ...f, folder_id: null } : f
320 );
321 },
322
323 addTag(tag: Tag) {
324 tags = [...tags, tag];
325 },
326
327 updateTag(updated: Tag) {
328 tags = tags.map(t => t.id === updated.id ? updated : t);
329 },
330
331 deleteTag(id: number) {
332 tags = tags.filter(t => t.id !== id);
333 selectedTagIds = selectedTagIds.filter(t => t !== id);
334 // Remove tag from all favorites
335 favorites = favorites.map(f => ({
336 ...f,
337 tags: f.tags?.filter(t => t.id !== id),
338 }));
339 },
340
341 // ─────────────────────────────────────────────────────────────────────────────
342 // Analytics Actions
343 // ─────────────────────────────────────────────────────────────────────────────
344 setAnalytics(data: Analytics | null) {
345 analytics = data;
346 if (data) {
347 domainStats = data.top_domains ?? [];
348 dailyVisits = data.daily_visits ?? [];
349 hourlyDistribution = data.hourly_distribution ?? [];
350 }
351 },
352
353 setAnalyticsLoading(loading: boolean) {
354 analyticsLoading = loading;
355 },
356
357 setDomainStats(stats: DomainStat[]) {
358 domainStats = stats;
359 },
360
361 // ─────────────────────────────────────────────────────────────────────────────
362 // UI Actions
363 // ─────────────────────────────────────────────────────────────────────────────
364 setActivePanel(panel: PanelType) {
365 activePanel = panel;
366 focusedIndex = -1; // Clear focus, let search input be focused
367 // Clear search when switching panels
368 historySearchQuery = '';
369 historySearchResults = [];
370 },
371
372 openCommandPalette() {
373 commandPaletteOpen = true;
374 commandPaletteQuery = '';
375 },
376
377 closeCommandPalette() {
378 commandPaletteOpen = false;
379 commandPaletteQuery = '';
380 },
381
382 setCommandPaletteQuery(q: string) {
383 commandPaletteQuery = q;
384 },
385
386 setFocusedIndex(i: number) {
387 focusedIndex = Math.max(-1, i);
388 },
389
390 focusNext() {
391 const maxIndex = this.currentListLength - 1;
392 if (maxIndex < 0) return;
393 // If unfocused (-1), go to first item
394 if (focusedIndex === -1) {
395 focusedIndex = 0;
396 } else {
397 focusedIndex = Math.min(focusedIndex + 1, maxIndex);
398 }
399 },
400
401 focusPrev() {
402 // If at first item or unfocused, stay at -1 (return to search)
403 if (focusedIndex <= 0) {
404 focusedIndex = -1;
405 } else {
406 focusedIndex = focusedIndex - 1;
407 }
408 },
409
410 focusFirst() {
411 focusedIndex = 0;
412 },
413
414 focusLast() {
415 focusedIndex = Math.max(0, this.currentListLength - 1);
416 },
417
418 clearFocus() {
419 focusedIndex = -1;
420 },
421
422 // Confirmation modal
423 showConfirm(message: string, action: () => void) {
424 confirmModalMessage = message;
425 confirmModalAction = action;
426 confirmModalOpen = true;
427 },
428
429 confirmAction() {
430 confirmModalAction?.();
431 confirmModalOpen = false;
432 confirmModalAction = null;
433 confirmModalMessage = '';
434 },
435
436 cancelConfirm() {
437 confirmModalOpen = false;
438 confirmModalAction = null;
439 confirmModalMessage = '';
440 },
441
442 // Cleanup modal
443 openCleanupModal() {
444 cleanupModalOpen = true;
445 cleanupRange = 'hour';
446 },
447
448 closeCleanupModal() {
449 cleanupModalOpen = false;
450 },
451
452 setCleanupRange(range: HistoryCleanupRange) {
453 cleanupRange = range;
454 },
455
456 // Keyboard prefix state (for 'g' and 'D' prefix indicators)
457 setPendingKeyPrefix(prefix: string | null) {
458 pendingKeyPrefix = prefix;
459 },
460
461 // ─────────────────────────────────────────────────────────────────────────────
462 // Reset
463 // ─────────────────────────────────────────────────────────────────────────────
464 reset() {
465 // History
466 history = [];
467 timeline = [];
468 historyLoading = true;
469 historyOffset = 0;
470 hasMoreHistory = true;
471 historySearchQuery = '';
472 historySearchResults = [];
473 historySearching = false;
474
475 // Favorites
476 favorites = [];
477 folders = [];
478 tags = [];
479 favoritesLoading = true;
480 selectedFolderId = null;
481 selectedTagIds = [];
482 editingFavorite = null;
483
484 // Analytics
485 analytics = null;
486 analyticsLoading = true;
487 domainStats = [];
488 dailyVisits = [];
489 hourlyDistribution = [];
490
491 // UI
492 activePanel = 'history';
493 commandPaletteOpen = false;
494 commandPaletteQuery = '';
495 focusedIndex = -1;
496 confirmModalOpen = false;
497 confirmModalMessage = '';
498 confirmModalAction = null;
499 cleanupModalOpen = false;
500 cleanupRange = 'hour';
501 },
502};