diff --git a/.gitignore b/.gitignore index 361f6b7b05..6a83546f87 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,5 @@ apps/mobile/ios/ npm-debug.log* yarn-debug.log* yarn-error.log* + +.claude/sweeps/ diff --git a/CLAUDE.md b/CLAUDE.md index 94a5dac32a..77f20c00c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Orbit -Personal habit tracker. Turborepo monorepo: `apps/web` (Next.js 15) + `apps/mobile` (Expo SDK 55) + `packages/shared`. +Personal habit tracker. Turborepo monorepo: `apps/web` (Next.js 16) + `apps/mobile` (Expo SDK 55) + `packages/shared`. Cross-cutting rules only live here. Each workspace has a scoped `CLAUDE.md` Claude auto-loads when touching files in that subtree. Read them before writing code in those areas. diff --git a/DESIGN.md b/DESIGN.md index 9a32f36687..c1fd38676a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -30,9 +30,10 @@ Canonical CSS lives in `apps/web/app/globals.css`; the mobile equivalent is `cre --hairline rgba(248,250,252,0.10) --hairline-strong rgba(248,250,252,0.18) --fg-1 rgb(248,250,252) --fg-2 rgb(202,213,226) --fg-3 rgb(144,161,185) --fg-4 rgb(98,116,142) ---fg-on-primary rgb(255,255,255) +--fg-on-primary rgb(255,255,255) /* scheme×mode-resolved; see hand-tune log */ --status-done var(--primary) · empty rgba(248,250,252,0.22) · skip rgb(144,161,185) --status-overdue rgb(254,154,0) · bad rgb(251,44,54) · frozen rgb(0,211,243) +--status-overdue-text rgb(254,154,0) · bad-text rgb(251,44,54) /* = base; AA ≥4.5 on canvas */ --selection-bg rgba(var(--primary-rgb),0.32) --gradient-header linear-gradient(180deg, rgb(34,9,79) 0%, rgba(2,6,24,0) 100%) /* violet-950 → transparent */ ``` @@ -44,6 +45,7 @@ Canonical CSS lives in `apps/web/app/globals.css`; the mobile equivalent is `cre --hairline rgba(2,6,24,0.08) · --hairline-strong rgba(2,6,24,0.16) --fg-1 rgb(15,23,43) · --fg-2 rgb(49,65,88) · --fg-3 rgb(98,116,142) · --fg-4 rgb(144,161,185) --status-empty rgba(2,6,24,0.18) · skip rgb(98,116,142) · overdue rgb(225,113,0) · bad rgb(231,0,11) · frozen rgb(0,146,184) +--status-overdue-text rgb(180,91,0) · bad-text rgb(231,0,11) /* overdue darkened to AA; bad = base */ --selection-bg rgba(var(--primary-rgb),0.18) --gradient-header linear-gradient(180deg, rgb(233,212,255) 0%, rgba(248,250,252,0) 100%) ``` @@ -87,6 +89,7 @@ Hand-tune log (rule 3): - **orange:** Δ-derived hue 18.36° read rose-tinted; nudged to 32 (warm brown-black canvas). - **blue / cyan:** canvas chroma gamut-clamped at the locked L (scaleBg 0.6226 / 0.5167); cyan fg ramp also clamped (scaleFg 0.843). Values are the sRGB gamut ceiling ×0.985 so web CSS and the TS pipeline resolve identical bytes. - **Light gradient stops:** purple keeps the handoff's +21.71° lilac rotation from the accent hue (byte-exact `#e9d4ff`); the rotation misreads on rose (peach) and cyan (periwinkle), so all non-purple schemes use their light accent hue directly at the locked L/C, chroma gamut-clamped. +- **fg-on-primary (scheme×mode):** white fails 4.5:1 AA on green/orange/cyan (both modes) and blue/rose (dark), so those eight resolve to the locked canvas ink rgb(2,6,24) (≥5.36:1 on every flipped accent); white stays on purple (both modes), blue light, rose light (4.53–7.03:1). Data in `color-schemes.ts` `fgOnPrimary`, mirrored per scheme×mode in `globals.css`. ## Type roles @@ -138,11 +141,12 @@ Exact dimensions in `design/handoff/orbit/project/orbit-kit.jsx`. Web in `apps/w - Opaque white cards on light — never translucent. - Inset 1px hairline rings instead of borders (web `box-shadow: inset 0 0 0 1px var(--hairline)`; RN border with the same color reads equivalently). - No opaque card-on-card on dark; surfaces stack by alpha, not by lighter hexes. -- Gradient-header usage: profile, paywall, auth, onboarding, celebrations, Início. +- Gradient-header usage: profile, paywall, auth, onboarding, celebrations, Início, calendar, chat. ## Status, emoji, icons - done = primary · overdue = amber · bad/danger = red · frozen = cyan · skip/empty = neutral (exact values in the token spec). +- Dots, rings and icons use the base status tokens; status-colored TEXT uses `--status-overdue-text` / `--status-bad-text` (mobile `statusOverdueText` / `statusBadText`) — AA ≥4.5 on the mode canvas (only light overdue differs from base). - Emoji deliberately, where the artboards show them: habit emoji wells, stat tiles, streak flame, celebration heroes. Never as decoration elsewhere. - Lucide 22px, strokeWidth 1.8 (2.2 active/emphasis). @@ -150,6 +154,8 @@ Exact dimensions in `design/handoff/orbit/project/orbit-kit.jsx`. Web in `apps/w Unchanged philosophy: transform + opacity only, durations 160/220/280, `--ease-standard` for state changes, `--ease-out` for entrances. Presets in `packages/shared/src/theme/motion.ts`. Respect `prefers-reduced-motion`. +SVG `stroke-dashoffset` ring/progress sweeps are sanctioned (paint-only property, no layout; the `status-sweep` keyframe is the precedent). + ## Bans - No raw `--slate-*` references in app code — semantic tokens only (the raw-slate mapping rule above). diff --git a/apps/mobile/CLAUDE.md b/apps/mobile/CLAUDE.md index 569c20636f..4fb21b68d4 100644 --- a/apps/mobile/CLAUDE.md +++ b/apps/mobile/CLAUDE.md @@ -7,6 +7,7 @@ Mobile client (Android only — no iOS app exists). Direct calls to `orbit-api` - **Named exports only.** `kebab-case` filenames, `PascalCase` components. - **Zero `any`** — root `CLAUDE.md` Code Standards rule 3. - **No `console.log`** in production code — rule 4. +- **No narration comments** (lint-enforced `local/no-comments`) — rule 5. Only `/** */` JSDoc on exports, WHY notes linking an upstream URL, and tooling directives. - **All user-facing strings through i18n** (react-i18next). Read locale via `i18n.language`, not `useDeviceLocale`. Never hardcode display text. - **All mutations through `apiClient`** (`lib/api-client.ts`). Never call `fetch` directly. - **Imports:** types from `@orbit/shared/types`, query keys from `@orbit/shared/query`, endpoints from `@orbit/shared/api`. @@ -20,7 +21,7 @@ Mobile client (Android only — no iOS app exists). Direct calls to `orbit-api` | Theme provider | `lib/theme-provider.tsx` | | API client | `lib/api-client.ts` | | Offline queue (SQLite) | `lib/queued-api-mutation.ts` | -| Auth storage | SecureStore — see `lib/auth.ts` | +| Auth storage | SecureStore — see `lib/secure-store.ts` | | TanStack Query hooks | `hooks/use-*.ts` | | Zustand stores | `stores/*-store.ts` | | Primitives (mirror of `apps/web/components/`) | `components/*` | @@ -68,11 +69,11 @@ React 19 forbids several patterns that worked before. The compiler will fail the | Want to add… | Look at… | |---|---| -| New screen | `app/(tabs)/today.tsx` | +| New screen | `app/(tabs)/index.tsx` | | New API call hook | `hooks/use-habits.ts` | | New Zustand store | `stores/ui-store.ts` | | New primitive | `components/ui/section-label.tsx` | -| Offline-safe mutation | `hooks/use-update-habit.ts` (search for `performQueuedApiMutation`) | +| Offline-safe mutation | `hooks/use-habits.ts` (search for `performQueuedApiMutation`) | | Theme-aware component | `components/ui/theme-toggle.tsx` | ## Testing diff --git a/apps/mobile/__tests__/components/chat/breakdown-suggestion.test.tsx b/apps/mobile/__tests__/components/chat/breakdown-suggestion.test.tsx new file mode 100644 index 0000000000..be13b17ff7 --- /dev/null +++ b/apps/mobile/__tests__/components/chat/breakdown-suggestion.test.tsx @@ -0,0 +1,150 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SuggestedSubHabit } from '@orbit/shared/types/chat' +import { BreakdownSuggestion } from '@/components/chat/breakdown-suggestion' + +const bulkCreateMock = vi.fn().mockResolvedValue(undefined) + +vi.mock('@/hooks/use-habits', () => ({ + useBulkCreateHabits: () => ({ + mutateAsync: bulkCreateMock, + isPending: false, + }), +})) + +vi.mock('@/lib/theme', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createTokensV2: () => new Proxy({}, { get: () => '#111111' }), + tintFromPrimary: () => 'rgba(127,70,247,0.1)', + } +}) + +vi.mock('lucide-react-native', () => { + const createIcon = (name: string) => (props: Record) => + React.createElement(name, props) + return { + Check: createIcon('Check'), + X: createIcon('X'), + Plus: createIcon('Plus'), + } +}) + +const TestRenderer = require('react-test-renderer') + +const subHabits: SuggestedSubHabit[] = [ + { title: 'Push-ups', description: '3 sets of 10' }, + { title: 'Squats', description: '3 sets of 15' }, +] + +const defaultProps = { + parentName: 'Exercise', + subHabits, + onConfirmed: vi.fn(), + onCancelled: vi.fn(), +} + +function renderBreakdown() { + let tree: { root: { findAll: (predicate: (node: { props?: Record; type?: unknown }) => boolean) => { props: Record }[] } } | undefined + TestRenderer.act(() => { + tree = TestRenderer.create() + }) + if (!tree) throw new Error('failed to render') + return tree +} + +function findByAccessibilityLabel(tree: ReturnType, label: string) { + return tree.root.findAll( + (node) => node.props?.accessibilityLabel === label, + ) +} + +function findInputByAccessibilityLabel(tree: ReturnType, label: string) { + return tree.root.findAll( + (node) => + node.props?.accessibilityLabel === label && + typeof node.props?.onChangeText === 'function', + ) +} + +function collectText(children: unknown): string { + if (children == null || typeof children === 'boolean') return '' + if (typeof children === 'string' || typeof children === 'number') { + return String(children) + } + if (Array.isArray(children)) { + return children.map(collectText).join(' ') + } + if (typeof children === 'object' && 'props' in (children as { props?: unknown })) { + return collectText((children as { props: { children?: unknown } }).props.children) + } + return '' +} + +function pressableContainingText( + tree: ReturnType, + needle: string, +) { + return tree.root + .findAll( + (node) => + typeof node.props?.onPress === 'function' && typeof node.type !== 'string', + ) + .find((node) => collectText(node.props.children).includes(needle)) +} + +beforeEach(() => { + defaultProps.onConfirmed.mockClear() + defaultProps.onCancelled.mockClear() + bulkCreateMock.mockReset() + bulkCreateMock.mockResolvedValue(undefined) +}) + +describe('BreakdownSuggestion (mobile)', () => { + it('labels each remove button with the habit name for screen readers', () => { + const tree = renderBreakdown() + expect( + findByAccessibilityLabel(tree, 'habits.breakdown.removeHabit:{"name":"Push-ups"}').length, + ).toBeGreaterThanOrEqual(1) + }) + + it('reveals the frequency-quantity editor only after a recurrence unit is chosen', () => { + const tree = renderBreakdown() + expect( + findInputByAccessibilityLabel(tree, 'habits.breakdown.frequencyQuantityLabel').length, + ).toBe(0) + + const chipWithWeekly = pressableContainingText(tree, 'habits.filter.weekly') + expect(chipWithWeekly).toBeDefined() + TestRenderer.act(() => { + ;(chipWithWeekly!.props.onPress as () => void)() + }) + + expect( + findInputByAccessibilityLabel(tree, 'habits.breakdown.frequencyQuantityLabel').length, + ).toBeGreaterThanOrEqual(1) + }) + + it('shows the friendly i18n fallback instead of a raw Error message on failure', async () => { + bulkCreateMock.mockRejectedValue(new Error('ECONNREFUSED 127.0.0.1:5432')) + const tree = renderBreakdown() + + const submit = pressableContainingText(tree, 'habits.breakdown.createCount') + expect(submit).toBeDefined() + await TestRenderer.act(async () => { + ;(submit!.props.onPress as () => void)() + await Promise.resolve() + await Promise.resolve() + }) + + const errorNodes = tree.root.findAll((node) => + collectText(node.props?.children).includes('errors.bulkCreateHabits'), + ) + expect(errorNodes.length).toBeGreaterThanOrEqual(1) + const rawNodes = tree.root.findAll((node) => + collectText(node.props?.children).includes('ECONNREFUSED'), + ) + expect(rawNodes.length).toBe(0) + }) +}) diff --git a/apps/mobile/__tests__/components/create-api-key-modal.test.tsx b/apps/mobile/__tests__/components/create-api-key-modal.test.tsx new file mode 100644 index 0000000000..1c3d831582 --- /dev/null +++ b/apps/mobile/__tests__/components/create-api-key-modal.test.tsx @@ -0,0 +1,197 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { + ApiKeyCreateRequest, + ApiKeyCreateResponse, +} from '@orbit/shared/types/api-key' +import { CreateApiKeyModal } from '@/components/ui/create-api-key-modal' + +const TestRenderer = require('react-test-renderer') + +type CreateKeyFn = ( + request: ApiKeyCreateRequest, +) => Promise + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en' }, + }), +})) + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})) + +vi.mock('expo-clipboard', () => ({ + setStringAsync: vi.fn(async () => {}), +})) + +vi.mock('@/lib/use-app-theme', () => ({ + useAppTheme: () => ({ currentScheme: 'purple', currentTheme: 'dark' }), +})) + +vi.mock('@/lib/theme', () => ({ + createTokensV2: () => new Proxy({}, { get: () => '#111111' }), +})) + +vi.mock('@/components/bottom-sheet-modal', () => ({ + BottomSheetModal: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ? React.createElement(React.Fragment, null, children) : null, +})) + +vi.mock('@/components/ui/keyboard-aware-scroll-view', () => ({ + KeyboardAwareBottomSheetScrollView: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), +})) + +vi.mock('@/components/ui/bottom-sheet-app-text-input', () => ({ + BottomSheetAppTextInput: (props: Record) => + React.createElement('TextInput', props), +})) + +vi.mock('@/components/ui/chip', () => ({ + Chip: ({ children }: { children: React.ReactNode }) => + React.createElement('Chip', null, children), +})) + +vi.mock('@/components/ui/pill-button', () => ({ + PillButton: ({ children, onPress, accessibilityLabel }: Record) => + React.createElement( + 'PillButton', + { onPress, accessibilityLabel }, + children as React.ReactNode, + ), +})) + +vi.mock('@/components/ui/settings-row', () => ({ + Switch: () => null, +})) + +function findByAccessibilityLabel( + tree: { root: { findAll: (predicate: (node: any) => boolean) => any[] } }, + label: string, +) { + return tree.root.findAll((node: any) => node.props?.accessibilityLabel === label) +} + +function flattenText(node: unknown): string { + if (node == null) return '' + if (typeof node === 'string' || typeof node === 'number') return String(node) + if (Array.isArray(node)) return node.map(flattenText).join('') + if (typeof node === 'object' && 'props' in (node as Record)) { + return flattenText((node as { props: { children?: unknown } }).props.children) + } + return '' +} + +function screenTexts(tree: { root: { findAllByType: (type: string) => any[] } }): string[] { + return tree.root.findAllByType('Text').map((node) => flattenText(node.props.children)) +} + +async function renderModal(onCreateKey: CreateKeyFn) { + let tree: ReturnType + await TestRenderer.act(async () => { + tree = TestRenderer.create( + , + ) + await Promise.resolve() + }) + return tree +} + +function getNameInput(tree: { root: { findAll: (p: (n: any) => boolean) => any[] } }) { + return findByAccessibilityLabel(tree, 'orbitMcp.keyName')[0] +} + +function getExpiryInput(tree: { root: { findAll: (p: (n: any) => boolean) => any[] } }) { + return findByAccessibilityLabel(tree, 'orbitMcp.expiresAtLabel')[0] +} + +function getSubmitButton(tree: { root: { findAll: (p: (n: any) => boolean) => any[] } }) { + return findByAccessibilityLabel(tree, 'orbitMcp.createKey')[0] +} + +describe('CreateApiKeyModal (mobile) — API-key expiry parity', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('parses a valid expiry as a UTC timestamp without timezone shifting', async () => { + const onCreateKey = vi.fn().mockResolvedValue({ + id: 'key-1', + key: 'sk-test-123', + name: 'My Key', + keyPrefix: 'sk-test', + scopes: [], + isReadOnly: false, + expiresAtUtc: '2026-04-20T18:45:00.000Z', + createdAtUtc: '2025-01-01T00:00:00Z', + lastUsedAtUtc: null, + isRevoked: false, + }) + + const tree = await renderModal(onCreateKey) + + await TestRenderer.act(async () => { + getNameInput(tree).props.onChangeText('My Key') + getExpiryInput(tree).props.onChangeText('2026-04-20T18:45') + await Promise.resolve() + }) + + await TestRenderer.act(async () => { + getSubmitButton(tree).props.onPress() + await Promise.resolve() + }) + + expect(onCreateKey).toHaveBeenCalledWith({ + name: 'My Key', + scopes: undefined, + isReadOnly: false, + expiresAtUtc: '2026-04-20T18:45:00.000Z', + }) + }) + + it('rejects a malformed expiry with the shared invalid-expiry key', async () => { + const onCreateKey = vi.fn() + const tree = await renderModal(onCreateKey) + + await TestRenderer.act(async () => { + getNameInput(tree).props.onChangeText('My Key') + getExpiryInput(tree).props.onChangeText('not-a-date') + await Promise.resolve() + }) + + await TestRenderer.act(async () => { + getSubmitButton(tree).props.onPress() + await Promise.resolve() + }) + + expect(onCreateKey).not.toHaveBeenCalled() + expect(screenTexts(tree)).toContain('orbitMcp.invalidExpiry') + }) + + it('rejects a name longer than the shared maximum', async () => { + const onCreateKey = vi.fn() + const tree = await renderModal(onCreateKey) + + await TestRenderer.act(async () => { + getNameInput(tree).props.onChangeText('A'.repeat(51)) + await Promise.resolve() + }) + + await TestRenderer.act(async () => { + getSubmitButton(tree).props.onPress() + await Promise.resolve() + }) + + expect(onCreateKey).not.toHaveBeenCalled() + expect(screenTexts(tree)).toContain('orbitMcp.keyNameMaxLength') + }) +}) diff --git a/apps/mobile/__tests__/components/habits/create-habit-modal.test.tsx b/apps/mobile/__tests__/components/habits/create-habit-modal.test.tsx index 9ffdd00de8..09c2a8f784 100644 --- a/apps/mobile/__tests__/components/habits/create-habit-modal.test.tsx +++ b/apps/mobile/__tests__/components/habits/create-habit-modal.test.tsx @@ -9,6 +9,7 @@ const TestRenderer = require('react-test-renderer') const useWatchMock = vi.fn() let mockHasProAccess = false +let mockFormIsValid = false vi.mock('react-hook-form', () => ({ useWatch: (args: { control: { values: Record }; name: string }) => @@ -47,7 +48,7 @@ vi.mock('@/hooks/use-habit-form', () => ({ reset: vi.fn(), setValue: vi.fn(), getValues: vi.fn(() => ({})), - formState: { isDirty: false, errors: {} }, + formState: { isDirty: false, errors: {}, get isValid() { return mockFormIsValid } }, }, isOneTime: false, isGeneral: false, @@ -152,6 +153,7 @@ describe('CreateHabitModal (mobile)', () => { beforeEach(() => { vi.clearAllMocks() mockHasProAccess = false + mockFormIsValid = false useWatchMock.mockImplementation(({ name }: { name: string }) => { switch (name) { case 'dueTime': @@ -200,30 +202,29 @@ describe('CreateHabitModal (mobile)', () => { ).toHaveLength(1) }) - it('disables the submit button until the title has content', () => { - const tree = renderModal() - - const findSubmit = () => - tree.root.findAll( - (node: any) => - node.type === 'Pressable' && - node.props.accessibilityRole === 'button' && - node.findAll( - (child: any) => - child.type === 'Text' && - child.props.children === 'habits.createHabit', - ).length > 0, - )[0] - - expect(findSubmit().props.disabled).toBe(true) - - const fields = tree.root.findAll( - (node: any) => node.type === 'HabitFormFields', + const findSubmit = (root: { + findAll: (predicate: (node: any) => boolean) => any[] + }) => + root.findAll( + (node: any) => + node.type === 'Pressable' && + node.props.accessibilityRole === 'button' && + node.findAll( + (child: any) => + child.type === 'Text' && + child.props.children === 'habits.createHabit', + ).length > 0, )[0] - TestRenderer.act(() => { - fields.props.onTitlePresenceChange(true) - }) - expect(findSubmit().props.disabled).toBe(false) + it('disables the submit button when the shared form schema is invalid', () => { + mockFormIsValid = false + const tree = renderModal() + expect(findSubmit(tree.root).props.disabled).toBe(true) + }) + + it('enables the submit button when the shared form schema is valid', () => { + mockFormIsValid = true + const tree = renderModal() + expect(findSubmit(tree.root).props.disabled).toBe(false) }) }) diff --git a/apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx b/apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx index 320da71cc8..21003e596e 100644 --- a/apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx +++ b/apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx @@ -450,10 +450,9 @@ describe('HabitFormFields (mobile)', () => { expect(hasText('habits.form.tags')).toBe(true) }) - it('reports title presence to onTitlePresenceChange as the title is typed and cleared', async () => { + it('commits the title to the form as it is typed so the shared schema gates submit', async () => { const formHelpers = createMockFormHelpers({ title: '' }) const tags = createMockTags() - const onTitlePresenceChange = vi.fn() let tree: any await TestRenderer.act(async () => { @@ -466,7 +465,6 @@ describe('HabitFormFields (mobile)', () => { onToggleGoal={vi.fn()} reminderTimes={[]} onReminderTimesChange={vi.fn()} - onTitlePresenceChange={onTitlePresenceChange} />, ) }) @@ -481,11 +479,8 @@ describe('HabitFormFields (mobile)', () => { await TestRenderer.act(async () => { titleInput.props.onChangeText('Read a book') }) - expect(onTitlePresenceChange).toHaveBeenLastCalledWith(true) - - await TestRenderer.act(async () => { - titleInput.props.onChangeText(' ') + expect(formHelpers.form.setValue).toHaveBeenLastCalledWith('title', 'Read a book', { + shouldDirty: true, }) - expect(onTitlePresenceChange).toHaveBeenLastCalledWith(false) }) }) diff --git a/apps/mobile/__tests__/components/habits/habit-list.test.tsx b/apps/mobile/__tests__/components/habits/habit-list.test.tsx index 7d1c46cd31..9171a85bf0 100644 --- a/apps/mobile/__tests__/components/habits/habit-list.test.tsx +++ b/apps/mobile/__tests__/components/habits/habit-list.test.tsx @@ -1328,10 +1328,14 @@ describe('HabitList', () => { }) const nonLoggableDot = nonLoggableTree.root.find( - (node: any) => node.props?.accessibilityLabel === 'habits.logHabit', + (node: any) => + typeof node.props?.accessibilityLabel === 'string' && + node.props.accessibilityLabel.includes('habits.logHabit'), ) const loggableDot = loggableTree.root.find( - (node: any) => node.props?.accessibilityLabel === 'habits.logHabit', + (node: any) => + typeof node.props?.accessibilityLabel === 'string' && + node.props.accessibilityLabel.includes('habits.logHabit'), ) expect(nonLoggableDot.props.disabled).toBe(true) diff --git a/apps/mobile/__tests__/components/navigation/notification-bell.test.tsx b/apps/mobile/__tests__/components/navigation/notification-bell.test.tsx new file mode 100644 index 0000000000..ce1e7c5503 --- /dev/null +++ b/apps/mobile/__tests__/components/navigation/notification-bell.test.tsx @@ -0,0 +1,149 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockNotification } from '@orbit/shared/__tests__/factories' +import type { NotificationItem } from '@orbit/shared/types/notification' + +import { NotificationBell } from '@/components/navigation/notification-bell' + +const TestRenderer = require('react-test-renderer') + +const mocks = vi.hoisted(() => ({ + notifications: [] as NotificationItem[], + isLoading: false, +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }), +})) + +vi.mock('@/hooks/use-notifications', () => ({ + useNotifications: () => ({ + notifications: mocks.notifications, + isLoading: mocks.isLoading, + }), + useMarkNotificationRead: () => ({ mutate: vi.fn() }), + useMarkAllNotificationsRead: () => ({ mutate: vi.fn() }), + useDeleteNotification: () => ({ mutate: vi.fn() }), + useDeleteAllNotifications: () => ({ mutate: vi.fn() }), +})) + +vi.mock('@/hooks/use-app-toast', () => ({ + useAppToast: () => ({ showQueued: vi.fn(), showError: vi.fn() }), +})) + +const EMPTY_PENDING_DELETES: string[] = [] +const noopUnsubscribe = () => {} + +vi.mock('@/lib/pending-notification-deletes', () => ({ + subscribePendingNotificationDeleteIds: () => noopUnsubscribe, + getPendingNotificationDeleteIdsSnapshot: () => EMPTY_PENDING_DELETES, + cancelPendingNotificationDelete: vi.fn(), + queuePendingNotificationDelete: vi.fn(() => true), +})) + +vi.mock('@/lib/use-app-theme', () => ({ + useAppTheme: () => ({ currentScheme: 'purple', currentTheme: 'dark' }), +})) + +vi.mock('@/lib/theme', () => ({ + createTokensV2: () => new Proxy({}, { get: () => '#111111' }), + tintFromPrimary: () => 'rgba(127,70,247,0.06)', +})) + +vi.mock('@/components/bottom-sheet-modal', () => ({ + BottomSheetModal: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ? React.createElement('Sheet', null, children) : null, +})) + +vi.mock('@/components/ui/drawer-content-inset', () => ({ + withDrawerContentInset: (style: unknown) => style, +})) + +vi.mock('@/components/navigation/notification-detail-modal', () => ({ + NotificationDetailModal: () => null, +})) + +vi.mock('@/components/ui/confirm-dialog', () => ({ + ConfirmDialog: () => null, +})) + +type FlatListNode = { + props: { + data: NotificationItem[] + keyExtractor: (item: NotificationItem) => string + renderItem: (info: { item: NotificationItem; index: number }) => React.ReactElement + ListEmptyComponent?: React.ReactNode + ListHeaderComponent?: React.ReactNode + } +} + +function render() { + let tree: { + root: { + findByType: (type: string) => { props: { onPress?: () => void } } + findAllByType: (type: string) => FlatListNode[] + } + } | null = null + TestRenderer.act(() => { + tree = TestRenderer.create(React.createElement(NotificationBell)) + }) + return tree! +} + +function openSheetAndGetList(tree: ReturnType): FlatListNode { + const bell = tree.root.findByType('Pressable') + TestRenderer.act(() => { + bell.props.onPress?.() + }) + const lists = tree.root.findAllByType('FlatList') + if (lists.length !== 1) { + throw new Error(`expected exactly one FlatList, found ${lists.length}`) + } + return lists[0]! +} + +function findLabel(node: unknown, label: string): boolean { + if (!node || typeof node !== 'object') return false + const element = node as { props?: Record } + if (element.props?.accessibilityLabel === label) return true + const children = element.props?.children + if (Array.isArray(children)) return children.some((child) => findLabel(child, label)) + return findLabel(children, label) +} + +beforeEach(() => { + mocks.notifications = [] + mocks.isLoading = false +}) + +describe('NotificationBell list rendering', () => { + it('renders notifications through a FlatList instead of a ScrollView', () => { + mocks.notifications = [ + createMockNotification({ id: 'n-1', title: 'Streak saved' }), + createMockNotification({ id: 'n-2', title: 'New achievement' }), + ] + + const tree = render() + expect(tree.root.findAllByType('ScrollView')).toHaveLength(0) + + const list = openSheetAndGetList(tree) + expect(list.props.data).toHaveLength(2) + expect(list.props.keyExtractor(mocks.notifications[0]!)).toBe('n-1') + }) + + it('renders each row via renderItem with the notification title as its label', () => { + mocks.notifications = [createMockNotification({ id: 'n-1', title: 'Streak saved' })] + + const list = openSheetAndGetList(render()) + const row = list.props.renderItem({ item: mocks.notifications[0]!, index: 0 }) + expect(findLabel(row, 'Streak saved')).toBe(true) + }) + + it('passes an empty data set and an empty-state component when there are no notifications', () => { + mocks.notifications = [] + + const list = openSheetAndGetList(render()) + expect(list.props.data).toHaveLength(0) + expect(list.props.ListEmptyComponent).toBeTruthy() + }) +}) diff --git a/apps/mobile/__tests__/components/onboarding/calendar-import-prompt.test.tsx b/apps/mobile/__tests__/components/onboarding/calendar-import-prompt.test.tsx new file mode 100644 index 0000000000..4f0a1cb14d --- /dev/null +++ b/apps/mobile/__tests__/components/onboarding/calendar-import-prompt.test.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { CalendarImportPrompt } from '@/components/onboarding/calendar-import-prompt' + +const TestRenderer = require('react-test-renderer') + +const mocks = vi.hoisted(() => ({ + profile: undefined as Record | undefined, + pathname: '/', +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }), +})) + +vi.mock('expo-router', () => ({ + usePathname: () => mocks.pathname, + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})) + +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ profile: mocks.profile, invalidate: vi.fn() }), +})) + +vi.mock('@/lib/queued-api-mutation', () => ({ + performQueuedApiMutation: vi.fn(async () => undefined), +})) + +vi.mock('@/lib/use-app-theme', () => ({ + useAppTheme: () => ({ currentScheme: 'purple', currentTheme: 'dark' }), +})) + +vi.mock('@/lib/theme', () => ({ + createTokensV2: () => new Proxy({}, { get: () => '#111111' }), +})) + +vi.mock('@/components/bottom-sheet-modal', () => ({ + BottomSheetModal: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ? React.createElement('Sheet', null, children) : null, +})) + +vi.mock('@/components/ui/pill-button', () => ({ + PillButton: ({ children }: { children: React.ReactNode }) => + React.createElement('PillButton', null, children), +})) + +function renderPrompt() { + let tree: { root: { findAllByType: (type: string) => unknown[] } } | null = null + TestRenderer.act(() => { + tree = TestRenderer.create(React.createElement(CalendarImportPrompt)) + }) + return tree! +} + +function sheetCount(tree: { root: { findAllByType: (type: string) => unknown[] } }): number { + return tree.root.findAllByType('Sheet').length +} + +function baseProfile(overrides: Record = {}) { + return { + hasCompletedOnboarding: true, + hasCompletedTour: true, + hasImportedCalendar: false, + ...overrides, + } +} + +beforeEach(() => { + mocks.profile = undefined + mocks.pathname = '/' +}) + +describe('CalendarImportPrompt gating', () => { + it('shows the sheet once onboarding and the tour are both complete', () => { + mocks.profile = baseProfile() + expect(sheetCount(renderPrompt())).toBe(1) + }) + + it('stays hidden while the tour is still running (hasCompletedTour false)', () => { + mocks.profile = baseProfile({ hasCompletedTour: false }) + expect(sheetCount(renderPrompt())).toBe(0) + }) + + it('stays hidden before onboarding completes', () => { + mocks.profile = baseProfile({ hasCompletedOnboarding: false }) + expect(sheetCount(renderPrompt())).toBe(0) + }) + + it('stays hidden once the calendar has been imported', () => { + mocks.profile = baseProfile({ hasImportedCalendar: true }) + expect(sheetCount(renderPrompt())).toBe(0) + }) + + it('stays hidden on the calendar-sync route', () => { + mocks.profile = baseProfile() + mocks.pathname = '/calendar-sync' + expect(sheetCount(renderPrompt())).toBe(0) + }) +}) diff --git a/apps/mobile/__tests__/components/ui/app-error-boundary.test.tsx b/apps/mobile/__tests__/components/ui/app-error-boundary.test.tsx new file mode 100644 index 0000000000..fb1a35b55d --- /dev/null +++ b/apps/mobile/__tests__/components/ui/app-error-boundary.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' +import { AppErrorScreen } from '@/components/ui/app-error-boundary' + +const TestRenderer = require('react-test-renderer') + +function render(element: React.ReactElement) { + let tree: any + TestRenderer.act(() => { + tree = TestRenderer.create(element) + }) + return tree +} + +describe('AppErrorScreen (mobile)', () => { + it('renders a retry button that calls retry when pressed', () => { + const retry = vi.fn() + const tree = render() + + const button = tree.root.findByType('Pressable') + expect(button.props.accessibilityRole).toBe('button') + + TestRenderer.act(() => { + button.props.onPress() + }) + expect(retry).toHaveBeenCalledTimes(1) + }) + + it('renders a designed fallback with an alert orb and a message', () => { + const tree = render() + const texts = tree.root.findAllByType('Text').map((node: any) => node.props.children) + expect(texts.length).toBeGreaterThan(0) + }) +}) diff --git a/apps/mobile/__tests__/hooks/use-calendar-data.test.ts b/apps/mobile/__tests__/hooks/use-calendar-data.test.ts new file mode 100644 index 0000000000..b513b0addf --- /dev/null +++ b/apps/mobile/__tests__/hooks/use-calendar-data.test.ts @@ -0,0 +1,189 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { habitKeys } from '@orbit/shared/query' +import type { CalendarMonthResponse } from '@orbit/shared/types/habit' + +import { useCalendarData } from '@/hooks/use-calendar-data' + +const TestRenderer = require('react-test-renderer') + +interface CapturedQuery { + queryKey: readonly unknown[] + queryFn: () => unknown +} + +const mocks = vi.hoisted(() => ({ + captured: [] as CapturedQuery[], + apiClient: vi.fn(), + data: undefined as CalendarMonthResponse | undefined, + isLoading: false, + error: null as Error | null, + useQuery: vi.fn((options: CapturedQuery) => { + mocks.captured.push(options) + return { + data: mocks.data, + isLoading: mocks.isLoading, + isFetching: false, + error: mocks.error, + refetch: vi.fn(), + } + }), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQuery: mocks.useQuery, +})) + +vi.mock('@/lib/api-client', () => ({ + apiClient: (...args: unknown[]) => mocks.apiClient(...args), +})) + +type CalendarDataResult = ReturnType + +function renderCalendarData(currentMonth: Date): CalendarDataResult { + let captured: CalendarDataResult | null = null + function Probe() { + captured = useCalendarData(currentMonth) + return null + } + TestRenderer.act(() => { + TestRenderer.create(React.createElement(Probe)) + }) + if (!captured) throw new Error('hook did not render') + return captured +} + +function buildHabit(overrides: Record = {}) { + return { + id: 'h-1', + title: 'Exercise', + isBadHabit: false, + dueTime: '09:00', + frequencyUnit: 'Day', + frequencyQuantity: 1, + scheduledDates: ['2025-01-15', '2025-01-16'], + instances: null, + isCompleted: false, + isGeneral: false, + isFlexible: false, + days: [], + dueDate: '2025-01-15', + dueEndTime: null, + endDate: null, + position: 0, + checklistItems: [], + createdAtUtc: '2025-01-01T00:00:00Z', + isOverdue: false, + reminderEnabled: false, + reminderTimes: [], + scheduledReminders: [], + slipAlertEnabled: false, + tags: [], + children: [], + hasSubHabits: false, + description: null, + flexibleTarget: null, + flexibleCompleted: null, + ...overrides, + } +} + +beforeEach(() => { + mocks.captured = [] + mocks.apiClient.mockReset() + mocks.useQuery.mockClear() + mocks.data = undefined + mocks.isLoading = false + mocks.error = null +}) + +describe('useCalendarData (mobile)', () => { + it('keys the query on the month range and fetches via apiClient', async () => { + renderCalendarData(new Date(2025, 0, 1)) + + const query = mocks.captured.at(-1)! + expect(query.queryKey).toEqual(habitKeys.calendar('2025-01-01', '2025-01-31')) + + mocks.apiClient.mockResolvedValue({ habits: [], logs: {} }) + await query.queryFn() + expect(mocks.apiClient).toHaveBeenCalledWith( + expect.stringContaining('dateFrom=2025-01-01&dateTo=2025-01-31'), + ) + }) + + it('builds a dayMap with completed status from logs', () => { + mocks.data = { + habits: [buildHabit()], + logs: { + 'h-1': [ + { id: 'log-1', date: '2025-01-15', value: 1, createdAtUtc: '2025-01-15T10:00:00Z' }, + ], + }, + } as unknown as CalendarMonthResponse + + const result = renderCalendarData(new Date(2025, 0, 1)) + + expect(result.dayMap.size).toBeGreaterThan(0) + const jan15 = result.dayMap.get('2025-01-15') + expect(jan15).toBeDefined() + expect(jan15![0]!.status).toBe('completed') + expect(jan15![0]!.title).toBe('Exercise') + }) + + it('returns an empty dayMap when there is no data', () => { + mocks.data = undefined + const result = renderCalendarData(new Date(2025, 0, 1)) + expect(result.dayMap.size).toBe(0) + }) + + it('surfaces the query error message', () => { + mocks.error = new Error('Internal server error') + const result = renderCalendarData(new Date(2025, 0, 1)) + expect(result.error).toBe('Internal server error') + }) + + it('marks unlogged past dates as missed', () => { + mocks.data = { + habits: [ + buildHabit({ + title: 'Read', + dueTime: null, + scheduledDates: ['2020-06-15'], + dueDate: '2020-06-15', + createdAtUtc: '2020-01-01T00:00:00Z', + }), + ], + logs: {}, + } as unknown as CalendarMonthResponse + + const result = renderCalendarData(new Date(2020, 5, 1)) + + const jun15 = result.dayMap.get('2020-06-15') + expect(jun15).toBeDefined() + expect(jun15![0]!.status).toBe('missed') + }) + + it('detects one-time habits (no frequencyUnit)', () => { + mocks.data = { + habits: [ + buildHabit({ + id: 'h-one', + title: 'One-time task', + dueTime: null, + frequencyUnit: null, + frequencyQuantity: null, + scheduledDates: ['2020-03-10'], + dueDate: '2020-03-10', + createdAtUtc: '2020-01-01T00:00:00Z', + }), + ], + logs: {}, + } as unknown as CalendarMonthResponse + + const result = renderCalendarData(new Date(2020, 2, 1)) + + const entry = result.dayMap.get('2020-03-10') + expect(entry).toBeDefined() + expect(entry![0]!.isOneTime).toBe(true) + }) +}) diff --git a/apps/mobile/__tests__/hooks/use-calendar-events.test.ts b/apps/mobile/__tests__/hooks/use-calendar-events.test.ts new file mode 100644 index 0000000000..6e21a01113 --- /dev/null +++ b/apps/mobile/__tests__/hooks/use-calendar-events.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { calendarKeys } from '@orbit/shared/query' +import type { CalendarSyncEvent } from '@orbit/shared' + +import { + useCalendarEvents, + type CalendarEventsResult, +} from '@/hooks/use-calendar-events' + +const mocks = vi.hoisted(() => ({ + useQuery: vi.fn(), + apiClient: vi.fn(), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQuery: mocks.useQuery, +})) + +vi.mock('@/lib/api-client', () => ({ + apiClient: mocks.apiClient, +})) + +function buildEvent(id: string): CalendarSyncEvent { + return { + id, + title: `Event ${id}`, + description: null, + startDate: '2025-01-01', + startTime: null, + endTime: null, + isRecurring: false, + recurrenceRule: null, + reminders: [], + } +} + +function captureQueryFn(): () => Promise { + let captured: (() => Promise) | null = null + mocks.useQuery.mockImplementation( + (config: { queryFn: () => Promise }) => { + captured = config.queryFn + return { data: undefined } + }, + ) + useCalendarEvents() + return captured! +} + +describe('mobile useCalendarEvents', () => { + beforeEach(() => { + mocks.useQuery.mockReset() + mocks.apiClient.mockReset() + }) + + it('registers the shared manual-fetch query key with retry disabled', () => { + mocks.useQuery.mockReturnValue({ data: undefined }) + useCalendarEvents() + + expect(mocks.useQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: [...calendarKeys.all, 'manual-fetch'], + retry: false, + }), + ) + }) + + it('returns a connected result with the fetched events', async () => { + const queryFn = captureQueryFn() + mocks.apiClient.mockResolvedValue([buildEvent('a'), buildEvent('b')]) + + const result = await queryFn() + + expect(result).toEqual({ + status: 'connected', + events: [buildEvent('a'), buildEvent('b')], + }) + expect(mocks.apiClient).toHaveBeenCalledWith('/api/calendar/events') + }) + + it('coerces a non-array payload to an empty connected list', async () => { + const queryFn = captureQueryFn() + mocks.apiClient.mockResolvedValue(null) + + const result = await queryFn() + + expect(result).toEqual({ status: 'connected', events: [] }) + }) + + it('maps a not-connected error to the not-connected status', async () => { + const queryFn = captureQueryFn() + mocks.apiClient.mockRejectedValue(new Error('Google Calendar is not connected')) + + const result = await queryFn() + + expect(result).toEqual({ status: 'not-connected' }) + }) + + it('maps an Unauthorized error to the not-connected status', async () => { + const queryFn = captureQueryFn() + mocks.apiClient.mockRejectedValue(new Error('Unauthorized')) + + const result = await queryFn() + + expect(result).toEqual({ status: 'not-connected' }) + }) + + it('rethrows unrelated network errors so the query surfaces them', async () => { + const queryFn = captureQueryFn() + mocks.apiClient.mockRejectedValue(new Error('Internal server error')) + + await expect(queryFn()).rejects.toThrow('Internal server error') + }) +}) diff --git a/apps/mobile/__tests__/hooks/use-chat-reward.test.tsx b/apps/mobile/__tests__/hooks/use-chat-reward.test.tsx new file mode 100644 index 0000000000..b1839f9107 --- /dev/null +++ b/apps/mobile/__tests__/hooks/use-chat-reward.test.tsx @@ -0,0 +1,133 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { API } from '@orbit/shared/api' +import { profileKeys } from '@orbit/shared/query' +import { createMockProfile } from '@orbit/shared/__tests__/factories' +import type { Profile } from '@orbit/shared/types/profile' + +import { useChatReward } from '@/hooks/use-chat-reward' + +const TestRenderer = require('react-test-renderer') + +const mocks = vi.hoisted(() => { + const state = { + profile: undefined as Profile | undefined, + } + + const queryClient = { + setQueryData: vi.fn( + ( + _queryKey: readonly unknown[], + updater: + | Profile + | undefined + | ((current: Profile | undefined) => Profile | undefined), + ) => { + state.profile = + typeof updater === 'function' + ? updater(state.profile) ?? state.profile + : updater ?? state.profile + }, + ), + } + + return { + state, + queryClient, + apiClient: vi.fn(), + showRewardedAd: vi.fn(), + markRewardClaimed: vi.fn(), + useQueryClient: vi.fn(() => queryClient), + } +}) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: mocks.useQueryClient, +})) + +vi.mock('@/lib/api-client', () => ({ + apiClient: mocks.apiClient, +})) + +vi.mock('@/hooks/use-ad-mob', () => ({ + useAdMob: () => ({ + isInitialized: true, + canClaimReward: true, + rewardsClaimedToday: 0, + dailyRewardCap: 3, + shouldShowAds: () => true, + showRewardedAd: mocks.showRewardedAd, + markRewardClaimed: mocks.markRewardClaimed, + }), +})) + +type RewardApi = ReturnType + +function renderReward(): { current: RewardApi } { + const ref: { current: RewardApi } = { current: null as unknown as RewardApi } + function Probe() { + ref.current = useChatReward() + return null + } + TestRenderer.act(() => { + TestRenderer.create(React.createElement(Probe)) + }) + return ref +} + +beforeEach(() => { + mocks.state.profile = createMockProfile({ aiMessagesUsed: 5, aiMessagesLimit: 5 }) + mocks.apiClient.mockReset() + mocks.showRewardedAd.mockReset() + mocks.markRewardClaimed.mockReset() + mocks.queryClient.setQueryData.mockClear() +}) + +describe('useChatReward', () => { + it('patches the cached profile limit with the backend newLimit after a rewarded ad', async () => { + mocks.showRewardedAd.mockResolvedValue(true) + mocks.apiClient.mockResolvedValue({ bonusMessagesGranted: 5, newLimit: 10 }) + + const reward = renderReward() + await TestRenderer.act(async () => { + await reward.current.watchAdForMessages() + }) + + expect(mocks.apiClient).toHaveBeenCalledWith(API.subscription.adReward, { + method: 'POST', + }) + expect(mocks.markRewardClaimed).toHaveBeenCalledOnce() + expect(mocks.queryClient.setQueryData).toHaveBeenCalledWith( + profileKeys.detail(), + expect.any(Function), + ) + expect(mocks.state.profile?.aiMessagesLimit).toBe(10) + expect(mocks.state.profile?.aiMessagesUsed).toBe(5) + }) + + it('does not claim a reward or patch the cache when the ad is not earned', async () => { + mocks.showRewardedAd.mockResolvedValue(false) + + const reward = renderReward() + await TestRenderer.act(async () => { + await reward.current.watchAdForMessages() + }) + + expect(mocks.apiClient).not.toHaveBeenCalled() + expect(mocks.markRewardClaimed).not.toHaveBeenCalled() + expect(mocks.state.profile?.aiMessagesLimit).toBe(5) + }) + + it('leaves the cached limit untouched when the reward claim request fails', async () => { + mocks.showRewardedAd.mockResolvedValue(true) + mocks.apiClient.mockRejectedValue(new Error('network')) + + const reward = renderReward() + await TestRenderer.act(async () => { + await reward.current.watchAdForMessages() + }) + + expect(mocks.queryClient.setQueryData).not.toHaveBeenCalled() + expect(mocks.state.profile?.aiMessagesLimit).toBe(5) + }) +}) diff --git a/apps/mobile/__tests__/hooks/use-goal-queries.test.ts b/apps/mobile/__tests__/hooks/use-goal-queries.test.ts new file mode 100644 index 0000000000..51af8cb874 --- /dev/null +++ b/apps/mobile/__tests__/hooks/use-goal-queries.test.ts @@ -0,0 +1,137 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { goalKeys } from '@orbit/shared/query' + +import { useGoals, useGoalDetail, useGoalMetrics } from '@/hooks/use-goal-queries' + +const TestRenderer = require('react-test-renderer') + +interface CapturedQuery { + queryKey: readonly unknown[] + queryFn: () => unknown + enabled?: boolean + select?: (data: unknown) => unknown +} + +const mocks = vi.hoisted(() => { + return { + captured: [] as CapturedQuery[], + apiClient: vi.fn(), + useQuery: vi.fn((options: CapturedQuery) => { + mocks.captured.push(options) + return { data: undefined, isLoading: false, isSuccess: false, isError: false } + }), + } +}) + +vi.mock('@tanstack/react-query', () => ({ + useQuery: mocks.useQuery, +})) + +vi.mock('@/lib/api-client', () => ({ + apiClient: (...args: unknown[]) => mocks.apiClient(...args), +})) + +function renderHookCapture(useHook: () => unknown): void { + function Probe() { + useHook() + return null + } + TestRenderer.act(() => { + TestRenderer.create(React.createElement(Probe)) + }) +} + +function lastQuery(): CapturedQuery { + const query = mocks.captured.at(-1) + if (!query) throw new Error('no query captured') + return query +} + +beforeEach(() => { + mocks.captured = [] + mocks.apiClient.mockReset() + mocks.useQuery.mockClear() +}) + +describe('useGoals (mobile query hook)', () => { + it('keys the query on the filters and defaults to a large page size', async () => { + mocks.apiClient.mockResolvedValue({ items: [], page: 1, pageSize: 100, totalCount: 0, totalPages: 1 }) + + renderHookCapture(() => useGoals()) + + const query = lastQuery() + expect(query.queryKey).toEqual(goalKeys.list({})) + + await query.queryFn() + expect(mocks.apiClient).toHaveBeenCalledWith('/api/goals?pageSize=100') + }) + + it('passes the status filter to the query key and the request', async () => { + mocks.apiClient.mockResolvedValue({ items: [], page: 1, pageSize: 100, totalCount: 0, totalPages: 1 }) + + renderHookCapture(() => useGoals('Active')) + + const query = lastQuery() + expect(query.queryKey).toEqual(goalKeys.list({ status: 'Active' })) + + await query.queryFn() + expect(mocks.apiClient).toHaveBeenCalledWith('/api/goals?pageSize=100&status=Active') + }) + + it('omits the status param when no filter is provided', async () => { + mocks.apiClient.mockResolvedValue({ items: [], page: 1, pageSize: 100, totalCount: 0, totalPages: 1 }) + + renderHookCapture(() => useGoals()) + + await lastQuery().queryFn() + const calledUrl = mocks.apiClient.mock.calls[0]![0] as string + expect(calledUrl).not.toContain('status=') + }) +}) + +describe('useGoalDetail (mobile)', () => { + it('fetches goal detail from the detail endpoint when id is provided', async () => { + mocks.apiClient.mockResolvedValue({ goal: { id: 'g-1' }, metrics: {} }) + + renderHookCapture(() => useGoalDetail('g-1')) + const query = lastQuery() + + expect(query.queryKey).toEqual(goalKeys.detail('g-1')) + expect(query.enabled).toBe(true) + + await query.queryFn() + expect(mocks.apiClient).toHaveBeenCalledWith('/api/goals/g-1/detail') + }) + + it('does not enable the query when id is null', () => { + renderHookCapture(() => useGoalDetail(null)) + expect(lastQuery().enabled).toBe(false) + }) +}) + +describe('useGoalMetrics (mobile)', () => { + it('fetches metrics from the metrics endpoint when id is provided', async () => { + mocks.apiClient.mockResolvedValue({ + progressPercentage: 25, + velocityPerDay: 0.4, + projectedCompletionDate: null, + daysToDeadline: null, + trackingStatus: 'OnTrack', + habitAdherence: [], + }) + + renderHookCapture(() => useGoalMetrics('g-1')) + const query = lastQuery() + + expect(query.queryKey).toEqual(goalKeys.metrics('g-1')) + + await query.queryFn() + expect(mocks.apiClient).toHaveBeenCalledWith('/api/goals/g-1/metrics') + }) + + it('does not enable the query when id is null', () => { + renderHookCapture(() => useGoalMetrics(null)) + expect(lastQuery().enabled).toBe(false) + }) +}) diff --git a/apps/mobile/__tests__/hooks/use-habit-queries.test.ts b/apps/mobile/__tests__/hooks/use-habit-queries.test.ts new file mode 100644 index 0000000000..08bd5455a1 --- /dev/null +++ b/apps/mobile/__tests__/hooks/use-habit-queries.test.ts @@ -0,0 +1,212 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { habitKeys, HABITS_REFETCH_INTERVAL } from '@orbit/shared/query' + +import { + useHabits, + useHabitDetail, + useHabitMetrics, + useHabitLogs, + useHabitFullDetail, + useTotalHabitCount, +} from '@/hooks/use-habit-queries' + +const TestRenderer = require('react-test-renderer') + +interface CapturedQuery { + queryKey: readonly unknown[] + queryFn: () => unknown + enabled?: boolean + refetchInterval?: () => number | false + select?: (data: unknown) => unknown +} + +const mocks = vi.hoisted(() => { + return { + captured: [] as CapturedQuery[], + apiClient: vi.fn(), + isAppActive: vi.fn(() => true), + isOnline: vi.fn(() => true), + useQuery: vi.fn((options: CapturedQuery) => { + mocks.captured.push(options) + return { data: undefined, isLoading: false, isSuccess: false, isError: false } + }), + } +}) + +vi.mock('@tanstack/react-query', () => ({ + useQuery: mocks.useQuery, +})) + +vi.mock('@/lib/api-client', () => ({ + apiClient: (...args: unknown[]) => mocks.apiClient(...args), +})) + +vi.mock('@/lib/query-client', () => ({ + isAppActive: () => mocks.isAppActive(), + isOnline: () => mocks.isOnline(), +})) + +function renderHookCapture(useHook: () => unknown): void { + function Probe() { + useHook() + return null + } + TestRenderer.act(() => { + TestRenderer.create(React.createElement(Probe)) + }) +} + +function lastQuery(): CapturedQuery { + const query = mocks.captured.at(-1) + if (!query) throw new Error('no query captured') + return query +} + +beforeEach(() => { + mocks.captured = [] + mocks.apiClient.mockReset() + mocks.useQuery.mockClear() + mocks.isAppActive.mockReturnValue(true) + mocks.isOnline.mockReturnValue(true) +}) + +describe('useHabits (mobile query hook)', () => { + it('keys the query on the filters and defaults to a large page size', async () => { + mocks.apiClient.mockResolvedValue({ items: [], page: 1, pageSize: 200, totalCount: 0, totalPages: 1 }) + + renderHookCapture(() => useHabits({})) + + const query = lastQuery() + expect(query.queryKey).toEqual(habitKeys.list({})) + + await query.queryFn() + expect(mocks.apiClient).toHaveBeenCalledWith('/api/habits?pageSize=200') + }) + + it('does not override the page size for date-bounded single-day queries', async () => { + mocks.apiClient.mockResolvedValue({ items: [], page: 1, pageSize: 50, totalCount: 0, totalPages: 1 }) + + renderHookCapture(() => useHabits({ dateFrom: '2025-01-01', dateTo: '2025-01-01' })) + + await lastQuery().queryFn() + expect(mocks.apiClient).toHaveBeenCalledWith('/api/habits?dateFrom=2025-01-01&dateTo=2025-01-01') + }) +}) + +describe('useHabits refetch gating (single-day / midnight rollover)', () => { + it('polls single-day queries while active and online', () => { + renderHookCapture(() => useHabits({ dateFrom: '2025-01-01', dateTo: '2025-01-01' })) + expect(lastQuery().refetchInterval?.()).toBe(HABITS_REFETCH_INTERVAL) + }) + + it('does not poll multi-day (calendar-range) queries', () => { + renderHookCapture(() => useHabits({ dateFrom: '2025-01-01', dateTo: '2025-01-31' })) + expect(lastQuery().refetchInterval?.()).toBe(false) + }) + + it('does not poll list queries with no date range', () => { + renderHookCapture(() => useHabits({})) + expect(lastQuery().refetchInterval?.()).toBe(false) + }) + + it('pauses polling when the app is offline', () => { + mocks.isOnline.mockReturnValue(false) + renderHookCapture(() => useHabits({ dateFrom: '2025-01-01', dateTo: '2025-01-01' })) + expect(lastQuery().refetchInterval?.()).toBe(false) + }) + + it('pauses polling when the app is backgrounded', () => { + mocks.isAppActive.mockReturnValue(false) + renderHookCapture(() => useHabits({ dateFrom: '2025-01-01', dateTo: '2025-01-01' })) + expect(lastQuery().refetchInterval?.()).toBe(false) + }) +}) + +describe('useHabitDetail (mobile)', () => { + it('fetches habit detail when id is provided', async () => { + mocks.apiClient.mockResolvedValue({ id: 'h-1', title: 'Exercise' }) + + renderHookCapture(() => useHabitDetail('h-1')) + const query = lastQuery() + + expect(query.queryKey).toEqual(habitKeys.detail('h-1')) + expect(query.enabled).toBe(true) + + await query.queryFn() + expect(mocks.apiClient).toHaveBeenCalledWith('/api/habits/h-1') + }) + + it('does not enable the query when id is null', () => { + renderHookCapture(() => useHabitDetail(null)) + expect(lastQuery().enabled).toBe(false) + }) +}) + +describe('useHabitMetrics (mobile)', () => { + it('fetches metrics from the metrics endpoint', async () => { + mocks.apiClient.mockResolvedValue({ + weeklyCompletionRate: 85, + monthlyCompletionRate: 70, + currentStreak: 5, + longestStreak: 14, + totalCompletions: 100, + lastCompletedDate: null, + }) + + renderHookCapture(() => useHabitMetrics('h-1')) + await lastQuery().queryFn() + expect(mocks.apiClient).toHaveBeenCalledWith('/api/habits/h-1/metrics') + }) + + it('does not enable the query when id is null', () => { + renderHookCapture(() => useHabitMetrics(null)) + expect(lastQuery().enabled).toBe(false) + }) +}) + +describe('useHabitLogs (mobile)', () => { + it('fetches logs from the logs endpoint', async () => { + mocks.apiClient.mockResolvedValue([]) + + renderHookCapture(() => useHabitLogs('h-1')) + await lastQuery().queryFn() + expect(mocks.apiClient).toHaveBeenCalledWith('/api/habits/h-1/logs') + }) + + it('does not enable the query when id is null', () => { + renderHookCapture(() => useHabitLogs(null)) + expect(lastQuery().enabled).toBe(false) + }) +}) + +describe('useHabitFullDetail (mobile)', () => { + it('fetches the full detail endpoint', async () => { + mocks.apiClient.mockResolvedValue({ habit: { id: 'h-1' }, logs: [], metrics: null }) + + renderHookCapture(() => useHabitFullDetail('h-1')) + const query = lastQuery() + expect(query.queryKey).toEqual(habitKeys.fullDetail('h-1')) + + await query.queryFn() + expect(mocks.apiClient).toHaveBeenCalledWith('/api/habits/h-1/detail') + }) + + it('does not enable the query when id is null', () => { + renderHookCapture(() => useHabitFullDetail(null)) + expect(lastQuery().enabled).toBe(false) + }) +}) + +describe('useTotalHabitCount (mobile)', () => { + it('reads the lightweight count endpoint', async () => { + mocks.apiClient.mockResolvedValue({ count: 42 }) + + renderHookCapture(() => useTotalHabitCount()) + const query = lastQuery() + expect(query.queryKey).toEqual(habitKeys.count()) + + await query.queryFn() + expect(mocks.apiClient).toHaveBeenCalledWith('/api/habits/count') + }) +}) diff --git a/apps/mobile/__tests__/hooks/use-login-code-entry.test.ts b/apps/mobile/__tests__/hooks/use-login-code-entry.test.ts new file mode 100644 index 0000000000..aaec9792f3 --- /dev/null +++ b/apps/mobile/__tests__/hooks/use-login-code-entry.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useLoginCodeEntry } from '@/hooks/use-login-code-entry' + +const TestRenderer = require('react-test-renderer') +const React = require('react') + +type Hook = ReturnType + +interface Harness { + current: Hook + unmount: () => void +} + +async function renderLoginCodeEntry(onComplete?: (code: string) => void): Promise { + const holder: { current: Hook | null } = { current: null } + + function Component() { + holder.current = useLoginCodeEntry(onComplete) + return null + } + + let renderer: { unmount: () => void } | null = null + await TestRenderer.act(async () => { + renderer = TestRenderer.create(React.createElement(Component)) + await Promise.resolve() + }) + + if (!holder.current || !renderer) { + throw new Error('Expected useLoginCodeEntry to initialize') + } + + return { + get current() { + if (!holder.current) throw new Error('hook not rendered') + return holder.current + }, + unmount: () => (renderer as { unmount: () => void }).unmount(), + } +} + +async function act(fn: () => void): Promise { + await TestRenderer.act(async () => { + fn() + await Promise.resolve() + }) +} + +function pressKey(key: string) { + return { nativeEvent: { key } } as Parameters[1] +} + +describe('mobile useLoginCodeEntry', () => { + it('advances digit-by-digit on single-character input', async () => { + const harness = await renderLoginCodeEntry() + + await act(() => harness.current.onCodeInput(0, '1')) + await act(() => harness.current.onCodeInput(1, '2')) + + expect(harness.current.codeDigits).toEqual(['1', '2', '', '', '', '']) + }) + + it('ignores non-numeric characters', async () => { + const harness = await renderLoginCodeEntry() + + await act(() => harness.current.onCodeInput(0, 'a')) + + expect(harness.current.codeDigits).toEqual(['', '', '', '', '', '']) + }) + + it('fills all digits from a pasted multi-character value', async () => { + const harness = await renderLoginCodeEntry() + + await act(() => harness.current.onCodeInput(0, '654321')) + + expect(harness.current.codeDigits).toEqual(['6', '5', '4', '3', '2', '1']) + }) + + it('resets digits to empty', async () => { + const harness = await renderLoginCodeEntry() + + await act(() => harness.current.onCodeInput(0, '123456')) + await act(() => harness.current.resetCodeDigits()) + + expect(harness.current.codeDigits).toEqual(['', '', '', '', '', '']) + }) + + describe('auto-submit (completeness watcher)', () => { + it('fires once when the final digit completes a valid code', async () => { + const onComplete = vi.fn() + const harness = await renderLoginCodeEntry(onComplete) + + await act(() => harness.current.onCodeInput(0, '1')) + await act(() => harness.current.onCodeInput(1, '2')) + await act(() => harness.current.onCodeInput(2, '3')) + await act(() => harness.current.onCodeInput(3, '4')) + await act(() => harness.current.onCodeInput(4, '5')) + expect(onComplete).not.toHaveBeenCalled() + + await act(() => harness.current.onCodeInput(5, '6')) + + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete).toHaveBeenCalledWith('123456') + }) + + it('fires once for a pasted complete code (no double submit)', async () => { + const onComplete = vi.fn() + const harness = await renderLoginCodeEntry(onComplete) + + await act(() => harness.current.onCodeInput(0, '123456')) + + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete).toHaveBeenCalledWith('123456') + }) + + it('re-fires after the code is cleared and re-entered', async () => { + const onComplete = vi.fn() + const harness = await renderLoginCodeEntry(onComplete) + + await act(() => harness.current.onCodeInput(0, '123456')) + expect(onComplete).toHaveBeenCalledTimes(1) + + await act(() => harness.current.resetCodeDigits()) + await act(() => harness.current.onCodeInput(0, '123456')) + + expect(onComplete).toHaveBeenCalledTimes(2) + }) + + it('does not fire for an incomplete code', async () => { + const onComplete = vi.fn() + const harness = await renderLoginCodeEntry(onComplete) + + await act(() => harness.current.onCodeInput(0, '12345')) + + expect(onComplete).not.toHaveBeenCalled() + }) + }) + + describe('backspace navigation', () => { + it('moves focus to the previous input when backspacing an empty cell', async () => { + const harness = await renderLoginCodeEntry() + const focusSpy = vi.fn() + harness.current.codeInputRefs.current[0] = { focus: focusSpy } as never + + await act(() => harness.current.onCodeKeyPress(1, pressKey('Backspace'))) + + expect(focusSpy).toHaveBeenCalled() + }) + + it('does not move focus when the current cell has a value', async () => { + const harness = await renderLoginCodeEntry() + await act(() => harness.current.onCodeInput(1, '4')) + const focusSpy = vi.fn() + harness.current.codeInputRefs.current[0] = { focus: focusSpy } as never + + await act(() => harness.current.onCodeKeyPress(1, pressKey('Backspace'))) + + expect(focusSpy).not.toHaveBeenCalled() + }) + }) + + describe('resend countdown', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('counts down from 60 and re-enables resend at zero', async () => { + const harness = await renderLoginCodeEntry() + + await act(() => harness.current.startResendCountdown()) + expect(harness.current.canResend).toBe(false) + expect(harness.current.resendCountdown).toBe(60) + + await act(() => vi.advanceTimersByTime(1000)) + expect(harness.current.resendCountdown).toBe(59) + + await act(() => vi.advanceTimersByTime(60_000)) + expect(harness.current.resendCountdown).toBe(0) + expect(harness.current.canResend).toBe(true) + }) + + it('clears the interval on unmount', async () => { + const harness = await renderLoginCodeEntry() + await act(() => harness.current.startResendCountdown()) + + const clearSpy = vi.spyOn(globalThis, 'clearInterval') + await act(() => harness.unmount()) + + expect(clearSpy).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/mobile/__tests__/hooks/use-overlay-back.test.tsx b/apps/mobile/__tests__/hooks/use-overlay-back.test.tsx new file mode 100644 index 0000000000..43b67c615b --- /dev/null +++ b/apps/mobile/__tests__/hooks/use-overlay-back.test.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useOverlayBack } from '@/hooks/use-overlay-back' +import { dismissTopOverlay } from '@/lib/overlay-stack' + +const TestRenderer = require('react-test-renderer') + +type Root = { unmount: () => void } + +const mountedRoots: Root[] = [] + +function Layer({ active, onDismiss }: Readonly<{ active: boolean; onDismiss: () => void }>) { + useOverlayBack(active, onDismiss) + return null +} + +async function renderTree(element: React.ReactElement): Promise { + let root: Root | null = null + await TestRenderer.act(async () => { + root = TestRenderer.create(element) + await Promise.resolve() + }) + const created = root as unknown as Root + mountedRoots.push(created) + return created +} + +afterEach(async () => { + await TestRenderer.act(async () => { + while (mountedRoots.length > 0) { + mountedRoots.pop()?.unmount() + } + await Promise.resolve() + }) +}) + +describe('mobile useOverlayBack', () => { + it('makes system back dismiss the registered layer and return true', async () => { + const onDismiss = vi.fn() + await renderTree() + + expect(dismissTopOverlay('system-back')).toBe(true) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + it('resolves the most recently registered layer first (LIFO)', async () => { + const lower = vi.fn() + const upper = vi.fn() + await renderTree( + <> + + + , + ) + + expect(dismissTopOverlay('system-back')).toBe(true) + expect(upper).toHaveBeenCalledTimes(1) + expect(lower).not.toHaveBeenCalled() + }) + + it('unregisters on unmount so back falls through again', async () => { + const onDismiss = vi.fn() + const root = await renderTree() + + await TestRenderer.act(async () => { + root.unmount() + await Promise.resolve() + }) + + expect(dismissTopOverlay('system-back')).toBe(false) + expect(onDismiss).not.toHaveBeenCalled() + }) + + it('does not register while inactive', async () => { + const onDismiss = vi.fn() + await renderTree() + + expect(dismissTopOverlay('system-back')).toBe(false) + expect(onDismiss).not.toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/__tests__/hooks/use-pending-operation-execution.test.tsx b/apps/mobile/__tests__/hooks/use-pending-operation-execution.test.tsx new file mode 100644 index 0000000000..fc215b647f --- /dev/null +++ b/apps/mobile/__tests__/hooks/use-pending-operation-execution.test.tsx @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { API } from '@orbit/shared/api' +import type { AgentExecuteOperationResponse } from '@orbit/shared/types' + +import { usePendingOperationExecution } from '@/hooks/use-pending-operation-execution' + +const TestRenderer = require('react-test-renderer') +const React = require('react') + +const mocks = vi.hoisted(() => ({ + apiClient: vi.fn(), +})) + +vi.mock('@/lib/api-client', () => ({ + apiClient: mocks.apiClient, +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'pt-BR' }, + }), +})) + +type Hook = ReturnType + +function makeExecution(summary: string): AgentExecuteOperationResponse { + return { + operation: { + operationId: 'op-1', + sourceName: 'CreateHabit', + riskClass: 'Low', + confirmationRequirement: 'None', + status: 'Succeeded', + summary, + }, + } as AgentExecuteOperationResponse +} + +async function renderExecution( + appendExecutionMessage: (response: AgentExecuteOperationResponse) => Promise, +): Promise<{ current: Hook }> { + const holder: { current: Hook | null } = { current: null } + + function Component() { + holder.current = usePendingOperationExecution({ appendExecutionMessage }) + return null + } + + await TestRenderer.act(async () => { + TestRenderer.create(React.createElement(Component)) + await Promise.resolve() + }) + + if (!holder.current) throw new Error('hook did not render') + return holder as { current: Hook } +} + +describe('usePendingOperationExecution step-up flow', () => { + beforeEach(() => { + mocks.apiClient.mockReset() + }) + + it('confirms then issues a step-up challenge, surfacing the device language', async () => { + mocks.apiClient + .mockResolvedValueOnce({ confirmationToken: 'token-9' }) + .mockResolvedValueOnce({ challengeId: 'challenge-9', deliveryTarget: 'a@b.test' }) + const appendExecutionMessage = vi.fn(async () => {}) + const hook = await renderExecution(appendExecutionMessage) + + let result: Awaited> | null = null + await TestRenderer.act(async () => { + result = await hook.current.prepareStepUpForBubble('pending-9') + }) + + expect(mocks.apiClient).toHaveBeenNthCalledWith( + 1, + API.ai.pendingOperationConfirm('pending-9'), + expect.objectContaining({ method: 'POST' }), + ) + expect(mocks.apiClient).toHaveBeenNthCalledWith( + 2, + API.ai.pendingOperationStepUp('pending-9'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ language: 'pt-BR' }), + }), + ) + expect(result).toEqual({ + ok: true, + challengeId: 'challenge-9', + confirmationToken: 'token-9', + }) + }) + + it('returns an error from prepare when the confirm call fails', async () => { + mocks.apiClient.mockRejectedValueOnce(new Error('confirm boom')) + const hook = await renderExecution(vi.fn(async () => {})) + + let result: Awaited> | null = null + await TestRenderer.act(async () => { + result = await hook.current.prepareStepUpForBubble('pending-9') + }) + + expect(result).toMatchObject({ ok: false }) + expect(mocks.apiClient).toHaveBeenCalledTimes(1) + }) + + it('verifies the code then executes, appending the resulting agent message', async () => { + mocks.apiClient + .mockResolvedValueOnce({ id: 'verification-1' }) + .mockResolvedValueOnce(makeExecution('Habit created')) + const appendExecutionMessage = vi.fn(async () => {}) + const hook = await renderExecution(appendExecutionMessage) + + let result: Awaited> | null = null + await TestRenderer.act(async () => { + result = await hook.current.verifyStepUpForBubble( + 'pending-9', + 'challenge-9', + '123456', + 'token-9', + ) + }) + + expect(mocks.apiClient).toHaveBeenNthCalledWith( + 1, + API.ai.pendingOperationVerifyStepUp('pending-9'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ challengeId: 'challenge-9', code: '123456' }), + }), + ) + expect(mocks.apiClient).toHaveBeenNthCalledWith( + 2, + API.ai.pendingOperationExecute('pending-9'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ confirmationToken: 'token-9' }), + }), + ) + expect(appendExecutionMessage).toHaveBeenCalledWith(makeExecution('Habit created')) + expect(result).toMatchObject({ ok: true }) + }) + + it('does not execute when verification fails', async () => { + mocks.apiClient.mockRejectedValueOnce(new Error('bad code')) + const appendExecutionMessage = vi.fn(async () => {}) + const hook = await renderExecution(appendExecutionMessage) + + let result: Awaited> | null = null + await TestRenderer.act(async () => { + result = await hook.current.verifyStepUpForBubble( + 'pending-9', + 'challenge-9', + '000000', + 'token-9', + ) + }) + + expect(result).toMatchObject({ ok: false }) + expect(mocks.apiClient).toHaveBeenCalledTimes(1) + expect(appendExecutionMessage).not.toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/__tests__/lib/google-auth.test.ts b/apps/mobile/__tests__/lib/google-auth.test.ts new file mode 100644 index 0000000000..bdb3759a3f --- /dev/null +++ b/apps/mobile/__tests__/lib/google-auth.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiClientError } from '@orbit/shared/utils' + +const { + apiClientMock, + setSessionMock, + signOutMock, +} = vi.hoisted(() => ({ + apiClientMock: vi.fn(), + setSessionMock: vi.fn(), + signOutMock: vi.fn(), +})) + +vi.mock('@/lib/api-client', () => ({ + apiClient: apiClientMock, +})) + +vi.mock('@/lib/supabase', () => ({ + supabase: { + auth: { + setSession: setSessionMock, + signOut: signOutMock, + }, + }, +})) + +vi.mock('expo-web-browser', () => ({ + openAuthSessionAsync: vi.fn(), + WebBrowserResultType: { DISMISS: 'dismiss', CANCEL: 'cancel' }, +})) + +const { completeGoogleAuthFromUrl } = await import('@/lib/google-auth') + +const CALLBACK = 'https://app.useorbit.org/auth-callback' + +const loginResponse = { + token: 'jwt-token', + refreshToken: 'refresh-token', + userId: 'user-1', + name: 'Thomas', + email: 'thomas@example.com', +} + +describe('completeGoogleAuthFromUrl', () => { + beforeEach(() => { + apiClientMock.mockReset() + setSessionMock.mockReset() + signOutMock.mockReset().mockResolvedValue(undefined) + }) + + it('returns the direct backend token payload without exchanging a session', async () => { + const url = `${CALLBACK}?token=jwt-token&refreshToken=refresh-token&userId=user-1&name=Thomas&email=thomas%40example.com` + + const result = await completeGoogleAuthFromUrl(url, 'en') + + expect(result).toEqual(loginResponse) + expect(setSessionMock).not.toHaveBeenCalled() + expect(apiClientMock).not.toHaveBeenCalled() + }) + + it('throws the backend error description when the callback carries an error', async () => { + const url = `${CALLBACK}?error=access_denied&error_description=User%20cancelled` + + await expect(completeGoogleAuthFromUrl(url, 'en')).rejects.toThrow('User cancelled') + expect(apiClientMock).not.toHaveBeenCalled() + }) + + it('throws when neither tokens nor a backend payload are present', async () => { + await expect(completeGoogleAuthFromUrl(CALLBACK, 'en')).rejects.toThrow('Authentication failed') + }) + + it('exchanges a supabase session via apiClient and forwards the language + referral', async () => { + const url = `${CALLBACK}#access_token=supa-access&refresh_token=supa-refresh&provider_token=g-access&provider_refresh_token=g-refresh` + setSessionMock.mockResolvedValue({ + data: { + session: { + access_token: 'supa-access', + provider_token: null, + provider_refresh_token: null, + }, + }, + error: null, + }) + apiClientMock.mockResolvedValue(loginResponse) + + const result = await completeGoogleAuthFromUrl(url, 'pt-BR', 'REF123') + + expect(result).toEqual(loginResponse) + expect(apiClientMock).toHaveBeenCalledWith( + '/api/auth/google', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + accessToken: 'supa-access', + language: 'pt-BR', + googleAccessToken: 'g-access', + googleRefreshToken: 'g-refresh', + referralCode: 'REF123', + }), + }), + ) + expect(signOutMock).toHaveBeenCalled() + }) + + it('signs out of supabase even when the backend exchange fails', async () => { + const url = `${CALLBACK}#access_token=supa-access&refresh_token=supa-refresh` + setSessionMock.mockResolvedValue({ + data: { session: { access_token: 'supa-access' } }, + error: null, + }) + apiClientMock.mockRejectedValue( + new ApiClientError(400, 'Invalid Google token', { code: 'INVALID_VERIFICATION_CODE' }), + ) + + await expect(completeGoogleAuthFromUrl(url, 'en')).rejects.toBeInstanceOf(ApiClientError) + expect(signOutMock).toHaveBeenCalled() + }) + + it('throws when supabase rejects the session', async () => { + const url = `${CALLBACK}#access_token=supa-access&refresh_token=supa-refresh` + setSessionMock.mockResolvedValue({ + data: { session: null }, + error: { message: 'session expired' }, + }) + + await expect(completeGoogleAuthFromUrl(url, 'en')).rejects.toThrow('session expired') + expect(apiClientMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/__tests__/lib/habit-mutation-helpers.test.ts b/apps/mobile/__tests__/lib/habit-mutation-helpers.test.ts index 13cc28c534..0053d68df1 100644 --- a/apps/mobile/__tests__/lib/habit-mutation-helpers.test.ts +++ b/apps/mobile/__tests__/lib/habit-mutation-helpers.test.ts @@ -189,6 +189,42 @@ describe('finalizeHabitMutation', () => { }) expect(mocks.refreshWidget).toHaveBeenCalledTimes(1) }) + + it('invalidates the habit count only when includeCount is set (parity with web create/delete/bulk)', async () => { + const withCount = { invalidateQueries: vi.fn(async () => {}) } + await finalizeHabitMutation(withCount as never, { ok: true }, null, { includeCount: true }) + expect(withCount.invalidateQueries).toHaveBeenCalledWith({ queryKey: habitKeys.count() }) + + const withoutCount = { invalidateQueries: vi.fn(async () => {}) } + await finalizeHabitMutation(withoutCount as never, { ok: true }, null, { includeGoals: true }) + expect(withoutCount.invalidateQueries).not.toHaveBeenCalledWith({ queryKey: habitKeys.count() }) + }) + + it('still refetches on a plain (non-queued) online error so the cache reconciles', async () => { + const queryClient = { invalidateQueries: vi.fn(async () => {}) } + + await finalizeHabitMutation(queryClient as never, { ok: true }, new Error('network'), { + includeGoals: true, + }) + + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: habitKeys.lists() }) + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: goalKeys.lists() }) + expect(mocks.refreshWidget).toHaveBeenCalledTimes(1) + }) + + it('still skips invalidation for a queued (offline) result even on error', async () => { + const queryClient = { invalidateQueries: vi.fn(async () => {}) } + + await finalizeHabitMutation( + queryClient as never, + { queued: true, queuedMutationId: 'offline-1' }, + new Error('network'), + { includeGoals: true }, + ) + + expect(queryClient.invalidateQueries).not.toHaveBeenCalled() + expect(mocks.refreshWidget).not.toHaveBeenCalled() + }) }) describe('habit mutation helper builders', () => { diff --git a/apps/mobile/__tests__/lib/i18n.test.ts b/apps/mobile/__tests__/lib/i18n.test.ts index 825b4b9212..e3d16d19ab 100644 --- a/apps/mobile/__tests__/lib/i18n.test.ts +++ b/apps/mobile/__tests__/lib/i18n.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import i18n from '@/lib/i18n' +import { i18n } from '@/lib/i18n' import { plural } from '@/lib/plural' describe('mobile i18n interpolation', () => { diff --git a/apps/mobile/__tests__/lib/theme.test.ts b/apps/mobile/__tests__/lib/theme.test.ts index b7ae32320a..77a519da93 100644 --- a/apps/mobile/__tests__/lib/theme.test.ts +++ b/apps/mobile/__tests__/lib/theme.test.ts @@ -25,10 +25,38 @@ describe('mobile theme runtime', () => { expect(dark.fg4).toBe('#62748e') expect(dark.bgElev).toBe('rgba(248, 250, 252, 0.06)') expect(dark.primary).toBe('#7f46f7') + expect(dark.fgOnPrimary).toBe('#ffffff') expect(dark.gradientHeaderFrom).toBe('#22094f') expect(dark.gradientHeaderTo).toBe('rgba(2, 6, 24, 0)') }) + it('resolves fg-on-primary per scheme and mode (AA flips to canvas ink)', () => { + expect(createTokensV2('purple', 'light').fgOnPrimary).toBe('#ffffff') + expect(createTokensV2('blue', 'light').fgOnPrimary).toBe('#ffffff') + expect(createTokensV2('rose', 'light').fgOnPrimary).toBe('#ffffff') + expect(createTokensV2('blue', 'dark').fgOnPrimary).toBe('#020618') + expect(createTokensV2('rose', 'dark').fgOnPrimary).toBe('#020618') + expect(createTokensV2('green', 'dark').fgOnPrimary).toBe('#020618') + expect(createTokensV2('green', 'light').fgOnPrimary).toBe('#020618') + expect(createTokensV2('orange', 'dark').fgOnPrimary).toBe('#020618') + expect(createTokensV2('orange', 'light').fgOnPrimary).toBe('#020618') + expect(createTokensV2('cyan', 'dark').fgOnPrimary).toBe('#020618') + expect(createTokensV2('cyan', 'light').fgOnPrimary).toBe('#020618') + }) + + it('exposes AA status text variants alongside the base status colors', () => { + const dark = createTokensV2('purple', 'dark') + const light = createTokensV2('purple', 'light') + + expect(dark.statusOverdueText).toBe(dark.statusOverdue) + expect(dark.statusBadText).toBe(dark.statusBad) + expect(light.statusBadText).toBe(light.statusBad) + expect(light.statusOverdue).toBe('#e17100') + expect(light.statusOverdueText).toBe('#b45b00') + expect(dark.fgOnBad).toBe('#020618') + expect(light.fgOnBad).toBe('#ffffff') + }) + it('purple light uses the pale handoff canvas with opaque white cards', () => { const light = createTokensV2('purple', 'light') diff --git a/apps/mobile/__tests__/screens/account-deletion.test.tsx b/apps/mobile/__tests__/screens/account-deletion.test.tsx new file mode 100644 index 0000000000..78ee0adc94 --- /dev/null +++ b/apps/mobile/__tests__/screens/account-deletion.test.tsx @@ -0,0 +1,525 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockProfile } from '@orbit/shared/__tests__/factories' +import { API } from '@orbit/shared/api' + +import ProfileScreen from '@/app/(tabs)/profile' + +const TestRenderer = require('react-test-renderer') + +vi.hoisted(() => { + ;(globalThis as { __DEV__?: boolean }).__DEV__ = false +}) + +const colorProxy = new Proxy( + {}, + { + get: (_target, prop: string) => (prop === 'white' ? '#ffffff' : '#111111'), + }, +) + +const mocks = vi.hoisted(() => { + const storage = new Map() + const router = { push: vi.fn(), replace: vi.fn() } + const queryClient = { + clear: vi.fn(), + invalidateQueries: vi.fn(async () => {}), + } + + return { + storage, + router, + queryClient, + apiClient: vi.fn(), + logout: vi.fn(), + useOffline: vi.fn(() => ({ + isOnline: true, + pendingCount: 0, + enqueue: vi.fn(), + flush: vi.fn(), + isFlushing: false, + })), + useProfile: vi.fn(() => ({ + profile: createMockProfile({ + hasProAccess: false, + isTrialActive: false, + isLifetimePro: false, + }), + isLoading: false, + error: null, + })), + useGamificationProfile: vi.fn(() => ({ profile: null })), + useHasProAccess: vi.fn(() => false), + useIsYearlyPro: vi.fn(() => false), + useTrialDaysLeft: vi.fn(() => 0), + useTrialExpired: vi.fn(() => true), + useTrialUrgent: vi.fn(() => false), + useQueryClient: vi.fn(() => queryClient), + useRouter: vi.fn(() => router), + useLocalSearchParams: vi.fn(() => ({})), + useTranslation: vi.fn(() => ({ + t: (key: string, params?: Record) => + params ? `${key}(${JSON.stringify(params)})` : key, + i18n: { language: 'en' }, + })), + useAppTheme: vi.fn(() => ({ + colors: colorProxy, + currentScheme: 'purple', + currentTheme: 'dark', + })), + asyncStorageGetItem: vi.fn(async (key: string) => storage.get(key) ?? null), + asyncStorageSetItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value) + }), + asyncStorageRemoveItem: vi.fn(async (key: string) => { + storage.delete(key) + }), + clearChecklistTemplates: vi.fn(async () => {}), + clearPersistedQueryCache: vi.fn(async () => {}), + plural: vi.fn((value: string) => value), + } +}) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: mocks.asyncStorageGetItem, + setItem: mocks.asyncStorageSetItem, + removeItem: mocks.asyncStorageRemoveItem, + }, +})) + +vi.mock('react-native', async (importOriginal) => { + const actual = await importOriginal() + const keyboardListener = { remove: vi.fn() } + return { + ...actual, + KeyboardAvoidingView: actual.View, + Keyboard: { + ...actual.Keyboard, + addListener: vi.fn(() => keyboardListener), + dismiss: vi.fn(), + }, + } +}) + +vi.mock('expo', () => ({ + __esModule: true, + requireNativeModule: vi.fn(() => ({})), +})) + +vi.mock('expo-router', () => ({ + useRouter: mocks.useRouter, + useLocalSearchParams: mocks.useLocalSearchParams, +})) + +vi.mock('react-i18next', () => ({ + useTranslation: mocks.useTranslation, +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: mocks.useQueryClient, + useQuery: () => ({ data: undefined, isLoading: false, error: null }), + useMutation: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }), +})) + +vi.mock('@/lib/use-app-theme', () => ({ + useAppTheme: mocks.useAppTheme, +})) + +vi.mock('@/lib/theme', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createColors: () => colorProxy, + createTokensV2: () => colorProxy, + } +}) + +vi.mock('@/components/gamification/streak-badge', () => ({ + StreakBadge: () => null, +})) + +vi.mock('@/components/navigation/notification-bell', () => ({ + NotificationBell: () => null, +})) + +vi.mock('@/components/ui/theme-toggle', () => ({ + ThemeToggle: () => null, +})) + +vi.mock('@/components/ui/app-bar', () => ({ + AppBar: ({ trailing }: { trailing?: React.ReactNode }) => + React.createElement(React.Fragment, null, trailing as React.ReactNode), +})) + +vi.mock('@/components/ui/section-label', () => ({ + SectionLabel: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), +})) + +vi.mock('@/components/ui/settings-row', () => ({ + SettingsRow: () => null, +})) + +vi.mock('@/components/ui/app-text-input', () => { + const AppTextInput = React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ focus: () => {} })) + return React.createElement('TextInput', props) + }) + AppTextInput.displayName = 'AppTextInput' + return { AppTextInput } +}) + +vi.mock('@/components/ui/keyboard-aware-scroll-view', () => ({ + KeyboardAwareScrollView: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), +})) + +vi.mock('@/app/(tabs)/profile/_components/profile-nav-card', () => ({ + ProfileNavCard: () => null, +})) + +vi.mock('@/app/(tabs)/profile/_components/profile-nav-icon', () => ({ + ProfileNavIcon: () => null, +})) + +vi.mock('@/components/ui/fresh-start-animation', () => ({ + FreshStartAnimation: () => null, +})) + +vi.mock('@/components/tour/tour-replay-modal', () => ({ + TourReplayModal: () => null, +})) + +vi.mock('@/hooks/use-offline', () => ({ + useOffline: mocks.useOffline, +})) + +vi.mock('@/hooks/use-profile', () => ({ + useProfile: mocks.useProfile, + useHasProAccess: mocks.useHasProAccess, + useTrialDaysLeft: mocks.useTrialDaysLeft, + useTrialExpired: mocks.useTrialExpired, + useTrialUrgent: mocks.useTrialUrgent, + useIsYearlyPro: mocks.useIsYearlyPro, +})) + +vi.mock('@/hooks/use-gamification', () => ({ + useGamificationProfile: mocks.useGamificationProfile, +})) + +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: (selector: (state: { logout: () => void }) => unknown) => + selector({ logout: mocks.logout }), +})) + +vi.mock('@/lib/api-client', () => ({ + apiClient: mocks.apiClient, +})) + +vi.mock('@/lib/checklist-template-storage', () => ({ + clearChecklistTemplates: mocks.clearChecklistTemplates, +})) + +vi.mock('@/lib/query-client', () => ({ + clearPersistedQueryCache: mocks.clearPersistedQueryCache, +})) + +vi.mock('@/lib/offline-mutations', () => ({ + buildQueuedMutation: vi.fn((mutation: Record) => mutation), + createQueuedAck: vi.fn((queuedMutationId: string) => ({ queued: true, queuedMutationId })), + isQueuedResult: vi.fn(() => false), + queueOrExecute: vi.fn(async () => ({ queued: false, queuedMutationId: 'mutation-1' })), +})) + +vi.mock('@/lib/offline-queue', () => ({ + clear: vi.fn(), + count: vi.fn(() => 0), + enqueue: vi.fn(), +})) + +vi.mock('@/lib/plural', () => ({ + plural: mocks.plural, +})) + +vi.mock('lucide-react-native', () => { + const iconNames = [ + 'AlertTriangle', + 'ArrowLeft', + 'BadgeCheck', + 'BarChart3', + 'CalendarDays', + 'Check', + 'CheckCircle', + 'CheckCircle2', + 'ChevronLeft', + 'ChevronRight', + 'Clock', + 'Compass', + 'CreditCard', + 'Download', + 'Flame', + 'Info', + 'List', + 'Lock', + 'LogOut', + 'MessageCircle', + 'MessageSquare', + 'Orbit', + 'Palette', + 'Pencil', + 'Play', + 'Plus', + 'RotateCcw', + 'Send', + 'Settings', + 'ShieldCheck', + 'Smartphone', + 'Sparkles', + 'Tag', + 'Target', + 'TriangleAlert', + 'Trash2', + 'User', + 'UserX', + 'WifiOff', + 'Wrench', + 'X', + ] + const createIcon = (name: string) => (props: Record) => + React.createElement(name, props) + const stubbed: Record = {} + for (const name of iconNames) { + stubbed[name] = createIcon(name) + } + return stubbed +}) + +vi.mock('@/components/ui/settings-group', () => ({ + SettingsGroup: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + SettingsGroupRow: () => null, +})) + +vi.mock('react-native-svg', () => { + const createSvg = (name: string) => (props: Record) => + React.createElement(name, props) + return { + __esModule: true, + default: createSvg('Svg'), + Defs: createSvg('Defs'), + LinearGradient: createSvg('LinearGradient'), + Path: createSvg('Path'), + Rect: createSvg('Rect'), + Stop: createSvg('Stop'), + } +}) + +function flattenText(node: unknown): string { + if (node == null) return '' + if (typeof node === 'string' || typeof node === 'number') return String(node) + if (Array.isArray(node)) return node.map(flattenText).join('') + if (typeof node === 'object' && 'props' in (node as Record)) { + return flattenText((node as { props: { children?: unknown } }).props.children) + } + return '' +} + +function screenTexts(tree: { root: { findAllByType: (type: string) => unknown[] } }): string[] { + return tree.root + .findAllByType('Text') + .map((node) => flattenText((node as { props: { children?: unknown } }).props.children)) +} + +async function renderScreen(element: React.ReactElement) { + let tree: ReturnType + await TestRenderer.act(async () => { + tree = TestRenderer.create(element) + await Promise.resolve() + await Promise.resolve() + }) + return tree +} + +function openDeleteModal(tree: { root: { findAll: (predicate: (node: any) => boolean) => any[] } }) { + const deleteButton = tree.root.findAll( + (node) => + typeof node.props?.onPress === 'function' && + node.props?.accessibilityLabel === 'profile.deleteAccount.button', + )[0] + return deleteButton +} + +describe('ProfileScreen account-deletion state machine', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.storage.clear() + mocks.useOffline.mockReturnValue({ + isOnline: true, + pendingCount: 0, + enqueue: vi.fn(), + flush: vi.fn(), + isFlushing: false, + }) + mocks.useProfile.mockReturnValue({ + profile: createMockProfile({ + hasProAccess: false, + isTrialActive: false, + isLifetimePro: false, + }), + isLoading: false, + error: null, + }) + mocks.useGamificationProfile.mockReturnValue({ profile: null }) + mocks.useHasProAccess.mockReturnValue(false) + mocks.apiClient.mockResolvedValue({ scheduledDeletionAt: '2026-07-01T00:00:00Z' }) + }) + + it('opens on the confirm step with the warning copy and no code inputs', async () => { + const tree = await renderScreen() + + await TestRenderer.act(async () => { + openDeleteModal(tree).props.onPress() + await Promise.resolve() + }) + + expect(screenTexts(tree)).toContain('profile.deleteAccount.warningFree') + expect(tree.root.findAllByType('TextInput')).toHaveLength(0) + }) + + it('requests deletion and advances to the code step with six inputs', async () => { + const tree = await renderScreen() + + await TestRenderer.act(async () => { + openDeleteModal(tree).props.onPress() + await Promise.resolve() + }) + + const sendButton = tree.root.findAll( + (node: any) => + typeof node.props?.onPress === 'function' && + flattenText(node.props?.children).includes('profile.deleteAccount.sendCode'), + )[0] + + await TestRenderer.act(async () => { + sendButton.props.onPress() + await Promise.resolve() + }) + + expect(mocks.apiClient).toHaveBeenCalledWith(API.auth.requestDeletion, { + method: 'POST', + }) + expect(screenTexts(tree)).toContain('profile.deleteAccount.codeInstructions') + expect(tree.root.findAllByType('TextInput')).toHaveLength(6) + }) + + it('surfaces an error when requesting deletion fails and stays on confirm', async () => { + mocks.apiClient.mockRejectedValueOnce(new Error('boom')) + const tree = await renderScreen() + + await TestRenderer.act(async () => { + openDeleteModal(tree).props.onPress() + await Promise.resolve() + }) + const sendButton = tree.root.findAll( + (node: any) => + typeof node.props?.onPress === 'function' && + flattenText(node.props?.children).includes('profile.deleteAccount.sendCode'), + )[0] + await TestRenderer.act(async () => { + sendButton.props.onPress() + await Promise.resolve() + }) + + expect(screenTexts(tree)).toContain('boom') + expect(tree.root.findAllByType('TextInput')).toHaveLength(0) + }) + + it('confirms with a six-digit code and advances to the deactivated step', async () => { + const tree = await renderScreen() + + await TestRenderer.act(async () => { + openDeleteModal(tree).props.onPress() + await Promise.resolve() + }) + const sendButton = tree.root.findAll( + (node: any) => + typeof node.props?.onPress === 'function' && + flattenText(node.props?.children).includes('profile.deleteAccount.sendCode'), + )[0] + await TestRenderer.act(async () => { + sendButton.props.onPress() + await Promise.resolve() + }) + + const inputs = tree.root.findAllByType('TextInput') + await TestRenderer.act(async () => { + inputs.forEach((input: any, index: number) => { + input.props.onChangeText(String(index + 1)) + }) + await Promise.resolve() + }) + + const confirmButton = tree.root.findAll( + (node: any) => + typeof node.props?.onPress === 'function' && + flattenText(node.props?.children).includes('profile.deleteAccount.confirmDelete'), + )[0] + await TestRenderer.act(async () => { + confirmButton.props.onPress() + await Promise.resolve() + }) + + expect(mocks.apiClient).toHaveBeenCalledWith(API.auth.confirmDeletion, { + method: 'POST', + body: JSON.stringify({ code: '123456' }), + }) + expect(screenTexts(tree)).toContain('profile.logout') + }) + + it('logs out from the deactivated step', async () => { + const tree = await renderScreen() + + await TestRenderer.act(async () => { + openDeleteModal(tree).props.onPress() + await Promise.resolve() + }) + const sendButton = tree.root.findAll( + (node: any) => + typeof node.props?.onPress === 'function' && + flattenText(node.props?.children).includes('profile.deleteAccount.sendCode'), + )[0] + await TestRenderer.act(async () => { + sendButton.props.onPress() + await Promise.resolve() + }) + const inputs = tree.root.findAllByType('TextInput') + await TestRenderer.act(async () => { + inputs.forEach((input: any, index: number) => { + input.props.onChangeText(String(index + 1)) + }) + await Promise.resolve() + }) + const confirmButton = tree.root.findAll( + (node: any) => + typeof node.props?.onPress === 'function' && + flattenText(node.props?.children).includes('profile.deleteAccount.confirmDelete'), + )[0] + await TestRenderer.act(async () => { + confirmButton.props.onPress() + await Promise.resolve() + }) + + const logoutButton = tree.root.findAll( + (node: any) => + typeof node.props?.onPress === 'function' && + flattenText(node.props?.children).includes('profile.logout'), + )[0] + await TestRenderer.act(async () => { + logoutButton.props.onPress() + await Promise.resolve() + }) + + expect(mocks.logout).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/mobile/__tests__/screens/calendar-sync.test.tsx b/apps/mobile/__tests__/screens/calendar-sync.test.tsx index 0b0db5bc6e..7b601ffe57 100644 --- a/apps/mobile/__tests__/screens/calendar-sync.test.tsx +++ b/apps/mobile/__tests__/screens/calendar-sync.test.tsx @@ -21,6 +21,16 @@ const mocks = vi.hoisted(() => { return { apiClient: vi.fn(), queryClient, + eventsQuery: { + data: { status: "connected", events: [] } as + | { status: "connected"; events: unknown[] } + | { status: "not-connected" } + | undefined, + isLoading: false, + isError: false, + error: null as Error | null, + refetch: vi.fn(), + }, router: { push: vi.fn(), replace: vi.fn(), @@ -91,6 +101,10 @@ vi.mock("@/hooks/use-calendar-auto-sync", () => ({ }), })); +vi.mock("@/hooks/use-calendar-events", () => ({ + useCalendarEvents: () => mocks.eventsQuery, +})); + vi.mock("@/lib/api-client", () => ({ apiClient: mocks.apiClient, })); @@ -194,17 +208,20 @@ describe("CalendarSyncScreen", () => { vi.clearAllMocks(); mocks.profile = createMockProfile({ hasProAccess: true }); mocks.apiClient.mockResolvedValue([]); + mocks.eventsQuery.data = { status: "connected", events: [] }; + mocks.eventsQuery.isLoading = false; + mocks.eventsQuery.isError = false; + mocks.eventsQuery.error = null; }); - it("fetches calendar events once after the screen settles", async () => { + it("refetches calendar events through the cached query once the screen settles", async () => { await TestRenderer.act(async () => { TestRenderer.create(); await Promise.resolve(); await Promise.resolve(); }); - expect(mocks.apiClient).toHaveBeenCalledTimes(1); - expect(mocks.apiClient).toHaveBeenCalledWith("/api/calendar/events"); + expect(mocks.eventsQuery.refetch).toHaveBeenCalledTimes(1); }); it("replaces to upgrade instead of pushing when a free user opens the screen", async () => { @@ -218,6 +235,6 @@ describe("CalendarSyncScreen", () => { expect(mocks.router.replace).toHaveBeenCalledWith("/upgrade"); expect(mocks.router.push).not.toHaveBeenCalledWith("/upgrade"); - expect(mocks.apiClient).not.toHaveBeenCalled(); + expect(mocks.eventsQuery.refetch).not.toHaveBeenCalled(); }); }); diff --git a/apps/mobile/__tests__/screens/intentional-offline-ux.test.tsx b/apps/mobile/__tests__/screens/intentional-offline-ux.test.tsx index f1cc14d4a4..162ed9d801 100644 --- a/apps/mobile/__tests__/screens/intentional-offline-ux.test.tsx +++ b/apps/mobile/__tests__/screens/intentional-offline-ux.test.tsx @@ -464,7 +464,7 @@ describe('intentional offline UX screens', () => { const tree = await renderScreen() const texts = tree.root.findAllByType('Text').map((node: any) => flattenText(node.props.children)) - expect(texts).toContain('calendarSync.notConnected') + expect(texts).toContain('offline.title') expect(texts).not.toContain('upgrade.plans.error') expect(texts).not.toContain('upgrade.billing.error') }) @@ -473,7 +473,7 @@ describe('intentional offline UX screens', () => { const tree = await renderScreen() const texts = tree.root.findAllByType('Text').map((node: any) => flattenText(node.props.children)) - expect(texts).toContain('calendarSync.notConnected') - expect(texts).toContain('profile.support.send') + expect(texts).toContain('offline.title') + expect(texts).toContain('offline.description') }) }) diff --git a/apps/mobile/__tests__/screens/today-screen.test.tsx b/apps/mobile/__tests__/screens/today-screen.test.tsx index d8a4b70e4c..00a1e770b7 100644 --- a/apps/mobile/__tests__/screens/today-screen.test.tsx +++ b/apps/mobile/__tests__/screens/today-screen.test.tsx @@ -284,9 +284,7 @@ vi.mock("@/components/ui/anchored-menu", () => ({ vi.mock("../../app/(tabs)/today-shell", () => todayShellMock); vi.mock("@/hooks/use-horizontal-swipe", () => ({ - useHorizontalSwipe: () => ({ - panHandlers: {}, - }), + useHorizontalSwipe: () => ({ toGestureArray: () => [] }), })); vi.mock("@/lib/habit-selection-state", () => ({ diff --git a/apps/mobile/__tests__/stores/chat-store.test.ts b/apps/mobile/__tests__/stores/chat-store.test.ts index 71d8c018a3..55ecc23926 100644 --- a/apps/mobile/__tests__/stores/chat-store.test.ts +++ b/apps/mobile/__tests__/stores/chat-store.test.ts @@ -6,12 +6,11 @@ describe('mobile chat store', () => { useChatStore.setState({ messages: [], isTyping: false, - isStreaming: false, }) }) - it('appends messages and toggles flags', () => { - const { addMessage, setIsTyping, setIsStreaming } = useChatStore.getState() + it('appends messages and toggles the typing flag', () => { + const { addMessage, setIsTyping } = useChatStore.getState() addMessage({ id: 'message-1', @@ -20,12 +19,10 @@ describe('mobile chat store', () => { timestamp: new Date('2026-04-06T00:00:00Z'), }) setIsTyping(true) - setIsStreaming(true) const state = useChatStore.getState() expect(state.messages).toHaveLength(1) expect(state.isTyping).toBe(true) - expect(state.isStreaming).toBe(true) }) it('clears the conversation state', () => { @@ -39,7 +36,6 @@ describe('mobile chat store', () => { }, ], isTyping: true, - isStreaming: true, }) useChatStore.getState().clearMessages() @@ -47,7 +43,6 @@ describe('mobile chat store', () => { expect(useChatStore.getState()).toMatchObject({ messages: [], isTyping: false, - isStreaming: false, }) }) }) diff --git a/apps/mobile/app/(tabs)/calendar.tsx b/apps/mobile/app/(tabs)/calendar.tsx index fa7fc38b7a..9a42dbe972 100644 --- a/apps/mobile/app/(tabs)/calendar.tsx +++ b/apps/mobile/app/(tabs)/calendar.tsx @@ -6,7 +6,7 @@ import { Text, Pressable, StyleSheet, - ScrollView, + FlatList, Switch, } from "react-native"; import Animated, { @@ -38,6 +38,7 @@ import { useCalendarData } from "@/hooks/use-habits"; import { useProfile } from "@/hooks/use-profile"; import { useTimeFormat } from "@/hooks/use-time-format"; import { useHorizontalSwipe } from "@/hooks/use-horizontal-swipe"; +import { GestureDetector } from "react-native-gesture-handler"; import { createTokensV2 } from "@/lib/theme"; import { useAppTheme } from "@/lib/use-app-theme"; import { SafeAreaView } from "react-native-safe-area-context"; @@ -85,6 +86,16 @@ function entryDotState(entry: CalendarDayEntry): StatusDotState { return "empty"; } +function dayStatusLabel( + status: DayStatus, + t: (key: string) => string, +): string | null { + if (status === "full") return t("calendar.legend.done"); + if (status === "partial") return t("calendar.legend.partial"); + if (status === "missed") return t("calendar.legend.missed"); + return null; +} + function statusBadge( entry: CalendarDayEntry, t: (key: string) => string, @@ -120,11 +131,11 @@ export default function CalendarScreen() { const styles = useMemo(() => createStyles(tokens), [tokens]); const calendarGridRef = useRef(null); const calendarDayRef = useRef(null); - const calendarScrollRef = useRef(null); + const calendarScrollRef = useRef>(null); useTourTarget("tour-calendar-grid", calendarGridRef); useTourTarget("tour-calendar-day", calendarDayRef); const calendarScrollTo = useCallback((y: number) => { - calendarScrollRef.current?.scrollTo({ y, animated: true }); + calendarScrollRef.current?.scrollToOffset({ offset: y, animated: true }); }, []); const { onTourScroll: onCalendarTourScroll } = useTourScrollContainer( "/calendar", @@ -160,7 +171,7 @@ export default function CalendarScreen() { setCurrentMonth((m) => addMonths(m, 1)); }, []); - const swipePanResponder = useHorizontalSwipe({ + const swipeGesture = useHorizontalSwipe({ onSwipeLeft: nextMonth, onSwipeRight: prevMonth, }); @@ -294,30 +305,15 @@ export default function CalendarScreen() { ? FadeInLeft.duration(220).reduceMotion(ReduceMotion.System) : undefined; - return ( - - - - + const hasDayEntries = Boolean(selectedDay) && filteredEntries.length > 0; + + const listHeader = ( + <> + {weekdayHeaders.map((d) => ( - + {d.label} @@ -339,6 +335,13 @@ export default function CalendarScreen() { const status = dayStatus(cell); const canSelect = cell.isCurrentMonth; const selected = canSelect && cell.dateStr === selectedDay; + const statusLabel = dayStatusLabel(status, t); + const dayDateLabel = format(cell.date, "EEEE, MMM d", { + locale: i18n.language === "pt-BR" ? ptBR : enUS, + }); + const dayAccessibilityLabel = statusLabel + ? `${dayDateLabel}, ${statusLabel}` + : dayDateLabel; return ( canSelect && onSelectDay(cell.dateStr)} disabled={!canSelect} hitSlop={4} + accessibilityRole="button" + accessibilityLabel={dayAccessibilityLabel} + accessibilityState={{ selected, disabled: !canSelect }} style={({ pressed }) => [ styles.dayCell, pressed && canSelect && styles.dayCellPressed, @@ -392,128 +398,167 @@ export default function CalendarScreen() { + - - - {selectedDay ? ( - - - - {formattedSelectedDate} - - {selectedEntries.length > 0 ? ( - - - - {t("calendar.showRecurring")} - - - ) : null} - + - {selectedEntries.length === 0 ? ( - - - {t("calendar.noHabitsScheduled")} + {selectedDay ? ( + + + + {formattedSelectedDate} + + {selectedEntries.length > 0 ? ( + + + + {t("calendar.showRecurring")} - ) : ( - <> - - {plural( - t("calendar.dayDetail.completionSummary", { - done: completedCount, - total: filteredEntries.length, - }), - filteredEntries.length, - )} - - - {filteredEntries.length === 0 ? ( - - - {t("calendar.noHabitsScheduled")} - - - ) : ( - - {filteredEntries.map((entry: CalendarDayEntry, idx: number) => { - const badge = statusBadge(entry, t); - return ( - - - - ); - })} - - )} - - )} - - - {t("calendar.goToDay")} - + ) : null} - ) : null} - - {t("calendar.thisMonth")} - - - - - - + + {selectedEntries.length === 0 || filteredEntries.length === 0 ? ( + + + {t("calendar.noHabitsScheduled")} + + + ) : ( + + {plural( + t("calendar.dayDetail.completionSummary", { + done: completedCount, + total: filteredEntries.length, + }), + filteredEntries.length, + )} + + )} + + ) : null} + + ); + + const listFooter = ( + + {selectedDay ? ( + + {t("calendar.goToDay")} + + ) : null} + + {t("calendar.thisMonth")} + + + + + + + ); + + const renderEntry = ({ + item, + index, + }: { + item: CalendarDayEntry; + index: number; + }) => { + const badge = statusBadge(item, t); + const isFirst = index === 0; + const isLast = index === filteredEntries.length - 1; + return ( + + + + + + ); + }; + + return ( + + + + `${item.habitId}-${index}`} + renderItem={renderEntry} + ListHeaderComponent={listHeader} + ListFooterComponent={listFooter} + showsVerticalScrollIndicator={false} + onScroll={onCalendarTourScroll} + scrollEventThrottle={16} + /> ); } @@ -642,7 +687,7 @@ function createStyles(tokens: Tokens) { opacity: 0.5, }, - daySection: { + daySectionHeaderBlock: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 12, @@ -654,6 +699,9 @@ function createStyles(tokens: Tokens) { justifyContent: "space-between", gap: 12, }, + listFooter: { + paddingTop: 4, + }, daySectionTitle: { flex: 1, minWidth: 0, @@ -693,16 +741,27 @@ function createStyles(tokens: Tokens) { textAlign: "center", }, - entriesCard: { - borderRadius: 18, - backgroundColor: tokens.bgCard, - borderWidth: 1, - borderColor: tokens.hairline, - overflow: "hidden", + entriesCardPad: { + paddingHorizontal: 20, + }, + entryRowFrame: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, + entryRowFrameFirst: { + borderTopWidth: 1, + borderTopLeftRadius: 18, + borderTopRightRadius: 18, + }, + entryRowFrameLast: { + borderBottomWidth: 1, + borderBottomLeftRadius: 18, + borderBottomRightRadius: 18, }, goToDayButton: { marginTop: 6, + marginHorizontal: 20, alignSelf: "stretch", }, }); diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index e2b3515a10..662a3a6503 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -10,6 +10,7 @@ import { import { Animated, AppState, + type FlatList, View, Text, Pressable, @@ -42,6 +43,7 @@ import { } from "@orbit/shared/utils"; import { useHabitVisibility } from "@/hooks/use-habit-visibility"; import type { HabitsFilter, NormalizedHabit } from "@orbit/shared/types/habit"; +import type { Goal } from "@orbit/shared/types/goal"; import { plural } from "@/lib/plural"; import { useAdMob } from "@/hooks/use-ad-mob"; import { useProfile } from "@/hooks/use-profile"; @@ -200,7 +202,7 @@ const TodaySearchBar = memo(function TodaySearchBar({ @@ -319,9 +321,9 @@ export default function TodayScreen() { >(() => new Set()); const habitsTourRef = useRef(null); useTourTarget("tour-habit-list", habitsTourRef); - const goalsScrollRef = useRef(null); + const goalsScrollRef = useRef>(null); const goalsScrollTo = useCallback((y: number) => { - goalsScrollRef.current?.scrollTo({ y, animated: true }); + goalsScrollRef.current?.scrollToOffset({ offset: y, animated: true }); }, []); const { onTourScroll: onGoalsTourScroll } = useTourScrollContainer( "/", @@ -484,7 +486,7 @@ export default function TodayScreen() { }; }, [handleTodayRollover]); - const swipePanResponder = useHorizontalSwipe({ + const swipeGesture = useHorizontalSwipe({ onSwipeLeft: goToNextDay, onSwipeRight: goToPreviousDay, }); @@ -1043,9 +1045,7 @@ export default function TodayScreen() { todayLabel={t("dates.goToToday")} nextLabel={t("dates.nextDay")} dateLabelAnim={dateLabelAnim} - panHandlers={ - !isSearchFocused ? swipePanResponder.panHandlers : undefined - } + swipeGesture={!isSearchFocused ? swipeGesture : undefined} /> {isSelectMode ? ( @@ -1221,6 +1222,7 @@ export default function TodayScreen() { }, ]} onPress={handleToggleCollapse} + accessibilityRole="button" > {habitListAllCollapsed ? ( {showCompleted ? ( @@ -1394,7 +1398,7 @@ export default function TodayScreen() { showSummary, slideDirection, styles, - swipePanResponder.panHandlers, + swipeGesture, t, tags, toggleTagFilter, @@ -1409,22 +1413,15 @@ export default function TodayScreen() { return ( {currentActiveView === "goals" ? ( - - {sharedHeader} - - + /> ) : ( ) { flex: 1, backgroundColor: tokens.bg, }, - scrollView: { - flex: 1, - }, - scrollContent: { - paddingBottom: 120, - }, scrollContentWithBulkBar: { paddingBottom: 220, }, diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index cc75cda80a..5201fbebdd 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -326,7 +326,7 @@ export default function ProfileScreen() { async function handleExportData() { if (isExporting) return if (!isOnline) { - setExportError(t('calendarSync.notConnected')) + setExportError(t('errors.offline')) return } setIsExporting(true) @@ -341,8 +341,8 @@ export default function ProfileScreen() { title: t('dataExport.shareTitle'), url: file.uri, }) - } catch (err: unknown) { - setExportError(getErrorMessage(err, t('dataExport.error'))) + } catch { + setExportError(t('dataExport.error')) } finally { setIsExporting(false) } @@ -374,7 +374,7 @@ export default function ProfileScreen() { async function handleRequestDeletion() { if (!isOnline) { - setDeleteError(t('calendarSync.notConnected')) + setDeleteError(t('errors.offline')) return } setDeleteLoading(true) @@ -394,7 +394,7 @@ export default function ProfileScreen() { const code = deleteCodeDigits.join('') if (code.length !== 6) return if (!isOnline) { - setDeleteError(t('calendarSync.notConnected')) + setDeleteError(t('errors.offline')) return } setDeleteLoading(true) @@ -539,7 +539,9 @@ export default function ProfileScreen() { > {error ? ( - {error instanceof Error ? error.message : t('errors.loadProfile')} + {__DEV__ && error instanceof Error + ? error.message + : t('errors.loadProfile')} ) : null} @@ -852,7 +854,7 @@ export default function ProfileScreen() { value={resetConfirmText} onChangeText={setResetConfirmText} placeholder={t('profile.freshStart.confirmPlaceholder')} - placeholderTextColor={tokens.fg4} + placeholderTextColor={tokens.fg3} autoCapitalize="characters" autoCorrect={false} textAlign="center" @@ -930,8 +932,8 @@ export default function ProfileScreen() { {!isOnline ? ( ) : deleteStep === 'confirm' ? ( @@ -1013,7 +1015,7 @@ export default function ProfileScreen() { autoComplete="one-time-code" maxLength={1} placeholder="0" - placeholderTextColor={tokens.fg4} + placeholderTextColor={tokens.fg3} textAlign="center" /> ))} @@ -1057,7 +1059,7 @@ export default function ProfileScreen() { }, ]} > - + {t('profile.deleteAccount.title')} ) { const { currentScheme, currentTheme } = useAppTheme() const tokens = createTokensV2(currentScheme, currentTheme) @@ -150,13 +150,8 @@ export function TodayDateNavigation({ if (!visible) return null - return ( - + const dateNav = ( + ) + + if (!swipeGesture) return dateNav + + return {dateNav} } const styles = StyleSheet.create({ diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 9bbf081a49..472d11e26c 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -14,6 +14,7 @@ import { usePathname, useRouter, useSegments, + type ErrorBoundaryProps, } from 'expo-router' import { StatusBar } from 'expo-status-bar' import Constants from 'expo-constants' @@ -52,6 +53,7 @@ import { } from '@/components/gamification/streak-freeze-celebration' import { WelcomeBackToast } from '@/components/gamification/welcome-back-toast' import { AppToast } from '@/components/ui/app-toast' +import { AppErrorScreen } from '@/components/ui/app-error-boundary' import { TrialExpiredModal } from '@/components/ui/trial-expired-modal' import { VersionUpdateDrawer } from '@/components/version-update-drawer' import { TourProvider } from '@/components/tour/tour-provider' @@ -113,6 +115,56 @@ function AuthGuard() { return null } +const SLIDE_FROM_RIGHT_SCREENS = [ + 'preferences', + 'ai-settings', + 'advanced', + 'about', + 'support', + 'achievements', + 'streak', + 'upgrade', + 'retrospective', + 'calendar-sync', +] as const + +function RootStackScreens({ + screenBackgroundColor, +}: Readonly<{ screenBackgroundColor: string }>) { + return ( + + + + + + {SLIDE_FROM_RIGHT_SCREENS.map((name) => ( + + ))} + + + + ) +} + function RootLayoutNav() { const router = useRouter() const pathname = usePathname() @@ -189,7 +241,6 @@ function RootLayoutNav() { useEffect(() => { if (Platform.OS !== 'android') return - if (!androidBackFallbackRoute) return const subscription = BackHandler.addEventListener( 'hardwareBackPress', @@ -198,6 +249,8 @@ function RootLayoutNav() { return true } + if (!androidBackFallbackRoute) return false + dismissOrFallback(router, androidBackFallbackRoute) return true }, @@ -213,72 +266,9 @@ function RootLayoutNav() { - - - - - - - - - - - - - - - - - - + {showBottomNav ? ( @@ -474,3 +464,11 @@ export default function RootLayout() { ) } + +export function ErrorBoundary({ error, retry }: Readonly) { + return ( + + void retry()} /> + + ) +} diff --git a/apps/mobile/app/advanced.tsx b/apps/mobile/app/advanced.tsx index 3f6afd2088..f95c91bdb0 100644 --- a/apps/mobile/app/advanced.tsx +++ b/apps/mobile/app/advanced.tsx @@ -12,7 +12,15 @@ import { useRouter } from 'expo-router' import { SafeAreaView } from 'react-native-safe-area-context' import { useTranslation } from 'react-i18next' import { enUS, ptBR } from 'date-fns/locale' -import { Lock, Plus, Smartphone } from 'lucide-react-native' +import { + CheckCircle, + Clock, + List, + Lock, + Plus, + RotateCcw, + Smartphone, +} from 'lucide-react-native' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { buildUpgradeHref } from '@/lib/upgrade-route' import { formatDistanceToNow, parseISO } from 'date-fns' @@ -21,6 +29,9 @@ import { buildMcpConfigJson, MCP_CONFIG_TABS, MCP_ENDPOINT_URL, + WIDGET_FEATURES, + WIDGET_STEP_KEYS, + type WidgetFeatureIconKey, } from '@orbit/shared/utils/advanced-settings' import type { AgentCapability, @@ -28,7 +39,7 @@ import type { ApiKeyCreateRequest, ApiKeyCreateResponse, } from '@orbit/shared/types' -import { apiKeyKeys } from '@orbit/shared/query' +import { aiKeys, apiKeyKeys } from '@orbit/shared/query' import { useProfile } from '@/hooks/use-profile' import { apiClient } from '@/lib/api-client' import { performQueuedApiMutation } from '@/lib/queued-api-mutation' @@ -38,6 +49,7 @@ import { useGoBackOrFallback } from '@/hooks/use-go-back-or-fallback' import { createTokensV2, tintFromPrimary } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' import { AppBar } from '@/components/ui/app-bar' +import { BottomSheetModal } from '@/components/bottom-sheet-modal' import { SectionLabel } from '@/components/ui/section-label' import { SettingsRow } from '@/components/ui/settings-row' import { Chip } from '@/components/ui/chip' @@ -49,6 +61,17 @@ function rowEntrance(index: number) { .reduceMotion(ReduceMotion.System) } +function WidgetFeatureIcon({ + iconKey, + color, +}: Readonly<{ iconKey: WidgetFeatureIconKey; color: string }>) { + const iconProps = { size: 16, strokeWidth: 1.8, color } + if (iconKey === 'checkCircle') return + if (iconKey === 'clock') return + if (iconKey === 'list') return + return +} + export default function AdvancedScreen() { const { t, i18n } = useTranslation() const router = useRouter() @@ -73,7 +96,7 @@ export default function AdvancedScreen() { }) const capabilitiesQuery = useQuery({ - queryKey: ['ai-capabilities'], + queryKey: aiKeys.capabilities(), queryFn: () => apiClient(API.ai.capabilities), enabled: profile?.hasProAccess ?? false, staleTime: 5 * 60 * 1000, @@ -99,6 +122,11 @@ export default function AdvancedScreen() { }, [capabilitiesQuery.data]) const MAX_API_KEYS = 5 const canCreateKey = apiKeys.length < MAX_API_KEYS + const canCreateScopedKey = + canCreateKey && + !capabilitiesQuery.isLoading && + !capabilitiesQuery.error && + scopeOptions.length > 0 const [createKeyModalOpen, setCreateKeyModalOpen] = useState(false) const [createKeyError, setCreateKeyError] = useState(null) @@ -155,7 +183,7 @@ export default function AdvancedScreen() { ): Promise { setCreateKeyError(null) if (!isOnline) { - setCreateKeyError(t('calendarSync.notConnected')) + setCreateKeyError(t('errors.offline')) return null } try { @@ -165,10 +193,8 @@ export default function AdvancedScreen() { }) await queryClient.invalidateQueries({ queryKey: apiKeyKeys.all }) return result - } catch (err: unknown) { - setCreateKeyError( - err instanceof Error ? err.message : t('apiKeys.errors.create'), - ) + } catch { + setCreateKeyError(t('orbitMcp.createKeyError')) return null } } @@ -240,6 +266,14 @@ export default function AdvancedScreen() { ) : null} + {capabilitiesQuery.error && !capabilitiesQuery.isLoading ? ( + + + {t('orbitMcp.apiKeysError')} + + + ) : null} + {!apiKeysQuery.isLoading && !apiKeysQuery.error && apiKeys.length === 0 ? ( @@ -304,7 +338,7 @@ export default function AdvancedScreen() { {`${key.keyPrefix}…`} {meta} @@ -317,14 +351,17 @@ export default function AdvancedScreen() { setCreateKeyModalOpen(true)} + disabled={!canCreateScopedKey} accessibilityRole="button" accessibilityLabel={t('orbitMcp.createKey')} + accessibilityState={{ disabled: !canCreateScopedKey }} style={({ pressed }) => [ styles.createKeyChip, { backgroundColor: tokens.selectionBg, borderColor: tintFromPrimary(tokens, 0.45), }, + !canCreateScopedKey ? styles.createKeyChipDisabled : null, pressed ? styles.actionChipPressed : null, ]} hitSlop={8} @@ -338,7 +375,7 @@ export default function AdvancedScreen() { ) : ( {t('orbitMcp.maxKeysReached')} @@ -404,7 +441,7 @@ export default function AdvancedScreen() { - + {activeConfigTab === 'web' ? t('orbitMcp.webNoApiKey') : t('orbitMcp.replaceKey')} @@ -449,13 +486,49 @@ export default function AdvancedScreen() { - setShowWidgetInfo(false)} title={t('profile.widgetTitle')} - description={`1. ${t('profile.widgetHow.step1')}\n\n2. ${t('profile.widgetHow.step2')}\n\n3. ${t('profile.widgetHow.step3')}`} - variant="info" - /> + contentKey="widget-info" + snapPoints={['70%']} + > + + + {t('profile.widgetHow.title')} + + + {WIDGET_STEP_KEYS.map((stepKey, index) => ( + + + {`${index + 1}.`} + + + {t(stepKey)} + + + ))} + + + + {t('profile.widgetHow.featuresTitle')} + + + {WIDGET_FEATURES.map((feature) => ( + + + + {t(feature.textKey)} + + + ))} + + + + ) : factsQuery.error ? ( + + + + {t('profile.facts.factsError')} + + factsQuery.refetch()}> + {t('profile.facts.retry')} + + ) : facts.length === 0 ? ( diff --git a/apps/mobile/app/calendar-sync-auto-section.tsx b/apps/mobile/app/calendar-sync-auto-section.tsx new file mode 100644 index 0000000000..94bdc082ea --- /dev/null +++ b/apps/mobile/app/calendar-sync-auto-section.tsx @@ -0,0 +1,133 @@ +import { Pressable, Text, View, type ViewStyle } from 'react-native' +import { CalendarDays, RefreshCw } from 'lucide-react-native' +import type { TFunction } from 'i18next' +import { isCalendarAutoSyncStatusReconnectRequired } from '@orbit/shared/utils' +import type { CalendarAutoSyncState } from '@orbit/shared/types' +import type { AppTokensV2 } from '@/lib/theme' +import { SectionLabel } from '@/components/ui/section-label' +import { SettingsDescription } from '@/components/ui/settings-description' +import { Switch } from '@/components/ui/settings-row' +import type { CalendarSyncStyles } from './calendar-sync-styles' + +interface CalendarAutoSyncSectionProps { + styles: CalendarSyncStyles + tokens: AppTokensV2 + chipTint: ViewStyle + t: TFunction + autoSyncState: CalendarAutoSyncState | undefined + isStateLoading: boolean + hasConnection: boolean + connectionMeta: string + isOnline: boolean + isTogglePending: boolean + isSyncNowPending: boolean + isConnecting: boolean + onToggleAutoSync: (enabled: boolean) => void + onSyncNow: () => void + onReconnect: () => void +} + +export function CalendarAutoSyncSection({ + styles, + tokens, + chipTint, + t, + autoSyncState, + isStateLoading, + hasConnection, + connectionMeta, + isOnline, + isTogglePending, + isSyncNowPending, + isConnecting, + onToggleAutoSync, + onSyncNow, + onReconnect, +}: Readonly) { + const enabled = autoSyncState?.enabled ?? false + + return ( + <> + {t('calendar.autoSync.title')} + + + + + + + + {t('calendar.title')} + + + {connectionMeta} + + + onToggleAutoSync(!enabled)} + disabled={ + !hasConnection || isTogglePending || isStateLoading || !isOnline + } + accessibilityLabel={t('calendar.autoSync.title')} + /> + + + + {t('calendar.autoSync.description')} + + {hasConnection ? ( + + [ + styles.quietAction, + chipTint, + (pressed || isSyncNowPending) && styles.quietActionDim, + ]} + > + + + {isSyncNowPending + ? t('calendar.autoSync.syncNowRunning') + : t('calendar.autoSync.syncNow')} + + + + ) : null} + {isCalendarAutoSyncStatusReconnectRequired(autoSyncState?.status) ? ( + + + {t('calendar.autoSync.reconnectBody')} + + [ + styles.quietAction, + chipTint, + (pressed || isConnecting) && styles.quietActionDim, + ]} + > + + {t('calendar.autoSync.reconnectCta')} + + + + ) : null} + + ) +} diff --git a/apps/mobile/app/calendar-sync-styles.ts b/apps/mobile/app/calendar-sync-styles.ts new file mode 100644 index 0000000000..ecbc88915a --- /dev/null +++ b/apps/mobile/app/calendar-sync-styles.ts @@ -0,0 +1,136 @@ +import { StyleSheet } from 'react-native' + +export function createStyles() { + return StyleSheet.create({ + safeArea: { flex: 1 }, + container: { flex: 1 }, + scrollContent: { paddingBottom: 40 }, + cardPad: { + paddingHorizontal: 20, + paddingBottom: 14, + }, + connectionCard: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + borderRadius: 16, + borderWidth: 1, + paddingVertical: 16, + paddingHorizontal: 18, + }, + connectionIconSlot: { + width: 26, + alignItems: 'center', + flexShrink: 0, + }, + connectionBody: { + flex: 1, + minWidth: 0, + }, + connectionTitle: { + fontFamily: 'Rubik_400Regular', + fontSize: 18, + lineHeight: 22.5, + }, + connectionMeta: { + fontFamily: 'Rubik_400Regular', + fontSize: 13, + lineHeight: 18, + marginTop: 3, + }, + syncNowRow: { + flexDirection: 'row', + justifyContent: 'flex-end', + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: 6, + }, + reconnectBlock: { + paddingHorizontal: 20, + paddingVertical: 12, + gap: 8, + alignItems: 'flex-start', + }, + stateText: { + fontFamily: 'Rubik_400Regular', + fontSize: 14, + lineHeight: 19.6, + textAlign: 'center', + }, + stateTitle: { + fontFamily: 'Rubik_500Medium', + fontSize: 18, + lineHeight: 22.5, + textAlign: 'center', + }, + stateGlyphCircle: { + width: 64, + height: 64, + borderRadius: 999, + alignItems: 'center', + justifyContent: 'center', + }, + centerBlock: { + paddingHorizontal: 24, + paddingVertical: 32, + alignItems: 'center', + gap: 14, + }, + eventRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingHorizontal: 20, + paddingVertical: 14, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + eventBody: { + flex: 1, + gap: 3, + }, + eventTitle: { + fontFamily: 'Rubik_500Medium', + fontSize: 15, + }, + eventMeta: { + fontFamily: 'Roboto_400Regular', + fontSize: 12, + fontVariant: ['tabular-nums'], + }, + quietAction: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 7, + borderRadius: 999, + borderWidth: 1, + paddingVertical: 9, + paddingHorizontal: 16, + }, + quietActionDim: { + opacity: 0.6, + transform: [{ scale: 0.96 }], + }, + quietActionText: { + fontFamily: 'Rubik_500Medium', + fontSize: 13, + }, + actionPad: { + paddingHorizontal: 20, + paddingVertical: 18, + }, + progressTrack: { + width: 200, + height: 8, + borderRadius: 999, + overflow: 'hidden', + }, + progressFill: { + width: '60%', + height: '100%', + borderRadius: 999, + }, + }) +} + +export type CalendarSyncStyles = ReturnType diff --git a/apps/mobile/app/calendar-sync.tsx b/apps/mobile/app/calendar-sync.tsx index 5b59760b17..4c9134f51a 100644 --- a/apps/mobile/app/calendar-sync.tsx +++ b/apps/mobile/app/calendar-sync.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { + ActivityIndicator, Pressable, ScrollView, - StyleSheet, Text, View, } from 'react-native' @@ -10,16 +10,13 @@ import Animated, { FadeInDown, ReduceMotion } from 'react-native-reanimated' import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { SafeAreaView } from 'react-native-safe-area-context' import { useTranslation } from 'react-i18next' -import { CalendarDays, RefreshCw, WifiOff } from 'lucide-react-native' -import { API } from '@orbit/shared/api' +import { CalendarDays, Link as LinkIcon, WifiOff } from 'lucide-react-native' import { calendarKeys } from '@orbit/shared/query' import { buildCalendarAutoSyncImportRequest, buildCalendarSyncImportRequest, formatCalendarAutoSyncLastSynced, formatCalendarSyncRecurrenceLabel, - isCalendarAutoSyncStatusReconnectRequired, - isCalendarSyncNotConnectedMessage, type CalendarSyncEvent, } from '@orbit/shared/utils' import { getErrorMessage } from '@orbit/shared/utils/error-utils' @@ -32,7 +29,7 @@ import { useRunCalendarSyncNow, useSetCalendarAutoSync, } from '@/hooks/use-calendar-auto-sync' -import { apiClient } from '@/lib/api-client' +import { useCalendarEvents } from '@/hooks/use-calendar-events' import { plural } from '@/lib/plural' import { startMobileGoogleAuth } from '@/lib/google-auth' import { createTokensV2, tintFromPrimary } from '@/lib/theme' @@ -42,10 +39,11 @@ import { useAppToast } from '@/hooks/use-app-toast' import { useGoBackOrFallback } from '@/hooks/use-go-back-or-fallback' import { AppBar } from '@/components/ui/app-bar' import { SectionLabel } from '@/components/ui/section-label' -import { SettingsDescription } from '@/components/ui/settings-description' -import { SettingsRow, Switch } from '@/components/ui/settings-row' +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 { createStyles } from './calendar-sync-styles' type Step = | 'loading' @@ -69,22 +67,6 @@ interface ImportResult { habits: { id: string; title: string }[] } -async function fetchCalendarEvents(): Promise { - try { - const data = await apiClient(API.calendar.events) - return Array.isArray(data) ? data : [] - } catch (err: unknown) { - const message = err instanceof Error ? err.message : '' - if ( - message === 'Unauthorized' || - isCalendarSyncNotConnectedMessage(message) - ) { - throw new Error('__NOT_CONNECTED__') - } - throw err - } -} - export default function CalendarSyncScreen() { const router = useRouter() const goBackOrFallback = useGoBackOrFallback() @@ -130,6 +112,10 @@ export default function CalendarSyncScreen() { const [isConnecting, setIsConnecting] = useState(false) const fetchErrorText = t('calendar.fetchError') + const eventsQuery = useCalendarEvents({ + enabled: (profile?.hasProAccess ?? false) && !isReviewMode && isOnline, + }) + const allSelected = events.length > 0 && selectedIds.size === events.length const selectedEvents = useMemo( @@ -139,30 +125,6 @@ export default function CalendarSyncScreen() { const selectedCount = selectedIds.size - const fetchManualEvents = useCallback(async () => { - if (!isOnline) { - setStep('offline') - return - } - - setStep('loading') - setErrorMessage('') - - try { - const nextEvents = await fetchCalendarEvents() - setEvents(nextEvents) - setSelectedIds(new Set(nextEvents.map((event) => event.id))) - setStep('select') - } catch (err: unknown) { - if (err instanceof Error && err.message === '__NOT_CONNECTED__') { - setStep('not-connected') - return - } - setErrorMessage(getErrorMessage(err, fetchErrorText)) - setStep('error') - } - }, [fetchErrorText, isOnline]) - useFocusEffect( useCallback(() => { if (!profile) return @@ -186,8 +148,8 @@ export default function CalendarSyncScreen() { return } - void fetchManualEvents() - }, [fetchManualEvents, isOnline, isReviewMode, profile, queryClient, router]), + void eventsQuery.refetch() + }, [eventsQuery, isOnline, isReviewMode, profile, queryClient, router]), ) useEffect(() => { @@ -234,6 +196,40 @@ export default function CalendarSyncScreen() { fetchErrorText, ]) + useEffect(() => { + if (isReviewMode) return + if (!isOnline) { + setStep('offline') + return + } + if (eventsQuery.isLoading) { + setStep('loading') + return + } + if (eventsQuery.isError) { + setErrorMessage(getErrorMessage(eventsQuery.error, fetchErrorText)) + setStep('error') + return + } + if (eventsQuery.data?.status === 'not-connected') { + setStep('not-connected') + return + } + + const nextEvents = eventsQuery.data?.events ?? [] + setEvents(nextEvents) + setSelectedIds(new Set(nextEvents.map((event) => event.id))) + setStep('select') + }, [ + isOnline, + isReviewMode, + eventsQuery.data, + eventsQuery.error, + eventsQuery.isError, + eventsQuery.isLoading, + fetchErrorText, + ]) + const handleBack = useCallback(() => { goBackOrFallback('/profile') }, [goBackOrFallback]) @@ -392,16 +388,16 @@ export default function CalendarSyncScreen() { setStep('offline') return } + setStep('loading') + setErrorMessage('') if (isReviewMode) { - setStep('loading') - setErrorMessage('') void queryClient.invalidateQueries({ queryKey: calendarKeys.syncSuggestions(), }) return } - void fetchManualEvents() - }, [fetchManualEvents, isOnline, isReviewMode, queryClient]) + void eventsQuery.refetch() + }, [eventsQuery, isOnline, isReviewMode, queryClient]) function renderEventRow(event: CalendarEvent, index: number) { const selected = selectedIds.has(event.id) @@ -482,96 +478,23 @@ export default function CalendarSyncScreen() { showsVerticalScrollIndicator={false} > {profile?.hasProAccess && !isProfileLoading ? ( - <> - {t('calendar.autoSync.title')} - - - - - - - - Google Calendar - - - {connectionMeta} - - - - handleToggleAutoSync(!(autoSyncState?.enabled ?? false)) - } - disabled={ - !hasConnection || - setAutoSyncMutation.isPending || - autoSyncStateQuery.isLoading || - !isOnline - } - accessibilityLabel={t('calendar.autoSync.title')} - /> - - - {t('calendar.autoSync.description')} - {hasConnection ? ( - - [ - styles.quietAction, - chipTint, - (pressed || runSyncNowMutation.isPending) && styles.quietActionDim, - ]} - > - - - {runSyncNowMutation.isPending - ? t('calendar.autoSync.syncNowRunning') - : t('calendar.autoSync.syncNow')} - - - - ) : null} - {isCalendarAutoSyncStatusReconnectRequired(autoSyncState?.status) ? ( - - - {t('calendar.autoSync.reconnectBody')} - - [ - styles.quietAction, - chipTint, - (pressed || isConnecting) && styles.quietActionDim, - ]} - > - - {t('calendar.autoSync.reconnectCta')} - - - - ) : null} - + ) : null} {(isProfileLoading || step === 'loading') && ( @@ -580,7 +503,8 @@ export default function CalendarSyncScreen() { accessibilityLiveRegion="polite" accessibilityLabel={t('calendar.fetchingEvents')} > - + + {t('calendar.fetchingEvents')} @@ -592,6 +516,14 @@ export default function CalendarSyncScreen() { accessibilityLiveRegion="polite" accessibilityLabel={t('calendar.notConnectedTitle')} > + + + + + {t('calendar.notConnectedTitle')} + {t('calendar.notConnectedDesc')} @@ -635,12 +567,29 @@ export default function CalendarSyncScreen() { {step === 'select' && ( <> {events.length === 0 ? ( - - + + + {isReviewMode ? t('calendar.autoSync.reviewModeEmpty') : t('calendar.noEvents')} + [ + styles.quietAction, + chipTint, + pressed && styles.quietActionDim, + ]} + > + + {t('common.goBack')} + + ) : ( <> @@ -739,7 +688,7 @@ export default function CalendarSyncScreen() { accessibilityRole="alert" accessibilityLiveRegion="assertive" > - + {errorMessage || t('calendar.errorTitle')} ) } - -function createStyles() { - return StyleSheet.create({ - safeArea: { flex: 1 }, - container: { flex: 1 }, - scrollContent: { paddingBottom: 40 }, - cardPad: { - paddingHorizontal: 20, - paddingBottom: 14, - }, - connectionCard: { - flexDirection: 'row', - alignItems: 'center', - gap: 14, - borderRadius: 16, - borderWidth: 1, - paddingVertical: 16, - paddingHorizontal: 18, - }, - connectionIconSlot: { - width: 26, - alignItems: 'center', - flexShrink: 0, - }, - connectionBody: { - flex: 1, - minWidth: 0, - }, - connectionTitle: { - fontFamily: 'Rubik_400Regular', - fontSize: 18, - lineHeight: 22.5, - }, - connectionMeta: { - fontFamily: 'Rubik_400Regular', - fontSize: 13, - lineHeight: 18, - marginTop: 3, - }, - syncNowRow: { - flexDirection: 'row', - justifyContent: 'flex-end', - paddingHorizontal: 20, - paddingTop: 16, - paddingBottom: 6, - }, - reconnectBlock: { - paddingHorizontal: 20, - paddingVertical: 12, - gap: 8, - alignItems: 'flex-start', - }, - stateText: { - fontFamily: 'Rubik_400Regular', - fontSize: 14, - lineHeight: 19.6, - textAlign: 'center', - }, - centerBlock: { - paddingHorizontal: 24, - paddingVertical: 32, - alignItems: 'center', - gap: 14, - }, - eventRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - paddingHorizontal: 20, - paddingVertical: 14, - borderBottomWidth: StyleSheet.hairlineWidth, - }, - eventBody: { - flex: 1, - gap: 3, - }, - eventTitle: { - fontFamily: 'Rubik_500Medium', - fontSize: 15, - }, - eventMeta: { - fontFamily: 'Roboto_400Regular', - fontSize: 12, - fontVariant: ['tabular-nums'], - }, - quietAction: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 7, - borderRadius: 999, - borderWidth: 1, - paddingVertical: 9, - paddingHorizontal: 16, - }, - quietActionDim: { - opacity: 0.6, - transform: [{ scale: 0.96 }], - }, - quietActionText: { - fontFamily: 'Rubik_500Medium', - fontSize: 13, - }, - actionPad: { - paddingHorizontal: 20, - paddingVertical: 18, - }, - progressTrack: { - width: 200, - height: 8, - borderRadius: 999, - overflow: 'hidden', - }, - progressFill: { - width: '60%', - height: '100%', - borderRadius: 999, - }, - }) -} diff --git a/apps/mobile/app/chat.styles.ts b/apps/mobile/app/chat.styles.ts index bd21cdcff8..b3c3a47a03 100644 --- a/apps/mobile/app/chat.styles.ts +++ b/apps/mobile/app/chat.styles.ts @@ -62,7 +62,7 @@ export function createStyles(tokens: Tokens) { textAlign: "center", maxWidth: 300, marginTop: 4, - color: tokens.fg4, + color: tokens.fg3, }, messageList: { paddingVertical: 16, @@ -127,7 +127,7 @@ export function createStyles(tokens: Tokens) { quickChip: { flexDirection: "row", alignItems: "center", - minHeight: 36, + minHeight: 44, paddingHorizontal: 16, borderRadius: 999, backgroundColor: tokens.bgElev, diff --git a/apps/mobile/app/chat.tsx b/apps/mobile/app/chat.tsx index 7c313189df..b3dd80e7ec 100644 --- a/apps/mobile/app/chat.tsx +++ b/apps/mobile/app/chat.tsx @@ -50,8 +50,8 @@ export default function ChatScreen() { useTourTarget("tour-chat-input", chatInputRef); useTourTarget("tour-chat-voice", chatVoiceRef); - const offlineTitle = t("calendarSync.notConnected"); - const offlineDescription = `${t("chat.send")} / ${t("chat.attachImage")} / ${t("chat.toggleMic")}`; + const offlineTitle = t("chat.offline.title"); + const offlineDescription = t("chat.offline.description"); const { flatListRef, diff --git a/apps/mobile/app/login.tsx b/apps/mobile/app/login.tsx index a9a887c1ff..f6f7955a97 100644 --- a/apps/mobile/app/login.tsx +++ b/apps/mobile/app/login.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Animated, View, @@ -164,9 +164,11 @@ export default function LoginScreen() { resetCodeDigits, onCodeInput, onCodeKeyPress, - } = useLoginCodeEntry() - const offlineTitle = t('calendarSync.notConnected') - const offlineDescription = `${t('auth.sendCode')} / ${t('auth.verify')} / ${t('auth.signInWithGoogle')}` + } = useLoginCodeEntry(() => { + void verifyCode() + }) + const offlineTitle = t('offline.title') + const offlineDescription = t('offline.description') useEffect(() => { async function hydrateAuthFlowState() { @@ -224,18 +226,6 @@ export default function LoginScreen() { } }, []) - const verifyCodeRef = useRef(() => {}) - useEffect(() => { - verifyCodeRef.current = verifyCode - }) - - useEffect(() => { - if (step !== 'code' || isSubmitting) return - if (isVerificationCodeComplete(codeDigits)) { - verifyCodeRef.current() - } - }, [codeDigits, step, isSubmitting]) - function resolveLoginErrorState( err: unknown, source: 'google' | 'magic-code' = 'magic-code', @@ -317,6 +307,9 @@ export default function LoginScreen() { name: res.name, email: res.email, }) + if (res.wasReactivated) { + setSuccessMessage(t('profile.deleteAccount.reactivated')) + } if (referralCode) { await markReferralApplied() await clearStoredReferralCode() @@ -400,7 +393,7 @@ export default function LoginScreen() { const canSubmitEmail = Boolean(email.trim()) && !isSubmitting && isOnline const canSubmitCode = - codeDigits.join('').length === 6 && !isSubmitting && isOnline + isVerificationCodeComplete(codeDigits) && !isSubmitting && isOnline return ( @@ -688,7 +681,7 @@ function createStyles(tokens: AppTokensV2) { fontFamily: 'Rubik_400Regular', fontSize: 14, lineHeight: 22, - color: tokens.statusOverdue, + color: tokens.statusOverdueText, textAlign: 'center', }, @@ -714,14 +707,14 @@ function createStyles(tokens: AppTokensV2) { dividerText: { fontFamily: 'Rubik_400Regular', fontSize: 13, - color: tokens.fg4, + color: tokens.fg3, }, legal: { fontFamily: 'Rubik_400Regular', fontSize: 12, lineHeight: 18, - color: tokens.fg4, + color: tokens.fg3, textAlign: 'center', marginTop: 8, }, diff --git a/apps/mobile/app/retrospective.tsx b/apps/mobile/app/retrospective.tsx index be44d83aca..6dc293994d 100644 --- a/apps/mobile/app/retrospective.tsx +++ b/apps/mobile/app/retrospective.tsx @@ -115,6 +115,334 @@ function RetrospectiveBody({ ) } +interface RetrospectiveLockedFreeProps { + tokens: Tokens + onSubscribe: () => void +} + +function RetrospectiveLockedFree({ + tokens, + onSubscribe, +}: Readonly) { + const { t } = useTranslation() + return ( + + + + + + {t('retrospective.locked')} + + + {t('retrospective.lockedHint')} + + + {t('upgrade.subscribe')} + + + ) +} + +interface RetrospectiveLockedYearlyProps { + tokens: Tokens + isTrialActive: boolean + isOnline: boolean + portalError: string + onSubscribe: () => void + onOpenPortal: () => void +} + +function RetrospectiveLockedYearly({ + tokens, + isTrialActive, + isOnline, + portalError, + onSubscribe, + onOpenPortal, +}: Readonly) { + const { t } = useTranslation() + return ( + + + + + + {t('retrospective.lockedYearly')} + + + {t('retrospective.lockedYearlyHint')} + + {isTrialActive ? ( + + {t('upgrade.subscribe')} + + ) : ( + + {t('retrospective.changePlan')} + + )} + {!isOnline ? ( + + ) : null} + {portalError ? ( + + {portalError} + + ) : null} + + ) +} + +interface RetrospectiveReportCardProps { + tokens: Tokens + text: string + fromCache: boolean + onRegenerate: () => void +} + +function RetrospectiveReportCard({ + tokens, + text, + fromCache, + onRegenerate, +}: Readonly) { + const { t } = useTranslation() + return ( + + + + + + + {t('retrospective.astraEyebrow').toUpperCase()} + + + [ + styles.actionChip, + { + backgroundColor: pressed ? tokens.bgElev2 : tokens.bgElev, + borderColor: tokens.hairline, + }, + pressed ? styles.actionChipPressed : null, + ]} + > + + {t('retrospective.regenerate')} + + + + + + {t('aiDisclosure.notMedicalAdvice')} + + {fromCache ? ( + + {t('retrospective.cached')} + + ) : null} + + + ) +} + +interface RetrospectiveContentProps { + tokens: Tokens + isOnline: boolean + isLoading: boolean + isCacheLoading: boolean + period: RetrospectivePeriod + periodChips: { id: RetrospectivePeriod; label: string }[] + displayedRetrospective: string | null + displayedFromCache: boolean + error: string | null + onSelectPeriod: (next: RetrospectivePeriod) => void + onGenerate: () => void +} + +function RetrospectiveContent({ + tokens, + isOnline, + isLoading, + isCacheLoading, + period, + periodChips, + displayedRetrospective, + displayedFromCache, + error, + onSelectPeriod, + onGenerate, +}: Readonly) { + const { t } = useTranslation() + return ( + <> + {!isOnline ? ( + + + + ) : null} + + + + {periodChips.map((p) => ( + onSelectPeriod(p.id)} + > + {p.label} + + ))} + + + + {isLoading ? ( + + + {t('retrospective.generating')} + + + + + + ) : null} + + {!isLoading && displayedRetrospective ? ( + + ) : null} + + {!isLoading && error && (!displayedRetrospective || isOnline) ? ( + + + {t('retrospective.error')} + + [ + styles.actionChip, + { + backgroundColor: pressed ? tokens.bgElev2 : tokens.bgElev, + borderColor: tokens.hairline, + }, + pressed ? styles.actionChipPressed : null, + ]} + > + + {t('common.retry')} + + + + ) : null} + + {!isLoading && + !displayedRetrospective && + !error && + !isCacheLoading ? ( + + + + + + + } + > + {t('retrospective.generate')} + + + + ) : null} + + ) +} + export default function RetrospectiveScreen() { const router = useRouter() const goBackOrFallback = useGoBackOrFallback() @@ -190,7 +518,7 @@ export default function RetrospectiveScreen() { async function handleOpenPortal() { if (!isOnline) { - setPortalError(t('calendarSync.notConnected')) + setPortalError(t('offline.title')) return } @@ -209,7 +537,7 @@ export default function RetrospectiveScreen() { function handleGenerate() { if (!isOnline) { - setError(t('calendarSync.notConnected')) + setError(t('offline.title')) return } void generate() @@ -244,259 +572,39 @@ export default function RetrospectiveScreen() { showsVerticalScrollIndicator={false} > {isLoaded && !hasProAccess ? ( - - - - - - {t('retrospective.locked')} - - - {t('retrospective.lockedHint')} - - router.push(buildUpgradeHref('/retrospective'))} - accessibilityLabel={t('upgrade.subscribe')} - style={styles.lockedCta} - > - {t('upgrade.subscribe')} - - + router.push(buildUpgradeHref('/retrospective'))} + /> ) : null} {isLoaded && hasProAccess && !isYearlyPro ? ( - - - - - - {t('retrospective.lockedYearly')} - - - {t('retrospective.lockedYearlyHint')} - - {profile?.isTrialActive ? ( - router.push(buildUpgradeHref('/retrospective'))} - accessibilityLabel={t('upgrade.subscribe')} - style={styles.lockedCta} - > - {t('upgrade.subscribe')} - - ) : ( - { - void handleOpenPortal() - }} - disabled={!isOnline} - accessibilityLabel={t('retrospective.changePlan')} - style={styles.lockedCta} - > - {t('retrospective.changePlan')} - - )} - {!isOnline ? ( - - ) : null} - {portalError ? ( - - {portalError} - - ) : null} - + router.push(buildUpgradeHref('/retrospective'))} + onOpenPortal={() => { + void handleOpenPortal() + }} + /> ) : null} {isLoaded && isYearlyPro ? ( - <> - {!isOnline ? ( - - - - ) : null} - - - - {periodChips.map((p) => ( - selectPeriod(p.id)} - > - {p.label} - - ))} - - - - {isLoading ? ( - - - {t('retrospective.generating')} - - - - - - ) : null} - - {!isLoading && displayedRetrospective ? ( - - - - - - - {t('retrospective.astraEyebrow').toUpperCase()} - - - [ - styles.actionChip, - { - backgroundColor: pressed ? tokens.bgElev2 : tokens.bgElev, - borderColor: tokens.hairline, - }, - pressed ? styles.actionChipPressed : null, - ]} - > - - {t('retrospective.regenerate')} - - - - - - {t('aiDisclosure.notMedicalAdvice')} - - {displayedFromCache ? ( - - {t('retrospective.cached')} - - ) : null} - - - ) : null} - - {!isLoading && error && (!displayedRetrospective || isOnline) ? ( - - - {t('retrospective.error')} - - [ - styles.actionChip, - { - backgroundColor: pressed ? tokens.bgElev2 : tokens.bgElev, - borderColor: tokens.hairline, - }, - pressed ? styles.actionChipPressed : null, - ]} - > - - {t('common.retry')} - - - - ) : null} - - {!isLoading && - !displayedRetrospective && - !error && - !isCacheLoading ? ( - - - - - - - } - > - {t('retrospective.generate')} - - - - ) : null} - + ) : null} diff --git a/apps/mobile/app/streak-sections.tsx b/apps/mobile/app/streak-sections.tsx index 3e4413d34f..7079ead9f7 100644 --- a/apps/mobile/app/streak-sections.tsx +++ b/apps/mobile/app/streak-sections.tsx @@ -102,7 +102,7 @@ export function StreakTimelineCard({ {weekDays.map((day) => ( {day.dayLabel.toUpperCase()} @@ -169,7 +169,7 @@ function StreakDayCell({ }: Readonly) { const inRun = isInRun(day.status) - let numeralColor = tokens.fg4 + let numeralColor = tokens.fg3 if (day.status === 'active' || day.status === 'frozen' || day.status === 'today') { numeralColor = tokens.fg1 } @@ -418,7 +418,7 @@ export function FreezeProgressCard({ {isFrozenToday ? ( } + icon={} label={t('streakDisplay.freeze.protected.today')} trailing={ ( } + icon={} label={displayDate(date, { month: 'short', day: 'numeric' })} /> ))} diff --git a/apps/mobile/app/support.tsx b/apps/mobile/app/support.tsx index a505a3f6e5..750d17eca9 100644 --- a/apps/mobile/app/support.tsx +++ b/apps/mobile/app/support.tsx @@ -82,6 +82,132 @@ function SupportField({ ) } +function SupportSuccessState({ tokens }: Readonly<{ tokens: Tokens }>) { + const { t } = useTranslation() + return ( + + + + + + {t('profile.support.success')} + + + {t('profile.support.successHint')} + + + ) +} + +interface SupportFormProps { + tokens: Tokens + isOnline: boolean + name: string + email: string + subject: string + message: string + nameError: string | null + emailError: string | null + error: string | null + canSend: boolean + onChangeName: (value: string) => void + onChangeEmail: (value: string) => void + onChangeSubject: (value: string) => void + onChangeMessage: (value: string) => void + onSend: () => void +} + +function SupportForm({ + tokens, + isOnline, + name, + email, + subject, + message, + nameError, + emailError, + error, + canSend, + onChangeName, + onChangeEmail, + onChangeSubject, + onChangeMessage, + onSend, +}: Readonly) { + const { t } = useTranslation() + return ( + + {!isOnline ? ( + + ) : null} + + + + + + + + + + + {error ? ( + + {error} + + ) : null} + + + {t('profile.support.send')} + + + + ) +} + export default function SupportScreen() { const goBackOrFallback = useGoBackOrFallback() const { t } = useTranslation() @@ -170,7 +296,7 @@ export default function SupportScreen() { const handleSend = useCallback(async () => { if (!isOnline) { - setError(t('calendarSync.notConnected')) + setError(t('offline.title')) return } if (!subject.trim() || !message.trim()) return @@ -220,89 +346,27 @@ export default function SupportScreen() { keyboardVerticalOffset={12} > {success ? ( - - - - - - {t('profile.support.success')} - - - {t('profile.support.successHint')} - - + ) : ( - - {!isOnline ? ( - - ) : null} - - - - - - - - - - - {error ? ( - - {error} - - ) : null} - - { - void handleSend() - }} - disabled={!canSend} - fullWidth - accessibilityLabel={t('profile.support.send')} - > - {t('profile.support.send')} - - - + { + void handleSend() + }} + /> )} diff --git a/apps/mobile/app/upgrade.tsx b/apps/mobile/app/upgrade.tsx index 1baaa9d23f..e0ac2c37b3 100644 --- a/apps/mobile/app/upgrade.tsx +++ b/apps/mobile/app/upgrade.tsx @@ -29,7 +29,7 @@ import { X as XIcon, } from 'lucide-react-native' import { API } from '@orbit/shared/api' -import { formatLocaleDate, getErrorMessage, playManageSubscriptionUrl } from '@orbit/shared/utils' +import { applySubscriptionDiscount, formatLocaleDate, getErrorMessage, playManageSubscriptionUrl } from '@orbit/shared/utils' import { TRIAL_EXPIRED_FEATURE_KEYS, UPGRADE_FEATURE_CATEGORIES, @@ -115,7 +115,8 @@ function monthlyEquivalentPriceLabel(plans: SubscriptionPlans, yearlyOffer: Play yearlyOffer.currency ?? plans.currency, ) } - return formatPrice(monthlyEquivalent(plans.yearly.unitAmount), plans.currency) + const discountedYearly = applySubscriptionDiscount(plans.yearly.unitAmount, plans.couponPercentOff) + return formatPrice(monthlyEquivalent(discountedYearly), plans.currency) } function UsageCard({ @@ -143,7 +144,7 @@ function UsageCard({ {t('upgrade.billing.usage.aiMessagesOf', { @@ -178,8 +179,13 @@ function PlanSelection({ onSelectInterval: (interval: SubscriptionInterval) => void t: UpgradeTextFn }>) { - const yearlyCharge = yearlyPrice ?? formatPrice(plans.yearly.unitAmount, plans.currency) - const monthlyCharge = monthlyPrice ?? formatPrice(plans.monthly.unitAmount, plans.currency) + const yearlyCharge = yearlyPrice + ?? formatPrice(applySubscriptionDiscount(plans.yearly.unitAmount, plans.couponPercentOff), plans.currency) + const monthlyCharge = monthlyPrice + ?? formatPrice(applySubscriptionDiscount(plans.monthly.unitAmount, plans.couponPercentOff), plans.currency) + const discountSuffix = !yearlyPrice && plans.couponPercentOff + ? ` · ${t('upgrade.plans.coupon.discountBadge', { percent: plans.couponPercentOff })}` + : '' return ( @@ -189,7 +195,7 @@ function PlanSelection({ price={t('upgrade.plans.equivalent', { price: monthlyEquivalentPriceLabel(plans, yearlyOffer), })} - sub={`${yearlyCharge}${t('upgrade.plans.yearly.period')}`} + sub={`${yearlyCharge}${t('upgrade.plans.yearly.period')}${discountSuffix}`} features={[ t('upgrade.plans.yearly.includesMonthly'), ...UPGRADE_YEARLY_EXTRA_FEATURES.map((feature) => @@ -406,7 +412,7 @@ export default function UpgradeScreen() { async function handlePortal() { if (!isOnline) { - setPortalError(t('calendarSync.notConnected')) + setPortalError(t('offline.title')) return } @@ -470,7 +476,7 @@ export default function UpgradeScreen() { > {t('upgrade.billing.actions.managePlay')} - + {t('upgrade.billing.actions.managePlayHint')} {portalError ? ( @@ -588,8 +594,8 @@ export default function UpgradeScreen() { return ( @@ -602,6 +608,7 @@ export default function UpgradeScreen() { {t('upgrade.billing.error')} { refetchBilling().catch(() => {}) }} @@ -702,7 +709,7 @@ export default function UpgradeScreen() { > {t('upgrade.billing.actions.manage')} - + {t('upgrade.billing.actions.manageHint')} {portalError ? ( @@ -776,6 +783,7 @@ export default function UpgradeScreen() { {t('upgrade.plans.error')} { refetchPlans().catch(() => {}) }} @@ -867,7 +875,7 @@ export default function UpgradeScreen() { )} - + {t('upgrade.plans.renewalNote')} @@ -900,8 +908,8 @@ export default function UpgradeScreen() { {!isOnline ? ( diff --git a/apps/mobile/components/chat/action-chips.tsx b/apps/mobile/components/chat/action-chips.tsx index 80cfb3548d..04ef4edcae 100644 --- a/apps/mobile/components/chat/action-chips.tsx +++ b/apps/mobile/components/chat/action-chips.tsx @@ -179,7 +179,7 @@ function chipStyle( }; case "Failed": return { - text: tokens.statusBad, + text: tokens.statusBadText, bg: `${tokens.statusBad}1A`, border: `${tokens.statusBad}4D`, Icon: XCircle, @@ -217,7 +217,7 @@ function createStyles(tokens: AppTokens) { errorText: { fontFamily: 'Rubik_400Regular', fontSize: 12, - color: tokens.statusBad, + color: tokens.statusBadText, marginTop: 4, paddingLeft: 4, }, diff --git a/apps/mobile/components/chat/breakdown-suggestion.tsx b/apps/mobile/components/chat/breakdown-suggestion.tsx index 4a90d0ed94..58319bd925 100644 --- a/apps/mobile/components/chat/breakdown-suggestion.tsx +++ b/apps/mobile/components/chat/breakdown-suggestion.tsx @@ -9,8 +9,13 @@ import { import { Check, X, Plus } from 'lucide-react-native' import { useTranslation } from 'react-i18next' import type { SuggestedSubHabit } from '@orbit/shared/types/chat' -import type { BulkHabitItem, FrequencyUnit } from '@orbit/shared/types/habit' +import type { FrequencyUnit } from '@orbit/shared/types/habit' import { frequencyUnitSchema } from '@orbit/shared/types/habit' +import { + buildBreakdownCreateRequest, + filterValidBreakdownHabits, + type BreakdownEditableHabit, +} from '@orbit/shared/utils' import { useBulkCreateHabits } from '@/hooks/use-habits' import { AppTextInput } from '@/components/ui/app-text-input' import { PillButton } from '@/components/ui/pill-button' @@ -20,16 +25,8 @@ import { plural } from '@/lib/plural' type AppTokens = ReturnType -interface EditableHabit { +interface EditableHabit extends BreakdownEditableHabit { id: string - title: string - description: string - frequencyUnit: FrequencyUnit | null - frequencyQuantity: number | null - days: string[] | null - isBadHabit: boolean - dueDate: string | null - checklistItems: { text: string; isChecked: boolean }[] | null } interface BreakdownSuggestionProps { @@ -43,6 +40,20 @@ function createEditableHabitId() { return `editable-habit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } +function createEditableHabit(source?: SuggestedSubHabit): EditableHabit { + return { + id: createEditableHabitId(), + title: source?.title ?? '', + description: source?.description ?? '', + frequencyUnit: source?.frequencyUnit ?? null, + frequencyQuantity: source?.frequencyQuantity ?? null, + days: source?.days ?? null, + isBadHabit: source?.isBadHabit ?? false, + dueDate: source?.dueDate ?? null, + checklistItems: source?.checklistItems ?? null, + } +} + const FREQUENCY_OPTIONS: { value: string; labelKey: string }[] = [ { value: '', labelKey: 'habits.filter.oneTime' }, { value: 'Day', labelKey: 'habits.filter.daily' }, @@ -51,6 +62,232 @@ const FREQUENCY_OPTIONS: { value: string; labelKey: string }[] = [ { value: 'Year', labelKey: 'habits.filter.yearly' }, ] +type BreakdownStyles = ReturnType + +interface BreakdownHabitRowProps { + habit: EditableHabit + tokens: AppTokens + styles: BreakdownStyles + onUpdate: (patch: Partial) => void + onRemove: () => void +} + +function BreakdownHabitRow({ + habit, + tokens, + styles, + onUpdate, + onRemove, +}: Readonly) { + const { t } = useTranslation() + return ( + + + onUpdate({ title: text })} + placeholder={t('habits.breakdown.habitNamePlaceholder')} + placeholderTextColor={tokens.fg3} + /> + + {FREQUENCY_OPTIONS.map((opt) => { + const isActive = + opt.value === '' ? !habit.frequencyUnit : habit.frequencyUnit === opt.value + return ( + { + const parsed = frequencyUnitSchema.safeParse(opt.value) + const val: FrequencyUnit | null = parsed.success ? parsed.data : null + onUpdate({ + frequencyUnit: val, + frequencyQuantity: val ? habit.frequencyQuantity : null, + }) + }} + > + + {t(opt.labelKey)} + + + ) + })} + + {habit.frequencyUnit ? ( + + {t('habits.breakdown.every')} + + onUpdate({ + frequencyQuantity: Number(text.replace(/[^0-9]/g, '')) || 1, + }) + } + keyboardType="number-pad" + accessibilityLabel={t('habits.breakdown.frequencyQuantityLabel')} + /> + + {t(`habits.form.unit${habit.frequencyUnit}` as 'habits.form.unitDay')} + + + ) : null} + + + + + + ) +} + +interface BreakdownSuccessCardProps { + tokens: AppTokens + styles: BreakdownStyles + parentName: string + createdCount: number + createAsParent: boolean +} + +function BreakdownSuccessCard({ + tokens, + styles, + parentName, + createdCount, + createAsParent, +}: Readonly) { + const { t } = useTranslation() + return ( + + + + + + + {createAsParent + ? plural( + t('habits.breakdown.createAsParentSuccess', { + name: parentName, + n: createdCount, + }), + createdCount, + ) + : plural(t('habits.breakdown.createdSuccess', { n: createdCount }), createdCount)} + + + + ) +} + +interface BreakdownActionsProps { + tokens: AppTokens + styles: BreakdownStyles + createAsParent: boolean + createError: string + validCount: number + isSubmitting: boolean + onAddHabit: () => void + onToggleCreateAsParent: () => void + onConfirm: () => void + onCancel: () => void +} + +function BreakdownActions({ + tokens, + styles, + createAsParent, + createError, + validCount, + isSubmitting, + onAddHabit, + onToggleCreateAsParent, + onConfirm, + onCancel, +}: Readonly) { + const { t } = useTranslation() + return ( + <> + + + {t('habits.breakdown.addHabit')} + + + + + {createAsParent && } + + + {t('habits.breakdown.createAsParent')} + + + + {createError !== '' && ( + {createError} + )} + + + : undefined + } + > + + {plural(t('habits.breakdown.createCount', { n: validCount }), validCount)} + + + + {t('common.cancel')} + + + + ) +} + export function BreakdownSuggestion({ parentName, subHabits, @@ -67,17 +304,7 @@ export function BreakdownSuggestion({ const styles = useMemo(() => createStyles(tokens), [tokens]) const [habits, setHabits] = useState( - subHabits.map((h) => ({ - id: createEditableHabitId(), - title: h.title, - description: h.description ?? '', - frequencyUnit: h.frequencyUnit ?? null, - frequencyQuantity: h.frequencyQuantity ?? null, - days: h.days ?? null, - isBadHabit: h.isBadHabit ?? false, - dueDate: h.dueDate ?? null, - checklistItems: h.checklistItems ?? null, - })), + subHabits.map((h) => createEditableHabit(h)), ) const [isCreated, setIsCreated] = useState(false) @@ -86,7 +313,7 @@ export function BreakdownSuggestion({ const [createError, setCreateError] = useState('') const validHabits = useMemo( - () => habits.filter((h) => h.title.trim().length > 0), + () => filterValidBreakdownHabits(habits), [habits], ) @@ -99,27 +326,7 @@ export function BreakdownSuggestion({ } function addHabit() { - setHabits((prev) => [ - ...prev, - { - id: createEditableHabitId(), - title: '', - description: '', - frequencyUnit: null, - frequencyQuantity: null, - days: null, - isBadHabit: false, - dueDate: null, - checklistItems: null, - }, - ]) - } - - function resolveFrequencyQuantity(habit: EditableHabit): number | undefined { - if (!habit.frequencyUnit) return undefined - return habit.frequencyQuantity && habit.frequencyQuantity >= 1 - ? habit.frequencyQuantity - : 1 + setHabits((prev) => [...prev, createEditableHabit()]) } async function handleConfirm() { @@ -127,52 +334,17 @@ export function BreakdownSuggestion({ setCreateError('') try { - const subItems: BulkHabitItem[] = validHabits.map((h) => ({ - title: h.title.trim(), - description: h.description.trim() || undefined, - frequencyUnit: h.frequencyUnit ?? undefined, - frequencyQuantity: resolveFrequencyQuantity(h), - days: h.days ?? undefined, - isBadHabit: h.isBadHabit, - dueDate: h.dueDate ?? undefined, - checklistItems: h.checklistItems ?? undefined, - })) - - if (createAsParent) { - const firstWithFreq = validHabits.find((h) => h.frequencyUnit) - const earliestDueDate = - validHabits - .map((h) => h.dueDate) - .filter((d): d is string => !!d) - .sort((a, b) => a.localeCompare(b))[0] ?? new Date().toISOString().slice(0, 10) - - let parentFreqQty: number | undefined - if (firstWithFreq?.frequencyUnit) { - parentFreqQty = resolveFrequencyQuantity(firstWithFreq) - } - - await bulkCreate.mutateAsync({ - habits: [ - { - title: parentName, - frequencyUnit: firstWithFreq?.frequencyUnit ?? undefined, - frequencyQuantity: parentFreqQty, - dueDate: earliestDueDate, - subHabits: subItems, - }, - ], - }) - setCreatedCount(subItems.length) - } else { - await bulkCreate.mutateAsync({ habits: subItems }) - setCreatedCount(subItems.length) - } - + await bulkCreate.mutateAsync( + buildBreakdownCreateRequest(validHabits, parentName, createAsParent), + ) + setCreatedCount(validHabits.length) setIsCreated(true) onConfirmed() } catch (err: unknown) { setCreateError( - err instanceof Error ? err.message : t('errors.bulkCreateHabits'), + process.env.NODE_ENV === 'development' && err instanceof Error + ? err.message + : t('errors.bulkCreateHabits'), ) } } @@ -181,24 +353,13 @@ export function BreakdownSuggestion({ if (isCreated) { return ( - - - - - - - {createAsParent - ? plural( - t('habits.breakdown.createAsParentSuccess', { - name: parentName, - n: createdCount, - }), - createdCount, - ) - : plural(t('habits.breakdown.createdSuccess', { n: createdCount }), createdCount)} - - - + ) } @@ -210,115 +371,31 @@ export function BreakdownSuggestion({ {habits.map((habit, index) => ( - - - updateHabit(index, { title: text })} - placeholder={t('habits.breakdown.habitNamePlaceholder')} - placeholderTextColor={tokens.fg3} - /> - - {FREQUENCY_OPTIONS.map((opt) => { - const isActive = - opt.value === '' ? !habit.frequencyUnit : habit.frequencyUnit === opt.value - return ( - { - const parsed = frequencyUnitSchema.safeParse(opt.value) - const val: FrequencyUnit | null = parsed.success ? parsed.data : null - updateHabit(index, { - frequencyUnit: val, - frequencyQuantity: val ? habit.frequencyQuantity : null, - }) - }} - > - - {t(opt.labelKey)} - - - ) - })} - - - removeHabit(index)} - > - - - + updateHabit(index, patch)} + onRemove={() => removeHabit(index)} + /> ))} - - - {t('habits.breakdown.addHabit')} - - - setCreateAsParent((prev) => !prev)} - > - - {createAsParent && } - - - {t('habits.breakdown.createAsParent')} - - - - {createError !== '' && ( - {createError} - )} - - - { - void handleConfirm() - }} - leading={ - isSubmitting ? : undefined - } - > - - {plural(t('habits.breakdown.createCount', { n: validHabits.length }), validHabits.length)} - - - - {t('common.cancel')} - - + setCreateAsParent((prev) => !prev)} + onConfirm={() => { + void handleConfirm() + }} + onCancel={onCancelled} + /> ) } @@ -390,6 +467,28 @@ function createStyles(tokens: AppTokens) { fontFamily: 'Rubik_500Medium', color: tokens.primary, }, + quantityRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + quantityLabel: { + fontFamily: 'Rubik_400Regular', + fontSize: 11, + color: tokens.fg3, + }, + quantityInput: { + fontFamily: 'Rubik_500Medium', + fontSize: 11, + color: tokens.fg2, + textAlign: 'center', + minWidth: 32, + minHeight: 0, + paddingVertical: 2, + paddingHorizontal: 6, + backgroundColor: tokens.bgField, + borderRadius: 6, + }, removeBtn: { padding: 6, borderRadius: 999, @@ -414,7 +513,7 @@ function createStyles(tokens: AppTokens) { height: 16, borderRadius: 4, borderWidth: 2, - borderColor: tokens.fg4, + borderColor: tokens.fg3, alignItems: 'center', justifyContent: 'center', }, diff --git a/apps/mobile/components/chat/chat-animations.tsx b/apps/mobile/components/chat/chat-animations.tsx index 5f2b537147..0ea39a7fe3 100644 --- a/apps/mobile/components/chat/chat-animations.tsx +++ b/apps/mobile/components/chat/chat-animations.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo } from "react"; import { View, Animated } from "react-native"; import { CHAT_VISUALIZER_BAR_OFFSETS as VISUALIZER_BAR_OFFSETS } from "@orbit/shared/chat"; +import { usePrefersReducedMotion } from "@/lib/motion"; import type { ChatStyles } from "@/app/chat.styles"; function AnimatedVisualizerBar({ @@ -11,8 +12,14 @@ function AnimatedVisualizerBar({ styles: ChatStyles; }>) { const scale = useMemo(() => new Animated.Value(0.45), []); + const prefersReducedMotion = usePrefersReducedMotion(); useEffect(() => { + if (prefersReducedMotion) { + scale.setValue(0.45); + return; + } + const animation = Animated.loop( Animated.sequence([ Animated.timing(scale, { @@ -31,7 +38,7 @@ function AnimatedVisualizerBar({ animation.start(); return () => animation.stop(); - }, [delay, scale]); + }, [delay, prefersReducedMotion, scale]); return ( ) { + const { t } = useTranslation(); const [draft, setDraft] = useState(""); const prevIsRecording = useRef(false); @@ -122,6 +124,7 @@ export const ChatComposerInput = memo(function ChatComposerInput({ onPress={handleSend} disabled={!canSend || !isOnline} accessibilityRole="button" + accessibilityLabel={t("chat.send")} accessibilityState={{ disabled: !canSend || !isOnline }} > diff --git a/apps/mobile/components/chat/chat-input-area.tsx b/apps/mobile/components/chat/chat-input-area.tsx index fc58ae4783..e09535def6 100644 --- a/apps/mobile/components/chat/chat-input-area.tsx +++ b/apps/mobile/components/chat/chat-input-area.tsx @@ -60,6 +60,200 @@ interface ChatInputAreaProps { onUpgrade: () => void; } +interface ChatInputNoticesProps { + tokens: Tokens; + styles: ChatStyles; + hasMessages: boolean; + isOnline: boolean; + offlineTitle: string; + offlineDescription: string; + sendError: string | null; + canRetry: boolean; + speechError: string | null; + imagePreview: string | null; + onRetry: () => void; + onRemoveImage: () => void; +} + +function ChatInputNotices({ + tokens, + styles, + hasMessages, + isOnline, + offlineTitle, + offlineDescription, + sendError, + canRetry, + speechError, + imagePreview, + onRetry, + onRemoveImage, +}: Readonly) { + const { t } = useTranslation(); + return ( + <> + {sendError && ( + + {sendError} + {canRetry ? ( + + {" " + t("common.retry")} + + ) : null} + + )} + {!isOnline && ( + + )} + {speechError === t("speech.micDenied") && ( + { + void Linking.openSettings().catch(() => {}); + }} + activeOpacity={0.75} + accessibilityRole="button" + > + + {t("common.openSettings")} + + + )} + + {imagePreview && ( + + + + + + + + + )} + + {hasMessages && !isOnline ? ( + + + {offlineTitle} + + ) : null} + + ); +} + +interface ChatStarterChipsProps { + styles: ChatStyles; + starterChips: readonly string[]; + onSendChip: (chip: string) => void; +} + +function ChatStarterChips({ styles, starterChips, onSendChip }: Readonly) { + return ( + + {starterChips.map((chip, index) => ( + + onSendChip(chip)} + style={({ pressed }) => [ + styles.quickChip, + pressed && styles.quickChipPressed, + ]} + > + {chip} + + + ))} + + ); +} + +interface ChatLimitNoticeProps { + tokens: Tokens; + styles: ChatStyles; + reward: ChatRewardState; + onUpgrade: () => void; +} + +function ChatLimitNotice({ tokens, styles, reward, onUpgrade }: Readonly) { + const { t } = useTranslation(); + return ( + + + } + onPress={onUpgrade} + > + {t("upgrade.subscribe")} + + {reward.adsEnabledForUser ? ( + + + + {reward.isLoadingReward + ? t("common.loading") + : t("ads.watchForMessages")} + + + + {reward.rewardsClaimedToday}/{reward.dailyRewardCap}{" "} + {t("ads.dailyLimitReached")} + + {reward.rewardMessage ? ( + + {reward.rewardMessage} + + ) : null} + + ) : null} + + ); +} + export const ChatInputArea = forwardRef>( function ChatInputArea(props, inputAreaRef) { const { t } = useTranslation(); @@ -100,101 +294,27 @@ export const ChatInputArea = forwardRef>( }, ]} > - {sendError && ( - - {sendError} - {canRetry ? ( - - {" " + t("common.retry")} - - ) : null} - - )} - {!isOnline && ( - - )} - {speechError === t("speech.micDenied") && ( - { - void Linking.openSettings().catch(() => {}); - }} - activeOpacity={0.75} - > - - {t("common.openSettings")} - - - )} - - {imagePreview && ( - - - - - - - - - )} - - {hasMessages && !isOnline ? ( - - - {offlineTitle} - - ) : null} + {hasMessages && ( - - {starterChips.map((chip, index) => ( - - onSendChip(chip)} - style={({ pressed }) => [ - styles.quickChip, - pressed && styles.quickChipPressed, - ]} - hitSlop={{ top: 4, bottom: 4 }} - > - {chip} - - - ))} - + )} >( /> {!hasProAccess && atMessageLimit && ( - - - } - onPress={onUpgrade} - > - {t("upgrade.subscribe")} - - {reward.adsEnabledForUser ? ( - - - - {reward.isLoadingReward - ? t("common.loading") - : t("ads.watchForMessages")} - - - - {reward.rewardsClaimedToday}/{reward.dailyRewardCap}{" "} - {t("ads.dailyLimitReached")} - - {reward.rewardMessage ? ( - - {reward.rewardMessage} - - ) : null} - - ) : null} - + )} {!hasProAccess && !atMessageLimit && ( diff --git a/apps/mobile/components/chat/chat-input-bar.tsx b/apps/mobile/components/chat/chat-input-bar.tsx index fefc9830bc..450f66a56e 100644 --- a/apps/mobile/components/chat/chat-input-bar.tsx +++ b/apps/mobile/components/chat/chat-input-bar.tsx @@ -154,6 +154,8 @@ export const ChatInputBar = forwardRef>( key={lang.value} activeOpacity={0.7} onPress={() => onSelectLanguage(lang.value)} + accessibilityRole="button" + accessibilityState={{ selected: speechLang === lang.value }} style={[ styles.languageOption, speechLang === lang.value && { diff --git a/apps/mobile/components/chat/clarification-card.tsx b/apps/mobile/components/chat/clarification-card.tsx index 14394c718f..3276688e1a 100644 --- a/apps/mobile/components/chat/clarification-card.tsx +++ b/apps/mobile/components/chat/clarification-card.tsx @@ -106,6 +106,8 @@ export function ClarificationCard({ activeOpacity={0.7} disabled={disabled} onPress={() => handleSelect(label, action.value)} + accessibilityRole="button" + accessibilityState={{ disabled }} > {isActive && } {label} @@ -153,7 +155,7 @@ function createStyles(tokens: AppTokens) { flexDirection: 'row', alignItems: 'center', gap: 6, - minHeight: 36, + minHeight: 44, paddingHorizontal: 14, borderRadius: 999, backgroundColor: tokens.bgElev, @@ -175,7 +177,7 @@ function createStyles(tokens: AppTokens) { errorText: { fontFamily: 'Rubik_400Regular', fontSize: 12, - color: tokens.statusBad, + color: tokens.statusBadText, }, successRow: { flexDirection: 'row', diff --git a/apps/mobile/components/chat/conflict-warning.tsx b/apps/mobile/components/chat/conflict-warning.tsx index 07e573f692..2c62ed5485 100644 --- a/apps/mobile/components/chat/conflict-warning.tsx +++ b/apps/mobile/components/chat/conflict-warning.tsx @@ -19,13 +19,13 @@ function severityColors( switch (severity) { case "HIGH": return { - text: tokens.statusBad, + text: tokens.statusBadText, bg: `${tokens.statusBad}1A`, border: `${tokens.statusBad}4D`, }; case "MEDIUM": return { - text: tokens.statusOverdue, + text: tokens.statusOverdueText, bg: `${tokens.statusOverdue}1A`, border: `${tokens.statusOverdue}4D`, }; diff --git a/apps/mobile/components/chat/pending-operation-card.tsx b/apps/mobile/components/chat/pending-operation-card.tsx index 09c20028a9..6e043ff13d 100644 --- a/apps/mobile/components/chat/pending-operation-card.tsx +++ b/apps/mobile/components/chat/pending-operation-card.tsx @@ -298,7 +298,7 @@ function createStyles(tokens: AppTokens) { error: { fontFamily: 'Rubik_400Regular', fontSize: 12, - color: tokens.statusBad, + color: tokens.statusBadText, }, success: { fontFamily: 'Rubik_500Medium', diff --git a/apps/mobile/components/chat/typing-indicator.tsx b/apps/mobile/components/chat/typing-indicator.tsx index da7d17c4b2..388edc2487 100644 --- a/apps/mobile/components/chat/typing-indicator.tsx +++ b/apps/mobile/components/chat/typing-indicator.tsx @@ -3,13 +3,20 @@ import { View, StyleSheet, Animated } from "react-native"; import Reanimated, { FadeInUp, ReduceMotion } from "react-native-reanimated"; import { Sparkles } from "lucide-react-native"; import { useTranslation } from "react-i18next"; +import { usePrefersReducedMotion } from "@/lib/motion"; import { createTokensV2, tintFromPrimary } from '@/lib/theme' import { useAppTheme } from "@/lib/use-app-theme"; function AnimatedDot({ delay, color }: Readonly<{ delay: number; color: string }>) { const opacity = useMemo(() => new Animated.Value(1), []); + const prefersReducedMotion = usePrefersReducedMotion(); useEffect(() => { + if (prefersReducedMotion) { + opacity.setValue(1); + return; + } + const animation = Animated.loop( Animated.sequence([ Animated.timing(opacity, { @@ -27,7 +34,7 @@ function AnimatedDot({ delay, color }: Readonly<{ delay: number; color: string } ); animation.start(); return () => animation.stop(); - }, [delay, opacity]); + }, [delay, opacity, prefersReducedMotion]); return ( diff --git a/apps/mobile/components/gamification/all-done-celebration.tsx b/apps/mobile/components/gamification/all-done-celebration.tsx index 138d2e8f84..5877f01245 100644 --- a/apps/mobile/components/gamification/all-done-celebration.tsx +++ b/apps/mobile/components/gamification/all-done-celebration.tsx @@ -70,6 +70,7 @@ export function AllDoneCelebration() { diff --git a/apps/mobile/components/gamification/level-up-overlay.tsx b/apps/mobile/components/gamification/level-up-overlay.tsx index a02ccfad4f..ed21257e46 100644 --- a/apps/mobile/components/gamification/level-up-overlay.tsx +++ b/apps/mobile/components/gamification/level-up-overlay.tsx @@ -13,6 +13,7 @@ import { usePrefersReducedMotion } from '@/lib/motion' import { createTokensV2, tintFromPrimary } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' import { useUIStore } from '@/stores/ui-store' +import { useOverlayBack } from '@/hooks/use-overlay-back' import { GradientTop } from '@/components/ui/gradient-top' import { PillButton } from '@/components/ui/pill-button' import { useCelebrationEntrance } from './celebration-motion' @@ -118,6 +119,8 @@ export function LevelUpOverlay({ } }, [activeLevelUp, dismiss, overlayOpacity]) + useOverlayBack(shouldRender, () => dismiss(activeLevelUp?.id)) + const spin = ringRotation.interpolate({ inputRange: [0, 1], outputRange: ['-18deg', '342deg'], diff --git a/apps/mobile/components/gamification/streak-celebration.tsx b/apps/mobile/components/gamification/streak-celebration.tsx index 1d22599610..b61da35018 100644 --- a/apps/mobile/components/gamification/streak-celebration.tsx +++ b/apps/mobile/components/gamification/streak-celebration.tsx @@ -89,7 +89,7 @@ export function StreakCelebration() { accessibilityRole="alert" accessibilityLiveRegion="polite" > - + diff --git a/apps/mobile/components/gamification/streak-freeze-celebration.tsx b/apps/mobile/components/gamification/streak-freeze-celebration.tsx index e30c0829e9..8071be8b14 100644 --- a/apps/mobile/components/gamification/streak-freeze-celebration.tsx +++ b/apps/mobile/components/gamification/streak-freeze-celebration.tsx @@ -102,6 +102,7 @@ export const StreakFreezeCelebration = forwardRef diff --git a/apps/mobile/components/gamification/welcome-back-toast.tsx b/apps/mobile/components/gamification/welcome-back-toast.tsx index 1d610b1f3b..8241f69c7d 100644 --- a/apps/mobile/components/gamification/welcome-back-toast.tsx +++ b/apps/mobile/components/gamification/welcome-back-toast.tsx @@ -174,6 +174,7 @@ export function WelcomeBackToast() { }, ]} onPress={dismiss} + accessibilityRole="button" accessibilityLabel={message} > diff --git a/apps/mobile/components/goal-card.tsx b/apps/mobile/components/goal-card.tsx index 329d9dc8b8..af2479e2a4 100644 --- a/apps/mobile/components/goal-card.tsx +++ b/apps/mobile/components/goal-card.tsx @@ -69,6 +69,22 @@ export function GoalCard({ goal, onPress, tourTargetId }: GoalCardProps) { tokens.fg3, ]) + const progressTextColor = useMemo(() => { + if (goal.status === 'Completed') return tokens.statusDone + if (goal.status === 'Abandoned') return tokens.fg3 + if (isStreak) return tokens.statusOverdueText + if (goal.progressPercentage >= 75) return tokens.statusDone + return tokens.primary + }, [ + goal.status, + goal.progressPercentage, + isStreak, + tokens.statusOverdueText, + tokens.statusDone, + tokens.primary, + tokens.fg3, + ]) + const deadlineInfo = useMemo(() => { if (!goal.deadline) return null const deadlineDate = parseISO(goal.deadline) @@ -86,14 +102,14 @@ export function GoalCard({ goal, onPress, tourTargetId }: GoalCardProps) { if (daysLeft <= 7) { return { text: plural(t('goals.deadline.daysLeft', { n: daysLeft }), daysLeft), - textColor: tokens.statusOverdue, + textColor: tokens.statusOverdueText, } } return { text: plural(t('goals.deadline.daysLeft', { n: daysLeft }), daysLeft), textColor: tokens.fg3, } - }, [goal.deadline, goal.status, t, tokens.statusOverdue, tokens.statusBad, tokens.fg3]) + }, [goal.deadline, goal.status, t, tokens.statusOverdueText, tokens.statusBad, tokens.fg3]) const statusBadge = useMemo(() => { if (goal.status === 'Completed') { @@ -193,7 +209,7 @@ export function GoalCard({ goal, onPress, tourTargetId }: GoalCardProps) { - + {progress}% diff --git a/apps/mobile/components/goals/create-goal-modal.tsx b/apps/mobile/components/goals/create-goal-modal.tsx index cf1b6a366a..84cef16d94 100644 --- a/apps/mobile/components/goals/create-goal-modal.tsx +++ b/apps/mobile/components/goals/create-goal-modal.tsx @@ -34,7 +34,7 @@ import { import { createTokensV2 } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' import type { GoalType } from '@orbit/shared/types/goal' -import { MAX_GOAL_DESCRIPTION_LENGTH } from '@orbit/shared/validation' +import { MAX_GOAL_DESCRIPTION_LENGTH, MAX_GOAL_UNIT_LENGTH } from '@orbit/shared/validation' interface CreateGoalModalProps { open: boolean @@ -65,6 +65,209 @@ const goalTypeOptions = [ }, ] as const +type CreateGoalStyles = ReturnType +type CreateGoalTokens = ReturnType + +interface GoalTypeSelectorProps { + tokens: CreateGoalTokens + styles: CreateGoalStyles + goalType: GoalType + onTypeChange: (type: GoalType) => void +} + +function GoalTypeSelector({ + tokens, + styles, + goalType, + onTypeChange, +}: Readonly) { + const { t } = useTranslation() + const activeTypeOption = + goalTypeOptions.find((option) => option.key === goalType) ?? goalTypeOptions[0] + return ( + + {t('goals.form.type')} + + {goalTypeOptions.map((option) => { + const isActive = goalType === option.key + const OptionIcon = option.icon + return ( + onTypeChange(option.key)} + accessibilityRole="button" + accessibilityLabel={t(option.titleKey)} + accessibilityState={{ selected: isActive }} + > + + + {t(option.titleKey)} + + + ) + })} + + + {t(activeTypeOption.descKey)} + {'hintKey' in activeTypeOption && activeTypeOption.hintKey ? ( + {t(activeTypeOption.hintKey)} + ) : null} + + + ) +} + +interface GoalTargetFieldsProps { + tokens: CreateGoalTokens + styles: CreateGoalStyles + isStreak: boolean + targetValue: string + unit: string + fieldErrors: Record + onChangeTarget: (value: string) => void + onChangeUnit: (value: string) => void +} + +function GoalTargetFields({ + tokens, + styles, + isStreak, + targetValue, + unit, + fieldErrors, + onChangeTarget, + onChangeUnit, +}: Readonly) { + const { t } = useTranslation() + return ( + + + + {isStreak + ? t('goals.form.streakTarget') + : t('goals.form.targetValue')} + + + {fieldErrors.targetValue ? ( + + {fieldErrors.targetValue} + + ) : null} + + {!isStreak ? ( + + {t('goals.form.unit')} + + {fieldErrors.unit ? ( + + {fieldErrors.unit} + + ) : null} + + ) : null} + + ) +} + +interface GoalDeadlineFieldProps { + tokens: CreateGoalTokens + styles: CreateGoalStyles + deadline: string + onChangeDeadline: (value: string) => void +} + +function GoalDeadlineField({ + tokens, + styles, + deadline, + onChangeDeadline, +}: Readonly) { + const { t } = useTranslation() + return ( + + + {t('goals.form.deadline')}{' '} + + ({t('goals.form.deadlineOptional')}) + + + {deadline ? ( + + + + + + onChangeDeadline('')} + activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={t('common.clear')} + > + + + + {isGoalDeadlinePast(deadline) ? ( + + {t('goals.form.deadlineInPast')} + + ) : null} + + ) : ( + onChangeDeadline(formatAPIDate(new Date()))} + activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={t('goals.form.addDeadline')} + > + + + {t('goals.form.addDeadline')} + + + )} + + ) +} + export function CreateGoalModal({ open, onClose }: CreateGoalModalProps) { const { t } = useTranslation() const translate = useCallback( @@ -139,9 +342,6 @@ export function CreateGoalModal({ open, onClose }: CreateGoalModalProps) { return errs }, [submitted, description, targetValue, unit, translate]) - const activeTypeOption = - goalTypeOptions.find((option) => option.key === goalType) ?? goalTypeOptions[0] - const handleTypeChange = useCallback( (type: GoalType) => { setGoalType(type) @@ -226,7 +426,7 @@ export function CreateGoalModal({ open, onClose }: CreateGoalModalProps) { ? t('goals.form.streakDescriptionPlaceholder') : t('goals.form.descriptionPlaceholder') } - placeholderTextColor={tokens.fg4} + placeholderTextColor={tokens.fg3} maxLength={MAX_GOAL_DESCRIPTION_LENGTH} accessibilityLabel={t('goals.form.description')} /> @@ -237,144 +437,30 @@ export function CreateGoalModal({ open, onClose }: CreateGoalModalProps) { ) : null} - - {t('goals.form.type')} - - {goalTypeOptions.map((option) => { - const isActive = goalType === option.key - const OptionIcon = option.icon - return ( - handleTypeChange(option.key)} - accessibilityRole="button" - accessibilityLabel={t(option.titleKey)} - accessibilityState={{ selected: isActive }} - > - - - {t(option.titleKey)} - - - ) - })} - - - {t(activeTypeOption.descKey)} - {'hintKey' in activeTypeOption && activeTypeOption.hintKey ? ( - {t(activeTypeOption.hintKey)} - ) : null} - - + - - - - {isStreak - ? t('goals.form.streakTarget') - : t('goals.form.targetValue')} - - - {fieldErrors.targetValue ? ( - - {fieldErrors.targetValue} - - ) : null} - - {!isStreak ? ( - - {t('goals.form.unit')} - - {fieldErrors.unit ? ( - - {fieldErrors.unit} - - ) : null} - - ) : null} - + - - - {t('goals.form.deadline')}{' '} - - ({t('goals.form.deadlineOptional')}) - - - {deadline ? ( - - - - - - setDeadline('')} - activeOpacity={0.7} - accessibilityRole="button" - accessibilityLabel={t('common.clear')} - > - - - - {isGoalDeadlinePast(deadline) ? ( - - {t('goals.form.deadlineInPast')} - - ) : null} - - ) : ( - setDeadline(formatAPIDate(new Date()))} - activeOpacity={0.7} - accessibilityRole="button" - accessibilityLabel={t('goals.form.addDeadline')} - > - - - {t('goals.form.addDeadline')} - - - )} - + +type EditGoalTokens = ReturnType + +interface EditGoalTargetFieldsProps { + styles: EditGoalStyles + isStreak: boolean + targetValue: string + unit: string + fieldErrors: Record + onChangeTarget: (value: string) => void + onChangeUnit: (value: string) => void +} + +function EditGoalTargetFields({ + styles, + isStreak, + targetValue, + unit, + fieldErrors, + onChangeTarget, + onChangeUnit, +}: Readonly) { + const { t } = useTranslation() + return ( + + + + {isStreak + ? t('goals.form.streakTarget') + : t('goals.form.targetValue')} + + + {fieldErrors.targetValue ? ( + + {fieldErrors.targetValue} + + ) : null} + + {!isStreak ? ( + + {t('goals.form.unit')} + + {fieldErrors.unit ? ( + + {fieldErrors.unit} + + ) : null} + + ) : null} + + ) +} + +interface EditGoalDeadlineFieldProps { + tokens: EditGoalTokens + styles: EditGoalStyles + deadline: string + onChangeDeadline: (value: string) => void +} + +function EditGoalDeadlineField({ + tokens, + styles, + deadline, + onChangeDeadline, +}: Readonly) { + const { t } = useTranslation() + return ( + + + {t('goals.form.deadline')}{' '} + + ({t('goals.form.deadlineOptional')}) + + + {deadline ? ( + + + + + + onChangeDeadline('')} + activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={t('common.clear')} + > + + + + {isGoalDeadlinePast(deadline) ? ( + + {t('goals.form.deadlineInPast')} + + ) : null} + + ) : ( + onChangeDeadline(formatAPIDate(new Date()))} + activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={t('goals.form.addDeadline')} + > + + + {t('goals.form.addDeadline')} + + + )} + + ) +} + export function EditGoalModal({ open, onClose, goal }: EditGoalModalProps) { const { t } = useTranslation() const translate = useCallback( @@ -194,7 +323,7 @@ export function EditGoalModal({ open, onClose, goal }: EditGoalModalProps) { value={description} onChangeText={setDescription} placeholder={t('goals.form.descriptionPlaceholder')} - placeholderTextColor={tokens.fg4} + placeholderTextColor={tokens.fg3} maxLength={MAX_GOAL_DESCRIPTION_LENGTH} accessibilityLabel={t('goals.form.description')} /> @@ -205,91 +334,22 @@ export function EditGoalModal({ open, onClose, goal }: EditGoalModalProps) { ) : null} - - - - {isStreak - ? t('goals.form.streakTarget') - : t('goals.form.targetValue')} - - - {fieldErrors.targetValue ? ( - - {fieldErrors.targetValue} - - ) : null} - - {!isStreak ? ( - - {t('goals.form.unit')} - - {fieldErrors.unit ? ( - - {fieldErrors.unit} - - ) : null} - - ) : null} - + - - - {t('goals.form.deadline')}{' '} - - ({t('goals.form.deadlineOptional')}) - - - {deadline ? ( - - - - - - setDeadline('')} - activeOpacity={0.7} - accessibilityRole="button" - accessibilityLabel={t('common.clear')} - > - - - - {isGoalDeadlinePast(deadline) ? ( - - {t('goals.form.deadlineInPast')} - - ) : null} - - ) : ( - setDeadline(formatAPIDate(new Date()))} - activeOpacity={0.7} - accessibilityRole="button" - accessibilityLabel={t('goals.form.addDeadline')} - > - - - {t('goals.form.addDeadline')} - - - )} - + + styles: ReturnType + onPress: () => void +} + +function GoalAskAstraButton({ + tokens, + styles, + onPress, +}: Readonly) { + const { t } = useTranslation() + return ( + + + + + + + {t('goals.detail.askAstraEyebrow')} + + + + {t('goals.detail.askAstraDefault')} + + + + + ) +} + /** * Goal Detail Drawer. Covers all 7 spec variants by status: on-track, * at-risk, behind, completed, abandoned, streak, update (active progress @@ -460,32 +502,11 @@ export function GoalDetailDrawer({ styles={styles} /> - - - - - - - {t('goals.detail.askAstraEyebrow')} - - - - {t('goals.detail.askAstraDefault')} - - - - + /> diff --git a/apps/mobile/components/goals/goal-detail-drawer/goal-progress-form.tsx b/apps/mobile/components/goals/goal-detail-drawer/goal-progress-form.tsx index 31b606cfe7..2216ac84d6 100644 --- a/apps/mobile/components/goals/goal-detail-drawer/goal-progress-form.tsx +++ b/apps/mobile/components/goals/goal-detail-drawer/goal-progress-form.tsx @@ -61,7 +61,7 @@ export function GoalProgressForm({ value={progressNote} onChangeText={onChangeNote} placeholder={t('goals.progressNote')} - placeholderTextColor={tokens.fg4} + placeholderTextColor={tokens.fg3} accessibilityLabel={t('goals.progressNote')} accessibilityHint={t('goals.updateProgress')} /> diff --git a/apps/mobile/components/goals/goal-detail-drawer/styles.ts b/apps/mobile/components/goals/goal-detail-drawer/styles.ts index 62377b328c..d69b0ce666 100644 --- a/apps/mobile/components/goals/goal-detail-drawer/styles.ts +++ b/apps/mobile/components/goals/goal-detail-drawer/styles.ts @@ -81,7 +81,7 @@ export function createStyles(tokens: AppTokens, bottomInset: number) { paddingHorizontal: 20, fontFamily: 'Rubik_400Regular', fontSize: 13, - color: tokens.statusOverdue, + color: tokens.statusOverdueText, }, askAstra: { position: 'relative', diff --git a/apps/mobile/components/goals/goal-list.tsx b/apps/mobile/components/goals/goal-list.tsx index 4688c36382..0889077ef3 100644 --- a/apps/mobile/components/goals/goal-list.tsx +++ b/apps/mobile/components/goals/goal-list.tsx @@ -1,5 +1,13 @@ -import { useCallback, useState } from 'react' -import { StyleSheet, View } from 'react-native' +import { forwardRef, useCallback, useState } from 'react' +import { + FlatList, + type NativeScrollEvent, + type NativeSyntheticEvent, + type StyleProp, + StyleSheet, + View, + type ViewStyle, +} from 'react-native' import Animated, { FadeInDown, ReduceMotion } from 'react-native-reanimated' import type { Goal } from '@orbit/shared/types/goal' import { GoalCard } from '@/components/goal-card' @@ -7,54 +15,91 @@ import { GoalDetailDrawer } from './goal-detail-drawer' interface GoalListProps { goals: Goal[] + ListHeaderComponent?: React.ComponentProps['ListHeaderComponent'] + ListEmptyComponent?: React.ComponentProps['ListEmptyComponent'] + contentContainerStyle?: StyleProp + onScroll?: (event: NativeSyntheticEvent) => void + onScrollBeginDrag?: (event: NativeSyntheticEvent) => void } -export function GoalList({ goals }: Readonly) { - const [selectedGoalId, setSelectedGoalId] = useState(null) - const [showDetail, setShowDetail] = useState(false) +export const GoalList = forwardRef, Readonly>( + function GoalList( + { + goals, + ListHeaderComponent, + ListEmptyComponent, + contentContainerStyle, + onScroll, + onScrollBeginDrag, + }, + ref, + ) { + const [selectedGoalId, setSelectedGoalId] = useState(null) + const [showDetail, setShowDetail] = useState(false) - const handleGoalPress = useCallback((goalId: string) => { - setSelectedGoalId(goalId) - setShowDetail(true) - }, []) + const handleGoalPress = useCallback((goalId: string) => { + setSelectedGoalId(goalId) + setShowDetail(true) + }, []) - const handleCloseDetail = useCallback(() => { - setShowDetail(false) - setSelectedGoalId(null) - }, []) + const handleCloseDetail = useCallback(() => { + setShowDetail(false) + setSelectedGoalId(null) + }, []) - return ( - - - {goals.map((goal, index) => ( - - - - ))} - + const renderItem = useCallback( + ({ item, index }: { item: Goal; index: number }) => ( + + + + ), + [handleGoalPress], + ) - {selectedGoalId ? ( - + goal.id} + renderItem={renderItem} + ListHeaderComponent={ListHeaderComponent} + ListEmptyComponent={ListEmptyComponent} + contentContainerStyle={[styles.listContent, contentContainerStyle]} + ItemSeparatorComponent={ItemSeparator} + showsVerticalScrollIndicator={false} + keyboardShouldPersistTaps="always" + onScroll={onScroll} + onScrollBeginDrag={onScrollBeginDrag} + scrollEventThrottle={16} /> - ) : null} - - ) + + {selectedGoalId ? ( + + ) : null} + + ) + }, +) + +function ItemSeparator() { + return } const styles = StyleSheet.create({ @@ -62,8 +107,10 @@ const styles = StyleSheet.create({ flex: 1, }, listContent: { - gap: 12, paddingHorizontal: 20, paddingBottom: 100, }, + separator: { + height: 12, + }, }) diff --git a/apps/mobile/components/goals/goals-view.tsx b/apps/mobile/components/goals/goals-view.tsx index fd5f0ddefc..7e265310a2 100644 --- a/apps/mobile/components/goals/goals-view.tsx +++ b/apps/mobile/components/goals/goals-view.tsx @@ -1,13 +1,18 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState, type ReactElement } from "react"; import { + type FlatList, + type NativeScrollEvent, + type NativeSyntheticEvent, Pressable, + type StyleProp, StyleSheet, Text, View, + type ViewStyle, } from "react-native"; import { Check, Filter } from "lucide-react-native"; import { useTranslation } from "react-i18next"; -import type { GoalStatus } from "@orbit/shared/types/goal"; +import type { Goal, GoalStatus } from "@orbit/shared/types/goal"; import { useGoals } from "@/hooks/use-goals"; import { GoalList } from "./goal-list"; import { AnchoredMenu } from "@/components/ui/anchored-menu"; @@ -24,6 +29,14 @@ interface StatusFilter { label: string; } +interface GoalsViewProps { + listHeader?: ReactElement; + scrollRef?: React.Ref>; + contentContainerStyle?: StyleProp; + onScroll?: (event: NativeSyntheticEvent) => void; + onScrollBeginDrag?: (event: NativeSyntheticEvent) => void; +} + function SkeletonCard({ styles, }: Readonly<{ @@ -38,7 +51,13 @@ function SkeletonCard({ ); } -export function GoalsView() { +export function GoalsView({ + listHeader, + scrollRef, + contentContainerStyle, + onScroll, + onScrollBeginDrag, +}: Readonly) { const { t } = useTranslation(); const { currentScheme, currentTheme } = useAppTheme() const tokens = useMemo( @@ -87,8 +106,9 @@ export function GoalsView() { }); }, [showFilterMenu]); - return ( - + const listHeaderElement = ( + <> + {listHeader} {t("goals.tab")} + + ); - {!isFetched ? ( - - - - - - ) : filteredGoals.length > 0 ? ( - - ) : ( - - )} + const listEmptyElement = !isFetched ? ( + + + + + + ) : ( + + ); + + return ( + + ( {drill.drillStack.length > 1 ? ( - + ) : null} diff --git a/apps/mobile/components/habit-list/date-group-section.tsx b/apps/mobile/components/habit-list/date-group-section.tsx index ce0b1e13da..6da4c9978e 100644 --- a/apps/mobile/components/habit-list/date-group-section.tsx +++ b/apps/mobile/components/habit-list/date-group-section.tsx @@ -56,7 +56,7 @@ export function HabitListDateGroupSection({ {group.isOverdue ? overdueLabel : group.label} diff --git a/apps/mobile/components/habit-list/drill-panel.tsx b/apps/mobile/components/habit-list/drill-panel.tsx index 3fa96b74d9..b2bb46fd5f 100644 --- a/apps/mobile/components/habit-list/drill-panel.tsx +++ b/apps/mobile/components/habit-list/drill-panel.tsx @@ -69,6 +69,7 @@ export function DrillFooter({ styles, label, onAddSubHabit }: DrillFooterProps) style={styles.drillAddBtn} onPress={onAddSubHabit} activeOpacity={0.7} + accessibilityRole="button" > {label} diff --git a/apps/mobile/components/habit-list/move-parent-dialog.tsx b/apps/mobile/components/habit-list/move-parent-dialog.tsx index 29fd654baf..70a9a16e8e 100644 --- a/apps/mobile/components/habit-list/move-parent-dialog.tsx +++ b/apps/mobile/components/habit-list/move-parent-dialog.tsx @@ -61,6 +61,8 @@ export function MoveParentDialog({ onSelectOption(option.id)} activeOpacity={0.75} + accessibilityRole="button" + accessibilityState={{ + selected: isSelectedOption, + disabled: option.disabled, + }} > {t('common.cancel')} @@ -148,6 +156,7 @@ export function MoveParentDialog({ disabled={!canSubmit} onPress={onConfirm} activeOpacity={0.8} + accessibilityRole="button" > {isPending ? ( diff --git a/apps/mobile/components/habits/create-habit-modal.tsx b/apps/mobile/components/habits/create-habit-modal.tsx index dd24a7499c..1f12235393 100644 --- a/apps/mobile/components/habits/create-habit-modal.tsx +++ b/apps/mobile/components/habits/create-habit-modal.tsx @@ -32,7 +32,7 @@ import { buildSubHabitRequest, buildCreateHabitRequest, } from '@/lib/habit-request-builders' -import { habitFormSchema } from '@orbit/shared/validation' +import { MAX_GOALS_PER_HABIT, habitFormSchema } from '@orbit/shared/validation' import { createTokensV2 } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' @@ -86,7 +86,6 @@ export function CreateHabitModal({ const [selectedGoalIds, setSelectedGoalIds] = useState([]) const [subHabits, setSubHabits] = useState([]) const [reminderTimes, setReminderTimes] = useState([0, 15]) - const [titleFilled, setTitleFilled] = useState(false) const [reminderWasManuallyToggled, setReminderWasManuallyToggled] = useState(false) const flushBufferedInputsRef = useRef<() => void>(() => {}) const [initialTagIdsSnapshot, setInitialTagIdsSnapshot] = useState('[]') @@ -108,7 +107,7 @@ export function CreateHabitModal({ name: 'scheduledReminders', }) ?? [] - const atGoalLimit = selectedGoalIds.length >= 10 + const atGoalLimit = selectedGoalIds.length >= MAX_GOALS_PER_HABIT const isDirty = formHelpers.form.formState.isDirty || JSON.stringify( @@ -142,7 +141,6 @@ export function CreateHabitModal({ const fallbackDate = initialDate ?? formatAPIDate(new Date()) setReminderWasManuallyToggled(false) - setTitleFilled(false) formHelpers.form.reset(buildEmptyHabitFormValues(fallbackDate)) tags.resetTags() setSelectedGoalIds([]) @@ -235,15 +233,6 @@ export function CreateHabitModal({ return } - const hasTypedSubHabits = subHabits.some( - (entry) => entry.value.trim().length > 0, - ) - if (!hasProAccess && hasTypedSubHabits) { - onClose() - router.push('/upgrade') - return - } - const permittedGoalIds = hasProAccess ? selectedGoalIds : [] const subHabitValues = hasProAccess ? subHabits.map((entry) => entry.value) @@ -310,7 +299,7 @@ export function CreateHabitModal({ ]) const isPending = createHabit.isPending || createSubHabit.isPending - const submitDisabled = isPending || !titleFilled + const submitDisabled = isPending || !formHelpers.form.formState.isValid const updateSubHabitValue = useCallback((id: string, value: string) => { setSubHabits((prev) => @@ -355,7 +344,6 @@ export function CreateHabitModal({ onReminderTimesChange={setReminderTimes} onReminderEnabledChange={handleReminderEnabledChange} onFlushBufferedInputsReady={handleBufferedInputsReady} - onTitlePresenceChange={setTitleFilled} > {!isSubHabitMode ? ( router.push('/upgrade')} tokens={tokens} styles={styles} /> @@ -440,12 +429,27 @@ function createStyles( alignItems: 'center', gap: 8, }, + subHabitsUpsellHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + }, + subHabitsHeaderLeft: { + flex: 1, + gap: 4, + }, subHabitsHint: { fontFamily: 'Rubik_400Regular', fontSize: 13, color: tokens.fg3, lineHeight: 19, }, + subHabitsUpgradeText: { + fontFamily: 'Rubik_500Medium', + fontSize: 13, + color: tokens.primary, + }, subHabitsList: { gap: 8, }, @@ -466,7 +470,7 @@ function createStyles( textAlign: 'right', fontFamily: 'Roboto_400Regular', fontSize: 12, - color: tokens.fg4, + color: tokens.fg3, }, subHabitInput: { flex: 1, diff --git a/apps/mobile/components/habits/create-habit-modal/sub-habit-editor.tsx b/apps/mobile/components/habits/create-habit-modal/sub-habit-editor.tsx index f4f110892e..02b9201b0a 100644 --- a/apps/mobile/components/habits/create-habit-modal/sub-habit-editor.tsx +++ b/apps/mobile/components/habits/create-habit-modal/sub-habit-editor.tsx @@ -1,6 +1,7 @@ import { Text, TouchableOpacity, View } from 'react-native' import { Plus, Trash2 } from 'lucide-react-native' import { useTranslation } from 'react-i18next' +import { MAX_HABIT_TITLE_LENGTH, MAX_SUB_HABITS } from '@orbit/shared/validation' import { BottomSheetAppTextInput } from '@/components/ui/bottom-sheet-app-text-input' import { ProBadge } from '@/components/ui/pro-badge' import type { createTokensV2 } from '@/lib/theme' @@ -16,12 +17,16 @@ interface SubHabitEditorProps { onUpdateSubHabit: (id: string, value: string) => void onRemoveSubHabit: (id: string) => void onAddSubHabit: () => void + onUpgrade: () => void tokens: ReturnType styles: { subHabitsSection: object subHabitsHeader: object + subHabitsUpsellHeader: object + subHabitsHeaderLeft: object fieldLabel: object subHabitsHint: object + subHabitsUpgradeText: object subHabitsList: object subHabitRow: object subHabitIndex: object @@ -38,22 +43,45 @@ export function SubHabitEditor({ onUpdateSubHabit, onRemoveSubHabit, onAddSubHabit, + onUpgrade, tokens, styles, }: Readonly) { const { t } = useTranslation() + if (!hasProAccess) { + return ( + + + + + {t('habits.form.subHabits')} + + + + {t('upgrade.features.subHabits.tooltip')} + + + + + {t('upgrade.subscribe')} + + + + + ) + } + return ( {t('habits.form.subHabits')} - {!hasProAccess ? : null} - {!hasProAccess ? ( - - {t('upgrade.comparison.subHabits.tooltip')} - - ) : null} {subHabits.length > 0 ? ( {subHabits.map((entry, index) => ( @@ -61,9 +89,9 @@ export function SubHabitEditor({ {index + 1} onUpdateSubHabit(entry.id, val)} /> @@ -78,9 +106,9 @@ export function SubHabitEditor({ right: 6, }} accessibilityRole="button" - accessibilityLabel={t('common.clear')} + accessibilityLabel={t('habits.form.removeSubHabit')} > - + ))} @@ -88,7 +116,7 @@ export function SubHabitEditor({ ) : null} = 20} + disabled={subHabits.length >= MAX_SUB_HABITS} onPress={onAddSubHabit} activeOpacity={0.7} accessibilityRole="button" diff --git a/apps/mobile/components/habits/edit-habit-modal.tsx b/apps/mobile/components/habits/edit-habit-modal.tsx index 3b545c09f5..8eb092a07b 100644 --- a/apps/mobile/components/habits/edit-habit-modal.tsx +++ b/apps/mobile/components/habits/edit-habit-modal.tsx @@ -22,7 +22,7 @@ import { } from '@orbit/shared/utils' import type { NormalizedHabit } from '@orbit/shared/types/habit' import { buildUpdateHabitRequest } from '@/lib/habit-request-builders' -import { habitFormSchema } from '@orbit/shared/validation' +import { MAX_GOALS_PER_HABIT, habitFormSchema } from '@orbit/shared/validation' import { createTokensV2 } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' @@ -61,9 +61,8 @@ export function EditHabitModal({ const [initialTagIds, setInitialTagIds] = useState('[]') const [initialGoalIds, setInitialGoalIds] = useState('[]') const [initialReminderTimes, setInitialReminderTimes] = useState('[0,15]') - const [titleFilled, setTitleFilled] = useState(false) - const atGoalLimit = selectedGoalIds.length >= 10 + const atGoalLimit = selectedGoalIds.length >= MAX_GOALS_PER_HABIT const isDirty = formHelpers.form.formState.isDirty || JSON.stringify( @@ -117,7 +116,6 @@ export function EditHabitModal({ if (open && habit) { const prefill = buildEditHabitFormState(habit, habitDetail) formHelpers.form.reset(prefill.formValues) - setTitleFilled(prefill.formValues.title.trim().length > 0) setOriginalEndDate(prefill.originalEndDate) setReminderTimes(prefill.reminderTimes) tags.resetTags(prefill.selectedTagIds) @@ -196,7 +194,7 @@ export function EditHabitModal({ translate, ]) - const submitDisabled = updateHabit.isPending || !titleFilled + const submitDisabled = updateHabit.isPending || !formHelpers.form.formState.isValid return ( <> @@ -224,7 +222,6 @@ export function EditHabitModal({ reminderTimes={reminderTimes} onReminderTimesChange={setReminderTimes} onFlushBufferedInputsReady={handleBufferedInputsReady} - onTitlePresenceChange={setTitleFilled} defaultExpanded={true} /> diff --git a/apps/mobile/components/habits/goal-linking-field.tsx b/apps/mobile/components/habits/goal-linking-field.tsx index e1dc279c93..920f520c6b 100644 --- a/apps/mobile/components/habits/goal-linking-field.tsx +++ b/apps/mobile/components/habits/goal-linking-field.tsx @@ -66,6 +66,8 @@ export function GoalLinkingField({ disabled={isDisabled} onPress={() => onToggleGoal(goal.id)} activeOpacity={0.75} + accessibilityRole="button" + accessibilityState={{ selected: isSelected, disabled: isDisabled }} > setSelectedDate(null)} activeOpacity={0.7} @@ -277,7 +286,7 @@ function createStyles(tokens: AppTokens) { fontSize: 11, textTransform: "uppercase", letterSpacing: 0.44, - color: tokens.fg4, + color: tokens.fg3, }, grid: { flexDirection: "row", diff --git a/apps/mobile/components/habits/habit-checklist.tsx b/apps/mobile/components/habits/habit-checklist.tsx index 2ec0a03b45..dc166e407e 100644 --- a/apps/mobile/components/habits/habit-checklist.tsx +++ b/apps/mobile/components/habits/habit-checklist.tsx @@ -114,6 +114,7 @@ function EditableChecklistItem({ styles, tokens, }: EditableChecklistItemProps) { + const { t } = useTranslation() const [localText, setLocalText] = useState(text) const [previousText, setPreviousText] = useState(text) @@ -142,6 +143,8 @@ function EditableChecklistItem({ {interactive && checkedCount > 0 && ( - + {t('habits.form.resetChecklist')} )} {interactive && ( - + {t('habits.form.clearChecklist')} )} @@ -347,7 +356,7 @@ export function HabitChecklist({ {editable && items.length > 0 && ( - + {t('habits.form.clearChecklist')} @@ -358,13 +367,14 @@ export function HabitChecklist({ void } +type HabitDetailStyles = ReturnType +type HabitDetailTokens = ReturnType + +interface HabitAskAstraButtonProps { + tokens: HabitDetailTokens + styles: HabitDetailStyles + askPrompt: string + onPress: () => void +} + +function HabitAskAstraButton({ + tokens, + styles, + askPrompt, + onPress, +}: Readonly) { + const { t } = useTranslation() + return ( + + + + + + + {t('habits.detail.askAstraEyebrow')} + + + + {askPrompt} + + + + + ) +} +type ChecklistItems = NonNullable +type HabitMetrics = NonNullable< + ReturnType['data'] +>['metrics'] +type HabitLogs = NonNullable< + ReturnType['data'] +>['logs'] + +interface HabitDetailContentProps { + habit: NormalizedHabit + tokens: HabitDetailTokens + styles: HabitDetailStyles + metrics: HabitMetrics | null + metricsLoading: boolean + logs: HabitLogs | null + liveChecklist: ChecklistItems + summaryStrip: string + askPrompt: string + locale: string + displayTime: (time: string) => string + onOpenDescription: () => void + onChecklistToggle: (index: number) => void + onChecklistReset: () => void + onChecklistClear: () => void + onAskAstra: () => void +} + +function HabitDetailContent({ + habit, + tokens, + styles, + metrics, + metricsLoading, + logs, + liveChecklist, + summaryStrip, + askPrompt, + locale, + displayTime, + onOpenDescription, + onChecklistToggle, + onChecklistReset, + onChecklistClear, + onAskAstra, +}: Readonly) { + const { t } = useTranslation() + return ( + + {habit.emoji || summaryStrip ? ( + + {habit.emoji ? ( + + {habit.emoji} + + ) : null} + {summaryStrip ? ( + + {summaryStrip} + + ) : null} + + ) : null} + + {habit.description ? ( + + + {habit.description} + + + ) : null} + + {liveChecklist.length > 0 ? ( + + + {t('habits.form.checklist')} + + + + + + ) : null} + + {habit.frequencyUnit || habit.isGeneral ? ( + + + {t('habits.detail.stats')} + + + + ) : null} + + {habit.dueTime ? ( + + + {t('habits.detail.reminders')} + + + {habit.scheduledReminders?.map((sr, idx) => ( + + ))} + + ) : null} + + {habit.endDate ? ( + + ) : null} + + {habit.linkedGoals && habit.linkedGoals.length > 0 ? ( + + + {t('habits.detail.linkedGoal')} + + {habit.linkedGoals.map((g) => ( + + ))} + + ) : null} + + + + + {t('habits.detail.activity')} + + + + + + + + + ) +} + /** * Habit Detail Drawer. Covers all variants by data-driven section presence: * active, skipped (checklist hidden when empty), checklist, bad, slip alert @@ -174,190 +426,24 @@ export function HabitDetailDrawer({ snapPoints={['68%', '92%']} > {habit ? ( - - {habit.emoji || summaryStrip ? ( - - {habit.emoji ? ( - - {habit.emoji} - - ) : null} - {summaryStrip ? ( - - {summaryStrip} - - ) : null} - - ) : null} - - {habit.description ? ( - setDescriptionViewerOpen(true)} - activeOpacity={0.7} - accessibilityRole="button" - accessibilityLabel={t('habits.detail.viewDescription')} - > - - {habit.description} - - - ) : null} - - {liveChecklist.length > 0 ? ( - - - {t('habits.form.checklist')} - - - - - - ) : null} - - {habit.frequencyUnit || habit.isGeneral ? ( - - - {t('habits.detail.stats')} - - - - ) : null} - - {habit.dueTime ? ( - - - {t('habits.detail.reminders')} - - - {habit.scheduledReminders?.map((sr, idx) => ( - - ))} - - ) : null} - - {habit.endDate ? ( - - ) : null} - - {habit.linkedGoals && habit.linkedGoals.length > 0 ? ( - - - {t('habits.detail.linkedGoal')} - - {habit.linkedGoals.map((g) => ( - - ))} - - ) : null} - - - - - {t('habits.detail.activity')} - - - - - - - - - - - - - {t('habits.detail.askAstraEyebrow')} - - - - {askPrompt} - - - - - + setDescriptionViewerOpen(true)} + onChecklistToggle={handleChecklistToggle} + onChecklistReset={handleChecklistReset} + onChecklistClear={handleChecklistClear} + onAskAstra={handleAskAstra} + /> ) : null} diff --git a/apps/mobile/components/habits/habit-form-fields.tsx b/apps/mobile/components/habits/habit-form-fields.tsx index e3a923deb5..72373a8512 100644 --- a/apps/mobile/components/habits/habit-form-fields.tsx +++ b/apps/mobile/components/habits/habit-form-fields.tsx @@ -6,7 +6,8 @@ import { useRef, type ReactNode, } from "react"; -import { View, LayoutAnimation } from "react-native"; +import { View } from "react-native"; +import Animated, { FadeInDown, ReduceMotion } from "react-native-reanimated"; import { useWatch } from "react-hook-form"; import type { TagSelectionState } from "@/hooks/use-tag-selection"; import type { HabitFormHelpers } from "@/hooks/use-habit-form"; @@ -38,7 +39,6 @@ interface HabitFormFieldsProps { onReminderTimesChange: (times: number[]) => void; onReminderEnabledChange?: (nextEnabled: boolean) => void; onFlushBufferedInputsReady?: (flush: () => void) => void; - onTitlePresenceChange?: (hasTitle: boolean) => void; /** When true, advanced fields are visible by default (used in edit modal) */ defaultExpanded?: boolean; children?: ReactNode; @@ -54,7 +54,6 @@ export function HabitFormFields({ onReminderTimesChange, onReminderEnabledChange, onFlushBufferedInputsReady, - onTitlePresenceChange, defaultExpanded = false, children, }: Readonly) { @@ -114,7 +113,6 @@ export function HabitFormFields({ const [showAdvanced, setShowAdvanced] = useState(defaultExpanded); function toggleAdvanced() { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); setShowAdvanced((prev) => !prev); } @@ -137,7 +135,7 @@ export function HabitFormFields({ error={errors.title?.message} registerFlush={registerBufferedInputFlusher} onCommit={(val) => setValue("title", val, { shouldDirty: true })} - onDraftChange={(val) => onTitlePresenceChange?.(val.trim().length > 0)} + onDraftChange={(val) => setValue("title", val, { shouldDirty: true })} leading={ {showAdvanced && ( - - {children} - + + {children} + + )} diff --git a/apps/mobile/components/habits/habit-form-fields/advanced-section.tsx b/apps/mobile/components/habits/habit-form-fields/advanced-section.tsx index b6223a51d8..43286bdc41 100644 --- a/apps/mobile/components/habits/habit-form-fields/advanced-section.tsx +++ b/apps/mobile/components/habits/habit-form-fields/advanced-section.tsx @@ -4,6 +4,7 @@ import { X, Plus, Check } from "lucide-react-native"; import { useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { HABIT_REMINDER_PRESETS } from "@orbit/shared/utils"; +import { MAX_HABIT_DESCRIPTION_LENGTH } from "@orbit/shared/validation"; import { HabitChecklist } from "../habit-checklist"; import { ChecklistTemplates } from "../checklist-templates"; import { GoalLinkingField } from "../goal-linking-field"; @@ -94,7 +95,7 @@ export function AdvancedSection({ value={description} registerFlush={registerFlush} placeholder={t("habits.form.descriptionPlaceholder")} - maxLength={2000} + maxLength={MAX_HABIT_DESCRIPTION_LENGTH} multiline numberOfLines={2} style={styles.textarea} @@ -154,6 +155,8 @@ export function AdvancedSection({ setValue("endDate", "", { shouldDirty: true })} activeOpacity={0.7} > @@ -165,6 +168,7 @@ export function AdvancedSection({ ) : ( setValue("endDate", dueDate || "", { shouldDirty: true }) } @@ -214,6 +218,9 @@ export function AdvancedSection({ {!isGeneral && ( setValue("isBadHabit", !isBadHabit, { shouldDirty: true }) } diff --git a/apps/mobile/components/habits/habit-form-fields/habit-emoji-selector.tsx b/apps/mobile/components/habits/habit-form-fields/habit-emoji-selector.tsx index f162f6f1e5..7e7f2da310 100644 --- a/apps/mobile/components/habits/habit-form-fields/habit-emoji-selector.tsx +++ b/apps/mobile/components/habits/habit-form-fields/habit-emoji-selector.tsx @@ -86,8 +86,17 @@ export function HabitEmojiSelector({ animationType="slide" onRequestClose={closePicker} > - - event.stopPropagation()}> + + event.stopPropagation()} + importantForAccessibility="no" + > @@ -116,7 +125,7 @@ export function HabitEmojiSelector({ value={query} onChangeText={setQuery} placeholder={t("habits.form.emojiSearchPlaceholder")} - placeholderTextColor={tokens.fg4} + placeholderTextColor={tokens.fg3} style={styles.emojiSearchInput} autoCapitalize="none" autoCorrect={false} diff --git a/apps/mobile/components/habits/habit-form-fields/habit-tag-chip.tsx b/apps/mobile/components/habits/habit-form-fields/habit-tag-chip.tsx index 48b7b3b27f..eb3bd52d12 100644 --- a/apps/mobile/components/habits/habit-form-fields/habit-tag-chip.tsx +++ b/apps/mobile/components/habits/habit-form-fields/habit-tag-chip.tsx @@ -10,6 +10,8 @@ interface HabitTagChipProps { onToggle: () => void; onEdit: () => void; onDelete: () => void; + editAriaLabel: string; + deleteAriaLabel: string; styles: ReturnType; tokens: AppTokens; } @@ -22,6 +24,8 @@ export function HabitTagChip({ onToggle, onEdit, onDelete, + editAriaLabel, + deleteAriaLabel, styles, tokens, }: Readonly) { @@ -37,6 +41,9 @@ export function HabitTagChip({ @@ -52,6 +59,9 @@ export function HabitTagChip({ @@ -64,6 +74,9 @@ export function HabitTagChip({ diff --git a/apps/mobile/components/habits/habit-form-fields/more-options-toggle.tsx b/apps/mobile/components/habits/habit-form-fields/more-options-toggle.tsx index 4fd19cdb62..3760f92405 100644 --- a/apps/mobile/components/habits/habit-form-fields/more-options-toggle.tsx +++ b/apps/mobile/components/habits/habit-form-fields/more-options-toggle.tsx @@ -52,6 +52,8 @@ export function MoreOptionsToggle({ diff --git a/apps/mobile/components/habits/habit-form-fields/reminder-section.tsx b/apps/mobile/components/habits/habit-form-fields/reminder-section.tsx index 0eceb3fea6..c19cdff26a 100644 --- a/apps/mobile/components/habits/habit-form-fields/reminder-section.tsx +++ b/apps/mobile/components/habits/habit-form-fields/reminder-section.tsx @@ -90,6 +90,9 @@ export function ReminderSection({ style={ reminderTimes.length <= 1 ? { opacity: 0.3 } : undefined } + hitSlop={{ top: 16, bottom: 16, left: 16, right: 16 }} + accessibilityRole="button" + accessibilityLabel={t("habits.form.removeReminder")} onPress={() => removeReminder(time)} activeOpacity={0.7} > @@ -101,6 +104,7 @@ export function ReminderSection({ { setShowAddReminder(!showAddReminder); setShowCustomInput(false); @@ -119,6 +123,7 @@ export function ReminderSection({ addPreset(preset.value)} activeOpacity={0.7} > @@ -145,6 +150,7 @@ export function ReminderSection({ sectionStyles.unitButton, customUnit === unit && sectionStyles.unitButtonActive, ]} + accessibilityRole="button" onPress={() => setCustomUnit(unit)} activeOpacity={0.7} > @@ -164,6 +170,8 @@ export function ReminderSection({ @@ -173,6 +181,7 @@ export function ReminderSection({ )} setShowCustomInput(!showCustomInput)} activeOpacity={0.7} > diff --git a/apps/mobile/components/habits/habit-form-fields/scheduled-reminder-section.tsx b/apps/mobile/components/habits/habit-form-fields/scheduled-reminder-section.tsx index 9f1d7edaac..d3866476c6 100644 --- a/apps/mobile/components/habits/habit-form-fields/scheduled-reminder-section.tsx +++ b/apps/mobile/components/habits/habit-form-fields/scheduled-reminder-section.tsx @@ -4,6 +4,10 @@ import { X, Plus, Bell } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import type { ScheduledReminderWhen } from "@orbit/shared/types/habit"; import { formatLocaleTime } from "@orbit/shared/utils"; +import { + MAX_SCHEDULED_REMINDERS, + validateScheduledReminders, +} from "@orbit/shared/validation"; import { AppTimePicker } from "@/components/ui/app-time-picker"; import { Switch } from "@/components/ui/settings-row"; import { type AppTokens, createSectionStyles } from "./styles"; @@ -32,7 +36,6 @@ export function ScheduledReminderSection({ const { t, i18n } = useTranslation(); const deviceLocale = i18n.language; const sectionStyles = useMemo(() => createSectionStyles(tokens), [tokens]); - const MAX_SCHEDULED_REMINDERS = 5; const [showForm, setShowForm] = useState(false); const [when, setWhen] = useState("same_day"); const [time, setTime] = useState(""); @@ -44,19 +47,16 @@ export function ScheduledReminderSection({ onValidationError(t("habits.form.invalidScheduledReminderTime")); return; } - if (atLimit) { - onValidationError(t("habits.form.scheduledReminderMax")); - return; - } const current = scheduledReminders ?? []; - const duplicate = current.some( - (sr) => sr.when === when && sr.time === time, - ); - if (duplicate) { - onValidationError(t("habits.form.duplicateScheduledReminder")); + const candidate = [...current, { when, time }]; + const validationErrorKey = validateScheduledReminders(candidate); + if (validationErrorKey) { + onValidationError( + t(validationErrorKey as "habits.form.scheduledReminderMax"), + ); return; } - onSetScheduledReminders([...current, { when, time }]); + onSetScheduledReminders(candidate); setTime(""); setShowForm(false); } @@ -104,6 +104,9 @@ export function ScheduledReminderSection({ {scheduledReminderLabel(sr)} removeScheduledReminder(idx)} activeOpacity={0.7} > @@ -117,6 +120,7 @@ export function ScheduledReminderSection({ {!showForm && !atLimit && ( setShowForm(true)} activeOpacity={0.7} > @@ -141,6 +145,8 @@ export function ScheduledReminderSection({ sectionStyles.whenButton, when === "day_before" && sectionStyles.whenButtonActive, ]} + accessibilityRole="button" + accessibilityState={{ selected: when === "day_before" }} onPress={() => setWhen("day_before")} activeOpacity={0.7} > @@ -159,6 +165,8 @@ export function ScheduledReminderSection({ sectionStyles.whenButton, when === "same_day" && sectionStyles.whenButtonActive, ]} + accessibilityRole="button" + accessibilityState={{ selected: when === "same_day" }} onPress={() => setWhen("same_day")} activeOpacity={0.7} > @@ -191,6 +199,7 @@ export function ScheduledReminderSection({ !time && { opacity: 0.4 }, ]} disabled={!time} + accessibilityRole="button" onPress={addScheduledReminder} activeOpacity={0.7} > @@ -200,6 +209,8 @@ export function ScheduledReminderSection({ { setShowForm(false); setTime(""); diff --git a/apps/mobile/components/habits/habit-form-fields/slip-alert-section.tsx b/apps/mobile/components/habits/habit-form-fields/slip-alert-section.tsx index 9b6a1a6b33..62ba68a345 100644 --- a/apps/mobile/components/habits/habit-form-fields/slip-alert-section.tsx +++ b/apps/mobile/components/habits/habit-form-fields/slip-alert-section.tsx @@ -50,6 +50,7 @@ export function SlipAlertSection({ style={sectionStyles.headerRow} onPress={() => router.push("/upgrade")} activeOpacity={0.8} + accessibilityRole="button" > diff --git a/apps/mobile/components/habits/habit-form-fields/styles.ts b/apps/mobile/components/habits/habit-form-fields/styles.ts index e05efe9a0d..f7500de29e 100644 --- a/apps/mobile/components/habits/habit-form-fields/styles.ts +++ b/apps/mobile/components/habits/habit-form-fields/styles.ts @@ -483,7 +483,7 @@ export function createStyles(tokens: AppTokens) { frequencyCardExample: { fontFamily: "Rubik_400Regular", fontSize: 12, - color: tokens.fg4, + color: tokens.fg3, lineHeight: 17, }, frequencyRow: { @@ -668,7 +668,7 @@ export function createStyles(tokens: AppTokens) { height: 26, borderRadius: 8, borderWidth: 2, - borderColor: tokens.fg4, + borderColor: tokens.fg3, alignItems: "center", justifyContent: "center", }, diff --git a/apps/mobile/components/habits/habit-form-fields/tag-editor-row.tsx b/apps/mobile/components/habits/habit-form-fields/tag-editor-row.tsx index 3aaa4b4a3d..3d75477bc3 100644 --- a/apps/mobile/components/habits/habit-form-fields/tag-editor-row.tsx +++ b/apps/mobile/components/habits/habit-form-fields/tag-editor-row.tsx @@ -1,5 +1,6 @@ import { View, Text, TouchableOpacity } from "react-native"; import { X } from "lucide-react-native"; +import { MAX_TAG_NAME_LENGTH } from "@orbit/shared/validation"; import { BottomSheetAppTextInput } from "@/components/ui/bottom-sheet-app-text-input"; import { type AppTokens, createStyles } from "./styles"; @@ -35,7 +36,7 @@ export function TagEditorRow({ tags.toggleTag(tag.id)} @@ -84,6 +86,7 @@ export function TagsSection({ tags.setShowNewTag(true)} activeOpacity={0.7} > diff --git a/apps/mobile/components/habits/habit-form-fields/title-section.tsx b/apps/mobile/components/habits/habit-form-fields/title-section.tsx index cde140390d..4d35eda0c8 100644 --- a/apps/mobile/components/habits/habit-form-fields/title-section.tsx +++ b/apps/mobile/components/habits/habit-form-fields/title-section.tsx @@ -2,6 +2,7 @@ import { type ReactNode } from "react"; import { View, Text } from "react-native"; import { useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { MAX_HABIT_TITLE_LENGTH } from "@orbit/shared/validation"; import { BufferedSheetInput } from "./buffered-sheet-input"; import type { HabitFormControl, HabitFormStyles } from "./types"; @@ -36,7 +37,7 @@ export function TitleSection({ ; - -export interface SectionThemeProps { - styles: HabitFormStyles; - tokens: AppTokens; -} diff --git a/apps/mobile/components/habits/habit-row.tsx b/apps/mobile/components/habits/habit-row.tsx index 3be0d9b725..ca2fb061ea 100644 --- a/apps/mobile/components/habits/habit-row.tsx +++ b/apps/mobile/components/habits/habit-row.tsx @@ -38,6 +38,7 @@ import { } from '@orbit/shared/utils' import type { NormalizedHabit } from '@orbit/shared/types/habit' import { useTimeFormat } from '@/hooks/use-time-format' +import { usePrefersReducedMotion } from '@/lib/motion' import { createTokensV2 } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' import { AnchoredMenu } from '@/components/ui/anchored-menu' @@ -122,11 +123,7 @@ export function HabitRow({ const isOverdue = status === 'overdue' const canLog = canLogHabitOnDate(habit, selectedDateStr, todayStr) - const metaParts: ( - | string - | { kind: 'overdue' } - | { kind: 'future'; label: string } - )[] = [] + const metaParts: HabitRowMetaPart[] = [] if (!habit.isGeneral && frequencyLabel) metaParts.push(frequencyLabel) if (habit.dueTime) { const due = displayTime(habit.dueTime) @@ -213,6 +210,13 @@ export function HabitRow({ const linkedGoal = (habit.linkedGoals?.length ?? 0) > 0 + const rowAccessibilityLabel = useMemo(() => { + const parts = [habit.title, t(`habits.statusDot.${dotState}` as const)] + if (linkedGoal) parts.push(t('habits.detail.linkedGoal')) + if (showStreak) parts.push(`🔥 ${streak}`) + return parts.join(', ') + }, [habit.title, dotState, linkedGoal, showStreak, streak, t]) + const indentPx = depth * 16 return ( @@ -224,7 +228,7 @@ export function HabitRow({ } delayLongPress={300} accessibilityRole="button" - accessibilityLabel={habit.title} + accessibilityLabel={rowAccessibilityLabel} style={({ pressed }) => [ styles.row, { @@ -294,145 +298,37 @@ export function HabitRow({ )} - - - {habit.title} - - - {habit.description?.trim() ? ( - - {stripInlineMarkdown(habit.description)} - - ) : null} - - {metaParts.length > 0 || showStreak ? ( - - {metaParts.map((part, i) => ( - - {i > 0 ? ( - · - ) : null} - {typeof part === 'string' ? ( - part - ) : part.kind === 'future' ? ( - part.label - ) : ( - - {t('habits.overdue')} - - )} - - ))} - {showStreak ? ( - - {metaParts.length > 0 ? ' ' : ''}🔥 {streak} - - ) : null} - - ) : null} - + - - {linkedGoal ? ( - - ) : null} - {!isSelectMode ? ( - hasChildren && childrenTotal > 0 ? ( - <> - - {childrenDone}/{childrenTotal} - - { - if (isDoneForRange) { - actions.onUnlog?.() - } else if (childrenDone >= childrenTotal) { - actions.onLog?.() - } else { - actions.onForceLogParent?.() - } - }} - hitSlop={{ top: 14, bottom: 14, left: 14, right: 14 }} - accessibilityRole="button" - accessibilityLabel={`${habit.title} ${childrenDone}/${childrenTotal}`} - > - - - - ) : ( - - ) - ) : null} - {!isSelectMode && hasMenuActions ? ( - - { - menuActivityAt.current = Date.now() - }} - onPress={openMenu} - hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }} - accessibilityRole="button" - accessibilityLabel={t('habits.actions.more')} - style={({ pressed }) => [ - styles.menuButton, - pressed ? { backgroundColor: tokens.bgElevPressed } : null, - ]} - > - - - - ) : null} - + { + menuActivityAt.current = Date.now() + }} + /> {hasMenuActions ? ( @@ -457,6 +353,217 @@ export function HabitRow({ ) } +type HabitRowMetaPart = + | string + | { kind: 'overdue' } + | { kind: 'future'; label: string } + +interface HabitRowContentProps { + habit: NormalizedHabit + titleSize: number + titleColor: string + isDoneForRange: boolean + metaParts: HabitRowMetaPart[] + showStreak: boolean + streak: number + tokens: ReturnType +} + +function HabitRowContent({ + habit, + titleSize, + titleColor, + isDoneForRange, + metaParts, + showStreak, + streak, + tokens, +}: Readonly) { + const { t } = useTranslation() + return ( + + + {habit.title} + + + {habit.description?.trim() ? ( + + {stripInlineMarkdown(habit.description)} + + ) : null} + + {metaParts.length > 0 || showStreak ? ( + + {metaParts.map((part, i) => ( + + {i > 0 ? ( + · + ) : null} + {typeof part === 'string' ? ( + part + ) : part.kind === 'future' ? ( + part.label + ) : ( + + {t('habits.overdue')} + + )} + + ))} + {showStreak ? ( + + {metaParts.length > 0 ? ' ' : ''}🔥 {streak} + + ) : null} + + ) : null} + + ) +} + +interface HabitRowTrailingProps { + habit: NormalizedHabit + isSelectMode: boolean + hasChildren: boolean + childrenDone: number + childrenTotal: number + linkedGoal: boolean + isDoneForRange: boolean + canLog: boolean + dotState: StatusDotState + hasMenuActions: boolean + menuButtonRef: React.RefObject + actions: HabitRowActions + tokens: ReturnType + onToggleStatus: () => void + onOpenMenu: () => void + onMenuActivity: () => void +} + +function HabitRowTrailing({ + habit, + isSelectMode, + hasChildren, + childrenDone, + childrenTotal, + linkedGoal, + isDoneForRange, + canLog, + dotState, + hasMenuActions, + menuButtonRef, + actions, + tokens, + onToggleStatus, + onOpenMenu, + onMenuActivity, +}: Readonly) { + const { t } = useTranslation() + return ( + + {linkedGoal ? ( + + ) : null} + {!isSelectMode ? ( + hasChildren && childrenTotal > 0 ? ( + <> + + {childrenDone}/{childrenTotal} + + { + if (isDoneForRange) { + actions.onUnlog?.() + } else if (childrenDone >= childrenTotal) { + actions.onLog?.() + } else { + actions.onForceLogParent?.() + } + }} + hitSlop={{ top: 14, bottom: 14, left: 14, right: 14 }} + accessibilityRole="button" + accessibilityLabel={`${habit.title} ${childrenDone}/${childrenTotal}`} + > + + + + ) : ( + + ) + ) : null} + {!isSelectMode && hasMenuActions ? ( + + [ + styles.menuButton, + pressed ? { backgroundColor: tokens.bgElevPressed } : null, + ]} + > + + + + ) : null} + + ) +} + const CHECK_FILLED_STATES: ReadonlySet = new Set([ 'done', 'skip', @@ -493,18 +600,19 @@ function CheckCircle({ const color = tone === 'bad' && state === 'done' ? tokens.statusBad : colorMap[state] + const prefersReducedMotion = usePrefersReducedMotion() const popScale = useSharedValue(1) const previousFilled = useRef(filled) useEffect(() => { - if (filled && !previousFilled.current) { + if (filled && !previousFilled.current && !prefersReducedMotion) { popScale.value = withSequence( withSpring(1.18, { damping: 14 }), withSpring(1), ) } previousFilled.current = filled - }, [filled, popScale]) + }, [filled, popScale, prefersReducedMotion]) const popStyle = useAnimatedStyle(() => ({ transform: [{ scale: popScale.value }], @@ -598,7 +706,7 @@ function HabitRowMenuBody({ ) : null} @@ -634,7 +742,7 @@ function HabitRowMenuBody({ diff --git a/apps/mobile/components/habits/today-ai-summary.tsx b/apps/mobile/components/habits/today-ai-summary.tsx index b7085a1df7..bd11370e69 100644 --- a/apps/mobile/components/habits/today-ai-summary.tsx +++ b/apps/mobile/components/habits/today-ai-summary.tsx @@ -201,7 +201,7 @@ function createStyles(tokens: ReturnType) { fontFamily: 'Rubik_400Regular', fontSize: 11, lineHeight: 15, - color: tokens.fg4, + color: tokens.fg3, marginTop: 6, }, }) diff --git a/apps/mobile/components/message-bubble.tsx b/apps/mobile/components/message-bubble.tsx index f2183c475f..d3f0ac55a8 100644 --- a/apps/mobile/components/message-bubble.tsx +++ b/apps/mobile/components/message-bubble.tsx @@ -118,6 +118,7 @@ export function MessageBubble({ accessibilityLabel={t("chat.attachmentPreview")} style={styles.imageAttachment} resizeMode="cover" + resizeMethod="resize" /> )} @@ -330,7 +331,7 @@ function createStyles(tokens: AppTokens) { flexDirection: "row", alignItems: "center", gap: 6, - minHeight: 36, + minHeight: 44, paddingHorizontal: 14, borderRadius: 999, backgroundColor: tokens.bgElev, @@ -366,13 +367,13 @@ function createStyles(tokens: AppTokens) { denialTitle: { fontFamily: 'Rubik_500Medium', fontSize: 12, - color: tokens.statusBad, + color: tokens.statusBadText, }, denialReason: { fontFamily: 'Rubik_400Regular', fontSize: 11, lineHeight: 16, - color: tokens.statusBad, + color: tokens.statusBadText, }, denialUpgrade: { fontFamily: 'Rubik_600SemiBold', diff --git a/apps/mobile/components/navigation/bottom-tab-bar.tsx b/apps/mobile/components/navigation/bottom-tab-bar.tsx index 026d1b0f0d..f4918b23a6 100644 --- a/apps/mobile/components/navigation/bottom-tab-bar.tsx +++ b/apps/mobile/components/navigation/bottom-tab-bar.tsx @@ -141,7 +141,7 @@ function TabButton({ onPress: () => void showUnread: boolean }>) { - const color = isActive ? tokens.primary : tokens.fg4 + const color = isActive ? tokens.primary : tokens.fg3 return ( + t: (key: string, values?: Record) => string + onPress: (notification: NotificationItem) => void + onRequestDelete: (notification: NotificationItem) => void +} + +function renderNotificationRow( + item: NotificationItem, + index: number, + context: NotificationRowContext, +) { + const { tokens, styles, t, onPress, onRequestDelete } = context + const glyph = getNotificationGlyph(item) + const GlyphIcon = glyphIconMap[glyph] + return ( + + onPress(item)} + accessibilityRole="button" + accessibilityLabel={item.title} + > + + + + + + + {item.title} + + + {formatNotificationRelativeTime(item.createdAtUtc, (key, values) => + t(`notifications.${key}`, values), + )} + + + + {item.body} + + + [ + styles.deleteBtn, + pressed ? styles.deleteBtnPressed : null, + ]} + onPress={() => onRequestDelete(item)} + accessibilityRole="button" + accessibilityLabel={t('notifications.deleteNotification')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + {({ pressed }) => ( + + )} + + + + ) +} + +type NotificationBellStyles = ReturnType + +interface NotificationListActionsProps { + tokens: AppTokens + styles: NotificationBellStyles + showMarkAllRead: boolean + showDeleteAll: boolean + onMarkAllRead: () => void + onDeleteAll: () => void +} + +function NotificationListActions({ + tokens, + styles, + showMarkAllRead, + showDeleteAll, + onMarkAllRead, + onDeleteAll, +}: Readonly) { + const { t } = useTranslation() + return ( + + {showMarkAllRead && ( + [ + styles.sheetActionBtn, + pressed ? styles.deleteBtnPressed : null, + ]} + onPress={onMarkAllRead} + accessibilityRole="button" + accessibilityLabel={t('notifications.markAllRead')} + > + {({ pressed }) => ( + + )} + + )} + {showDeleteAll && ( + [ + styles.sheetActionBtn, + pressed ? styles.deleteBtnPressed : null, + ]} + onPress={onDeleteAll} + accessibilityRole="button" + accessibilityLabel={t('notifications.deleteAll')} + > + {({ pressed }) => ( + + )} + + )} + + ) +} + +interface NotificationListEmptyProps { + tokens: AppTokens + styles: NotificationBellStyles + isLoading: boolean +} + +function NotificationListEmpty({ + tokens, + styles, + isLoading, +}: Readonly) { + const { t } = useTranslation() + if (isLoading) { + return ( + + + + ) + } + return ( + + + + + {t('notifications.empty')} + + ) +} + export function NotificationBell() { const { t } = useTranslation() const { currentScheme, currentTheme } = useAppTheme() @@ -137,73 +296,13 @@ export function NotificationBell() { } function renderNotification({ item, index }: { item: NotificationItem; index: number }) { - const glyph = getNotificationGlyph(item) - const GlyphIcon = glyphIconMap[glyph] - return ( - - handlePress(item)} - accessibilityLabel={item.title} - > - - - - - - - {item.title} - - - {formatNotificationRelativeTime(item.createdAtUtc, (key, values) => - t(`notifications.${key}`, values), - )} - - - - {item.body} - - - [ - styles.deleteBtn, - pressed ? styles.deleteBtnPressed : null, - ]} - onPress={() => requestDeleteNotification(item)} - accessibilityRole="button" - accessibilityLabel={t('notifications.deleteNotification')} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - > - {({ pressed }) => ( - - )} - - - - ) - } - - function renderEmpty() { - if (isLoading) { - return ( - - - - ) - } - return ( - - - - - {t('notifications.empty')} - - ) + return renderNotificationRow(item, index, { + tokens, + styles, + t, + onPress: handlePress, + onRequestDelete: requestDeleteNotification, + }) } return ( @@ -232,63 +331,34 @@ export function NotificationBell() { title={t('notifications.title')} snapPoints={['88%', '96%']} > - - {visibleUnreadCount > 0 && ( - [ - styles.sheetActionBtn, - pressed ? styles.deleteBtnPressed : null, - ]} - onPress={() => markAllAsRead.mutate()} - accessibilityRole="button" - accessibilityLabel={t('notifications.markAllRead')} - > - {({ pressed }) => ( - - )} - - )} - {visibleNotifications.length > 0 && ( - [ - styles.sheetActionBtn, - pressed ? styles.deleteBtnPressed : null, - ]} - onPress={() => setShowDeleteAllConfirm(true)} - accessibilityRole="button" - accessibilityLabel={t('notifications.deleteAll')} - > - {({ pressed }) => ( - - )} - - )} - - - item.id} + renderItem={renderNotification} showsVerticalScrollIndicator={false} + ListHeaderComponent={ + 0} + showDeleteAll={visibleNotifications.length > 0} + onMarkAllRead={() => markAllAsRead.mutate()} + onDeleteAll={() => setShowDeleteAllConfirm(true)} + /> + } + ListEmptyComponent={ + + } contentContainerStyle={[ withDrawerContentInset(styles.listContent), visibleNotifications.length === 0 && styles.emptyListContainer, ]} - > - {isLoading && visibleNotifications.length === 0 ? ( - renderEmpty() - ) : visibleNotifications.length === 0 ? ( - renderEmpty() - ) : ( - visibleNotifications.map((item, index) => renderNotification({ item, index })) - )} - + /> {selectedNotification && ( @@ -396,7 +466,7 @@ function createStyles(tokens: AppTokens) { notifTime: { fontFamily: 'Rubik_400Regular', fontSize: 12, - color: tokens.fg4, + color: tokens.fg3, flexShrink: 0, }, notifBody: { diff --git a/apps/mobile/components/navigation/notification-detail-modal.tsx b/apps/mobile/components/navigation/notification-detail-modal.tsx index e5fbb3e4c2..eb9d35d5db 100644 --- a/apps/mobile/components/navigation/notification-detail-modal.tsx +++ b/apps/mobile/components/navigation/notification-detail-modal.tsx @@ -148,7 +148,7 @@ function createStyles(tokens: ReturnType) { paddingBottom: 10, fontFamily: 'Roboto_400Regular', fontSize: 12, - color: tokens.fg4, + color: tokens.fg3, letterSpacing: 0.24, fontVariant: ['tabular-nums'], borderBottomWidth: StyleSheet.hairlineWidth, diff --git a/apps/mobile/components/onboarding/calendar-import-prompt.tsx b/apps/mobile/components/onboarding/calendar-import-prompt.tsx index c48fb72d39..2daf44c45f 100644 --- a/apps/mobile/components/onboarding/calendar-import-prompt.tsx +++ b/apps/mobile/components/onboarding/calendar-import-prompt.tsx @@ -29,6 +29,7 @@ export function CalendarImportPrompt() { const shouldShow = Boolean( profile?.hasCompletedOnboarding && + profile?.hasCompletedTour && !profile?.hasImportedCalendar && pathname !== '/calendar-sync' && !dismissed, diff --git a/apps/mobile/components/onboarding/onboarding-complete-habit.tsx b/apps/mobile/components/onboarding/onboarding-complete-habit.tsx index 02adca2ddf..5a5aec9411 100644 --- a/apps/mobile/components/onboarding/onboarding-complete-habit.tsx +++ b/apps/mobile/components/onboarding/onboarding-complete-habit.tsx @@ -110,7 +110,14 @@ export function OnboardingCompleteHabit({ - + ) { - const width = useMemo(() => new Animated.Value(7), []) + const scale = useMemo(() => new Animated.Value(DOT_INACTIVE_SCALE), []) useEffect(() => { if (reducedMotion) { - width.setValue(active ? 24 : 7) + scale.setValue(active ? 1 : DOT_INACTIVE_SCALE) return } - const animation = Animated.timing(width, { - toValue: active ? 24 : 7, + const animation = Animated.timing(scale, { + toValue: active ? 1 : DOT_INACTIVE_SCALE, duration: 220, easing: toAnimatedEasing(easings.smooth), - useNativeDriver: false, + useNativeDriver: true, }) animation.start() return () => animation.stop() - }, [active, reducedMotion, width]) + }, [active, reducedMotion, scale]) return ( ) } +interface OnboardingStepContentProps { + viewingAstra: boolean + sharedStep: number + createdHabitId: string | null + createdHabitTitle: string + createdGoal: boolean + onHabitCreated: (habitId: string, title: string) => void + onHabitCompleted: () => void + onGoalCreated: () => void + onGoalSkipped: () => void + onFinish: () => void +} + +function OnboardingStepContent({ + viewingAstra, + sharedStep, + createdHabitId, + createdHabitTitle, + createdGoal, + onHabitCreated, + onHabitCompleted, + onGoalCreated, + onGoalSkipped, + onFinish, +}: Readonly) { + if (viewingAstra) return + switch (sharedStep) { + case 0: + return + case 1: + return ( + + ) + case 2: + return ( + + ) + case 3: + return ( + + ) + case 4: + return + case 5: + return ( + + ) + default: + return null + } +} + +interface OnboardingFooterProps { + tokens: AppTokensV2 + styles: ReturnType + displayTotal: number + displayStep: number + prefersReducedMotion: boolean + canAdvance: boolean + hasPrev: boolean + isStarter: boolean + onNext: () => void + onPrev: () => void +} + +function OnboardingFooter({ + tokens, + styles, + displayTotal, + displayStep, + prefersReducedMotion, + canAdvance, + hasPrev, + isStarter, + onNext, + onPrev, +}: Readonly) { + const { t } = useTranslation() + return ( + + + {Array.from({ length: displayTotal }).map((_, i) => ( + + ))} + + + + {canAdvance && ( + + {isStarter + ? t('onboarding.flow.begin') + : t('onboarding.flow.next')} + + )} + {hasPrev && ( + + + {t('onboarding.flow.back')} + + + )} + + + ) +} + export function OnboardingFlow() { const { t } = useTranslation() const router = useRouter() @@ -201,61 +338,25 @@ export function OnboardingFlow() { const hideFooter = !viewingAstra && shouldHideOnboardingFooter(sharedStep) - const stepContent = (() => { - if (viewingAstra) return - switch (sharedStep) { - case 0: - return - case 1: - return ( - - ) - case 2: - return ( - - ) - case 3: - return ( - - ) - case 4: - return - case 5: - return ( - - ) - default: - return null - } - })() - const progressLabel = `Orbit · ${String(displayStep).padStart(2, '0')} / ${String(displayTotal).padStart(2, '0')}` + function handleRequestClose() { + if (hasPrev) goPrev() + } + return ( - + {progressLabel} {!isFinalStep && ( - + {t('onboarding.flow.skip')} @@ -284,45 +385,34 @@ export function OnboardingFlow() { }, ]} > - {stepContent} + {!hideFooter && ( - - - {Array.from({ length: displayTotal }).map((_, i) => ( - - ))} - - - - {canAdvance && ( - - {isStarter - ? t('onboarding.flow.begin') - : t('onboarding.flow.next')} - - )} - {hasPrev && ( - - - {t('onboarding.flow.back')} - - - )} - - + )} diff --git a/apps/mobile/components/onboarding/onboarding-welcome.tsx b/apps/mobile/components/onboarding/onboarding-welcome.tsx index 137249fc2f..25a90efb0d 100644 --- a/apps/mobile/components/onboarding/onboarding-welcome.tsx +++ b/apps/mobile/components/onboarding/onboarding-welcome.tsx @@ -153,6 +153,11 @@ export function OnboardingWelcome() { handleSchemeSelect(option.value)} + accessibilityRole="button" + accessibilityLabel={t( + `preferences.color${option.value.charAt(0).toUpperCase() + option.value.slice(1)}`, + )} + accessibilityState={{ selected: isActive }} style={[ styles.schemeBtn, { diff --git a/apps/mobile/components/referral/referral-drawer.tsx b/apps/mobile/components/referral/referral-drawer.tsx index 9257afe9e2..94810e9390 100644 --- a/apps/mobile/components/referral/referral-drawer.tsx +++ b/apps/mobile/components/referral/referral-drawer.tsx @@ -153,7 +153,7 @@ export function ReferralDrawer({ open, onClose }: Readonly) value={String(stats.pendingReferrals)} mono accessory="none" - valueColor={tokens.statusOverdue} + valueColor={tokens.fg1} /> ) : null} {stats.successfulReferrals > 0 ? ( @@ -214,7 +214,7 @@ function createStyles(tokens: ReturnType) { errorText: { fontFamily: 'Rubik_400Regular', fontSize: 14, - color: tokens.statusOverdue, + color: tokens.statusOverdueText, }, heroRow: { alignItems: 'center', diff --git a/apps/mobile/components/tour/tour-overlay.tsx b/apps/mobile/components/tour/tour-overlay.tsx index d18cf59c18..b71c7682b3 100644 --- a/apps/mobile/components/tour/tour-overlay.tsx +++ b/apps/mobile/components/tour/tour-overlay.tsx @@ -7,6 +7,7 @@ import { TourSpotlight } from './tour-spotlight' import { TourTooltip } from './tour-tooltip' import { API } from '@orbit/shared/api' import { apiClient } from '@/lib/api-client' +import { useOverlayBack } from '@/hooks/use-overlay-back' import AsyncStorage from '@react-native-async-storage/async-storage' /** @@ -72,6 +73,8 @@ export function TourOverlay() { handleEnd() }, [handleEnd]) + useOverlayBack(isActive && !isNavigating && !!targetRect && !!step, handleSkip) + if (!isActive || isNavigating || !targetRect || !step) { return null } diff --git a/apps/mobile/components/tour/tour-replay-modal.tsx b/apps/mobile/components/tour/tour-replay-modal.tsx index a362306451..63c6ee6f48 100644 --- a/apps/mobile/components/tour/tour-replay-modal.tsx +++ b/apps/mobile/components/tour/tour-replay-modal.tsx @@ -107,7 +107,12 @@ export function TourReplayModal({ visible, onClose }: TourReplayModalProps) { onRequestClose={onClose} > - + @@ -152,6 +157,7 @@ export function TourReplayModal({ visible, onClose }: TourReplayModalProps) { pressed && styles.sectionRowPressed, ]} onPress={() => handleReplaySection(section)} + accessibilityRole="button" > {Icon ? ( diff --git a/apps/mobile/components/tour/tour-tooltip.tsx b/apps/mobile/components/tour/tour-tooltip.tsx index 04d32f3440..e1c2b6b31c 100644 --- a/apps/mobile/components/tour/tour-tooltip.tsx +++ b/apps/mobile/components/tour/tour-tooltip.tsx @@ -155,7 +155,7 @@ export function TourTooltip({ {!isFirstStep ? ( - + {t('tour.ui.back')} @@ -166,6 +166,7 @@ export function TourTooltip({ [ styles.nextButton, primaryGlow(tokens), @@ -180,7 +181,7 @@ export function TourTooltip({ - + {t('tour.ui.skip')} diff --git a/apps/mobile/components/ui/anchored-menu.tsx b/apps/mobile/components/ui/anchored-menu.tsx index 4f4ec419fd..78ae1b0d98 100644 --- a/apps/mobile/components/ui/anchored-menu.tsx +++ b/apps/mobile/components/ui/anchored-menu.tsx @@ -137,6 +137,8 @@ export function AnchoredMenu({ style={styles.backdropPressTarget} activeOpacity={1} onPress={onClose} + importantForAccessibility="no" + accessibilityElementsHidden /> setIsOpen(false)} + accessibilityRole="button" + accessibilityLabel={t('common.close')} > @@ -167,6 +170,7 @@ export function AppDatePicker({ @@ -199,6 +203,8 @@ export function AppDatePicker({ ]} onPress={() => selectDay(day)} activeOpacity={0.7} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} accessibilityLabel={formatLocaleDate(day, locale, { month: 'long', day: 'numeric', @@ -250,7 +256,7 @@ function createStyles(tokens: AppTokens) { marginRight: 8, }, triggerPlaceholder: { - color: tokens.fg4, + color: tokens.fg3, }, backdrop: { flex: 1, @@ -315,7 +321,7 @@ function createStyles(tokens: AppTokens) { fontSize: 12, }, dayTextOutside: { - color: tokens.fg4, + color: tokens.fg3, }, dayTextSelected: { color: tokens.fgOnPrimary, diff --git a/apps/mobile/components/ui/app-error-boundary.tsx b/apps/mobile/components/ui/app-error-boundary.tsx new file mode 100644 index 0000000000..bebdcf441b --- /dev/null +++ b/apps/mobile/components/ui/app-error-boundary.tsx @@ -0,0 +1,91 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { AlertTriangle } from 'lucide-react-native' +import { i18n } from '@/lib/i18n' +import { createTokensV2, primaryGlow, radius } from '@/lib/theme' + +interface AppErrorScreenProps { + error: Error + retry: () => void +} + +/** + * Self-contained render-error fallback for the Expo Router root ErrorBoundary. + * Resolves tokens + copy from singletons (createTokensV2 / i18n) rather than + * React context, so it renders even when the provider tree is what threw. + * Mirrors the web (app)/error.tsx alert-orb + retry layout. + */ +export function AppErrorScreen({ error, retry }: Readonly) { + const tokens = createTokensV2() + const message = + process.env.NODE_ENV === 'development' && error.message + ? error.message + : i18n.t('auth.genericError') + + return ( + + + + + {message} + [ + styles.pill, + primaryGlow(tokens), + { + backgroundColor: tokens.primary, + opacity: pressed ? 0.9 : 1, + }, + ]} + > + + {i18n.t('common.retry')} + + + + ) +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 36, + paddingVertical: 64, + }, + orb: { + width: 80, + height: 80, + borderRadius: 999, + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + }, + message: { + marginTop: 18, + fontFamily: 'Rubik_500Medium', + fontSize: 22, + lineHeight: 29, + textAlign: 'center', + }, + pill: { + marginTop: 22, + minHeight: 52, + paddingHorizontal: 28, + borderRadius: radius.full, + alignItems: 'center', + justifyContent: 'center', + }, + pillLabel: { + fontFamily: 'Rubik_500Medium', + fontSize: 16, + }, +}) diff --git a/apps/mobile/components/ui/app-select.tsx b/apps/mobile/components/ui/app-select.tsx index b7022b46d4..c3bc504b8c 100644 --- a/apps/mobile/components/ui/app-select.tsx +++ b/apps/mobile/components/ui/app-select.tsx @@ -77,6 +77,7 @@ export function AppSelect({ style={styles.backdrop} activeOpacity={1} onPress={() => setIsOpen(false)} + importantForAccessibility="no" > handleSelect(item.value)} activeOpacity={0.7} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} > ( return ( @@ -159,6 +161,8 @@ export function AppTimePicker({ style={styles.backdrop} activeOpacity={1} onPress={() => setIsOpen(false)} + accessibilityRole="button" + accessibilityLabel={t('common.close')} > setIsOpen(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + accessibilityRole="button" > {t('common.cancel')} @@ -178,6 +183,7 @@ export function AppTimePicker({ setIsOpen(false) }} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + accessibilityRole="button" > {t('common.done')} @@ -239,7 +245,7 @@ function createStyles(tokens: AppTokens) { color: tokens.fg3, }, triggerPlaceholder: { - color: tokens.fg4, + color: tokens.fg3, }, backdrop: { flex: 1, diff --git a/apps/mobile/components/ui/app-toast.tsx b/apps/mobile/components/ui/app-toast.tsx index 27865a3c68..2271fcc7c1 100644 --- a/apps/mobile/components/ui/app-toast.tsx +++ b/apps/mobile/components/ui/app-toast.tsx @@ -157,6 +157,7 @@ export function AppToast() { }, ]} onPress={hideToast} + accessibilityRole="button" > @@ -165,7 +166,7 @@ export function AppToast() { {currentToast.message} {currentToast.actionLabel ? ( - + {currentToast.actionLabel} diff --git a/apps/mobile/components/ui/badge.tsx b/apps/mobile/components/ui/badge.tsx index 1e08a8d538..5aef5284ef 100644 --- a/apps/mobile/components/ui/badge.tsx +++ b/apps/mobile/components/ui/badge.tsx @@ -54,7 +54,7 @@ function toneStyles( if (tone === 'amber') { return { container: { backgroundColor: rgbaFromHex(tokens.statusOverdue, 0.18) }, - text: { color: tokens.statusOverdue }, + text: { color: tokens.statusOverdueText }, } } return { diff --git a/apps/mobile/components/ui/bottom-sheet-app-text-input.tsx b/apps/mobile/components/ui/bottom-sheet-app-text-input.tsx index f72df6171a..db6bb9566d 100644 --- a/apps/mobile/components/ui/bottom-sheet-app-text-input.tsx +++ b/apps/mobile/components/ui/bottom-sheet-app-text-input.tsx @@ -98,7 +98,7 @@ export const BottomSheetAppTextInput = forwardRef< return ( onOpenChange(false)} > {!infoOnly ? ( [ styles.actionPill, pressed ? styles.cancelPillPressed : styles.cancelPill, @@ -154,6 +157,7 @@ export function ConfirmDialog({ ) : null} [ styles.actionPill, destructive ? styles.confirmPillDestructive : null, @@ -163,7 +167,13 @@ export function ConfirmDialog({ ]} onPress={handleConfirm} > - + {confirmLabel ?? (infoOnly ? t('common.close') : t('common.confirm'))} @@ -255,5 +265,8 @@ function createStyles(tokens: AppTokens) { color: tokens.fgOnPrimary, fontSize: 15, }, + confirmLabelDestructive: { + color: tokens.fgOnBad, + }, }) } diff --git a/apps/mobile/components/ui/create-api-key-modal.tsx b/apps/mobile/components/ui/create-api-key-modal.tsx index 2313a14a43..186e1921f1 100644 --- a/apps/mobile/components/ui/create-api-key-modal.tsx +++ b/apps/mobile/components/ui/create-api-key-modal.tsx @@ -13,6 +13,10 @@ import type { ApiKeyCreateRequest, ApiKeyCreateResponse, } from '@orbit/shared/types/api-key' +import { + MAX_API_KEY_NAME_LENGTH, + parseApiKeyExpiryUtc, +} from '@orbit/shared/validation' import { BottomSheetModal } from '@/components/bottom-sheet-modal' import { BottomSheetAppTextInput } from '@/components/ui/bottom-sheet-app-text-input' import { KeyboardAwareBottomSheetScrollView } from '@/components/ui/keyboard-aware-scroll-view' @@ -39,6 +43,232 @@ interface CreateApiKeyModalProps { onCreated?: () => void } +type ApiKeyModalStyles = ReturnType + +interface ApiKeyRevealPanelProps { + tokens: AppTokensV2 + styles: ApiKeyModalStyles + createdKey: ApiKeyCreateResponse + copied: boolean + onCopy: () => void + onClose: () => void +} + +function ApiKeyRevealPanel({ + tokens, + styles, + createdKey, + copied, + onCopy, + onClose, +}: Readonly) { + const { t } = useTranslation() + return ( + <> + + {t('orbitMcp.keyCreatedWarning')} + + + + + + {copied ? t('orbitMcp.copied') : t('orbitMcp.copy')} + + + + {createdKey.key} + + + + + {`${t('orbitMcp.scopesLabel')} ${ + createdKey.scopes.length + ? createdKey.scopes.length + : t('orbitMcp.noScopes') + } · ${t('orbitMcp.readOnlyLabel')} ${ + createdKey.isReadOnly ? t('common.yes') : t('common.no') + } · ${ + createdKey.expiresAtUtc + ? createdKey.expiresAtUtc + : t('common.never') + }`} + + + + + {t('orbitMcp.done')} + + + + ) +} + +interface ApiKeyCreateFormProps { + tokens: AppTokensV2 + styles: ApiKeyModalStyles + availableScopes: ScopeOption[] + keyName: string + selectedScopes: string[] + isReadOnly: boolean + expiresAt: string + validationError: string + apiError?: string | null + isSubmitting: boolean + onChangeName: (value: string) => void + onChangeExpiresAt: (value: string) => void + onToggleScope: (scope: string) => void + onSelectAllScopes: () => void + onClearScopes: () => void + onToggleReadOnly: () => void + onCancel: () => void + onSubmit: () => void +} + +function ApiKeyCreateForm({ + tokens, + styles, + availableScopes, + keyName, + selectedScopes, + isReadOnly, + expiresAt, + validationError, + apiError, + isSubmitting, + onChangeName, + onChangeExpiresAt, + onToggleScope, + onSelectAllScopes, + onClearScopes, + onToggleReadOnly, + onCancel, + onSubmit, +}: Readonly) { + const { t } = useTranslation() + return ( + <> + + {t('orbitMcp.keyName')} + + + + + {t('orbitMcp.scopesLabel')} + + {availableScopes.map((scope) => ( + onToggleScope(scope.scope)} + > + {scope.scope} + + ))} + + + + + {t('common.selectAll')} + + + + {t('common.clear')} + + + + + + + {t('orbitMcp.readOnlyKeyLabel')} + + + + + + + {t('orbitMcp.expiresAtLabel')} + + + + + {validationError ? ( + + {validationError} + + ) : null} + + {apiError ? ( + + {apiError} + + ) : null} + + + + {t('common.cancel')} + + + {isSubmitting ? ( + + ) : ( + t('orbitMcp.createKey') + )} + + + + ) +} + /** * API key sheet with two phases: "Create" (name well, scope chips, read-only * switch row, expires well) and the one-time reveal (bg-field mono well with @@ -96,14 +326,13 @@ export function CreateApiKeyModal({ setValidationError(t('orbitMcp.keyNameRequired')) return false } - if (trimmed.length > 50) { + if (trimmed.length > MAX_API_KEY_NAME_LENGTH) { setValidationError(t('orbitMcp.keyNameMaxLength')) return false } if (expiresAt.trim()) { - const parsed = new Date(expiresAt) - if (Number.isNaN(parsed.getTime())) { - setValidationError(t('auth.genericError')) + if (!parseApiKeyExpiryUtc(expiresAt)) { + setValidationError(t('orbitMcp.invalidExpiry')) return false } } @@ -123,13 +352,15 @@ export function CreateApiKeyModal({ setIsSubmitting(true) try { + const expiresAtUtc = expiresAt.trim() + ? parseApiKeyExpiryUtc(expiresAt)?.toISOString() ?? null + : null + const result = await onCreateKey({ name: keyName.trim(), scopes: selectedScopes.length > 0 ? selectedScopes : undefined, isReadOnly, - expiresAtUtc: expiresAt.trim() - ? new Date(expiresAt).toISOString() - : null, + expiresAtUtc, }) if (result) { setCreatedKey(result) @@ -172,172 +403,42 @@ export function CreateApiKeyModal({ showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="always" > - {isRevealState ? ( - <> - - {t('orbitMcp.keyCreatedWarning')} - - - - { - void copyKey() - }} - accessibilityRole="button" - accessibilityLabel={t('orbitMcp.copy')} - hitSlop={8} - > - - {copied ? t('orbitMcp.copied') : t('orbitMcp.copy')} - - - - {createdKey?.key} - - - - - {`${t('orbitMcp.scopesLabel')} ${ - createdKey?.scopes.length - ? createdKey.scopes.length - : t('orbitMcp.noScopes') - } · ${t('orbitMcp.readOnlyLabel')} ${ - createdKey?.isReadOnly ? t('common.yes') : t('common.no') - } · ${ - createdKey?.expiresAtUtc - ? createdKey.expiresAtUtc - : t('common.never') - }`} - - - - onOpenChange(false)} - accessibilityLabel={t('orbitMcp.done')} - > - {t('orbitMcp.done')} - - - + {isRevealState && createdKey ? ( + { + void copyKey() + }} + onClose={() => onOpenChange(false)} + /> ) : ( - <> - - {t('orbitMcp.keyName')} - - - - - {t('orbitMcp.scopesLabel')} - - {availableScopes.map((scope) => ( - toggleScope(scope.scope)} - > - {scope.scope} - - ))} - - - - setSelectedScopes( - availableScopes.map((scope) => scope.scope), - ) - } - accessibilityRole="button" - accessibilityLabel={t('common.selectAll')} - > - - {t('common.selectAll')} - - - setSelectedScopes([])} - accessibilityRole="button" - accessibilityLabel={t('common.clear')} - > - {t('common.clear')} - - - - - - - {t('orbitMcp.readOnlyKeyLabel')} - - setIsReadOnly((current) => !current)} - accessibilityLabel={t('orbitMcp.readOnlyKeyLabel')} - /> - - - - - {t('orbitMcp.expiresAtLabel')} - - - - - {validationError ? ( - - {validationError} - - ) : null} - - {apiError ? ( - - {apiError} - - ) : null} - - - onOpenChange(false)} - disabled={isSubmitting} - accessibilityLabel={t('common.cancel')} - > - {t('common.cancel')} - - { - void handleSubmit() - }} - disabled={isSubmitting} - accessibilityLabel={t('orbitMcp.createKey')} - > - {isSubmitting ? ( - - ) : ( - t('orbitMcp.createKey') - )} - - - + + setSelectedScopes(availableScopes.map((scope) => scope.scope)) + } + onClearScopes={() => setSelectedScopes([])} + onToggleReadOnly={() => setIsReadOnly((current) => !current)} + onCancel={() => onOpenChange(false)} + onSubmit={() => { + void handleSubmit() + }} + /> )} @@ -412,7 +513,7 @@ function createStyles(tokens: AppTokensV2, bottomInset: number) { fontFamily: 'Rubik_500Medium', fontSize: 14, lineHeight: 20, - color: tokens.statusOverdue, + color: tokens.statusOverdueText, }, keyWell: { position: 'relative', @@ -446,7 +547,7 @@ function createStyles(tokens: AppTokensV2, bottomInset: number) { fontFamily: 'Roboto_400Regular', fontSize: 11, letterSpacing: 0.22, - color: tokens.fg4, + color: tokens.fg3, fontVariant: ['tabular-nums'], }, errorText: { diff --git a/apps/mobile/components/ui/expiry-warning.tsx b/apps/mobile/components/ui/expiry-warning.tsx index da9890b09f..7af805f39d 100644 --- a/apps/mobile/components/ui/expiry-warning.tsx +++ b/apps/mobile/components/ui/expiry-warning.tsx @@ -107,7 +107,7 @@ export function ExpiryWarning() { )} - + {isExpired ? t('auth.login') : t('auth.refresh')} @@ -148,12 +148,12 @@ function createStyles(tokens: AppTokensV2) { color: tokens.fg2, }, urgent: { - color: tokens.statusOverdue, + color: tokens.statusOverdueText, }, monoCount: { fontFamily: 'Roboto_400Regular', fontVariant: ['tabular-nums'], - color: tokens.statusOverdue, + color: tokens.statusOverdueText, }, actionPress: { paddingVertical: 8, diff --git a/apps/mobile/components/ui/field-input.tsx b/apps/mobile/components/ui/field-input.tsx index 7212ee7e79..b204e9900c 100644 --- a/apps/mobile/components/ui/field-input.tsx +++ b/apps/mobile/components/ui/field-input.tsx @@ -36,7 +36,7 @@ export const FieldInput = forwardRef( ) : null} { setFocused(true) diff --git a/apps/mobile/components/ui/fresh-start-animation.tsx b/apps/mobile/components/ui/fresh-start-animation.tsx index a0400d51d7..e0b3fa51a0 100644 --- a/apps/mobile/components/ui/fresh-start-animation.tsx +++ b/apps/mobile/components/ui/fresh-start-animation.tsx @@ -148,7 +148,12 @@ export function FreshStartAnimation({ onComplete }: Readonly + diff --git a/apps/mobile/components/ui/highlight-text.tsx b/apps/mobile/components/ui/highlight-text.tsx index fb8b487b85..ce56296c0b 100644 --- a/apps/mobile/components/ui/highlight-text.tsx +++ b/apps/mobile/components/ui/highlight-text.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { Text, type TextStyle } from 'react-native' +import { highlightText } from '@orbit/shared/utils' import { createTokensV2, tintFromPrimary } from '@/lib/theme' import { useAppTheme } from '@/lib/use-app-theme' @@ -9,43 +10,6 @@ interface HighlightTextProps { style?: TextStyle } -interface Segment { - text: string - isMatch: boolean -} - -/** - * indexOf-based highlight matching (port of web utils/highlight.ts). - * Avoids the stateful `lastIndex` bug from using regex.test() with the `g` flag. - */ -function highlightText(text: string, query: string): Segment[] { - if (!query || !text) return [{ text: text || '', isMatch: false }] - - const segments: Segment[] = [] - const lowerText = text.toLowerCase() - const lowerQuery = query.toLowerCase().trim() - - if (!lowerQuery) return [{ text, isMatch: false }] - - let pos = 0 - let idx = lowerText.indexOf(lowerQuery, pos) - - while (idx !== -1) { - if (idx > pos) { - segments.push({ text: text.slice(pos, idx), isMatch: false }) - } - segments.push({ text: text.slice(idx, idx + lowerQuery.length), isMatch: true }) - pos = idx + lowerQuery.length - idx = lowerText.indexOf(lowerQuery, pos) - } - - if (pos < text.length) { - segments.push({ text: text.slice(pos), isMatch: false }) - } - - return segments.length > 0 ? segments : [{ text, isMatch: false }] -} - export function HighlightText({ text, query, style }: Readonly) { const { currentScheme, currentTheme } = useAppTheme() const tokens = useMemo( diff --git a/apps/mobile/components/ui/push-prompt.tsx b/apps/mobile/components/ui/push-prompt.tsx index 3eadcba3cd..ca9aac3c82 100644 --- a/apps/mobile/components/ui/push-prompt.tsx +++ b/apps/mobile/components/ui/push-prompt.tsx @@ -238,7 +238,7 @@ function createStyles(tokens: AppTokensV2) { retryText: { fontFamily: 'Rubik_400Regular', fontSize: 12, - color: tokens.statusOverdue, + color: tokens.statusOverdueText, }, buttons: { flexDirection: 'column', diff --git a/apps/mobile/components/ui/select-check.tsx b/apps/mobile/components/ui/select-check.tsx index 63a8a24f4f..9c7402036d 100644 --- a/apps/mobile/components/ui/select-check.tsx +++ b/apps/mobile/components/ui/select-check.tsx @@ -15,7 +15,7 @@ export function RadioGlyph({ { width: size, height: size }, selected ? { backgroundColor: tokens.primary } - : { borderWidth: 2, borderColor: tokens.fg4 }, + : { borderWidth: 2, borderColor: tokens.fg3 }, ]} > {selected ? ( @@ -32,7 +32,7 @@ export function RadioGlyph({ ) } -/** Kit Radio: 24px circle — primary fill + white dot when selected, inset 2px fg-4 ring otherwise. */ +/** Kit Radio: 24px circle — primary fill + white dot when selected, inset 2px fg-3 ring otherwise. */ interface SelectCheckProps { selected: boolean /** Circle size in px (default 24 per kit spec). */ diff --git a/apps/mobile/components/ui/skeleton.tsx b/apps/mobile/components/ui/skeleton.tsx index ffeae5cdeb..6caaea2eed 100644 --- a/apps/mobile/components/ui/skeleton.tsx +++ b/apps/mobile/components/ui/skeleton.tsx @@ -6,11 +6,17 @@ import { type ViewStyle, } from 'react-native' import { createTokensV2, radius } from '@/lib/theme' +import { usePrefersReducedMotion } from '@/lib/motion' import { useAppTheme } from '@/lib/use-app-theme' function usePulseOpacity() { const opacity = useMemo(() => new Animated.Value(1), []) + const prefersReducedMotion = usePrefersReducedMotion() useEffect(() => { + if (prefersReducedMotion) { + opacity.setValue(1) + return + } const loop = Animated.loop( Animated.sequence([ Animated.timing(opacity, { toValue: 0.55, duration: 550, useNativeDriver: true }), @@ -19,7 +25,7 @@ function usePulseOpacity() { ) loop.start() return () => loop.stop() - }, [opacity]) + }, [opacity, prefersReducedMotion]) return opacity } diff --git a/apps/mobile/components/upgrade/plan-card.tsx b/apps/mobile/components/upgrade/plan-card.tsx index cbecbca97c..41c2798f25 100644 --- a/apps/mobile/components/upgrade/plan-card.tsx +++ b/apps/mobile/components/upgrade/plan-card.tsx @@ -62,7 +62,7 @@ export function PlanCard({ styles.radio, selected ? { backgroundColor: tokens.primary } - : { borderWidth: 2, borderColor: tokens.fg4 }, + : { borderWidth: 2, borderColor: tokens.fg3 }, ]} > {selected ? ( diff --git a/apps/mobile/eslint.config.js b/apps/mobile/eslint.config.js index 53e0acff49..e533def862 100644 --- a/apps/mobile/eslint.config.js +++ b/apps/mobile/eslint.config.js @@ -4,10 +4,7 @@ const expoConfig = require("eslint-config-expo/flat") const reactHooks = require("eslint-plugin-react-hooks") const noComments = require("../../eslint-rules/no-comments.cjs") -// eslint-config-expo@55 ships react-hooks v5, which pre-dates the React 19 / -// Compiler rules (set-state-in-effect, refs, immutability, etc.). Strip the -// expo registration and re-register with v7 so disable directives that -// reference the new rules resolve correctly and the rules actually fire. +// https://github.com/expo/expo/issues/43758 — eslint-config-expo@55 bundles react-hooks v5 (no React-19/Compiler rules); strip its registration and re-register v7. const expoConfigArray = Array.isArray(expoConfig) ? expoConfig : [expoConfig] const patchedExpoConfig = expoConfigArray.map((c) => { if (c && c.plugins && c.plugins["react-hooks"]) { diff --git a/apps/mobile/hooks/use-billing.ts b/apps/mobile/hooks/use-billing.ts index a389352642..b12d674e47 100644 --- a/apps/mobile/hooks/use-billing.ts +++ b/apps/mobile/hooks/use-billing.ts @@ -7,7 +7,7 @@ import { apiClient } from '@/lib/api-client' export function useBilling(enabled = false) { const query = useQuery({ - queryKey: [...subscriptionKeys.all, 'billing'] as const, + queryKey: subscriptionKeys.billing(), queryFn: async (): Promise => { try { return await apiClient(API.subscription.billing) diff --git a/apps/mobile/hooks/use-calendar-events.ts b/apps/mobile/hooks/use-calendar-events.ts new file mode 100644 index 0000000000..2cebb00aca --- /dev/null +++ b/apps/mobile/hooks/use-calendar-events.ts @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query' +import { calendarKeys } from '@orbit/shared/query' +import { API } from '@orbit/shared/api' +import type { CalendarSyncEvent } from '@orbit/shared' +import { isCalendarSyncNotConnectedMessage } from '@orbit/shared/utils' +import { apiClient } from '@/lib/api-client' + +interface CalendarEventsQueryOptions { + enabled?: boolean +} + +export type CalendarEventsResult = + | { status: 'connected'; events: CalendarSyncEvent[] } + | { status: 'not-connected' } + +const CALENDAR_EVENTS_KEY = [...calendarKeys.all, 'manual-fetch'] as const + +/** + * Fetches the user's upcoming Google Calendar events for the manual import flow. + * + * Mirrors apps/web/hooks/use-calendar-events.ts: a cached query returning a + * discriminated union so callers branch on `status` to render the not-connected + * prompt vs the event list. Other network errors surface via the query's + * `error` field. + */ +export function useCalendarEvents(options?: CalendarEventsQueryOptions) { + return useQuery({ + queryKey: CALENDAR_EVENTS_KEY, + queryFn: async () => { + try { + const data = await apiClient(API.calendar.events) + return { status: 'connected', events: Array.isArray(data) ? data : [] } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : '' + if ( + message === 'Unauthorized' || + isCalendarSyncNotConnectedMessage(message) + ) { + return { status: 'not-connected' } + } + throw err + } + }, + enabled: options?.enabled ?? true, + retry: false, + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }) +} diff --git a/apps/mobile/hooks/use-chat-composer.ts b/apps/mobile/hooks/use-chat-composer.ts index 40748dbc0c..c9ef54ad73 100644 --- a/apps/mobile/hooks/use-chat-composer.ts +++ b/apps/mobile/hooks/use-chat-composer.ts @@ -4,7 +4,6 @@ import * as ImagePicker from "expo-image-picker"; import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; -import { API } from "@orbit/shared/api"; import { CHAT_STARTER_CHIP_KEYS, CHAT_SPEECH_LANGUAGES as SPEECH_LANGUAGES, @@ -16,10 +15,8 @@ import { import { goalKeys, habitKeys, profileKeys, tagKeys } from "@orbit/shared/query"; import type { AgentExecuteOperationResponse, - AgentStepUpChallenge, ChatMessage, ChatResponse, - PendingAgentOperationConfirmation, } from "@orbit/shared/types"; import type { Profile } from "@orbit/shared/types/profile"; import { @@ -36,10 +33,10 @@ import { getErrorMessage, resolveUpgradeEntitlementFromPolicyDenial, } from "@orbit/shared/utils"; -import { apiClient } from "@/lib/api-client"; import { openChatStream } from "@/lib/chat-stream"; import { useProfile } from "@/hooks/use-profile"; import { useSpeechToText } from "@/hooks/use-speech-to-text"; +import { usePendingOperationExecution } from "@/hooks/use-pending-operation-execution"; import { useChatStore } from "@/stores/chat-store"; interface RNFileUpload { @@ -54,18 +51,6 @@ declare global { } } -export type PendingExecutionResult = - | { ok: true; response: AgentExecuteOperationResponse } - | { ok: false; error: string }; - -export type PreparedStepUpExecution = - | { - ok: true; - challenge: AgentStepUpChallenge; - confirmationToken: string; - } - | { ok: false; error: string }; - interface AttemptedSend { content: string; image: ImagePicker.ImagePickerAsset | null; @@ -291,7 +276,7 @@ export function useChatComposer({ isOnline, offlineTitle }: UseChatComposerOptio const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"] as ImagePicker.MediaType[], allowsMultipleSelection: false, - quality: 1, + quality: 0.7, }); if (result.canceled) return; @@ -622,137 +607,16 @@ export function useChatComposer({ isOnline, offlineTitle }: UseChatComposerOptio const canRetryLastSend = lastFailedSend !== null && !isTyping; - const confirmAndExecutePendingOperation = useCallback( - async (pendingOperationId: string): Promise => { - try { - const confirmation = await apiClient( - API.ai.pendingOperationConfirm(pendingOperationId), - { - method: "POST", - }, - ); - - const execution = await apiClient( - API.ai.pendingOperationExecute(pendingOperationId), - { - method: "POST", - body: JSON.stringify({ - confirmationToken: confirmation.confirmationToken, - }), - }, - ); - - await appendExecutionMessage(execution); - return { ok: true, response: execution }; - } catch (error: unknown) { - return { ok: false, error: getErrorMessage(error, t("chat.sendError")) }; - } - }, - [appendExecutionMessage, t], - ); - - const preparePendingOperationStepUp = useCallback( - async (pendingOperationId: string): Promise => { - try { - const confirmation = await apiClient( - API.ai.pendingOperationConfirm(pendingOperationId), - { - method: "POST", - }, - ); - - const challenge = await apiClient( - API.ai.pendingOperationStepUp(pendingOperationId), - { - method: "POST", - body: JSON.stringify({ language: i18n.language }), - }, - ); - - return { - ok: true, - challenge, - confirmationToken: confirmation.confirmationToken, - }; - } catch (error: unknown) { - return { ok: false, error: getErrorMessage(error, t("chat.sendError")) }; - } - }, - [i18n.language, t], - ); - - const verifyAndExecutePendingOperationStepUp = useCallback( - async ( - pendingOperationId: string, - challengeId: string, - code: string, - confirmationToken: string, - ): Promise => { - try { - await apiClient<{ id: string } | null>( - API.ai.pendingOperationVerifyStepUp(pendingOperationId), - { - method: "POST", - body: JSON.stringify({ challengeId, code }), - }, - ); - - const execution = await apiClient( - API.ai.pendingOperationExecute(pendingOperationId), - { - method: "POST", - body: JSON.stringify({ confirmationToken }), - }, - ); - - await appendExecutionMessage(execution); - return { ok: true, response: execution }; - } catch (error: unknown) { - return { ok: false, error: getErrorMessage(error, t("chat.sendError")) }; - } - }, - [appendExecutionMessage, t], - ); + const { + confirmAndExecutePendingOperation, + prepareStepUpForBubble, + verifyStepUpForBubble, + } = usePendingOperationExecution({ appendExecutionMessage }); const handleBreakdownConfirmed = useCallback(() => { queryClient.invalidateQueries({ queryKey: habitKeys.lists() }); }, [queryClient]); - const prepareStepUpForBubble = useCallback( - async (pendingOperationId: string) => { - const result = await preparePendingOperationStepUp(pendingOperationId); - if (!result.ok) { - return { ok: false as const, error: result.error }; - } - return { - ok: true as const, - challengeId: result.challenge.challengeId, - confirmationToken: result.confirmationToken, - }; - }, - [preparePendingOperationStepUp], - ); - - const verifyStepUpForBubble = useCallback( - async ( - pendingOperationId: string, - challengeId: string, - code: string, - confirmationToken: string, - ) => { - const result = await verifyAndExecutePendingOperationStepUp( - pendingOperationId, - challengeId, - code, - confirmationToken, - ); - return result.ok - ? { ok: true as const, response: result.response } - : { ok: false as const, error: result.error }; - }, - [verifyAndExecutePendingOperationStepUp], - ); - return { flatListRef, messages, diff --git a/apps/mobile/hooks/use-dismiss-guard.ts b/apps/mobile/hooks/use-dismiss-guard.ts index f312e408b7..628bfe76ab 100644 --- a/apps/mobile/hooks/use-dismiss-guard.ts +++ b/apps/mobile/hooks/use-dismiss-guard.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState } from 'react' +import { resolveDismissGuardAction } from '@orbit/shared/hooks' interface UseDismissGuardOptions { isDirty: boolean @@ -9,22 +10,25 @@ export function useDismissGuard({ isDirty, onDismiss }: Readonly { - if (isDirty) { - setShowDiscardDialog(true) - return + const decision = resolveDismissGuardAction('request', isDirty) + setShowDiscardDialog(decision.showDiscardDialog) + if (decision.shouldDismiss) { + onDismiss() } - - onDismiss() }, [isDirty, onDismiss]) const confirmDismiss = useCallback(() => { - setShowDiscardDialog(false) - onDismiss() - }, [onDismiss]) + const decision = resolveDismissGuardAction('confirm', isDirty) + setShowDiscardDialog(decision.showDiscardDialog) + if (decision.shouldDismiss) { + onDismiss() + } + }, [isDirty, onDismiss]) const cancelDismiss = useCallback(() => { - setShowDiscardDialog(false) - }, []) + const decision = resolveDismissGuardAction('cancel', isDirty) + setShowDiscardDialog(decision.showDiscardDialog) + }, [isDirty]) return useMemo( () => ({ diff --git a/apps/mobile/hooks/use-habits.ts b/apps/mobile/hooks/use-habits.ts index da6832e4b3..bf2546ef97 100644 --- a/apps/mobile/hooks/use-habits.ts +++ b/apps/mobile/hooks/use-habits.ts @@ -344,7 +344,8 @@ export function useCreateHabit() { useUIStore.getState().setLastCreatedHabitId(result.id) }, - onSettled: (data, error) => finalizeHabitMutation(queryClient, data, error), + onSettled: (data, error) => + finalizeHabitMutation(queryClient, data, error, { includeCount: true }), }) } @@ -428,7 +429,10 @@ export function useDeleteHabit() { }, onSettled: (data, error) => - finalizeHabitMutation(queryClient, data, error, { includeGoals: true }), + finalizeHabitMutation(queryClient, data, error, { + includeGoals: true, + includeCount: true, + }), }) } @@ -766,7 +770,8 @@ export function useBulkCreateHabits() { adjustHabitCount(queryClient, -context.createdCount) }, - onSettled: (data, error) => finalizeHabitMutation(queryClient, data, error), + onSettled: (data, error) => + finalizeHabitMutation(queryClient, data, error, { includeCount: true }), }) } @@ -815,7 +820,10 @@ export function useBulkDeleteHabits() { }, onSettled: (data, error) => - finalizeHabitMutation(queryClient, data, error, { includeGoals: true }), + finalizeHabitMutation(queryClient, data, error, { + includeGoals: true, + includeCount: true, + }), }) } diff --git a/apps/mobile/hooks/use-horizontal-swipe.ts b/apps/mobile/hooks/use-horizontal-swipe.ts index 97bf0d6375..4b1a8fc51e 100644 --- a/apps/mobile/hooks/use-horizontal-swipe.ts +++ b/apps/mobile/hooks/use-horizontal-swipe.ts @@ -1,5 +1,7 @@ import { useMemo } from 'react' -import { Dimensions, PanResponder } from 'react-native' +import { Dimensions } from 'react-native' +import { Gesture } from 'react-native-gesture-handler' +import { runOnJS } from 'react-native-reanimated' interface UseHorizontalSwipeOptions { onSwipeLeft: () => void @@ -9,6 +11,11 @@ interface UseHorizontalSwipeOptions { minVelocity?: number } +/** + * Horizontal swipe gesture for day/month paging. Returns an RNGH `Gesture.Pan` + * to feed a `GestureDetector`; the recognition runs on the UI thread so list + * scrolling stays smooth. Thresholds match the prior PanResponder exactly. + */ export function useHorizontalSwipe({ onSwipeLeft, onSwipeRight, @@ -16,36 +23,34 @@ export function useHorizontalSwipe({ edgeExclusion = 24, minVelocity = 0.2, }: Readonly) { + const screenWidth = Dimensions.get('window').width + return useMemo( () => - PanResponder.create({ - onMoveShouldSetPanResponder: (event, gestureState) => { - const touch = event.nativeEvent - const startX = touch.pageX - gestureState.dx - const screenWidth = Dimensions.get('window').width + Gesture.Pan() + .activeOffsetX([-minDistance, minDistance]) + .failOffsetY([-minDistance, minDistance]) + .onEnd((event) => { + 'worklet' + const startX = event.absoluteX - event.translationX - if (startX <= edgeExclusion) { - return false - } + if (startX <= edgeExclusion) return + if (screenWidth > 0 && startX >= screenWidth - edgeExclusion) return - if (screenWidth > 0 && startX >= screenWidth - edgeExclusion) { - return false - } + const dx = event.translationX + const dy = event.translationY + const vx = event.velocityX / 1000 + + if (Math.abs(dx) <= Math.abs(dy) * 1.2) return + if (Math.abs(dx) <= minDistance) return + if (Math.abs(vx) < minVelocity) return - return ( - Math.abs(gestureState.dx) > Math.abs(gestureState.dy) * 1.2 && - Math.abs(gestureState.dx) > minDistance && - Math.abs(gestureState.vx) >= minVelocity - ) - }, - onPanResponderRelease: (_event, gestureState) => { - if (gestureState.dx <= -minDistance) { - onSwipeLeft() - } else if (gestureState.dx >= minDistance) { - onSwipeRight() + if (dx <= -minDistance) { + runOnJS(onSwipeLeft)() + } else if (dx >= minDistance) { + runOnJS(onSwipeRight)() } - }, - }), - [edgeExclusion, minDistance, minVelocity, onSwipeLeft, onSwipeRight], + }), + [edgeExclusion, minDistance, minVelocity, onSwipeLeft, onSwipeRight, screenWidth], ) } diff --git a/apps/mobile/hooks/use-login-code-entry.ts b/apps/mobile/hooks/use-login-code-entry.ts index f6bc831798..fe77ca763f 100644 --- a/apps/mobile/hooks/use-login-code-entry.ts +++ b/apps/mobile/hooks/use-login-code-entry.ts @@ -3,6 +3,7 @@ import type { NativeSyntheticEvent, TextInputKeyPressEventData, TextInput } from import { createVerificationCodeDigits, fillVerificationCodeDigits, + isVerificationCodeComplete, normalizeVerificationCodeInput, VERIFICATION_CODE_LENGTH, } from '@orbit/shared/utils' @@ -19,12 +20,13 @@ interface UseLoginCodeEntryResult { onCodeKeyPress: (index: number, event: NativeSyntheticEvent) => void } -export function useLoginCodeEntry(): UseLoginCodeEntryResult { +export function useLoginCodeEntry(onCompleteCode?: (code: string) => void): UseLoginCodeEntryResult { const [codeDigits, setCodeDigits] = useState(() => createVerificationCodeDigits()) const [canResend, setCanResend] = useState(true) const [resendCountdown, setResendCountdown] = useState(0) const codeInputRefs = useRef<(TextInput | null)[]>([]) const resendTimerRef = useRef | null>(null) + const submittedCodeRef = useRef(null) const clearResendTimer = useCallback(() => { if (resendTimerRef.current === null) return @@ -34,6 +36,17 @@ export function useLoginCodeEntry(): UseLoginCodeEntryResult { useEffect(() => () => clearResendTimer(), [clearResendTimer]) + const joinedCode = codeDigits.join('') + useEffect(() => { + if (!isVerificationCodeComplete(codeDigits, VERIFICATION_CODE_LENGTH)) { + submittedCodeRef.current = null + return + } + if (submittedCodeRef.current === joinedCode) return + submittedCodeRef.current = joinedCode + onCompleteCode?.(joinedCode) + }, [codeDigits, joinedCode, onCompleteCode]) + const startResendCountdown = useCallback(() => { clearResendTimer() setCanResend(false) diff --git a/apps/mobile/hooks/use-overlay-back.ts b/apps/mobile/hooks/use-overlay-back.ts new file mode 100644 index 0000000000..e05653eddd --- /dev/null +++ b/apps/mobile/hooks/use-overlay-back.ts @@ -0,0 +1,31 @@ +import { useEffect, useId, useRef } from 'react' +import { registerOverlay, unregisterOverlay } from '@/lib/overlay-stack' + +/** + * Registers a non-Modal blocking layer (tour, level-up) in the shared overlay + * stack while it is active, so the root hardware-back handler dismisses the + * top-most layer LIFO and returns true instead of falling through to + * navigation. Surfaces backed by a RN Modal use onRequestClose instead. + */ +export function useOverlayBack(active: boolean, onDismiss: () => void): void { + const overlayId = useId() + const onDismissRef = useRef(onDismiss) + + useEffect(() => { + onDismissRef.current = onDismiss + }, [onDismiss]) + + useEffect(() => { + if (!active) return + + registerOverlay({ + id: overlayId, + dismiss: () => { + onDismissRef.current() + return true + }, + }) + + return () => unregisterOverlay(overlayId) + }, [active, overlayId]) +} diff --git a/apps/mobile/hooks/use-pending-operation-execution.ts b/apps/mobile/hooks/use-pending-operation-execution.ts new file mode 100644 index 0000000000..8b8b123134 --- /dev/null +++ b/apps/mobile/hooks/use-pending-operation-execution.ts @@ -0,0 +1,172 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { API } from "@orbit/shared/api"; +import type { + AgentExecuteOperationResponse, + AgentStepUpChallenge, + PendingAgentOperationConfirmation, +} from "@orbit/shared/types"; +import { getErrorMessage } from "@orbit/shared/utils"; +import { apiClient } from "@/lib/api-client"; + +export type PendingExecutionResult = + | { ok: true; response: AgentExecuteOperationResponse } + | { ok: false; error: string }; + +export type PreparedStepUpExecution = + | { + ok: true; + challenge: AgentStepUpChallenge; + confirmationToken: string; + } + | { ok: false; error: string }; + +interface UsePendingOperationExecutionOptions { + appendExecutionMessage: ( + response: AgentExecuteOperationResponse, + ) => Promise; +} + +/** + * Wraps the agent pending-operation confirm/step-up/execute API calls used by + * the chat composer. Each call returns a discriminated `ok` result and appends + * the resulting agent message via the injected `appendExecutionMessage`. + */ +export function usePendingOperationExecution({ + appendExecutionMessage, +}: UsePendingOperationExecutionOptions) { + const { t, i18n } = useTranslation(); + + const confirmAndExecutePendingOperation = useCallback( + async (pendingOperationId: string): Promise => { + try { + const confirmation = await apiClient( + API.ai.pendingOperationConfirm(pendingOperationId), + { + method: "POST", + }, + ); + + const execution = await apiClient( + API.ai.pendingOperationExecute(pendingOperationId), + { + method: "POST", + body: JSON.stringify({ + confirmationToken: confirmation.confirmationToken, + }), + }, + ); + + await appendExecutionMessage(execution); + return { ok: true, response: execution }; + } catch (error: unknown) { + return { ok: false, error: getErrorMessage(error, t("chat.sendError")) }; + } + }, + [appendExecutionMessage, t], + ); + + const preparePendingOperationStepUp = useCallback( + async (pendingOperationId: string): Promise => { + try { + const confirmation = await apiClient( + API.ai.pendingOperationConfirm(pendingOperationId), + { + method: "POST", + }, + ); + + const challenge = await apiClient( + API.ai.pendingOperationStepUp(pendingOperationId), + { + method: "POST", + body: JSON.stringify({ language: i18n.language }), + }, + ); + + return { + ok: true, + challenge, + confirmationToken: confirmation.confirmationToken, + }; + } catch (error: unknown) { + return { ok: false, error: getErrorMessage(error, t("chat.sendError")) }; + } + }, + [i18n.language, t], + ); + + const verifyAndExecutePendingOperationStepUp = useCallback( + async ( + pendingOperationId: string, + challengeId: string, + code: string, + confirmationToken: string, + ): Promise => { + try { + await apiClient<{ id: string } | null>( + API.ai.pendingOperationVerifyStepUp(pendingOperationId), + { + method: "POST", + body: JSON.stringify({ challengeId, code }), + }, + ); + + const execution = await apiClient( + API.ai.pendingOperationExecute(pendingOperationId), + { + method: "POST", + body: JSON.stringify({ confirmationToken }), + }, + ); + + await appendExecutionMessage(execution); + return { ok: true, response: execution }; + } catch (error: unknown) { + return { ok: false, error: getErrorMessage(error, t("chat.sendError")) }; + } + }, + [appendExecutionMessage, t], + ); + + const prepareStepUpForBubble = useCallback( + async (pendingOperationId: string) => { + const result = await preparePendingOperationStepUp(pendingOperationId); + if (!result.ok) { + return { ok: false as const, error: result.error }; + } + return { + ok: true as const, + challengeId: result.challenge.challengeId, + confirmationToken: result.confirmationToken, + }; + }, + [preparePendingOperationStepUp], + ); + + const verifyStepUpForBubble = useCallback( + async ( + pendingOperationId: string, + challengeId: string, + code: string, + confirmationToken: string, + ) => { + const result = await verifyAndExecutePendingOperationStepUp( + pendingOperationId, + challengeId, + code, + confirmationToken, + ); + return result.ok + ? { ok: true as const, response: result.response } + : { ok: false as const, error: result.error }; + }, + [verifyAndExecutePendingOperationStepUp], + ); + + return { + confirmAndExecutePendingOperation, + prepareStepUpForBubble, + verifyStepUpForBubble, + }; +} diff --git a/apps/mobile/hooks/use-push-notifications.ts b/apps/mobile/hooks/use-push-notifications.ts index c83e2c8afd..a8bb41f86e 100644 --- a/apps/mobile/hooks/use-push-notifications.ts +++ b/apps/mobile/hooks/use-push-notifications.ts @@ -6,7 +6,9 @@ import * as Device from 'expo-device' import { useRouter } from 'expo-router' import type { Href } from 'expo-router' import { API } from '@orbit/shared/api' +import { schemes } from '@orbit/shared/theme' import type { NativePushRegistrationStatus } from '@orbit/shared/utils' +import { i18n } from '@/lib/i18n' import { apiClient } from '@/lib/api-client' import { normalizePermissionStatus, @@ -175,10 +177,10 @@ if (notificationsModule) { async function ensureAndroidChannel(): Promise { if (!notificationsModule || Platform.OS !== 'android') return await notificationsModule.setNotificationChannelAsync('default', { - name: 'Default', + name: i18n.t('notifications.channel.default'), importance: notificationsModule.AndroidImportance.MAX, vibrationPattern: [0, 250, 250, 250], - lightColor: '#7f46f7', + lightColor: schemes.purple.accent.dark.primary, }) } @@ -318,14 +320,14 @@ export function usePushNotifications(): UsePushNotificationsReturn { } catch (err: unknown) { setExpoPushToken(null) setRegistrationStatus('token-missing') - setError(err instanceof Error ? err.message : 'Push token is unavailable on this device.') + setError(err instanceof Error ? err.message : i18n.t('settings.notifications.tokenMissing')) return false } setExpoPushToken(token) if (!token) { setRegistrationStatus('token-missing') - setError('Push token is unavailable on this device.') + setError(i18n.t('settings.notifications.tokenMissing')) return false } @@ -343,7 +345,7 @@ export function usePushNotifications(): UsePushNotificationsReturn { return true } catch (err: unknown) { setRegistrationStatus('sync-failed') - setError(err instanceof Error ? err.message : 'Failed to sync push subscription.') + setError(err instanceof Error ? err.message : i18n.t('settings.notifications.syncFailed')) return false } })() @@ -411,7 +413,7 @@ export function usePushNotifications(): UsePushNotificationsReturn { setExpoPushToken(null) setRegistrationStatus('sync-failed') setIsRegistered(false) - setError(err instanceof Error ? err.message : 'Failed to refresh push notification state.') + setError(err instanceof Error ? err.message : i18n.t('settings.notifications.syncFailed')) } }, [isSupported, readDisabledPreference, registerAndSync]) @@ -450,7 +452,7 @@ export function usePushNotifications(): UsePushNotificationsReturn { } catch (err: unknown) { setRegistrationStatus('sync-failed') setIsRegistered(false) - setError(err instanceof Error ? err.message : 'Failed to register for push notifications.') + setError(err instanceof Error ? err.message : i18n.t('settings.notifications.syncFailed')) return false } finally { setIsLoading(false) @@ -477,7 +479,7 @@ export function usePushNotifications(): UsePushNotificationsReturn { token = await getCurrentPushToken() } catch (err: unknown) { setRegistrationStatus('token-missing') - setError(err instanceof Error ? err.message : 'Push token is unavailable on this device.') + setError(err instanceof Error ? err.message : i18n.t('settings.notifications.tokenMissing')) return false } @@ -486,13 +488,13 @@ export function usePushNotifications(): UsePushNotificationsReturn { if (!token) { setRegistrationStatus('token-missing') - setError('Push token is unavailable on this device.') + setError(i18n.t('settings.notifications.tokenMissing')) return false } if (!isAuthenticated) { setRegistrationStatus('sync-failed') - setError('Failed to sync push unsubscription.') + setError(i18n.t('settings.notifications.syncFailed')) return false } @@ -504,7 +506,7 @@ export function usePushNotifications(): UsePushNotificationsReturn { return true } catch (err: unknown) { setRegistrationStatus('sync-failed') - setError(err instanceof Error ? err.message : 'Failed to sync push unsubscription.') + setError(err instanceof Error ? err.message : i18n.t('settings.notifications.syncFailed')) return false } finally { setIsLoading(false) diff --git a/apps/mobile/hooks/use-retrospective.ts b/apps/mobile/hooks/use-retrospective.ts index 80dbf84608..fcc6816454 100644 --- a/apps/mobile/hooks/use-retrospective.ts +++ b/apps/mobile/hooks/use-retrospective.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import i18n from '@/lib/i18n' +import { i18n } from '@/lib/i18n' import { getErrorMessage } from '@orbit/shared/utils' import { buildRetrospectiveRequestUrl, diff --git a/apps/mobile/hooks/use-subscription-plans.ts b/apps/mobile/hooks/use-subscription-plans.ts index ff2e6fd69b..a02299dd05 100644 --- a/apps/mobile/hooks/use-subscription-plans.ts +++ b/apps/mobile/hooks/use-subscription-plans.ts @@ -1,24 +1,45 @@ import { useQuery } from '@tanstack/react-query' +import { useCallback } from 'react' import { subscriptionKeys, QUERY_STALE_TIMES } from '@orbit/shared/query' import { API } from '@orbit/shared/api' import type { SubscriptionPlans } from '@orbit/shared/types/subscription' -import { formatPrice, monthlyEquivalent } from '@orbit/shared/utils' +import { + applySubscriptionDiscount, + formatPrice, + getClientTimeZone, + monthlyEquivalent, +} from '@orbit/shared/utils' import { apiClient } from '@/lib/api-client' export function useSubscriptionPlans() { + const plansUrl = (() => { + const timeZone = getClientTimeZone() + return timeZone + ? `${API.subscription.plans}?timeZone=${encodeURIComponent(timeZone)}` + : API.subscription.plans + })() + const query = useQuery({ queryKey: subscriptionKeys.plans(), - queryFn: () => apiClient(API.subscription.plans), + queryFn: () => apiClient(plansUrl), staleTime: QUERY_STALE_TIMES.subscriptionPlans, refetchOnMount: 'always', }) const plans = query.data ?? null + const discountedAmount = useCallback( + (unitAmount: number): number => { + return applySubscriptionDiscount(unitAmount, plans?.couponPercentOff) + }, + [plans?.couponPercentOff], + ) + return { ...query, plans, formatPrice, + discountedAmount, monthlyEquivalent, } } diff --git a/apps/mobile/hooks/use-tag-selection.ts b/apps/mobile/hooks/use-tag-selection.ts index cfa3982dd9..abd4ea4ed2 100644 --- a/apps/mobile/hooks/use-tag-selection.ts +++ b/apps/mobile/hooks/use-tag-selection.ts @@ -1,25 +1,22 @@ import { useState, useMemo, useCallback } from 'react' -import { toggleSelectedId } from '@orbit/shared/utils' -import { MAX_TAGS_PER_HABIT, validateTagForm } from '@orbit/shared/validation' - -const TAG_COLORS = [ - '#7f46f7', - '#dc2626', - '#b45309', - '#047857', - '#2563eb', - '#be185d', - '#4f46e5', - '#0f766e', - '#c2410c', - '#4d7c0f', -] as const - -interface EditableTag { - id: string - name: string - color: string -} +import { MAX_TAGS_PER_HABIT } from '@orbit/shared/validation' +import { + TAG_COLORS, + type EditableTag, + type TagSelectionCoreState, + cancelTagEdit, + createAndSelectTagFlow, + createInitialTagSelectionState, + deleteTagFlow, + isTagSelectionAtLimit, + saveEditTagFlow, + setEditTagColorDraft, + setEditTagNameDraft, + setNewTagColorDraft, + setNewTagNameDraft, + startTagEdit, + toggleTagSelection, +} from '@orbit/shared/hooks' export interface TagSelectionState { selectedTagIds: string[] @@ -57,164 +54,85 @@ export function useTagSelection( initialTagIds: string[] = [], maxTags = MAX_TAGS_PER_HABIT, ): TagSelectionState { - const [selectedTagIds, setSelectedTagIds] = useState([...initialTagIds]) - const [showNewTag, setShowNewTag] = useState(false) - const [newTagName, setNewTagName] = useState('') - const [newTagColor, setNewTagColor] = useState(TAG_COLORS[0]) - const [editingTagId, setEditingTagId] = useState(null) - const [editTagName, setEditTagName] = useState('') - const [editTagColor, setEditTagColor] = useState('') - const [tagValidationErrorKey, setTagValidationErrorKey] = useState(null) - - const atTagLimit = useMemo( - () => selectedTagIds.length >= maxTags, - [selectedTagIds.length, maxTags], + const [state, setState] = useState(() => + createInitialTagSelectionState(initialTagIds), ) + const atTagLimit = useMemo(() => isTagSelectionAtLimit(state, maxTags), [state, maxTags]) + const toggleTag = useCallback((tagId: string) => { - setSelectedTagIds((prev) => toggleSelectedId(prev, tagId)) + setState((previous) => toggleTagSelection(previous, tagId)) }, []) const resetTags = useCallback((tagIds: string[] = []) => { - setSelectedTagIds([...tagIds]) - setEditingTagId(null) - setEditTagName('') - setEditTagColor('') - setShowNewTag(false) - setNewTagName('') - setNewTagColor(TAG_COLORS[0]) - setTagValidationErrorKey(null) + setState(createInitialTagSelectionState(tagIds)) }, []) - const startEditTag = useCallback((tag: EditableTag) => { - setEditingTagId(tag.id) - setEditTagName(tag.name) - setEditTagColor(tag.color) - setTagValidationErrorKey(null) + const setShowNewTag = useCallback((show: boolean) => { + setState((previous) => ({ ...previous, showNewTag: show })) }, []) - const handleSetNewTagName = useCallback((name: string) => { - setNewTagName(name) - setTagValidationErrorKey(null) + const setNewTagName = useCallback((name: string) => { + setState((previous) => setNewTagNameDraft(previous, name)) }, []) - const handleSetNewTagColor = useCallback((color: string) => { - setNewTagColor(color) - setTagValidationErrorKey(null) + const setNewTagColor = useCallback((color: string) => { + setState((previous) => setNewTagColorDraft(previous, color)) }, []) - const handleSetEditTagName = useCallback((name: string) => { - setEditTagName(name) - setTagValidationErrorKey(null) + const setEditTagName = useCallback((name: string) => { + setState((previous) => setEditTagNameDraft(previous, name)) }, []) - const handleSetEditTagColor = useCallback((color: string) => { - setEditTagColor(color) - setTagValidationErrorKey(null) + const setEditTagColor = useCallback((color: string) => { + setState((previous) => setEditTagColorDraft(previous, color)) }, []) - const saveEditTag = useCallback( - async (updateTag: (id: string, name: string, color: string) => Promise) => { - if (!editingTagId) { - return - } - - const validationErrorKey = validateTagForm(editTagName, editTagColor) - if (validationErrorKey) { - setTagValidationErrorKey(validationErrorKey) - return - } - - await updateTag(editingTagId, editTagName.trim(), editTagColor) - setEditingTagId(null) - setTagValidationErrorKey(null) - }, - [editingTagId, editTagName, editTagColor], - ) + const startEditTag = useCallback((tag: EditableTag) => { + setState((previous) => startTagEdit(previous, tag)) + }, []) const cancelEditTag = useCallback(() => { - setEditingTagId(null) - setEditTagName('') - setEditTagColor('') - setTagValidationErrorKey(null) + setState((previous) => cancelTagEdit(previous)) }, []) - const deleteTag = useCallback( - async (tagId: string, deleteTagFn: (id: string) => Promise) => { - const previousSelectedTagIds = selectedTagIds - const previousEditingState = - editingTagId === tagId - ? { - id: editingTagId, - name: editTagName, - color: editTagColor, - } - : null - - setSelectedTagIds((prev) => prev.filter((selectedId) => selectedId !== tagId)) - if (editingTagId === tagId) { - setEditingTagId(null) - } - - try { - await deleteTagFn(tagId) - setTagValidationErrorKey(null) - } catch (error: unknown) { - setSelectedTagIds(previousSelectedTagIds) - if (previousEditingState) { - setEditingTagId(previousEditingState.id) - setEditTagName(previousEditingState.name) - setEditTagColor(previousEditingState.color) - } - throw error - } - }, - [selectedTagIds, editingTagId, editTagName, editTagColor], + const createAndSelectTag = useCallback( + (createTag: (name: string, color: string) => Promise) => + createAndSelectTagFlow(state, maxTags, createTag, setState), + [maxTags, state], ) - const createAndSelectTag = useCallback( - async (createTag: (name: string, color: string) => Promise) => { - if (selectedTagIds.length >= maxTags) { - return - } - - const validationErrorKey = validateTagForm(newTagName, newTagColor) - if (validationErrorKey) { - setTagValidationErrorKey(validationErrorKey) - return - } - - const tagId = await createTag(newTagName.trim(), newTagColor) - if (tagId) { - setSelectedTagIds((prev) => (prev.length >= maxTags ? prev : [...prev, tagId])) - } - setNewTagName('') - setShowNewTag(false) - setNewTagColor(TAG_COLORS[0]) - setTagValidationErrorKey(null) - }, - [maxTags, newTagColor, newTagName, selectedTagIds.length], + const saveEditTag = useCallback( + (updateTag: (id: string, name: string, color: string) => Promise) => + saveEditTagFlow(state, updateTag, setState), + [state], + ) + + const deleteTag = useCallback( + (tagId: string, deleteTagFn: (id: string) => Promise) => + deleteTagFlow(state, tagId, deleteTagFn, setState), + [state], ) return { - selectedTagIds, + selectedTagIds: state.selectedTagIds, atTagLimit, - tagValidationErrorKey, + tagValidationErrorKey: state.tagValidationErrorKey, toggleTag, resetTags, - showNewTag, + showNewTag: state.showNewTag, setShowNewTag, - newTagName, - setNewTagName: handleSetNewTagName, - newTagColor, - setNewTagColor: handleSetNewTagColor, + newTagName: state.newTagName, + setNewTagName, + newTagColor: state.newTagColor, + setNewTagColor, tagColors: TAG_COLORS, createAndSelectTag, - editingTagId, - editTagName, - setEditTagName: handleSetEditTagName, - editTagColor, - setEditTagColor: handleSetEditTagColor, + editingTagId: state.editingTagId, + editTagName: state.editTagName, + setEditTagName, + editTagColor: state.editTagColor, + setEditTagColor, startEditTag, saveEditTag, cancelEditTag, diff --git a/apps/mobile/hooks/use-tour-scroll-container.ts b/apps/mobile/hooks/use-tour-scroll-container.ts index 0d3643b7c8..9566822841 100644 --- a/apps/mobile/hooks/use-tour-scroll-container.ts +++ b/apps/mobile/hooks/use-tour-scroll-container.ts @@ -1,13 +1,17 @@ import { useEffect, useCallback } from 'react' import type { NativeSyntheticEvent, NativeScrollEvent } from 'react-native' import { tourScrollRegistry } from '@/components/tour/tour-target-context' +import { useTourStore } from '@/stores/tour-store' /** * Registers a scroll function for a given route. * Pass a scrollTo callback that actually scrolls your ScrollView/FlatList. - * Returns an onScroll handler to track scroll position. + * Returns an onScroll handler that tracks scroll position only while a tour is + * active, since the offset is read solely by the tour's scroll-into-view. */ export function useTourScrollContainer(route: string, scrollTo: (y: number) => void) { + const isTourActive = useTourStore((state) => state.isActive) + useEffect(() => { tourScrollRegistry.register(route, scrollTo) return () => tourScrollRegistry.unregister(route) @@ -15,9 +19,10 @@ export function useTourScrollContainer(route: string, scrollTo: (y: number) => v const onTourScroll = useCallback( (e: NativeSyntheticEvent) => { + if (!isTourActive) return tourScrollRegistry.updateScrollY(route, e.nativeEvent.contentOffset.y) }, - [route], + [isTourActive, route], ) return { onTourScroll } diff --git a/apps/mobile/lib/google-auth.ts b/apps/mobile/lib/google-auth.ts index 50034ae812..0c7508b7e0 100644 --- a/apps/mobile/lib/google-auth.ts +++ b/apps/mobile/lib/google-auth.ts @@ -2,7 +2,8 @@ import * as WebBrowser from 'expo-web-browser' import type { Session } from '@supabase/supabase-js' import { API } from '@orbit/shared/api' import type { BackendLoginResponse } from '@orbit/shared/types/auth' -import { buildGoogleCalendarOAuthOptions, createApiClientError } from '@orbit/shared/utils' +import { buildGoogleCalendarOAuthOptions } from '@orbit/shared/utils' +import { apiClient } from './api-client' import { isSafeReturnUrl, storeAuthReturnUrl } from './auth-flow' import { AUTH_CALLBACK_URL, @@ -13,37 +14,10 @@ import { } from './google-auth-callback' import { supabase } from './supabase' -const API_BASE = process.env.EXPO_PUBLIC_API_BASE ?? 'https://api.useorbit.org' - export type MobileGoogleAuthResult = | { type: 'success'; url: string } | { type: WebBrowser.WebBrowserResultType } -function mergeRequestIdIntoPayload(payload: unknown, requestId: string | null): unknown { - const trimmedRequestId = requestId?.trim() - if (!trimmedRequestId) { - return payload - } - - if (payload === null) { - return { requestId: trimmedRequestId } - } - - if (typeof payload !== 'object') { - return payload - } - - const payloadRecord = payload as Record - if (typeof payloadRecord.requestId === 'string') { - return payload - } - - return { - ...payloadRecord, - requestId: trimmedRequestId, - } -} - async function exchangeGoogleSession( session: Session, language: string, @@ -51,9 +25,8 @@ async function exchangeGoogleSession( providerToken?: string, providerRefreshToken?: string, ): Promise { - const response = await fetch(`${API_BASE}${API.auth.google}`, { + return apiClient(API.auth.google, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accessToken: session.access_token, language, @@ -62,21 +35,6 @@ async function exchangeGoogleSession( ...(referralCode ? { referralCode } : {}), }), }) - - const data = mergeRequestIdIntoPayload( - await response.json().catch(() => null) as unknown, - response.headers.get('x-orbit-request-id'), - ) - - if (!response.ok) { - throw createApiClientError( - response.status, - data, - `Request failed: ${response.status}`, - ) - } - - return data as BackendLoginResponse } export function getGoogleAuthRedirectUrl(): string { diff --git a/apps/mobile/lib/habit-mutation-helpers.ts b/apps/mobile/lib/habit-mutation-helpers.ts index 74298c67f4..69c4680d62 100644 --- a/apps/mobile/lib/habit-mutation-helpers.ts +++ b/apps/mobile/lib/habit-mutation-helpers.ts @@ -123,8 +123,8 @@ function cloneTopLevelHabit(node: HabitTreeNode): HabitScheduleItem { tags: node.tags, children, hasSubHabits: children.length > 0 || node.hasSubHabits, - flexibleTarget: 'flexibleTarget' in node ? node.flexibleTarget : null, - flexibleCompleted: 'flexibleCompleted' in node ? node.flexibleCompleted : null, + flexibleTarget: ('flexibleTarget' in node ? node.flexibleTarget : null) ?? null, + flexibleCompleted: ('flexibleCompleted' in node ? node.flexibleCompleted : null) ?? null, isLoggedInRange: ('isLoggedInRange' in node ? node.isLoggedInRange : false) ?? false, linkedGoals: 'linkedGoals' in node ? node.linkedGoals : undefined, instances: 'instances' in node ? node.instances : [], @@ -636,20 +636,27 @@ function runBackgroundInvalidations(tasks: Promise[]) { void Promise.allSettled(tasks) } +interface HabitInvalidationOptions { + habitId?: string + includeGoals?: boolean + includeGamification?: boolean + includeProfile?: boolean + includeCount?: boolean +} + export function invalidateHabitMutationQueries( queryClient: QueryClient, - options?: { - habitId?: string - includeGoals?: boolean - includeGamification?: boolean - includeProfile?: boolean - }, + options?: HabitInvalidationOptions, ): void { const invalidations: Promise[] = [ queryClient.invalidateQueries({ queryKey: habitKeys.lists() }), queryClient.invalidateQueries({ queryKey: habitKeys.summaryPrefix() }), ] + if (options?.includeCount) { + invalidations.push(queryClient.invalidateQueries({ queryKey: habitKeys.count() })) + } + if (options?.habitId) { invalidations.push( queryClient.invalidateQueries({ queryKey: habitKeys.detail(options.habitId) }), @@ -676,14 +683,9 @@ export function finalizeHabitMutation( queryClient: QueryClient, data: unknown, error: Error | null, - options?: { - habitId?: string - includeGoals?: boolean - includeGamification?: boolean - includeProfile?: boolean - }, + options?: HabitInvalidationOptions, ): void { - if (error || isQueuedResult(data)) { + if (isQueuedResult(data)) { return } diff --git a/apps/mobile/lib/habit-optimistic-helpers.ts b/apps/mobile/lib/habit-optimistic-helpers.ts index bc9b927af2..7b365520d8 100644 --- a/apps/mobile/lib/habit-optimistic-helpers.ts +++ b/apps/mobile/lib/habit-optimistic-helpers.ts @@ -10,22 +10,9 @@ import type { HabitScheduleChild, HabitScheduleItem, } from '@orbit/shared/types/habit' +import { withChildren } from '@orbit/shared/utils' -type ChildContainer = { - children: HabitScheduleChild[] - hasSubHabits: boolean -} - -function withChildren( - node: T, - children: HabitScheduleChild[], -): T { - return { - ...node, - children, - hasSubHabits: children.length > 0 || (node.hasSubHabits && node.children.length === 0), - } -} +export { optimisticPatchHabit } from '@orbit/shared/utils' /** Toggle isCompleted on a single habit item, resetting checklist if needed */ function toggleHabitCompletion(item: HabitScheduleItem): HabitScheduleItem { @@ -108,38 +95,6 @@ export function optimisticUpdateChecklist( }) } -function patchChildHabit( - child: HabitScheduleChild, - habitId: string, - patch: Partial, -): HabitScheduleChild { - if (child.id === habitId) { - return { ...child, ...patch } - } - - return withChildren( - child, - child.children.map((nestedChild) => patchChildHabit(nestedChild, habitId, patch)), - ) -} - -/** Optimistically patch a parent or child habit in-place */ -export function optimisticPatchHabit( - items: HabitScheduleItem[], - habitId: string, - patch: Partial, -): HabitScheduleItem[] { - return items.map((item) => { - if (item.id === habitId) return { ...item, ...patch } - return withChildren( - item, - item.children.map((child) => - patchChildHabit(child, habitId, patch as Partial), - ), - ) - }) -} - function removeChildHabits( children: HabitScheduleChild[], habitIds: Set, diff --git a/apps/mobile/lib/i18n.ts b/apps/mobile/lib/i18n.ts index 71d1f126ca..a5c5e14cb8 100644 --- a/apps/mobile/lib/i18n.ts +++ b/apps/mobile/lib/i18n.ts @@ -4,7 +4,7 @@ import en from '@orbit/shared/i18n/en.json' import ptBR from '@orbit/shared/i18n/pt-BR.json' import { resolveSystemLocale } from '@orbit/shared/utils' -const i18n = i18next +export const i18n = i18next function detectInitialLocale() { try { @@ -28,5 +28,3 @@ i18n.use(initReactI18next).init({ suffix: '}', }, }) - -export default i18n diff --git a/apps/mobile/lib/motion.ts b/apps/mobile/lib/motion.ts index caffb797fc..43bce8bd13 100644 --- a/apps/mobile/lib/motion.ts +++ b/apps/mobile/lib/motion.ts @@ -6,7 +6,6 @@ import { motionLayerTiming, motionPresets, orbitalMotion, - motionSprings, resolveMotionPreset, type MotionScenario, } from '@orbit/shared/theme' @@ -42,18 +41,6 @@ export async function getPrefersReducedMotion(): Promise { return reducedMotionRequest } -export function setReducedMotionPreferenceForTests(value: boolean) { - cachedReducedMotionPreference = value - hasLoadedReducedMotionPreference = true - reducedMotionRequest = null -} - -export function resetReducedMotionPreferenceForTests() { - cachedReducedMotionPreference = false - hasLoadedReducedMotionPreference = false - reducedMotionRequest = null -} - export function usePrefersReducedMotion(): boolean { const [prefersReducedMotion, setPrefersReducedMotion] = useState( hasLoadedReducedMotionPreference ? cachedReducedMotionPreference : false, @@ -109,29 +96,6 @@ export function toAnimatedEasing( ) } -export function getReanimatedEasing( - easing: readonly [number, number, number, number], -): readonly [number, number, number, number] { - return easing -} - -export function getSpringConfig( - spring: keyof typeof motionSprings, - prefersReducedMotion: boolean, -) { - if (prefersReducedMotion) { - return { - stiffness: 420, - damping: 42, - mass: 1, - restSpeedThreshold: 2, - restDisplacementThreshold: 2, - } - } - - return motionSprings[spring] -} - export const mobileMotion = { durations: motionDurations, easings: motionEasings, diff --git a/apps/mobile/lib/offline-runtime.ts b/apps/mobile/lib/offline-runtime.ts index efde56e7b8..04709c4cc8 100644 --- a/apps/mobile/lib/offline-runtime.ts +++ b/apps/mobile/lib/offline-runtime.ts @@ -6,10 +6,6 @@ export function setCachedConnectivity(online: boolean): void { cachedOnline = online } -export function getCachedConnectivity(): boolean { - return cachedOnline -} - export async function getCurrentConnectivity(): Promise { try { const state = await NetInfo.fetch() diff --git a/apps/mobile/lib/supabase.ts b/apps/mobile/lib/supabase.ts index 10c0598f85..438ae2a553 100644 --- a/apps/mobile/lib/supabase.ts +++ b/apps/mobile/lib/supabase.ts @@ -9,13 +9,12 @@ interface SupabaseStorageAdapter { removeItem: (key: string) => void } -const SUPABASE_URL = - process.env.EXPO_PUBLIC_SUPABASE_URL - ?? 'https://wdscxamegetmhqldqsdg.supabase.co' +const SUPABASE_URL = process.env.EXPO_PUBLIC_SUPABASE_URL +const SUPABASE_PUBLISHABLE_KEY = process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY -const SUPABASE_PUBLISHABLE_KEY = - process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY - ?? 'sb_publishable_CGlL4PSxvp2Ia0SCHcathQ_iAQnmXis' +if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) { + throw new Error('Supabase config missing') +} const storage = (globalThis as typeof globalThis & { localStorage: SupabaseStorageAdapter }).localStorage diff --git a/apps/mobile/lib/theme-provider.tsx b/apps/mobile/lib/theme-provider.tsx index af6f7b6954..a5cff56c15 100644 --- a/apps/mobile/lib/theme-provider.tsx +++ b/apps/mobile/lib/theme-provider.tsx @@ -122,6 +122,15 @@ export function ThemeProvider({ children }: Readonly<{ children: ReactNode }>) { }) }, [transitionOpacity]) + const finishThemeTransition = useCallback(() => { + if (transitionFrameRef.current !== null) { + cancelAnimationFrame(transitionFrameRef.current) + transitionFrameRef.current = null + } + transitionOpacity.stopAnimation() + setTransitionSnapshot(null) + }, [transitionOpacity]) + useEffect(() => { setRuntimeTheme({ scheme: currentScheme, themeMode: currentTheme }) }, [currentScheme, currentTheme]) @@ -252,6 +261,7 @@ export function ThemeProvider({ children }: Readonly<{ children: ReactNode }>) { animationType="none" statusBarTranslucent visible + onRequestClose={finishThemeTransition} > > = { - sans: { - 400: 'Rubik_400Regular', - 500: 'Rubik_500Medium', - 600: 'Rubik_600SemiBold', - 700: 'Rubik_700Bold', - }, - display: { - 500: 'Inter_500Medium', - 600: 'Inter_600SemiBold', - 700: 'Inter_700Bold', - }, - mono: { - 400: 'Roboto_400Regular', - 500: 'Roboto_500Medium', - 700: 'Roboto_700Bold', - }, -} - -/** Resolves a loaded font name for the family + weight (nearest loaded weight). */ -export function resolveFontFamily( - family: TypeRoleFamily, - weight: 400 | 500 | 600 | 700, -): string { - const familyWeights = FONT_FAMILIES[family] - const exact = familyWeights[weight] - if (exact) return exact - const available = Object.keys(familyWeights) - .map(Number) - .sort((a, b) => Math.abs(a - weight) - Math.abs(b - weight)) - return familyWeights[available[0] ?? 400] ?? FONT_FAMILIES.sans[400]! -} - -/** - * RN TextStyle for a shared semantic type role. Pass tokens to resolve the - * role's color; omit for layout-only usage. - */ -export function typeRoleStyle( - role: TypeRoleName, - tokens?: AppTokensV2, -): TextStyle { - const spec = typeRoles[role] - const style: TextStyle = { - fontFamily: resolveFontFamily(spec.family, spec.weight), - } - - if ('size' in spec && spec.size !== undefined) { - style.fontSize = spec.size - if ('lineHeight' in spec && spec.lineHeight !== undefined) { - style.lineHeight = Math.round(spec.size * spec.lineHeight) - } - if ('letterSpacingEm' in spec && spec.letterSpacingEm !== undefined) { - style.letterSpacing = spec.size * spec.letterSpacingEm - } - } - if ('uppercase' in spec && spec.uppercase) { - style.textTransform = 'uppercase' - } - if ('tabularNums' in spec && spec.tabularNums) { - style.fontVariant = ['tabular-nums'] - } - if (tokens) { - style.color = tokens[spec.colorToken] - } - - return style -} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 407124c59b..db90afe9d6 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -54,7 +54,6 @@ "expo-store-review": "55.0.13", "expo-updates": "55.0.24", "expo-web-browser": "55.0.13", - "fs-extra": "^11.3.4", "hermes-compiler": "0.14.1", "html-parse-stringify": "^3.0.1", "i18next": "^26.0.3", @@ -79,6 +78,7 @@ "react-native-worklets": "0.7.2", "regenerator-runtime": "^0.13.2", "sp-react-native-in-app-updates": "^1.5.0", + "underscore": "^1.13.8", "zod": "^4.3.6", "zustand": "^5.0.12" }, @@ -107,6 +107,7 @@ "expo-image": "55.0.8", "expo-image-loader": "55.0.0", "expo-keep-awake": "55.0.6", - "expo-log-box": "55.0.10" + "expo-log-box": "55.0.10", + "underscore": "^1.13.8" } } diff --git a/apps/mobile/stores/auth-store.ts b/apps/mobile/stores/auth-store.ts index 37cae7b652..440d514997 100644 --- a/apps/mobile/stores/auth-store.ts +++ b/apps/mobile/stores/auth-store.ts @@ -16,7 +16,7 @@ import { import { clearWidgetToken, saveWidgetToken } from '@/lib/orbit-widget' import { apiClient } from '@/lib/api-client' import { clearPersistedQueryCache, queryClient } from '@/lib/query-client' -import i18n from '@/lib/i18n' +import { i18n } from '@/lib/i18n' import { setRuntimeTheme } from '@/lib/theme' import { useChatStore } from './chat-store' import { useReviewReminderStore } from './review-reminder-store' @@ -102,6 +102,13 @@ export async function clearSessionAndResetAuth(): Promise { useAuthStore.setState({ isAuthenticated: false, user: null, expiresAt: null }) } +/** + * Rotates the access token using the stored refresh token. Uses raw fetch, not + * apiClient: apiClient's own 401 handler calls this function, so routing it back + * through apiClient would invert the dependency and lose the clearOnFailure + * contract (apiClient throws + clears unconditionally; this returns null and + * honours clearOnFailure). Returns the new token, or null on failure. + */ export async function refreshSessionToken(options?: { clearOnFailure?: boolean }): Promise { @@ -224,15 +231,10 @@ export const useAuthStore = create((set, get) => ({ const refreshToken = await getRefreshToken() if (refreshToken) { - try { - await fetch(`${process.env.EXPO_PUBLIC_API_BASE ?? 'https://api.useorbit.org'}${API.auth.logout}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refreshToken }), - }) - } catch { - // Best-effort logout; always clear local session state. - } + await apiClient(API.auth.logout, { + method: 'POST', + body: JSON.stringify({ refreshToken }), + }).catch(() => {}) } await clearStoredAuthReturnUrl() diff --git a/apps/mobile/stores/chat-store.ts b/apps/mobile/stores/chat-store.ts index a3b85cff73..0957629da4 100644 --- a/apps/mobile/stores/chat-store.ts +++ b/apps/mobile/stores/chat-store.ts @@ -2,21 +2,15 @@ import { create } from 'zustand' import { createChatStoreState, type ChatStoreState } from '@orbit/shared/stores' interface MobileChatStoreState extends ChatStoreState { - isStreaming: boolean - setIsStreaming: (value: boolean) => void clearMessages: () => void } export const useChatStore = create((set) => ({ ...createChatStoreState(set as Parameters[0]), - isStreaming: false, - - setIsStreaming: (value) => set({ isStreaming: value }), clearMessages: () => set({ messages: [], isTyping: false, - isStreaming: false, }), })) diff --git a/apps/mobile/tailwind.config.js b/apps/mobile/tailwind.config.js index 12d59efea9..ab3c5a10fd 100644 --- a/apps/mobile/tailwind.config.js +++ b/apps/mobile/tailwind.config.js @@ -7,44 +7,4 @@ module.exports = { "./lib/**/*.{js,jsx,ts,tsx}", ], presets: [require("nativewind/preset")], - theme: { - extend: { - colors: { - // Static defaults matching the purple-dark scheme; runtime values come - // from `createTokensV2(scheme, mode)` and are applied via inline styles. - bg: "#020618", - "bg-elev": "rgba(248, 250, 252, 0.06)", - "bg-elev-2": "rgba(248, 250, 252, 0.10)", - "bg-sunk": "rgba(0, 0, 0, 0.28)", - hairline: "rgba(248, 250, 252, 0.10)", - "hairline-strong": "rgba(248, 250, 252, 0.18)", - "fg-1": "#f8fafc", - "fg-2": "#cad5e2", - "fg-3": "#90a1b9", - "fg-4": "#62748e", - "fg-on-primary": "#ffffff", - primary: "#7f46f7", - "primary-pressed": "#631df2", - "status-done": "#7f46f7", - "status-empty": "rgba(248, 250, 252, 0.22)", - "status-skip": "#90a1b9", - "status-overdue": "#fe9a00", - "status-bad": "#fb2c36", - "status-frozen": "#00d3f3", - }, - fontFamily: { - sans: ["Rubik", "System"], - display: ["Inter", "System"], - mono: ["Roboto", "System"], - }, - borderRadius: { - sm: "8px", - md: "12px", - lg: "16px", - xl: "20px", - sheet: "26px", - }, - }, - }, - plugins: [], }; diff --git a/apps/mobile/test-mocks/lucide-react-native.ts b/apps/mobile/test-mocks/lucide-react-native.ts index 91492ebb46..4cf7a6a4a6 100644 --- a/apps/mobile/test-mocks/lucide-react-native.ts +++ b/apps/mobile/test-mocks/lucide-react-native.ts @@ -8,6 +8,7 @@ function createIcon(name: string) { } } +export const AlertTriangle = createIcon('AlertTriangle') export const ArrowRight = createIcon('ArrowRight') export const Bell = createIcon('Bell') export const CalendarCheck = createIcon('CalendarCheck') diff --git a/apps/mobile/test-mocks/react-native-gesture-handler.ts b/apps/mobile/test-mocks/react-native-gesture-handler.ts index 0bdc6db430..0d8b42a465 100644 --- a/apps/mobile/test-mocks/react-native-gesture-handler.ts +++ b/apps/mobile/test-mocks/react-native-gesture-handler.ts @@ -15,9 +15,27 @@ function createHostComponent(name: string) { export const GestureHandlerRootView = createHostComponent('GestureHandlerRootView') export const PanGestureHandler = createHostComponent('PanGestureHandler') export const TapGestureHandler = createHostComponent('TapGestureHandler') +export const GestureDetector = createHostComponent('GestureDetector') + +function createPanGestureBuilder() { + const builder = { + activeOffsetX: () => builder, + failOffsetY: () => builder, + onEnd: () => builder, + onUpdate: () => builder, + onBegin: () => builder, + } + return builder +} + +export const Gesture = { + Pan: createPanGestureBuilder, +} export default { GestureHandlerRootView, PanGestureHandler, TapGestureHandler, + GestureDetector, + Gesture, } diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index 05cb9d4c6e..ff029b810e 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -14,7 +14,6 @@ export default defineConfig({ include: ['lib/**/*.{ts,tsx}', 'stores/**/*.ts'], exclude: [ '**/*.d.ts', - 'lib/google-auth.ts', 'lib/offline-queue.ts', 'lib/orbit-widget.ts', 'lib/providers.tsx', diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index 2ba9dc321e..80b2515256 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -1,4 +1,4 @@ -# apps/web — Next.js 15 + App Router +# apps/web — Next.js 16 + App Router Web client. Server Components by default. Auth via httpOnly cookies + BFF proxy. State via Zustand + TanStack Query. @@ -8,8 +8,9 @@ Web client. Server Components by default. Auth via httpOnly cookies + BFF proxy. - **Server Components by default.** `"use client"` only when you need hooks, events, browser APIs, or context. If you can render on the server, do. - **Zero `any`** — root `CLAUDE.md` Code Standards rule 3. - **No `console.log`** in production code — rule 4. +- **No narration comments** (lint-enforced `local/no-comments`) — rule 5. Only `/** */` JSDoc on exports, WHY notes linking an upstream URL, and tooling directives. - **All user-facing strings through i18n** (next-intl). Never hardcode display text. -- **All mutations through Server Actions** in `app/actions/*.ts`. Never call the API from a client component. +- **All mutations through Server Actions** in `app/actions/*.ts`. Never call the API from a client component. Sanctioned exceptions: (1) cookie-setting auth/session flows (`app/api/auth/*` — send-code, verify-code, google, logout, session) are BFF route handlers because they own the httpOnly auth cookie lifecycle; (2) the SSE chat send in `hooks/use-chat-composer.ts` fetches the BFF chat-stream route directly because a Server Action cannot return a streaming `ReadableStream`. Everything else stays a Server Action. - **Imports:** types from `@orbit/shared/types`, query keys from `@orbit/shared/query`, endpoints from `@orbit/shared/api`. ## Key files @@ -18,7 +19,7 @@ Web client. Server Components by default. Auth via httpOnly cookies + BFF proxy. |---|---| | App shell + nav | `app/(app)/layout.tsx` + `components/navigation/web-nav.tsx` | | Design tokens (navy+violet system, 6 schemes × dark/light) | `app/globals.css` (Tailwind v4 CSS-first) | -| Auth middleware | `middleware.ts` | +| Auth middleware | `proxy.ts` | | BFF catch-all proxy | `app/api/[...path]/route.ts` | | Server Actions (mutations) | `app/actions/*.ts` | | TanStack Query hooks | `hooks/use-*.ts` | @@ -28,28 +29,26 @@ Web client. Server Components by default. Auth via httpOnly cookies + BFF proxy. ## State management - **Server state:** TanStack Query. One hook per resource in `hooks/use-*.ts`. Query keys from `@orbit/shared/query/keys.ts` — never invent inline keys. -- **Client state:** Zustand stores in `stores/*-store.ts`. Persist with `skipHydration: true` and rehydrate manually in `app/providers.tsx` to avoid SSR mismatches. +- **Client state:** Zustand stores in `stores/*-store.ts`. Persist with `skipHydration: true` and rehydrate manually in `lib/providers.tsx` to avoid SSR mismatches. - **Form state:** react-hook-form + Zod resolver. Schemas live in `packages/shared/src/validation/*.ts` so the mobile app uses the same rules. ## Server Actions pattern ```ts "use server" -import { authedFetch } from "@/lib/server-fetch" +import { serverAuthFetch } from "@/lib/server-fetch" import { API } from "@orbit/shared/api" export async function createHabit(input: CreateHabitInput) { - const result = await authedFetch(API.habits.create, { + return serverAuthFetch(API.habits.create, { method: "POST", body: JSON.stringify(input), }) - revalidateTag("habits") - return result } ``` -- Always go through `authedFetch` so the httpOnly cookie is forwarded. -- Use `revalidateTag` / `revalidatePath` after mutations so the TanStack cache (web) refetches. +- Always go through `serverAuthFetch` so the httpOnly cookie is forwarded. +- Cache refresh is client-side: mutation hooks invalidate TanStack queries (`queryClient.invalidateQueries` with keys from `@orbit/shared/query`). Server Actions do not call `revalidateTag` / `revalidatePath`. - Errors propagate as thrown — the client side catches via `useMutation`'s `onError`. ## BFF proxy @@ -66,7 +65,7 @@ export async function createHabit(input: CreateHabitInput) { | Want to add… | Look at… | |---|---| -| New page | `app/(app)/today/page.tsx` | +| New page | `app/(app)/page.tsx` | | New Server Action | `app/actions/habits.ts` | | New TanStack hook | `hooks/use-habits.ts` | | New Zustand store | `stores/ui-store.ts` | diff --git a/apps/web/__tests__/actions/profile.test.ts b/apps/web/__tests__/actions/profile.test.ts index 40aebae279..159b6f4bf5 100644 --- a/apps/web/__tests__/actions/profile.test.ts +++ b/apps/web/__tests__/actions/profile.test.ts @@ -24,8 +24,8 @@ const { updateColorScheme, completeOnboarding, resetAccount, - dismissCalendarImport, } = await import('@/app/actions/profile') +const { dismissCalendarImport } = await import('@/app/actions/calendar') describe('profile server actions', () => { beforeEach(() => { diff --git a/apps/web/__tests__/app/api/auth-google-route.test.ts b/apps/web/__tests__/app/api/auth-google-route.test.ts new file mode 100644 index 0000000000..db8fe9ab3d --- /dev/null +++ b/apps/web/__tests__/app/api/auth-google-route.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextRequest } from 'next/server' +import { POST } from '@/app/api/auth/google/route' +import { setSessionCookies } from '@/lib/auth-api' + +vi.mock('@/lib/auth-api', () => ({ + setSessionCookies: vi.fn(), +})) + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +function makeRequest(body: unknown) { + return new NextRequest('http://localhost:3000/api/auth/google', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +const validBody = { accessToken: 'supabase-access', language: 'en' } + +describe('google BFF route', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.mocked(setSessionCookies).mockReset() + }) + + it('sets session cookies and strips tokens from the returned user', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + token: 'jwt-token', + refreshToken: 'refresh-token', + userId: 'user-1', + name: 'Thomas', + email: 'thomas@example.com', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + + const response = await POST(makeRequest(validBody)) + const json = await response.json() + + expect(response.status).toBe(200) + expect(vi.mocked(setSessionCookies)).toHaveBeenCalledWith('jwt-token', 'refresh-token') + expect(json).not.toHaveProperty('token') + expect(json).not.toHaveProperty('refreshToken') + }) + + it('rejects a body missing accessToken with 400 before forwarding', async () => { + const response = await POST(makeRequest({ language: 'en' })) + + expect(response.status).toBe(400) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('returns a generic 500 when the upstream throws', async () => { + mockFetch.mockRejectedValue(new Error('upstream offline')) + + const response = await POST(makeRequest(validBody)) + const json = await response.json() + + expect(response.status).toBe(500) + expect(json.error).toBe('Authentication failed') + expect(JSON.stringify(json)).not.toContain('upstream offline') + }) +}) diff --git a/apps/web/__tests__/app/api/auth-send-code-route.test.ts b/apps/web/__tests__/app/api/auth-send-code-route.test.ts new file mode 100644 index 0000000000..ad628248bc --- /dev/null +++ b/apps/web/__tests__/app/api/auth-send-code-route.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextRequest } from 'next/server' +import { POST } from '@/app/api/auth/send-code/route' + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +function makeRequest(body: unknown) { + return new NextRequest('http://localhost:3000/api/auth/send-code', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +const validBody = { email: 'thomas@example.com', language: 'en' } + +describe('send-code BFF route', () => { + beforeEach(() => { + mockFetch.mockReset() + }) + + it('forwards a valid body to the backend and returns its payload', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const response = await POST(makeRequest(validBody)) + + expect(response.status).toBe(200) + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:5000/api/auth/send-code', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(validBody), + }), + ) + }) + + it('rejects a malformed body with 400 before forwarding', async () => { + const response = await POST(makeRequest({ email: 123 })) + + expect(response.status).toBe(400) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('passes through a backend rate-limit error', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ error: 'Please wait', errorCode: 'RATE_LIMITED' }), { + status: 429, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const response = await POST(makeRequest(validBody)) + const json = await response.json() + + expect(response.status).toBe(429) + expect(json.errorCode).toBe('RATE_LIMITED') + }) + + it('returns a generic 500 when the upstream throws', async () => { + mockFetch.mockRejectedValue(new Error('dns failure detail')) + + const response = await POST(makeRequest(validBody)) + const json = await response.json() + + expect(response.status).toBe(500) + expect(json.error).toBe('Authentication failed') + expect(JSON.stringify(json)).not.toContain('dns failure detail') + }) +}) diff --git a/apps/web/__tests__/app/api/auth-verify-code-route.test.ts b/apps/web/__tests__/app/api/auth-verify-code-route.test.ts new file mode 100644 index 0000000000..3ef7ead6f0 --- /dev/null +++ b/apps/web/__tests__/app/api/auth-verify-code-route.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextRequest } from 'next/server' +import { POST } from '@/app/api/auth/verify-code/route' +import { setSessionCookies } from '@/lib/auth-api' + +vi.mock('@/lib/auth-api', () => ({ + setSessionCookies: vi.fn(), +})) + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +function makeRequest(body: unknown) { + return new NextRequest('http://localhost:3000/api/auth/verify-code', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +const validBody = { email: 'thomas@example.com', code: '123456', language: 'en' } + +describe('verify-code BFF route', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.mocked(setSessionCookies).mockReset() + }) + + it('sets httpOnly session cookies and returns the user without the token', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + token: 'jwt-token', + refreshToken: 'refresh-token', + userId: 'user-1', + name: 'Thomas', + email: 'thomas@example.com', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + + const response = await POST(makeRequest(validBody)) + const json = await response.json() + + expect(response.status).toBe(200) + expect(vi.mocked(setSessionCookies)).toHaveBeenCalledWith('jwt-token', 'refresh-token') + expect(json).toMatchObject({ userId: 'user-1', name: 'Thomas', email: 'thomas@example.com' }) + expect(json).not.toHaveProperty('token') + expect(json).not.toHaveProperty('refreshToken') + }) + + it('rejects a malformed body with 400 before forwarding', async () => { + const response = await POST(makeRequest({ email: 'thomas@example.com' })) + + expect(response.status).toBe(400) + expect(mockFetch).not.toHaveBeenCalled() + expect(vi.mocked(setSessionCookies)).not.toHaveBeenCalled() + }) + + it('passes through a backend error status and payload', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ error: 'Invalid verification code', errorCode: 'INVALID_VERIFICATION_CODE' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const response = await POST(makeRequest(validBody)) + const json = await response.json() + + expect(response.status).toBe(400) + expect(json.error).toBe('Invalid verification code') + expect(json.errorCode).toBe('INVALID_VERIFICATION_CODE') + expect(vi.mocked(setSessionCookies)).not.toHaveBeenCalled() + }) + + it('returns a generic 500 and does not leak detail when the upstream throws', async () => { + mockFetch.mockRejectedValue(new Error('ECONNREFUSED api-base')) + + const response = await POST(makeRequest(validBody)) + const json = await response.json() + + expect(response.status).toBe(500) + expect(json.error).toBe('Authentication failed') + expect(JSON.stringify(json)).not.toContain('ECONNREFUSED') + }) +}) diff --git a/apps/web/__tests__/components/ai-settings/ai-feature-toggles.test.tsx b/apps/web/__tests__/components/ai-settings/ai-feature-toggles.test.tsx new file mode 100644 index 0000000000..f4c2b67429 --- /dev/null +++ b/apps/web/__tests__/components/ai-settings/ai-feature-toggles.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ profile: { isTrialActive: false, hasProAccess: true } }), +})) + +vi.mock('next/link', () => ({ + default: ({ + children, + href, + ...props + }: { + children: React.ReactNode + href: string + [key: string]: unknown + }) => ( + + {children} + + ), +})) + +import { AiFeatureToggles } from '@/app/(app)/ai-settings/_components/ai-feature-toggles' + +function baseProps() { + return { + hasProAccess: true, + aiMemoryEnabled: true, + aiSummaryEnabled: false, + memoryPending: false, + summaryPending: false, + onToggleMemory: vi.fn(), + onToggleSummary: vi.fn(), + } +} + +describe('AiFeatureToggles', () => { + it('renders memory and summary switches reflecting their on/off state', () => { + render() + expect( + screen.getByRole('switch', { name: 'profile.aiMemory.title' }), + ).toHaveAttribute('aria-checked', 'true') + expect( + screen.getByRole('switch', { name: 'profile.aiSummary.title' }), + ).toHaveAttribute('aria-checked', 'false') + }) + + it('fires the memory and summary toggle callbacks on click', () => { + const props = baseProps() + render() + fireEvent.click(screen.getByRole('switch', { name: 'profile.aiMemory.title' })) + expect(props.onToggleMemory).toHaveBeenCalled() + fireEvent.click(screen.getByRole('switch', { name: 'profile.aiSummary.title' })) + expect(props.onToggleSummary).toHaveBeenCalled() + }) + + it('disables a switch while its mutation is pending', () => { + const props = { ...baseProps(), memoryPending: true } + render() + expect( + screen.getByRole('switch', { name: 'profile.aiMemory.title' }), + ).toBeDisabled() + }) + + it('renders upgrade links instead of switches for non-pro users', () => { + render() + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + const upgradeLinks = screen.getAllByRole('link') + expect(upgradeLinks).toHaveLength(2) + expect(upgradeLinks[0]).toHaveAttribute('href', '/upgrade') + }) +}) diff --git a/apps/web/__tests__/components/ai-settings/fact-item.test.tsx b/apps/web/__tests__/components/ai-settings/fact-item.test.tsx new file mode 100644 index 0000000000..83e8fa2b0a --- /dev/null +++ b/apps/web/__tests__/components/ai-settings/fact-item.test.tsx @@ -0,0 +1,118 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import type { UserFact } from '@orbit/shared/types/user-fact' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +import { FactItem } from '@/app/(app)/ai-settings/_components/fact-item' + +function makeFact(overrides: Partial = {}): UserFact { + return { + id: 'fact-1', + factText: 'Prefers morning workouts', + category: 'preference', + extractedAtUtc: '2026-01-01T00:00:00Z', + updatedAtUtc: null, + ...overrides, + } +} + +describe('FactItem', () => { + it('renders the fact text', () => { + render( + , + ) + expect(screen.getByText('Prefers morning workouts')).toBeInTheDocument() + }) + + it('renders the uppercased category label when a category is present', () => { + render( + , + ) + expect(screen.getByText('PROFILE.FACTS.PREFERENCE')).toBeInTheDocument() + }) + + it('omits the category label when there is no category', () => { + render( + , + ) + expect(screen.queryByText('PROFILE.FACTS.PREFERENCE')).not.toBeInTheDocument() + }) + + it('shows a delete button outside select mode and fires onDelete', () => { + const onDelete = vi.fn() + render( + , + ) + fireEvent.click(screen.getByLabelText('common.delete')) + expect(onDelete).toHaveBeenCalled() + }) + + it('renders a pressable toggle in select mode and fires onToggleSelection', () => { + const onToggleSelection = vi.fn() + render( + , + ) + const toggle = screen.getByRole('button', { pressed: false }) + fireEvent.click(toggle) + expect(onToggleSelection).toHaveBeenCalled() + }) + + it('reflects the selected state via aria-pressed in select mode', () => { + render( + , + ) + expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument() + }) + + it('hides the delete button in select mode', () => { + render( + , + ) + expect(screen.queryByLabelText('common.delete')).not.toBeInTheDocument() + }) +}) diff --git a/apps/web/__tests__/components/ai-settings/facts-pagination.test.tsx b/apps/web/__tests__/components/ai-settings/facts-pagination.test.tsx new file mode 100644 index 0000000000..33c1a42935 --- /dev/null +++ b/apps/web/__tests__/components/ai-settings/facts-pagination.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => { + if (params) return `${key}:${JSON.stringify(params)}` + return key + }, +})) + +import { FactsPagination } from '@/app/(app)/ai-settings/_components/facts-pagination' + +describe('FactsPagination', () => { + it('renders the current page and total via i18n', () => { + render( + , + ) + expect( + screen.getByText('profile.facts.count:{"n":2,"max":4}'), + ).toBeInTheDocument() + }) + + it('navigates to the previous and next page on click', () => { + const onPrevious = vi.fn() + const onNext = vi.fn() + render( + , + ) + fireEvent.click(screen.getByLabelText('common.previousPage')) + expect(onPrevious).toHaveBeenCalled() + fireEvent.click(screen.getByLabelText('common.nextPage')) + expect(onNext).toHaveBeenCalled() + }) + + it('disables previous on the first page', () => { + render( + , + ) + expect(screen.getByLabelText('common.previousPage')).toBeDisabled() + expect(screen.getByLabelText('common.nextPage')).not.toBeDisabled() + }) + + it('disables next on the last page', () => { + render( + , + ) + expect(screen.getByLabelText('common.nextPage')).toBeDisabled() + expect(screen.getByLabelText('common.previousPage')).not.toBeDisabled() + }) +}) diff --git a/apps/web/__tests__/components/ai-settings/facts-select-bar.test.tsx b/apps/web/__tests__/components/ai-settings/facts-select-bar.test.tsx new file mode 100644 index 0000000000..e54abfe00f --- /dev/null +++ b/apps/web/__tests__/components/ai-settings/facts-select-bar.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => { + if (params) return `${key}:${JSON.stringify(params)}` + return key + }, +})) + +import { FactsSelectBar } from '@/app/(app)/ai-settings/_components/facts-select-bar' + +function baseProps() { + return { + factCount: 7, + selectMode: false, + selectedCount: 0, + allSelected: false, + bulkDeletePending: false, + onToggleSelectAll: vi.fn(), + onBulkDelete: vi.fn(), + onToggleSelectMode: vi.fn(), + } +} + +describe('FactsSelectBar', () => { + it('shows the fact count and a select action when idle', () => { + const props = baseProps() + render() + expect(screen.getByText('7')).toBeInTheDocument() + fireEvent.click(screen.getByText('profile.facts.select')) + expect(props.onToggleSelectMode).toHaveBeenCalled() + }) + + it('shows the selected count and a cancel action in select mode', () => { + const props = { ...baseProps(), selectMode: true, selectedCount: 2 } + render() + expect( + screen.getByText('2 profile.facts.select'), + ).toBeInTheDocument() + fireEvent.click(screen.getByText('profile.facts.cancel')) + expect(props.onToggleSelectMode).toHaveBeenCalled() + }) + + it('toggles select-all and shows the select-all label when none are selected', () => { + const props = { ...baseProps(), selectMode: true, selectedCount: 0 } + render() + fireEvent.click(screen.getByText('profile.facts.selectAll')) + expect(props.onToggleSelectAll).toHaveBeenCalled() + }) + + it('shows the deselect-all label when everything is selected', () => { + const props = { + ...baseProps(), + selectMode: true, + selectedCount: 7, + allSelected: true, + } + render() + expect(screen.getByText('profile.facts.deselectAll')).toBeInTheDocument() + }) + + it('fires bulk delete when there is a selection', () => { + const props = { ...baseProps(), selectMode: true, selectedCount: 3 } + render() + fireEvent.click(screen.getByText('profile.facts.deleteSelected:{"n":3}')) + expect(props.onBulkDelete).toHaveBeenCalled() + }) + + it('disables bulk delete while a delete is pending', () => { + const props = { + ...baseProps(), + selectMode: true, + selectedCount: 3, + bulkDeletePending: true, + } + render() + expect( + screen.getByText('profile.facts.deleteSelected:{"n":3}'), + ).toBeDisabled() + }) + + it('hides the bulk-delete action when nothing is selected', () => { + const props = { ...baseProps(), selectMode: true, selectedCount: 0 } + render() + expect( + screen.queryByText('profile.facts.deleteSelected:{"n":0}'), + ).not.toBeInTheDocument() + }) +}) diff --git a/apps/web/__tests__/components/chat/breakdown-suggestion.test.tsx b/apps/web/__tests__/components/chat/breakdown-suggestion.test.tsx index 9b9642b272..963df9c3cf 100644 --- a/apps/web/__tests__/components/chat/breakdown-suggestion.test.tsx +++ b/apps/web/__tests__/components/chat/breakdown-suggestion.test.tsx @@ -18,9 +18,11 @@ vi.mock('@/lib/plural', () => ({ plural: (text: string) => text, })) +const bulkCreateMock = vi.fn().mockResolvedValue(undefined) + vi.mock('@/hooks/use-habits', () => ({ useBulkCreateHabits: () => ({ - mutateAsync: vi.fn().mockResolvedValue(undefined), + mutateAsync: bulkCreateMock, isPending: false, }), })) @@ -51,6 +53,8 @@ describe('BreakdownSuggestion', () => { beforeEach(() => { defaultProps.onConfirmed.mockClear() defaultProps.onCancelled.mockClear() + bulkCreateMock.mockReset() + bulkCreateMock.mockResolvedValue(undefined) }) it('renders sub-habits as editable inputs', () => { @@ -73,15 +77,64 @@ describe('BreakdownSuggestion', () => { expect(inputs.length).toBeGreaterThanOrEqual(3) }) - it('removes a habit when X button clicked', () => { + it('removes a habit when its labeled X button is clicked', () => { render(, { wrapper: createWrapper() }) - const removeButtons = screen.getAllByRole('button').filter((btn) => { - const svg = btn.querySelector('svg') - return svg && btn.closest('[class*="hover:text-red"]') + const removeButton = screen.getByRole('button', { + name: /habits\.breakdown\.removeHabit.*Push-ups/, }) - if (removeButtons.length > 0) { - fireEvent.click(removeButtons[0]!) - expect(screen.queryByDisplayValue('Push-ups')).not.toBeInTheDocument() + fireEvent.click(removeButton) + expect(screen.queryByDisplayValue('Push-ups')).not.toBeInTheDocument() + }) + + it('shows the friendly i18n fallback instead of a raw Error message on failure', async () => { + bulkCreateMock.mockRejectedValue(new Error('ECONNREFUSED 127.0.0.1:5432')) + render(, { wrapper: createWrapper() }) + + fireEvent.click( + screen.getByText('habits.breakdown.createCount({"n":2})'), + ) + + expect( + await screen.findByText('errors.bulkCreateHabits'), + ).toBeInTheDocument() + expect(screen.queryByText(/ECONNREFUSED/)).not.toBeInTheDocument() + }) + + it('reveals the frequency-quantity editor once a recurrence unit is chosen', () => { + render(, { wrapper: createWrapper() }) + + expect( + screen.queryByLabelText('habits.breakdown.frequencyQuantityLabel'), + ).not.toBeInTheDocument() + + const [firstUnitSelect] = screen.getAllByRole('combobox') + fireEvent.change(firstUnitSelect!, { target: { value: 'Week' } }) + + expect( + screen.getByLabelText('habits.breakdown.frequencyQuantityLabel'), + ).toBeInTheDocument() + }) + + it('passes the chosen frequency quantity into the bulk-create request', async () => { + render(, { wrapper: createWrapper() }) + + const [firstUnitSelect] = screen.getAllByRole('combobox') + fireEvent.change(firstUnitSelect!, { target: { value: 'Week' } }) + fireEvent.change( + screen.getByLabelText('habits.breakdown.frequencyQuantityLabel'), + { target: { value: '3' } }, + ) + fireEvent.click( + screen.getByText('habits.breakdown.createCount({"n":2})'), + ) + + await vi.waitFor(() => expect(bulkCreateMock).toHaveBeenCalled()) + const request = bulkCreateMock.mock.calls[0]![0] as { + habits: { frequencyUnit?: string; frequencyQuantity?: number }[] } + expect(request.habits[0]).toMatchObject({ + frequencyUnit: 'Week', + frequencyQuantity: 3, + }) }) }) diff --git a/apps/web/__tests__/components/chat/chat-composer-bar.test.tsx b/apps/web/__tests__/components/chat/chat-composer-bar.test.tsx new file mode 100644 index 0000000000..9606d37518 --- /dev/null +++ b/apps/web/__tests__/components/chat/chat-composer-bar.test.tsx @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { createRef } from 'react' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => { + if (params) return `${key}:${JSON.stringify(params)}` + return key + }, +})) + +import { ChatComposerBar } from '@/app/(chat)/chat/chat-composer-bar' + +function baseProps() { + return { + textareaRef: createRef(), + fileInputRef: createRef(), + langPickerRef: createRef(), + input: '', + setInput: vi.fn(), + sendError: null, + imagePreview: null, + isRecording: false, + speechSupported: true, + speechLang: 'en-US', + setSpeechLang: vi.fn(), + toggleRecording: vi.fn(), + recordingTime: '0:05', + currentLangFlag: 'FLAG', + showLangPicker: false, + setShowLangPicker: vi.fn(), + starterChips: [] as string[], + isTyping: false, + hasProAccess: false, + aiMessagesUsed: 3, + aiMessagesLimit: 20, + atMessageLimit: false, + canSend: true, + hasMessages: false, + limitLocked: false, + openFilePicker: vi.fn(), + handleFileSelect: vi.fn(), + handlePaste: vi.fn(), + handleKeyDown: vi.fn(), + removeImage: vi.fn(), + sendMessage: vi.fn(), + retryLastSend: vi.fn(), + canRetryLastSend: false, + onUpgrade: vi.fn(), + } +} + +describe('ChatComposerBar', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('renders the textarea with the placeholder and sends on send-button click', () => { + const props = baseProps() + render() + expect(screen.getByLabelText('chat.placeholder')).toBeInTheDocument() + fireEvent.click(screen.getByLabelText('chat.send')) + expect(props.sendMessage).toHaveBeenCalled() + }) + + it('typing in the textarea calls setInput', () => { + const props = baseProps() + render() + fireEvent.change(screen.getByLabelText('chat.placeholder'), { + target: { value: 'hello' }, + }) + expect(props.setInput).toHaveBeenCalledWith('hello') + }) + + it('disables the send button when canSend is false', () => { + const props = { ...baseProps(), canSend: false } + render() + expect(screen.getByLabelText('chat.send')).toBeDisabled() + }) + + it('shows the message usage counter for non-pro users under the limit', () => { + render() + expect(screen.getByText('3/20 chat.messagesUsed')).toBeInTheDocument() + }) + + it('hides the usage counter for pro users', () => { + render() + expect(screen.queryByText('3/20 chat.messagesUsed')).not.toBeInTheDocument() + }) + + it('renders a send-error alert and retry button that fires retryLastSend', () => { + const props = { + ...baseProps(), + sendError: 'Something failed', + canRetryLastSend: true, + } + render() + expect(screen.getByRole('alert')).toHaveTextContent('Something failed') + fireEvent.click(screen.getByText('common.retry')) + expect(props.retryLastSend).toHaveBeenCalled() + }) + + it('omits the retry button when retry is unavailable', () => { + const props = { + ...baseProps(), + sendError: 'Something failed', + canRetryLastSend: false, + } + render() + expect(screen.queryByText('common.retry')).not.toBeInTheDocument() + }) + + it('renders the image preview and removes it on click', () => { + const props = { ...baseProps(), imagePreview: 'blob:preview' } + render() + fireEvent.click(screen.getByLabelText('chat.removeImage')) + expect(props.removeImage).toHaveBeenCalled() + }) + + it('renders starter chips when there are messages and sends the chip on click', () => { + const props = { + ...baseProps(), + hasMessages: true, + starterChips: ['First chip', 'Second chip'], + } + render() + fireEvent.click(screen.getByText('First chip')) + expect(props.sendMessage).toHaveBeenCalledWith('First chip') + }) + + it('renders the recording bar with stop control while recording', () => { + const props = { ...baseProps(), isRecording: true } + render() + expect(screen.getByText('0:05')).toBeInTheDocument() + fireEvent.click(screen.getByLabelText('chat.stopRecording')) + expect(props.toggleRecording).toHaveBeenCalled() + }) + + it('opens the file picker when the attach button is clicked', () => { + const props = baseProps() + render() + fireEvent.click(screen.getByLabelText('chat.attachImage')) + expect(props.openFilePicker).toHaveBeenCalled() + }) + + it('toggles recording from the mic button', () => { + const props = baseProps() + render() + fireEvent.click(screen.getByLabelText('chat.toggleMic')) + expect(props.toggleRecording).toHaveBeenCalled() + }) + + it('toggles the language picker open from the flag button', () => { + const props = baseProps() + render() + fireEvent.click(screen.getByLabelText('chat.speechLanguage')) + expect(props.setShowLangPicker).toHaveBeenCalled() + }) + + it('opens the speech language picker and selects a language', () => { + const props = { ...baseProps(), showLangPicker: true } + render() + fireEvent.click(screen.getByText('Espanol')) + expect(props.setSpeechLang).toHaveBeenCalledWith('es-ES') + expect(props.setShowLangPicker).toHaveBeenCalledWith(false) + }) + + it('hides the mic and language picker when speech is unsupported', () => { + const props = { ...baseProps(), speechSupported: false } + render() + expect(screen.queryByLabelText('chat.toggleMic')).not.toBeInTheDocument() + }) + + it('locks the input and shows the upgrade gate when the limit is reached', () => { + const props = { ...baseProps(), limitLocked: true } + render() + expect(screen.getByLabelText('chat.placeholder')).toBeDisabled() + fireEvent.click(screen.getByText('upgrade.subscribe')) + expect(props.onUpgrade).toHaveBeenCalled() + }) +}) diff --git a/apps/web/__tests__/components/chat/chat-empty-state.test.tsx b/apps/web/__tests__/components/chat/chat-empty-state.test.tsx new file mode 100644 index 0000000000..5a66a4487c --- /dev/null +++ b/apps/web/__tests__/components/chat/chat-empty-state.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +import { ChatEmptyState } from '@/app/(chat)/chat/chat-empty-state' + +describe('ChatEmptyState', () => { + it('renders the empty-state title and prompt copy', () => { + render() + expect(screen.getByText('chat.empty.title')).toBeInTheDocument() + expect(screen.getByText('chat.suggestion.prompt')).toBeInTheDocument() + }) + + it('renders the medical-advice disclosure', () => { + render() + expect( + screen.getByText('aiDisclosure.notMedicalAdvice'), + ).toBeInTheDocument() + }) + + it('forwards a chosen suggestion to onSelectSuggestion', () => { + const onSelectSuggestion = vi.fn() + render() + fireEvent.click(screen.getByText('chat.suggestion.meditated')) + expect(onSelectSuggestion).toHaveBeenCalledWith('chat.suggestion.meditated') + }) +}) diff --git a/apps/web/__tests__/components/goals/goal-list.test.tsx b/apps/web/__tests__/components/goals/goal-list.test.tsx index 7b4011164a..7b8bbbe738 100644 --- a/apps/web/__tests__/components/goals/goal-list.test.tsx +++ b/apps/web/__tests__/components/goals/goal-list.test.tsx @@ -82,7 +82,7 @@ describe('GoalList', () => { it('sets aria-roledescription on draggable sections', () => { const { container } = render() - const sections = container.querySelectorAll('[aria-roledescription="draggable item"]') + const sections = container.querySelectorAll('[aria-roledescription="goals.dragItem"]') expect(sections).toHaveLength(2) expect(sections[0]).toHaveAttribute('aria-label', 'Run 100km') expect(sections[1]).toHaveAttribute('aria-label', 'Read 12 books') diff --git a/apps/web/__tests__/components/habits/habit-list.test.tsx b/apps/web/__tests__/components/habits/habit-list.test.tsx index cdd9880b57..83205fe686 100644 --- a/apps/web/__tests__/components/habits/habit-list.test.tsx +++ b/apps/web/__tests__/components/habits/habit-list.test.tsx @@ -243,6 +243,7 @@ vi.mock('@/components/ui/app-overlay', () => ({ vi.mock('@dnd-kit/core', () => ({ DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, closestCenter: vi.fn(), + KeyboardSensor: vi.fn(), PointerSensor: vi.fn(), TouchSensor: vi.fn(), useSensor: () => ({}), @@ -251,6 +252,7 @@ vi.mock('@dnd-kit/core', () => ({ vi.mock('@dnd-kit/sortable', () => ({ SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}, + sortableKeyboardCoordinates: vi.fn(), useSortable: () => ({ attributes: {}, listeners: {}, diff --git a/apps/web/__tests__/components/preferences/preference-picker-sheet.test.tsx b/apps/web/__tests__/components/preferences/preference-picker-sheet.test.tsx new file mode 100644 index 0000000000..e8b8e2b79c --- /dev/null +++ b/apps/web/__tests__/components/preferences/preference-picker-sheet.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import type { PreferencePicker } from '@/app/(app)/preferences/_components/preference-picker-sheet' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock('dompurify', () => ({ + default: { sanitize: (html: string) => html }, +})) + +import { PreferencePickerSheet } from '@/app/(app)/preferences/_components/preference-picker-sheet' + +const pickerTitles: Record = { + language: 'Language', + theme: 'Theme', + scheme: 'Color', + weekStart: 'Week start', +} + +function baseProps() { + return { + activePicker: null as PreferencePicker | null, + mounted: true, + selectedLanguage: 'en', + currentTheme: 'dark' as const, + currentScheme: 'purple' as const, + weekStartDay: 1, + themeModeOptions: [ + { value: 'light' as const, label: 'Light' }, + { value: 'dark' as const, label: 'Dark' }, + ], + weekStartOptions: [ + { value: 1, label: 'Monday' }, + { value: 0, label: 'Sunday' }, + ], + pickerTitles, + onClose: vi.fn(), + onLanguageChange: vi.fn(), + onThemeModeChange: vi.fn(), + onSchemeChange: vi.fn(), + onWeekStartChange: vi.fn(), + } +} + +describe('PreferencePickerSheet', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('renders nothing when no picker is active', () => { + render() + expect(screen.queryByText('English')).not.toBeInTheDocument() + }) + + it('renders the language options and fires onLanguageChange + onClose on select', () => { + const props = { ...baseProps(), activePicker: 'language' as const } + render() + fireEvent.click(screen.getByText('Português')) + expect(props.onLanguageChange).toHaveBeenCalledWith('pt-BR') + expect(props.onClose).toHaveBeenCalled() + }) + + it('renders theme options and fires onThemeModeChange on select', () => { + const props = { ...baseProps(), activePicker: 'theme' as const } + render() + fireEvent.click(screen.getByText('Dark')) + expect(props.onThemeModeChange).toHaveBeenCalledWith('dark') + }) + + it('renders color scheme options and fires onSchemeChange without closing', () => { + const props = { ...baseProps(), activePicker: 'scheme' as const } + render() + fireEvent.click(screen.getByText('preferences.colorBlue')) + expect(props.onSchemeChange).toHaveBeenCalledWith('blue') + expect(props.onClose).not.toHaveBeenCalled() + }) + + it('shows a save footer for the scheme picker that closes on click', () => { + const props = { ...baseProps(), activePicker: 'scheme' as const } + render() + fireEvent.click(screen.getByText('common.save')) + expect(props.onClose).toHaveBeenCalled() + }) + + it('renders week-start options and fires onWeekStartChange on select', () => { + const props = { ...baseProps(), activePicker: 'weekStart' as const } + render() + fireEvent.click(screen.getByText('Sunday')) + expect(props.onWeekStartChange).toHaveBeenCalledWith(0) + }) + + it('marks the currently selected language radio as checked', () => { + const props = { ...baseProps(), activePicker: 'language' as const } + render() + const checked = screen.getByRole('radio', { checked: true }) + expect(checked).toHaveTextContent('English') + }) +}) diff --git a/apps/web/__tests__/components/profile/profile-nav-sections.test.tsx b/apps/web/__tests__/components/profile/profile-nav-sections.test.tsx new file mode 100644 index 0000000000..47a0267c45 --- /dev/null +++ b/apps/web/__tests__/components/profile/profile-nav-sections.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { createMockGamificationProfile } from '@orbit/shared/__tests__/factories' +import type { ProfileNavItem } from '@orbit/shared/utils/profile-navigation' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => { + if (params) return `${key}:${JSON.stringify(params)}` + return key + }, +})) + +import { ProfileNavSections } from '@/app/(app)/profile/_components/profile-nav-sections' + +function makeNavItem(overrides: Partial = {}): ProfileNavItem { + return { + id: 'preferences', + section: 'account', + route: '/preferences', + iconKey: 'settings', + titleKey: 'profile.sections.preferences', + hintKey: 'profile.sections.preferencesHint', + variant: 'default', + proBadge: false, + hintMode: 'static', + entitlementRequirement: null, + entitlementMode: null, + ...overrides, + } +} + +function baseProps() { + return { + accountNavItems: [makeNavItem()], + featureNavItems: [ + makeNavItem({ + id: 'achievements', + section: 'features', + route: '/achievements', + iconKey: 'achievements', + titleKey: 'gamification.profileCard.title', + hintKey: 'gamification.profileCard.hint', + proBadge: true, + hintMode: 'gamificationProfile', + }), + ], + navTourMap: {} as Record, + hasProAccess: false, + gamificationProfile: null, + onNavClick: vi.fn(), + onTourReplay: vi.fn(), + } +} + +describe('ProfileNavSections', () => { + it('renders account and feature section labels', () => { + render() + expect(screen.getByText('profile.sections.account')).toBeInTheDocument() + expect(screen.getByText('profile.sections.features')).toBeInTheDocument() + }) + + it('renders a nav row per item and fires onNavClick with the item', () => { + const props = baseProps() + render() + fireEvent.click(screen.getByText('profile.sections.preferences')) + expect(props.onNavClick).toHaveBeenCalledWith(props.accountNavItems[0]) + }) + + it('fires onNavClick for feature rows with the feature item', () => { + const props = baseProps() + render() + fireEvent.click(screen.getByText('gamification.profileCard.title')) + expect(props.onNavClick).toHaveBeenCalledWith(props.featureNavItems[0]) + }) + + it('renders the tour-replay row and fires onTourReplay', () => { + const props = baseProps() + render() + fireEvent.click(screen.getByText('tour.replay.title')) + expect(props.onTourReplay).toHaveBeenCalled() + }) + + it('shows the static hint for gamification rows without pro access', () => { + render() + expect(screen.getByText('gamification.profileCard.hint')).toBeInTheDocument() + }) + + it('shows the level and XP hint for gamification rows with pro access and a profile', () => { + const props = { + ...baseProps(), + hasProAccess: true, + gamificationProfile: createMockGamificationProfile({ level: 3, totalXp: 500 }), + } + render() + expect( + screen.getByText( + 'gamification.profileCard.level:{"level":3} · gamification.profileCard.totalXp:{"total":500}', + ), + ).toBeInTheDocument() + }) +}) diff --git a/apps/web/__tests__/components/retrospective/retrospective-card.test.tsx b/apps/web/__tests__/components/retrospective/retrospective-card.test.tsx new file mode 100644 index 0000000000..3791e85939 --- /dev/null +++ b/apps/web/__tests__/components/retrospective/retrospective-card.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock('dompurify', () => ({ + default: { sanitize: (html: string) => html }, +})) + +import { RetrospectiveCard } from '@/app/(app)/retrospective/_components/retrospective-card' + +function baseProps() { + return { + retrospective: 'You stayed consistent this week.', + fromCache: false, + isLoading: false, + isOnline: true, + onRegenerate: vi.fn(), + } +} + +describe('RetrospectiveCard', () => { + it('renders the retrospective text and the Astra eyebrow', () => { + render() + expect( + screen.getByText('You stayed consistent this week.'), + ).toBeInTheDocument() + expect(screen.getByText('retrospective.astraEyebrow')).toBeInTheDocument() + }) + + it('renders the medical-advice disclosure', () => { + render() + expect( + screen.getByText('aiDisclosure.notMedicalAdvice'), + ).toBeInTheDocument() + }) + + it('renders bold markdown as a strong element', () => { + render( + , + ) + expect(screen.getByText('Highlight').tagName).toBe('STRONG') + }) + + it('regenerate button fires onRegenerate when online and idle', () => { + const props = baseProps() + render() + fireEvent.click(screen.getByText('retrospective.regenerate')) + expect(props.onRegenerate).toHaveBeenCalled() + }) + + it('disables regenerate while loading', () => { + render() + expect(screen.getByText('retrospective.regenerate')).toBeDisabled() + }) + + it('disables regenerate while offline', () => { + render() + expect(screen.getByText('retrospective.regenerate')).toBeDisabled() + }) + + it('shows the cached note when the result is from cache', () => { + render() + expect(screen.getByText('retrospective.cached')).toBeInTheDocument() + }) + + it('hides the cached note when the result is fresh', () => { + render() + expect(screen.queryByText('retrospective.cached')).not.toBeInTheDocument() + }) +}) diff --git a/apps/web/__tests__/components/retrospective/retrospective-locked-states.test.tsx b/apps/web/__tests__/components/retrospective/retrospective-locked-states.test.tsx new file mode 100644 index 0000000000..f91e197c6f --- /dev/null +++ b/apps/web/__tests__/components/retrospective/retrospective-locked-states.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock('next/link', () => ({ + default: ({ + children, + href, + ...props + }: { + children: React.ReactNode + href: string + [key: string]: unknown + }) => ( + + {children} + + ), +})) + +import { RetrospectiveLockedStates } from '@/app/(app)/retrospective/_components/retrospective-locked-states' + +function baseProps() { + return { + hasProAccess: true, + isYearlyPro: true, + isTrialActive: false, + portalError: '', + onOpenPortal: vi.fn(), + } +} + +describe('RetrospectiveLockedStates', () => { + it('renders the pro-locked block with an upgrade link when not pro', () => { + render() + expect(screen.getByText('retrospective.locked')).toBeInTheDocument() + expect( + screen.getByRole('link', { name: 'upgrade.subscribe' }), + ).toHaveAttribute('href', '/upgrade') + }) + + it('renders the yearly-locked block with a change-plan button for paid monthly users', () => { + const props = { ...baseProps(), isYearlyPro: false } + render() + expect(screen.getByText('retrospective.lockedYearly')).toBeInTheDocument() + fireEvent.click(screen.getByText('retrospective.changePlan')) + expect(props.onOpenPortal).toHaveBeenCalled() + }) + + it('renders an upgrade link instead of change-plan when on a trial', () => { + const props = { ...baseProps(), isYearlyPro: false, isTrialActive: true } + render() + expect( + screen.getByRole('link', { name: 'upgrade.subscribe' }), + ).toHaveAttribute('href', '/upgrade') + expect(screen.queryByText('retrospective.changePlan')).not.toBeInTheDocument() + }) + + it('surfaces a portal error message when present', () => { + const props = { + ...baseProps(), + isYearlyPro: false, + portalError: 'Could not open portal', + } + render() + expect(screen.getByText('Could not open portal')).toBeInTheDocument() + }) + + it('renders nothing for a yearly pro user', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) +}) diff --git a/apps/web/__tests__/hooks/use-chat-image-attachment.test.tsx b/apps/web/__tests__/hooks/use-chat-image-attachment.test.tsx new file mode 100644 index 0000000000..c40b8ff4d4 --- /dev/null +++ b/apps/web/__tests__/hooks/use-chat-image-attachment.test.tsx @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import type { ChangeEvent, ClipboardEvent } from 'react' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +import { useChatImageAttachment } from '@/hooks/use-chat-image-attachment' + +function imageFile(type: string, sizeBytes: number, name = 'photo.png'): File { + const file = new File(['x'], name, { type }) + Object.defineProperty(file, 'size', { value: sizeBytes }) + return file +} + +function fileSelectEvent(file: File | undefined): ChangeEvent { + return { + target: { files: file ? [file] : [], value: 'preset' }, + } as unknown as ChangeEvent +} + +function pasteEvent(file: File | null, type: string): ClipboardEvent { + const preventDefault = vi.fn() + return { + preventDefault, + clipboardData: { + items: [{ type, getAsFile: () => file }], + }, + } as unknown as ClipboardEvent +} + +describe('useChatImageAttachment', () => { + beforeEach(() => { + vi.stubGlobal('URL', { + createObjectURL: vi.fn(() => 'blob:preview-1'), + revokeObjectURL: vi.fn(), + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('accepts a valid pick, clears the error, and builds a preview URL', () => { + const setSendError = vi.fn() + const { result } = renderHook(() => useChatImageAttachment(setSendError)) + + act(() => { + result.current.handleFileSelect(fileSelectEvent(imageFile('image/png', 1024))) + }) + + expect(setSendError).toHaveBeenCalledWith(null) + expect(result.current.selectedImage?.type).toBe('image/png') + expect(result.current.imagePreview).toBe('blob:preview-1') + }) + + it('rejects an unsupported type with the type error key and keeps no selection', () => { + const setSendError = vi.fn() + const { result } = renderHook(() => useChatImageAttachment(setSendError)) + + act(() => { + result.current.handleFileSelect(fileSelectEvent(imageFile('image/gif', 1024, 'bad.gif'))) + }) + + expect(setSendError).toHaveBeenCalledWith('chat.imageError') + expect(result.current.selectedImage).toBeNull() + expect(result.current.imagePreview).toBeNull() + }) + + it('rejects an oversized image with the size error key', () => { + const setSendError = vi.fn() + const { result } = renderHook(() => useChatImageAttachment(setSendError)) + + act(() => { + result.current.handleFileSelect( + fileSelectEvent(imageFile('image/png', 21 * 1024 * 1024)), + ) + }) + + expect(setSendError).toHaveBeenCalledWith('chat.imageSizeError') + expect(result.current.selectedImage).toBeNull() + }) + + it('captures a pasted image and prevents the default paste', () => { + const setSendError = vi.fn() + const { result } = renderHook(() => useChatImageAttachment(setSendError)) + const event = pasteEvent(imageFile('image/jpeg', 2048, 'pasted.jpg'), 'image/jpeg') + + act(() => { + result.current.handlePaste(event) + }) + + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(setSendError).toHaveBeenCalledWith(null) + expect(result.current.selectedImage?.type).toBe('image/jpeg') + }) + + it('surfaces a validation error when a pasted image is the wrong type', () => { + const setSendError = vi.fn() + const { result } = renderHook(() => useChatImageAttachment(setSendError)) + const event = pasteEvent(imageFile('image/gif', 2048, 'pasted.gif'), 'image/gif') + + act(() => { + result.current.handlePaste(event) + }) + + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(setSendError).toHaveBeenCalledWith('chat.imageError') + expect(result.current.selectedImage).toBeNull() + }) + + it('revokes the preview URL when the image is removed', () => { + const revokeObjectURL = vi.fn() + vi.stubGlobal('URL', { + createObjectURL: vi.fn(() => 'blob:preview-2'), + revokeObjectURL, + }) + const { result } = renderHook(() => useChatImageAttachment(vi.fn())) + + act(() => { + result.current.handleFileSelect(fileSelectEvent(imageFile('image/png', 1024))) + }) + expect(result.current.imagePreview).toBe('blob:preview-2') + + act(() => { + result.current.removeImage() + }) + + expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-2') + expect(result.current.selectedImage).toBeNull() + expect(result.current.imagePreview).toBeNull() + }) +}) diff --git a/apps/web/__tests__/hooks/use-chat-pending-operations.test.ts b/apps/web/__tests__/hooks/use-chat-pending-operations.test.ts new file mode 100644 index 0000000000..20ce5a006b --- /dev/null +++ b/apps/web/__tests__/hooks/use-chat-pending-operations.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import type { AgentExecuteOperationResponse } from '@orbit/shared/types/ai' + +const mocks = vi.hoisted(() => ({ + confirmPendingOperation: vi.fn(), + executePendingOperation: vi.fn(), + issuePendingOperationStepUp: vi.fn(), + verifyPendingOperationStepUp: vi.fn(), +})) + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'pt-BR', +})) + +vi.mock('@/app/actions/chat', () => ({ + confirmPendingOperation: mocks.confirmPendingOperation, + executePendingOperation: mocks.executePendingOperation, + issuePendingOperationStepUp: mocks.issuePendingOperationStepUp, + verifyPendingOperationStepUp: mocks.verifyPendingOperationStepUp, +})) + +import { useChatPendingOperations } from '@/hooks/use-chat-pending-operations' + +function makeExecution(summary: string): AgentExecuteOperationResponse { + return { + operation: { + operationId: 'op-1', + sourceName: 'CreateHabit', + riskClass: 'Low', + confirmationRequirement: 'None', + status: 'Succeeded', + summary, + }, + } as AgentExecuteOperationResponse +} + +describe('useChatPendingOperations', () => { + beforeEach(() => { + mocks.confirmPendingOperation.mockReset() + mocks.executePendingOperation.mockReset() + mocks.issuePendingOperationStepUp.mockReset() + mocks.verifyPendingOperationStepUp.mockReset() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('confirms then executes and forwards the execution to onExecuted', async () => { + mocks.confirmPendingOperation.mockResolvedValue({ + ok: true, + data: { confirmationToken: 'token-1' }, + }) + mocks.executePendingOperation.mockResolvedValue({ + ok: true, + data: makeExecution('Created'), + }) + const onExecuted = vi.fn(async () => {}) + const { result } = renderHook(() => useChatPendingOperations(onExecuted)) + + let outcome: Awaited> | null = null + await act(async () => { + outcome = await result.current.confirmAndExecutePendingOperation('pending-1') + }) + + expect(mocks.confirmPendingOperation).toHaveBeenCalledWith('pending-1') + expect(mocks.executePendingOperation).toHaveBeenCalledWith('pending-1', 'token-1') + expect(onExecuted).toHaveBeenCalledWith(makeExecution('Created')) + expect(outcome).toMatchObject({ ok: true }) + }) + + it('stops at confirm failure without executing', async () => { + mocks.confirmPendingOperation.mockResolvedValue({ + ok: false, + error: 'nope', + status: 400, + }) + const onExecuted = vi.fn(async () => {}) + const { result } = renderHook(() => useChatPendingOperations(onExecuted)) + + let outcome: Awaited> | null = null + await act(async () => { + outcome = await result.current.confirmAndExecutePendingOperation('pending-1') + }) + + expect(mocks.executePendingOperation).not.toHaveBeenCalled() + expect(onExecuted).not.toHaveBeenCalled() + expect(outcome).toMatchObject({ ok: false, error: 'chat.sendError' }) + }) + + it('prepares a step-up by confirming then issuing a challenge with the active locale', async () => { + mocks.confirmPendingOperation.mockResolvedValue({ + ok: true, + data: { confirmationToken: 'token-2' }, + }) + mocks.issuePendingOperationStepUp.mockResolvedValue({ + ok: true, + data: { challengeId: 'challenge-2' }, + }) + const { result } = renderHook(() => useChatPendingOperations(vi.fn(async () => {}))) + + let outcome: Awaited> | null = null + await act(async () => { + outcome = await result.current.prepareStepUpForBubble('pending-2') + }) + + expect(mocks.issuePendingOperationStepUp).toHaveBeenCalledWith('pending-2', 'pt-BR') + expect(outcome).toEqual({ + ok: true, + challengeId: 'challenge-2', + confirmationToken: 'token-2', + }) + }) + + it('reports the challenge error when issuing a step-up fails', async () => { + mocks.confirmPendingOperation.mockResolvedValue({ + ok: true, + data: { confirmationToken: 'token-2' }, + }) + mocks.issuePendingOperationStepUp.mockResolvedValue({ + ok: false, + error: 'challenge down', + status: 500, + }) + const { result } = renderHook(() => useChatPendingOperations(vi.fn(async () => {}))) + + let outcome: Awaited> | null = null + await act(async () => { + outcome = await result.current.prepareStepUpForBubble('pending-2') + }) + + expect(outcome).toMatchObject({ ok: false, error: 'chat.sendError' }) + }) + + it('verifies the code then executes, forwarding to onExecuted', async () => { + mocks.verifyPendingOperationStepUp.mockResolvedValue({ ok: true, data: { id: 'v-1' } }) + mocks.executePendingOperation.mockResolvedValue({ + ok: true, + data: makeExecution('Done'), + }) + const onExecuted = vi.fn(async () => {}) + const { result } = renderHook(() => useChatPendingOperations(onExecuted)) + + let outcome: Awaited> | null = null + await act(async () => { + outcome = await result.current.verifyStepUpForBubble('pending-2', 'challenge-2', '123456', 'token-2') + }) + + expect(mocks.verifyPendingOperationStepUp).toHaveBeenCalledWith('pending-2', 'challenge-2', '123456') + expect(mocks.executePendingOperation).toHaveBeenCalledWith('pending-2', 'token-2') + expect(onExecuted).toHaveBeenCalledWith(makeExecution('Done')) + expect(outcome).toMatchObject({ ok: true }) + }) + + it('does not execute when verification fails', async () => { + mocks.verifyPendingOperationStepUp.mockResolvedValue({ + ok: false, + error: 'wrong code', + status: 401, + }) + const onExecuted = vi.fn(async () => {}) + const { result } = renderHook(() => useChatPendingOperations(onExecuted)) + + let outcome: Awaited> | null = null + await act(async () => { + outcome = await result.current.verifyStepUpForBubble('pending-2', 'challenge-2', '000000', 'token-2') + }) + + expect(mocks.executePendingOperation).not.toHaveBeenCalled() + expect(onExecuted).not.toHaveBeenCalled() + expect(outcome).toMatchObject({ ok: false, error: 'chat.sendError' }) + }) +}) diff --git a/apps/web/__tests__/hooks/use-drill-navigation.test.ts b/apps/web/__tests__/hooks/use-drill-navigation.test.ts index 3bb1985d2b..759331e01d 100644 --- a/apps/web/__tests__/hooks/use-drill-navigation.test.ts +++ b/apps/web/__tests__/hooks/use-drill-navigation.test.ts @@ -13,6 +13,11 @@ const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) import { useDrillNavigation } from '@/hooks/use-drill-navigation' +import { registerOverlay, unregisterOverlay } from '@/lib/overlay-stack' + +function pressEscape() { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) +} function makeHabit(overrides: Partial = {}): NormalizedHabit { return { @@ -195,6 +200,103 @@ describe('useDrillNavigation', () => { expect(result.current.drillError).toBeTruthy() }) + it('pops one drill level on Escape (mirrors mobile hardware back)', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(makeDetailResponse()), + }) + + const { result } = renderHook(() => useDrillNavigation(habitsById, 0)) + + await act(async () => { + await result.current.drillInto('parent1') + }) + expect(result.current.currentParentId).toBe('parent1') + + act(() => { + pressEscape() + }) + + expect(result.current.drillStack).toEqual([]) + expect(result.current.currentParentId).toBeNull() + }) + + it('does not pop on Escape while an overlay is open (overlay wins LIFO)', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(makeDetailResponse()), + }) + + const { result } = renderHook(() => useDrillNavigation(habitsById, 0)) + + await act(async () => { + await result.current.drillInto('parent1') + }) + + registerOverlay({ id: 'overlay-1', dismiss: () => {} }) + try { + act(() => { + pressEscape() + }) + expect(result.current.currentParentId).toBe('parent1') + } finally { + unregisterOverlay('overlay-1') + } + }) + + it('does not pop on Escape while focus is inside an open menu/popover', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(makeDetailResponse()), + }) + + const { result } = renderHook(() => useDrillNavigation(habitsById, 0)) + + await act(async () => { + await result.current.drillInto('parent1') + }) + + const menu = document.createElement('div') + menu.setAttribute('role', 'menu') + const item = document.createElement('button') + menu.appendChild(item) + document.body.appendChild(menu) + item.focus() + try { + act(() => { + item.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + expect(result.current.currentParentId).toBe('parent1') + } finally { + menu.remove() + } + }) + + it('does not pop on Escape while typing in a text field', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(makeDetailResponse()), + }) + + const { result } = renderHook(() => useDrillNavigation(habitsById, 0)) + + await act(async () => { + await result.current.drillInto('parent1') + }) + + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + try { + act(() => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + expect(result.current.currentParentId).toBe('parent1') + } finally { + input.remove() + } + }) + it('refreshes the current drill parent on demand', async () => { mockFetch.mockResolvedValue({ ok: true, diff --git a/apps/web/__tests__/hooks/use-habits.test.ts b/apps/web/__tests__/hooks/use-habits.test.ts index 3c1dc5e677..0bbae02057 100644 --- a/apps/web/__tests__/hooks/use-habits.test.ts +++ b/apps/web/__tests__/hooks/use-habits.test.ts @@ -284,6 +284,31 @@ describe('useLogHabit', () => { expect(mockedLogHabit).toHaveBeenCalledWith('h-1', undefined) }) + it('invalidates lists, summary, goals, gamification, and profile on settle (parity with mobile)', async () => { + const { logHabit } = await import('@/app/actions/habits') + vi.mocked(logHabit).mockResolvedValue({ + logId: 'log-x', + isFirstCompletionToday: false, + currentStreak: 1, + }) + + const queryClient = createQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + const { result } = renderHook(() => useLogHabit(), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync({ habitId: 'h-1' }) + }) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: habitKeys.lists() }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: habitKeys.summaryPrefix() }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: goalKeys.lists() }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: gamificationKeys.all }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: profileKeys.all }) + }) + it('passes date to logHabit action', async () => { const { logHabit } = await import('@/app/actions/habits') const mockedLogHabit = vi.mocked(logHabit) @@ -367,6 +392,27 @@ describe('useSkipHabit', () => { expect(mockedSkipHabit).toHaveBeenCalledWith('h-1', undefined) }) + it('invalidates lists, summary, goals, gamification, and profile on settle (parity with mobile)', async () => { + const { skipHabit } = await import('@/app/actions/habits') + vi.mocked(skipHabit).mockResolvedValue(undefined) + + const queryClient = createQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + const { result } = renderHook(() => useSkipHabit(), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync({ habitId: 'h-1' }) + }) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: habitKeys.lists() }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: habitKeys.summaryPrefix() }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: goalKeys.lists() }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: gamificationKeys.all }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: profileKeys.all }) + }) + it('passes date to skipHabit action', async () => { const { skipHabit } = await import('@/app/actions/habits') const mockedSkipHabit = vi.mocked(skipHabit) diff --git a/apps/web/__tests__/hooks/use-login-code-entry.test.ts b/apps/web/__tests__/hooks/use-login-code-entry.test.ts index 0cc9242dc6..8701c3248b 100644 --- a/apps/web/__tests__/hooks/use-login-code-entry.test.ts +++ b/apps/web/__tests__/hooks/use-login-code-entry.test.ts @@ -36,7 +36,7 @@ describe('useLoginCodeEntry', () => { expect(nextFocus).toHaveBeenCalledTimes(1) }) - it('fills multiple digits from one input and completes asynchronously', () => { + it('fills multiple digits from one input and auto-submits once complete', () => { const onComplete = vi.fn() const { result } = renderHook(() => useLoginCodeEntry(onComplete)) const lastFocus = attachFocusableRef( @@ -50,12 +50,36 @@ describe('useLoginCodeEntry', () => { expect(result.current.codeDigits).toEqual(['1', '2', '3', '4', '5', '6']) expect(lastFocus).toHaveBeenCalledTimes(1) + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete).toHaveBeenCalledWith('123456') + }) + + it('auto-submits when the final digit is typed manually', () => { + const onComplete = vi.fn() + const { result } = renderHook(() => useLoginCodeEntry(onComplete)) + + act(() => { + result.current.setCodeDigits(['1', '2', '3', '4', '5', '']) + }) expect(onComplete).not.toHaveBeenCalled() act(() => { - vi.runAllTimers() + result.current.onCodeInput(5, '6') + }) + + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete).toHaveBeenCalledWith('123456') + }) + + it('auto-submits when a complete code is set from a deep link', () => { + const onComplete = vi.fn() + const { result } = renderHook(() => useLoginCodeEntry(onComplete)) + + act(() => { + result.current.setCodeDigits('123456'.split('')) }) + expect(onComplete).toHaveBeenCalledTimes(1) expect(onComplete).toHaveBeenCalledWith('123456') }) @@ -80,12 +104,22 @@ describe('useLoginCodeEntry', () => { expect(preventDefault).toHaveBeenCalledTimes(1) expect(result.current.codeDigits).toEqual(['9', '8', '7', '6', '5', '4']) expect(lastFocus).toHaveBeenCalledTimes(1) + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete).toHaveBeenCalledWith('987654') + }) + it('does not double-submit the same complete code', () => { + const onComplete = vi.fn() + const { result } = renderHook(() => useLoginCodeEntry(onComplete)) + + act(() => { + result.current.onCodeInput(0, '123456') + }) act(() => { - vi.runAllTimers() + result.current.onCodeInput(5, '6') }) - expect(onComplete).toHaveBeenCalledWith('987654') + expect(onComplete).toHaveBeenCalledTimes(1) }) it('moves focus backward on backspace and resets digits', () => { diff --git a/apps/web/__tests__/hooks/use-overlay-escape.test.tsx b/apps/web/__tests__/hooks/use-overlay-escape.test.tsx new file mode 100644 index 0000000000..7f80e2cbbe --- /dev/null +++ b/apps/web/__tests__/hooks/use-overlay-escape.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, act, cleanup } from '@testing-library/react' +import { useOverlayEscape } from '@/hooks/use-overlay-escape' +import { isTopOverlay } from '@/lib/overlay-stack' + +function Layer({ + open, + onDismiss, + id, +}: Readonly<{ open: boolean; onDismiss: (reason: string) => void; id: string }>) { + useOverlayEscape({ open, onDismiss, restoreFocus: false }) + return open ?
: null +} + +function pressEscape() { + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) +} + +describe('useOverlayescape', () => { + afterEach(cleanup) + + it('resolves Escape to the most recently opened layer (LIFO)', () => { + const first = vi.fn() + const second = vi.fn() + + const view = render( + <> + + + , + ) + + pressEscape() + expect(second).toHaveBeenCalledWith('escape') + expect(first).not.toHaveBeenCalled() + + view.rerender( + <> + + + , + ) + + pressEscape() + expect(first).toHaveBeenCalledWith('escape') + expect(first).toHaveBeenCalledTimes(1) + }) + + it('unregisters from the overlay stack on unmount', () => { + const view = render() + view.unmount() + expect(isTopOverlay).toBeDefined() + expect(document.querySelector('[data-testid="solo"]')).toBeNull() + }) + + it('does not fire when the layer is closed', () => { + const onDismiss = vi.fn() + render() + pressEscape() + expect(onDismiss).not.toHaveBeenCalled() + }) + + it('restores focus to the previously focused element on close', () => { + const trigger = document.createElement('button') + document.body.appendChild(trigger) + trigger.focus() + expect(document.activeElement).toBe(trigger) + + function Restoring({ open }: Readonly<{ open: boolean }>) { + useOverlayEscape({ open, onDismiss: vi.fn(), restoreFocus: true }) + return open ? : null + } + + const view = render() + view.rerender() + + expect(document.activeElement).toBe(trigger) + document.body.removeChild(trigger) + }) +}) diff --git a/apps/web/__tests__/hooks/use-profile.test.ts b/apps/web/__tests__/hooks/use-profile.test.ts index 860024da69..87ea7d671e 100644 --- a/apps/web/__tests__/hooks/use-profile.test.ts +++ b/apps/web/__tests__/hooks/use-profile.test.ts @@ -9,6 +9,10 @@ import type { Profile } from '@orbit/shared/types/profile' const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) +vi.mock('next-intl', () => ({ + useLocale: () => 'en', +})) + vi.mock('@/app/actions/profile', () => ({ updateThemePreference: vi.fn().mockResolvedValue(undefined), updateColorScheme: vi.fn().mockResolvedValue(undefined), diff --git a/apps/web/__tests__/hooks/use-push-notification-preferences.test.ts b/apps/web/__tests__/hooks/use-push-notification-preferences.test.ts index a17346a009..de2a903326 100644 --- a/apps/web/__tests__/hooks/use-push-notification-preferences.test.ts +++ b/apps/web/__tests__/hooks/use-push-notification-preferences.test.ts @@ -214,7 +214,7 @@ describe('use-push-notification-preferences helpers', () => { }) mockSubscribePush.mockRejectedValue(new Error('Server error')) - await expect(subscribeToPushNotifications()).rejects.toThrow('Failed to subscribe to push notifications') + await expect(subscribeToPushNotifications()).rejects.toThrow() expect(subscription.unsubscribe).toHaveBeenCalledTimes(1) }) diff --git a/apps/web/__tests__/pages/advanced.test.tsx b/apps/web/__tests__/pages/advanced.test.tsx index 82a48a06f7..5df35e4bc9 100644 --- a/apps/web/__tests__/pages/advanced.test.tsx +++ b/apps/web/__tests__/pages/advanced.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, waitFor } from '@testing-library/react' vi.mock('next-intl', () => ({ @@ -37,7 +37,7 @@ let mockCapabilitiesError: Error | null = null vi.mock('@tanstack/react-query', () => ({ useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - if (Array.isArray(queryKey) && queryKey[0] === 'ai-capabilities') { + if (Array.isArray(queryKey) && queryKey[0] === 'ai' && queryKey[1] === 'capabilities') { return { data: mockCapabilitiesLoading ? undefined : mockCapabilities, isLoading: mockCapabilitiesLoading, @@ -70,6 +70,9 @@ vi.mock('@orbit/shared/query', () => ({ lists: () => ['api-keys', 'list'], all: ['api-keys'], }, + aiKeys: { + capabilities: () => ['ai', 'capabilities'], + }, })) vi.mock('@orbit/shared/api', () => ({ @@ -260,14 +263,16 @@ describe('AdvancedPage', () => { expect(screen.getByText('orbitMcp.confirm')).toBeInTheDocument() }) - it('cancels revoke confirmation', () => { + it('cancels revoke confirmation', async () => { mockApiKeys = [ { id: 'k1', name: 'Key 1', keyPrefix: 'orbit_sk_1', createdAtUtc: '2025-01-01T00:00:00Z', lastUsedAtUtc: null }, ] render() fireEvent.click(screen.getByText('orbitMcp.revoke')) fireEvent.click(screen.getByText('orbitMcp.cancel')) - expect(screen.queryByText('orbitMcp.revokeConfirm')).not.toBeInTheDocument() + await waitFor(() => + expect(screen.queryByText('orbitMcp.revokeConfirm')).not.toBeInTheDocument(), + ) }) it('shows max keys warning when at limit', () => { diff --git a/apps/web/__tests__/pages/calendar-sync.test.tsx b/apps/web/__tests__/pages/calendar-sync.test.tsx index 4c8a5e9854..ff0b9b7ebe 100644 --- a/apps/web/__tests__/pages/calendar-sync.test.tsx +++ b/apps/web/__tests__/pages/calendar-sync.test.tsx @@ -268,7 +268,7 @@ describe('CalendarSyncPage', () => { it('renders the page header with title and back button', () => { render() - expect(screen.getByText('calendar.title')).toBeInTheDocument() + expect(screen.getAllByText('calendar.title').length).toBeGreaterThan(0) expect(screen.getByRole('button', { name: 'common.backToProfile' })).toBeInTheDocument() }) diff --git a/apps/web/app/(app)/achievements/_components/achievement-category-section.tsx b/apps/web/app/(app)/achievements/_components/achievement-category-section.tsx index f02020a5e6..590a56ab9a 100644 --- a/apps/web/app/(app)/achievements/_components/achievement-category-section.tsx +++ b/apps/web/app/(app)/achievements/_components/achievement-category-section.tsx @@ -1,5 +1,3 @@ -'use client' - import type { Achievement } from '@orbit/shared/types/gamification' import { achievementEmoji } from '@orbit/shared/utils' import { SectionLabel } from '@/components/ui/section-label' diff --git a/apps/web/app/(app)/achievements/_components/achievement-xp-card.tsx b/apps/web/app/(app)/achievements/_components/achievement-xp-card.tsx new file mode 100644 index 0000000000..f86e7d18db --- /dev/null +++ b/apps/web/app/(app)/achievements/_components/achievement-xp-card.tsx @@ -0,0 +1,106 @@ +'use client' + +import { useTranslations } from 'next-intl' +import type { GamificationProfile } from '@orbit/shared/types/gamification' +import { ProgressBar } from '@/components/ui/progress-bar' + +interface AchievementXpCardProps { + profile: GamificationProfile + xpProgress: number + formatNum: (n: number) => string +} + +export function AchievementXpCard({ + profile, + xpProgress, + formatNum, +}: Readonly) { + const t = useTranslations() + + return ( +
+
+
+ + {t('gamification.profileCard.level', { level: profile.level })} + +
+
+ {profile.levelTitle} +
+
+ {t('gamification.profileCard.xp', { + current: formatNum(profile.totalXp), + next: formatNum(profile.xpForNextLevel), + })} +
+
+
+ + + +
+ + {t('gamification.profileCard.totalXp', { + total: formatNum(profile.totalXp), + })} + + + {t('gamification.profileCard.earned', { + count: profile.achievementsEarned, + total: profile.achievementsTotal, + })} + +
+
+
+ ) +} diff --git a/apps/web/app/(app)/achievements/_components/achievements-locked-state.tsx b/apps/web/app/(app)/achievements/_components/achievements-locked-state.tsx new file mode 100644 index 0000000000..c76c379c28 --- /dev/null +++ b/apps/web/app/(app)/achievements/_components/achievements-locked-state.tsx @@ -0,0 +1,59 @@ +'use client' + +import Link from 'next/link' +import { Lock } from 'lucide-react' +import { useTranslations } from 'next-intl' + +export function AchievementsLockedState() { + const t = useTranslations() + + return ( +
+ + + {t('gamification.page.lockedTitle')} + + + {t('gamification.page.lockedDescription')} + + + {t('gamification.page.upgradeButton')} + +
+ ) +} diff --git a/apps/web/app/(app)/achievements/page.tsx b/apps/web/app/(app)/achievements/page.tsx index 0f0b14176d..44564c3d21 100644 --- a/apps/web/app/(app)/achievements/page.tsx +++ b/apps/web/app/(app)/achievements/page.tsx @@ -1,15 +1,15 @@ 'use client' import { useEffect } from 'react' -import Link from 'next/link' import { useRouter } from 'next/navigation' import { useTranslations, useLocale } from 'next-intl' import { useProfile, useHasProAccess } from '@/hooks/use-profile' import { useGamificationProfile } from '@/hooks/use-gamification' import { AchievementCategorySection } from './_components/achievement-category-section' +import { AchievementXpCard } from './_components/achievement-xp-card' +import { AchievementsLockedState } from './_components/achievements-locked-state' import { AppBar } from '@/components/ui/app-bar' import { ProBadge } from '@/components/ui/pro-badge' -import { ProgressBar } from '@/components/ui/progress-bar' import { useGoBackOrFallback } from '@/hooks/use-go-back-or-fallback' export default function AchievementsPage() { @@ -50,142 +50,24 @@ export default function AchievementsPage() {
{!profileLoading && !hasProAccess ? ( -
- - {t('gamification.page.lockedTitle')} - - - {t('gamification.page.lockedDescription')} - - - {t('gamification.page.upgradeButton')} - -
+ ) : ( <> {isLoading && !profile && (
-
-
-
+
+
+
)} {profile && ( <> -
-
-
- - {t('gamification.profileCard.level', { level: profile.level })} - -
-
- {profile.levelTitle} -
-
- {t('gamification.profileCard.xp', { - current: formatNum(profile.totalXp), - next: formatNum(profile.xpForNextLevel), - })} -
-
-
- - - -
- - {t('gamification.profileCard.totalXp', { - total: formatNum(profile.totalXp), - })} - - - {t('gamification.profileCard.earned', { - count: profile.achievementsEarned, - total: profile.achievementsTotal, - })} - -
-
-
+ {achievementsByCategory.map((category) => ( { const res = await fetch(API.apiKeys.list) - if (!res.ok) return [] + if (!res.ok) { + throw new Error('Failed to load API keys') + } return res.json() } @@ -113,7 +116,7 @@ function CopyIconButton({ function SubsectionTitle({ children }: Readonly<{ children: React.ReactNode }>) { return ( -

) }} > {children} -

+ ) } @@ -145,7 +148,7 @@ export default function AdvancedPage() { }) const capabilitiesQuery = useQuery({ - queryKey: ['ai-capabilities'], + queryKey: aiKeys.capabilities(), queryFn: fetchCapabilities, enabled: profile?.hasProAccess ?? false, staleTime: 5 * 60 * 1000, @@ -268,7 +271,7 @@ export default function AdvancedPage() { - + }>{t('orbitMcp.title')} @@ -321,7 +324,7 @@ export default function AdvancedPage() { fontFamily: 'var(--font-sans)', fontSize: 13, fontWeight: 500, - color: 'var(--status-overdue)', + color: 'var(--status-overdue-text)', }} > {t('orbitMcp.maxKeysReached')} @@ -407,7 +410,7 @@ export default function AdvancedPage() { fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.02em', - color: 'var(--fg-4)', + color: 'var(--fg-3)', }} > {scopes.length > 0 ? scopes.join(', ') : t('orbitMcp.noScopes')} @@ -420,7 +423,7 @@ export default function AdvancedPage() { fontSize: 11, lineHeight: 1.6, letterSpacing: '0.02em', - color: 'var(--fg-4)', + color: 'var(--fg-3)', fontVariantNumeric: 'tabular-nums', }} > @@ -438,48 +441,16 @@ export default function AdvancedPage() {
- {revokingKeyId === key.id ? ( -
+ - -
-
- ) : ( -
- -
- )} + {t('orbitMcp.revoke')} + +
) })} @@ -553,7 +524,7 @@ export default function AdvancedPage() { /> } /> -

+

{t('orbitMcp.webNoApiKey')}

@@ -574,7 +545,7 @@ export default function AdvancedPage() { /> } /> -

+

{t('orbitMcp.replaceKey')}

@@ -664,6 +635,21 @@ export default function AdvancedPage() { availableScopes={scopeOptions} apiError={createKeyError} /> + + { + if (!open) setRevokingKeyId(null) + }} + title={t('orbitMcp.revoke')} + description={t('orbitMcp.revokeConfirm')} + cancelLabel={t('orbitMcp.cancel')} + confirmLabel={t('orbitMcp.confirm')} + variant="danger" + onConfirm={() => { + if (revokingKeyId) revokeKeyMutation.mutate(revokingKeyId) + }} + />
) } diff --git a/apps/web/app/(app)/ai-settings/_components/ai-feature-toggles.tsx b/apps/web/app/(app)/ai-settings/_components/ai-feature-toggles.tsx new file mode 100644 index 0000000000..c771e8bf6c --- /dev/null +++ b/apps/web/app/(app)/ai-settings/_components/ai-feature-toggles.tsx @@ -0,0 +1,74 @@ +'use client' + +import { Brain, Satellite } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { SectionLabel } from '@/components/ui/section-label' +import { SettingsRow, Switch } from '@/components/ui/settings-row' +import { ProBadge } from '@/components/ui/pro-badge' +import { ProUpgradeLink } from './pro-upgrade-link' + +interface AiFeatureTogglesProps { + hasProAccess: boolean + aiMemoryEnabled: boolean + aiSummaryEnabled: boolean + memoryPending: boolean + summaryPending: boolean + onToggleMemory: () => void + onToggleSummary: () => void +} + +export function AiFeatureToggles({ + hasProAccess, + aiMemoryEnabled, + aiSummaryEnabled, + memoryPending, + summaryPending, + onToggleMemory, + onToggleSummary, +}: Readonly) { + const t = useTranslations() + + return ( + <> + }> + {t('profile.sections.aiFeatures')} + + + {hasProAccess ? ( + + ) : ( + + )} + + + {hasProAccess ? ( + + ) : ( + + )} + + + ) +} diff --git a/apps/web/app/(app)/ai-settings/_components/fact-item.tsx b/apps/web/app/(app)/ai-settings/_components/fact-item.tsx new file mode 100644 index 0000000000..da0f760472 --- /dev/null +++ b/apps/web/app/(app)/ai-settings/_components/fact-item.tsx @@ -0,0 +1,107 @@ +'use client' + +import { X } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { normalizeUserFactCategory } from '@orbit/shared/utils' +import { RadioGlyph } from '@/components/ui/select-check' +import type { UserFact } from '@orbit/shared/types/user-fact' + +interface FactItemProps { + fact: UserFact + selectMode: boolean + isSelected: boolean + onToggleSelection: () => void + onDelete: () => void +} + +export function FactItem({ + fact, + selectMode, + isSelected, + onToggleSelection, + onDelete, +}: Readonly) { + const t = useTranslations() + const category = fact.category ? normalizeUserFactCategory(fact.category) : null + const categoryLabel = category + ? t(`profile.facts.${category}` as Parameters[0]).toUpperCase() + : null + + const cardStyle: React.CSSProperties = { + padding: '14px 16px', + gap: 12, + borderRadius: 16, + background: isSelected + ? 'rgba(var(--primary-rgb), 0.08)' + : 'var(--bg-card)', + boxShadow: isSelected + ? 'inset 0 0 0 1px rgba(var(--primary-rgb), 0.28)' + : 'inset 0 0 0 1px var(--hairline)', + } + + const Inner = ( + <> + {selectMode && } +
+ {categoryLabel && ( + + {categoryLabel} + + )} + + {fact.factText} + +
+ {!selectMode && ( + + )} + + ) + + if (selectMode) { + return ( + + ) + } + + return ( +
+ {Inner} +
+ ) +} diff --git a/apps/web/app/(app)/ai-settings/_components/facts-pagination.tsx b/apps/web/app/(app)/ai-settings/_components/facts-pagination.tsx new file mode 100644 index 0000000000..19f142a7de --- /dev/null +++ b/apps/web/app/(app)/ai-settings/_components/facts-pagination.tsx @@ -0,0 +1,55 @@ +'use client' + +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useTranslations } from 'next-intl' + +interface FactsPaginationProps { + page: number + totalPages: number + onPrevious: () => void + onNext: () => void +} + +export function FactsPagination({ + page, + totalPages, + onPrevious, + onNext, +}: Readonly) { + const t = useTranslations() + + return ( + + {t('profile.facts.count', { n: page, max: totalPages })} + + + + ) +} diff --git a/apps/web/app/(app)/ai-settings/_components/facts-select-bar.tsx b/apps/web/app/(app)/ai-settings/_components/facts-select-bar.tsx new file mode 100644 index 0000000000..656dedec2b --- /dev/null +++ b/apps/web/app/(app)/ai-settings/_components/facts-select-bar.tsx @@ -0,0 +1,93 @@ +'use client' + +import { useTranslations } from 'next-intl' + +function ChipActionButton({ + onClick, + disabled = false, + destructive = false, + children, +}: Readonly<{ + onClick: () => void + disabled?: boolean + destructive?: boolean + children: React.ReactNode +}>) { + return ( + + ) +} + +interface FactsSelectBarProps { + factCount: number + selectMode: boolean + selectedCount: number + allSelected: boolean + bulkDeletePending: boolean + onToggleSelectAll: () => void + onBulkDelete: () => void + onToggleSelectMode: () => void +} + +export function FactsSelectBar({ + factCount, + selectMode, + selectedCount, + allSelected, + bulkDeletePending, + onToggleSelectAll, + onBulkDelete, + onToggleSelectMode, +}: Readonly) { + const t = useTranslations() + + return ( +
+ + {selectMode + ? `${selectedCount} ${t('profile.facts.select').toLowerCase()}` + : `${factCount}`} + +
+ {selectMode && ( + + {allSelected + ? t('profile.facts.deselectAll') + : t('profile.facts.selectAll')} + + )} + {selectMode && selectedCount > 0 && ( + + {t('profile.facts.deleteSelected', { n: selectedCount })} + + )} + + {selectMode ? t('profile.facts.cancel') : t('profile.facts.select')} + +
+
+ ) +} diff --git a/apps/web/app/(app)/ai-settings/_components/pro-upgrade-link.tsx b/apps/web/app/(app)/ai-settings/_components/pro-upgrade-link.tsx new file mode 100644 index 0000000000..5a50b7ae23 --- /dev/null +++ b/apps/web/app/(app)/ai-settings/_components/pro-upgrade-link.tsx @@ -0,0 +1,13 @@ +'use client' + +import Link from 'next/link' +import { Lock } from 'lucide-react' + +export function ProUpgradeLink({ label }: Readonly<{ label: string }>) { + return ( + +