diff --git a/.github/workflows/desktop2_ci.yaml b/.github/workflows/desktop2_ci.yaml index 1f9dd1771..27939eb87 100644 --- a/.github/workflows/desktop2_ci.yaml +++ b/.github/workflows/desktop2_ci.yaml @@ -21,8 +21,6 @@ jobs: include: - platform: "macos" runner: "macos-14" - - platform: "windows" - runner: "windows-latest" runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/desktop_ci.yaml b/.github/workflows/desktop_ci.yaml index 875b21afc..2cd9ba50a 100644 --- a/.github/workflows/desktop_ci.yaml +++ b/.github/workflows/desktop_ci.yaml @@ -21,8 +21,6 @@ jobs: include: - platform: "macos" runner: "macos-14" - - platform: "windows" - runner: "windows-latest" runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 diff --git a/apps/desktop2/src/components/main/body/sessions/outer-header/folder.tsx b/apps/desktop2/src/components/main/body/sessions/outer-header/folder.tsx index 6a51c5897..66aeb8d68 100644 --- a/apps/desktop2/src/components/main/body/sessions/outer-header/folder.tsx +++ b/apps/desktop2/src/components/main/body/sessions/outer-header/folder.tsx @@ -3,33 +3,56 @@ import { useCallback } from "react"; import * as persisted from "../../../../../store/tinybase/persisted"; import { useTabs } from "../../../../../store/zustand/tabs"; -export function FolderChain({ title, folderId }: { title: string; folderId: string }) { - const folderIds = persisted.UI.useLinkedRowIds( - "folderToParentFolder", - folderId, - persisted.STORE_ID, - ); - - if (!folderIds || folderIds.length === 0) { - return null; - } - - const folderChain = [...folderIds].reverse(); +export function FolderChain({ sessionId }: { sessionId: string }) { + const folderId = persisted.UI.useCell("sessions", sessionId, "folder_id", persisted.STORE_ID); + const title = persisted.UI.useCell("sessions", sessionId, "title", persisted.STORE_ID); return (
- {folderChain.map((id, index) => ( + {!folderId + ? + : } +
+ ); +} + +function RenderIfRootExist({ folderId, title }: { folderId: string; title: string }) { + const folderIds = useFolderList(folderId); + return ( + <> + {folderIds.map((id, index) => (
{index > 0 && /}
))} -
- / - {title} -
- + / + {title} + + ); +} + +function RenderIfRootNotExist({ sessionId }: { sessionId: string }) { + const title = persisted.UI.useCell("sessions", sessionId, "title", persisted.STORE_ID); + + return ( + <> + + / + {title ?? "Untitled"} + + ); +} + +function useFolderList(rootFolderId: string) { + const folderIds = persisted.UI.useLinkedRowIds( + "folderToParentFolder", + rootFolderId, + persisted.STORE_ID, ); + return [...folderIds].reverse(); } function FolderItem({ folderId }: { folderId: string }) { @@ -42,7 +65,7 @@ function FolderItem({ folderId }: { folderId: string }) { return ( - )} - - ); -} - -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 = useMemo(() => buckets.some(bucket => bucket.label === "Today"), [buckets]); - - const setCurrentTimeIndicatorRef = useCallback((node: HTMLDivElement | null) => { - setIndicatorNode(prevNode => (prevNode === node ? prevNode : node)); - }, []); - - const scrollToToday = useCallback(() => { - const container = containerRef.current; - 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 container = containerRef.current; - - if (!container || !indicatorNode) { - setIsTodayVisible(true); - setIsScrolledPastToday(false); - return; - } - - const observer = new IntersectionObserver( - ([entry]) => { - const containerRect = container.getBoundingClientRect(); - const indicatorRect = indicatorNode.getBoundingClientRect(); - - setIsTodayVisible(entry.isIntersecting); - setIsScrolledPastToday(indicatorRect.top < containerRect.top); - }, - { root: container, threshold: 0.1 }, - ); - - observer.observe(indicatorNode); - - return () => observer.disconnect(); - }, [indicatorNode]); - - return { - containerRef, - isTodayVisible, - isScrolledPastToday, - scrollToToday, - hasToday, - setCurrentTimeIndicatorRef, - }; -} - -function DateHeader({ label }: { label: string }) { - return
{label}
; -} - -function TimelineItemComponent({ item, precision }: { item: TimelineItem; precision: TimelinePrecision }) { - const { currentTab, openCurrent, openNew } = useTabs(); - const store = persisted.UI.useStore(persisted.STORE_ID); - - const title = item.data.title || "Untitled"; - const timestamp = item.type === "event" ? item.data.started_at : item.data.created_at; - const eventId = item.type === "event" ? item.id : (item.data.event_id || undefined); - - const handleClick = () => { - if (item.type === "event") { - handleEventClick(false); - } else { - const tab: Tab = { id: item.id, type: "sessions", active: false, state: { editor: "raw" } }; - openCurrent(tab); - } - }; - - const handleCmdClick = () => { - if (item.type === "event") { - handleEventClick(true); - } else { - const tab: Tab = { id: item.id, type: "sessions", active: false, state: { editor: "raw" } }; - openNew(tab); - } - }; - - const handleEventClick = (openInNewTab: boolean) => { - if (!eventId || !store) { - return; - } - - const sessions = store.getTable("sessions"); - let existingSessionId: string | null = null; - - Object.entries(sessions).forEach(([sessionId, session]) => { - if (session.event_id === eventId) { - existingSessionId = sessionId; - } - }); - - if (existingSessionId) { - const tab: Tab = { id: existingSessionId, type: "sessions", active: false, state: { editor: "raw" } }; - if (openInNewTab) { - openNew(tab); - } else { - openCurrent(tab); - } - } else { - const sessionId = id(); - store.setRow("sessions", sessionId, { - event_id: eventId, - title: title, - created_at: new Date().toISOString(), - }); - const tab: Tab = { id: sessionId, type: "sessions", active: false, state: { editor: "raw" } }; - if (openInNewTab) { - openNew(tab); - } else { - openCurrent(tab); - } - } - }; - - // TODO: not ideal - const active = currentTab?.type === "sessions" && ( - (item.type === "session" && currentTab.id === item.id) - || (item.type === "event" && item.id === eventId && (() => { - if (!store) { - return false; - } - const session = store.getRow("sessions", currentTab.id); - return session?.event_id === eventId; - })()) - ); - - const contextMenu = ( - <> - handleCmdClick()}> - - New Tab - - console.log("Delete:", item.type, item.id)}> - - Delete - - - ); - const displayTime = useMemo(() => { - if (!timestamp) { - return ""; - } - - const date = new Date(timestamp); - if (Number.isNaN(date.getTime())) { - return ""; - } - - const time = date.toLocaleTimeString([], { hour: "numeric", minute: "numeric" }); - - if (precision === "time") { - return time; - } - - const sameYear = date.getFullYear() === new Date().getFullYear(); - const dateStr = sameYear - ? date.toLocaleDateString([], { month: "short", day: "numeric" }) - : date.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" }); - return `${dateStr}, ${time}`; - }, [timestamp, precision]); - - return ( - -
-
{title}
- {displayTime &&
{displayTime}
} -
-
- ); -} - -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], - ); - - const currentTimeMs = currentTime.getTime(); - - const indicatorIndex = useMemo(() => { - for (let index = 0; index < entries.length; index += 1) { - const timestamp = entries[index].timestamp; - - if (!timestamp) { - return index; - } - - if (timestamp.getTime() < currentTimeMs) { - return index; - } - } - - return entries.length; - }, [entries, currentTimeMs]); - - if (entries.length === 0) { - return ( - <> - -
- No items today -
- - ); - } - - return ( - <> - {entries.map((entry, index) => ( - - {index === indicatorIndex && } - - - ))} - {indicatorIndex === entries.length && } - - ); -} - -function useTimelineData(): TimelineBucket[] { - const eventsWithoutSessionTable = persisted.UI.useResultTable( - persisted.QUERIES.eventsWithoutSession, - persisted.STORE_ID, - ); - const sessionsWithMaybeEventTable = persisted.UI.useResultTable( - persisted.QUERIES.sessionsWithMaybeEvent, - persisted.STORE_ID, - ); - - return useMemo( - () => - buildTimelineBuckets({ - eventsWithoutSessionTable, - sessionsWithMaybeEventTable, - }), - [eventsWithoutSessionTable, sessionsWithMaybeEventTable], - ); -} - -function useCurrentTime() { - const [now, setNow] = useState(() => new Date()); - - useEffect(() => { - const update = () => setNow(new Date()); - update(); - const interval = window.setInterval(update, 60_000); - - return () => { - window.clearInterval(interval); - }; - }, []); - - return now; -} - -function getItemTimestamp(item: TimelineItem): Date | null { - const value = item.type === "event" ? item.data.started_at : item.data.created_at; - - if (!value) { - return null; - } - - 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/components/main/sidebar/timeline/anchor.ts b/apps/desktop2/src/components/main/sidebar/timeline/anchor.ts new file mode 100644 index 000000000..9524b7bd3 --- /dev/null +++ b/apps/desktop2/src/components/main/sidebar/timeline/anchor.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export function useAnchor({ + isAnchorActive, + autoScrollOnMount, +}: { + isAnchorActive: boolean; + autoScrollOnMount?: boolean; +}) { + const containerRef = useRef(null); + const [isAnchorVisible, setIsAnchorVisible] = useState(true); + const [isScrolledPastAnchor, setIsScrolledPastAnchor] = useState(false); + const [anchorNode, setAnchorNode] = useState(null); + + const registerAnchor = useCallback((node: HTMLDivElement | null) => { + setAnchorNode(previousNode => (previousNode === node ? previousNode : node)); + }, []); + + const scrollToAnchor = useCallback(() => { + const container = containerRef.current; + if (!container || !anchorNode) { + return; + } + + const containerRect = container.getBoundingClientRect(); + const anchorRect = anchorNode.getBoundingClientRect(); + const anchorCenter = anchorRect.top - containerRect.top + container.scrollTop + (anchorRect.height / 2); + const targetScrollTop = Math.max(anchorCenter - (container.clientHeight / 2), 0); + container.scrollTo({ top: targetScrollTop, behavior: "smooth" }); + }, [anchorNode]); + + useEffect(() => { + if (!isAnchorActive || !autoScrollOnMount) { + return; + } + + requestAnimationFrame(() => { + scrollToAnchor(); + }); + }, [autoScrollOnMount, isAnchorActive, scrollToAnchor]); + + useEffect(() => { + const container = containerRef.current; + + if (!container || !anchorNode) { + setIsAnchorVisible(true); + setIsScrolledPastAnchor(false); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + const containerRect = container.getBoundingClientRect(); + const anchorRect = anchorNode.getBoundingClientRect(); + + setIsAnchorVisible(entry.isIntersecting); + setIsScrolledPastAnchor(anchorRect.top < containerRect.top); + }, + { root: container, threshold: 0.1 }, + ); + + observer.observe(anchorNode); + + return () => observer.disconnect(); + }, [anchorNode]); + + return { + containerRef, + isAnchorVisible, + isScrolledPastAnchor, + scrollToAnchor, + isAnchorActive, + registerAnchor, + }; +} diff --git a/apps/desktop2/src/components/main/sidebar/timeline/index.tsx b/apps/desktop2/src/components/main/sidebar/timeline/index.tsx new file mode 100644 index 000000000..b559730ba --- /dev/null +++ b/apps/desktop2/src/components/main/sidebar/timeline/index.tsx @@ -0,0 +1,202 @@ +import { clsx } from "clsx"; +import { CalendarIcon } from "lucide-react"; +import { useMemo } from "react"; +import type { ReactNode } from "react"; + +import { Button } from "@hypr/ui/components/ui/button"; +import { cn } from "@hypr/ui/lib/utils"; +import * as persisted from "../../../../store/tinybase/persisted"; +import { buildTimelineBuckets } from "../../../../utils/timeline"; +import type { TimelineBucket, TimelineItem, TimelinePrecision } from "../../../../utils/timeline"; +import { useAnchor } from "./anchor"; +import { TimelineItemComponent } from "./item"; +import { CurrentTimeIndicator, useCurrentTime } from "./realtime"; + +export function TimelineView() { + const buckets = useTimelineData(); + const hasToday = useMemo(() => buckets.some(bucket => bucket.label === "Today"), [buckets]); + + const { + containerRef, + isAnchorVisible: isTodayVisible, + isScrolledPastAnchor: isScrolledPastToday, + scrollToAnchor: scrollToToday, + registerAnchor: setCurrentTimeIndicatorRef, + } = useAnchor({ isAnchorActive: hasToday, autoScrollOnMount: true }); + + return ( +
+
+ {buckets.map((bucket) => { + const isToday = bucket.label === "Today"; + + return ( +
+
+
{bucket.label}
+
+ {isToday + ? ( + + ) + : ( + bucket.items.map((item) => ( + + )) + )} +
+ ); + })} +
+ + {hasToday && !isTodayVisible && ( + + )} +
+ ); +} + +function TodayBucket({ + items, + precision, + registerIndicator, +}: { + items: TimelineItem[]; + precision: TimelinePrecision; + registerIndicator: (node: HTMLDivElement | null) => void; +}) { + const currentTimeMs = useCurrentTime().getTime(); + + const entries = useMemo( + () => + items.map((timelineItem) => ({ + item: timelineItem, + timestamp: getItemTimestamp(timelineItem), + })), + [items], + ); + + const indicatorIndex = useMemo(() => { + const index = entries.findIndex(({ timestamp }) => { + if (!timestamp) { + return true; + } + + return timestamp.getTime() < currentTimeMs; + }); + + if (index === -1) { + return entries.length; + } + + return index; + }, [entries, currentTimeMs]); + + const renderedEntries = useMemo(() => { + if (entries.length === 0) { + return ( + <> + +
+ No items today +
+ + ); + } + + const nodes: ReactNode[] = []; + + entries.forEach((entry, index) => { + if (index === indicatorIndex) { + nodes.push(); + } + + nodes.push( + , + ); + }); + + if (indicatorIndex === entries.length) { + nodes.push(); + } + + return <>{nodes}; + }, [entries, indicatorIndex, precision, registerIndicator]); + + return renderedEntries; +} + +function useTimelineData(): TimelineBucket[] { + const eventsWithoutSessionTable = persisted.UI.useResultTable( + persisted.QUERIES.eventsWithoutSession, + persisted.STORE_ID, + ); + const sessionsWithMaybeEventTable = persisted.UI.useResultTable( + persisted.QUERIES.sessionsWithMaybeEvent, + persisted.STORE_ID, + ); + + return useMemo( + () => + buildTimelineBuckets({ + eventsWithoutSessionTable, + sessionsWithMaybeEventTable, + }), + [eventsWithoutSessionTable, sessionsWithMaybeEventTable], + ); +} + +function getItemTimestamp(item: TimelineItem): Date | null { + const value = item.type === "event" ? item.data.started_at : item.data.created_at; + + if (!value) { + return null; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return null; + } + + return date; +} diff --git a/apps/desktop2/src/components/main/sidebar/timeline/item.tsx b/apps/desktop2/src/components/main/sidebar/timeline/item.tsx new file mode 100644 index 000000000..601750755 --- /dev/null +++ b/apps/desktop2/src/components/main/sidebar/timeline/item.tsx @@ -0,0 +1,139 @@ +import { ExternalLink, Trash2 } from "lucide-react"; +import { useMemo } from "react"; + +import { ContextMenuItem } from "@hypr/ui/components/ui/context-menu"; +import { cn } from "@hypr/ui/lib/utils"; +import * as persisted from "../../../../store/tinybase/persisted"; +import { Tab, useTabs } from "../../../../store/zustand/tabs"; +import { id } from "../../../../utils"; +import { type TimelineItem, TimelinePrecision } from "../../../../utils/timeline"; +import { InteractiveButton } from "../../../interactive-button"; + +export function TimelineItemComponent({ item, precision }: { item: TimelineItem; precision: TimelinePrecision }) { + const { currentTab, openCurrent, openNew } = useTabs(); + const store = persisted.UI.useStore(persisted.STORE_ID); + + const title = item.data.title || "Untitled"; + const timestamp = item.type === "event" ? item.data.started_at : item.data.created_at; + const eventId = item.type === "event" ? item.id : (item.data.event_id || undefined); + + const handleClick = () => { + if (item.type === "event") { + handleEventClick(false); + } else { + const tab: Tab = { id: item.id, type: "sessions", active: false, state: { editor: "raw" } }; + openCurrent(tab); + } + }; + + const handleCmdClick = () => { + if (item.type === "event") { + handleEventClick(true); + } else { + const tab: Tab = { id: item.id, type: "sessions", active: false, state: { editor: "raw" } }; + openNew(tab); + } + }; + + const handleEventClick = (openInNewTab: boolean) => { + if (!eventId || !store) { + return; + } + + const sessions = store.getTable("sessions"); + let existingSessionId: string | null = null; + + Object.entries(sessions).forEach(([sessionId, session]) => { + if (session.event_id === eventId) { + existingSessionId = sessionId; + } + }); + + if (existingSessionId) { + const tab: Tab = { id: existingSessionId, type: "sessions", active: false, state: { editor: "raw" } }; + if (openInNewTab) { + openNew(tab); + } else { + openCurrent(tab); + } + } else { + const sessionId = id(); + store.setRow("sessions", sessionId, { + event_id: eventId, + title: title, + created_at: new Date().toISOString(), + }); + const tab: Tab = { id: sessionId, type: "sessions", active: false, state: { editor: "raw" } }; + if (openInNewTab) { + openNew(tab); + } else { + openCurrent(tab); + } + } + }; + + // TODO: not ideal + const active = currentTab?.type === "sessions" && ( + (item.type === "session" && currentTab.id === item.id) + || (item.type === "event" && item.id === eventId && (() => { + if (!store) { + return false; + } + const session = store.getRow("sessions", currentTab.id); + return session?.event_id === eventId; + })()) + ); + + const contextMenu = ( + <> + handleCmdClick()}> + + New Tab + + console.log("Delete:", item.type, item.id)}> + + Delete + + + ); + const displayTime = useMemo(() => { + if (!timestamp) { + return ""; + } + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return ""; + } + + const time = date.toLocaleTimeString([], { hour: "numeric", minute: "numeric" }); + + if (precision === "time") { + return time; + } + + const sameYear = date.getFullYear() === new Date().getFullYear(); + const dateStr = sameYear + ? date.toLocaleDateString([], { month: "short", day: "numeric" }) + : date.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" }); + return `${dateStr}, ${time}`; + }, [timestamp, precision]); + + return ( + +
+
{title}
+ {displayTime &&
{displayTime}
} +
+
+ ); +} diff --git a/apps/desktop2/src/components/main/sidebar/timeline/realtime.tsx b/apps/desktop2/src/components/main/sidebar/timeline/realtime.tsx new file mode 100644 index 000000000..8f2e233eb --- /dev/null +++ b/apps/desktop2/src/components/main/sidebar/timeline/realtime.tsx @@ -0,0 +1,21 @@ +import { forwardRef, useEffect, useState } from "react"; + +export const CurrentTimeIndicator = forwardRef((_, ref) => ( +
+
+
+)); + +export function useCurrentTime() { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const update = () => setNow(new Date()); + update(); + + const interval = window.setInterval(update, 60_000); + return () => window.clearInterval(interval); + }, []); + + return now; +}