Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ vi.mock('@/hooks/use-habits', () => ({
useCreateSubHabit: () => ({ mutateAsync: vi.fn(), isPending: false }),
}))

vi.mock('@/hooks/use-habit-suggestion', () => ({
useHabitSuggestion: () => ({ mutateAsync: vi.fn(), isPending: false }),
}))

vi.mock('@/hooks/use-profile', () => ({
useProfile: () => ({ profile: { hasProAccess: mockHasProAccess } }),
}))

vi.mock('@/hooks/use-app-toast', () => ({
useAppToast: () => ({ showError: vi.fn() }),
useAppToast: () => ({ showError: vi.fn(), showSuccess: vi.fn(), showInfo: vi.fn() }),
}))

vi.mock('@/stores/ui-store', () => ({
Expand Down
117 changes: 117 additions & 0 deletions apps/mobile/__tests__/hooks/use-habit-suggestion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { API } from '@orbit/shared/api'
import { profileKeys, subscriptionKeys } from '@orbit/shared/query'
import { useHabitSuggestion } from '@/hooks/use-habit-suggestion'

const TestRenderer = require('react-test-renderer')

const mocks = vi.hoisted(() => {
const captured = {
mutationArgs: null as Record<string, unknown> | null,
}
const queryClient = {
invalidateQueries: vi.fn(async () => {}),
}
return {
captured,
queryClient,
useMutation: vi.fn((args: Record<string, unknown>) => {
captured.mutationArgs = args
return { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }
}),
useQueryClient: vi.fn(() => queryClient),
apiClient: vi.fn(),
}
})

vi.mock('@tanstack/react-query', () => ({
useMutation: mocks.useMutation,
useQueryClient: mocks.useQueryClient,
}))

vi.mock('@/lib/api-client', () => ({
apiClient: mocks.apiClient,
}))

function renderHook(hook: () => unknown) {
function Harness() {
hook()
return null
}
return TestRenderer.act(() => {
TestRenderer.create(<Harness />)
return Promise.resolve()
})
}

const validSuggestion = {
emoji: '🏃',
frequencyUnit: 'Day',
frequencyQuantity: 1,
days: ['Monday'],
subHabits: ['Warm up'],
}

describe('mobile useHabitSuggestion', () => {
beforeEach(() => {
mocks.captured.mutationArgs = null
mocks.useMutation.mockClear()
mocks.useQueryClient.mockClear()
mocks.apiClient.mockReset()
mocks.queryClient.invalidateQueries.mockClear()
})

it('mutationFn POSTs to the suggest-setup endpoint and returns the parsed suggestion', async () => {
await renderHook(() => useHabitSuggestion())
const mutationFn = mocks.captured.mutationArgs?.mutationFn as (
data: { title: string; language?: string },
) => Promise<typeof validSuggestion>

mocks.apiClient.mockResolvedValue(validSuggestion)
const result = await mutationFn({ title: 'Run', language: 'en' })

expect(mocks.apiClient).toHaveBeenCalledWith(API.habits.suggestSetup, {
method: 'POST',
body: JSON.stringify({ title: 'Run', language: 'en' }),
})
expect(result.emoji).toBe('🏃')
expect(result.frequencyUnit).toBe('Day')
})

it('invalidates the allowance queries on success', async () => {
await renderHook(() => useHabitSuggestion())
const onSuccess = mocks.captured.mutationArgs?.onSuccess as () => void

onSuccess()

expect(mocks.queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: subscriptionKeys.status(),
})
expect(mocks.queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: profileKeys.detail(),
})
})

it('mutationFn rejects when the response fails schema validation', async () => {
await renderHook(() => useHabitSuggestion())
const mutationFn = mocks.captured.mutationArgs?.mutationFn as (
data: { title: string },
) => Promise<unknown>

mocks.apiClient.mockResolvedValue({ emoji: 123 })

await expect(mutationFn({ title: 'Run' })).rejects.toBeTruthy()
})

it('mutationFn propagates an apiClient error (e.g. a pay-gate rejection)', async () => {
await renderHook(() => useHabitSuggestion())
const mutationFn = mocks.captured.mutationArgs?.mutationFn as (
data: { title: string },
) => Promise<unknown>

mocks.apiClient.mockRejectedValue(new Error('limit reached'))

await expect(mutationFn({ title: 'Run' })).rejects.toThrow('limit reached')
})
})
61 changes: 59 additions & 2 deletions apps/mobile/components/habits/create-habit-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ import { useHabitForm } from '@/hooks/use-habit-form'
import { useProfile } from '@/hooks/use-profile'
import { useTagSelection } from '@/hooks/use-tag-selection'
import { useCreateHabit, useCreateSubHabit } from '@/hooks/use-habits'
import { useHabitSuggestion } from '@/hooks/use-habit-suggestion'
import {
applyHabitFormMode,
buildEmptyHabitFormValues,
buildHabitFormPatchFromSuggestion,
buildParentHabitFormState,
extractBackendErrorCode,
formatAPIDate,
getFriendlyErrorMessage,
resolveAutoManagedReminderEnabled,
Expand Down Expand Up @@ -55,7 +58,7 @@ export function CreateHabitModal({
initialDate,
parentHabit,
}: Readonly<CreateHabitModalProps>) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const router = useRouter()
const translate = useCallback(
(key: string, values?: Record<string, unknown>) => t(key, values),
Expand All @@ -71,7 +74,8 @@ export function CreateHabitModal({
const { profile } = useProfile()
const createHabit = useCreateHabit()
const createSubHabit = useCreateSubHabit()
const { showError } = useAppToast()
const suggestion = useHabitSuggestion()
const { showError, showSuccess, showInfo } = useAppToast()
const isSubHabitMode = !!parentHabit
const hasProAccess = profile?.hasProAccess ?? false
const activeView = useUIStore((s) => s.activeView)
Expand Down Expand Up @@ -300,6 +304,57 @@ export function CreateHabitModal({
translate,
])

const handleSuggest = useCallback(async () => {
flushBufferedInputsRef.current()
const title = formHelpers.form.getValues('title')?.trim() ?? ''
if (title.length === 0) return

try {
const patch = buildHabitFormPatchFromSuggestion(
await suggestion.mutateAsync({ title, language: i18n.language }),
)

if (patch.emoji) {
formHelpers.form.setValue('emoji', patch.emoji, { shouldDirty: true })
}

if (patch.mode === 'recurring') {
formHelpers.setRecurring()
if (patch.frequencyUnit) {
formHelpers.form.setValue('frequencyUnit', patch.frequencyUnit, { shouldDirty: true })
}
if (patch.frequencyQuantity) {
formHelpers.form.setValue('frequencyQuantity', patch.frequencyQuantity, { shouldDirty: true })
}
formHelpers.form.setValue('days', patch.days, { shouldDirty: true })
} else {
formHelpers.setOneTime()
}

const appliedSubHabits = hasProAccess && patch.subHabitTitles.length > 0
if (appliedSubHabits) {
setSubHabits((prev) => [
...prev.filter((entry) => entry.value.trim().length > 0),
...patch.subHabitTitles.map((subHabitTitle) => createSubHabitEntry(subHabitTitle)),
])
}

const appliedAnything =
patch.emoji !== null || patch.frequencyUnit !== null || patch.days.length > 0 || appliedSubHabits
if (appliedAnything) {
showSuccess(t('habits.form.aiSuggestApplied'))
} else {
showInfo(t('habits.form.aiSuggestEmpty'))
}
} catch (error: unknown) {
showError(
extractBackendErrorCode(error) === 'PAY_GATE'
? t('habits.form.aiSuggestLimitReached')
: t('habits.form.aiSuggestError'),
)
}
}, [formHelpers, hasProAccess, i18n.language, showError, showInfo, showSuccess, suggestion, t])

const isPending = createHabit.isPending || createSubHabit.isPending
const submitDisabled = isPending || watchedTitle.trim().length === 0

Expand Down Expand Up @@ -346,6 +401,8 @@ export function CreateHabitModal({
onReminderTimesChange={setReminderTimes}
onReminderEnabledChange={handleReminderEnabledChange}
onFlushBufferedInputsReady={handleBufferedInputsReady}
onSuggestSetup={isSubHabitMode ? undefined : handleSuggest}
isSuggesting={suggestion.isPending}
>
{!isSubHabitMode ? (
<SubHabitEditor
Expand Down
32 changes: 31 additions & 1 deletion apps/mobile/components/habits/habit-form-fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import {
useRef,
type ReactNode,
} from "react";
import { View } from "react-native";
import { ActivityIndicator, View } from "react-native";
import Animated, { FadeInDown, ReduceMotion } from "react-native-reanimated";
import { Sparkles } from "lucide-react-native";
import { useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { TagSelectionState } from "@/hooks/use-tag-selection";
import type { HabitFormHelpers } from "@/hooks/use-habit-form";
import { PillButton } from "@/components/ui/pill-button";
import { useAppToast } from "@/hooks/use-app-toast";
import { useHasProAccess } from "@/hooks/use-profile";
import { createTokensV2 } from '@/lib/theme';
Expand Down Expand Up @@ -41,6 +44,9 @@ interface HabitFormFieldsProps {
onFlushBufferedInputsReady?: (flush: () => void) => void;
/** When true, advanced fields are visible by default (used in edit modal) */
defaultExpanded?: boolean;
/** When provided, renders the "Suggest with AI" affordance that requests a setup for the title. */
onSuggestSetup?: () => void;
isSuggesting?: boolean;
children?: ReactNode;
}

Expand All @@ -55,8 +61,11 @@ export function HabitFormFields({
onReminderEnabledChange,
onFlushBufferedInputsReady,
defaultExpanded = false,
onSuggestSetup,
isSuggesting = false,
children,
}: Readonly<HabitFormFieldsProps>) {
const { t } = useTranslation();
const { currentScheme, currentTheme } = useAppTheme()
const tokens = useMemo(
() => createTokensV2(currentScheme, currentTheme),
Expand Down Expand Up @@ -98,6 +107,7 @@ export function HabitFormFields({
}, []);

const watchedEmoji = useWatch({ control: form.control, name: "emoji" }) ?? "";
const watchedTitle = useWatch({ control: form.control, name: "title" }) ?? "";

const handleReminderEnabledChange = useCallback(
(nextEnabled: boolean) => {
Expand Down Expand Up @@ -147,6 +157,26 @@ export function HabitFormFields({
styles={styles}
/>

{onSuggestSetup ? (
<PillButton
variant="ghost"
busy={isSuggesting}
disabled={isSuggesting || watchedTitle.trim().length === 0}
onPress={onSuggestSetup}
accessibilityLabel={t("habits.form.aiSuggest")}
style={{ alignSelf: "flex-start" }}
leading={
isSuggesting ? (
<ActivityIndicator size="small" color={tokens.fg1} />
) : (
<Sparkles size={16} color={tokens.fg1} strokeWidth={2} />
)
}
>
{isSuggesting ? t("habits.form.aiSuggesting") : t("habits.form.aiSuggest")}
</PillButton>
) : null}

<FrequencyTypeCards
isOneTime={isOneTime}
isGeneral={isGeneral}
Expand Down
33 changes: 33 additions & 0 deletions apps/mobile/hooks/use-habit-suggestion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { API } from '@orbit/shared/api'
import { profileKeys, subscriptionKeys } from '@orbit/shared/query'
import {
habitSetupSuggestionSchema,
type HabitSetupSuggestion,
type HabitSetupSuggestionRequest,
} from '@orbit/shared/types/habit'
import { apiClient } from '@/lib/api-client'

/**
* Requests an AI setup suggestion (emoji, schedule, sub-habit breakdown) for a habit title and
* parses the response. Called directly (never through the offline queue — a suggestion has no offline
* value), and consumes one AI message, so the subscription status and profile are invalidated to
* refresh the remaining-allowance UI.
*/
export function useHabitSuggestion() {
const queryClient = useQueryClient()

return useMutation<HabitSetupSuggestion, Error, HabitSetupSuggestionRequest>({
mutationFn: async (data) =>
habitSetupSuggestionSchema.parse(
await apiClient<HabitSetupSuggestion>(API.habits.suggestSetup, {
method: 'POST',
body: JSON.stringify(data),
}),
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: subscriptionKeys.status() })
queryClient.invalidateQueries({ queryKey: profileKeys.detail() })
},
})
}
Loading