diff --git a/apps/desktop/src/main/top-meeting-timeline.test.tsx b/apps/desktop/src/main/top-meeting-timeline.test.tsx index 4d9f727669..789fd71066 100644 --- a/apps/desktop/src/main/top-meeting-timeline.test.tsx +++ b/apps/desktop/src/main/top-meeting-timeline.test.tsx @@ -1,4 +1,5 @@ import { + act, cleanup, fireEvent, render, @@ -8,7 +9,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - createNewMeeting: vi.fn(), + createNewNote: vi.fn(), liveSessionId: null as string | null, openNew: vi.fn(), startDragging: vi.fn().mockResolvedValue(undefined), @@ -60,7 +61,7 @@ vi.mock("~/shared/hooks/useNativeContextMenu", () => ({ })); vi.mock("~/shared/useNewNote", () => ({ - useNewNoteAndListen: () => mocks.createNewMeeting, + useNewNote: () => mocks.createNewNote, })); vi.mock("~/store/tinybase/hooks", () => ({ @@ -150,7 +151,7 @@ import { describe("TopMeetingTimeline", () => { beforeEach(() => { - mocks.createNewMeeting.mockClear(); + mocks.createNewNote.mockClear(); mocks.openNew.mockClear(); mocks.startDragging.mockClear(); mocks.stopListening.mockClear(); @@ -170,7 +171,7 @@ describe("TopMeetingTimeline", () => { render(); const createButton = screen.getByRole("button", { - name: /Create new meeting/, + name: /Create new note/, }); fireEvent.pointerDown(createButton, { @@ -182,14 +183,14 @@ describe("TopMeetingTimeline", () => { fireEvent.click(createButton); expect(mocks.startDragging).not.toHaveBeenCalled(); - expect(mocks.createNewMeeting).toHaveBeenCalledTimes(1); + expect(mocks.createNewNote).toHaveBeenCalledTimes(1); }); it("starts window drag and ignores the release click after dragging", () => { render(); const createButton = screen.getByRole("button", { - name: /Create new meeting/, + name: /Create new note/, }); fireEvent.pointerDown(createButton, { @@ -206,7 +207,7 @@ describe("TopMeetingTimeline", () => { fireEvent.click(createButton); expect(mocks.startDragging).toHaveBeenCalledTimes(1); - expect(mocks.createNewMeeting).not.toHaveBeenCalled(); + expect(mocks.createNewNote).not.toHaveBeenCalled(); }); it("shows timeline item titles above start time metadata", () => { @@ -224,10 +225,11 @@ describe("TopMeetingTimeline", () => { render(); const title = screen.getByText("Design Review"); - const startMetadata = screen.getByText(startLabel); - const buttonText = title.closest("button")?.textContent ?? ""; + const cardButton = title.closest("button"); + const startMetadata = within(cardButton!).getByText(startLabel); + const buttonText = cardButton?.textContent ?? ""; - expect(title.closest("button")).toBe(startMetadata.closest("button")); + expect(cardButton).toBe(startMetadata.closest("button")); expect(buttonText.indexOf("Design Review")).toBeLessThan( buttonText.indexOf(startLabel), ); @@ -369,7 +371,7 @@ describe("TopMeetingTimeline", () => { expect(indicatorX).toBe(cardWidth / 2); }); - it("places the current time marker between open-ended notes and future meetings", () => { + it("places the create note card next to the latest note instead of a future event", () => { const now = new Date("2026-05-29T15:41:00.000Z"); vi.useFakeTimers(); vi.setSystemTime(now); @@ -393,12 +395,123 @@ describe("TopMeetingTimeline", () => { render(); - expect(screen.getByTestId("top-timeline-now-indicator").style.left).toBe( - "162px", + const createButton = screen.getByRole("button", { + name: /Create new note/, + }); + const createCard = createButton.closest( + "[data-timeline-start-ms]", + ) as HTMLDivElement | null; + const cardWidth = Number.parseFloat(createCard?.style.width ?? ""); + const carousel = createCard?.parentElement as HTMLDivElement | null; + const timelineCards = Array.from( + document.querySelectorAll("[data-timeline-start-ms]"), + ); + const indicatorX = Number.parseFloat( + screen.getByTestId("top-timeline-now-indicator").style.left, ); + + expect(timelineCards[0]?.textContent).toContain("Untitled"); + expect(timelineCards[1]?.textContent).toContain("Create new note"); + expect(timelineCards[2]?.textContent).toContain("Design sync"); + expect(createCard?.textContent).not.toContain("Today"); + expect(createCard?.textContent).not.toContain("3:41 PM"); + expect(cardWidth).toBe(160); + expect(carousel?.style.width).toBe("512px"); + expect(indicatorX).toBe(164); }); - it("hides the current time marker during active ad-hoc meetings", () => { + it("keeps same-day events before the current-time create note card", () => { + const now = new Date("2026-05-29T15:41:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + + mocks.timelineSessionsTable = { + "session-1": { + created_at: new Date("2026-05-29T15:28:00.000Z").toISOString(), + event_json: "", + title: "Untitled", + }, + }; + mocks.timelineEventsTable = { + "event-1": { + calendar_id: null, + ended_at: new Date("2026-05-29T16:00:00.000Z").toISOString(), + has_recurrence_rules: false, + started_at: new Date("2026-05-29T15:33:00.000Z").toISOString(), + title: "Post-note event", + }, + }; + + render(); + + screen.getByRole("button", { name: /Create new note/ }); + const timelineCards = Array.from( + document.querySelectorAll("[data-timeline-start-ms]"), + ); + + expect(timelineCards[0]?.textContent).toContain("Untitled"); + expect(timelineCards[1]?.textContent).toContain("Post-note event"); + expect(timelineCards[2]?.textContent).toContain("Create new note"); + }); + + it("places the create note card before future notes", () => { + const now = new Date("2026-05-29T15:41:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + + mocks.timelineSessionsTable = { + "session-1": { + created_at: new Date("2026-05-29T15:28:00.000Z").toISOString(), + event_json: "", + title: "Current note", + }, + "session-2": { + created_at: new Date("2026-05-29T16:15:00.000Z").toISOString(), + event_json: "", + title: "Future note", + }, + }; + + render(); + + const timelineCards = Array.from( + document.querySelectorAll("[data-timeline-start-ms]"), + ); + + expect(timelineCards[0]?.textContent).toContain("Current note"); + expect(timelineCards[1]?.textContent).toContain("Create new note"); + expect(timelineCards[2]?.textContent).toContain("Future note"); + }); + + it("keeps manual scroll when the trailing create note clock ticks", () => { + const now = new Date("2026-05-29T15:41:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + + render(); + + const createCard = screen + .getByRole("button", { name: /Create new note/ }) + .closest("[data-timeline-start-ms]") as HTMLDivElement | null; + const scrollContainer = createCard?.parentElement?.parentElement; + + expect(scrollContainer).toBeTruthy(); + + Object.defineProperty(scrollContainer, "clientWidth", { + configurable: true, + value: 100, + }); + scrollContainer!.scrollLeft = 42; + + act(() => { + vi.setSystemTime(new Date(now.getTime() + 60_100)); + vi.advanceTimersByTime(60_100); + }); + + expect(scrollContainer!.scrollLeft).toBe(42); + }); + + it("keeps create note visible next to active ad-hoc meetings", () => { const now = new Date("2026-05-29T15:41:00.000Z"); vi.useFakeTimers(); vi.setSystemTime(now); @@ -415,6 +528,15 @@ describe("TopMeetingTimeline", () => { render(); + const timelineCards = Array.from( + document.querySelectorAll("[data-timeline-start-ms]"), + ); + + expect(timelineCards[0]?.textContent).toContain("Live Ad-hoc"); + expect(timelineCards[1]?.textContent).toContain("Create new note"); + expect( + screen.getByRole("button", { name: /Create new note/ }), + ).toBeTruthy(); expect(screen.queryByTestId("top-timeline-now-indicator")).toBeNull(); }); @@ -460,11 +582,11 @@ describe("TopMeetingTimeline", () => { configurable: true, value: 100, }); - scrollContainer!.scrollLeft = 250; + scrollContainer!.scrollLeft = 380; fireEvent.scroll(scrollContainer!); fireEvent.click(screen.getByRole("button", { name: "Now" })); - expect(scrollContainer!.scrollLeft).toBe(112); + expect(scrollContainer!.scrollLeft).toBe(114); }); }); diff --git a/apps/desktop/src/main/top-meeting-timeline.tsx b/apps/desktop/src/main/top-meeting-timeline.tsx index c5e7a2139b..32d1d5556f 100644 --- a/apps/desktop/src/main/top-meeting-timeline.tsx +++ b/apps/desktop/src/main/top-meeting-timeline.tsx @@ -56,7 +56,7 @@ import { type MenuItemDef, useNativeContextMenu, } from "~/shared/hooks/useNativeContextMenu"; -import { useNewNoteAndListen } from "~/shared/useNewNote"; +import { useNewNote } from "~/shared/useNewNote"; import { useCurrentTimeMs } from "~/sidebar/timeline/realtime"; import type { TimelineEventRow, @@ -74,6 +74,7 @@ import { useListener } from "~/stt/contexts"; const TIMELINE_HEIGHT = 44; const TIMELINE_CAROUSEL_CARD_WIDTH = 160; const TIMELINE_CAROUSEL_PADDING = 0; +const TIMELINE_CAROUSEL_END_PADDING = 24; const TIMELINE_CAROUSEL_GAP = 4; const TIMELINE_PAST_DAYS = 6; const TIMELINE_FUTURE_DAYS = 1; @@ -165,7 +166,7 @@ export function TopMeetingTimeline({ currentTab }: { currentTab: Tab | null }) { const [todayChipDirection, setTodayChipDirection] = useState(null); - const createNewMeeting = useNewNoteAndListen({ behavior: "current" }); + const createNewNote = useNewNote({ behavior: "current" }); const openNew = useTabs((state) => state.openNew); const renderItems = useMemo( @@ -193,7 +194,8 @@ export function TopMeetingTimeline({ currentTab }: { currentTab: Tab | null }) { endExclusive: timelineEnd, hasHiddenPastItems, hasHiddenFutureItems, - timezone, + currentTime: new Date(currentTimeMs), + includeCreateNote: true, }), [ renderItems, @@ -202,7 +204,7 @@ export function TopMeetingTimeline({ currentTab }: { currentTab: Tab | null }) { timelineEnd, hasHiddenPastItems, hasHiddenFutureItems, - timezone, + currentTimeMs, ], ); const carouselWidth = getTimelineCarouselWidth(carouselItems); @@ -440,12 +442,11 @@ export function TopMeetingTimeline({ currentTab }: { currentTab: Tab | null }) { /> ) : null} {carouselItems.map((renderItem) => - renderItem.kind === "create-meeting" ? ( - ) : renderItem.kind === "open-calendar" ? ( void; }) { return ( @@ -761,12 +760,9 @@ function TimelineCreateMeetingCard({ ])} onClick={onClick} > - - {formatRelativeTimelineDay(item.start, timezone)} - - Create new meeting + Create new note @@ -1003,7 +999,8 @@ function buildTimelineCarouselItems({ endExclusive, hasHiddenPastItems, hasHiddenFutureItems, - timezone, + currentTime, + includeCreateNote, }: { renderItems: TimelineRenderItem[]; currentDate: Date; @@ -1011,30 +1008,22 @@ function buildTimelineCarouselItems({ endExclusive: Date; hasHiddenPastItems: boolean; hasHiddenFutureItems: boolean; - timezone?: string; + currentTime: Date; + includeCreateNote: boolean; }): TimelineCarouselItem[] { - const items: TimelineCarouselItem[] = [...renderItems]; - const hasToday = items.some((item) => - isSameTimelineDay( - getTimelineCarouselItemStart(item), - currentDate, - timezone, - ), - ); - - if (!hasToday) { - items.push({ - kind: "create-meeting", - id: `create-meeting-${currentDate.getTime()}`, - start: currentDate, - }); - } + const items: TimelineRenderItem[] = [...renderItems]; const sortedItems = items.sort( (a, b) => getTimelineCarouselItemStart(a).getTime() - getTimelineCarouselItemStart(b).getTime(), ); + const visibleItems = includeCreateNote + ? insertCreateNoteAtCurrentTime(sortedItems, { + currentDate, + currentTime, + }) + : sortedItems; const result: TimelineCarouselItem[] = hasHiddenPastItems ? [ @@ -1043,9 +1032,9 @@ function buildTimelineCarouselItems({ id: `open-calendar-${startInclusive.getTime()}`, start: startInclusive, }, - ...sortedItems, + ...visibleItems, ] - : sortedItems; + : visibleItems; if (hasHiddenFutureItems) { result.push({ @@ -1058,6 +1047,36 @@ function buildTimelineCarouselItems({ return result; } +function insertCreateNoteAtCurrentTime( + items: TimelineRenderItem[], + { + currentDate, + currentTime, + }: { + currentDate: Date; + currentTime: Date; + }, +): TimelineCarouselItem[] { + const createNote: TimelineCreateNoteItem = { + kind: "create-note", + id: `create-note-${currentDate.getTime()}`, + start: currentTime, + }; + const chronologicalIndex = items.findIndex( + (item) => item.start.getTime() > currentTime.getTime(), + ); + + if (chronologicalIndex === -1) { + return [...items, createNote]; + } + + return [ + ...items.slice(0, chronologicalIndex), + createNote, + ...items.slice(chronologicalIndex), + ]; +} + function hasTimelineEntriesBefore( entries: MeetingTimelineEntry[], startInclusive: Date, @@ -1094,6 +1113,7 @@ function getTimelineCarouselWidth(items: TimelineCarouselItem[]): number { return ( TIMELINE_CAROUSEL_PADDING * 2 + + TIMELINE_CAROUSEL_END_PADDING + contentWidth + TIMELINE_CAROUSEL_GAP * Math.max(0, items.length - 1) ); @@ -1207,6 +1227,14 @@ function getTimelineCarouselNowX( } if (previousItem && nextItem) { + if (previousItem.item.kind === "create-note") { + return previousItem.left; + } + + if (nextItem.item.kind === "create-note") { + return nextItem.left; + } + return previousItem.right + (nextItem.left - previousItem.right) / 2; } @@ -1218,6 +1246,10 @@ function getTimelineCarouselNowX( return null; } + if (previousItem.item.kind === "create-note") { + return previousItem.left; + } + if (previousItem.item.kind !== "item") { return previousItem.left + previousItem.width / 2; } @@ -1340,11 +1372,17 @@ function getTimelineCarouselAnchorKey( return [ selectedSessionId ?? "today", items.length, - first ? getTimelineCarouselItemStart(first).getTime() : 0, - last ? getTimelineCarouselItemStart(last).getTime() : 0, + first ? getTimelineCarouselAnchorToken(first) : 0, + last ? getTimelineCarouselAnchorToken(last) : 0, ].join(":"); } +function getTimelineCarouselAnchorToken(item: TimelineCarouselItem): string { + return item.kind === "create-note" + ? item.id + : String(getTimelineCarouselItemStart(item).getTime()); +} + function getTimelineCarouselItemStart(item: TimelineCarouselItem): Date { return item.start; }