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
7 changes: 5 additions & 2 deletions apps/calendar-web/src/auth/posthog/oauth-signup-funnel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { type OAuthFailedReason } from "@core/analytics/oauth-signup.events";
import {
OAUTH_FAILED_EVENT,
type OAuthFailedReason,
} from "@core/analytics/oauth-signup.events";
import { type ProviderKind } from "@core/types/sync/identity.contracts";
import {
type SignupFailureReason,
Expand Down Expand Up @@ -32,7 +35,7 @@ export function trackOAuthSignupFailed(input: {
step?: SignupStep;
error?: string;
}): void {
track("oauth_failed", {
track(OAUTH_FAILED_EVENT, {
provider: input.provider,
reason: oauthFailedReasonFromSignupFailure(input.reason),
...(input.providerError ? { provider_error: input.providerError } : {}),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as signupFunnel from "@web/auth/posthog/signup-funnel";
import * as trackModule from "@web/auth/posthog/track";
import { recordOAuthSignInCallbackLanding } from "@web/auth/providers/authorization/record-oauth-signin-callback-landing";
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";

describe("recordOAuthSignInCallbackLanding", () => {
const realFetch = globalThis.fetch;

afterEach(() => {
globalThis.fetch = realFetch;
});

it("relays then records the client landing events in order", () => {
const calls: string[] = [];
const fetchMock = mock(async () => {
calls.push("relay");
return new Response(null, { status: 204 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const stepSpy = spyOn(signupFunnel, "trackSignupStep").mockImplementation(
() => {
calls.push("step");
},
);
const trackSpy = spyOn(trackModule, "track").mockImplementation(() => {
calls.push("track");
});

recordOAuthSignInCallbackLanding("google", "?state=abc&code=1");

expect(fetchMock).toHaveBeenCalledTimes(1);
const firstCall = fetchMock.mock.calls[0];
expect(firstCall).toBeDefined();
const [url, init] = firstCall as unknown as [string, RequestInit];
expect(url).toContain("/auth/google/callback?state=abc");
expect(init.headers).toEqual(
expect.objectContaining({ "X-Compass-OAuth-Telemetry": "1" }),
);
expect(stepSpy).toHaveBeenCalledWith("oauth_callback_returned", {
method: "google",
});
expect(trackSpy).toHaveBeenCalledWith("oauth_callback_received", {
provider: "google",
});
expect(calls).toEqual(["relay", "step", "track"]);

stepSpy.mockRestore();
trackSpy.mockRestore();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { OAUTH_CALLBACK_RECEIVED_EVENT } from "@core/analytics/oauth-signup.events";
import { type ProviderKind } from "@core/types/sync/identity.contracts";
import { trackSignupStep } from "@web/auth/posthog/signup-funnel";
import { track } from "@web/auth/posthog/track";
import { relayProviderOAuthCallbackTelemetry } from "@web/auth/providers/authorization/relay-provider-oauth-callback-telemetry";

/**
* Records that the user returned from the provider before any success or
* failure branch. Order: server relay, then client funnel + landing events.
*/
export function recordOAuthSignInCallbackLanding(
provider: ProviderKind,
search: string,
): void {
relayProviderOAuthCallbackTelemetry(provider, search);
trackSignupStep("oauth_callback_returned", { method: provider });
track(OAUTH_CALLBACK_RECEIVED_EVENT, { provider });
}
13 changes: 4 additions & 9 deletions apps/calendar-web/src/common/utils/datetime/web.date.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type EventSchedule,
EventScheduleSchema,
} from "@core/types/event.contracts";
import { civilHourSlotCount, startOfCivilDay } from "@core/util/date/civil-day";
import { civilHourLineInstants } from "@core/util/date/civil-day";
import dayjs, { type Dayjs } from "@core/util/date/dayjs";
import { ACCEPTED_TIMES } from "@web/common/constants/web.constants";
import { type SelectOption } from "@web/common/types/component.types";
Expand Down Expand Up @@ -49,15 +49,10 @@ export const getHourLabels = (
includeMidnight = false,
referenceDay: Dayjs | Date | string = dayjs(),
timeZone: string = getEffectiveTimeZone(),
) => {
const slotCount = civilHourSlotCount(referenceDay, timeZone);
const hours = includeMidnight ? slotCount : Math.max(0, slotCount - 1);
const day = startOfCivilDay(referenceDay, timeZone);

return Array.from({ length: hours }, (_, index) =>
day.add(index + 1, "hour").format(HOURS_AM_SHORT_FORMAT),
) =>
civilHourLineInstants(referenceDay, timeZone, includeMidnight).map(
(instant) => instant.format(HOURS_AM_SHORT_FORMAT),
);
};

const getTimeLabel = (value: string) => value.replace(":00", "");

Expand Down
9 changes: 6 additions & 3 deletions apps/calendar-web/src/common/utils/grid/grid.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,12 @@ export const getMinuteHeight = (clientHeight: number) =>
export const getCurrentPercentOfDay = (
timeZone: string = getEffectiveTimeZone(),
) => {
const now = dayjs().tz(timeZone);
const civilMinutes = civilDayLengthMinutes(now, timeZone);
return (minutesFromCivilMidnight(now, timeZone) / civilMinutes) * 100;
const now = dayjs();
return (
(minutesFromCivilMidnight(now, timeZone) /
civilDayLengthMinutes(now, timeZone)) *
100
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now-line uses host calendar date

Medium Severity

getCurrentPercentOfDay now passes an unzoned dayjs() into civilDayLengthMinutes. That helper reads the calendar date from its input without converting, so when the host timezone and timeZone fall on different dates the now-line uses the wrong day length, most visibly on a DST transition.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 38e3cd5. Configure here.

};

// Scrolls the current time to just below the viewport top, not flush with
Expand Down
24 changes: 23 additions & 1 deletion apps/calendar-web/src/grid/components/TimedGrid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import dayjs from "@core/util/date/dayjs";
import { DATA_TIMED_GRID_ROW } from "@web/common/constants/web.constants";
import { TIMED_HOUR_SLOT_HEIGHT_CLASS } from "@web/grid/grid.constants";
import {
TIMED_HOUR_SLOT_HEIGHT_CLASS,
TIMED_VISIBLE_HOURS,
} from "@web/grid/grid.constants";
import { ALL_DAY_COLUMN_TINT_PERCENT } from "@web/grid/utils/allDayColumnTint.util";
import { describe, expect, it } from "bun:test";
import "@testing-library/jest-dom";
Expand Down Expand Up @@ -113,4 +116,23 @@ describe("TimedGrid", () => {
expect(labelSlot).toHaveClass(TIMED_HOUR_SLOT_HEIGHT_CLASS);
expect(hourRow).toHaveClass(TIMED_HOUR_SLOT_HEIGHT_CLASS);
});

it("sets grid geometry CSS variables once on the scroll region", () => {
render(
<TimedGrid
eventsLayer={null}
timedColumnsRef={() => {}}
timedGridRef={() => {}}
today={today}
visibleDates={[{ date: today, key: "today" }]}
/>,
);

const grid = screen.getByRole("region", { name: "Timed events grid" });
expect(grid.style.getPropertyValue("--calendar-column-count")).toBe("1");
expect(grid.style.getPropertyValue("--calendar-visible-hours")).toBe(
String(TIMED_VISIBLE_HOURS),
);
expect(grid.style.getPropertyValue("--calendar-day-hour-slots")).toBe("24");
});
});
29 changes: 10 additions & 19 deletions apps/calendar-web/src/grid/components/TimedGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,21 +78,21 @@ export const TimedGrid: FC<TimedGridProps> = ({
className={`peer c-scroll relative min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden [--scrollbar-width:0px] focus-visible:outline-none ${CALENDAR_GRID_PH_NO_RAGECLICK_CLASS}`}
id={timedGridId}
ref={timedGridRef}
style={
{
"--calendar-column-count": visibleDates.length,
"--calendar-column-min-width": `${EVENT_WIDTH_MINIMUM}px`,
"--calendar-grid-margin-left": `${marginLeft}px`,
"--calendar-day-hour-slots": civilHourSlots,
"--calendar-visible-hours": TIMED_VISIBLE_HOURS,
} as CSSVariables
}
>
<CalendarTimeColumn at={labelDay.toDate()} />
<div
className="absolute top-0 left-(--calendar-grid-margin-left) grid h-[calc(var(--calendar-day-hour-slots)*100%/var(--calendar-visible-hours))] w-[calc(100%-var(--calendar-grid-margin-left))] grid-cols-[repeat(var(--calendar-column-count),minmax(var(--calendar-column-min-width),1fr))]"
id={columnsId}
ref={timedColumnsRef}
style={
{
"--calendar-column-count": visibleDates.length,
"--calendar-column-min-width": `${EVENT_WIDTH_MINIMUM}px`,
"--calendar-grid-margin-left": `${marginLeft}px`,
"--calendar-day-hour-slots": civilHourSlots,
"--calendar-visible-hours": TIMED_VISIBLE_HOURS,
} as CSSVariables
}
>
{todayColumnIndexes.map((columnIndex) => (
<CalendarNowLine
Expand Down Expand Up @@ -137,16 +137,7 @@ export const TimedGrid: FC<TimedGridProps> = ({
</table>
</div>

<div
className="absolute left-(--calendar-grid-margin-left) h-full w-[calc(100%-var(--calendar-grid-margin-left))]"
style={
{
"--calendar-grid-margin-left": `${marginLeft}px`,
"--calendar-day-hour-slots": civilHourSlots,
"--calendar-visible-hours": TIMED_VISIBLE_HOURS,
} as CSSVariables
}
>
<div className="absolute left-(--calendar-grid-margin-left) h-full w-[calc(100%-var(--calendar-grid-margin-left))]">
{hourRowLabels.map((dayTime) => (
<div
className={`relative flex ${TIMED_HOUR_SLOT_HEIGHT_CLASS} w-full items-start border-border border-b`}
Expand Down
4 changes: 2 additions & 2 deletions apps/calendar-web/src/grid/hooks/useGridCoordinates.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type MutableRefObject } from "react";
import {
civilDayLengthMinutes,
minutesFromCivilMidnight,
startOfCivilDay,
} from "@core/util/date/civil-day";
import dayjs from "@core/util/date/dayjs";
Expand Down Expand Up @@ -72,8 +73,7 @@ export const useGridCoordinates = (

const getYByDate = (date: string) => {
const timeZone = getEffectiveTimeZone();
const zoned = dayjs(date).tz(timeZone);
const minutes = zoned.diff(startOfCivilDay(zoned, timeZone), "minute");
const minutes = minutesFromCivilMidnight(date, timeZone);
return measurements.hourHeight * (minutes / 60);
};

Expand Down
8 changes: 3 additions & 5 deletions apps/calendar-web/src/timezone/mapped-hour-labels.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { HOURS_AM_SHORT_FORMAT } from "@core/constants/date.constants";
import { civilHourSlotCount, startOfCivilDay } from "@core/util/date/civil-day";
import { civilHourLineInstants } from "@core/util/date/civil-day";
import type dayjs from "@core/util/date/dayjs";

function formatMappedHourLabel(date: dayjs.Dayjs): string {
Expand All @@ -18,9 +18,7 @@ export function mappedHourLabels(
displayTimeZone: string,
at: Date | string,
): string[] {
const labelCount = Math.max(0, civilHourSlotCount(at, effectiveTimeZone) - 1);
const day = startOfCivilDay(at, effectiveTimeZone);
return Array.from({ length: labelCount }, (_, index) =>
formatMappedHourLabel(day.add(index + 1, "hour").tz(displayTimeZone)),
return civilHourLineInstants(at, effectiveTimeZone).map((instant) =>
formatMappedHourLabel(instant.tz(displayTimeZone)),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,13 @@ import { shouldRelayDesktopAppleOAuthCallback } from "@web/auth/callback/desktop
import { reportAuthCallbackCrash } from "@web/auth/callback/report-auth-callback-crash";
import { useOneShotAuthCallback } from "@web/auth/callback/useOneShotAuthCallback";
import { useCompleteAuthentication } from "@web/auth/compass/hooks/useCompleteAuthentication";
import {
trackSignupCompleted,
trackSignupStep,
} from "@web/auth/posthog/signup-funnel";
import { trackSignupCompleted } from "@web/auth/posthog/signup-funnel";
import { track } from "@web/auth/posthog/track";
import {
clearProviderAuthorizationIntent,
readProviderAuthorizationIntent,
} from "@web/auth/providers/authorization/provider-authorization.storage";
import { relayProviderOAuthCallbackTelemetry } from "@web/auth/providers/authorization/relay-provider-oauth-callback-telemetry";
import { recordOAuthSignInCallbackLanding } from "@web/auth/providers/authorization/record-oauth-signin-callback-landing";
import { DEFAULT_CALENDAR_ROUTE } from "@web/common/constants/routes";
import { showErrorToast } from "@web/common/utils/toast/error-toast.util";

Expand All @@ -34,9 +31,7 @@ export async function completeAppleAuthCallback({
navigate,
search,
}: CompleteAppleAuthCallbackOptions): Promise<void> {
relayProviderOAuthCallbackTelemetry("apple", search);
trackSignupStep("oauth_callback_returned", { method: "apple" });
track("oauth_callback_received", { provider: "apple" });
recordOAuthSignInCallbackLanding("apple", search);

const params = new URLSearchParams(search);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
isSignInProviderKind,
PROVIDER_AUTHORIZATION_ERROR_MESSAGE,
} from "@web/auth/providers/authorization/provider-authorization.constants";
import { relayProviderOAuthCallbackTelemetry } from "@web/auth/providers/authorization/relay-provider-oauth-callback-telemetry";
import { recordOAuthSignInCallbackLanding } from "@web/auth/providers/authorization/record-oauth-signin-callback-landing";
import { missingPermissionsActions } from "@web/auth/providers/missing-permissions.store";
import { DEFAULT_CALENDAR_ROUTE } from "@web/common/constants/routes";
import { getToastDefaultOptions } from "@web/common/constants/toast.constants";
Expand All @@ -40,12 +40,9 @@ export async function completeProviderAuthCallback({
navigate,
search,
}: CompleteProviderAuthCallbackOptions): Promise<void> {
relayProviderOAuthCallbackTelemetry(provider, search);

// Before any branch: a user who came back from the provider is counted even
// when the exchange below fails.
trackSignupStep("oauth_callback_returned", { method: provider });
track("oauth_callback_received", { provider });
recordOAuthSignInCallbackLanding(provider, search);

const result = await completeProviderAuthorization({
provider,
Expand Down
8 changes: 4 additions & 4 deletions docs/development/performance-baselines.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ per-package minified bytes, an informational `totalGzipBytes` baseline from
`main`, and chunk count. Local `bun run build:web` prints the report and
logs gzip vs that baseline; it does not fail on absolute gzip size.

Cloud deploy builds run `.github/perf/run-boot-size-check.sh` from
`.github/docker/Dockerfile.web` with `COMPASS_BOOT_SIZE_ENFORCE=0`, so
staging and production image builds log boot gzip and never fail on an
absolute ceiling.
Cloud deploy images run `bun run build.ts` from
`.github/docker/Dockerfile.web` with the real `compass.yaml` (API URL,
PostHog, OAuth). They do not swap in `compass.boot-size.yaml`. Boot gzip
is gated per PR, not at deploy.

Required PR CI is [`.github/workflows/boot-bundle.yml`](../../.github/workflows/boot-bundle.yml):
it builds merge base and the PR with the same deploy-shaped config as
Expand Down
4 changes: 1 addition & 3 deletions packages/backend/src/auth/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,7 @@ class AuthController {
return;
}

const telemetryOnly =
req.header("x-compass-oauth-telemetry") === "1" ||
req.query["telemetry"] === "1";
const telemetryOnly = req.header("x-compass-oauth-telemetry") === "1";

const resolution = handleProviderOAuthCallback({
provider,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { buildSignInOAuthState } from "@core/auth/signin-oauth-state.util";
import {
appleFormPostAnalytics,
handleProviderOAuthCallback,
parseProviderOAuthCallbackProvider,
} from "@backend/auth/services/provider-oauth-callback.service";
Expand Down Expand Up @@ -73,4 +74,35 @@ describe("provider-oauth-callback.service", () => {
expect(result).toEqual({ mode: "telemetry" });
received.mockRestore();
});

it("captures Apple form-post callback and failure on the same analytics path", () => {
const received = spyOn(
analytics.providerOAuthSignupAnalytics,
"captureCallbackReceived",
).mockImplementation(() => undefined);
const failed = spyOn(
analytics.providerOAuthSignupAnalytics,
"captureFailed",
).mockImplementation(() => undefined);

appleFormPostAnalytics({
state: "apple-state",
error: "access_denied",
});

expect(received).toHaveBeenCalledWith({
provider: "apple",
state: "apple-state",
});
expect(failed).toHaveBeenCalledWith(
expect.objectContaining({
provider: "apple",
reason: "access_denied",
providerError: "access_denied",
}),
);

received.mockRestore();
failed.mockRestore();
});
});
Loading
Loading