From 04979f6f9ac49ad388b9a505d7e9bfe1bf856d7e Mon Sep 17 00:00:00 2001 From: Michael Zick <1907006+michaelzick@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:17:54 -0700 Subject: [PATCH 1/3] Preserve hidden calendar events on sync --- .../src/services/planner-service-calendar.ts | 12 +++- .../src/services/planner-service.test.ts | 24 ++++++- .../timeline-board-calendar-event.tsx | 47 ++++++++++-- .../src/components/timeline-board.test.tsx | 72 +++++++++++++++++++ apps/web/src/components/timeline-board.tsx | 70 ++++++++++++++++++ 5 files changed, 215 insertions(+), 10 deletions(-) diff --git a/apps/server/src/services/planner-service-calendar.ts b/apps/server/src/services/planner-service-calendar.ts index ea4699a..c2085c8 100644 --- a/apps/server/src/services/planner-service-calendar.ts +++ b/apps/server/src/services/planner-service-calendar.ts @@ -10,6 +10,7 @@ import { buildGoogleCalendarSyncScope, recordGoogleCalendarSync, } from "./planner-service-calendar-sync.js"; +import { resolveDismissedExternalUpdatedAt } from "./planner-domain.js"; import { syncGoogleTaskCompletionStatuses } from "./planner-service-google-tasks-sync.js"; export async function syncPlannerGoogleCalendar( @@ -39,6 +40,9 @@ export async function syncPlannerGoogleCalendar( const syncedExternalEventIds: string[] = []; for (const record of records) { syncedExternalEventIds.push(record.externalEventId); + const previousEvent = record.isAppManaged + ? null + : await repository.getCalendarEventByExternalEventId(record.externalEventId, pool); await repository.upsertCalendarEvent( { externalEventId: record.externalEventId, @@ -51,7 +55,13 @@ export async function syncPlannerGoogleCalendar( scheduleBlockId: record.scheduleBlockId, rawPayload: record.rawPayload, externalUpdatedAt: record.externalUpdatedAt, - dismissedExternalUpdatedAt: null, + dismissedExternalUpdatedAt: previousEvent + ? resolveDismissedExternalUpdatedAt({ + previousExternalUpdatedAt: previousEvent.externalUpdatedAt, + previousDismissedExternalUpdatedAt: previousEvent.dismissedExternalUpdatedAt, + nextExternalUpdatedAt: record.externalUpdatedAt, + }) + : null, sourceCalendarId: record.sourceCalendarId, sourceCalendarName: record.sourceCalendarName, }, diff --git a/apps/server/src/services/planner-service.test.ts b/apps/server/src/services/planner-service.test.ts index 7eb1e1d..04ee377 100644 --- a/apps/server/src/services/planner-service.test.ts +++ b/apps/server/src/services/planner-service.test.ts @@ -1094,7 +1094,7 @@ describe("planner-service", () => { ); }); - it("clears dismissals for synced Google events on every manual sync", async () => { + it("preserves dismissed synced Google events until Google updates them", async () => { const repository = createRepositoryMock(); repository.getIntegrationToken.mockResolvedValue({ provider: "google", @@ -1107,6 +1107,15 @@ describe("planner-service", () => { }, }); repository.listCalendarEventsForRange.mockResolvedValue([]); + repository.getCalendarEventByExternalEventId + .mockResolvedValueOnce({ + externalUpdatedAt: "2026-04-06T07:30:00.000Z", + dismissedExternalUpdatedAt: "2026-04-06T07:30:00.000Z", + }) + .mockResolvedValueOnce({ + externalUpdatedAt: "2026-04-06T07:30:00.000Z", + dismissedExternalUpdatedAt: "2026-04-06T07:30:00.000Z", + }); syncGoogleCalendarWindow .mockResolvedValueOnce([ @@ -1119,6 +1128,10 @@ describe("planner-service", () => { rawPayload: {}, scheduleBlockId: null, externalUpdatedAt: "2026-04-06T07:30:00.000Z", + backgroundColor: null, + foregroundColor: null, + sourceCalendarId: "primary", + sourceCalendarName: "Primary", }, ]) .mockResolvedValueOnce([ @@ -1130,7 +1143,11 @@ describe("planner-service", () => { isAppManaged: false, rawPayload: {}, scheduleBlockId: null, - externalUpdatedAt: "2026-04-06T07:30:00.000Z", + externalUpdatedAt: "2026-04-06T08:15:00.000Z", + backgroundColor: null, + foregroundColor: null, + sourceCalendarId: "primary", + sourceCalendarName: "Primary", }, ]); @@ -1143,7 +1160,7 @@ describe("planner-service", () => { 1, expect.objectContaining({ externalEventId: "evt-1", - dismissedExternalUpdatedAt: null, + dismissedExternalUpdatedAt: "2026-04-06T07:30:00.000Z", }), fakeDb, ); @@ -1155,6 +1172,7 @@ describe("planner-service", () => { }), fakeDb, ); + expect(repository.getCalendarEventByExternalEventId).toHaveBeenCalledWith("evt-1", fakeDb); }); it("records the selected calendar set when a manual calendar sync succeeds", async () => { diff --git a/apps/web/src/components/timeline-board-calendar-event.tsx b/apps/web/src/components/timeline-board-calendar-event.tsx index 44a7566..4cd11ab 100644 --- a/apps/web/src/components/timeline-board-calendar-event.tsx +++ b/apps/web/src/components/timeline-board-calendar-event.tsx @@ -8,15 +8,38 @@ import { cn, formatTime } from "@/lib/utils"; const FALLBACK_TIMELINE_EVENT_BORDER = "#6374ad"; +type CalendarEventLane = { + laneIndex: number; + laneCount: number; +}; + +type TimelineEventStyle = CSSProperties & { + "--timeline-event-left": string; + "--timeline-event-left-sm": string; + "--timeline-event-width": string; + "--timeline-event-width-sm": string; +}; + +function getLaneLeft(laneIndex: number, laneCount: number, insetPx: number) { + const gutterPx = insetPx * 2; + return `calc(${insetPx}px + ((100% - ${gutterPx}px) / ${laneCount}) * ${laneIndex})`; +} + +function getLaneWidth(laneCount: number, insetPx: number) { + return `calc((100% - ${insetPx * 2}px) / ${laneCount})`; +} + export function TimelineBoardCalendarEvent({ date, event, + lane, isSelected, onSelectCalendarEvent, onDismissCalendarEvent, }: { date: string; event: CalendarEventView; + lane: CalendarEventLane; isSelected: boolean; onSelectCalendarEvent: (calendarEventId: string) => void; onDismissCalendarEvent: (calendarEventId: string, title: string) => void; @@ -30,10 +53,17 @@ export function TimelineBoardCalendarEvent({ const isVeryShort = new Date(event.endAt).getTime() - new Date(event.startAt).getTime() < 30 * 60 * 1000; + const laneCount = Math.max(1, lane.laneCount); + const laneIndex = Math.min(laneCount - 1, Math.max(0, lane.laneIndex)); + const isSharedLane = laneCount > 1; const titleColor = "var(--calendar-event-title)"; const borderColorSource = event.backgroundColor ?? FALLBACK_TIMELINE_EVENT_BORDER; - const cardStyle: CSSProperties = { + const cardStyle: TimelineEventStyle = { + "--timeline-event-left": getLaneLeft(laneIndex, laneCount, 8), + "--timeline-event-left-sm": getLaneLeft(laneIndex, laneCount, 12), + "--timeline-event-width": getLaneWidth(laneCount, 8), + "--timeline-event-width-sm": getLaneWidth(laneCount, 12), top: placement.top, height: placement.height, backgroundColor: "transparent", @@ -53,7 +83,8 @@ export function TimelineBoardCalendarEvent({ key={event.id} data-planner-selectable="true" className={cn( - "absolute left-2 right-2 cursor-pointer overflow-hidden rounded-[24px] border px-3 text-sm sm:left-3 sm:right-3 sm:px-4", + "absolute left-[var(--timeline-event-left)] w-[var(--timeline-event-width)] cursor-pointer overflow-hidden rounded-[24px] border text-sm sm:left-[var(--timeline-event-left-sm)] sm:w-[var(--timeline-event-width-sm)]", + isSharedLane ? "px-2 sm:px-3" : "px-3 sm:px-4", isShort ? "py-0" : "py-3 sm:py-4", )} style={cardStyle} @@ -62,9 +93,13 @@ export function TimelineBoardCalendarEvent({
@@ -84,7 +119,7 @@ export function TimelineBoardCalendarEvent({ )}
-
+
{!isVeryShort && ( diff --git a/apps/web/src/components/timeline-board.test.tsx b/apps/web/src/components/timeline-board.test.tsx index adff9a1..e786a97 100644 --- a/apps/web/src/components/timeline-board.test.tsx +++ b/apps/web/src/components/timeline-board.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { CalendarEventView } from "@timefraim/shared"; import { describe, expect, it, vi } from "vitest"; import { TimelineBoard } from "@/components/timeline-board"; import { buildTask } from "@/test/fixtures"; @@ -24,6 +25,23 @@ function getTimelineItem(title: string) { return item as HTMLElement; } +function buildCalendarEvent(overrides: Partial = {}): CalendarEventView { + return { + id: "calendar-1", + externalEventId: "google-1", + title: "Team sync", + startAt: "2026-04-06T15:00:00.000Z", + endAt: "2026-04-06T15:30:00.000Z", + isAppManaged: false, + backgroundColor: null, + foregroundColor: null, + sourceCalendarId: null, + sourceCalendarName: null, + togglProjectId: null, + ...overrides, + }; +} + describe("TimelineBoard", () => { it("shows Google Calendar blockers and priority labels on scheduled tasks", () => { render( @@ -211,6 +229,60 @@ describe("TimelineBoard", () => { expect(onSelectCalendarEvent).not.toHaveBeenCalled(); }); + it("splits simultaneous Google Calendar blockers into equal horizontal lanes", () => { + render( + , + ); + + const firstEvent = getTimelineItem("Clean"); + const secondEvent = getTimelineItem("Laundry"); + const thirdEvent = getTimelineItem("Bird Stuff"); + + expect(firstEvent.style.top).toBe(secondEvent.style.top); + expect(secondEvent.style.top).toBe(thirdEvent.style.top); + expect(firstEvent.style.getPropertyValue("--timeline-event-width")).toBe("calc((100% - 16px) / 3)"); + expect(secondEvent.style.getPropertyValue("--timeline-event-width")).toBe("calc((100% - 16px) / 3)"); + expect(thirdEvent.style.getPropertyValue("--timeline-event-width")).toBe("calc((100% - 16px) / 3)"); + expect(firstEvent.style.getPropertyValue("--timeline-event-left")).toBe("calc(8px + ((100% - 16px) / 3) * 0)"); + expect(secondEvent.style.getPropertyValue("--timeline-event-left")).toBe("calc(8px + ((100% - 16px) / 3) * 1)"); + expect(thirdEvent.style.getPropertyValue("--timeline-event-left")).toBe("calc(8px + ((100% - 16px) / 3) * 2)"); + expect(screen.getAllByRole("button", { name: /hide/i })).toHaveLength(3); + }); + it.each(["light", "dark"] as const)("uses the white selected border for calendar events in %s mode", (theme) => { document.documentElement.className = theme; diff --git a/apps/web/src/components/timeline-board.tsx b/apps/web/src/components/timeline-board.tsx index 6973706..084772a 100644 --- a/apps/web/src/components/timeline-board.tsx +++ b/apps/web/src/components/timeline-board.tsx @@ -3,6 +3,7 @@ import type { CalendarEventView, ScheduleBlock, Task, TimerSession } from "@time import { buildTimelineSlots, getTimelineContainerHeight, + getTimelinePlacement, SLOT_HEIGHT, } from "@/components/timeline-geometry"; import { TimelineBoardCalendarEvent } from "@/components/timeline-board-calendar-event"; @@ -47,6 +48,73 @@ function deriveRunState(block: ScheduleBlock, task: Task | undefined, activeTime return "idle"; } +type CalendarEventLane = { + laneIndex: number; + laneCount: number; +}; + +type PositionedCalendarEvent = { + event: CalendarEventView; + top: number; + bottom: number; + order: number; +}; + +const DEFAULT_CALENDAR_EVENT_LANE: CalendarEventLane = { + laneIndex: 0, + laneCount: 1, +}; + +function assignCalendarEventLanes(cluster: PositionedCalendarEvent[], lanes: Map) { + const laneEnds: number[] = []; + const laneIndexes = new Map(); + + for (const item of cluster) { + const reusableLaneIndex = laneEnds.findIndex((bottom) => bottom <= item.top); + const laneIndex = reusableLaneIndex === -1 ? laneEnds.length : reusableLaneIndex; + + laneEnds[laneIndex] = item.bottom; + laneIndexes.set(item.event.id, laneIndex); + } + + const laneCount = Math.max(1, laneEnds.length); + for (const item of cluster) { + lanes.set(item.event.id, { + laneIndex: laneIndexes.get(item.event.id) ?? 0, + laneCount, + }); + } +} + +function getCalendarEventLanes(date: string, calendarEvents: CalendarEventView[]) { + const positionedEvents: PositionedCalendarEvent[] = calendarEvents.flatMap((event, order) => { + const placement = getTimelinePlacement(date, event.startAt, event.endAt); + return placement + ? [{ event, order, top: placement.top, bottom: placement.top + placement.height }] + : []; + }); + const lanes = new Map(); + let cluster: PositionedCalendarEvent[] = []; + let clusterBottom = -Infinity; + + positionedEvents.sort((left, right) => left.top - right.top || left.bottom - right.bottom || left.order - right.order); + + for (const item of positionedEvents) { + if (cluster.length === 0 || item.top < clusterBottom) { + cluster.push(item); + clusterBottom = Math.max(clusterBottom, item.bottom); + continue; + } + + assignCalendarEventLanes(cluster, lanes); + cluster = [item]; + clusterBottom = item.bottom; + } + + assignCalendarEventLanes(cluster, lanes); + return lanes; +} + export function TimelineBoard({ date, tasks, @@ -84,6 +152,7 @@ export function TimelineBoard({ }) { const containerHeight = getTimelineContainerHeight(); const slots = buildTimelineSlots(date); + const calendarEventLanes = getCalendarEventLanes(date, calendarEvents); return (
@@ -97,6 +166,7 @@ export function TimelineBoard({ key={event.id} date={date} event={event} + lane={calendarEventLanes.get(event.id) ?? DEFAULT_CALENDAR_EVENT_LANE} isSelected={selectedCalendarEventId === event.id} onSelectCalendarEvent={onSelectCalendarEvent} onDismissCalendarEvent={onDismissCalendarEvent} From 9f601df92ce65a3602182aad5a185792da4bb83f Mon Sep 17 00:00:00 2001 From: Michael Zick <1907006+michaelzick@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:31:02 -0700 Subject: [PATCH 2/3] Restore hidden calendar events on explicit sync --- apps/server/src/http/register-planner-routes.ts | 6 +++--- apps/server/src/http/route-helpers.ts | 11 ++++++++++- apps/server/src/services/planner-service-calendar.ts | 9 +++++---- apps/server/src/services/planner-service.test.ts | 6 +++--- apps/server/src/services/planner-service.ts | 4 +++- apps/web/src/hooks/use-auto-google-task-sync.test.tsx | 1 + apps/web/src/hooks/use-planner-mutations.test.tsx | 6 ++++++ apps/web/src/hooks/use-planner-mutations.ts | 2 +- apps/web/src/lib/api-planner.ts | 4 ++-- packages/shared/src/day-plan.ts | 7 +++++++ packages/shared/src/index.test.ts | 9 +++++++++ 11 files changed, 50 insertions(+), 15 deletions(-) diff --git a/apps/server/src/http/register-planner-routes.ts b/apps/server/src/http/register-planner-routes.ts index 809e510..3aaf5a3 100644 --- a/apps/server/src/http/register-planner-routes.ts +++ b/apps/server/src/http/register-planner-routes.ts @@ -3,7 +3,7 @@ import { registerPlannerDraftRoutes } from "./register-planner-draft-routes.js"; import { registerPlannerDuplicateRoutes } from "./register-planner-duplicate-routes.js"; import { registerPlannerMutationRoutes } from "./register-planner-mutation-routes.js"; import { registerPlannerTimerRoutes } from "./register-planner-timer-routes.js"; -import { parseDayQuery, withAuthenticatedRoute } from "./route-helpers.js"; +import { parseCalendarSyncQuery, parseDayQuery, withAuthenticatedRoute } from "./route-helpers.js"; import type { PlannerService } from "../services/planner-service.js"; export function registerPlannerRoutes(app: FastifyInstance, plannerService: PlannerService) { @@ -18,8 +18,8 @@ export function registerPlannerRoutes(app: FastifyInstance, plannerService: Plan app.post( "/api/calendar/sync", withAuthenticatedRoute(async (request) => { - const { date, tz } = parseDayQuery(request.query); - return plannerService.syncGoogleCalendar(date, tz); + const { date, restoreHidden, tz } = parseCalendarSyncQuery(request.query); + return plannerService.syncGoogleCalendar(date, tz, { restoreHidden }); }), ); registerPlannerMutationRoutes(app, plannerService); diff --git a/apps/server/src/http/route-helpers.ts b/apps/server/src/http/route-helpers.ts index 0e73407..a382add 100644 --- a/apps/server/src/http/route-helpers.ts +++ b/apps/server/src/http/route-helpers.ts @@ -1,4 +1,4 @@ -import { dayQuerySchema } from "@timefraim/shared"; +import { calendarSyncQuerySchema, dayQuerySchema } from "@timefraim/shared"; import type { FastifyReply, FastifyRequest, RouteHandlerMethod } from "fastify"; import type { ZodType } from "zod"; import { requireAuthenticatedUser, type AuthenticatedUser } from "./auth.js"; @@ -38,6 +38,15 @@ export function parseDayQuery(query: unknown) { }; } +export function parseCalendarSyncQuery(query: unknown) { + const result = calendarSyncQuerySchema.safeParse(query); + return { + date: result.success ? result.data.date : todayIsoDate(), + restoreHidden: result.success ? (result.data.restoreHidden ?? false) : false, + tz: result.success ? (result.data.tz ?? 0) : 0, + }; +} + export function parseWithReply(reply: FastifyReply, schema: ZodType, value: unknown) { const result = schema.safeParse(value); if (!result.success) { diff --git a/apps/server/src/services/planner-service-calendar.ts b/apps/server/src/services/planner-service-calendar.ts index c2085c8..d14df41 100644 --- a/apps/server/src/services/planner-service-calendar.ts +++ b/apps/server/src/services/planner-service-calendar.ts @@ -17,6 +17,7 @@ export async function syncPlannerGoogleCalendar( repository: PlannerRepository, date: string, tzOffsetMinutes: number, + options: { restoreHidden?: boolean } = {}, ): Promise { const row = await repository.getIntegrationToken("google", pool); const connection = readGoogleConnection(row); @@ -55,13 +56,13 @@ export async function syncPlannerGoogleCalendar( scheduleBlockId: record.scheduleBlockId, rawPayload: record.rawPayload, externalUpdatedAt: record.externalUpdatedAt, - dismissedExternalUpdatedAt: previousEvent - ? resolveDismissedExternalUpdatedAt({ + dismissedExternalUpdatedAt: options.restoreHidden || !previousEvent + ? null + : resolveDismissedExternalUpdatedAt({ previousExternalUpdatedAt: previousEvent.externalUpdatedAt, previousDismissedExternalUpdatedAt: previousEvent.dismissedExternalUpdatedAt, nextExternalUpdatedAt: record.externalUpdatedAt, - }) - : null, + }), sourceCalendarId: record.sourceCalendarId, sourceCalendarName: record.sourceCalendarName, }, diff --git a/apps/server/src/services/planner-service.test.ts b/apps/server/src/services/planner-service.test.ts index 04ee377..5e1817f 100644 --- a/apps/server/src/services/planner-service.test.ts +++ b/apps/server/src/services/planner-service.test.ts @@ -1094,7 +1094,7 @@ describe("planner-service", () => { ); }); - it("preserves dismissed synced Google events until Google updates them", async () => { + it("preserves hidden synced events for background syncs and restores them on explicit syncs", async () => { const repository = createRepositoryMock(); repository.getIntegrationToken.mockResolvedValue({ provider: "google", @@ -1143,7 +1143,7 @@ describe("planner-service", () => { isAppManaged: false, rawPayload: {}, scheduleBlockId: null, - externalUpdatedAt: "2026-04-06T08:15:00.000Z", + externalUpdatedAt: "2026-04-06T07:30:00.000Z", backgroundColor: null, foregroundColor: null, sourceCalendarId: "primary", @@ -1154,7 +1154,7 @@ describe("planner-service", () => { const service = new PlannerService(repository as never); await service.syncGoogleCalendar("2026-04-06", 0); - await service.syncGoogleCalendar("2026-04-06", 0); + await service.syncGoogleCalendar("2026-04-06", 0, { restoreHidden: true }); expect(repository.upsertCalendarEvent).toHaveBeenNthCalledWith( 1, diff --git a/apps/server/src/services/planner-service.ts b/apps/server/src/services/planner-service.ts index 936e615..3b9cc73 100644 --- a/apps/server/src/services/planner-service.ts +++ b/apps/server/src/services/planner-service.ts @@ -84,7 +84,9 @@ export class PlannerService { getIntegrationStatus: (effectiveUserId) => this.getIntegrationStatus(effectiveUserId), }); } - async syncGoogleCalendar(date = todayIsoDate(), tzOffsetMinutes = 0) { return syncPlannerGoogleCalendar(this.repository, date, tzOffsetMinutes); } + async syncGoogleCalendar(date = todayIsoDate(), tzOffsetMinutes = 0, options?: { restoreHidden?: boolean }) { + return syncPlannerGoogleCalendar(this.repository, date, tzOffsetMinutes, options); + } async createDraft(kind: DraftKind, payload: Record, actorRole: ActorRole, ownerUserId?: string | null) { const resolvedOwnerUserId = ownerUserId ?? (actorRole === "assistant" ? await getAllowedPlannerUserId(this.repository) : null); return this.repository.createDraft( diff --git a/apps/web/src/hooks/use-auto-google-task-sync.test.tsx b/apps/web/src/hooks/use-auto-google-task-sync.test.tsx index 247f29a..d12aa96 100644 --- a/apps/web/src/hooks/use-auto-google-task-sync.test.tsx +++ b/apps/web/src/hooks/use-auto-google-task-sync.test.tsx @@ -56,6 +56,7 @@ describe("useAutoGoogleTaskSync", () => { ); await waitFor(() => expect(api.syncCalendar).toHaveBeenCalledTimes(1)); + expect(api.syncCalendar).toHaveBeenNthCalledWith(1, "token", "2026-04-06", expect.any(Number)); void act(() => window.dispatchEvent(new Event("focus"))); await waitFor(() => expect(api.syncCalendar).toHaveBeenCalledTimes(2)); diff --git a/apps/web/src/hooks/use-planner-mutations.test.tsx b/apps/web/src/hooks/use-planner-mutations.test.tsx index a775b8a..58338df 100644 --- a/apps/web/src/hooks/use-planner-mutations.test.tsx +++ b/apps/web/src/hooks/use-planner-mutations.test.tsx @@ -164,6 +164,12 @@ describe("usePlannerMutations", () => { await result.current.actions.syncCalendar(); }); + expect(api.syncCalendar).toHaveBeenCalledWith( + "token", + "2026-04-06", + expect.any(Number), + { restoreHidden: true }, + ); expect(queryClient.getQueryData(key)?.calendarEvents).toEqual([restoredEvent]); expect(queryClient.getQueryData(key)?.calendarSync).toEqual({ status: "fully_synced", diff --git a/apps/web/src/hooks/use-planner-mutations.ts b/apps/web/src/hooks/use-planner-mutations.ts index b70fee7..ba8e581 100644 --- a/apps/web/src/hooks/use-planner-mutations.ts +++ b/apps/web/src/hooks/use-planner-mutations.ts @@ -117,7 +117,7 @@ export function usePlannerMutations({ date, token, onSuccess }: UsePlannerMutati onSuccess, }); const syncCalendarMutation = useMutation({ - mutationFn: () => api.syncCalendar(token, date, new Date(`${date}T12:00:00`).getTimezoneOffset()), + mutationFn: () => api.syncCalendar(token, date, new Date(`${date}T12:00:00`).getTimezoneOffset(), { restoreHidden: true }), onSuccess: async (result: CalendarSyncResult) => { queryClient.setQueryData(dayPlanQueryKey, (current) => current diff --git a/apps/web/src/lib/api-planner.ts b/apps/web/src/lib/api-planner.ts index 9ae0db1..4b78cbd 100644 --- a/apps/web/src/lib/api-planner.ts +++ b/apps/web/src/lib/api-planner.ts @@ -123,8 +123,8 @@ export const plannerApi = { method: "POST", schema: syncDraftSchema, }), - syncCalendar: (token: string, date: string, tz: number) => - request(withQuery("/api/calendar/sync", { date, tz }), token, { + syncCalendar: (token: string, date: string, tz: number, options?: { restoreHidden?: boolean }) => + request(withQuery("/api/calendar/sync", { date, restoreHidden: options?.restoreHidden, tz }), token, { method: "POST", schema: calendarSyncResultSchema, }), diff --git a/packages/shared/src/day-plan.ts b/packages/shared/src/day-plan.ts index 2cf8adf..cfdfb5b 100644 --- a/packages/shared/src/day-plan.ts +++ b/packages/shared/src/day-plan.ts @@ -34,5 +34,12 @@ export const dayQuerySchema = z.object({ tz: z.coerce.number().int().optional(), }); +export const calendarSyncQuerySchema = dayQuerySchema.extend({ + restoreHidden: z.union([ + z.boolean(), + z.enum(["true", "false"]).transform((value) => value === "true"), + ]).optional(), +}); + export type AuditLog = z.infer; export type DayPlan = z.infer; diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 4e11b04..8dc1123 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { apiErrorSchema, calendarEventViewSchema, + calendarSyncQuerySchema, calendarSyncResultSchema, googleCalendarSettingsSchema, plannerMutationResultSchema, @@ -55,6 +56,14 @@ describe("shared barrel exports", () => { }).date, ).toBe("2026-04-06"); + expect( + calendarSyncQuerySchema.parse({ + date: "2026-04-06", + restoreHidden: "true", + tz: "-420", + }).restoreHidden, + ).toBe(true); + expect( calendarEventViewSchema.parse({ id: "calendar-1", From 50ac8a71bdfab425d24ed4b4a2f554397f0bdcaf Mon Sep 17 00:00:00 2001 From: Michael Zick <1907006+michaelzick@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:36:49 -0700 Subject: [PATCH 3/3] Pin shared calendar hide controls --- .../timeline-board-calendar-event.tsx | 77 +++++++++++++------ .../src/components/timeline-board.test.tsx | 5 +- 2 files changed, 56 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/timeline-board-calendar-event.tsx b/apps/web/src/components/timeline-board-calendar-event.tsx index 4cd11ab..048dd5f 100644 --- a/apps/web/src/components/timeline-board-calendar-event.tsx +++ b/apps/web/src/components/timeline-board-calendar-event.tsx @@ -59,6 +59,7 @@ export function TimelineBoardCalendarEvent({ const titleColor = "var(--calendar-event-title)"; const borderColorSource = event.backgroundColor ?? FALLBACK_TIMELINE_EVENT_BORDER; + const showSharedLaneBadge = isSharedLane && !isShort && !isVeryShort; const cardStyle: TimelineEventStyle = { "--timeline-event-left": getLaneLeft(laneIndex, laneCount, 8), "--timeline-event-left-sm": getLaneLeft(laneIndex, laneCount, 12), @@ -95,8 +96,8 @@ export function TimelineBoardCalendarEvent({ "flex min-w-0", isSharedLane ? isShort - ? "h-full flex-row items-center justify-between gap-1.5" - : "flex-col gap-1.5" + ? "h-full flex-row items-center gap-1.5 pr-14" + : "flex-col gap-1.5 pr-14 sm:pr-16" : isShort ? "h-full flex-row items-center justify-between gap-2" : "flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3", @@ -119,29 +120,55 @@ export function TimelineBoardCalendarEvent({ )}
-
- {!isVeryShort && ( - - - {sourceCalendarName} - - - )} - -
+ {isSharedLane ? ( + <> + {showSharedLaneBadge && ( + + + {sourceCalendarName} + + + )} + + + ) : ( +
+ {!isVeryShort && ( + + + {sourceCalendarName} + + + )} + +
+ )}
); diff --git a/apps/web/src/components/timeline-board.test.tsx b/apps/web/src/components/timeline-board.test.tsx index e786a97..20ad9f1 100644 --- a/apps/web/src/components/timeline-board.test.tsx +++ b/apps/web/src/components/timeline-board.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { CalendarEventView } from "@timefraim/shared"; import { describe, expect, it, vi } from "vitest"; @@ -281,6 +281,9 @@ describe("TimelineBoard", () => { expect(secondEvent.style.getPropertyValue("--timeline-event-left")).toBe("calc(8px + ((100% - 16px) / 3) * 1)"); expect(thirdEvent.style.getPropertyValue("--timeline-event-left")).toBe("calc(8px + ((100% - 16px) / 3) * 2)"); expect(screen.getAllByRole("button", { name: /hide/i })).toHaveLength(3); + for (const eventCard of [firstEvent, secondEvent, thirdEvent]) { + expect(within(eventCard).getByRole("button", { name: /hide/i })).toHaveClass("absolute", "right-2", "top-2"); + } }); it.each(["light", "dark"] as const)("uses the white selected border for calendar events in %s mode", (theme) => {