diff --git a/apps/mobile/__tests__/components/calendar-picker-section.test.tsx b/apps/mobile/__tests__/components/calendar-picker-section.test.tsx new file mode 100644 index 000000000..d76023b8a --- /dev/null +++ b/apps/mobile/__tests__/components/calendar-picker-section.test.tsx @@ -0,0 +1,142 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { UserCalendar } from '@orbit/shared/types/calendar' + +import { CalendarPickerSection } from '@/app/calendar-picker-section' +import { createStyles } from '@/app/calendar-sync-styles' + +const TestRenderer = require('react-test-renderer') + +const mocks = vi.hoisted(() => ({ + calendars: undefined as UserCalendar[] | undefined, + isLoading: false, + isError: false, + mutate: vi.fn(), + showError: vi.fn(), +})) + +vi.mock('@/lib/use-app-theme', () => ({ + useAppTheme: () => ({ currentScheme: 'purple', currentTheme: 'dark' }), +})) + +vi.mock('@/lib/theme', () => ({ + createTokensV2: () => new Proxy({}, { get: () => '#111111' }), + easings: { smooth: [0.2, 0, 0, 1] }, + tintFromPrimary: () => 'rgba(127,70,247,0.1)', +})) + +vi.mock('@/lib/motion', () => ({ + toAnimatedEasing: () => (value: number) => value, +})) + +vi.mock('@/hooks/use-calendars', () => ({ + useCalendars: () => ({ + data: mocks.calendars, + isLoading: mocks.isLoading, + isError: mocks.isError, + }), + useSetSelectedCalendars: () => ({ mutate: mocks.mutate }), +})) + +vi.mock('@/hooks/use-app-toast', () => ({ + useAppToast: () => ({ showError: mocks.showError }), +})) + +const tokens = new Proxy({}, { get: () => '#111111' }) as never +const styles = createStyles() +const t = ((key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key) as never + +function buildCalendar(overrides: Partial = {}): UserCalendar { + return { + id: 'cal-1', + name: 'Personal', + accessRole: 'owner', + primary: true, + backgroundColor: '#7f46f7', + isSynced: true, + ...overrides, + } +} + +type TestNode = { props: Record; type?: unknown } + +function render(enabled: boolean) { + let tree: { + root: { + findAll: (predicate: (node: TestNode) => boolean) => TestNode[] + } + } | null = null + TestRenderer.act(() => { + tree = TestRenderer.create( + React.createElement(CalendarPickerSection, { styles, tokens, t, enabled }), + ) + }) + return tree! +} + +function switches(tree: ReturnType) { + return tree.root.findAll( + (node) => node.props.accessibilityRole === 'switch' && typeof node.type === 'string', + ) +} + +beforeEach(() => { + mocks.calendars = undefined + mocks.isLoading = false + mocks.isError = false + mocks.mutate.mockReset() + mocks.showError.mockReset() +}) + +describe('mobile CalendarPickerSection', () => { + it('renders nothing when disabled', () => { + mocks.calendars = [buildCalendar()] + const tree = render(false) + const hostNodes = tree.root.findAll((node) => typeof node.type === 'string') + expect(hostNodes.length).toBe(0) + }) + + it('renders a switch per calendar reflecting its synced state', () => { + mocks.calendars = [ + buildCalendar({ id: 'cal-1', isSynced: true }), + buildCalendar({ id: 'cal-2', name: 'Work', primary: false, isSynced: false }), + ] + const found = switches(render(true)) + expect(found).toHaveLength(2) + expect((found[0]!.props.accessibilityState as { checked: boolean }).checked).toBe(true) + expect((found[1]!.props.accessibilityState as { checked: boolean }).checked).toBe(false) + }) + + it('persists the flipped synced value on toggle', () => { + mocks.calendars = [buildCalendar({ id: 'cal-1', isSynced: true })] + const found = switches(render(true)) + + TestRenderer.act(() => { + ;(found[0]!.props.onPress as () => void)() + }) + + expect(mocks.mutate).toHaveBeenCalledWith( + { id: 'cal-1', isSynced: false }, + expect.anything(), + ) + }) + + it('renders the empty state when no calendars are returned', () => { + mocks.calendars = [] + const tree = render(true) + const texts = tree.root.findAll( + (node) => node.props.children === 'calendar.calendars.empty', + ) + expect(texts.length).toBeGreaterThan(0) + }) + + it('renders the error state', () => { + mocks.isError = true + const tree = render(true) + const texts = tree.root.findAll( + (node) => node.props.children === 'calendar.calendars.error', + ) + expect(texts.length).toBeGreaterThan(0) + }) +}) diff --git a/apps/mobile/__tests__/components/ui/app-time-picker.test.tsx b/apps/mobile/__tests__/components/ui/app-time-picker.test.tsx index ad4a2b18b..26e453fd6 100644 --- a/apps/mobile/__tests__/components/ui/app-time-picker.test.tsx +++ b/apps/mobile/__tests__/components/ui/app-time-picker.test.tsx @@ -11,6 +11,17 @@ import { AppTimePicker } from '@/components/ui/app-time-picker' const TestRenderer = require('react-test-renderer') +let mockUses24HourClock = true + +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ + profile: { + uses24HourClock: mockUses24HourClock, + timeZone: 'America/Sao_Paulo', + }, + }), +})) + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, values?: Record) => @@ -22,9 +33,10 @@ vi.mock('react-i18next', () => ({ describe('AppTimePicker', () => { beforeEach(() => { resetDateTimePickerMock() + mockUses24HourClock = true }) - it('uses the active locale for display text and Android 24-hour picker mode', async () => { + it('uses the active locale for display text and Android 24-hour picker mode when uses24HourClock is true', async () => { const onChange = vi.fn() let tree: any @@ -37,7 +49,13 @@ describe('AppTimePicker', () => { const [textTrigger] = tree.root.findAllByType(Pressable) const label = tree.root.findByType('Text') - expect(label.props.children).toBe(formatLocaleTime('14:30', 'pt-BR')) + expect(label.props.children).toBe( + formatLocaleTime('14:30', 'pt-BR', { + hour: 'numeric', + minute: '2-digit', + hour12: false, + }), + ) await TestRenderer.act(async () => { textTrigger.props.onPress() @@ -47,6 +65,27 @@ describe('AppTimePicker', () => { expect(dateTimePickerOpenCalls[0]?.is24Hour).toBe(true) }) + it('opens the Android picker in 12-hour mode when uses24HourClock is false', async () => { + mockUses24HourClock = false + const onChange = vi.fn() + let tree: any + + await TestRenderer.act(async () => { + tree = TestRenderer.create( + , + ) + }) + + const [textTrigger] = tree.root.findAllByType(Pressable) + + await TestRenderer.act(async () => { + textTrigger.props.onPress() + }) + + expect(dateTimePickerOpenCalls).toHaveLength(1) + expect(dateTimePickerOpenCalls[0]?.is24Hour).toBe(false) + }) + it('renders a clear button when value is set and onClear is provided', async () => { const onClear = vi.fn() let tree: any diff --git a/apps/mobile/__tests__/hooks/use-calendars.test.ts b/apps/mobile/__tests__/hooks/use-calendars.test.ts new file mode 100644 index 000000000..191d99725 --- /dev/null +++ b/apps/mobile/__tests__/hooks/use-calendars.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { calendarKeys } from '@orbit/shared/query' +import type { UserCalendar } from '@orbit/shared/types/calendar' + +import { useCalendars, useSetSelectedCalendars } from '@/hooks/use-calendars' + +const mocks = vi.hoisted(() => { + const store: { calendars: UserCalendar[] | undefined } = { calendars: undefined } + + const queryClient = { + cancelQueries: vi.fn(async () => {}), + invalidateQueries: vi.fn(async () => {}), + getQueryData: vi.fn(() => store.calendars), + setQueryData: vi.fn( + ( + _queryKey: readonly unknown[], + updater: UserCalendar[] | undefined | ((old: unknown) => unknown), + ) => { + store.calendars = + typeof updater === 'function' + ? (updater as (old: unknown) => UserCalendar[] | undefined)(store.calendars) + : updater + }, + ), + } + + return { + store, + queryClient, + useQuery: vi.fn(), + useQueryClient: vi.fn(() => queryClient), + useMutation: vi.fn((config: unknown) => config), + apiClient: vi.fn(), + } +}) + +vi.mock('@tanstack/react-query', () => ({ + useQuery: mocks.useQuery, + useQueryClient: mocks.useQueryClient, + useMutation: mocks.useMutation, +})) + +vi.mock('@/lib/api-client', () => ({ + apiClient: mocks.apiClient, +})) + +type MutationConfig = { + mutationFn: (variables: TVariables) => Promise + onMutate?: (variables: TVariables) => Promise | TContext + onError?: (error: Error, variables: TVariables, context: TContext | undefined) => void +} + +function buildCalendar(overrides: Partial = {}): UserCalendar { + return { + id: 'cal-1', + name: 'Personal', + accessRole: 'owner', + primary: true, + backgroundColor: '#7f46f7', + isSynced: true, + ...overrides, + } +} + +describe('mobile calendar picker hooks', () => { + beforeEach(() => { + mocks.store.calendars = undefined + mocks.apiClient.mockReset() + mocks.useQuery.mockReset() + mocks.useMutation.mockClear() + mocks.queryClient.getQueryData.mockClear() + mocks.queryClient.setQueryData.mockClear() + mocks.queryClient.invalidateQueries.mockClear() + }) + + it('useCalendars loads and parses calendars from the api', async () => { + let capturedFn: (() => Promise) | null = null + mocks.useQuery.mockImplementation( + (config: { queryKey: readonly unknown[]; queryFn: () => Promise }) => { + capturedFn = config.queryFn + return { data: undefined } + }, + ) + + const calendars = [buildCalendar(), buildCalendar({ id: 'cal-2', name: 'Work', primary: false })] + mocks.apiClient.mockResolvedValue(calendars) + + useCalendars() + + expect(mocks.useQuery).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: calendarKeys.calendars() }), + ) + + const result = await capturedFn!() + expect(result).toEqual(calendars) + expect(mocks.apiClient).toHaveBeenCalledWith('/api/calendar/calendars') + }) + + it('useSetSelectedCalendars sends the ids of every synced calendar after the toggle', async () => { + const mutation = useSetSelectedCalendars() as unknown as MutationConfig< + void, + { id: string; isSynced: boolean }, + { previous: UserCalendar[] | undefined } + > + + mocks.store.calendars = [ + buildCalendar({ id: 'cal-1', isSynced: true }), + buildCalendar({ id: 'cal-2', isSynced: false }), + ] + mocks.apiClient.mockResolvedValue(undefined) + + await mutation.mutationFn({ id: 'cal-2', isSynced: true }) + + expect(mocks.apiClient).toHaveBeenCalledWith( + '/api/calendar/selected-calendars', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ calendarIds: ['cal-1', 'cal-2'] }), + }), + ) + }) + + it('useSetSelectedCalendars optimistically flips isSynced for the toggled calendar', async () => { + const mutation = useSetSelectedCalendars() as unknown as MutationConfig< + void, + { id: string; isSynced: boolean }, + { previous: UserCalendar[] | undefined } + > + + mocks.store.calendars = [buildCalendar({ id: 'cal-1', isSynced: true })] + + await mutation.onMutate?.({ id: 'cal-1', isSynced: false }) + + expect(mocks.store.calendars?.[0]?.isSynced).toBe(false) + }) + + it('useSetSelectedCalendars rolls back the optimistic update when the api fails', async () => { + const mutation = useSetSelectedCalendars() as unknown as MutationConfig< + void, + { id: string; isSynced: boolean }, + { previous: UserCalendar[] | undefined } + > + + const initial = [buildCalendar({ id: 'cal-1', isSynced: true })] + mocks.store.calendars = [...initial] + mocks.apiClient.mockRejectedValue(new Error('Save failed')) + + const context = await mutation.onMutate?.({ id: 'cal-1', isSynced: false }) + expect(mocks.store.calendars?.[0]?.isSynced).toBe(false) + + await expect(mutation.mutationFn({ id: 'cal-1', isSynced: false })).rejects.toThrow( + 'Save failed', + ) + mutation.onError?.(new Error('Save failed'), { id: 'cal-1', isSynced: false }, context) + + expect(mocks.store.calendars?.[0]?.isSynced).toBe(true) + }) +}) diff --git a/apps/mobile/__tests__/screens/calendar-sync.test.tsx b/apps/mobile/__tests__/screens/calendar-sync.test.tsx index 7b601ffe5..138415385 100644 --- a/apps/mobile/__tests__/screens/calendar-sync.test.tsx +++ b/apps/mobile/__tests__/screens/calendar-sync.test.tsx @@ -6,6 +6,11 @@ import CalendarSyncScreen from "@/app/calendar-sync"; const TestRenderer = require("react-test-renderer"); +type TestNode = { + props: Record; + findAll: (predicate: (node: TestNode) => boolean) => TestNode[]; +}; + const colorProxy: any = new Proxy( {}, { @@ -105,6 +110,11 @@ vi.mock("@/hooks/use-calendar-events", () => ({ useCalendarEvents: () => mocks.eventsQuery, })); +vi.mock("@/hooks/use-calendars", () => ({ + useCalendars: () => ({ data: [], isLoading: false, isError: false }), + useSetSelectedCalendars: () => ({ mutate: vi.fn() }), +})); + vi.mock("@/lib/api-client", () => ({ apiClient: mocks.apiClient, })); @@ -172,7 +182,6 @@ vi.mock("lucide-react-native", () => { }; }); -// Phase 5 v8 primitives consumed by the migrated calendar-sync screen. vi.mock("@/components/ui/app-bar", () => ({ AppBar: () => null, })); @@ -237,4 +246,102 @@ describe("CalendarSyncScreen", () => { expect(mocks.router.push).not.toHaveBeenCalledWith("/upgrade"); expect(mocks.eventsQuery.refetch).not.toHaveBeenCalled(); }); + + function buildEvents(count: number) { + return Array.from({ length: count }, (_value, index) => ({ + id: `ev-${index}`, + title: `Event ${index}`, + description: null, + startDate: "2026-07-01", + startTime: null, + endTime: null, + isRecurring: false, + recurrenceRule: null, + reminders: [], + calendarName: "Work", + })); + } + + function countEventTitles(root: { + findAll: ( + predicate: (node: { props: Record; type?: unknown }) => boolean, + ) => unknown[]; + }) { + return root.findAll( + (node) => + typeof node.type === "string" && /^Event \d+$/.test(String(node.props.children)), + ).length; + } + + function findShowMore(root: { + findAll: (predicate: (node: { props: Record }) => boolean) => { + props: Record; + }[]; + }) { + return root.findAll((node) => node.props.children === "calendar.showMore"); + } + + it("renders only the first page of events and reveals more on demand", async () => { + mocks.eventsQuery.data = { status: "connected", events: buildEvents(45) }; + + let tree: any; + await TestRenderer.act(async () => { + tree = TestRenderer.create(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(countEventTitles(tree.root)).toBe(20); + + const showMore = findShowMore(tree.root); + expect(showMore.length).toBeGreaterThan(0); + + const pressable = tree.root.find( + (node: TestNode) => + node.props.accessibilityRole === "button" && + typeof node.props.onPress === "function" && + node.findAll((child: TestNode) => + child.props.children === "calendar.showMore", + ).length > 0, + ); + + await TestRenderer.act(async () => { + (pressable.props.onPress as () => void)(); + await Promise.resolve(); + }); + + expect(countEventTitles(tree.root)).toBe(40); + }); + + it("does not show the pager when events fit on one page", async () => { + mocks.eventsQuery.data = { status: "connected", events: buildEvents(8) }; + + let tree: any; + await TestRenderer.act(async () => { + tree = TestRenderer.create(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(countEventTitles(tree.root)).toBe(8); + expect(findShowMore(tree.root).length).toBe(0); + }); + + it("shows the source calendar name in each event's meta", async () => { + mocks.eventsQuery.data = { status: "connected", events: buildEvents(1) }; + + let tree: any; + await TestRenderer.act(async () => { + tree = TestRenderer.create(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const metaNodes = tree.root.findAll( + (node: { props: Record }) => + typeof node.props.children === "string" && + (node.props.children as string).includes("Work"), + ); + expect(metaNodes.length).toBeGreaterThan(0); + }); }); diff --git a/apps/mobile/app/calendar-picker-section.tsx b/apps/mobile/app/calendar-picker-section.tsx new file mode 100644 index 000000000..ab6e71063 --- /dev/null +++ b/apps/mobile/app/calendar-picker-section.tsx @@ -0,0 +1,104 @@ +import { ActivityIndicator, Text, View } from 'react-native' +import type { TFunction } from 'i18next' +import { getFriendlyErrorMessage } from '@orbit/shared/utils' +import type { AppTokensV2 } from '@/lib/theme' +import { SectionLabel } from '@/components/ui/section-label' +import { SettingsDescription } from '@/components/ui/settings-description' +import { SettingsRow, Switch } from '@/components/ui/settings-row' +import { useCalendars, useSetSelectedCalendars } from '@/hooks/use-calendars' +import { useAppToast } from '@/hooks/use-app-toast' +import type { CalendarSyncStyles } from './calendar-sync-styles' + +interface CalendarPickerSectionProps { + styles: CalendarSyncStyles + tokens: AppTokensV2 + t: TFunction + enabled: boolean +} + +/** + * "Calendars" settings section: one Switch row per Google calendar, toggling + * which calendars Orbit reads events from. Persists each toggle immediately. + * Renders nothing until enabled so it stays hidden when Google is not connected. + */ +export function CalendarPickerSection({ + styles, + tokens, + t, + enabled, +}: Readonly) { + const { data: calendars, isLoading, isError } = useCalendars({ enabled }) + const setSelectedCalendars = useSetSelectedCalendars() + const { showError } = useAppToast() + + if (!enabled) return null + + function handleToggle(id: string, isSynced: boolean) { + setSelectedCalendars.mutate( + { id, isSynced }, + { + onError: (err: unknown) => { + showError(getFriendlyErrorMessage(err, t, 'calendar.calendars.saveFailed', 'generic')) + }, + }, + ) + } + + return ( + <> + {t('calendar.calendars.title')} + + {isLoading ? ( + + + + {t('calendar.calendars.loading')} + + + ) : null} + + {isError && !isLoading ? ( + + + {t('calendar.calendars.error')} + + + ) : null} + + {!isLoading && !isError && calendars && calendars.length === 0 ? ( + + + {t('calendar.calendars.empty')} + + + ) : null} + + {!isLoading && !isError + ? calendars?.map((calendar, index) => ( + + handleToggle(calendar.id, !calendar.isSynced)} + accessibilityLabel={t('calendar.calendars.toggleLabel', { + name: calendar.name, + })} + /> + + )) + : null} + + {t('calendar.calendars.description')} + + ) +} diff --git a/apps/mobile/app/calendar-sync-styles.ts b/apps/mobile/app/calendar-sync-styles.ts index ecbc88915..8a55295c3 100644 --- a/apps/mobile/app/calendar-sync-styles.ts +++ b/apps/mobile/app/calendar-sync-styles.ts @@ -119,6 +119,33 @@ export function createStyles() { paddingHorizontal: 20, paddingVertical: 18, }, + pickerStateRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 20, + paddingTop: 6, + }, + pickerStateText: { + fontFamily: 'Rubik_400Regular', + fontSize: 14, + lineHeight: 19.6, + }, + showMoreRow: { + alignItems: 'center', + gap: 8, + paddingHorizontal: 20, + paddingTop: 14, + }, + showingCountText: { + fontFamily: 'Roboto_400Regular', + fontSize: 12, + fontVariant: ['tabular-nums'], + }, + eventTagText: { + fontFamily: 'Rubik_400Regular', + fontSize: 12, + }, progressTrack: { width: 200, height: 8, diff --git a/apps/mobile/app/calendar-sync.tsx b/apps/mobile/app/calendar-sync.tsx index 8c818f33a..36201afde 100644 --- a/apps/mobile/app/calendar-sync.tsx +++ b/apps/mobile/app/calendar-sync.tsx @@ -43,8 +43,11 @@ import { SettingsRow } from '@/components/ui/settings-row' import { SelectCheck } from '@/components/ui/select-check' import { PillButton } from '@/components/ui/pill-button' import { CalendarAutoSyncSection } from './calendar-sync-auto-section' +import { CalendarPickerSection } from './calendar-picker-section' import { createStyles } from './calendar-sync-styles' +const EVENTS_PAGE_SIZE = 20 + type Step = | 'loading' | 'select' @@ -113,6 +116,7 @@ export default function CalendarSyncScreen() { const [importResult, setImportResult] = useState(null) const [isConnecting, setIsConnecting] = useState(false) const [previousEventsKey, setPreviousEventsKey] = useState(null) + const [visibleCount, setVisibleCount] = useState(EVENTS_PAGE_SIZE) const eventsQuery = useCalendarEvents({ enabled: (profile?.hasProAccess ?? false) && !isReviewMode && isOnline, @@ -130,6 +134,7 @@ export default function CalendarSyncScreen() { if (eventsKey !== previousEventsKey) { setPreviousEventsKey(eventsKey) setEvents(incomingEvents) + setVisibleCount(EVENTS_PAGE_SIZE) if (isReviewMode && previousEventsKey !== null) { setSelectedIds((prev) => { const next = new Set() @@ -380,7 +385,12 @@ export default function CalendarSyncScreen() { const timeLabel = event.startTime ? `${event.startTime}${event.endTime ? `-${event.endTime}` : ''}` : '' - const meta = [dateLabel, timeLabel, event.isRecurring ? recurrenceLabel : null] + const meta = [ + dateLabel, + timeLabel, + event.isRecurring ? recurrenceLabel : null, + event.calendarName ?? null, + ] .filter(Boolean) .join(' · ') @@ -465,6 +475,15 @@ export default function CalendarSyncScreen() { /> ) : null} + {profile?.hasProAccess && !isProfileLoading ? ( + + ) : null} + {(isProfileLoading || step === 'loading') && ( - {events.map(renderEventRow)} + {events.slice(0, visibleCount).map(renderEventRow)} + {events.length > visibleCount ? ( + + + setVisibleCount((count) => + Math.min(count + EVENTS_PAGE_SIZE, events.length), + ) + } + accessibilityRole="button" + style={({ pressed }) => [ + styles.quietAction, + chipTint, + pressed && styles.quietActionDim, + ]} + > + + {t('calendar.showMore')} + + + + {t('calendar.showingCount', { + shown: Math.min(visibleCount, events.length), + total: events.length, + })} + + + ) : null} ) { + const { t } = useTranslation() return ( @@ -199,7 +200,11 @@ function HabitStatList({ }, ]} > - {habit.completionRate}% + {habit.isOneTime + ? habit.completedCount > 0 + ? t('retrospective.completed') + : t('retrospective.notCompleted') + : `${habit.completionRate}%`} ))} diff --git a/apps/mobile/components/ui/app-time-picker.tsx b/apps/mobile/components/ui/app-time-picker.tsx index 6cc1940be..52fdfb9db 100644 --- a/apps/mobile/components/ui/app-time-picker.tsx +++ b/apps/mobile/components/ui/app-time-picker.tsx @@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next' import { detectDefaultTimeFormat, formatLocaleTime } from '@orbit/shared/utils' import { createTokensV2, radius, shadowsV2 } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' +import { useProfile } from '@/hooks/use-profile' type AppTokens = ReturnType @@ -70,8 +71,11 @@ export function AppTimePicker({ const styles = useMemo(() => createStyles(tokens), [tokens]) const [isOpen, setIsOpen] = useState(false) const [draftValue, setDraftValue] = useState(() => parseTimeValue(value)) - const is24Hour = detectDefaultTimeFormat(locale) === '24h' - const displayValue = value ? formatLocaleTime(value, locale) : '' + const { profile } = useProfile() + const is24Hour = profile?.uses24HourClock ?? detectDefaultTimeFormat(locale) === '24h' + const displayValue = value + ? formatLocaleTime(value, locale, { hour: 'numeric', minute: '2-digit', hour12: !is24Hour }) + : '' const openPicker = useCallback(() => { if (disabled) return diff --git a/apps/mobile/hooks/use-calendars.ts b/apps/mobile/hooks/use-calendars.ts new file mode 100644 index 000000000..fc64973f9 --- /dev/null +++ b/apps/mobile/hooks/use-calendars.ts @@ -0,0 +1,89 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { API } from '@orbit/shared/api' +import { calendarKeys } from '@orbit/shared/query' +import { + userCalendarsSchema, + type UserCalendar, +} from '@orbit/shared/types/calendar' +import { apiClient } from '@/lib/api-client' + +interface CalendarsQueryOptions { + enabled?: boolean +} + +async function fetchUserCalendars(): Promise { + const raw = await apiClient(API.calendar.calendars) + return userCalendarsSchema.parse(raw) +} + +/** + * Loads the user's Google calendars, each flagged with whether Orbit currently + * syncs it. Returns `[]` while disabled or empty so callers can render plainly. + */ +export function useCalendars(options?: CalendarsQueryOptions) { + return useQuery({ + queryKey: calendarKeys.calendars(), + queryFn: fetchUserCalendars, + enabled: options?.enabled ?? true, + staleTime: 30_000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: true, + }) +} + +interface SetSelectedCalendarsContext { + previous: UserCalendar[] | undefined +} + +/** + * Persists which calendars Orbit syncs. Optimistically flips `isSynced` for the + * toggled calendar in the cached list and rolls back if the request fails. + */ +export function useSetSelectedCalendars() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id, isSynced }) => { + const current = + queryClient.getQueryData(calendarKeys.calendars()) ?? [] + const calendarIds = current + .map((calendar) => + calendar.id === id ? { ...calendar, isSynced } : calendar, + ) + .filter((calendar) => calendar.isSynced) + .map((calendar) => calendar.id) + await apiClient(API.calendar.selectedCalendars, { + method: 'PUT', + body: JSON.stringify({ calendarIds }), + }) + }, + + onMutate: async ({ id, isSynced }) => { + await queryClient.cancelQueries({ queryKey: calendarKeys.calendars() }) + const previous = queryClient.getQueryData( + calendarKeys.calendars(), + ) + + if (previous) { + queryClient.setQueryData( + calendarKeys.calendars(), + previous.map((calendar) => + calendar.id === id ? { ...calendar, isSynced } : calendar, + ), + ) + } + + return { previous } + }, + + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(calendarKeys.calendars(), context.previous) + } + }, + + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: calendarKeys.calendars() }) + }, + }) +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e89cf2795..ddfb48d30 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -30,7 +30,7 @@ "@react-native-community/netinfo": "11.5.2", "@react-navigation/native": "^7.1.33", "@sentry/react-native": "^8.15.1", - "@siteed/audio-studio": "^3.2.0", + "@siteed/audio-studio": "3.2.0", "@supabase/supabase-js": "^2.102.1", "@tanstack/react-query": "^5.96.2", "date-fns": "^4.1.0", diff --git a/apps/web/__tests__/app/calendar-sync-page.test.tsx b/apps/web/__tests__/app/calendar-sync-page.test.tsx new file mode 100644 index 000000000..7d15dfd25 --- /dev/null +++ b/apps/web/__tests__/app/calendar-sync-page.test.tsx @@ -0,0 +1,139 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { createMockProfile } from '@orbit/shared/__tests__/factories' +import type { CalendarSyncEvent } from '@orbit/shared' + +const useCalendarEventsMock = vi.fn() + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => + params ? `${key}(${JSON.stringify(params)})` : key, +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock('next/link', () => ({ + default: ({ children }: { children: React.ReactNode }) => {children}, +})) + +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ profile: createMockProfile({ hasProAccess: true }) }), + useHasProAccess: () => true, +})) + +vi.mock('@/hooks/use-habits', () => ({ + useBulkCreateHabits: () => ({ mutate: vi.fn() }), +})) + +vi.mock('@/hooks/use-go-back-or-fallback', () => ({ + useGoBackOrFallback: () => vi.fn(), +})) + +vi.mock('@/hooks/use-calendar-auto-sync', () => ({ + useCalendarAutoSyncState: () => ({ data: { hasGoogleConnection: false }, isLoading: false }), + useCalendarSyncSuggestions: () => ({ data: [], isLoading: false, isError: false }), + useDismissCalendarSuggestion: () => ({ mutateAsync: vi.fn(), isPending: false }), + useRunCalendarSyncNow: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSetCalendarAutoSync: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + +vi.mock('@/hooks/use-calendar-events', () => ({ + useCalendarEvents: () => useCalendarEventsMock(), +})) + +vi.mock('@/hooks/use-calendars', () => ({ + useCalendars: () => ({ data: [], isLoading: false, isError: false }), + useSetSelectedCalendars: () => ({ mutateAsync: vi.fn() }), +})) + +vi.mock('@/components/ui/app-bar', () => ({ AppBar: () => null })) + +vi.mock('@/lib/supabase', () => ({ getSupabaseClient: () => ({ auth: { signInWithOAuth: vi.fn() } }) })) + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) + +import CalendarSyncPage from '@/app/(app)/calendar-sync/page' + +function buildEvents(count: number): CalendarSyncEvent[] { + return Array.from({ length: count }, (_value, index) => ({ + id: `ev-${index}`, + title: `Event ${index}`, + description: null, + startDate: '2026-07-01', + startTime: null, + endTime: null, + isRecurring: false, + recurrenceRule: null, + reminders: [], + calendarName: 'Work', + })) +} + +function countEventRows(): number { + return screen.getAllByText(/^Event \d+$/).length +} + +describe('CalendarSyncPage pagination', () => { + beforeEach(() => { + useCalendarEventsMock.mockReset() + }) + + it('renders only the first page of events and reveals more on demand', () => { + useCalendarEventsMock.mockReturnValue({ + data: { status: 'connected', events: buildEvents(45) }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + render() + + expect(countEventRows()).toBe(20) + expect( + screen.getByText('calendar.showingCount({"shown":20,"total":45})'), + ).toBeInTheDocument() + + fireEvent.click(screen.getByText('calendar.showMore')) + + expect(countEventRows()).toBe(40) + expect( + screen.getByText('calendar.showingCount({"shown":40,"total":45})'), + ).toBeInTheDocument() + + fireEvent.click(screen.getByText('calendar.showMore')) + + expect(countEventRows()).toBe(45) + expect(screen.queryByText('calendar.showMore')).not.toBeInTheDocument() + }) + + it('does not show the pager when events fit on one page', () => { + useCalendarEventsMock.mockReturnValue({ + data: { status: 'connected', events: buildEvents(8) }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + render() + + expect(countEventRows()).toBe(8) + expect(screen.queryByText('calendar.showMore')).not.toBeInTheDocument() + }) + + it('shows the source calendar name on each event row', () => { + useCalendarEventsMock.mockReturnValue({ + data: { status: 'connected', events: buildEvents(1) }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }) + + render() + + expect(screen.getByText('Work')).toBeInTheDocument() + }) +}) diff --git a/apps/web/__tests__/components/calendar/calendar-picker-section.test.tsx b/apps/web/__tests__/components/calendar/calendar-picker-section.test.tsx new file mode 100644 index 000000000..59112fdd5 --- /dev/null +++ b/apps/web/__tests__/components/calendar/calendar-picker-section.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import type { UserCalendar } from '@orbit/shared/types/calendar' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => { + if (params) return `${key}:${JSON.stringify(params)}` + return key + }, +})) + +const mutateAsync = vi.fn(() => Promise.resolve()) +const useCalendarsMock = vi.fn() + +vi.mock('@/hooks/use-calendars', () => ({ + useCalendars: () => useCalendarsMock(), + useSetSelectedCalendars: () => ({ mutateAsync }), +})) + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +import { CalendarPickerSection } from '@/app/(app)/calendar-sync/_components/calendar-picker-section' + +function buildCalendar(overrides: Partial = {}): UserCalendar { + return { + id: 'cal-1', + name: 'Personal', + accessRole: 'owner', + primary: true, + backgroundColor: '#7f46f7', + isSynced: true, + ...overrides, + } +} + +describe('CalendarPickerSection', () => { + beforeEach(() => { + mutateAsync.mockClear() + useCalendarsMock.mockReset() + }) + + it('renders nothing when disabled', () => { + useCalendarsMock.mockReturnValue({ data: undefined, isLoading: false, isError: false }) + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('renders a switch per calendar reflecting its synced state', () => { + useCalendarsMock.mockReturnValue({ + data: [ + buildCalendar({ id: 'cal-1', name: 'Personal', isSynced: true }), + buildCalendar({ id: 'cal-2', name: 'Work', primary: false, isSynced: false }), + ], + isLoading: false, + isError: false, + }) + + render() + + const switches = screen.getAllByRole('switch') + expect(switches).toHaveLength(2) + expect(switches[0]).toHaveAttribute('aria-checked', 'true') + expect(switches[1]).toHaveAttribute('aria-checked', 'false') + expect(screen.getByText('Personal')).toBeInTheDocument() + expect(screen.getByText('Work')).toBeInTheDocument() + }) + + it('persists the flipped synced value on toggle', () => { + useCalendarsMock.mockReturnValue({ + data: [buildCalendar({ id: 'cal-1', isSynced: true })], + isLoading: false, + isError: false, + }) + + render() + + fireEvent.click(screen.getByRole('switch')) + expect(mutateAsync).toHaveBeenCalledWith({ id: 'cal-1', isSynced: false }) + }) + + it('shows the loading state', () => { + useCalendarsMock.mockReturnValue({ data: undefined, isLoading: true, isError: false }) + render() + expect(screen.getByText('calendar.calendars.loading')).toBeInTheDocument() + }) + + it('shows the error state', () => { + useCalendarsMock.mockReturnValue({ data: undefined, isLoading: false, isError: true }) + render() + expect(screen.getByText('calendar.calendars.error')).toBeInTheDocument() + }) + + it('shows the empty state', () => { + useCalendarsMock.mockReturnValue({ data: [], isLoading: false, isError: false }) + render() + expect(screen.getByText('calendar.calendars.empty')).toBeInTheDocument() + }) +}) diff --git a/apps/web/__tests__/components/habits/habit-form-fields.test.tsx b/apps/web/__tests__/components/habits/habit-form-fields.test.tsx index 194402754..7fc51c996 100644 --- a/apps/web/__tests__/components/habits/habit-form-fields.test.tsx +++ b/apps/web/__tests__/components/habits/habit-form-fields.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, within } from '@testing-library/react' import React from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { HabitFormFields } from '@/components/habits/habit-form-fields' @@ -33,6 +33,12 @@ let mockHasProAccess = false vi.mock('@/hooks/use-profile', () => ({ useHasProAccess: () => mockHasProAccess, + useProfile: () => ({ + profile: { + uses24HourClock: true, + timeZone: 'America/Sao_Paulo', + }, + }), })) vi.mock('@/components/habits/habit-checklist', () => ({ @@ -152,6 +158,19 @@ function renderWithProviders(ui: React.ReactElement) { ) } +function pad(value: number): string { + return String(value).padStart(2, '0') +} + +function selectTimeInPicker(triggerLabel: string, hour24: number, minute: number) { + fireEvent.click(screen.getByLabelText(triggerLabel)) + const hours = screen.getByRole('listbox', { name: 'common.hours' }) + fireEvent.click(within(hours).getByRole('option', { name: pad(hour24) })) + const minutes = screen.getByRole('listbox', { name: 'common.minutes' }) + fireEvent.click(within(minutes).getByRole('option', { name: pad(minute) })) + fireEvent.click(screen.getByText('common.done')) +} + describe('HabitFormFields', () => { beforeEach(() => { @@ -455,9 +474,7 @@ describe('HabitFormFields', () => { />, ) - fireEvent.change(screen.getByLabelText('habits.form.dueTime'), { - target: { value: '15:58' }, - }) + selectTimeInPicker('habits.form.dueTime', 15, 58) expect(setValue).toHaveBeenCalledWith('dueTime', '15:58', { shouldDirty: true }) }) @@ -499,9 +516,7 @@ describe('HabitFormFields', () => { />, ) - fireEvent.change(screen.getByLabelText('habits.form.dueEndTime'), { - target: { value: '22:15' }, - }) + selectTimeInPicker('habits.form.dueEndTime', 22, 15) expect(setValue).toHaveBeenCalledWith('dueEndTime', '22:15', { shouldDirty: true }) }) @@ -848,8 +863,7 @@ describe('HabitFormFields', () => { onReminderTimesChange={vi.fn()} />, ) - const dueTimeInput = screen.getByLabelText('habits.form.dueTime') - fireEvent.change(dueTimeInput, { target: { value: '14:30' } }) + selectTimeInPicker('habits.form.dueTime', 14, 30) expect(setValue).toHaveBeenCalledWith('dueTime', '14:30', { shouldDirty: true }) }) diff --git a/apps/web/__tests__/hooks/use-calendars.test.ts b/apps/web/__tests__/hooks/use-calendars.test.ts new file mode 100644 index 000000000..5004d6df8 --- /dev/null +++ b/apps/web/__tests__/hooks/use-calendars.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { useCalendars, useSetSelectedCalendars } from '@/hooks/use-calendars' +import { calendarKeys } from '@orbit/shared/query' +import type { UserCalendar } from '@orbit/shared/types/calendar' + +const getUserCalendars = vi.fn() +const setSelectedCalendars = vi.fn() + +vi.mock('@/app/actions/calendar', () => ({ + getUserCalendars: () => getUserCalendars(), + setSelectedCalendars: (calendarIds: string[]) => setSelectedCalendars(calendarIds), +})) + +function createWrapper(queryClient?: QueryClient) { + const client = + queryClient ?? + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client }, children) + } + + return { Wrapper, client } +} + +function buildCalendar(overrides: Partial = {}): UserCalendar { + return { + id: 'cal-1', + name: 'Personal', + accessRole: 'owner', + primary: true, + backgroundColor: '#7f46f7', + isSynced: true, + ...overrides, + } +} + +describe('useCalendars', () => { + beforeEach(() => { + getUserCalendars.mockReset() + setSelectedCalendars.mockReset() + }) + + it('loads and parses the calendars from the action', async () => { + const calendars = [buildCalendar(), buildCalendar({ id: 'cal-2', name: 'Work', primary: false })] + getUserCalendars.mockResolvedValueOnce(calendars) + + const { Wrapper } = createWrapper() + const { result } = renderHook(() => useCalendars(), { wrapper: Wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toEqual(calendars) + expect(getUserCalendars).toHaveBeenCalledTimes(1) + }) + + it('does not fetch while disabled', () => { + const { Wrapper } = createWrapper() + renderHook(() => useCalendars({ enabled: false }), { wrapper: Wrapper }) + + expect(getUserCalendars).not.toHaveBeenCalled() + }) +}) + +describe('useSetSelectedCalendars', () => { + beforeEach(() => { + getUserCalendars.mockReset() + setSelectedCalendars.mockReset() + }) + + it('sends the ids of every synced calendar after applying the toggle', async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + client.setQueryData(calendarKeys.calendars(), [ + buildCalendar({ id: 'cal-1', isSynced: true }), + buildCalendar({ id: 'cal-2', isSynced: false }), + ]) + + setSelectedCalendars.mockResolvedValueOnce(undefined) + + const { Wrapper } = createWrapper(client) + const { result } = renderHook(() => useSetSelectedCalendars(), { wrapper: Wrapper }) + + await act(async () => { + await result.current.mutateAsync({ id: 'cal-2', isSynced: true }) + }) + + expect(setSelectedCalendars).toHaveBeenCalledWith(['cal-1', 'cal-2']) + }) + + it('optimistically flips isSynced for the toggled calendar', async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + client.setQueryData(calendarKeys.calendars(), [ + buildCalendar({ id: 'cal-1', isSynced: true }), + ]) + + let resolveRequest: () => void = () => {} + setSelectedCalendars.mockReturnValueOnce( + new Promise((resolve) => { + resolveRequest = resolve + }), + ) + + const { Wrapper } = createWrapper(client) + const { result } = renderHook(() => useSetSelectedCalendars(), { wrapper: Wrapper }) + + act(() => { + result.current.mutate({ id: 'cal-1', isSynced: false }) + }) + + await waitFor(() => { + const cached = client.getQueryData(calendarKeys.calendars()) + expect(cached?.[0]?.isSynced).toBe(false) + }) + + act(() => { + resolveRequest() + }) + }) + + it('rolls back the optimistic update when the request fails', async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + client.setQueryData(calendarKeys.calendars(), [ + buildCalendar({ id: 'cal-1', isSynced: true }), + ]) + + setSelectedCalendars.mockRejectedValueOnce(new Error('boom')) + + const { Wrapper } = createWrapper(client) + const { result } = renderHook(() => useSetSelectedCalendars(), { wrapper: Wrapper }) + + await act(async () => { + try { + await result.current.mutateAsync({ id: 'cal-1', isSynced: false }) + } catch { + void 0 + } + }) + + await waitFor(() => { + const cached = client.getQueryData(calendarKeys.calendars()) + expect(cached?.[0]?.isSynced).toBe(true) + }) + }) +}) diff --git a/apps/web/__tests__/pages/calendar-sync.test.tsx b/apps/web/__tests__/pages/calendar-sync.test.tsx index ff0b9b7eb..ab31009ee 100644 --- a/apps/web/__tests__/pages/calendar-sync.test.tsx +++ b/apps/web/__tests__/pages/calendar-sync.test.tsx @@ -121,6 +121,11 @@ vi.mock('@/hooks/use-calendar-auto-sync', () => ({ }), })) +vi.mock('@/hooks/use-calendars', () => ({ + useCalendars: () => ({ data: [], isLoading: false, isError: false }), + useSetSelectedCalendars: () => ({ mutateAsync: vi.fn() }), +})) + vi.mock('@/hooks/use-calendar-events', async () => { const { useEffect, useState } = await import('react') const { isCalendarSyncNotConnectedMessage } = await import('@orbit/shared/utils') diff --git a/apps/web/app/(app)/calendar-sync/_components/calendar-picker-section.tsx b/apps/web/app/(app)/calendar-sync/_components/calendar-picker-section.tsx new file mode 100644 index 000000000..be3fa2ef0 --- /dev/null +++ b/apps/web/app/(app)/calendar-sync/_components/calendar-picker-section.tsx @@ -0,0 +1,103 @@ +'use client' + +import { Loader2 } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { SectionLabel } from '@/components/ui/section-label' +import { SettingsDescription } from '@/components/ui/settings-description' +import { SettingsRow, Switch } from '@/components/ui/settings-row' +import { useCalendars, useSetSelectedCalendars } from '@/hooks/use-calendars' +import { getFriendlyErrorMessage } from '@orbit/shared/utils' +import { toast } from 'sonner' + +interface CalendarPickerSectionProps { + enabled: boolean +} + +/** + * "Calendars" settings section: one Switch row per Google calendar, toggling + * which calendars Orbit reads events from. Persists each toggle immediately. + * Renders nothing until enabled so it stays hidden when Google is not connected. + */ +export function CalendarPickerSection({ enabled }: Readonly) { + const t = useTranslations() + const { data: calendars, isLoading, isError } = useCalendars({ enabled }) + const setSelectedCalendars = useSetSelectedCalendars() + + if (!enabled) return null + + async function handleToggle(id: string, isSynced: boolean) { + try { + await setSelectedCalendars.mutateAsync({ id, isSynced }) + } catch (err: unknown) { + toast.error(getFriendlyErrorMessage(err, t, 'calendar.calendars.saveFailed', 'generic')) + } + } + + return ( + <> + {t('calendar.calendars.title')} + + {isLoading && ( +
+ + + {t('calendar.calendars.loading')} + +
+ )} + + {isError && !isLoading && ( +

+ {t('calendar.calendars.error')} +

+ )} + + {!isLoading && !isError && calendars && calendars.length === 0 && ( +

+ {t('calendar.calendars.empty')} +

+ )} + + {!isLoading && + !isError && + calendars?.map((calendar, index) => ( + + void handleToggle(calendar.id, !calendar.isSynced)} + ariaLabel={t('calendar.calendars.toggleLabel', { name: calendar.name })} + /> + + ))} + + {t('calendar.calendars.description')} + + ) +} diff --git a/apps/web/app/(app)/calendar-sync/page.tsx b/apps/web/app/(app)/calendar-sync/page.tsx index a7c23efd3..5920fe739 100644 --- a/apps/web/app/(app)/calendar-sync/page.tsx +++ b/apps/web/app/(app)/calendar-sync/page.tsx @@ -33,6 +33,7 @@ import { useSetCalendarAutoSync, } from '@/hooks/use-calendar-auto-sync' import { useCalendarEvents } from '@/hooks/use-calendar-events' +import { CalendarPickerSection } from './_components/calendar-picker-section' import { getSupabaseClient } from '@/lib/supabase' import type { CalendarSyncEvent, CalendarSyncSuggestion } from '@orbit/shared' import { @@ -51,6 +52,8 @@ interface ImportResult { habits: { id: string; title: string }[] } +const EVENTS_PAGE_SIZE = 20 + type Step = 'loading' | 'select' | 'importing' | 'done' | 'error' | 'not-connected' type WizardStage = 'browse' | 'importing' | 'done' | 'error' type CalendarEvent = CalendarSyncEvent @@ -275,8 +278,11 @@ export default function CalendarSyncPage() { const [errorMessage, setErrorMessage] = useState('') const [importResult, setImportResult] = useState(null) const [previousEventsKey, setPreviousEventsKey] = useState(null) + const [visibleCount, setVisibleCount] = useState(EVENTS_PAGE_SIZE) const eventsQuery = useCalendarEvents({ enabled: isProUser && !isReviewMode }) + const autoSyncStateQuery = useCalendarAutoSyncState({ enabled: isProUser }) + const googleConnected = autoSyncStateQuery.data?.hasGoogleConnection === true const suggestionsQuery = useCalendarSyncSuggestions({ enabled: isProUser && isReviewMode }) const dismissSuggestion = useDismissCalendarSuggestion() const suggestions: CalendarSyncSuggestion[] = useMemo( @@ -294,6 +300,7 @@ export default function CalendarSyncPage() { if (eventsKey !== previousEventsKey) { setPreviousEventsKey(eventsKey) setEvents(incomingEvents) + setVisibleCount(EVENTS_PAGE_SIZE) if (isReviewMode && previousEventsKey !== null) { setSelectedIds((prev) => { const next = new Set() @@ -454,6 +461,7 @@ export default function CalendarSyncPage() {
{hasProAccess && } + {hasProAccess && } {step === 'loading' && (
@@ -541,7 +549,7 @@ export default function CalendarSyncPage() {
- {events.map((event) => { + {events.slice(0, visibleCount).map((event) => { const suggestionId = isReviewMode ? findSuggestionIdForEvent(event.id) : null const selected = selectedIds.has(event.id) return ( @@ -630,6 +638,19 @@ export default function CalendarSyncPage() { {event.reminders.length} )} + {event.calendarName && ( + + {event.calendarName} + + )} {event.description && ( + {events.length > visibleCount && ( +
+ + + {t('calendar.showingCount', { + shown: Math.min(visibleCount, events.length), + total: events.length, + })} + +
+ )} +
) { + const t = useTranslations() return (
{title}
@@ -154,7 +155,11 @@ function HabitStatList({ color: tone === 'attention' ? 'var(--status-overdue-text)' : 'var(--fg-2)', }} > - {habit.completionRate}% + {habit.isOneTime + ? habit.completedCount > 0 + ? t('retrospective.completed') + : t('retrospective.notCompleted') + : `${habit.completionRate}%`}
))} diff --git a/apps/web/app/actions/calendar.ts b/apps/web/app/actions/calendar.ts index 57c5aa5c4..0f6e29638 100644 --- a/apps/web/app/actions/calendar.ts +++ b/apps/web/app/actions/calendar.ts @@ -3,6 +3,17 @@ import { API } from '@orbit/shared/api' import { serverAuthFetch } from '@/lib/server-fetch' +export async function getUserCalendars(): Promise { + return serverAuthFetch(API.calendar.calendars, { method: 'GET' }) +} + +export async function setSelectedCalendars(calendarIds: string[]): Promise { + return serverAuthFetch(API.calendar.selectedCalendars, { + method: 'PUT', + body: JSON.stringify({ calendarIds }), + }) +} + export async function setCalendarAutoSync(enabled: boolean): Promise { return serverAuthFetch(API.calendar.autoSync, { method: 'PUT', diff --git a/apps/web/components/habits/habit-form-fields.tsx b/apps/web/components/habits/habit-form-fields.tsx index 02a1c4771..287f915f9 100644 --- a/apps/web/components/habits/habit-form-fields.tsx +++ b/apps/web/components/habits/habit-form-fields.tsx @@ -739,7 +739,7 @@ function ScheduledReminderSection({ value={time} ariaLabel={t('habits.form.scheduledReminderTimePlaceholder')} placeholder={t('habits.form.scheduledReminderTimePlaceholder')} - className="form-input flex-1" + className="flex-1" onChange={setTime} /> + ) + })} +
+ ) +} + export function AppTimePicker({ id, value, @@ -26,41 +110,151 @@ export function AppTimePicker({ placeholder, ariaLabel, disabled = false, - className = 'form-input', + className = '', }: Readonly) { const t = useTranslations() const locale = useLocale() const generatedId = useId() + const dialogLabelId = useId() const inputId = id ?? generatedId - const displayValue = value ? formatLocaleTime(value, locale) : '' + const { profile } = useProfile() + const is24Hour = profile?.uses24HourClock ?? detectDefaultTimeFormat(locale) === '24h' + const containerRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [draft, setDraft] = useState({ hour24: 9, minute: 0 }) + + const displayValue = value + ? formatLocaleTime(value, locale, { hour: 'numeric', minute: '2-digit', hour12: !is24Hour }) + : '' const canClear = !disabled && !!value && !!onClear + const { hour12, period } = to12Hour(draft.hour24) + + const closePicker = useCallback(() => setIsOpen(false), []) + + const openPicker = useCallback(() => { + if (disabled) return + const parsed = parseTime(value) + const now = new Date() + setDraft(parsed ?? { hour24: now.getHours(), minute: now.getMinutes() }) + setIsOpen(true) + }, [disabled, value]) + + useOverlayEscape({ open: isOpen, onDismiss: closePicker }) + + useEffect(() => { + if (!isOpen) return + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + closePicker() + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isOpen, closePicker]) + + function applyDraft() { + onChange(`${pad(draft.hour24)}:${pad(draft.minute)}`) + closePicker() + } return ( -
- + + + {canClear && ( - ) : null} + )} + + {isOpen && ( + <> + ) } diff --git a/apps/web/hooks/use-calendars.ts b/apps/web/hooks/use-calendars.ts new file mode 100644 index 000000000..c87a16d7a --- /dev/null +++ b/apps/web/hooks/use-calendars.ts @@ -0,0 +1,87 @@ +'use client' + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { calendarKeys } from '@orbit/shared/query' +import { userCalendarsSchema } from '@orbit/shared/types/calendar' +import type { UserCalendar } from '@orbit/shared/types/calendar' +import { + getUserCalendars as getUserCalendarsAction, + setSelectedCalendars as setSelectedCalendarsAction, +} from '@/app/actions/calendar' + +interface CalendarsQueryOptions { + enabled?: boolean +} + +/** + * Loads the user's Google calendars, each flagged with whether Orbit currently + * syncs it. Returns `[]` while disabled or empty so callers can render plainly. + */ +export function useCalendars(options?: CalendarsQueryOptions) { + return useQuery({ + queryKey: calendarKeys.calendars(), + queryFn: async () => { + const raw = await getUserCalendarsAction() + return userCalendarsSchema.parse(raw) + }, + enabled: options?.enabled ?? true, + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: true, + }) +} + +interface SetSelectedCalendarsContext { + previous: UserCalendar[] | undefined +} + +/** + * Persists which calendars Orbit syncs. Optimistically flips `isSynced` for the + * toggled calendar in the cached list and rolls back if the request fails. + */ +export function useSetSelectedCalendars() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id, isSynced }) => { + const current = + queryClient.getQueryData(calendarKeys.calendars()) ?? [] + const selectedIds = current + .map((calendar) => + calendar.id === id ? { ...calendar, isSynced } : calendar, + ) + .filter((calendar) => calendar.isSynced) + .map((calendar) => calendar.id) + await setSelectedCalendarsAction(selectedIds) + }, + + onMutate: async ({ id, isSynced }) => { + await queryClient.cancelQueries({ queryKey: calendarKeys.calendars() }) + + const previous = queryClient.getQueryData( + calendarKeys.calendars(), + ) + + if (previous) { + queryClient.setQueryData( + calendarKeys.calendars(), + previous.map((calendar) => + calendar.id === id ? { ...calendar, isSynced } : calendar, + ), + ) + } + + return { previous } + }, + + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(calendarKeys.calendars(), context.previous) + } + }, + + onSettled: () => { + queryClient.invalidateQueries({ queryKey: calendarKeys.calendars() }) + }, + }) +} diff --git a/packages/shared/src/api/endpoints.ts b/packages/shared/src/api/endpoints.ts index d0ab9fa8e..28d7851d1 100644 --- a/packages/shared/src/api/endpoints.ts +++ b/packages/shared/src/api/endpoints.ts @@ -130,6 +130,8 @@ export const API = { calendar: { events: '/api/calendar/events', + calendars: '/api/calendar/calendars', + selectedCalendars: '/api/calendar/selected-calendars', dismiss: '/api/calendar/dismiss', autoSyncState: '/api/calendar/auto-sync/state', autoSync: '/api/calendar/auto-sync', diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index 64819f0f8..d62b01e05 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -134,6 +134,9 @@ "selectedDate": "Selected date: {date}", "selectTime": "Select time", "selectedTime": "Selected time: {time}", + "hours": "Hours", + "minutes": "Minutes", + "amPm": "AM/PM", "previousMonth": "Previous month", "nextMonth": "Next month", "continue": "Continue", @@ -602,6 +605,18 @@ "recurrenceYearly": "Yearly", "notConnectedTitle": "Connect Google Account", "notConnectedDesc": "Sign in with Google to import your calendar events as habits.", + "showMore": "Show more", + "showingCount": "Showing {shown} of {total}", + "calendars": { + "title": "Calendars", + "description": "Choose which Google calendars Orbit reads events from.", + "loading": "Loading your calendars...", + "empty": "No calendars found in your Google account.", + "error": "Could not load your calendars. Please try again.", + "primaryLabel": "Primary", + "toggleLabel": "Sync {name}", + "saveFailed": "Could not save your calendar selection. Please try again." + }, "autoSync": { "title": "Automatic sync", "description": "Orbit checks your Google Calendar for new events and notifies you. Habits are never created without your confirmation.", @@ -1067,6 +1082,8 @@ "weeklyTitle": "Weekly consistency", "topHabitsTitle": "Top habits", "needsAttentionTitle": "Needs attention", + "completed": "Completed", + "notCompleted": "Not completed", "sections": { "highlights": "Highlights", "missed": "Missed opportunities", diff --git a/packages/shared/src/i18n/pt-BR.json b/packages/shared/src/i18n/pt-BR.json index 693eb8cb8..35a798f5e 100644 --- a/packages/shared/src/i18n/pt-BR.json +++ b/packages/shared/src/i18n/pt-BR.json @@ -134,6 +134,9 @@ "selectedDate": "Data selecionada: {date}", "selectTime": "Selecionar horário", "selectedTime": "Horário selecionado: {time}", + "hours": "Horas", + "minutes": "Minutos", + "amPm": "AM/PM", "previousMonth": "Mês anterior", "nextMonth": "Próximo mês", "continue": "Continuar", @@ -602,6 +605,18 @@ "recurrenceYearly": "Anual", "notConnectedTitle": "Conectar conta Google", "notConnectedDesc": "Entre com o Google para importar os eventos do seu calendário como hábitos.", + "showMore": "Mostrar mais", + "showingCount": "Mostrando {shown} de {total}", + "calendars": { + "title": "Calendários", + "description": "Escolha de quais calendários do Google o Orbit lê os eventos.", + "loading": "Carregando seus calendários...", + "empty": "Nenhum calendário encontrado na sua conta Google.", + "error": "Não foi possível carregar seus calendários. Tente de novo.", + "primaryLabel": "Principal", + "toggleLabel": "Sincronizar {name}", + "saveFailed": "Não foi possível salvar a seleção de calendários. Tente de novo." + }, "autoSync": { "title": "Sincronização automática", "description": "O Orbit fica de olho em novos eventos do seu Google Calendar e te avisa. Nenhum hábito é criado sem você confirmar.", @@ -1067,6 +1082,8 @@ "weeklyTitle": "Consistência semanal", "topHabitsTitle": "Melhores hábitos", "needsAttentionTitle": "Precisa de atenção", + "completed": "Concluído", + "notCompleted": "Não concluído", "sections": { "highlights": "Destaques", "missed": "Oportunidades perdidas", diff --git a/packages/shared/src/query/keys.ts b/packages/shared/src/query/keys.ts index cafa58fd8..ca9ecdd4f 100644 --- a/packages/shared/src/query/keys.ts +++ b/packages/shared/src/query/keys.ts @@ -84,6 +84,7 @@ export const calendarKeys = { events: (from: string, to: string) => [...calendarKeys.all, 'events', from, to] as const, autoSyncState: () => [...calendarKeys.all, 'auto-sync-state'] as const, syncSuggestions: () => [...calendarKeys.all, 'sync-suggestions'] as const, + calendars: () => [...calendarKeys.all, 'calendars'] as const, } export const aiKeys = { diff --git a/packages/shared/src/types/calendar.ts b/packages/shared/src/types/calendar.ts index 2c751a875..255f78105 100644 --- a/packages/shared/src/types/calendar.ts +++ b/packages/shared/src/types/calendar.ts @@ -34,6 +34,8 @@ const calendarSyncEventSchema = z.object({ isRecurring: z.boolean(), recurrenceRule: z.string().nullable(), reminders: z.array(z.number()), + calendarId: z.string().optional(), + calendarName: z.string().optional(), }) export const calendarAutoSyncStatusSchema = z.enum(['Idle', 'ReconnectRequired', 'TransientError']) @@ -65,3 +67,22 @@ export const calendarAutoSyncResultSchema = z.object({ }) export type CalendarAutoSyncResult = z.infer + +export const userCalendarSchema = z.object({ + id: z.string(), + name: z.string(), + accessRole: z.string(), + primary: z.boolean(), + backgroundColor: z.string().nullable(), + isSynced: z.boolean(), +}) + +export type UserCalendar = z.infer + +export const userCalendarsSchema = z.array(userCalendarSchema) + +export const selectedCalendarsRequestSchema = z.object({ + calendarIds: z.array(z.string()), +}) + +export type SelectedCalendarsRequest = z.infer diff --git a/packages/shared/src/types/profile.ts b/packages/shared/src/types/profile.ts index 9658a45e0..d101c9edb 100644 --- a/packages/shared/src/types/profile.ts +++ b/packages/shared/src/types/profile.ts @@ -21,6 +21,7 @@ export const profileSchema = z.object({ name: z.string(), email: z.string(), timeZone: z.string().nullable(), + uses24HourClock: z.boolean().optional(), aiMemoryEnabled: z.boolean(), aiSummaryEnabled: z.boolean(), hasCompletedOnboarding: z.boolean(), diff --git a/packages/shared/src/utils/calendar-sync.ts b/packages/shared/src/utils/calendar-sync.ts index 50b1c04de..346130733 100644 --- a/packages/shared/src/utils/calendar-sync.ts +++ b/packages/shared/src/utils/calendar-sync.ts @@ -12,6 +12,8 @@ export interface CalendarSyncEvent { isRecurring: boolean recurrenceRule: string | null reminders: number[] + calendarId?: string + calendarName?: string } export interface CalendarSyncParsedRecurrence { diff --git a/packages/shared/src/utils/retrospective.ts b/packages/shared/src/utils/retrospective.ts index edd43f47f..43d581798 100644 --- a/packages/shared/src/utils/retrospective.ts +++ b/packages/shared/src/utils/retrospective.ts @@ -16,6 +16,7 @@ export interface RetrospectiveHabitStat { completionRate: number completedCount: number scheduledCount: number + isOneTime?: boolean } export interface RetrospectiveMetrics {