-
Notifications
You must be signed in to change notification settings - Fork 49
feat: simplify onboarding #1404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
1cc66ae
bcd9704
6cf674d
367fd91
c714c57
889f94a
82e4fe2
64a29d8
92b182a
b94777b
f151324
bb54bf1
1559e68
e5b122c
d5cf873
410794c
c4fd05c
b972901
5ac651d
5cc9ad4
77b0ba9
3601507
811ab9f
701a39a
895c81f
64d91b2
5ba256a
9e083f0
eeb824f
5f6205d
bac8b27
eb7ed12
18b7a52
f1adaa8
d322254
5351e68
cac83ab
d8b7a96
e1eebd3
92c5ef6
83ada8a
38139f8
7148725
07a4d82
cd921a6
739fa8a
6c7464a
cf2f477
5a9d041
e40ca95
76cb0fe
9a612d4
4d6cfb5
01d1b8e
05b7b0e
d2fd159
ef4667c
f0ccfb5
12ccd37
3ed7c68
0ac9183
c588194
6bb0e2a
a2d4098
3e5e37c
964dc28
a4cace0
dca5f6d
37d1ffa
ddc5928
56d1337
75fc3f4
85adcdc
15c8a78
7a1893a
2dfe7e0
b3476ea
9d51126
0abc2dd
700ce5b
217dd69
5904478
b5b6885
f15c1d1
dd7ec2b
2da6826
491220d
a846208
5598452
22789a5
61da56a
059cc68
a023560
6c9fb0f
cc94421
efec135
f0bf56d
122eb45
ee33602
3093d8b
fff5370
c418eb1
947708c
bd48d59
6197900
935d293
8bfb35c
f6f2bed
70be161
21f76ef
a59ef20
20222bd
e3d1914
1ca6efa
b08540c
373bb5e
214c794
47e1487
9c7d3bd
17bb59e
f386e6d
c1b3b9f
7ebaa22
7f8ea0d
1f67418
dfe41a7
af88ce7
b6c89d0
b9c34c1
c68f568
9189358
18ec635
8bf7de3
862cd4f
51507a5
6171dfd
f79f889
1fb3013
ab6f788
16dc25d
a70ce61
f5d55f2
b85426d
84a2135
080fcdc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -76,10 +76,29 @@ export class GcalEventRRule extends RRule { | |||||||||||||||||||||||||||||||
| index < GCAL_MAX_RECURRENCES, | ||||||||||||||||||||||||||||||||
| ): Date[] { | ||||||||||||||||||||||||||||||||
| const dates = super.all(iterator); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // If no dates were generated, return empty array | ||||||||||||||||||||||||||||||||
| if (dates.length === 0) { | ||||||||||||||||||||||||||||||||
| return []; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const firstInstance = dates[0]; | ||||||||||||||||||||||||||||||||
| const firstInstanceStartDate = dayjs(firstInstance).tz(this.#timezone); | ||||||||||||||||||||||||||||||||
| const includesDtStart = this.#startDate.isSame(firstInstanceStartDate); | ||||||||||||||||||||||||||||||||
| const rDates = includesDtStart ? [] : [this.#startDate.toDate()]; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Check if dtstart is already included in the generated dates | ||||||||||||||||||||||||||||||||
| // Use a more lenient comparison to handle timezone precision issues | ||||||||||||||||||||||||||||||||
| const includesDtStart = | ||||||||||||||||||||||||||||||||
| this.#startDate.isSame(firstInstanceStartDate, "minute") || | ||||||||||||||||||||||||||||||||
| dates.some((date) => { | ||||||||||||||||||||||||||||||||
| const dateInTz = dayjs(date).tz(this.#timezone); | ||||||||||||||||||||||||||||||||
| return this.#startDate.isSame(dateInTz, "minute"); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
| const includesDtStart = | |
| this.#startDate.isSame(firstInstanceStartDate, "minute") || | |
| dates.some((date) => { | |
| const dateInTz = dayjs(date).tz(this.#timezone); | |
| return this.#startDate.isSame(dateInTz, "minute"); | |
| }); | |
| const includesDtStart = this.#startDate.isSame( | |
| firstInstanceStartDate, | |
| "minute", | |
| ) | |
| ? true | |
| : dates.some((date) => { | |
| const dateInTz = dayjs(date).tz(this.#timezone); | |
| return this.#startDate.isSame(dateInTz, "minute"); | |
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { Task } from "@web/common/types/task.types"; | ||
| import { | ||
| getDateKey, | ||
| loadTasksFromStorage, | ||
| saveTasksToStorage, | ||
| } from "@web/common/utils/storage/storage.util"; | ||
| import { seedInitialTasks } from "./task-seeding.util"; | ||
|
|
||
| // Mock localStorage | ||
| const localStorageMock = (() => { | ||
| let store: Record<string, string> = {}; | ||
|
|
||
| return { | ||
| getItem: (key: string) => store[key] || null, | ||
| setItem: (key: string, value: string) => { | ||
| store[key] = value.toString(); | ||
| }, | ||
| removeItem: (key: string) => { | ||
| delete store[key]; | ||
| }, | ||
| clear: () => { | ||
| store = {}; | ||
| }, | ||
| }; | ||
| })(); | ||
|
|
||
| Object.defineProperty(window, "localStorage", { | ||
| value: localStorageMock, | ||
| }); | ||
|
|
||
| describe("task-seeding.util", () => { | ||
| beforeEach(() => { | ||
| localStorageMock.clear(); | ||
| }); | ||
|
|
||
| describe("seedInitialTasks", () => { | ||
| it("should seed initial tasks when none exist", () => { | ||
| const dateKey = getDateKey(); | ||
| const tasks = seedInitialTasks(dateKey); | ||
|
|
||
| expect(tasks).toHaveLength(2); | ||
| expect(tasks[0].title).toBe("Review project proposal"); | ||
| expect(tasks[1].title).toBe("Write weekly report"); | ||
| expect(tasks[0].status).toBe("todo"); | ||
| expect(tasks[1].status).toBe("todo"); | ||
| expect(tasks[0].id).toBeDefined(); | ||
| expect(tasks[1].id).toBeDefined(); | ||
| }); | ||
|
|
||
| it("should return existing tasks if they already exist", () => { | ||
| const dateKey = getDateKey(); | ||
| const existingTask: Task = { | ||
| id: "existing-id", | ||
| title: "Existing task", | ||
| status: "todo", | ||
| createdAt: new Date().toISOString(), | ||
| order: 0, | ||
| }; | ||
|
|
||
| saveTasksToStorage(dateKey, [existingTask]); | ||
| const tasks = seedInitialTasks(dateKey); | ||
|
|
||
| expect(tasks).toHaveLength(1); | ||
| expect(tasks[0].id).toBe("existing-id"); | ||
| expect(tasks[0].title).toBe("Existing task"); | ||
| }); | ||
|
|
||
| it("should save tasks to localStorage", () => { | ||
| const dateKey = getDateKey(); | ||
| seedInitialTasks(dateKey); | ||
|
|
||
| const storedTasks = loadTasksFromStorage(dateKey); | ||
| expect(storedTasks).toHaveLength(2); | ||
| }); | ||
|
|
||
| it("should work with different date keys", () => { | ||
| const dateKey1 = "2024-01-01"; | ||
| const dateKey2 = "2024-01-02"; | ||
|
|
||
| const tasks1 = seedInitialTasks(dateKey1); | ||
| const tasks2 = seedInitialTasks(dateKey2); | ||
|
|
||
| expect(tasks1).toHaveLength(2); | ||
| expect(tasks2).toHaveLength(2); | ||
| expect(tasks1[0].id).not.toBe(tasks2[0].id); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { v4 as uuidv4 } from "uuid"; | ||
| import { Task } from "@web/common/types/task.types"; | ||
| import { | ||
| loadTasksFromStorage, | ||
| saveTasksToStorage, | ||
| } from "@web/common/utils/storage/storage.util"; | ||
|
|
||
| /** | ||
| * Initial task titles to seed for new users | ||
| */ | ||
| const INITIAL_TASK_TITLES = ["Review project proposal", "Write weekly report"]; | ||
tyler-dane marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * Seeds initial tasks for a given date if no tasks exist | ||
| * @param dateKey - Date key in format YYYY-MM-DD | ||
| * @returns Array of seeded tasks | ||
| */ | ||
| export function seedInitialTasks(dateKey: string): Task[] { | ||
| const existingTasks = loadTasksFromStorage(dateKey); | ||
|
|
||
| // If tasks already exist, return them | ||
| if (existingTasks.length > 0) { | ||
| return existingTasks; | ||
| } | ||
|
|
||
| // Create initial tasks | ||
| const initialTasks: Task[] = INITIAL_TASK_TITLES.map((title, index) => ({ | ||
| id: uuidv4(), | ||
| title, | ||
| status: "todo" as const, | ||
| createdAt: new Date().toISOString(), | ||
| order: index, | ||
| })); | ||
|
|
||
| // Save to localStorage | ||
| saveTasksToStorage(dateKey, initialTasks); | ||
|
|
||
| return initialTasks; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { Outlet } from "react-router-dom"; | ||
| import { useGlobalShortcuts } from "@web/views/Calendar/hooks/shortcuts/useGlobalShortcuts"; | ||
|
|
||
| /** | ||
| * Layout component for unauthenticated/guest users | ||
| * Provides the same global shortcuts (like cmd+k) as authenticated users | ||
| * but without requiring authentication | ||
| */ | ||
| export const GuestLayout = () => { | ||
| // Enable global shortcuts for guest users (including cmd+k palette) | ||
| useGlobalShortcuts(); | ||
|
|
||
| return <Outlet />; | ||
| }; | ||
tyler-dane marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import { BrowserRouter } from "react-router-dom"; | ||
| import { render, screen } from "@testing-library/react"; | ||
| import userEvent from "@testing-library/user-event"; | ||
| import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; | ||
| import { AuthPrompt } from "./AuthPrompt"; | ||
|
|
||
| const renderWithRouter = (component: React.ReactElement) => { | ||
| return render(<BrowserRouter>{component}</BrowserRouter>); | ||
| }; | ||
|
|
||
| describe("AuthPrompt", () => { | ||
| beforeEach(() => { | ||
| localStorage.clear(); | ||
| }); | ||
|
|
||
| it("should render sign in message", () => { | ||
| const onDismiss = jest.fn(); | ||
|
|
||
| renderWithRouter(<AuthPrompt onDismiss={onDismiss} />); | ||
|
|
||
| expect( | ||
| screen.getByText("Sign in to sync across devices"), | ||
| ).toBeInTheDocument(); | ||
| expect( | ||
| screen.getByText(/Your tasks are saved locally/i), | ||
| ).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("should dismiss when 'Later' button is clicked", async () => { | ||
| const onDismiss = jest.fn(); | ||
|
|
||
| renderWithRouter(<AuthPrompt onDismiss={onDismiss} />); | ||
|
|
||
| const laterButton = screen.getByRole("button", { name: /later/i }); | ||
| await userEvent.click(laterButton); | ||
|
|
||
| expect(onDismiss).toHaveBeenCalled(); | ||
| expect(localStorage.getItem(STORAGE_KEYS.AUTH_PROMPT_DISMISSED)).toBe( | ||
| "true", | ||
| ); | ||
| }); | ||
|
|
||
| it("should navigate to login when 'Sign in' button is clicked", async () => { | ||
| const onDismiss = jest.fn(); | ||
|
|
||
| renderWithRouter(<AuthPrompt onDismiss={onDismiss} />); | ||
|
|
||
| const signInButton = screen.getByRole("button", { name: /sign in/i }); | ||
| await userEvent.click(signInButton); | ||
|
|
||
| // Check that navigation occurred (window.location would change in real app) | ||
| // In test environment, we just verify the button click works | ||
| expect(signInButton).toBeInTheDocument(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.