Skip to content

Commit 80b9317

Browse files
authored
feat(web): improve calendar navigation cues (#2173)
* feat(web): improve calendar navigation cues * refactor(web): simplify calendar interaction states
1 parent bd83e6e commit 80b9317

23 files changed

Lines changed: 363 additions & 115 deletions

packages/web/src/api/util/api.util.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
} from "@core/types/auth.types";
77
import { session } from "@web/auth/compass/session/Session";
88
import { ENV_WEB } from "@web/common/constants/env.constants";
9-
import { ROOT_ROUTES } from "@web/common/constants/routes";
9+
import { DEFAULT_CALENDAR_ROUTE } from "@web/common/constants/routes";
1010
import {
1111
assignLocation,
1212
reloadLocation,
@@ -85,10 +85,10 @@ export const signOut = async (status: SignoutStatus) => {
8585

8686
await session.signOut();
8787

88-
if (window.location.pathname.startsWith(ROOT_ROUTES.DAY)) {
88+
if (window.location.pathname.startsWith(DEFAULT_CALENDAR_ROUTE)) {
8989
return;
9090
}
91-
assignLocation(ROOT_ROUTES.DAY);
91+
assignLocation(DEFAULT_CALENDAR_ROUTE);
9292
};
9393

9494
export const getRequestUrl = (url: string): string => {

packages/web/src/auth/google/authorization/complete-google-authorization.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
GoogleConnectErrorResponseSchema,
55
} from "@core/types/auth.types";
66
import { type ApiError, type ApiMethodConfig } from "@web/api/api.types";
7-
import { ROOT_ROUTES } from "@web/common/constants/routes";
7+
import { DEFAULT_CALENDAR_ROUTE } from "@web/common/constants/routes";
88
import {
99
GOOGLE_AUTH_SCOPES_REQUIRED,
1010
GOOGLE_AUTHORIZATION_ERROR_MESSAGE,
@@ -58,7 +58,7 @@ export type CompleteGoogleAuthorizationResult =
5858

5959
const fail = (
6060
message = GOOGLE_AUTHORIZATION_ERROR_MESSAGE,
61-
returnPath: string = ROOT_ROUTES.DAY,
61+
returnPath: string = DEFAULT_CALENDAR_ROUTE,
6262
): CompleteGoogleAuthorizationResult => ({
6363
message,
6464
returnPath,
@@ -101,7 +101,7 @@ export async function completeGoogleAuthorization({
101101

102102
const savedIntent = readGoogleAuthorizationIntent(state);
103103
clearGoogleAuthorizationIntent(state);
104-
const returnPath = savedIntent?.returnPath ?? ROOT_ROUTES.DAY;
104+
const returnPath = savedIntent?.returnPath ?? DEFAULT_CALENDAR_ROUTE;
105105

106106
if (!savedIntent || params.get("error")) {
107107
return fail(GOOGLE_AUTHORIZATION_ERROR_MESSAGE, returnPath);

packages/web/src/auth/google/authorization/google-authorization.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ describe("completeGoogleAuthorization", () => {
197197
).resolves.toEqual({
198198
status: "failed",
199199
message: "We couldn't connect your Google account. Please try again.",
200-
returnPath: "/day",
200+
returnPath: "/week",
201201
});
202202

203203
expect(deps.authApi.loginOrSignup).not.toHaveBeenCalled();

packages/web/src/auth/google/authorization/google-authorization.util.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@ describe("google-authorization.util", () => {
3030
).toBe("/week/2026-07-08?x=1");
3131
});
3232

33-
it("falls back to /day for external return paths", () => {
33+
it("falls back to /week for external return paths", () => {
3434
expect(
3535
getSafeGoogleAuthReturnPath(
3636
"https://evil.example/phish",
3737
"http://localhost:9080",
3838
),
39-
).toBe("/day");
39+
).toBe("/week");
4040
});
4141

4242
it("builds the existing auth-code payload shape", () => {

packages/web/src/auth/google/authorization/google-authorization.util.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { type GoogleAuthCodeRequest } from "@core/types/auth.types";
2+
import { DEFAULT_CALENDAR_ROUTE } from "@web/common/constants/routes";
23
import { GOOGLE_AUTH_CALLBACK_PATH } from "./google-authorization.constants";
34

45
export function buildGoogleAuthCallbackUrl(origin = window.location.origin) {
@@ -13,11 +14,11 @@ export function getSafeGoogleAuthReturnPath(
1314
const url = new URL(href, origin);
1415

1516
if (url.origin !== origin) {
16-
return "/day";
17+
return DEFAULT_CALENDAR_ROUTE;
1718
}
1819

1920
if (url.pathname === GOOGLE_AUTH_CALLBACK_PATH) {
20-
return "/day";
21+
return DEFAULT_CALENDAR_ROUTE;
2122
}
2223

2324
// Drop the transient auth-modal params so the OAuth round-trip doesn't
@@ -28,7 +29,7 @@ export function getSafeGoogleAuthReturnPath(
2829

2930
return `${url.pathname}${url.search}${url.hash}`;
3031
} catch {
31-
return "/day";
32+
return DEFAULT_CALENDAR_ROUTE;
3233
}
3334
}
3435

packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts

Lines changed: 4 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,23 @@
1-
import { useCallback, useSyncExternalStore } from "react";
1+
import { useCallback } from "react";
22
import { SyncApi } from "@web/api/sync.api";
33
import { getApiErrorCode, isApiError } from "@web/api/util/api.util";
4-
import { hasUserEverAuthenticated } from "@web/auth/compass/state/auth.state.util";
54
import { useStartGoogleAuthorization } from "@web/auth/google/authorization/useStartGoogleAuthorization";
65
import {
76
clearGoogleSyncIndicatorOverride,
8-
getGoogleSyncIndicatorOverride,
97
setRepairingSyncIndicatorOverride,
10-
subscribeToGoogleSyncUIState,
118
} from "@web/auth/google/state/google.sync.state";
129
import { syncPendingLocalEvents } from "@web/auth/google/util/google.auth.util";
13-
import {
14-
selectGoogleConnectionState,
15-
selectUserMetadataStatus,
16-
useUserMetadataStore,
17-
} from "@web/auth/state/user-metadata.store";
1810
import { GOOGLE_REPAIR_FAILED_TOAST_ID } from "@web/common/constants/toast.constants";
1911
import { showErrorToast } from "@web/common/utils/toast/error-toast.util";
2012
import { settingsActions } from "@web/settings/settings.store";
2113
import { useIsGoogleAvailable } from "../useIsGoogleAvailable/useIsGoogleAvailable";
22-
import {
23-
type GoogleUiState,
24-
type UseConnectGoogleResult,
25-
} from "./useConnectGoogle.types";
14+
import { type UseConnectGoogleResult } from "./useConnectGoogle.types";
2615
import { getGoogleConnectionConfig } from "./useConnectGoogle.util";
27-
28-
// Merges store-derived Google connection state with transient UI overrides from
29-
// google.sync.ui.state.ts; the override is read via useSyncExternalStore so React
30-
// stays aligned with that external store (see comments there).
16+
import { useGoogleUiState } from "./useGoogleUiState";
3117

3218
export const useConnectGoogle = (): UseConnectGoogleResult => {
3319
const isAvailable = useIsGoogleAvailable();
34-
const connectionState = useUserMetadataStore(selectGoogleConnectionState);
35-
const userMetadataStatus = useUserMetadataStore(selectUserMetadataStatus);
36-
const syncIndicator = useSyncExternalStore(
37-
subscribeToGoogleSyncUIState,
38-
getGoogleSyncIndicatorOverride,
39-
getGoogleSyncIndicatorOverride,
40-
);
20+
const state = useGoogleUiState();
4121
const { startGoogleAuthorization } = useStartGoogleAuthorization({
4222
intent: "connectCalendar",
4323
prompt: "consent",
@@ -88,21 +68,6 @@ export const useConnectGoogle = (): UseConnectGoogleResult => {
8868
void startRepair();
8969
}, []);
9070

91-
// "checking" is a UI-only state until we have loaded metadata from the server.
92-
// Covers both "idle" and "loading" so returning users do not briefly see
93-
// NOT_CONNECTED from the selector default.
94-
const isCheckingStatus =
95-
hasUserEverAuthenticated() && userMetadataStatus !== "loaded";
96-
97-
const state: GoogleUiState =
98-
syncIndicator === "repairing"
99-
? "repairing"
100-
: syncIndicator === "syncing"
101-
? "IMPORTING"
102-
: isCheckingStatus
103-
? "checking"
104-
: connectionState;
105-
10671
return {
10772
...getGoogleConnectionConfig(state, onOpenGoogleAuth, onRepairGoogle),
10873
isAvailable,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { act, cleanup, renderHook } from "@testing-library/react";
2+
import {
3+
resetGoogleSyncUIStateForTests,
4+
setRepairingSyncIndicatorOverride,
5+
setSyncingSyncIndicatorOverride,
6+
} from "@web/auth/google/state/google.sync.state";
7+
import { userMetadataActions } from "@web/auth/state/user-metadata.store";
8+
import { resolveGoogleUiState, useGoogleUiState } from "./useGoogleUiState";
9+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
10+
11+
beforeEach(() => {
12+
resetGoogleSyncUIStateForTests();
13+
userMetadataActions.clear();
14+
});
15+
16+
afterEach(() => {
17+
cleanup();
18+
resetGoogleSyncUIStateForTests();
19+
userMetadataActions.clear();
20+
});
21+
22+
describe("useGoogleUiState", () => {
23+
it("reflects the loaded Google connection state", () => {
24+
userMetadataActions.set({ google: { connectionState: "HEALTHY" } });
25+
26+
const { result } = renderHook(() => useGoogleUiState());
27+
28+
expect(result.current).toBe("HEALTHY");
29+
});
30+
31+
it("prioritizes transient syncing and repair states", () => {
32+
userMetadataActions.set({ google: { connectionState: "HEALTHY" } });
33+
const { result } = renderHook(() => useGoogleUiState());
34+
35+
act(() => setSyncingSyncIndicatorOverride());
36+
expect(result.current).toBe("IMPORTING");
37+
38+
act(() => setRepairingSyncIndicatorOverride());
39+
expect(result.current).toBe("repairing");
40+
});
41+
42+
it("reports checking while a returning user's metadata loads", () => {
43+
expect(
44+
resolveGoogleUiState({
45+
connectionState: "NOT_CONNECTED",
46+
hasAuthenticated: true,
47+
syncIndicator: null,
48+
userMetadataStatus: "loading",
49+
}),
50+
).toBe("checking");
51+
});
52+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useSyncExternalStore } from "react";
2+
import { type GoogleConnectionState } from "@core/types/user.types";
3+
import { hasUserEverAuthenticated } from "@web/auth/compass/state/auth.state.util";
4+
import {
5+
getGoogleSyncIndicatorOverride,
6+
subscribeToGoogleSyncUIState,
7+
} from "@web/auth/google/state/google.sync.state";
8+
import {
9+
selectGoogleConnectionState,
10+
selectUserMetadataStatus,
11+
type UserMetadataStatus,
12+
useUserMetadataStore,
13+
} from "@web/auth/state/user-metadata.store";
14+
import { type GoogleUiState } from "./useConnectGoogle.types";
15+
16+
type SyncIndicator = ReturnType<typeof getGoogleSyncIndicatorOverride>;
17+
18+
export function resolveGoogleUiState({
19+
connectionState,
20+
hasAuthenticated,
21+
syncIndicator,
22+
userMetadataStatus,
23+
}: {
24+
connectionState: GoogleConnectionState;
25+
hasAuthenticated: boolean;
26+
syncIndicator: SyncIndicator;
27+
userMetadataStatus: UserMetadataStatus;
28+
}): GoogleUiState {
29+
if (syncIndicator === "repairing") return "repairing";
30+
if (syncIndicator === "syncing") return "IMPORTING";
31+
32+
if (hasAuthenticated && userMetadataStatus !== "loaded") {
33+
return "checking";
34+
}
35+
36+
return connectionState;
37+
}
38+
39+
// Merges server metadata with transient repair/import overrides. The external
40+
// store subscription keeps every sync indicator aligned as SSE updates arrive.
41+
export function useGoogleUiState(): GoogleUiState {
42+
const connectionState = useUserMetadataStore(selectGoogleConnectionState);
43+
const userMetadataStatus = useUserMetadataStore(selectUserMetadataStatus);
44+
const syncIndicator = useSyncExternalStore(
45+
subscribeToGoogleSyncUIState,
46+
getGoogleSyncIndicatorOverride,
47+
getGoogleSyncIndicatorOverride,
48+
);
49+
50+
// Returning users should not briefly look disconnected while their server
51+
// metadata is still loading.
52+
return resolveGoogleUiState({
53+
connectionState,
54+
hasAuthenticated: hasUserEverAuthenticated(),
55+
syncIndicator,
56+
userMetadataStatus,
57+
});
58+
}

packages/web/src/common/constants/routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export const ROOT_ROUTES = {
1010
DAY_DATE: "/day/$dateString",
1111
} as const;
1212

13+
export const DEFAULT_CALENDAR_ROUTE = ROOT_ROUTES.WEEK;
14+
1315
// TanStack route *ids* (used for useMatch/useParams `from`), which diverge
1416
// from the URL-shaped ROOT_ROUTES above under the pathless "authenticated"
1517
// layout route. Kept as literals rather than importing the route objects

packages/web/src/components/PlannerSidebar/PlannerSidebarActions/PlannerSidebarActions.test.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { render, screen } from "@testing-library/react";
1+
import { act, render, screen } from "@testing-library/react";
22
import { createStoreWrapper } from "@web/__tests__/render-with-store";
3-
import { afterAll, describe, expect, it, mock } from "bun:test";
3+
import {
4+
resetGoogleSyncUIStateForTests,
5+
setSyncingSyncIndicatorOverride,
6+
} from "@web/auth/google/state/google.sync.state";
7+
import { afterAll, afterEach, describe, expect, it, mock } from "bun:test";
48

59
// mock.module is process-wide and not reliably restorable, so the real hook
610
// is captured up front and a flag (flipped off in afterAll) decides which
@@ -28,10 +32,33 @@ afterAll(() => {
2832
isVersionCheckMocked = false;
2933
});
3034

35+
afterEach(() => {
36+
act(() => resetGoogleSyncUIStateForTests());
37+
});
38+
3139
const { PlannerSidebarActions } =
3240
require("@web/components/PlannerSidebar/PlannerSidebarActions/PlannerSidebarActions") as typeof import("@web/components/PlannerSidebar/PlannerSidebarActions/PlannerSidebarActions");
3341

3442
describe("PlannerSidebarActions", () => {
43+
it("shimmers the command palette icon while Google Calendar is syncing", () => {
44+
const { wrapper } = createStoreWrapper();
45+
setSyncingSyncIndicatorOverride();
46+
47+
render(
48+
<PlannerSidebarActions
49+
isShortcutsOpen={false}
50+
onToggleShortcuts={mock()}
51+
/>,
52+
{ wrapper },
53+
);
54+
55+
expect(
56+
screen.getByRole("button", {
57+
name: "Open command palette, calendar syncing",
58+
}),
59+
).toBeInTheDocument();
60+
});
61+
3562
it("does not render the background import spinner in the sidebar", () => {
3663
const { wrapper } = createStoreWrapper();
3764

@@ -48,6 +75,9 @@ describe("PlannerSidebarActions", () => {
4875
name: "Syncing Google Calendar in the background.",
4976
}),
5077
).not.toBeInTheDocument();
78+
expect(
79+
screen.getByRole("button", { name: "Open command palette" }),
80+
).toBeInTheDocument();
5181
});
5282

5383
it("labels the shortcuts button as a close action when shortcuts are open", () => {

0 commit comments

Comments
 (0)