diff --git a/apps/calendar-web/src/auth/posthog/oauth-signup-funnel.ts b/apps/calendar-web/src/auth/posthog/oauth-signup-funnel.ts index 0a452b94d..58f2e2c93 100644 --- a/apps/calendar-web/src/auth/posthog/oauth-signup-funnel.ts +++ b/apps/calendar-web/src/auth/posthog/oauth-signup-funnel.ts @@ -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, @@ -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 } : {}), diff --git a/apps/calendar-web/src/auth/providers/authorization/record-oauth-signin-callback-landing.test.ts b/apps/calendar-web/src/auth/providers/authorization/record-oauth-signin-callback-landing.test.ts new file mode 100644 index 000000000..08f3c5f1c --- /dev/null +++ b/apps/calendar-web/src/auth/providers/authorization/record-oauth-signin-callback-landing.test.ts @@ -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(); + }); +}); diff --git a/apps/calendar-web/src/auth/providers/authorization/record-oauth-signin-callback-landing.ts b/apps/calendar-web/src/auth/providers/authorization/record-oauth-signin-callback-landing.ts new file mode 100644 index 000000000..3a42b2dea --- /dev/null +++ b/apps/calendar-web/src/auth/providers/authorization/record-oauth-signin-callback-landing.ts @@ -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 }); +} diff --git a/apps/calendar-web/src/common/utils/datetime/web.date.util.ts b/apps/calendar-web/src/common/utils/datetime/web.date.util.ts index 00366b228..b91579ea2 100644 --- a/apps/calendar-web/src/common/utils/datetime/web.date.util.ts +++ b/apps/calendar-web/src/common/utils/datetime/web.date.util.ts @@ -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"; @@ -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", ""); diff --git a/apps/calendar-web/src/common/utils/grid/grid.util.ts b/apps/calendar-web/src/common/utils/grid/grid.util.ts index 77feb3590..71b0f84db 100644 --- a/apps/calendar-web/src/common/utils/grid/grid.util.ts +++ b/apps/calendar-web/src/common/utils/grid/grid.util.ts @@ -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 + ); }; // Scrolls the current time to just below the viewport top, not flush with diff --git a/apps/calendar-web/src/grid/components/TimedGrid.test.tsx b/apps/calendar-web/src/grid/components/TimedGrid.test.tsx index 9bc7073c5..2064f442d 100644 --- a/apps/calendar-web/src/grid/components/TimedGrid.test.tsx +++ b/apps/calendar-web/src/grid/components/TimedGrid.test.tsx @@ -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"; @@ -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( + {}} + 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"); + }); }); diff --git a/apps/calendar-web/src/grid/components/TimedGrid.tsx b/apps/calendar-web/src/grid/components/TimedGrid.tsx index c0a201f16..bbca9c0c1 100644 --- a/apps/calendar-web/src/grid/components/TimedGrid.tsx +++ b/apps/calendar-web/src/grid/components/TimedGrid.tsx @@ -78,21 +78,21 @@ export const TimedGrid: FC = ({ 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 + } >
{todayColumnIndexes.map((columnIndex) => ( = ({
-
+
{hourRowLabels.map((dayTime) => (
{ 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); }; diff --git a/apps/calendar-web/src/timezone/mapped-hour-labels.ts b/apps/calendar-web/src/timezone/mapped-hour-labels.ts index d64848629..346c20e70 100644 --- a/apps/calendar-web/src/timezone/mapped-hour-labels.ts +++ b/apps/calendar-web/src/timezone/mapped-hour-labels.ts @@ -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 { @@ -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)), ); } diff --git a/apps/calendar-web/src/views/AppleAuthCallback/AppleAuthCallback.tsx b/apps/calendar-web/src/views/AppleAuthCallback/AppleAuthCallback.tsx index 0fcabaee3..6c9d66594 100644 --- a/apps/calendar-web/src/views/AppleAuthCallback/AppleAuthCallback.tsx +++ b/apps/calendar-web/src/views/AppleAuthCallback/AppleAuthCallback.tsx @@ -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"; @@ -34,9 +31,7 @@ export async function completeAppleAuthCallback({ navigate, search, }: CompleteAppleAuthCallbackOptions): Promise { - relayProviderOAuthCallbackTelemetry("apple", search); - trackSignupStep("oauth_callback_returned", { method: "apple" }); - track("oauth_callback_received", { provider: "apple" }); + recordOAuthSignInCallbackLanding("apple", search); const params = new URLSearchParams(search); diff --git a/apps/calendar-web/src/views/ProviderAuthCallback/ProviderAuthCallback.tsx b/apps/calendar-web/src/views/ProviderAuthCallback/ProviderAuthCallback.tsx index 3c5d76d32..f395d41f6 100644 --- a/apps/calendar-web/src/views/ProviderAuthCallback/ProviderAuthCallback.tsx +++ b/apps/calendar-web/src/views/ProviderAuthCallback/ProviderAuthCallback.tsx @@ -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"; @@ -40,12 +40,9 @@ export async function completeProviderAuthCallback({ navigate, search, }: CompleteProviderAuthCallbackOptions): Promise { - 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, diff --git a/docs/development/performance-baselines.md b/docs/development/performance-baselines.md index 609af8b0d..695d5f10e 100644 --- a/docs/development/performance-baselines.md +++ b/docs/development/performance-baselines.md @@ -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 diff --git a/packages/backend/src/auth/controllers/auth.controller.ts b/packages/backend/src/auth/controllers/auth.controller.ts index ef1ebee36..64a58063b 100644 --- a/packages/backend/src/auth/controllers/auth.controller.ts +++ b/packages/backend/src/auth/controllers/auth.controller.ts @@ -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, diff --git a/packages/backend/src/auth/services/provider-oauth-callback.service.test.ts b/packages/backend/src/auth/services/provider-oauth-callback.service.test.ts index eb1be219a..f87d99d80 100644 --- a/packages/backend/src/auth/services/provider-oauth-callback.service.test.ts +++ b/packages/backend/src/auth/services/provider-oauth-callback.service.test.ts @@ -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"; @@ -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(); + }); }); diff --git a/packages/backend/src/auth/services/provider-oauth-callback.service.ts b/packages/backend/src/auth/services/provider-oauth-callback.service.ts index 3f53088bd..61de46bd9 100644 --- a/packages/backend/src/auth/services/provider-oauth-callback.service.ts +++ b/packages/backend/src/auth/services/provider-oauth-callback.service.ts @@ -30,6 +30,30 @@ function queryField( return typeof value === "string" && value.length > 0 ? value : undefined; } +function captureOAuthCallbackQueryAnalytics( + provider: ProviderKind, + query: { state?: string; code?: string; providerError?: string }, +): void { + providerOAuthSignupAnalytics.captureCallbackReceived({ + provider, + state: query.state, + }); + + const failureReason = oauthFailedReasonFromCallbackQuery({ + state: query.state, + code: query.code, + providerError: query.providerError, + }); + if (failureReason) { + providerOAuthSignupAnalytics.captureFailed({ + provider, + state: query.state, + reason: failureReason, + providerError: query.providerError, + }); + } +} + function spaCallbackUrl( provider: ProviderKind, query: ProviderOAuthCallbackQuery, @@ -62,24 +86,11 @@ export function handleProviderOAuthCallback(input: { const code = queryField(input.query, "code"); const providerError = queryField(input.query, "error"); - providerOAuthSignupAnalytics.captureCallbackReceived({ - provider: input.provider, - state, - }); - - const failureReason = oauthFailedReasonFromCallbackQuery({ + captureOAuthCallbackQueryAnalytics(input.provider, { state, code, providerError, }); - if (failureReason) { - providerOAuthSignupAnalytics.captureFailed({ - provider: input.provider, - state, - reason: failureReason, - providerError, - }); - } if (input.telemetryOnly) { return { mode: "telemetry" }; @@ -96,21 +107,9 @@ export function appleFormPostAnalytics(input: { code?: string; error?: string; }): void { - providerOAuthSignupAnalytics.captureCallbackReceived({ - provider: "apple", - state: input.state, - }); - const failureReason = oauthFailedReasonFromCallbackQuery({ + captureOAuthCallbackQueryAnalytics("apple", { state: input.state, code: input.code, providerError: input.error, }); - if (failureReason) { - providerOAuthSignupAnalytics.captureFailed({ - provider: "apple", - state: input.state, - reason: failureReason, - providerError: input.error, - }); - } } diff --git a/packages/backend/src/auth/services/provider-oauth-signup.analytics.ts b/packages/backend/src/auth/services/provider-oauth-signup.analytics.ts index 9990fabfa..ed2f2434b 100644 --- a/packages/backend/src/auth/services/provider-oauth-signup.analytics.ts +++ b/packages/backend/src/auth/services/provider-oauth-signup.analytics.ts @@ -15,19 +15,33 @@ function distinctIdFromOAuthState(state: string | null | undefined): string { return posthogDistinctIdFromSignInOAuthState(state) ?? FALLBACK_DISTINCT_ID; } +function captureOAuthSignupEvent( + event: typeof OAUTH_CALLBACK_RECEIVED_EVENT | typeof OAUTH_FAILED_EVENT, + input: { + provider: ProviderKind; + state: string | null | undefined; + reason?: OAuthFailedReason; + providerError?: string; + }, +): void { + void captureSafely(getBackendPostHogClient(), { + event, + distinctId: distinctIdFromOAuthState(input.state), + properties: { + environment: CONFIG.NODE_ENV, + provider: input.provider, + ...(input.reason ? { reason: input.reason } : {}), + ...(input.providerError ? { provider_error: input.providerError } : {}), + }, + }); +} + export const providerOAuthSignupAnalytics = { captureCallbackReceived(input: { provider: ProviderKind; state: string | null | undefined; }): void { - void captureSafely(getBackendPostHogClient(), { - event: OAUTH_CALLBACK_RECEIVED_EVENT, - distinctId: distinctIdFromOAuthState(input.state), - properties: { - environment: CONFIG.NODE_ENV, - provider: input.provider, - }, - }); + captureOAuthSignupEvent(OAUTH_CALLBACK_RECEIVED_EVENT, input); }, captureFailed(input: { @@ -36,15 +50,6 @@ export const providerOAuthSignupAnalytics = { reason: OAuthFailedReason; providerError?: string; }): void { - void captureSafely(getBackendPostHogClient(), { - event: OAUTH_FAILED_EVENT, - distinctId: distinctIdFromOAuthState(input.state), - properties: { - environment: CONFIG.NODE_ENV, - provider: input.provider, - reason: input.reason, - ...(input.providerError ? { provider_error: input.providerError } : {}), - }, - }); + captureOAuthSignupEvent(OAUTH_FAILED_EVENT, input); }, }; diff --git a/packages/core/src/analytics/oauth-signup.events.test.ts b/packages/core/src/analytics/oauth-signup.events.test.ts index 9fc914331..fe8402fd5 100644 --- a/packages/core/src/analytics/oauth-signup.events.test.ts +++ b/packages/core/src/analytics/oauth-signup.events.test.ts @@ -1,17 +1,7 @@ -import { - oauthFailedReasonFromCallbackQuery, - oauthFailedReasonFromProviderError, -} from "@core/analytics/oauth-signup.events"; +import { oauthFailedReasonFromCallbackQuery } from "@core/analytics/oauth-signup.events"; import { describe, expect, it } from "bun:test"; describe("oauth-signup.events", () => { - it("maps provider errors", () => { - expect(oauthFailedReasonFromProviderError("access_denied")).toBe( - "access_denied", - ); - expect(oauthFailedReasonFromProviderError("server_error")).toBe("other"); - }); - it("derives callback failure reasons from query params", () => { expect( oauthFailedReasonFromCallbackQuery({ @@ -27,5 +17,19 @@ describe("oauth-signup.events", () => { providerError: null, }), ).toBe("invalid_state"); + expect( + oauthFailedReasonFromCallbackQuery({ + state: "x", + code: "abc", + providerError: "access_denied", + }), + ).toBe("access_denied"); + expect( + oauthFailedReasonFromCallbackQuery({ + state: "x", + code: "abc", + providerError: "server_error", + }), + ).toBe("other"); }); }); diff --git a/packages/core/src/analytics/oauth-signup.events.ts b/packages/core/src/analytics/oauth-signup.events.ts index 7a075b15a..7b0d5cf7b 100644 --- a/packages/core/src/analytics/oauth-signup.events.ts +++ b/packages/core/src/analytics/oauth-signup.events.ts @@ -1,13 +1,7 @@ -import { type ProviderKind } from "@core/types/sync/identity.contracts"; - /** Server- and client-side OAuth signup funnel events (see `track.ts`). */ export const OAUTH_CALLBACK_RECEIVED_EVENT = "oauth_callback_received" as const; export const OAUTH_FAILED_EVENT = "oauth_failed" as const; -export type OAuthSignupServerEvent = - | typeof OAUTH_CALLBACK_RECEIVED_EVENT - | typeof OAUTH_FAILED_EVENT; - export type OAuthFailedReason = | "access_denied" | "invalid_state" @@ -15,38 +9,6 @@ export type OAuthFailedReason = | "missing_code" | "other"; -const OAUTH_FAILED_REASONS: readonly OAuthFailedReason[] = [ - "access_denied", - "invalid_state", - "token_exchange_error", - "missing_code", - "other", -]; - -export function isOAuthFailedReason( - value: unknown, -): value is OAuthFailedReason { - return ( - typeof value === "string" && - (OAUTH_FAILED_REASONS as readonly string[]).includes(value) - ); -} - -export type OAuthSignupEventProperties = { - provider: ProviderKind; - reason?: OAuthFailedReason; - /** Raw provider `error` query param when present (e.g. Google OAuth errors). */ - provider_error?: string; -}; - -export function oauthFailedReasonFromProviderError( - providerError: string | null | undefined, -): OAuthFailedReason { - if (providerError === "access_denied") return "access_denied"; - if (providerError) return "other"; - return "other"; -} - export function oauthFailedReasonFromCallbackQuery(input: { state: string | null | undefined; code: string | null | undefined; diff --git a/packages/core/src/util/date/civil-day.test.ts b/packages/core/src/util/date/civil-day.test.ts index 2daf85e76..e872c3933 100644 --- a/packages/core/src/util/date/civil-day.test.ts +++ b/packages/core/src/util/date/civil-day.test.ts @@ -1,5 +1,6 @@ import { civilDayLengthMinutes, + civilHourLineInstants, civilHourSlotCount, formatInstantInTimeZone, minutesFromCivilMidnight, @@ -50,6 +51,26 @@ describe("minutesFromCivilMidnight", () => { }); }); +describe("civilHourLineInstants", () => { + it("walks 1 AM through 11 PM on a non-transition day", () => { + const lines = civilHourLineInstants("2026-08-20", denver); + expect(lines).toHaveLength(23); + expect(lines[0]?.format("YYYY-MM-DD HH:mm")).toBe("2026-08-20 01:00"); + expect(lines.at(-1)?.format("YYYY-MM-DD HH:mm")).toBe("2026-08-20 23:00"); + }); + + it("adds the next midnight when includeMidnight is true", () => { + const lines = civilHourLineInstants("2026-08-20", denver, true); + expect(lines).toHaveLength(24); + expect(lines.at(-1)?.format("YYYY-MM-DD HH:mm")).toBe("2026-08-21 00:00"); + }); + + it("follows DST slot counts", () => { + expect(civilHourLineInstants("2026-11-01", denver, true)).toHaveLength(25); + expect(civilHourLineInstants("2027-03-14", denver, true)).toHaveLength(23); + }); +}); + describe("formatInstantInTimeZone", () => { it("re-anchors to the zone offset for the civil date", () => { const formatted = formatInstantInTimeZone( diff --git a/packages/core/src/util/date/civil-day.ts b/packages/core/src/util/date/civil-day.ts index ed5063fe0..416e352e0 100644 --- a/packages/core/src/util/date/civil-day.ts +++ b/packages/core/src/util/date/civil-day.ts @@ -38,9 +38,23 @@ export function minutesFromCivilMidnight( } /** - * RFC3339 offset string for an absolute instant in `timeZone`, with the - * offset that applies on that civil date (handles stale offsets in `iso`). + * Hour-line instants after civil midnight (1 AM through 11 PM, plus the next + * midnight when `includeMidnight` is true). Slot count follows DST. */ +export function civilHourLineInstants( + day: Dayjs | Date | string, + timeZone: string, + includeMidnight = false, +): Dayjs[] { + const slotCount = civilHourSlotCount(day, timeZone); + const hours = includeMidnight ? slotCount : Math.max(0, slotCount - 1); + const start = startOfCivilDay(day, timeZone); + return Array.from({ length: hours }, (_, index) => + start.add(index + 1, "hour"), + ); +} + +/** RFC3339 string for an instant in `timeZone` (dayjs default format). */ export function formatInstantInTimeZone( iso: string | Date, timeZone: string,