From e2a0284353609ced50f224803275f99ce017cea0 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 14 Oct 2025 20:45:21 +0900 Subject: [PATCH 1/8] init --- .../src/components/main/sidebar/timeline.tsx | 200 +++++++++++++++--- 1 file changed, 166 insertions(+), 34 deletions(-) diff --git a/apps/desktop2/src/components/main/sidebar/timeline.tsx b/apps/desktop2/src/components/main/sidebar/timeline.tsx index 100a5e395..3e5f5e405 100644 --- a/apps/desktop2/src/components/main/sidebar/timeline.tsx +++ b/apps/desktop2/src/components/main/sidebar/timeline.tsx @@ -1,8 +1,9 @@ import { clsx } from "clsx"; -import { differenceInDays, format, formatDistanceToNowStrict, isPast } from "date-fns"; -import { ExternalLink, Trash2 } from "lucide-react"; -import { useMemo } from "react"; +import { differenceInDays, differenceInMonths, format, isPast, startOfDay } from "date-fns"; +import { CalendarIcon, ExternalLink, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@hypr/ui/components/ui/button"; import { ContextMenuItem } from "@hypr/ui/components/ui/context-menu"; import * as persisted from "../../../store/tinybase/persisted"; import { Tab, useTabs } from "../../../store/zustand/tabs"; @@ -14,38 +15,90 @@ type TimelineItem = | { type: "session"; id: string; date: string; data: persisted.Session }; export function TimelineView() { - const { groupedItems, sortedDates } = useTimelineData(); + const buckets = useTimelineData(); + const todaySectionRef = useRef(null); + const containerRef = useRef(null); + const [isTodayVisible, setIsTodayVisible] = useState(true); + const [isScrolledPastToday, setIsScrolledPastToday] = useState(false); + + const hasToday = buckets.some(bucket => bucket.label === "Today"); + + useEffect(() => { + const section = todaySectionRef.current; + const container = containerRef.current; + if (section && container) { + container.scrollTo({ top: section.offsetTop }); + } + }, []); + + useEffect(() => { + const section = todaySectionRef.current; + const container = containerRef.current; + if (!section || !container) { + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + const containerRect = container.getBoundingClientRect(); + const sectionRect = section.getBoundingClientRect(); + + setIsTodayVisible(entry.isIntersecting); + setIsScrolledPastToday(sectionRect.top < containerRect.top); + }, + { root: container, threshold: 0.1 }, + ); + + observer.observe(section); + + return () => observer.disconnect(); + }, [buckets]); + + const scrollToToday = useCallback(() => { + const section = todaySectionRef.current; + const container = containerRef.current; + if (section && container) { + container.scrollTo({ top: section.offsetTop, behavior: "smooth" }); + } + }, []); return ( -
- {sortedDates.map((date) => ( -
-
- +
+
+ {buckets.map((bucket) => ( +
+
+ +
+ {bucket.items.map((item) => ( + + ))}
- {groupedItems[date].map((item) => ( - - ))} -
- ))} + ))} +
+ + {hasToday && !isTodayVisible && ( + + )}
); } -function DateHeader({ date }: { date: string }) { - const d = new Date(date); - const daysDiff = differenceInDays(new Date(), d); - - let label: string; - if (daysDiff < 30) { - label = formatDistanceToNowStrict(d, { addSuffix: true, unit: "day" }); - } else { - label = formatDistanceToNowStrict(d, { addSuffix: true, unit: "month" }); - } - +function DateHeader({ label }: { label: string }) { return
{label}
; } @@ -158,6 +211,77 @@ function TimelineItemComponent({ item }: { item: TimelineItem }) { ); } +function getBucketInfo(date: Date): { label: string; sortKey: number } { + const now = startOfDay(new Date()); + const targetDay = startOfDay(date); + const daysDiff = differenceInDays(targetDay, now); + + const sortKey = targetDay.getTime(); + + if (daysDiff === 0) { + return { label: "Today", sortKey }; + } + + if (daysDiff === -1) { + return { label: "Yesterday", sortKey }; + } + + if (daysDiff === 1) { + return { label: "Tomorrow", sortKey }; + } + + if (daysDiff >= -6 && daysDiff <= -2) { + const absDays = Math.abs(daysDiff); + return { label: `${absDays} days ago`, sortKey }; + } + + if (daysDiff >= 2 && daysDiff <= 6) { + return { label: `in ${daysDiff} days`, sortKey }; + } + + if (daysDiff >= -27 && daysDiff <= -7) { + const weeks = Math.round(Math.abs(daysDiff) / 7); + const weekStart = startOfDay(new Date(now.getTime() - weeks * 7 * 24 * 60 * 60 * 1000)); + const weekSortKey = weekStart.getTime(); + + if (weeks === 1) { + return { label: "a week ago", sortKey: weekSortKey }; + } + return { label: `${weeks} weeks ago`, sortKey: weekSortKey }; + } + + if (daysDiff >= 7 && daysDiff <= 27) { + const weeks = Math.round(daysDiff / 7); + const weekStart = startOfDay(new Date(now.getTime() + weeks * 7 * 24 * 60 * 60 * 1000)); + const weekSortKey = weekStart.getTime(); + + if (weeks === 1) { + return { label: "next week", sortKey: weekSortKey }; + } + return { label: `in ${weeks} weeks`, sortKey: weekSortKey }; + } + + if (daysDiff < -27) { + const months = Math.abs(differenceInMonths(targetDay, now)); + const monthStart = new Date(now.getFullYear(), now.getMonth() - months, 1); + const monthSortKey = monthStart.getTime(); + + if (months === 1) { + return { label: "a month ago", sortKey: monthSortKey }; + } + return { label: `${months} months ago`, sortKey: monthSortKey }; + } + + const months = differenceInMonths(targetDay, now); + const monthStart = new Date(now.getFullYear(), now.getMonth() + months, 1); + const monthSortKey = monthStart.getTime(); + + if (months === 1) { + return { label: "next month", sortKey: monthSortKey }; + } + return { label: `in ${months} months`, sortKey: monthSortKey }; +} + function useTimelineData() { const eventsWithoutSessionTable = persisted.UI.useResultTable( persisted.QUERIES.eventsWithoutSession, @@ -206,12 +330,20 @@ function useTimelineData() { return new Date(timeB).getTime() - new Date(timeA).getTime(); }); - const groupedItems = items.reduce>((groups, item) => { - (groups[item.date] ||= []).push(item); - return groups; - }, {}); + const bucketMap = new Map(); + + items.forEach((item) => { + const itemDate = new Date(item.date); + const bucket = getBucketInfo(itemDate); + + if (!bucketMap.has(bucket.label)) { + bucketMap.set(bucket.label, { sortKey: bucket.sortKey, items: [] }); + } + bucketMap.get(bucket.label)!.items.push(item); + }); - const sortedDates = Object.keys(groupedItems).sort((a, b) => b.localeCompare(a)); - return { groupedItems, sortedDates }; + return Array.from(bucketMap.entries()) + .sort((a, b) => b[1].sortKey - a[1].sortKey) + .map(([label, value]) => ({ label, items: value.items })); }, [eventsWithoutSessionTable, sessionsWithMaybeEventTable]); } From 9b19f0cd51850c3695a7ac495554193aad1b5332 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 14 Oct 2025 20:58:40 +0900 Subject: [PATCH 2/8] fix --- .../src/components/main/sidebar/timeline.tsx | 81 ++++++++++--------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/apps/desktop2/src/components/main/sidebar/timeline.tsx b/apps/desktop2/src/components/main/sidebar/timeline.tsx index 3e5f5e405..b97056c50 100644 --- a/apps/desktop2/src/components/main/sidebar/timeline.tsx +++ b/apps/desktop2/src/components/main/sidebar/timeline.tsx @@ -1,5 +1,5 @@ import { clsx } from "clsx"; -import { differenceInDays, differenceInMonths, format, isPast, startOfDay } from "date-fns"; +import { differenceInCalendarMonths, differenceInDays, format, isPast, startOfDay } from "date-fns"; import { CalendarIcon, ExternalLink, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -215,8 +215,8 @@ function getBucketInfo(date: Date): { label: string; sortKey: number } { const now = startOfDay(new Date()); const targetDay = startOfDay(date); const daysDiff = differenceInDays(targetDay, now); - const sortKey = targetDay.getTime(); + const absDays = Math.abs(daysDiff); if (daysDiff === 0) { return { label: "Today", sortKey }; @@ -230,56 +230,57 @@ function getBucketInfo(date: Date): { label: string; sortKey: number } { return { label: "Tomorrow", sortKey }; } - if (daysDiff >= -6 && daysDiff <= -2) { - const absDays = Math.abs(daysDiff); - return { label: `${absDays} days ago`, sortKey }; - } + if (daysDiff < 0) { + if (absDays <= 6) { + return { label: `${absDays} days ago`, sortKey }; + } - if (daysDiff >= 2 && daysDiff <= 6) { - return { label: `in ${daysDiff} days`, sortKey }; - } + if (absDays <= 27) { + const weeks = Math.max(1, Math.round(absDays / 7)); + const weekStart = startOfDay(new Date(now.getTime() - weeks * 7 * 24 * 60 * 60 * 1000)); + const weekSortKey = weekStart.getTime(); - if (daysDiff >= -27 && daysDiff <= -7) { - const weeks = Math.round(Math.abs(daysDiff) / 7); - const weekStart = startOfDay(new Date(now.getTime() - weeks * 7 * 24 * 60 * 60 * 1000)); - const weekSortKey = weekStart.getTime(); + return { + label: weeks === 1 ? "a week ago" : `${weeks} weeks ago`, + sortKey: weekSortKey, + }; + } - if (weeks === 1) { - return { label: "a week ago", sortKey: weekSortKey }; + let months = Math.abs(differenceInCalendarMonths(targetDay, now)); + if (months === 0) { + months = 1; } - return { label: `${weeks} weeks ago`, sortKey: weekSortKey }; + const monthStart = startOfDay(new Date(targetDay.getFullYear(), targetDay.getMonth(), 1)); + return { + label: months === 1 ? "a month ago" : `${months} months ago`, + sortKey: monthStart.getTime(), + }; } - if (daysDiff >= 7 && daysDiff <= 27) { - const weeks = Math.round(daysDiff / 7); - const weekStart = startOfDay(new Date(now.getTime() + weeks * 7 * 24 * 60 * 60 * 1000)); - const weekSortKey = weekStart.getTime(); - - if (weeks === 1) { - return { label: "next week", sortKey: weekSortKey }; - } - return { label: `in ${weeks} weeks`, sortKey: weekSortKey }; + if (absDays <= 6) { + return { label: `in ${absDays} days`, sortKey }; } - if (daysDiff < -27) { - const months = Math.abs(differenceInMonths(targetDay, now)); - const monthStart = new Date(now.getFullYear(), now.getMonth() - months, 1); - const monthSortKey = monthStart.getTime(); + if (absDays <= 27) { + const weeks = Math.max(1, Math.round(absDays / 7)); + const weekStart = startOfDay(new Date(now.getTime() + weeks * 7 * 24 * 60 * 60 * 1000)); + const weekSortKey = weekStart.getTime(); - if (months === 1) { - return { label: "a month ago", sortKey: monthSortKey }; - } - return { label: `${months} months ago`, sortKey: monthSortKey }; + return { + label: weeks === 1 ? "next week" : `in ${weeks} weeks`, + sortKey: weekSortKey, + }; } - const months = differenceInMonths(targetDay, now); - const monthStart = new Date(now.getFullYear(), now.getMonth() + months, 1); - const monthSortKey = monthStart.getTime(); - - if (months === 1) { - return { label: "next month", sortKey: monthSortKey }; + let months = differenceInCalendarMonths(targetDay, now); + if (months === 0) { + months = 1; } - return { label: `in ${months} months`, sortKey: monthSortKey }; + const monthStart = startOfDay(new Date(targetDay.getFullYear(), targetDay.getMonth(), 1)); + return { + label: months === 1 ? "next month" : `in ${months} months`, + sortKey: monthStart.getTime(), + }; } function useTimelineData() { From 33f7185bcb625adf024b7bb80427f561f2807ccd Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 14 Oct 2025 21:06:48 +0900 Subject: [PATCH 3/8] precision rendering --- .../src/components/main/sidebar/timeline.tsx | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/apps/desktop2/src/components/main/sidebar/timeline.tsx b/apps/desktop2/src/components/main/sidebar/timeline.tsx index b97056c50..cc65258e0 100644 --- a/apps/desktop2/src/components/main/sidebar/timeline.tsx +++ b/apps/desktop2/src/components/main/sidebar/timeline.tsx @@ -14,6 +14,14 @@ type TimelineItem = | { type: "event"; id: string; date: string; data: persisted.Event } | { type: "session"; id: string; date: string; data: persisted.Session }; +type TimelinePrecision = "time" | "date"; + +type TimelineBucket = { + label: string; + precision: TimelinePrecision; + items: TimelineItem[]; +}; + export function TimelineView() { const buckets = useTimelineData(); const todaySectionRef = useRef(null); @@ -74,6 +82,7 @@ export function TimelineView() { ))}
@@ -102,7 +111,7 @@ function DateHeader({ label }: { label: string }) { return
{label}
; } -function TimelineItemComponent({ item }: { item: TimelineItem }) { +function TimelineItemComponent({ item, precision }: { item: TimelineItem; precision: TimelinePrecision }) { const { currentTab, openCurrent, openNew } = useTabs(); const store = persisted.UI.useStore(persisted.STORE_ID); @@ -189,8 +198,26 @@ function TimelineItemComponent({ item }: { item: TimelineItem }) { ); + const displayTime = useMemo(() => { + if (!timestamp) { + return ""; + } + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return ""; + } + + const time = format(date, "HH:mm"); + + if (precision === "time") { + return time; + } - const displayTime = timestamp ? format(new Date(timestamp), "HH:mm") : ""; + const sameYear = date.getFullYear() === new Date().getFullYear(); + const dateStr = format(date, sameYear ? "MMM d" : "MMM d, yyyy"); + return `${dateStr}, ${time}`; + }, [timestamp, precision]); return ( (); + const bucketMap = new Map(); items.forEach((item) => { const itemDate = new Date(item.date); const bucket = getBucketInfo(itemDate); if (!bucketMap.has(bucket.label)) { - bucketMap.set(bucket.label, { sortKey: bucket.sortKey, items: [] }); + bucketMap.set(bucket.label, { sortKey: bucket.sortKey, precision: bucket.precision, items: [] }); } bucketMap.get(bucket.label)!.items.push(item); }); return Array.from(bucketMap.entries()) .sort((a, b) => b[1].sortKey - a[1].sortKey) - .map(([label, value]) => ({ label, items: value.items })); + .map(([label, value]) => ({ label, items: value.items, precision: value.precision } satisfies TimelineBucket)); }, [eventsWithoutSessionTable, sessionsWithMaybeEventTable]); } From a63dee7260c34b7900113dc9187dd8448eb3fa2e Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 15 Oct 2025 07:14:42 +0900 Subject: [PATCH 4/8] tab select hotkey --- .../src/components/main/body/index.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/desktop2/src/components/main/body/index.tsx b/apps/desktop2/src/components/main/body/index.tsx index 70d428a2c..58cd6b851 100644 --- a/apps/desktop2/src/components/main/body/index.tsx +++ b/apps/desktop2/src/components/main/body/index.tsx @@ -24,6 +24,7 @@ export function Body() { const { chat } = useShell(); useTabCloseHotkey(); + useTabSelectHotkeys(); if (!currentTab) { return null; @@ -250,6 +251,31 @@ const useTabCloseHotkey = () => { ); }; +const useTabSelectHotkeys = () => { + const { tabs, select } = useTabs(); + + useHotkeys( + ["mod+1", "mod+2", "mod+3", "mod+4", "mod+5", "mod+6", "mod+7", "mod+8", "mod+9"], + (event) => { + const key = event.key; + + const targetIndex = key === "9" + ? tabs.length - 1 + : Number.parseInt(key, 10) - 1; + + const target = tabs[targetIndex]; + if (!target) { + return; + } + + event.preventDefault(); + select(target); + }, + { enableOnFormTags: true }, + [tabs, select], + ); +}; + function useScrollActiveTabIntoView(tabs: Tab[]) { const tabRefsMap = useRef>(new Map()); From 5edccbf36357f7982b12ae6c92bdc71d97fe2a9c Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 15 Oct 2025 08:08:32 +0900 Subject: [PATCH 5/8] looks ok --- .../src/components/main/sidebar/timeline.tsx | 386 +++++++++--------- apps/desktop2/src/utils/timeline.test.ts | 159 ++++++++ apps/desktop2/src/utils/timeline.ts | 182 +++++++++ 3 files changed, 542 insertions(+), 185 deletions(-) create mode 100644 apps/desktop2/src/utils/timeline.test.ts create mode 100644 apps/desktop2/src/utils/timeline.ts diff --git a/apps/desktop2/src/components/main/sidebar/timeline.tsx b/apps/desktop2/src/components/main/sidebar/timeline.tsx index cc65258e0..c616ac858 100644 --- a/apps/desktop2/src/components/main/sidebar/timeline.tsx +++ b/apps/desktop2/src/components/main/sidebar/timeline.tsx @@ -1,110 +1,148 @@ import { clsx } from "clsx"; -import { differenceInCalendarMonths, differenceInDays, format, isPast, startOfDay } from "date-fns"; import { CalendarIcon, ExternalLink, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { forwardRef, Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@hypr/ui/components/ui/button"; import { ContextMenuItem } from "@hypr/ui/components/ui/context-menu"; import * as persisted from "../../../store/tinybase/persisted"; import { Tab, useTabs } from "../../../store/zustand/tabs"; import { id } from "../../../utils"; +import { buildTimelineBuckets } from "../../../utils/timeline"; +import type { TimelineBucket, TimelineItem, TimelinePrecision } from "../../../utils/timeline"; import { InteractiveButton } from "../../interactive-button"; -type TimelineItem = - | { type: "event"; id: string; date: string; data: persisted.Event } - | { type: "session"; id: string; date: string; data: persisted.Session }; +export function TimelineView() { + const buckets = useTimelineData(); + const currentTime = useCurrentTime(); + const { + containerRef, + isTodayVisible, + isScrolledPastToday, + scrollToToday, + hasToday, + setCurrentTimeIndicatorRef, + } = useTimelineScroll(buckets); -type TimelinePrecision = "time" | "date"; + return ( +
+
+ {buckets.map((bucket) => { + const isToday = bucket.label === "Today"; + + return ( +
+
+ +
+ {isToday + ? ( + + ) + : ( + bucket.items.map((item) => ( + + )) + )} +
+ ); + })} +
-type TimelineBucket = { - label: string; - precision: TimelinePrecision; - items: TimelineItem[]; -}; + {hasToday && !isTodayVisible && ( + + )} +
+ ); +} -export function TimelineView() { - const buckets = useTimelineData(); - const todaySectionRef = useRef(null); +function useTimelineScroll(buckets: TimelineBucket[]) { const containerRef = useRef(null); const [isTodayVisible, setIsTodayVisible] = useState(true); const [isScrolledPastToday, setIsScrolledPastToday] = useState(false); + const [indicatorNode, setIndicatorNode] = useState(null); - const hasToday = buckets.some(bucket => bucket.label === "Today"); + const hasToday = useMemo(() => buckets.some(bucket => bucket.label === "Today"), [buckets]); - useEffect(() => { - const section = todaySectionRef.current; + const setCurrentTimeIndicatorRef = useCallback((node: HTMLDivElement | null) => { + setIndicatorNode(prevNode => (prevNode === node ? prevNode : node)); + }, []); + + const scrollToToday = useCallback(() => { const container = containerRef.current; - if (section && container) { - container.scrollTo({ top: section.offsetTop }); + if (!container || !indicatorNode) { + return; } - }, []); + + const containerRect = container.getBoundingClientRect(); + const indicatorRect = indicatorNode.getBoundingClientRect(); + const indicatorCenter = indicatorRect.top - containerRect.top + container.scrollTop + (indicatorRect.height / 2); + const targetScrollTop = Math.max(indicatorCenter - (container.clientHeight / 2), 0); + container.scrollTo({ top: targetScrollTop, behavior: "smooth" }); + }, [indicatorNode]); + + useEffect(() => { + if (!hasToday) { + return; + } + + requestAnimationFrame(() => { + scrollToToday(); + }); + }, [hasToday, scrollToToday]); useEffect(() => { - const section = todaySectionRef.current; const container = containerRef.current; - if (!section || !container) { + + if (!container || !indicatorNode) { + setIsTodayVisible(true); + setIsScrolledPastToday(false); return; } const observer = new IntersectionObserver( ([entry]) => { const containerRect = container.getBoundingClientRect(); - const sectionRect = section.getBoundingClientRect(); + const indicatorRect = indicatorNode.getBoundingClientRect(); setIsTodayVisible(entry.isIntersecting); - setIsScrolledPastToday(sectionRect.top < containerRect.top); + setIsScrolledPastToday(indicatorRect.top < containerRect.top); }, { root: container, threshold: 0.1 }, ); - observer.observe(section); + observer.observe(indicatorNode); return () => observer.disconnect(); - }, [buckets]); + }, [indicatorNode]); - const scrollToToday = useCallback(() => { - const section = todaySectionRef.current; - const container = containerRef.current; - if (section && container) { - container.scrollTo({ top: section.offsetTop, behavior: "smooth" }); - } - }, []); - - return ( -
-
- {buckets.map((bucket) => ( -
-
- -
- {bucket.items.map((item) => ( - - ))} -
- ))} -
- - {hasToday && !isTodayVisible && ( - - )} -
- ); + return { + containerRef, + isTodayVisible, + isScrolledPastToday, + scrollToToday, + hasToday, + setCurrentTimeIndicatorRef, + }; } function DateHeader({ label }: { label: string }) { @@ -208,14 +246,16 @@ function TimelineItemComponent({ item, precision }: { item: TimelineItem; precis return ""; } - const time = format(date, "HH:mm"); + const time = date.toLocaleTimeString([], { hour: "numeric", minute: "numeric" }); if (precision === "time") { return time; } const sameYear = date.getFullYear() === new Date().getFullYear(); - const dateStr = format(date, sameYear ? "MMM d" : "MMM d, yyyy"); + const dateStr = sameYear + ? date.toLocaleDateString([], { month: "short", day: "numeric" }) + : date.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" }); return `${dateStr}, ${time}`; }, [timestamp, precision]); @@ -238,83 +278,65 @@ function TimelineItemComponent({ item, precision }: { item: TimelineItem; precis ); } -function getBucketInfo(date: Date): { label: string; sortKey: number; precision: TimelinePrecision } { - const now = startOfDay(new Date()); - const targetDay = startOfDay(date); - const daysDiff = differenceInDays(targetDay, now); - const sortKey = targetDay.getTime(); - const absDays = Math.abs(daysDiff); - - if (daysDiff === 0) { - return { label: "Today", sortKey, precision: "time" }; - } - - if (daysDiff === -1) { - return { label: "Yesterday", sortKey, precision: "time" }; - } - - if (daysDiff === 1) { - return { label: "Tomorrow", sortKey, precision: "time" }; - } +function TodayBucket({ + items, + precision, + currentTime, + registerIndicator, +}: { + items: TimelineItem[]; + precision: TimelinePrecision; + currentTime: Date; + registerIndicator: (node: HTMLDivElement | null) => void; +}) { + const entries = useMemo( + () => items.map((timelineItem) => ({ item: timelineItem, timestamp: getItemTimestamp(timelineItem) })), + [items], + ); - if (daysDiff < 0) { - if (absDays <= 6) { - return { label: `${absDays} days ago`, sortKey, precision: "time" }; - } + const currentTimeMs = currentTime.getTime(); - if (absDays <= 27) { - const weeks = Math.max(1, Math.round(absDays / 7)); - const weekStart = startOfDay(new Date(now.getTime() - weeks * 7 * 24 * 60 * 60 * 1000)); - const weekSortKey = weekStart.getTime(); + const indicatorIndex = useMemo(() => { + for (let index = 0; index < entries.length; index += 1) { + const timestamp = entries[index].timestamp; - return { - label: weeks === 1 ? "a week ago" : `${weeks} weeks ago`, - sortKey: weekSortKey, - precision: "date", - }; - } + if (!timestamp) { + return index; + } - let months = Math.abs(differenceInCalendarMonths(targetDay, now)); - if (months === 0) { - months = 1; + if (timestamp.getTime() < currentTimeMs) { + return index; + } } - const monthStart = startOfDay(new Date(targetDay.getFullYear(), targetDay.getMonth(), 1)); - return { - label: months === 1 ? "a month ago" : `${months} months ago`, - sortKey: monthStart.getTime(), - precision: "date", - }; - } - - if (absDays <= 6) { - return { label: `in ${absDays} days`, sortKey, precision: "time" }; - } - - if (absDays <= 27) { - const weeks = Math.max(1, Math.round(absDays / 7)); - const weekStart = startOfDay(new Date(now.getTime() + weeks * 7 * 24 * 60 * 60 * 1000)); - const weekSortKey = weekStart.getTime(); - return { - label: weeks === 1 ? "next week" : `in ${weeks} weeks`, - sortKey: weekSortKey, - precision: "date", - }; + return entries.length; + }, [entries, currentTimeMs]); + + if (entries.length === 0) { + return ( + <> + +
+ No items today +
+ + ); } - let months = differenceInCalendarMonths(targetDay, now); - if (months === 0) { - months = 1; - } - const monthStart = startOfDay(new Date(targetDay.getFullYear(), targetDay.getMonth(), 1)); - return { - label: months === 1 ? "next month" : `in ${months} months`, - sortKey: monthStart.getTime(), - precision: "date", - }; + return ( + <> + {entries.map((entry, index) => ( + + {index === indicatorIndex && } + + + ))} + {indicatorIndex === entries.length && } + + ); } -function useTimelineData() { +function useTimelineData(): TimelineBucket[] { const eventsWithoutSessionTable = persisted.UI.useResultTable( persisted.QUERIES.eventsWithoutSession, persisted.STORE_ID, @@ -324,58 +346,52 @@ function useTimelineData() { persisted.STORE_ID, ); - return useMemo(() => { - const items: TimelineItem[] = []; - const seenEvents = new Set(); - - eventsWithoutSessionTable && Object.entries(eventsWithoutSessionTable).forEach(([eventId, row]) => { - const eventStartTime = new Date(String(row.started_at || "")); - if (!isPast(eventStartTime)) { - items.push({ - type: "event", - id: eventId, - date: format(eventStartTime, "yyyy-MM-dd"), - data: row as unknown as persisted.Event, - }); - seenEvents.add(eventId); - } - }); + return useMemo( + () => + buildTimelineBuckets({ + eventsWithoutSessionTable, + sessionsWithMaybeEventTable, + }), + [eventsWithoutSessionTable, sessionsWithMaybeEventTable], + ); +} - sessionsWithMaybeEventTable && Object.entries(sessionsWithMaybeEventTable).forEach(([sessionId, row]) => { - const eventId = row.event_id ? String(row.event_id) : undefined; - if (eventId && seenEvents.has(eventId)) { - return; - } +function useCurrentTime() { + const [now, setNow] = useState(() => new Date()); - const timestamp = String(row.event_started_at || row.created_at || ""); - items.push({ - type: "session", - id: sessionId, - date: format(new Date(timestamp), "yyyy-MM-dd"), - data: row as unknown as persisted.Session, - }); - }); + useEffect(() => { + const update = () => setNow(new Date()); + update(); + const interval = window.setInterval(update, 60_000); - items.sort((a, b) => { - const timeA = a.type === "event" ? a.data.started_at : a.data.created_at; - const timeB = b.type === "event" ? b.data.started_at : b.data.created_at; - return new Date(timeB).getTime() - new Date(timeA).getTime(); - }); + return () => { + window.clearInterval(interval); + }; + }, []); - const bucketMap = new Map(); + return now; +} - items.forEach((item) => { - const itemDate = new Date(item.date); - const bucket = getBucketInfo(itemDate); +function getItemTimestamp(item: TimelineItem): Date | null { + const value = item.type === "event" ? item.data.started_at : item.data.created_at; - if (!bucketMap.has(bucket.label)) { - bucketMap.set(bucket.label, { sortKey: bucket.sortKey, precision: bucket.precision, items: [] }); - } - bucketMap.get(bucket.label)!.items.push(item); - }); + if (!value) { + return null; + } - return Array.from(bucketMap.entries()) - .sort((a, b) => b[1].sortKey - a[1].sortKey) - .map(([label, value]) => ({ label, items: value.items, precision: value.precision } satisfies TimelineBucket)); - }, [eventsWithoutSessionTable, sessionsWithMaybeEventTable]); + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return null; + } + + return date; } + +const CurrentTimeIndicator = forwardRef((_, ref) => ( +
+
+
+)); + +CurrentTimeIndicator.displayName = "CurrentTimeIndicator"; diff --git a/apps/desktop2/src/utils/timeline.test.ts b/apps/desktop2/src/utils/timeline.test.ts new file mode 100644 index 000000000..9a8bd4ca7 --- /dev/null +++ b/apps/desktop2/src/utils/timeline.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { + buildTimelineBuckets, + type EventsWithoutSessionTable, + getBucketInfo, + type SessionsWithMaybeEventTable, +} from "./timeline"; + +const SYSTEM_TIME = new Date("2024-01-15T12:00:00.000Z"); + +describe("timeline utils", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(SYSTEM_TIME); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("getBucketInfo returns Today for current date", () => { + const info = getBucketInfo(new Date("2024-01-15T05:00:00.000Z")); + expect(info).toMatchObject({ label: "Today", precision: "time" }); + }); + + test("getBucketInfo groups recent past days", () => { + const info = getBucketInfo(new Date("2024-01-10T05:00:00.000Z")); + expect(info).toMatchObject({ label: "5 days ago", precision: "time" }); + }); + + test("getBucketInfo groups distant future months", () => { + const info = getBucketInfo(new Date("2024-03-20T12:00:00.000Z")); + expect(info).toMatchObject({ label: "in 2 months", precision: "date" }); + }); + + test("buildTimelineBuckets includes Today bucket even without items", () => { + const buckets = buildTimelineBuckets({ + eventsWithoutSessionTable: null, + sessionsWithMaybeEventTable: null, + }); + + const todayBucket = buckets.find(bucket => bucket.label === "Today"); + expect(todayBucket).toBeDefined(); + expect(todayBucket?.items).toEqual([]); + }); + + test("buildTimelineBuckets prioritizes upcoming events and avoids duplicate sessions", () => { + const eventsWithoutSessionTable: EventsWithoutSessionTable = { + "event-1": { + title: "Future Event", + started_at: "2024-01-20T16:00:00.000Z", + ended_at: "2024-01-20T17:00:00.000Z", + created_at: "2024-01-10T15:00:00.000Z", + calendar_id: "cal-1", + user_id: "user-1", + }, + }; + + const sessionsWithMaybeEventTable: SessionsWithMaybeEventTable = { + "session-1": { + title: "Linked Session", + created_at: "2024-01-10T16:00:00.000Z", + event_id: "event-1", + event_started_at: "2024-01-20T16:00:00.000Z", + user_id: "user-1", + raw_md: "", + enhanced_md: "", + transcript: { words: [] }, + }, + "session-2": { + title: "Standalone Session", + created_at: "2024-01-14T15:00:00.000Z", + user_id: "user-1", + raw_md: "", + enhanced_md: "", + transcript: { words: [] }, + }, + }; + + const buckets = buildTimelineBuckets({ eventsWithoutSessionTable, sessionsWithMaybeEventTable }); + + const futureBucket = buckets[0]; + expect(futureBucket.label).toBe("in 6 days"); + expect(futureBucket.items).toHaveLength(1); + expect(futureBucket.items[0]).toMatchObject({ type: "event", id: "event-1" }); + + const sessionBucket = buckets.find(bucket => bucket.items.some(item => item.id === "session-2")); + expect(sessionBucket).toBeDefined(); + expect(sessionBucket?.items).toHaveLength(1); + const containsLinkedSession = buckets.some(bucket => bucket.items.some(item => item.id === "session-1")); + expect(containsLinkedSession).toBe(false); + }); + + test("buildTimelineBuckets excludes past events but keeps related sessions", () => { + const eventsWithoutSessionTable: EventsWithoutSessionTable = { + "event-past": { + title: "Past Event", + started_at: "2024-01-10T10:00:00.000Z", + ended_at: "2024-01-10T11:00:00.000Z", + created_at: "2024-01-05T09:00:00.000Z", + calendar_id: "cal-1", + user_id: "user-1", + }, + }; + + const sessionsWithMaybeEventTable: SessionsWithMaybeEventTable = { + "session-past": { + title: "Follow-up Session", + created_at: "2024-01-10T12:00:00.000Z", + event_id: "event-past", + event_started_at: "2024-01-10T10:00:00.000Z", + user_id: "user-1", + raw_md: "", + enhanced_md: "", + transcript: { words: [] }, + }, + }; + + const buckets = buildTimelineBuckets({ eventsWithoutSessionTable, sessionsWithMaybeEventTable }); + + const pastBucket = buckets.find(bucket => bucket.label === "5 days ago"); + expect(pastBucket).toBeDefined(); + expect(pastBucket?.items).toHaveLength(1); + expect(pastBucket?.items[0]).toMatchObject({ type: "session", id: "session-past" }); + + const hasPastEvent = buckets.some(bucket => bucket.items.some(item => item.id === "event-past")); + expect(hasPastEvent).toBe(false); + + const todayBucket = buckets.find(bucket => bucket.label === "Today"); + expect(todayBucket).toBeDefined(); + }); + + test("buildTimelineBuckets sorts buckets by most recent first", () => { + const sessionsWithMaybeEventTable: SessionsWithMaybeEventTable = { + "session-future": { + title: "Future Session", + created_at: "2024-01-10T12:00:00.000Z", + event_started_at: "2024-01-16T09:00:00.000Z", + user_id: "user-1", + raw_md: "", + enhanced_md: "", + transcript: { words: [] }, + }, + "session-past": { + title: "Past Session", + created_at: "2024-01-14T09:00:00.000Z", + user_id: "user-1", + raw_md: "", + enhanced_md: "", + transcript: { words: [] }, + }, + }; + + const buckets = buildTimelineBuckets({ eventsWithoutSessionTable: null, sessionsWithMaybeEventTable }); + + expect(buckets.map(bucket => bucket.label)).toEqual(["Tomorrow", "Today", "Yesterday"]); + }); +}); diff --git a/apps/desktop2/src/utils/timeline.ts b/apps/desktop2/src/utils/timeline.ts new file mode 100644 index 000000000..eddf12b6a --- /dev/null +++ b/apps/desktop2/src/utils/timeline.ts @@ -0,0 +1,182 @@ +import { differenceInCalendarMonths, differenceInDays, format, isPast, startOfDay } from "date-fns"; + +import type * as persisted from "../store/tinybase/persisted"; + +export type TimelineEventRow = { + started_at?: string | null; + created_at?: string | null; + title?: string | null; + [key: string]: unknown; +}; + +export type TimelineSessionRow = { + event_started_at?: string | null; + created_at?: string | null; + event_id?: string | null; + title?: string | null; + [key: string]: unknown; +}; + +export type EventsWithoutSessionTable = Record | null | undefined; +export type SessionsWithMaybeEventTable = Record | null | undefined; + +export type TimelineItem = + | { type: "event"; id: string; date: string; data: persisted.Event } + | { type: "session"; id: string; date: string; data: persisted.Session }; + +export type TimelinePrecision = "time" | "date"; + +export type TimelineBucket = { + label: string; + precision: TimelinePrecision; + items: TimelineItem[]; +}; + +export function getBucketInfo(date: Date): { label: string; sortKey: number; precision: TimelinePrecision } { + const now = startOfDay(new Date()); + const targetDay = startOfDay(date); + const daysDiff = differenceInDays(targetDay, now); + const sortKey = targetDay.getTime(); + const absDays = Math.abs(daysDiff); + + if (daysDiff === 0) { + return { label: "Today", sortKey, precision: "time" }; + } + + if (daysDiff === -1) { + return { label: "Yesterday", sortKey, precision: "time" }; + } + + if (daysDiff === 1) { + return { label: "Tomorrow", sortKey, precision: "time" }; + } + + if (daysDiff < 0) { + if (absDays <= 6) { + return { label: `${absDays} days ago`, sortKey, precision: "time" }; + } + + if (absDays <= 27) { + const weeks = Math.max(1, Math.round(absDays / 7)); + const weekStart = startOfDay(new Date(now.getTime() - weeks * 7 * 24 * 60 * 60 * 1000)); + const weekSortKey = weekStart.getTime(); + + return { + label: weeks === 1 ? "a week ago" : `${weeks} weeks ago`, + sortKey: weekSortKey, + precision: "date", + }; + } + + let months = Math.abs(differenceInCalendarMonths(targetDay, now)); + if (months === 0) { + months = 1; + } + const monthStart = startOfDay(new Date(targetDay.getFullYear(), targetDay.getMonth(), 1)); + return { + label: months === 1 ? "a month ago" : `${months} months ago`, + sortKey: monthStart.getTime(), + precision: "date", + }; + } + + if (absDays <= 6) { + return { label: `in ${absDays} days`, sortKey, precision: "time" }; + } + + if (absDays <= 27) { + const weeks = Math.max(1, Math.round(absDays / 7)); + const weekStart = startOfDay(new Date(now.getTime() + weeks * 7 * 24 * 60 * 60 * 1000)); + const weekSortKey = weekStart.getTime(); + + return { + label: weeks === 1 ? "next week" : `in ${weeks} weeks`, + sortKey: weekSortKey, + precision: "date", + }; + } + + let months = differenceInCalendarMonths(targetDay, now); + if (months === 0) { + months = 1; + } + const monthStart = startOfDay(new Date(targetDay.getFullYear(), targetDay.getMonth(), 1)); + return { + label: months === 1 ? "next month" : `in ${months} months`, + sortKey: monthStart.getTime(), + precision: "date", + }; +} + +export function buildTimelineBuckets({ + eventsWithoutSessionTable, + sessionsWithMaybeEventTable, +}: { + eventsWithoutSessionTable: EventsWithoutSessionTable; + sessionsWithMaybeEventTable: SessionsWithMaybeEventTable; +}): TimelineBucket[] { + const items: TimelineItem[] = []; + const seenEvents = new Set(); + + eventsWithoutSessionTable + && Object.entries(eventsWithoutSessionTable).forEach(([eventId, row]) => { + const eventStartTime = new Date(String(row.started_at || "")); + + if (!isPast(eventStartTime)) { + items.push({ + type: "event", + id: eventId, + date: format(eventStartTime, "yyyy-MM-dd"), + data: row as unknown as persisted.Event, + }); + seenEvents.add(eventId); + } + }); + + sessionsWithMaybeEventTable + && Object.entries(sessionsWithMaybeEventTable).forEach(([sessionId, row]) => { + const eventId = row.event_id ? String(row.event_id) : undefined; + if (eventId && seenEvents.has(eventId)) { + return; + } + + const timestamp = String(row.event_started_at || row.created_at || ""); + items.push({ + type: "session", + id: sessionId, + date: format(new Date(timestamp), "yyyy-MM-dd"), + data: row as unknown as persisted.Session, + }); + }); + + items.sort((a, b) => { + const timeA = a.type === "event" ? a.data.started_at : a.data.created_at; + const timeB = b.type === "event" ? b.data.started_at : b.data.created_at; + return new Date(timeB).getTime() - new Date(timeA).getTime(); + }); + + const bucketMap = new Map(); + + items.forEach((item) => { + const itemDate = new Date(item.date); + const bucket = getBucketInfo(itemDate); + + if (!bucketMap.has(bucket.label)) { + bucketMap.set(bucket.label, { sortKey: bucket.sortKey, precision: bucket.precision, items: [] }); + } + bucketMap.get(bucket.label)!.items.push(item); + }); + + const todayBucket = getBucketInfo(new Date()); + if (!bucketMap.has(todayBucket.label)) { + bucketMap.set(todayBucket.label, { + sortKey: todayBucket.sortKey, + precision: todayBucket.precision, + items: [], + }); + } + + return Array.from(bucketMap.entries()) + .sort((a, b) => b[1].sortKey - a[1].sortKey) + .map(([label, value]) => ({ label, items: value.items, precision: value.precision } satisfies TimelineBucket)); +} From 63ad9d7d07bf0fcb2faa1839476ed86ce609ebc6 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 15 Oct 2025 08:20:02 +0900 Subject: [PATCH 6/8] fix test --- apps/desktop2/src/utils/timeline.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/desktop2/src/utils/timeline.test.ts b/apps/desktop2/src/utils/timeline.test.ts index 9a8bd4ca7..ce8f88f36 100644 --- a/apps/desktop2/src/utils/timeline.test.ts +++ b/apps/desktop2/src/utils/timeline.test.ts @@ -49,9 +49,9 @@ describe("timeline utils", () => { const eventsWithoutSessionTable: EventsWithoutSessionTable = { "event-1": { title: "Future Event", - started_at: "2024-01-20T16:00:00.000Z", - ended_at: "2024-01-20T17:00:00.000Z", - created_at: "2024-01-10T15:00:00.000Z", + started_at: "2024-01-18T12:00:00.000Z", + ended_at: "2024-01-18T13:00:00.000Z", + created_at: "2024-01-10T12:00:00.000Z", calendar_id: "cal-1", user_id: "user-1", }, @@ -60,9 +60,9 @@ describe("timeline utils", () => { const sessionsWithMaybeEventTable: SessionsWithMaybeEventTable = { "session-1": { title: "Linked Session", - created_at: "2024-01-10T16:00:00.000Z", + created_at: "2024-01-10T12:00:00.000Z", event_id: "event-1", - event_started_at: "2024-01-20T16:00:00.000Z", + event_started_at: "2024-01-18T12:00:00.000Z", user_id: "user-1", raw_md: "", enhanced_md: "", @@ -70,7 +70,7 @@ describe("timeline utils", () => { }, "session-2": { title: "Standalone Session", - created_at: "2024-01-14T15:00:00.000Z", + created_at: "2024-01-14T12:00:00.000Z", user_id: "user-1", raw_md: "", enhanced_md: "", @@ -81,7 +81,7 @@ describe("timeline utils", () => { const buckets = buildTimelineBuckets({ eventsWithoutSessionTable, sessionsWithMaybeEventTable }); const futureBucket = buckets[0]; - expect(futureBucket.label).toBe("in 6 days"); + expect(futureBucket.label).toBe("in 3 days"); expect(futureBucket.items).toHaveLength(1); expect(futureBucket.items[0]).toMatchObject({ type: "event", id: "event-1" }); From c12fd57ba3161ca8b3cb1ff552898da0f8b4ec90 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 15 Oct 2025 08:45:37 +0900 Subject: [PATCH 7/8] chores --- apps/desktop2/src/utils/timeline.test.ts | 2 ++ apps/desktop2/src/utils/timeline.ts | 26 +++++++++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/desktop2/src/utils/timeline.test.ts b/apps/desktop2/src/utils/timeline.test.ts index ce8f88f36..980e92cc5 100644 --- a/apps/desktop2/src/utils/timeline.test.ts +++ b/apps/desktop2/src/utils/timeline.test.ts @@ -1,3 +1,5 @@ +process.env.TZ = "UTC"; + import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { diff --git a/apps/desktop2/src/utils/timeline.ts b/apps/desktop2/src/utils/timeline.ts index eddf12b6a..831270045 100644 --- a/apps/desktop2/src/utils/timeline.ts +++ b/apps/desktop2/src/utils/timeline.ts @@ -1,5 +1,6 @@ -import { differenceInCalendarMonths, differenceInDays, format, isPast, startOfDay } from "date-fns"; +import { differenceInCalendarMonths, differenceInDays, isPast, startOfDay } from "date-fns"; +import { format } from "@hypr/utils/datetime"; import type * as persisted from "../store/tinybase/persisted"; export type TimelineEventRow = { @@ -120,7 +121,12 @@ export function buildTimelineBuckets({ eventsWithoutSessionTable && Object.entries(eventsWithoutSessionTable).forEach(([eventId, row]) => { - const eventStartTime = new Date(String(row.started_at || "")); + const rawTimestamp = String(row.started_at ?? ""); + const eventStartTime = new Date(rawTimestamp); + + if (isNaN(eventStartTime.getTime())) { + return; + } if (!isPast(eventStartTime)) { items.push({ @@ -140,11 +146,17 @@ export function buildTimelineBuckets({ return; } - const timestamp = String(row.event_started_at || row.created_at || ""); + const rawTimestamp = String(row.event_started_at ?? row.created_at ?? ""); + const date = new Date(rawTimestamp); + + if (isNaN(date.getTime())) { + return; + } + items.push({ type: "session", id: sessionId, - date: format(new Date(timestamp), "yyyy-MM-dd"), + date: format(date, "yyyy-MM-dd"), data: row as unknown as persisted.Session, }); }); @@ -152,7 +164,11 @@ export function buildTimelineBuckets({ items.sort((a, b) => { const timeA = a.type === "event" ? a.data.started_at : a.data.created_at; const timeB = b.type === "event" ? b.data.started_at : b.data.created_at; - return new Date(timeB).getTime() - new Date(timeA).getTime(); + const dateA = new Date(String(timeA ?? "")); + const dateB = new Date(String(timeB ?? "")); + const timeAValue = isNaN(dateA.getTime()) ? 0 : dateA.getTime(); + const timeBValue = isNaN(dateB.getTime()) ? 0 : dateB.getTime(); + return timeBValue - timeAValue; }); const bucketMap = new Map(); From 2e3adb9ffc0ec9821854dfeb62e7496d37dc1e2e Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 15 Oct 2025 09:00:16 +0900 Subject: [PATCH 8/8] fix --- packages/utils/src/datetime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/utils/src/datetime.ts b/packages/utils/src/datetime.ts index 549c9aa6c..e524cea85 100644 --- a/packages/utils/src/datetime.ts +++ b/packages/utils/src/datetime.ts @@ -124,7 +124,7 @@ export const formatRelativeWithDay = (date: string | null | undefined, t?: strin export const timezone = () => { if (typeof window === "undefined") { - throw new Error("timezone is only available on browser"); + return "UTC"; } return Intl.DateTimeFormat().resolvedOptions().timeZone;