{
+ const supabase = getSupabaseClient()
+ const redirectTo = `${globalThis.location.origin}/auth-callback`
+ sessionStorage.setItem('auth_return_url', '/calendar-sync')
+
+ await supabase.auth.signInWithOAuth({
+ provider: 'google',
+ options: {
+ redirectTo,
+ scopes: 'https://www.googleapis.com/auth/calendar.readonly',
+ queryParams: {
+ access_type: 'offline',
+ },
+ },
+ })
+}
+
export default function CalendarSyncPage() {
const t = useTranslations()
const router = useRouter()
@@ -215,23 +236,6 @@ export default function CalendarSyncPage() {
}
}
- async function connectGoogle() {
- const supabase = getSupabaseClient()
- const redirectTo = `${globalThis.location.origin}/auth-callback`
- sessionStorage.setItem('auth_return_url', '/calendar-sync')
-
- await supabase.auth.signInWithOAuth({
- provider: 'google',
- options: {
- redirectTo,
- scopes: 'https://www.googleapis.com/auth/calendar.readonly',
- queryParams: {
- access_type: 'offline',
- },
- },
- })
- }
-
return (
{/* Header */}
diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx
index f30ade6ee..032388395 100644
--- a/apps/web/app/(app)/page.tsx
+++ b/apps/web/app/(app)/page.tsx
@@ -120,6 +120,7 @@ function ControlsMenu({
top: `${position.top}px`,
}}
onClick={(e) => e.stopPropagation()}
+ onKeyDown={(e) => { if (e.key === 'Escape') onClose() }}
>
{billing.cancelAtPeriodEnd
- ? t('upgrade.billing.plan.canceledHint', { date: formatBillingDate(billing.currentPeriodEnd) })
- : t('upgrade.billing.plan.renewsOn', { date: formatBillingDate(billing.currentPeriodEnd) })}
+ ? t('upgrade.billing.plan.canceledHint', { date: formatBillingDate(billing.currentPeriodEnd, locale, dateFnsLocale) })
+ : t('upgrade.billing.plan.renewsOn', { date: formatBillingDate(billing.currentPeriodEnd, locale, dateFnsLocale) })}
{billing.amountPerPeriod > 0 && (
{' '}·{' '}{formatPrice(billing.amountPerPeriod, billing.currency)}
@@ -334,27 +375,7 @@ export default function UpgradePage() {
)}
- {/* Usage stats */}
-
-
{t('upgrade.billing.usage.title')}
-
-
- {t('upgrade.billing.usage.aiMessages')}
-
- {t('upgrade.billing.usage.aiMessagesOf', {
- used: profile?.aiMessagesUsed ?? 0,
- limit: profile?.aiMessagesLimit ?? 0,
- })}
-
-
-
-
-
+
{/* Invoice history */}
{billing.recentInvoices.length > 0 && (
@@ -368,22 +389,16 @@ export default function UpgradePage() {
- {formatBillingDate(invoice.date)}
+ {formatBillingDate(invoice.date, locale, dateFnsLocale)}
- {invoiceReasonLabel(invoice.billingReason)}
+ {invoiceReasonLabelFn(invoice.billingReason, t)}
- {invoiceStatusLabel(invoice.status)}
+ {invoiceStatusLabelFn(invoice.status, t)}
@@ -442,27 +457,7 @@ export default function UpgradePage() {
- {/* Usage stats */}
-
-
{t('upgrade.billing.usage.title')}
-
-
- {t('upgrade.billing.usage.aiMessages')}
-
- {t('upgrade.billing.usage.aiMessagesOf', {
- used: profile?.aiMessagesUsed ?? 0,
- limit: profile?.aiMessagesLimit ?? 0,
- })}
-
-
-
-
-
+
>
)}
@@ -740,28 +735,16 @@ export default function UpgradePage() {
{/* Free value */}
- {feat.type === 'boolean' ? (
- feat.freeEnabled ? (
-
- ) : (
-
- )
- ) : (
- {t(`upgrade.features.${feat.key}.free`)}
- )}
+ {feat.type === 'boolean'
+ ?
+ : {t(`upgrade.features.${feat.key}.free`)}}
{/* Pro value */}
- {feat.type === 'boolean' ? (
- feat.proEnabled ? (
-
- ) : (
-
- )
- ) : (
- {t(`upgrade.features.${feat.key}.pro`)}
- )}
+ {feat.type === 'boolean'
+ ?
+ : {t(`upgrade.features.${feat.key}.pro`)}}
))}
diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx
index 44ff8dd0c..dec8513a9 100644
--- a/apps/web/app/(auth)/login/page.tsx
+++ b/apps/web/app/(auth)/login/page.tsx
@@ -74,6 +74,173 @@ function extractFetchError(err: unknown): string | undefined {
return undefined
}
+function translateBackendError(error: string, t: ReturnType): string {
+ const key = BACKEND_ERROR_MAP[error]
+ return key ? t(key) : error
+}
+
+function extractError(err: unknown, t: ReturnType): string {
+ const backendError = extractFetchError(err)
+ return backendError ? translateBackendError(backendError, t) : t('auth.genericError')
+}
+
+// ---------------------------------------------------------------------------
+// Sub-components (S3776: extracted to reduce cognitive complexity)
+// ---------------------------------------------------------------------------
+
+function Spinner({ size = 4 }: Readonly<{ size?: number }>) {
+ return (
+
+ )
+}
+
+function GoogleIcon() {
+ return (
+
+ )
+}
+
+interface EmailStepProps {
+ email: string
+ onEmailChange: (email: string) => void
+ isSubmitting: boolean
+ isGoogleLoading: boolean
+ onSendCode: () => void
+ onSignInWithGoogle: () => void
+ t: ReturnType
+}
+
+function EmailStep({ email, onEmailChange, isSubmitting, isGoogleLoading, onSendCode, onSignInWithGoogle, t }: Readonly) {
+ return (
+ <>
+
+
+
+
+
{t('auth.orContinueWith')}
+
+
+
+
+ {isGoogleLoading ? : }
+ {t('auth.signInWithGoogle')}
+
+ >
+ )
+}
+
+interface CodeStepProps {
+ email: string
+ codeDigits: string[]
+ isSubmitting: boolean
+ canResend: boolean
+ resendCountdown: number
+ codeInputRefs: React.RefObject<(HTMLInputElement | null)[]>
+ onVerifyCode: () => void
+ onCodeInput: (index: number, value: string) => void
+ onCodeKeydown: (index: number, event: React.KeyboardEvent) => void
+ onCodePaste: (event: React.ClipboardEvent) => void
+ onBackToEmail: () => void
+ onResendCode: () => void
+ t: ReturnType
+}
+
+function CodeStep({
+ email, codeDigits, isSubmitting, canResend, resendCountdown,
+ codeInputRefs, onVerifyCode, onCodeInput, onCodeKeydown, onCodePaste,
+ onBackToEmail, onResendCode, t,
+}: Readonly) {
+ return (
+ <>
+
+ {t('auth.codeSentTo')}{' '}
+ {email}
+
+
+
+
+
+
+ {t('auth.changeEmail')}
+
+
+ {canResend ? t('auth.resendCode') : `${t('auth.resendCode')} (${resendCountdown}s)`}
+
+
+ >
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Main component
+// ---------------------------------------------------------------------------
+
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
@@ -81,16 +248,6 @@ export default function LoginPage() {
const locale = useLocale()
const { setAuth } = useAuthStore()
- function translateBackendError(error: string): string {
- const key = BACKEND_ERROR_MAP[error]
- return key ? t(key) : error
- }
-
- function extractError(err: unknown): string {
- const backendError = extractFetchError(err)
- return backendError ? translateBackendError(backendError) : t('auth.genericError')
- }
-
const [step, setStep] = useState<'email' | 'code'>('email')
const [email, setEmail] = useState('')
const [codeDigits, setCodeDigits] = useState(['', '', '', '', '', ''])
@@ -173,7 +330,7 @@ export default function LoginPage() {
setSuccessMessage(t('auth.codeSent'))
startResendCountdown()
} catch (err: unknown) {
- setErrorMessage(extractError(err))
+ setErrorMessage(extractError(err, t))
} finally {
setIsSubmitting(false)
}
@@ -218,7 +375,7 @@ export default function LoginPage() {
router.push(getReturnUrl())
} catch (err: unknown) {
- setErrorMessage(extractError(err))
+ setErrorMessage(extractError(err, t))
} finally {
setIsSubmitting(false)
}
@@ -245,7 +402,7 @@ export default function LoginPage() {
setSuccessMessage(t('auth.codeSent'))
startResendCountdown()
} catch (err: unknown) {
- setErrorMessage(extractError(err))
+ setErrorMessage(extractError(err, t))
} finally {
setIsSubmitting(false)
}
@@ -372,157 +529,31 @@ export default function LoginPage() {
)}
{step === 'email' ? (
- <>
- {/* Step 1: Email */}
-
-
- {/* OAuth divider */}
-
-
-
- {t('auth.orContinueWith')}
-
-
-
-
- {/* Google Sign-In */}
-
- {isGoogleLoading ? (
-
- ) : (
-
- )}
- {t('auth.signInWithGoogle')}
-
- >
+
) : (
- <>
- {/* Step 2: Code verification */}
-
- {t('auth.codeSentTo')}{' '}
- {email}
-
-
-
-
-
-
- {t('auth.changeEmail')}
-
-
- {canResend
- ? t('auth.resendCode')
- : `${t('auth.resendCode')} (${resendCountdown}s)`}
-
-
- >
+
)}
{/* Privacy & Terms */}
diff --git a/apps/web/components/chat/breakdown-suggestion.tsx b/apps/web/components/chat/breakdown-suggestion.tsx
index 6f10f695b..d8a3b7f73 100644
--- a/apps/web/components/chat/breakdown-suggestion.tsx
+++ b/apps/web/components/chat/breakdown-suggestion.tsx
@@ -31,6 +31,14 @@ interface BreakdownSuggestionProps {
onCancelled: () => void
}
+// ---------------------------------------------------------------------------
+// Rich text renderer (S6478: extracted outside component to avoid re-creation)
+// ---------------------------------------------------------------------------
+
+function RichBoldPrimary(chunks: React.ReactNode): React.ReactNode {
+ return {chunks}
+}
+
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -194,7 +202,7 @@ export function BreakdownSuggestion({
{t.rich('habits.breakdown.breakInto', {
- name: (chunks) => {chunks},
+ name: RichBoldPrimary,
})}
diff --git a/apps/web/components/habits/checklist-templates.tsx b/apps/web/components/habits/checklist-templates.tsx
index 03a5eae41..1d3e9eb02 100644
--- a/apps/web/components/habits/checklist-templates.tsx
+++ b/apps/web/components/habits/checklist-templates.tsx
@@ -18,7 +18,7 @@ interface ChecklistTemplate {
const STORAGE_KEY = 'orbit-checklist-templates'
function loadTemplates(): ChecklistTemplate[] {
- if (typeof globalThis === 'undefined' || typeof globalThis.localStorage === 'undefined') return []
+ if (typeof globalThis === 'undefined' || typeof globalThis.localStorage === 'undefined') return [] // NOSONAR - SSR guard
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as ChecklistTemplate[]) : []
diff --git a/apps/web/components/habits/create-habit-modal.tsx b/apps/web/components/habits/create-habit-modal.tsx
index 7cae80143..f1f6d2c2b 100644
--- a/apps/web/components/habits/create-habit-modal.tsx
+++ b/apps/web/components/habits/create-habit-modal.tsx
@@ -195,6 +195,14 @@ export function CreateHabitModal({
const isPending = createHabit.isPending || createSubHabit.isPending
+ const updateSubHabitValue = useCallback((id: string, value: string) => {
+ setSubHabits((prev) => prev.map((s) => s.id === id ? { ...s, value } : s))
+ }, [])
+
+ const removeSubHabit = useCallback((id: string) => {
+ setSubHabits((prev) => prev.filter((s) => s.id !== id))
+ }, [])
+
return (
{
- setSubHabits((prev) =>
- prev.map((s) => s.id === entry.id ? { ...s, value: e.target.value } : s)
- )
- }}
+ onChange={(e) => updateSubHabitValue(entry.id, e.target.value)}
/>
- setSubHabits((prev) => prev.filter((s) => s.id !== entry.id))
- }
+ onClick={() => removeSubHabit(entry.id)}
>
diff --git a/apps/web/components/habits/habit-calendar.tsx b/apps/web/components/habits/habit-calendar.tsx
index 729db7339..d4fc8fdba 100644
--- a/apps/web/components/habits/habit-calendar.tsx
+++ b/apps/web/components/habits/habit-calendar.tsx
@@ -195,7 +195,7 @@ export function HabitCalendar({ habitId, logs: externalLogs }: Readonly 0 ? { marginLeft: `${depth * 1.5}rem` } : undefined
- const articleClassName = buildArticleClassName(
+ const articleClassName = buildArticleClassName({
isChild, status, isDoneForRange, isNotDueToday,
showActionsMenu, isSelected, justCompleted, justCreated,
- )
+ })
return (
<>
void
-}) {
+}>) {
const Icon = item.icon
return (
diff --git a/apps/web/components/navigation/notification-bell.tsx b/apps/web/components/navigation/notification-bell.tsx
index 5d3f2843e..2c0a7e590 100644
--- a/apps/web/components/navigation/notification-bell.tsx
+++ b/apps/web/components/navigation/notification-bell.tsx
@@ -119,17 +119,19 @@ export function NotificationBell() {
className="flex-1 overflow-y-auto list-none m-0 p-0"
aria-label={t('notifications.title')}
>
- {isLoading && notifications.length === 0 ? (
+ {isLoading && notifications.length === 0 && (
- ) : notifications.length === 0 ? (
+ )}
+ {!isLoading && notifications.length === 0 && (
{t('notifications.empty')}
- ) : (
+ )}
+ {notifications.length > 0 && (
notifications.map((item) => (
e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
>
{/* Drag handle (mobile) */}
diff --git a/apps/web/components/ui/create-api-key-modal.tsx b/apps/web/components/ui/create-api-key-modal.tsx
index 32bcf2574..1e3d73abd 100644
--- a/apps/web/components/ui/create-api-key-modal.tsx
+++ b/apps/web/components/ui/create-api-key-modal.tsx
@@ -103,39 +103,7 @@ export function CreateApiKeyModal({
dismissible={!isRevealState}
>
{/* Create Form */}
- {!isRevealState ? (
-
- ) : (
+ {isRevealState ? (
{/* Warning */}
@@ -179,6 +147,38 @@ export function CreateApiKeyModal({
{t('orbitMcp.done')}
+ ) : (
+
)}
)
diff --git a/apps/web/hooks/use-gamification.ts b/apps/web/hooks/use-gamification.ts
index c9e2971be..890be3b0b 100644
--- a/apps/web/hooks/use-gamification.ts
+++ b/apps/web/hooks/use-gamification.ts
@@ -6,8 +6,7 @@ import {
useMutation,
useQueryClient,
} from '@tanstack/react-query'
-import { gamificationKeys, profileKeys } from '@orbit/shared/query'
-import { QUERY_STALE_TIMES } from '@orbit/shared/query'
+import { gamificationKeys, profileKeys, QUERY_STALE_TIMES } from '@orbit/shared/query'
import { API } from '@orbit/shared/api'
import type {
GamificationProfile,
diff --git a/apps/web/hooks/use-goals.ts b/apps/web/hooks/use-goals.ts
index dc7ecaf9a..60e912405 100644
--- a/apps/web/hooks/use-goals.ts
+++ b/apps/web/hooks/use-goals.ts
@@ -5,8 +5,7 @@ import {
useMutation,
useQueryClient,
} from '@tanstack/react-query'
-import { goalKeys, habitKeys } from '@orbit/shared/query'
-import { QUERY_STALE_TIMES } from '@orbit/shared/query'
+import { goalKeys, habitKeys, QUERY_STALE_TIMES } from '@orbit/shared/query'
import { API } from '@orbit/shared/api'
import type {
Goal,
@@ -273,7 +272,7 @@ export function useReorderGoals() {
const positionMap = new Map(positions.map((p) => [p.id, p.position]))
return old.map((g) => {
const newPos = positionMap.get(g.id)
- return newPos !== undefined ? { ...g, position: newPos } : g
+ return newPos === undefined ? g : { ...g, position: newPos }
})
},
)
diff --git a/apps/web/hooks/use-habit-form.ts b/apps/web/hooks/use-habit-form.ts
index 90e9de151..6f96136e2 100644
--- a/apps/web/hooks/use-habit-form.ts
+++ b/apps/web/hooks/use-habit-form.ts
@@ -8,14 +8,9 @@ import { useTranslations } from 'next-intl'
import {
habitFormSchema,
type HabitFormData,
- validateEndDate,
- validateEndTime,
- validateTime,
- validateFrequency,
- validateScheduledReminders,
validateHabitForm,
} from '@orbit/shared/validation'
-import type { FrequencyUnit, ChecklistItem, ScheduledReminderTime } from '@orbit/shared/types/habit'
+import type { FrequencyUnit } from '@orbit/shared/types/habit'
// ---------------------------------------------------------------------------
// Types
diff --git a/apps/web/hooks/use-habits.ts b/apps/web/hooks/use-habits.ts
index 1d84313dd..07656b425 100644
--- a/apps/web/hooks/use-habits.ts
+++ b/apps/web/hooks/use-habits.ts
@@ -1,13 +1,12 @@
'use client'
-import { useMemo, useCallback } from 'react'
+import { useCallback } from 'react'
import {
useQuery,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
-import { habitKeys, goalKeys, gamificationKeys, profileKeys } from '@orbit/shared/query'
-import { QUERY_STALE_TIMES } from '@orbit/shared/query'
+import { habitKeys, goalKeys, gamificationKeys, profileKeys, QUERY_STALE_TIMES } from '@orbit/shared/query'
import { API } from '@orbit/shared/api'
import { formatAPIDate } from '@orbit/shared/utils'
import { fetchJson } from '@/lib/api-fetch'
@@ -21,7 +20,6 @@ import type {
HabitDetail,
HabitMetrics,
HabitFullDetail,
- LogHabitResponse,
CreateHabitRequest,
UpdateHabitRequest,
ReorderHabitsRequest,
@@ -29,15 +27,11 @@ import type {
CreateSubHabitRequest,
MoveHabitParentRequest,
BulkCreateRequest,
- BulkCreateResponse,
- BulkDeleteResponse,
BulkLogItemRequest,
- BulkLogResult,
BulkSkipItemRequest,
- BulkSkipResult,
+ LinkedGoalUpdate,
} from '@orbit/shared/types/habit'
import type { Goal } from '@orbit/shared/types/goal'
-import type { LinkedGoalUpdate } from '@orbit/shared/types/habit'
import type { Profile } from '@orbit/shared/types/profile'
import type { GamificationProfile } from '@orbit/shared/types/gamification'
import type { HabitLog } from '@orbit/shared/types/calendar'
diff --git a/apps/web/hooks/use-notifications.ts b/apps/web/hooks/use-notifications.ts
index 01e68ba21..b3a74afad 100644
--- a/apps/web/hooks/use-notifications.ts
+++ b/apps/web/hooks/use-notifications.ts
@@ -1,16 +1,14 @@
'use client'
-import { useMemo, useCallback, useEffect, useRef } from 'react'
+import { useEffect, useRef } from 'react'
import {
useQuery,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
-import { notificationKeys } from '@orbit/shared/query'
-import { QUERY_STALE_TIMES } from '@orbit/shared/query'
+import { notificationKeys, QUERY_STALE_TIMES } from '@orbit/shared/query'
import { API } from '@orbit/shared/api'
import type {
- NotificationItem,
NotificationsResponse,
} from '@orbit/shared/types/notification'
import {
@@ -52,11 +50,9 @@ export function useNotifications() {
// Refetch immediately on tab focus
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() })
// Restart polling
- if (!intervalRef.current) {
- intervalRef.current = setInterval(() => {
- queryClient.invalidateQueries({ queryKey: notificationKeys.lists() })
- }, 60000)
- }
+ intervalRef.current ??= setInterval(() => {
+ queryClient.invalidateQueries({ queryKey: notificationKeys.lists() })
+ }, 60000)
}
}
diff --git a/apps/web/hooks/use-profile.ts b/apps/web/hooks/use-profile.ts
index fdba9662c..ea0911c7e 100644
--- a/apps/web/hooks/use-profile.ts
+++ b/apps/web/hooks/use-profile.ts
@@ -5,7 +5,7 @@ import { differenceInCalendarDays, parseISO } from 'date-fns'
import { useEffect, useCallback, useMemo } from 'react'
import { profileKeys } from '@orbit/shared/query'
import { API } from '@orbit/shared/api'
-import type { Profile, PlanType } from '@orbit/shared/types/profile'
+import type { Profile } from '@orbit/shared/types/profile'
import { updateTimezone } from '@/app/actions/profile'
import { fetchJson } from '@/lib/api-fetch'
diff --git a/apps/web/hooks/use-speech-to-text.ts b/apps/web/hooks/use-speech-to-text.ts
index 1edd0f93b..201e220e1 100644
--- a/apps/web/hooks/use-speech-to-text.ts
+++ b/apps/web/hooks/use-speech-to-text.ts
@@ -80,7 +80,7 @@ export function useSpeechToText() {
const [transcript, setTranscript] = useState('')
const [error, setError] = useState
(null)
const [selectedLanguage, setSelectedLanguageState] = useState(() => {
- if (typeof globalThis === 'undefined' || typeof globalThis.localStorage === 'undefined') return locale === 'pt-BR' ? 'pt-BR' : 'en-US'
+ if (typeof globalThis === 'undefined' || typeof globalThis.localStorage === 'undefined') return locale === 'pt-BR' ? 'pt-BR' : 'en-US' // NOSONAR - SSR guard
return localStorage.getItem(SPEECH_LANG_KEY) ?? (locale === 'pt-BR' ? 'pt-BR' : 'en-US')
})
const [recordingDuration, setRecordingDuration] = useState(0)
diff --git a/apps/web/hooks/use-summary.ts b/apps/web/hooks/use-summary.ts
index 06b63e709..fb7843da5 100644
--- a/apps/web/hooks/use-summary.ts
+++ b/apps/web/hooks/use-summary.ts
@@ -1,8 +1,7 @@
'use client'
import { useQuery, useQueryClient } from '@tanstack/react-query'
-import { habitKeys } from '@orbit/shared/query'
-import { QUERY_STALE_TIMES } from '@orbit/shared/query'
+import { habitKeys, QUERY_STALE_TIMES } from '@orbit/shared/query'
import { API } from '@orbit/shared/api'
// ---------------------------------------------------------------------------
diff --git a/apps/web/hooks/use-time-format.ts b/apps/web/hooks/use-time-format.ts
index 002317f2d..d2c3ab6b2 100644
--- a/apps/web/hooks/use-time-format.ts
+++ b/apps/web/hooks/use-time-format.ts
@@ -5,7 +5,7 @@ import { useState, useCallback, useMemo } from 'react'
export type TimeFormat = '12h' | '24h'
function detectDefaultFormat(): TimeFormat {
- if (typeof globalThis === 'undefined' || typeof globalThis.document === 'undefined') return '24h'
+ if (typeof globalThis === 'undefined' || typeof globalThis.document === 'undefined') return '24h' // NOSONAR - SSR guard
try {
const resolved = new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions()
return (resolved as { hour12?: boolean }).hour12 ? '12h' : '24h'
@@ -28,7 +28,7 @@ export function formatTime(time: string, fmt: TimeFormat): string {
export function useTimeFormat() {
const [currentFormat, setCurrentFormat] = useState(() => {
- if (typeof globalThis === 'undefined' || typeof globalThis.localStorage === 'undefined') return '24h'
+ if (typeof globalThis === 'undefined' || typeof globalThis.localStorage === 'undefined') return '24h' // NOSONAR - SSR guard
return (localStorage.getItem('orbit_time_format') as TimeFormat) ?? detectDefaultFormat()
})
diff --git a/apps/web/lib/api-fetch.ts b/apps/web/lib/api-fetch.ts
index 54d6972b8..378bfbd69 100644
--- a/apps/web/lib/api-fetch.ts
+++ b/apps/web/lib/api-fetch.ts
@@ -40,7 +40,7 @@ export async function apiFetch(url: string, options?: RequestInit): Promise 0) {
+ request.reminderEnabled = true
+ request.scheduledReminders = data.scheduledReminders ?? undefined
+ return
+ }
+ request.reminderEnabled = false
+}
+
export function buildUpdateHabitRequest(
data: HabitFormData,
isOneTime: boolean,
@@ -150,30 +188,8 @@ export function buildUpdateHabitRequest(
if (data.description) request.description = data.description
if (!data.isGeneral) {
- // Schedule fields
- if (data.dueDate) request.dueDate = data.dueDate
- if (!isOneTime) {
- request.frequencyUnit = data.frequencyUnit ?? undefined
- request.frequencyQuantity = data.frequencyQuantity ?? undefined
- if (data.days?.length) request.days = data.days
- if (data.endDate) {
- request.endDate = data.endDate
- } else if (originalEndDate && !data.endDate) {
- request.clearEndDate = true
- }
- }
- // Reminder fields
- if (data.dueTime) {
- request.dueTime = data.dueTime
- request.dueEndTime = data.dueEndTime || undefined
- request.reminderEnabled = data.reminderEnabled
- request.reminderTimes = reminderTimes
- } else if (data.reminderEnabled && (data.scheduledReminders?.length ?? 0) > 0) {
- request.reminderEnabled = true
- request.scheduledReminders = data.scheduledReminders ?? undefined
- } else {
- request.reminderEnabled = false
- }
+ applyUpdateScheduleFields(request, data, isOneTime, originalEndDate)
+ applyUpdateReminderFields(request, data, reminderTimes)
}
request.slipAlertEnabled = data.isBadHabit ? data.slipAlertEnabled : false
diff --git a/apps/web/lib/providers.tsx b/apps/web/lib/providers.tsx
index cebaea76a..e6cb4d4a1 100644
--- a/apps/web/lib/providers.tsx
+++ b/apps/web/lib/providers.tsx
@@ -4,7 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { getQueryClient } from './query-client'
import type { ReactNode } from 'react'
-export function Providers({ children }: { children: ReactNode }) {
+export function Providers({ children }: Readonly<{ children: ReactNode }>) {
const queryClient = getQueryClient()
return (
diff --git a/apps/web/lib/query-client.ts b/apps/web/lib/query-client.ts
index 7676beb50..9fb8ff16c 100644
--- a/apps/web/lib/query-client.ts
+++ b/apps/web/lib/query-client.ts
@@ -8,7 +8,7 @@ export function createQueryClient(): QueryClient {
gcTime: 24 * 60 * 60 * 1000, // 24 hours (keep in cache for offline)
retry: (failureCount, error) => {
// Don't retry when offline
- if (typeof navigator !== 'undefined' && !navigator.onLine) return false
+ if (typeof navigator !== 'undefined' && !navigator.onLine) return false // NOSONAR - SSR guard
// Don't retry auth errors
if (error instanceof Error && error.message.includes('401')) return false
return failureCount < 3
@@ -27,7 +27,7 @@ export function createQueryClient(): QueryClient {
let browserQueryClient: QueryClient | undefined
export function getQueryClient(): QueryClient {
- if (typeof globalThis === 'undefined' || typeof globalThis.document === 'undefined') {
+ if (typeof globalThis === 'undefined' || typeof globalThis.document === 'undefined') { // NOSONAR - SSR guard
// Server: always create a new client
return createQueryClient()
}
diff --git a/apps/web/stores/auth-store.ts b/apps/web/stores/auth-store.ts
index f206d5554..021cc9c66 100644
--- a/apps/web/stores/auth-store.ts
+++ b/apps/web/stores/auth-store.ts
@@ -85,7 +85,7 @@ export const useAuthStore = create((set, get) => ({
set({ isAuthenticated: false, user: null, expiresAt: null })
// Redirect to login (only in browser)
- if (typeof globalThis !== 'undefined' && typeof globalThis.location !== 'undefined') {
+ if (typeof globalThis !== 'undefined' && typeof globalThis.location !== 'undefined') { // NOSONAR - SSR guard
globalThis.location.href = '/login'
}
},
diff --git a/packages/shared/src/__tests__/types.test.ts b/packages/shared/src/__tests__/types.test.ts
index f989176bf..8bd71f990 100644
--- a/packages/shared/src/__tests__/types.test.ts
+++ b/packages/shared/src/__tests__/types.test.ts
@@ -9,6 +9,68 @@ import { profileSchema } from '../types/profile'
import { notificationItemSchema, notificationsResponseSchema } from '../types/notification'
import { achievementSchema, gamificationProfileSchema } from '../types/gamification'
import { appConfigSchema } from '../types/config'
+
+// Auth schemas
+import {
+ userSchema,
+ loginResponseSchema,
+ backendLoginResponseSchema,
+ refreshResponseSchema,
+ sendCodeRequestSchema,
+ verifyCodeRequestSchema,
+ googleAuthRequestSchema,
+} from '../types/auth'
+
+// Chat schemas
+import {
+ aiActionTypeSchema,
+ actionStatusSchema,
+ conflictingHabitSchema,
+ conflictWarningSchema,
+ suggestedSubHabitSchema,
+ actionResultSchema,
+ chatMessageSchema,
+ chatResponseSchema,
+} from '../types/chat'
+
+// Sync schemas
+import {
+ mutationTypeSchema,
+ queuedMutationSchema,
+ syncBatchRequestSchema,
+ syncMutationResultSchema,
+ syncBatchResponseSchema,
+ syncChangesResponseSchema,
+} from '../types/sync'
+
+// Subscription schemas
+import {
+ planPriceSchema,
+ subscriptionPlansSchema,
+ billingPaymentMethodSchema,
+ billingInvoiceSchema,
+ billingDetailsSchema,
+} from '../types/subscription'
+
+// Referral schemas
+import {
+ referralCodeSchema,
+ referralStatsSchema,
+ referralDashboardSchema,
+} from '../types/referral'
+
+// User fact schema
+import { userFactSchema } from '../types/user-fact'
+
+// API key schemas
+import { apiKeySchema, apiKeyCreateResponseSchema } from '../types/api-key'
+
+// Checklist template schema
+import { checklistTemplateSchema } from '../types/checklist-template'
+
+// API error schema
+import { apiErrorSchema } from '../types/api'
+
import {
createMockHabit,
createMockGoal,
@@ -346,3 +408,1191 @@ describe('config schema', () => {
expect(result.success).toBe(true)
})
})
+
+// ---------------------------------------------------------------------------
+// Auth schemas
+// ---------------------------------------------------------------------------
+
+describe('auth schemas', () => {
+ describe('userSchema', () => {
+ it('parses a valid User', () => {
+ const result = userSchema.safeParse({
+ userId: 'u-1',
+ name: 'Thomas',
+ email: 'thomas@example.com',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing email', () => {
+ const result = userSchema.safeParse({ userId: 'u-1', name: 'Thomas' })
+ expect(result.success).toBe(false)
+ })
+
+ it('rejects non-string userId', () => {
+ const result = userSchema.safeParse({ userId: 123, name: 'Thomas', email: 't@t.com' })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('loginResponseSchema', () => {
+ it('parses a valid login response', () => {
+ const result = loginResponseSchema.safeParse({
+ userId: 'u-1',
+ name: 'Thomas',
+ email: 'thomas@example.com',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('parses login response with optional wasReactivated', () => {
+ const result = loginResponseSchema.safeParse({
+ userId: 'u-1',
+ name: 'Thomas',
+ email: 'thomas@example.com',
+ wasReactivated: true,
+ })
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.wasReactivated).toBe(true)
+ }
+ })
+
+ it('allows omitting wasReactivated', () => {
+ const result = loginResponseSchema.safeParse({
+ userId: 'u-1',
+ name: 'Thomas',
+ email: 'thomas@example.com',
+ })
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.wasReactivated).toBeUndefined()
+ }
+ })
+ })
+
+ describe('backendLoginResponseSchema', () => {
+ it('parses a valid backend login response with token', () => {
+ const result = backendLoginResponseSchema.safeParse({
+ userId: 'u-1',
+ name: 'Thomas',
+ email: 'thomas@example.com',
+ token: 'jwt-token',
+ refreshToken: 'refresh-token',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts null refreshToken', () => {
+ const result = backendLoginResponseSchema.safeParse({
+ userId: 'u-1',
+ name: 'Thomas',
+ email: 'thomas@example.com',
+ token: 'jwt-token',
+ refreshToken: null,
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing token', () => {
+ const result = backendLoginResponseSchema.safeParse({
+ userId: 'u-1',
+ name: 'Thomas',
+ email: 'thomas@example.com',
+ refreshToken: 'r-token',
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('refreshResponseSchema', () => {
+ it('parses valid refresh response', () => {
+ const result = refreshResponseSchema.safeParse({
+ token: 'new-jwt',
+ refreshToken: 'new-refresh',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing refreshToken', () => {
+ const result = refreshResponseSchema.safeParse({ token: 'new-jwt' })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('sendCodeRequestSchema', () => {
+ it('parses valid send code request', () => {
+ const result = sendCodeRequestSchema.safeParse({
+ email: 'test@test.com',
+ language: 'en',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing language', () => {
+ const result = sendCodeRequestSchema.safeParse({ email: 'test@test.com' })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('verifyCodeRequestSchema', () => {
+ it('parses valid verify code request', () => {
+ const result = verifyCodeRequestSchema.safeParse({
+ email: 'test@test.com',
+ code: '123456',
+ language: 'en',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts optional referralCode', () => {
+ const result = verifyCodeRequestSchema.safeParse({
+ email: 'test@test.com',
+ code: '123456',
+ language: 'en',
+ referralCode: 'REF123',
+ })
+ expect(result.success).toBe(true)
+ if (result.success) {
+ expect(result.data.referralCode).toBe('REF123')
+ }
+ })
+
+ it('rejects missing code', () => {
+ const result = verifyCodeRequestSchema.safeParse({
+ email: 'test@test.com',
+ language: 'en',
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('googleAuthRequestSchema', () => {
+ it('parses valid google auth request', () => {
+ const result = googleAuthRequestSchema.safeParse({
+ accessToken: 'google-access-token',
+ language: 'en',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts optional google tokens and referralCode', () => {
+ const result = googleAuthRequestSchema.safeParse({
+ accessToken: 'token',
+ language: 'en',
+ googleAccessToken: 'g-access',
+ googleRefreshToken: 'g-refresh',
+ referralCode: 'REF123',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing accessToken', () => {
+ const result = googleAuthRequestSchema.safeParse({ language: 'en' })
+ expect(result.success).toBe(false)
+ })
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Chat schemas
+// ---------------------------------------------------------------------------
+
+describe('chat schemas', () => {
+ describe('aiActionTypeSchema', () => {
+ it('parses valid action types', () => {
+ const types = [
+ 'CreateHabit', 'LogHabit', 'UpdateHabit', 'DeleteHabit', 'SkipHabit',
+ 'CreateSubHabit', 'SuggestBreakdown', 'AssignTags', 'DuplicateHabit', 'MoveHabit',
+ ]
+ for (const t of types) {
+ expect(aiActionTypeSchema.safeParse(t).success).toBe(true)
+ }
+ })
+
+ it('rejects invalid action type', () => {
+ expect(aiActionTypeSchema.safeParse('Archive').success).toBe(false)
+ })
+ })
+
+ describe('actionStatusSchema', () => {
+ it('parses valid statuses', () => {
+ for (const s of ['Success', 'Failed', 'Suggestion']) {
+ expect(actionStatusSchema.safeParse(s).success).toBe(true)
+ }
+ })
+
+ it('rejects invalid status', () => {
+ expect(actionStatusSchema.safeParse('Pending').success).toBe(false)
+ })
+ })
+
+ describe('conflictingHabitSchema', () => {
+ it('parses valid conflicting habit', () => {
+ const result = conflictingHabitSchema.safeParse({
+ habitId: 'h-1',
+ habitTitle: 'Exercise',
+ conflictDescription: 'Schedule overlap',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing conflictDescription', () => {
+ const result = conflictingHabitSchema.safeParse({
+ habitId: 'h-1',
+ habitTitle: 'Exercise',
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('conflictWarningSchema', () => {
+ it('parses valid conflict warning', () => {
+ const result = conflictWarningSchema.safeParse({
+ hasConflict: true,
+ conflictingHabits: [
+ { habitId: 'h-1', habitTitle: 'Exercise', conflictDescription: 'Overlap' },
+ ],
+ severity: 'HIGH',
+ recommendation: 'Adjust schedule',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts null recommendation', () => {
+ const result = conflictWarningSchema.safeParse({
+ hasConflict: false,
+ conflictingHabits: [],
+ severity: 'LOW',
+ recommendation: null,
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects invalid severity', () => {
+ const result = conflictWarningSchema.safeParse({
+ hasConflict: true,
+ conflictingHabits: [],
+ severity: 'CRITICAL',
+ recommendation: null,
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('suggestedSubHabitSchema', () => {
+ it('parses minimal suggested sub-habit', () => {
+ const result = suggestedSubHabitSchema.safeParse({ title: 'Morning run' })
+ expect(result.success).toBe(true)
+ })
+
+ it('parses fully populated suggested sub-habit', () => {
+ const result = suggestedSubHabitSchema.safeParse({
+ title: 'Morning run',
+ description: 'Run 5km',
+ frequencyUnit: 'Day',
+ frequencyQuantity: 1,
+ days: ['Monday', 'Wednesday'],
+ isBadHabit: false,
+ dueDate: '2025-06-01',
+ dueTime: '07:00',
+ note: 'Start slow',
+ habitId: 'h-parent',
+ slipAlertEnabled: false,
+ reminderEnabled: true,
+ reminderTimes: ['06:30'],
+ tagNames: ['fitness'],
+ checklistItems: [{ text: 'Warm up', isChecked: false }],
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts null optional fields', () => {
+ const result = suggestedSubHabitSchema.safeParse({
+ title: 'Test',
+ description: null,
+ frequencyUnit: null,
+ frequencyQuantity: null,
+ days: null,
+ isBadHabit: null,
+ dueDate: null,
+ dueTime: null,
+ note: null,
+ })
+ expect(result.success).toBe(true)
+ })
+ })
+
+ describe('actionResultSchema', () => {
+ it('parses valid action result', () => {
+ const result = actionResultSchema.safeParse({
+ type: 'CreateHabit',
+ status: 'Success',
+ entityId: 'h-1',
+ entityName: 'Exercise',
+ error: null,
+ field: null,
+ suggestedSubHabits: null,
+ conflictWarning: null,
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('parses action result with suggestions', () => {
+ const result = actionResultSchema.safeParse({
+ type: 'SuggestBreakdown',
+ status: 'Suggestion',
+ entityId: null,
+ entityName: null,
+ error: null,
+ field: null,
+ suggestedSubHabits: [{ title: 'Step 1' }, { title: 'Step 2' }],
+ conflictWarning: null,
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('parses action result with conflict warning', () => {
+ const result = actionResultSchema.safeParse({
+ type: 'CreateHabit',
+ status: 'Success',
+ entityId: 'h-1',
+ entityName: 'Exercise',
+ error: null,
+ field: null,
+ suggestedSubHabits: null,
+ conflictWarning: {
+ hasConflict: true,
+ conflictingHabits: [],
+ severity: 'MEDIUM',
+ recommendation: 'Consider rescheduling',
+ },
+ })
+ expect(result.success).toBe(true)
+ })
+ })
+
+ describe('chatMessageSchema', () => {
+ it('parses valid chat message', () => {
+ const result = chatMessageSchema.safeParse({
+ id: 'msg-1',
+ role: 'user',
+ content: 'Hello',
+ timestamp: new Date(),
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('parses ai message with actions', () => {
+ const result = chatMessageSchema.safeParse({
+ id: 'msg-2',
+ role: 'ai',
+ content: 'Created habit',
+ actions: [{
+ type: 'CreateHabit',
+ status: 'Success',
+ entityId: 'h-1',
+ entityName: 'Exercise',
+ error: null,
+ field: null,
+ suggestedSubHabits: null,
+ conflictWarning: null,
+ }],
+ imageUrl: null,
+ timestamp: new Date(),
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects invalid role', () => {
+ const result = chatMessageSchema.safeParse({
+ id: 'msg-1',
+ role: 'system',
+ content: 'test',
+ timestamp: new Date(),
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('chatResponseSchema', () => {
+ it('parses valid chat response', () => {
+ const result = chatResponseSchema.safeParse({
+ aiMessage: 'Done!',
+ actions: [],
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts null aiMessage', () => {
+ const result = chatResponseSchema.safeParse({
+ aiMessage: null,
+ actions: [{
+ type: 'LogHabit',
+ status: 'Success',
+ entityId: 'h-1',
+ entityName: 'Exercise',
+ error: null,
+ field: null,
+ suggestedSubHabits: null,
+ conflictWarning: null,
+ }],
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing actions', () => {
+ const result = chatResponseSchema.safeParse({ aiMessage: 'Hello' })
+ expect(result.success).toBe(false)
+ })
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Sync schemas
+// ---------------------------------------------------------------------------
+
+describe('sync schemas', () => {
+ describe('mutationTypeSchema', () => {
+ it('parses valid mutation types', () => {
+ const types = [
+ 'createHabit', 'updateHabit', 'deleteHabit', 'logHabit', 'skipHabit',
+ 'reorderHabits', 'updateChecklist', 'duplicateHabit', 'moveHabitParent',
+ 'createGoal', 'updateGoal', 'deleteGoal', 'updateGoalProgress', 'updateGoalStatus', 'reorderGoals',
+ 'createTag', 'updateTag', 'deleteTag', 'assignTags',
+ 'markNotificationRead', 'markAllNotificationsRead', 'deleteNotification',
+ ]
+ for (const t of types) {
+ expect(mutationTypeSchema.safeParse(t).success).toBe(true)
+ }
+ })
+
+ it('rejects invalid mutation type', () => {
+ expect(mutationTypeSchema.safeParse('archiveHabit').success).toBe(false)
+ })
+ })
+
+ describe('queuedMutationSchema', () => {
+ it('parses valid queued mutation', () => {
+ const result = queuedMutationSchema.safeParse({
+ id: 'mut-1',
+ timestamp: Date.now(),
+ type: 'createHabit',
+ endpoint: '/api/habits',
+ method: 'POST',
+ payload: { title: 'Exercise' },
+ retries: 0,
+ maxRetries: 3,
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts DELETE method', () => {
+ const result = queuedMutationSchema.safeParse({
+ id: 'mut-2',
+ timestamp: Date.now(),
+ type: 'deleteHabit',
+ endpoint: '/api/habits/h-1',
+ method: 'DELETE',
+ payload: null,
+ retries: 1,
+ maxRetries: 3,
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects invalid method', () => {
+ const result = queuedMutationSchema.safeParse({
+ id: 'mut-1',
+ timestamp: Date.now(),
+ type: 'createHabit',
+ endpoint: '/api/habits',
+ method: 'GET',
+ payload: null,
+ retries: 0,
+ maxRetries: 3,
+ })
+ expect(result.success).toBe(false)
+ })
+
+ it('rejects missing id', () => {
+ const result = queuedMutationSchema.safeParse({
+ timestamp: Date.now(),
+ type: 'createHabit',
+ endpoint: '/api/habits',
+ method: 'POST',
+ payload: null,
+ retries: 0,
+ maxRetries: 3,
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('syncBatchRequestSchema', () => {
+ it('parses valid batch request', () => {
+ const result = syncBatchRequestSchema.safeParse({
+ mutations: [
+ {
+ id: 'mut-1',
+ timestamp: '2025-01-01T00:00:00Z',
+ type: 'createHabit',
+ payload: { title: 'Test' },
+ },
+ ],
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts empty mutations array', () => {
+ const result = syncBatchRequestSchema.safeParse({ mutations: [] })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing mutations', () => {
+ const result = syncBatchRequestSchema.safeParse({})
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('syncMutationResultSchema', () => {
+ it('parses success result', () => {
+ const result = syncMutationResultSchema.safeParse({
+ mutationId: 'mut-1',
+ status: 'success',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('parses conflict result with error', () => {
+ const result = syncMutationResultSchema.safeParse({
+ mutationId: 'mut-1',
+ status: 'conflict',
+ error: 'Version mismatch',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('parses all valid statuses', () => {
+ for (const s of ['success', 'conflict', 'gone', 'error']) {
+ expect(syncMutationResultSchema.safeParse({ mutationId: 'x', status: s }).success).toBe(true)
+ }
+ })
+
+ it('rejects invalid status', () => {
+ const result = syncMutationResultSchema.safeParse({
+ mutationId: 'mut-1',
+ status: 'pending',
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('syncBatchResponseSchema', () => {
+ it('parses valid batch response', () => {
+ const result = syncBatchResponseSchema.safeParse({
+ results: [{ mutationId: 'mut-1', status: 'success' }],
+ errors: [],
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('parses response with errors', () => {
+ const result = syncBatchResponseSchema.safeParse({
+ results: [],
+ errors: [{ mutationId: 'mut-2', status: 'error', error: 'Server error' }],
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing results field', () => {
+ const result = syncBatchResponseSchema.safeParse({ errors: [] })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('syncChangesResponseSchema', () => {
+ it('parses valid sync changes response', () => {
+ const result = syncChangesResponseSchema.safeParse({
+ serverTime: '2025-01-15T10:00:00Z',
+ changes: {
+ habits: [],
+ goals: [],
+ tags: [],
+ notifications: [],
+ deletedIds: {
+ habits: [],
+ goals: [],
+ tags: [],
+ },
+ },
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('parses response with populated arrays', () => {
+ const result = syncChangesResponseSchema.safeParse({
+ serverTime: '2025-01-15T10:00:00Z',
+ changes: {
+ habits: [{ id: 'h-1', title: 'Exercise' }],
+ goals: [{ id: 'g-1', title: 'Read' }],
+ tags: [{ id: 't-1', name: 'Health' }],
+ notifications: [{ id: 'n-1' }],
+ deletedIds: {
+ habits: ['h-2'],
+ goals: ['g-2'],
+ tags: ['t-2'],
+ },
+ },
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing deletedIds', () => {
+ const result = syncChangesResponseSchema.safeParse({
+ serverTime: '2025-01-15T10:00:00Z',
+ changes: {
+ habits: [],
+ goals: [],
+ tags: [],
+ notifications: [],
+ },
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Subscription schemas
+// ---------------------------------------------------------------------------
+
+describe('subscription schemas', () => {
+ describe('planPriceSchema', () => {
+ it('parses valid plan price', () => {
+ const result = planPriceSchema.safeParse({ unitAmount: 999, currency: 'usd' })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing currency', () => {
+ const result = planPriceSchema.safeParse({ unitAmount: 999 })
+ expect(result.success).toBe(false)
+ })
+
+ it('rejects non-number unitAmount', () => {
+ const result = planPriceSchema.safeParse({ unitAmount: '9.99', currency: 'usd' })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('subscriptionPlansSchema', () => {
+ it('parses valid subscription plans', () => {
+ const result = subscriptionPlansSchema.safeParse({
+ monthly: { unitAmount: 999, currency: 'usd' },
+ yearly: { unitAmount: 7999, currency: 'usd' },
+ savingsPercent: 33,
+ couponPercentOff: null,
+ currency: 'usd',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts couponPercentOff value', () => {
+ const result = subscriptionPlansSchema.safeParse({
+ monthly: { unitAmount: 999, currency: 'usd' },
+ yearly: { unitAmount: 7999, currency: 'usd' },
+ savingsPercent: 33,
+ couponPercentOff: 20,
+ currency: 'usd',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing yearly plan', () => {
+ const result = subscriptionPlansSchema.safeParse({
+ monthly: { unitAmount: 999, currency: 'usd' },
+ savingsPercent: 33,
+ couponPercentOff: null,
+ currency: 'usd',
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('billingPaymentMethodSchema', () => {
+ it('parses valid payment method', () => {
+ const result = billingPaymentMethodSchema.safeParse({
+ brand: 'visa',
+ last4: '4242',
+ expMonth: 12,
+ expYear: 2027,
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing last4', () => {
+ const result = billingPaymentMethodSchema.safeParse({
+ brand: 'visa',
+ expMonth: 12,
+ expYear: 2027,
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('billingInvoiceSchema', () => {
+ it('parses valid invoice', () => {
+ const result = billingInvoiceSchema.safeParse({
+ id: 'inv-1',
+ date: '2025-01-01',
+ amountPaid: 999,
+ currency: 'usd',
+ status: 'paid',
+ hostedInvoiceUrl: 'https://stripe.com/invoice/1',
+ invoicePdf: 'https://stripe.com/invoice/1.pdf',
+ billingReason: 'subscription_create',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts null URLs', () => {
+ const result = billingInvoiceSchema.safeParse({
+ id: 'inv-1',
+ date: '2025-01-01',
+ amountPaid: 0,
+ currency: 'usd',
+ status: 'draft',
+ hostedInvoiceUrl: null,
+ invoicePdf: null,
+ billingReason: 'manual',
+ })
+ expect(result.success).toBe(true)
+ })
+ })
+
+ describe('billingDetailsSchema', () => {
+ it('parses valid billing details', () => {
+ const result = billingDetailsSchema.safeParse({
+ status: 'active',
+ currentPeriodEnd: '2025-12-31',
+ cancelAtPeriodEnd: false,
+ interval: 'month',
+ amountPerPeriod: 999,
+ currency: 'usd',
+ paymentMethod: {
+ brand: 'visa',
+ last4: '4242',
+ expMonth: 12,
+ expYear: 2027,
+ },
+ recentInvoices: [],
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts null paymentMethod', () => {
+ const result = billingDetailsSchema.safeParse({
+ status: 'active',
+ currentPeriodEnd: '2025-12-31',
+ cancelAtPeriodEnd: false,
+ interval: 'year',
+ amountPerPeriod: 7999,
+ currency: 'usd',
+ paymentMethod: null,
+ recentInvoices: [],
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('parses with invoices array', () => {
+ const result = billingDetailsSchema.safeParse({
+ status: 'active',
+ currentPeriodEnd: '2025-12-31',
+ cancelAtPeriodEnd: true,
+ interval: 'month',
+ amountPerPeriod: 999,
+ currency: 'usd',
+ paymentMethod: null,
+ recentInvoices: [{
+ id: 'inv-1',
+ date: '2025-01-01',
+ amountPaid: 999,
+ currency: 'usd',
+ status: 'paid',
+ hostedInvoiceUrl: null,
+ invoicePdf: null,
+ billingReason: 'subscription_cycle',
+ }],
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing status', () => {
+ const result = billingDetailsSchema.safeParse({
+ currentPeriodEnd: '2025-12-31',
+ cancelAtPeriodEnd: false,
+ interval: 'month',
+ amountPerPeriod: 999,
+ currency: 'usd',
+ paymentMethod: null,
+ recentInvoices: [],
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Referral schemas
+// ---------------------------------------------------------------------------
+
+describe('referral schemas', () => {
+ describe('referralCodeSchema', () => {
+ it('parses valid referral code', () => {
+ const result = referralCodeSchema.safeParse({
+ code: 'REF123',
+ link: 'https://app.useorbit.org/ref/REF123',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing link', () => {
+ const result = referralCodeSchema.safeParse({ code: 'REF123' })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('referralStatsSchema', () => {
+ it('parses valid referral stats', () => {
+ const result = referralStatsSchema.safeParse({
+ referralCode: 'REF123',
+ referralLink: 'https://app.useorbit.org/ref/REF123',
+ successfulReferrals: 3,
+ pendingReferrals: 1,
+ maxReferrals: 10,
+ rewardType: 'discount',
+ discountPercent: 20,
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts null code and link', () => {
+ const result = referralStatsSchema.safeParse({
+ referralCode: null,
+ referralLink: null,
+ successfulReferrals: 0,
+ pendingReferrals: 0,
+ maxReferrals: 10,
+ rewardType: 'discount',
+ discountPercent: 20,
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing rewardType', () => {
+ const result = referralStatsSchema.safeParse({
+ referralCode: 'REF123',
+ referralLink: 'link',
+ successfulReferrals: 0,
+ pendingReferrals: 0,
+ maxReferrals: 10,
+ discountPercent: 20,
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('referralDashboardSchema', () => {
+ it('parses valid referral dashboard', () => {
+ const result = referralDashboardSchema.safeParse({
+ code: 'REF123',
+ link: 'https://app.useorbit.org/ref/REF123',
+ stats: {
+ referralCode: 'REF123',
+ referralLink: 'https://app.useorbit.org/ref/REF123',
+ successfulReferrals: 5,
+ pendingReferrals: 2,
+ maxReferrals: 10,
+ rewardType: 'discount',
+ discountPercent: 20,
+ },
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing stats', () => {
+ const result = referralDashboardSchema.safeParse({
+ code: 'REF123',
+ link: 'https://app.useorbit.org/ref/REF123',
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+})
+
+// ---------------------------------------------------------------------------
+// User fact schema
+// ---------------------------------------------------------------------------
+
+describe('user fact schema', () => {
+ it('parses valid user fact', () => {
+ const result = userFactSchema.safeParse({
+ id: 'fact-1',
+ factText: 'User prefers morning workouts',
+ category: 'preferences',
+ extractedAtUtc: '2025-01-01T00:00:00Z',
+ updatedAtUtc: null,
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts null category', () => {
+ const result = userFactSchema.safeParse({
+ id: 'fact-1',
+ factText: 'Some fact',
+ category: null,
+ extractedAtUtc: '2025-01-01T00:00:00Z',
+ updatedAtUtc: '2025-01-02T00:00:00Z',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing factText', () => {
+ const result = userFactSchema.safeParse({
+ id: 'fact-1',
+ category: null,
+ extractedAtUtc: '2025-01-01T00:00:00Z',
+ updatedAtUtc: null,
+ })
+ expect(result.success).toBe(false)
+ })
+
+ it('rejects non-string id', () => {
+ const result = userFactSchema.safeParse({
+ id: 123,
+ factText: 'Some fact',
+ category: null,
+ extractedAtUtc: '2025-01-01T00:00:00Z',
+ updatedAtUtc: null,
+ })
+ expect(result.success).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// API key schemas
+// ---------------------------------------------------------------------------
+
+describe('api key schemas', () => {
+ describe('apiKeySchema', () => {
+ it('parses valid API key', () => {
+ const result = apiKeySchema.safeParse({
+ id: 'key-1',
+ name: 'My API Key',
+ keyPrefix: 'orb_abc',
+ createdAtUtc: '2025-01-01T00:00:00Z',
+ lastUsedAtUtc: null,
+ isRevoked: false,
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts non-null lastUsedAtUtc', () => {
+ const result = apiKeySchema.safeParse({
+ id: 'key-1',
+ name: 'Key',
+ keyPrefix: 'orb_xyz',
+ createdAtUtc: '2025-01-01T00:00:00Z',
+ lastUsedAtUtc: '2025-06-15T10:00:00Z',
+ isRevoked: false,
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing name', () => {
+ const result = apiKeySchema.safeParse({
+ id: 'key-1',
+ keyPrefix: 'orb_abc',
+ createdAtUtc: '2025-01-01T00:00:00Z',
+ lastUsedAtUtc: null,
+ isRevoked: false,
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+
+ describe('apiKeyCreateResponseSchema', () => {
+ it('parses valid create response with full key', () => {
+ const result = apiKeyCreateResponseSchema.safeParse({
+ id: 'key-1',
+ name: 'My API Key',
+ keyPrefix: 'orb_abc',
+ createdAtUtc: '2025-01-01T00:00:00Z',
+ lastUsedAtUtc: null,
+ isRevoked: false,
+ key: 'orb_abc123def456',
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing key field on create response', () => {
+ const result = apiKeyCreateResponseSchema.safeParse({
+ id: 'key-1',
+ name: 'My API Key',
+ keyPrefix: 'orb_abc',
+ createdAtUtc: '2025-01-01T00:00:00Z',
+ lastUsedAtUtc: null,
+ isRevoked: false,
+ })
+ expect(result.success).toBe(false)
+ })
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Checklist template schema
+// ---------------------------------------------------------------------------
+
+describe('checklist template schema', () => {
+ it('parses valid checklist template', () => {
+ const result = checklistTemplateSchema.safeParse({
+ id: 'tpl-1',
+ name: 'Morning Routine',
+ items: ['Wake up', 'Brush teeth', 'Shower'],
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('accepts empty items array', () => {
+ const result = checklistTemplateSchema.safeParse({
+ id: 'tpl-1',
+ name: 'Empty Template',
+ items: [],
+ })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing name', () => {
+ const result = checklistTemplateSchema.safeParse({
+ id: 'tpl-1',
+ items: ['Item 1'],
+ })
+ expect(result.success).toBe(false)
+ })
+
+ it('rejects non-string items', () => {
+ const result = checklistTemplateSchema.safeParse({
+ id: 'tpl-1',
+ name: 'Template',
+ items: [1, 2, 3],
+ })
+ expect(result.success).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// API error schema
+// ---------------------------------------------------------------------------
+
+describe('api error schema', () => {
+ it('parses valid API error', () => {
+ const result = apiErrorSchema.safeParse({ error: 'Not found' })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects missing error field', () => {
+ const result = apiErrorSchema.safeParse({ message: 'Not found' })
+ expect(result.success).toBe(false)
+ })
+
+ it('rejects non-string error', () => {
+ const result = apiErrorSchema.safeParse({ error: 404 })
+ expect(result.success).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Barrel re-exports
+// ---------------------------------------------------------------------------
+
+describe('barrel re-exports', () => {
+ it('types/index re-exports all type modules', async () => {
+ const barrel = await import('../types/index')
+ // Auth
+ expect(barrel.userSchema).toBeDefined()
+ expect(barrel.loginResponseSchema).toBeDefined()
+ // Chat
+ expect(barrel.chatMessageSchema).toBeDefined()
+ expect(barrel.chatResponseSchema).toBeDefined()
+ // Sync
+ expect(barrel.mutationTypeSchema).toBeDefined()
+ expect(barrel.syncBatchResponseSchema).toBeDefined()
+ // Subscription
+ expect(barrel.planPriceSchema).toBeDefined()
+ expect(barrel.billingDetailsSchema).toBeDefined()
+ // Referral
+ expect(barrel.referralCodeSchema).toBeDefined()
+ expect(barrel.referralDashboardSchema).toBeDefined()
+ // User fact
+ expect(barrel.userFactSchema).toBeDefined()
+ // API key
+ expect(barrel.apiKeySchema).toBeDefined()
+ // Checklist template
+ expect(barrel.checklistTemplateSchema).toBeDefined()
+ // API error
+ expect(barrel.apiErrorSchema).toBeDefined()
+ // Config
+ expect(barrel.appConfigSchema).toBeDefined()
+ // Existing types
+ expect(barrel.normalizedHabitSchema).toBeDefined()
+ expect(barrel.goalSchema).toBeDefined()
+ expect(barrel.profileSchema).toBeDefined()
+ })
+
+ it('utils/index re-exports utility functions', async () => {
+ const barrel = await import('../utils/index')
+ expect(barrel.parseAPIDate).toBeDefined()
+ expect(barrel.formatAPIDate).toBeDefined()
+ expect(barrel.getTimezoneList).toBeDefined()
+ expect(barrel.isValidEmail).toBeDefined()
+ expect(barrel.getErrorMessage).toBeDefined()
+ expect(barrel.extractBackendError).toBeDefined()
+ })
+
+ it('api/index re-exports API helpers', async () => {
+ const barrel = await import('../api/index')
+ expect(barrel.API).toBeDefined()
+ expect(barrel.getErrorMessage).toBeDefined()
+ expect(barrel.extractBackendError).toBeDefined()
+ })
+
+ it('query/index re-exports query key factories', async () => {
+ const barrel = await import('../query/index')
+ expect(barrel.habitKeys).toBeDefined()
+ expect(barrel.goalKeys).toBeDefined()
+ expect(barrel.profileKeys).toBeDefined()
+ expect(barrel.tagKeys).toBeDefined()
+ expect(barrel.notificationKeys).toBeDefined()
+ expect(barrel.gamificationKeys).toBeDefined()
+ expect(barrel.subscriptionKeys).toBeDefined()
+ expect(barrel.referralKeys).toBeDefined()
+ expect(barrel.apiKeyKeys).toBeDefined()
+ expect(barrel.configKeys).toBeDefined()
+ expect(barrel.calendarKeys).toBeDefined()
+ expect(barrel.userFactKeys).toBeDefined()
+ expect(barrel.checklistTemplateKeys).toBeDefined()
+ expect(barrel.QUERY_STALE_TIMES).toBeDefined()
+ })
+
+ it('validation/index re-exports form schemas', async () => {
+ const barrel = await import('../validation/index')
+ expect(barrel.habitFormSchema).toBeDefined()
+ expect(barrel.goalFormSchema).toBeDefined()
+ expect(barrel.validateEndDate).toBeDefined()
+ expect(barrel.validateEndTime).toBeDefined()
+ expect(barrel.validateTime).toBeDefined()
+ expect(barrel.validateFrequency).toBeDefined()
+ expect(barrel.validateScheduledReminders).toBeDefined()
+ expect(barrel.validateHabitForm).toBeDefined()
+ })
+})