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