Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/server/src/http/register-planner-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/http/route-helpers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<T>(reply: FastifyReply, schema: ZodType<T>, value: unknown) {
const result = schema.safeParse(value);
if (!result.success) {
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/services/planner-service-calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ 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(
repository: PlannerRepository,
date: string,
tzOffsetMinutes: number,
options: { restoreHidden?: boolean } = {},
): Promise<CalendarSyncResult> {
const row = await repository.getIntegrationToken("google", pool);
const connection = readGoogleConnection(row);
Expand All @@ -39,6 +41,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,
Expand All @@ -51,7 +56,13 @@ export async function syncPlannerGoogleCalendar(
scheduleBlockId: record.scheduleBlockId,
rawPayload: record.rawPayload,
externalUpdatedAt: record.externalUpdatedAt,
dismissedExternalUpdatedAt: null,
dismissedExternalUpdatedAt: options.restoreHidden || !previousEvent
? null
: resolveDismissedExternalUpdatedAt({
previousExternalUpdatedAt: previousEvent.externalUpdatedAt,
previousDismissedExternalUpdatedAt: previousEvent.dismissedExternalUpdatedAt,
nextExternalUpdatedAt: record.externalUpdatedAt,
}),
sourceCalendarId: record.sourceCalendarId,
sourceCalendarName: record.sourceCalendarName,
},
Expand Down
24 changes: 21 additions & 3 deletions apps/server/src/services/planner-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1094,7 +1094,7 @@ describe("planner-service", () => {
);
});

it("clears dismissals for synced Google events on every manual sync", async () => {
it("preserves hidden synced events for background syncs and restores them on explicit syncs", async () => {
const repository = createRepositoryMock();
repository.getIntegrationToken.mockResolvedValue({
provider: "google",
Expand All @@ -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([
Expand All @@ -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([
Expand All @@ -1131,19 +1144,23 @@ describe("planner-service", () => {
rawPayload: {},
scheduleBlockId: null,
externalUpdatedAt: "2026-04-06T07:30:00.000Z",
backgroundColor: null,
foregroundColor: null,
sourceCalendarId: "primary",
sourceCalendarName: "Primary",
},
]);

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,
expect.objectContaining({
externalEventId: "evt-1",
dismissedExternalUpdatedAt: null,
dismissedExternalUpdatedAt: "2026-04-06T07:30:00.000Z",
}),
fakeDb,
);
Expand All @@ -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 () => {
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/services/planner-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, actorRole: ActorRole, ownerUserId?: string | null) {
const resolvedOwnerUserId = ownerUserId ?? (actorRole === "assistant" ? await getAllowedPlannerUserId(this.repository) : null);
return this.repository.createDraft(
Expand Down
118 changes: 90 additions & 28 deletions apps/web/src/components/timeline-board-calendar-event.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,10 +53,18 @@ 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 showSharedLaneBadge = isSharedLane && !isShort && !isVeryShort;
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",
Expand All @@ -53,7 +84,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}
Expand All @@ -62,9 +94,13 @@ export function TimelineBoardCalendarEvent({
<div
className={cn(
"flex min-w-0",
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",
isSharedLane
? isShort
? "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",
)}
>
<div className="min-w-0 sm:flex-1">
Expand All @@ -84,29 +120,55 @@ export function TimelineBoardCalendarEvent({
</>
)}
</div>
<div className="flex min-w-0 items-center gap-2 sm:shrink-0">
{!isVeryShort && (
<Badge className="min-w-0 flex-1 normal-case tracking-[0.08em] sm:flex-none" style={badgeStyle}>
<span className="min-w-0 truncate" style={{ color: titleColor }} title={sourceCalendarName}>
{sourceCalendarName}
</span>
</Badge>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 px-2"
style={{ color: titleColor }}
onClick={(clickEvent) => {
clickEvent.stopPropagation();
onDismissCalendarEvent(event.id, event.title);
}}
>
<X className="h-4 w-4" />
{!isVeryShort && "Hide"}
</Button>
</div>
{isSharedLane ? (
<>
{showSharedLaneBadge && (
<Badge className="min-w-0 max-w-full normal-case tracking-[0.08em]" style={badgeStyle}>
<span className="min-w-0 truncate" style={{ color: titleColor }} title={sourceCalendarName}>
{sourceCalendarName}
</span>
</Badge>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-2 top-2 h-8 shrink-0 px-2"
style={{ color: titleColor }}
onClick={(clickEvent) => {
clickEvent.stopPropagation();
onDismissCalendarEvent(event.id, event.title);
}}
>
<X className="h-4 w-4" />
{!isVeryShort && "Hide"}
</Button>
</>
) : (
<div className="flex min-w-0 items-center gap-2 sm:shrink-0">
{!isVeryShort && (
<Badge className="min-w-0 flex-1 normal-case tracking-[0.08em] sm:flex-none" style={badgeStyle}>
<span className="min-w-0 truncate" style={{ color: titleColor }} title={sourceCalendarName}>
{sourceCalendarName}
</span>
</Badge>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 px-2"
style={{ color: titleColor }}
onClick={(clickEvent) => {
clickEvent.stopPropagation();
onDismissCalendarEvent(event.id, event.title);
}}
>
<X className="h-4 w-4" />
{!isVeryShort && "Hide"}
</Button>
</div>
)}
</div>
</div>
);
Expand Down
Loading