|
1 | 1 | import { TimelineItem } from "../types" |
2 | 2 | import { TimelineFilters } from "../components/TimelineToolbar" |
3 | 3 | import { useGlobal } from "../state/useGlobal" |
| 4 | +import { useMemo } from "react" |
| 5 | +import { normalize } from "./normalize" |
| 6 | +import { safeTime } from "./safeTime" |
4 | 7 |
|
5 | 8 | export function useTimeline(filters: TimelineFilters): TimelineItem[] { |
6 | 9 | const [items] = useGlobal<TimelineItem[]>("timelineItems", [], { persist: true }) |
| 10 | + const [search] = useGlobal("search", "") |
7 | 11 |
|
8 | | - const filteredItems = items.filter((item) => { |
9 | | - // if there are any filters selected, only show items that match the filters |
10 | | - return filters.types.includes(item.type) |
11 | | - }) |
| 12 | + return useMemo(() => { |
| 13 | + // 1) Types filter: if none selected, show everything |
| 14 | + const byType = |
| 15 | + (filters.types?.length ?? 0) === 0 |
| 16 | + ? items.slice() // clone so we can sort safely later |
| 17 | + : items.filter((item) => filters.types.includes(item.type)) |
12 | 18 |
|
13 | | - filteredItems.sort((a, b) => { |
14 | | - return new Date(b.date).getTime() - new Date(a.date).getTime() |
15 | | - }) |
| 19 | + // 2) Search (normalize once) |
| 20 | + const q = normalize(search) |
| 21 | + const visited = new WeakSet<object>() |
| 22 | + const matches = (val: unknown): boolean => { |
| 23 | + if (val === null || val === undefined) return false |
16 | 24 |
|
17 | | - return filteredItems |
| 25 | + if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") { |
| 26 | + return normalize(val).includes(q) |
| 27 | + } |
| 28 | + if (val instanceof Date) { |
| 29 | + return normalize(val.toISOString()).includes(q) |
| 30 | + } |
| 31 | + if (Array.isArray(val)) { |
| 32 | + for (const v of val) if (matches(v)) return true |
| 33 | + return false |
| 34 | + } |
| 35 | + if (typeof val === "object") { |
| 36 | + const obj = val as Record<string, unknown> |
| 37 | + if (visited.has(obj)) return false |
| 38 | + visited.add(obj) |
| 39 | + for (const k in obj) { |
| 40 | + if (matches(obj[k])) return true |
| 41 | + } |
| 42 | + return false |
| 43 | + } |
| 44 | + |
| 45 | + return false |
| 46 | + } |
| 47 | + |
| 48 | + const bySearch = q ? byType.filter((item) => matches(item)) : byType |
| 49 | + |
| 50 | + // 3) Sort newest first, safely handling bad dates |
| 51 | + bySearch.sort((a, b) => safeTime(b.date) - safeTime(a.date)) |
| 52 | + |
| 53 | + return bySearch |
| 54 | + }, [items, JSON.stringify(filters.types ?? []), search]) |
18 | 55 |
|
19 | 56 | // TODO: User controlled sorting and level filtering |
20 | 57 |
|
|
0 commit comments