From 416bd576b06b24685caad3769e5e11bc5ada7060 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 5 Apr 2026 11:33:53 -0300 Subject: [PATCH] fix: resolve 4 SonarQube issues and align mobile with web parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarQube fixes (web): - S6754: Replace unused useState with useMemo for showGeneralOnToday - S6754: Rename searchQuery to localSearchQuery for symmetric naming - S3776: Extract helpers to reduce extractFetchError cognitive complexity - S6847: Remove unnecessary onClick/onKeyDown from dialog element Mobile parity fixes: - use-habits: Add linked goal updates, gamification XP, profile streak on log - use-habit-form: Support configurable weekStartDay (was hardcoded Monday) - use-retrospective: Use i18n locale instead of hardcoded English - use-tag-selection: Align maxTags default to 10 (was 5) - use-time-format: Fix async-in-useMemo bug, use useEffect instead - ui-store: Add manuallySelectedIds, toggleSelectionCascade, selectAllHabits, showCreateGoalModal to match web store - query-client: Align staleTime (5m), gcTime (24h), retry logic with web - Add missing lib files: plural.ts, habit-request-builders.ts, habit-optimistic-helpers.ts - support: Add name/email fields, profile pre-fill, validation, i18n - privacy: Replace hardcoded English with i18n keys - retrospective: Add period selector, generate button, cache indicator - preferences: Fix "Portugues" typo to "Português" Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mobile/app/preferences.tsx | 2 +- apps/mobile/app/privacy.tsx | 59 ++---- apps/mobile/app/retrospective.tsx | 165 +++++++++------- apps/mobile/app/support.tsx | 112 +++++++++-- apps/mobile/hooks/use-habit-form.ts | 12 +- apps/mobile/hooks/use-habits.ts | 39 +++- apps/mobile/hooks/use-retrospective.ts | 3 +- apps/mobile/hooks/use-tag-selection.ts | 2 +- apps/mobile/hooks/use-time-format.ts | 4 +- apps/mobile/lib/habit-optimistic-helpers.ts | 80 ++++++++ apps/mobile/lib/habit-request-builders.ts | 199 ++++++++++++++++++++ apps/mobile/lib/plural.ts | 17 ++ apps/mobile/lib/query-client.ts | 15 +- apps/mobile/stores/ui-store.ts | 52 ++++- apps/web/app/(app)/page.tsx | 16 +- apps/web/app/(auth)/login/page.tsx | 48 +++-- apps/web/components/ui/app-overlay.tsx | 2 - 17 files changed, 657 insertions(+), 170 deletions(-) create mode 100644 apps/mobile/lib/habit-optimistic-helpers.ts create mode 100644 apps/mobile/lib/habit-request-builders.ts create mode 100644 apps/mobile/lib/plural.ts diff --git a/apps/mobile/app/preferences.tsx b/apps/mobile/app/preferences.tsx index 9c086bea3..80fb40af7 100644 --- a/apps/mobile/app/preferences.tsx +++ b/apps/mobile/app/preferences.tsx @@ -35,7 +35,7 @@ const colors = { // Language options const LANGUAGE_OPTIONS: { value: 'en' | 'pt-BR'; label: string }[] = [ { value: 'en', label: 'English' }, - { value: 'pt-BR', label: 'Portugues' }, + { value: 'pt-BR', label: 'Português' }, ] // --------------------------------------------------------------------------- diff --git a/apps/mobile/app/privacy.tsx b/apps/mobile/app/privacy.tsx index 6eb604cb3..187baed81 100644 --- a/apps/mobile/app/privacy.tsx +++ b/apps/mobile/app/privacy.tsx @@ -13,6 +13,7 @@ import { ShieldCheck, ExternalLink, } from 'lucide-react-native' +import { useTranslation } from 'react-i18next' // --------------------------------------------------------------------------- // Colors @@ -36,41 +37,18 @@ const colors = { export default function PrivacyScreen() { const router = useRouter() + const { t } = useTranslation() const PRIVACY_URL = 'https://useorbit.org/privacy' - const sections = [ - { - title: 'Data Collection', - content: - 'Orbit collects only the data necessary to provide our service: your email, habit data, and optional AI conversation history. We never sell your data.', - }, - { - title: 'Data Storage', - content: - 'Your data is stored securely on encrypted servers. Habit logs, goals, and profile data are associated with your account and accessible only by you.', - }, - { - title: 'AI & Privacy', - content: - 'When AI features are enabled, your habit data is processed to generate summaries and insights. This data is never used to train AI models or shared with third parties.', - }, - { - title: 'Third Parties', - content: - 'We use essential third-party services for authentication (Google OAuth), payments (Stripe), and infrastructure. These services only receive the minimum data required.', - }, - { - title: 'Data Deletion', - content: - 'You can delete your account at any time from Profile > Account Actions. Your data will be permanently deleted within 30 days. You can also use "Fresh Start" to wipe your data while keeping your account.', - }, - { - title: 'Your Rights', - content: - 'You have the right to access, export, and delete your data. Contact support@useorbit.org for any data-related requests.', - }, - ] + const SECTION_KEYS = [ + 'dataCollection', + 'dataStorage', + 'aiPrivacy', + 'thirdParties', + 'dataDeletion', + 'yourRights', + ] as const return ( @@ -88,24 +66,23 @@ export default function PrivacyScreen() { > - Privacy Policy + {t('privacy.title')} {/* Shield badge */} - Your privacy matters + {t('privacy.badge')} - Orbit is designed with privacy first. We collect minimal data and - never sell it. + {t('privacy.badgeDescription')} {/* Sections */} - {sections.map((section) => ( - - {section.title} - {section.content} + {SECTION_KEYS.map((key) => ( + + {t(`privacy.${key}.title`)} + {t(`privacy.${key}.content`)} ))} @@ -117,7 +94,7 @@ export default function PrivacyScreen() { > - Read Full Privacy Policy + {t('privacy.fullPolicy')} diff --git a/apps/mobile/app/retrospective.tsx b/apps/mobile/app/retrospective.tsx index 14680f107..4e2802eea 100644 --- a/apps/mobile/app/retrospective.tsx +++ b/apps/mobile/app/retrospective.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import React from 'react' import { View, Text, @@ -9,12 +9,9 @@ import { ActivityIndicator, } from 'react-native' import { useRouter } from 'expo-router' -import { ArrowLeft, Sparkles, ChevronLeft, ChevronRight } from 'lucide-react-native' -import { useQuery } from '@tanstack/react-query' -import { format, subWeeks, addWeeks, startOfWeek, endOfWeek } from 'date-fns' -import { habitKeys } from '@orbit/shared/query' -import { formatAPIDate } from '@orbit/shared/utils' -import { apiClient } from '@/lib/api-client' +import { ArrowLeft, Sparkles } from 'lucide-react-native' +import { useTranslation } from 'react-i18next' +import { useRetrospective, type RetrospectivePeriod } from '@/hooks/use-retrospective' // --------------------------------------------------------------------------- // Colors @@ -35,22 +32,20 @@ const colors = { // Retrospective Screen // --------------------------------------------------------------------------- +const PERIODS: RetrospectivePeriod[] = ['week', 'month', 'quarter', 'semester', 'year'] + export default function RetrospectiveScreen() { const router = useRouter() - const [currentWeek, setCurrentWeek] = useState(() => startOfWeek(new Date())) - - const weekStart = formatAPIDate(currentWeek) - const weekEnd = formatAPIDate(endOfWeek(currentWeek)) - const periodLabel = `${format(currentWeek, 'MMM d')} - ${format(endOfWeek(currentWeek), 'MMM d, yyyy')}` - - const { data, isLoading, error } = useQuery({ - queryKey: habitKeys.retrospective(weekStart), - queryFn: () => - apiClient<{ retrospective: string }>( - `/api/habits/retrospective?period=week&date=${weekStart}`, - ), - staleTime: 10 * 60 * 1000, - }) + const { t } = useTranslation() + const { + retrospective, + isLoading, + error, + fromCache, + period, + setPeriod, + generate, + } = useRetrospective() return ( @@ -68,54 +63,71 @@ export default function RetrospectiveScreen() { > - Retrospective + {t('retrospective.title')} - {/* Week navigation */} - - setCurrentWeek((w) => subWeeks(w, 1))} - activeOpacity={0.7} - > - - - {periodLabel} - setCurrentWeek((w) => addWeeks(w, 1))} - activeOpacity={0.7} - > - - - + {/* Period selector */} + + {PERIODS.map((p) => ( + setPeriod(p)} + activeOpacity={0.7} + > + + {t(`retrospective.periods.${p}`)} + + + ))} + + + {/* Generate button */} + + {isLoading ? ( + + ) : ( + + )} + {t('retrospective.generate')} + {/* Content */} {isLoading && ( - Generating your retrospective... + {t('retrospective.generating')} )} {error && ( - - Failed to generate retrospective. This feature requires a Pro - subscription and completed habits for the selected week. - + {error} )} - {data?.retrospective && ( + {retrospective && ( - AI Retrospective + + {t('retrospective.aiTitle')} + {fromCache && ({t('retrospective.cached')})} + - {data.retrospective} + {retrospective} )} @@ -145,28 +157,55 @@ const styles = StyleSheet.create({ color: colors.textPrimary, letterSpacing: -0.5, }, - weekNav: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + periodScroll: { + marginBottom: 12, + }, + periodScrollContent: { + gap: 8, + paddingVertical: 2, + }, + periodChip: { + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 20, backgroundColor: colors.surface, - borderRadius: 14, borderWidth: 1, borderColor: colors.border, - padding: 4, - marginBottom: 16, }, - weekNavButton: { - width: 40, - height: 40, - borderRadius: 12, + periodChipActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + periodChipText: { + fontSize: 13, + fontWeight: '600', + color: colors.textMuted, + }, + periodChipTextActive: { + color: '#fff', + }, + generateButton: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + gap: 8, + backgroundColor: colors.primary, + borderRadius: 16, + paddingVertical: 14, + marginBottom: 16, }, - weekLabel: { - fontSize: 14, - fontWeight: '600', - color: colors.textPrimary, + generateButtonDisabled: { + opacity: 0.5, + }, + generateButtonText: { + fontSize: 15, + fontWeight: '700', + color: '#fff', + }, + cacheIndicator: { + fontSize: 12, + fontWeight: '400', + color: colors.textMuted, }, loadingContainer: { alignItems: 'center', diff --git a/apps/mobile/app/support.tsx b/apps/mobile/app/support.tsx index 66a2b6c23..b1b128199 100644 --- a/apps/mobile/app/support.tsx +++ b/apps/mobile/app/support.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { View, Text, @@ -12,6 +12,9 @@ import { } from 'react-native' import { useRouter } from 'expo-router' import { ArrowLeft, Send } from 'lucide-react-native' +import { useTranslation } from 'react-i18next' +import { isValidEmail } from '@orbit/shared/utils/email' +import { useProfile } from '@/hooks/use-profile' import { apiClient } from '@/lib/api-client' // --------------------------------------------------------------------------- @@ -36,27 +39,67 @@ const colors = { export default function SupportScreen() { const router = useRouter() + const { t } = useTranslation() + const { profile } = useProfile() + const [name, setName] = useState('') + const [email, setEmail] = useState('') const [subject, setSubject] = useState('') const [message, setMessage] = useState('') const [sending, setSending] = useState(false) + const [nameError, setNameError] = useState(null) + const [emailError, setEmailError] = useState(null) + + // Pre-fill from profile + useEffect(() => { + if (profile) { + setName(profile.name ?? '') + setEmail(profile.email ?? '') + } + }, [profile]) + + function validateForm(): boolean { + setNameError(null) + setEmailError(null) + let valid = true + + const effectiveName = name.trim() || profile?.name + if (!effectiveName) { + setNameError(t('profile.support.nameRequired')) + valid = false + } + + const effectiveEmail = email.trim() || profile?.email + if (!effectiveEmail) { + setEmailError(t('profile.support.emailRequired')) + valid = false + } else if (!isValidEmail(effectiveEmail)) { + setEmailError(t('profile.support.emailInvalid')) + valid = false + } + + return valid + } async function handleSend() { if (!subject.trim() || !message.trim()) return + if (!validateForm()) return setSending(true) try { await apiClient('/api/support', { method: 'POST', body: JSON.stringify({ + name: name.trim() || profile?.name, + email: email.trim() || profile?.email, subject: subject.trim(), message: message.trim(), }), }) - Alert.alert('Sent', 'Your message has been sent. We will get back to you soon.', [ + Alert.alert(t('profile.support.title'), t('profile.support.success'), [ { text: 'OK', onPress: () => router.back() }, ]) } catch (err: unknown) { - const msg = err instanceof Error ? err.message : 'Failed to send.' - Alert.alert('Error', msg) + const msg = err instanceof Error ? err.message : t('auth.genericError') + Alert.alert(t('common.error'), msg) } finally { setSending(false) } @@ -79,32 +122,59 @@ export default function SupportScreen() { > - Support + {t('profile.support.title')} - Send us a message + {t('profile.support.title')} - Have a question, found a bug, or want to suggest a feature? Let us - know. + {t('profile.support.description')} - Subject + {/* Name & Email row */} + + + {t('profile.support.namePlaceholder')} + + {nameError && {nameError}} + + + {t('profile.support.emailPlaceholder')} + + {emailError && {emailError}} + + + + {t('profile.support.subjectPlaceholder')} - Message + {t('profile.support.messagePlaceholder')} - Send Message + {t('profile.support.send')} )} @@ -217,4 +287,20 @@ const styles = StyleSheet.create({ fontWeight: '700', color: '#fff', }, + row: { + flexDirection: 'row', + gap: 12, + }, + halfField: { + flex: 1, + }, + inputError: { + borderColor: '#ef4444', + }, + errorText: { + fontSize: 11, + color: '#f87171', + marginTop: 4, + paddingHorizontal: 4, + }, }) diff --git a/apps/mobile/hooks/use-habit-form.ts b/apps/mobile/hooks/use-habit-form.ts index 065444995..b0b7ea71c 100644 --- a/apps/mobile/hooks/use-habit-form.ts +++ b/apps/mobile/hooks/use-habit-form.ts @@ -16,6 +16,7 @@ import type { FrequencyUnit } from '@orbit/shared/types/habit' export interface HabitFormOptions { initialData?: Partial + weekStartDay?: number // 0 = Sunday, 1 = Monday (default) } export interface HabitFormHelpers { @@ -55,7 +56,7 @@ export interface HabitFormHelpers { // --------------------------------------------------------------------------- export function useHabitForm(options: HabitFormOptions = {}): HabitFormHelpers { - const { initialData } = options + const { initialData, weekStartDay = 1 } = options const { t } = useTranslation() const form = useForm({ @@ -95,7 +96,7 @@ export function useHabitForm(options: HabitFormOptions = {}): HabitFormHelpers { // -- Day picker list -- const daysList = useMemo(() => { - return [ + const mondayFirst = [ { value: 'Monday', label: t('dates.daysShort.monday') }, { value: 'Tuesday', label: t('dates.daysShort.tuesday') }, { value: 'Wednesday', label: t('dates.daysShort.wednesday') }, @@ -104,7 +105,12 @@ export function useHabitForm(options: HabitFormOptions = {}): HabitFormHelpers { { value: 'Saturday', label: t('dates.daysShort.saturday') }, { value: 'Sunday', label: t('dates.daysShort.sunday') }, ] - }, [t]) + // weekStartDay === 0 means Sunday-first + if (weekStartDay === 0) { + return [mondayFirst[6]!, ...mondayFirst.slice(0, 6)] + } + return mondayFirst + }, [t, weekStartDay]) // -- Frequency units list -- const frequencyUnits = useMemo( diff --git a/apps/mobile/hooks/use-habits.ts b/apps/mobile/hooks/use-habits.ts index ac80b2322..f13b6172f 100644 --- a/apps/mobile/hooks/use-habits.ts +++ b/apps/mobile/hooks/use-habits.ts @@ -18,6 +18,7 @@ import type { HabitMetrics, HabitFullDetail, LogHabitResponse, + LinkedGoalUpdate, CreateHabitRequest, UpdateHabitRequest, ReorderHabitsRequest, @@ -32,6 +33,9 @@ import type { BulkSkipItemRequest, BulkSkipResult, } from '@orbit/shared/types/habit' +import type { Goal } from '@orbit/shared/types/goal' +import type { Profile } from '@orbit/shared/types/profile' +import type { GamificationProfile } from '@orbit/shared/types/gamification' import type { HabitLog } from '@orbit/shared/types/calendar' import type { CalendarDayEntry, HabitDayStatus } from '@orbit/shared/types/calendar' import type { CalendarMonthResponse } from '@orbit/shared/types/habit' @@ -44,6 +48,20 @@ import { useUIStore } from '@/stores/ui-store' // Helpers // --------------------------------------------------------------------------- +function applyLinkedGoalUpdates(goals: Goal[], updates: LinkedGoalUpdate[]): Goal[] { + return goals.map((goal) => { + const update = updates.find(u => u.goalId === goal.id) + if (!update) return goal + return { + ...goal, + currentValue: update.newProgress, + progressPercentage: update.targetValue > 0 + ? Math.min(100, Math.round(update.newProgress / update.targetValue * 1000) / 10) + : 0, + } + }) +} + function buildQueryString(filters: HabitsFilter): string { const params = new URLSearchParams() if (filters.dateFrom) params.append('dateFrom', filters.dateFrom) @@ -381,9 +399,28 @@ export function useLogHabit() { }, onSuccess: (response) => { - // Streak celebration + // Streak celebration + update profile streak immediately so StreakBadge reflects it if (response?.isFirstCompletionToday && response.currentStreak > 0) { setStreakCelebration({ streak: response.currentStreak }) + queryClient.setQueryData(profileKeys.detail(), (old) => + old ? { ...old, currentStreak: response.currentStreak } : old, + ) + } + + // Apply targeted goal updates from enriched response (instant, no refetch needed) + if (response?.linkedGoalUpdates?.length) { + queryClient.setQueriesData( + { queryKey: goalKeys.lists() }, + (old) => old ? applyLinkedGoalUpdates(old, response.linkedGoalUpdates!) : old, + ) + } + + // Apply gamification XP/achievement updates from enriched response (instant) + if (response?.xpEarned || response?.newAchievementIds?.length) { + queryClient.setQueryData(gamificationKeys.profile(), (old) => { + if (!old) return old + return { ...old, totalXp: old.totalXp + (response.xpEarned ?? 0) } + }) } // Check all-done celebration diff --git a/apps/mobile/hooks/use-retrospective.ts b/apps/mobile/hooks/use-retrospective.ts index ec144f4be..8e724bfa4 100644 --- a/apps/mobile/hooks/use-retrospective.ts +++ b/apps/mobile/hooks/use-retrospective.ts @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react' import { useTranslation } from 'react-i18next' +import i18n from '@/lib/i18n' import { API } from '@orbit/shared/api' import { getErrorMessage } from '@orbit/shared/utils' import { apiClient } from '@/lib/api-client' @@ -27,7 +28,7 @@ export function useRetrospective() { try { const params = new URLSearchParams({ period, - language: 'en', + language: i18n.language ?? 'en', }) const data = await apiClient( `${API.habits.retrospective}?${params.toString()}`, diff --git a/apps/mobile/hooks/use-tag-selection.ts b/apps/mobile/hooks/use-tag-selection.ts index 208956c41..1d6478ab5 100644 --- a/apps/mobile/hooks/use-tag-selection.ts +++ b/apps/mobile/hooks/use-tag-selection.ts @@ -63,7 +63,7 @@ export interface TagSelectionState { export function useTagSelection( initialTagIds: string[] = [], - maxTags = 5, + maxTags = 10, ): TagSelectionState { const [selectedTagIds, setSelectedTagIds] = useState([...initialTagIds]) const [showNewTag, setShowNewTag] = useState(false) diff --git a/apps/mobile/hooks/use-time-format.ts b/apps/mobile/hooks/use-time-format.ts index abcd0531e..1a41fc938 100644 --- a/apps/mobile/hooks/use-time-format.ts +++ b/apps/mobile/hooks/use-time-format.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo } from 'react' +import { useState, useCallback, useMemo, useEffect } from 'react' import AsyncStorage from '@react-native-async-storage/async-storage' export type TimeFormat = '12h' | '24h' @@ -28,7 +28,7 @@ export function useTimeFormat() { const [currentFormat, setCurrentFormat] = useState(detectDefaultFormat) // Load saved format on init - useMemo(() => { + useEffect(() => { AsyncStorage.getItem('orbit_time_format').then((saved) => { if (saved === '12h' || saved === '24h') { setCurrentFormat(saved) diff --git a/apps/mobile/lib/habit-optimistic-helpers.ts b/apps/mobile/lib/habit-optimistic-helpers.ts new file mode 100644 index 000000000..5a07920e2 --- /dev/null +++ b/apps/mobile/lib/habit-optimistic-helpers.ts @@ -0,0 +1,80 @@ +/** + * Optimistic update helpers for habit list cache mutations. + * + * Extracted to reduce nesting depth and cognitive complexity, + * and to ensure parity between web and mobile. + */ + +import type { + HabitScheduleItem, + ChecklistItem, +} from '@orbit/shared/types/habit' + +// --------------------------------------------------------------------------- +// Toggle completion +// --------------------------------------------------------------------------- + +/** Toggle isCompleted on a single habit item, resetting checklist if needed */ +function toggleHabitCompletion(item: HabitScheduleItem): HabitScheduleItem { + const wasCompleted = item.isCompleted + const updated = { ...item, isCompleted: !item.isCompleted } + if (!wasCompleted && item.frequencyUnit && item.checklistItems?.length > 0) { + updated.checklistItems = item.checklistItems.map((i) => ({ ...i, isChecked: false })) + } + return updated +} + +/** Toggle isCompleted on a child within an item's children array */ +function toggleChildCompletion( + item: HabitScheduleItem, + habitId: string, +): HabitScheduleItem { + return { + ...item, + children: item.children.map((c) => { + if (c.id !== habitId) return c + const wasCompleted = c.isCompleted + const updated = { ...c, isCompleted: !c.isCompleted } + if (!wasCompleted && c.frequencyUnit && c.checklistItems?.length > 0) { + updated.checklistItems = c.checklistItems.map((i) => ({ ...i, isChecked: false })) + } + return updated + }), + } +} + +/** Optimistically toggle completion for a habit in a list (parent or child) */ +export function optimisticToggleCompletion( + items: HabitScheduleItem[], + habitId: string, +): HabitScheduleItem[] { + return items.map((item) => { + if (item.id === habitId) return toggleHabitCompletion(item) + if (item.children.some((c) => c.id === habitId)) return toggleChildCompletion(item, habitId) + return item + }) +} + +// --------------------------------------------------------------------------- +// Update checklist +// --------------------------------------------------------------------------- + +/** Optimistically update checklist items for a habit in a list */ +export function optimisticUpdateChecklist( + items: HabitScheduleItem[], + habitId: string, + newItems: ChecklistItem[], +): HabitScheduleItem[] { + return items.map((item) => { + if (item.id === habitId) return { ...item, checklistItems: newItems } + if (item.children.some((c) => c.id === habitId)) { + return { + ...item, + children: item.children.map((c) => + c.id === habitId ? { ...c, checklistItems: newItems } : c, + ), + } + } + return item + }) +} diff --git a/apps/mobile/lib/habit-request-builders.ts b/apps/mobile/lib/habit-request-builders.ts new file mode 100644 index 000000000..71b025e7b --- /dev/null +++ b/apps/mobile/lib/habit-request-builders.ts @@ -0,0 +1,199 @@ +/** + * Request builder helpers for habit create/edit modals. + * + * Shared logic extracted to reduce cognitive complexity + * and ensure parity between web and mobile. + */ + +import type { + CreateHabitRequest, + CreateSubHabitRequest, + UpdateHabitRequest, + ScheduledReminderTime, +} from '@orbit/shared/types/habit' + +// --------------------------------------------------------------------------- +// Shared form data shape +// --------------------------------------------------------------------------- + +export interface HabitFormData { + title: string + description: string + isGeneral: boolean + isFlexible: boolean + frequencyUnit: 'Day' | 'Week' | 'Month' | 'Year' | null + frequencyQuantity: number | null + days: string[] + dueDate: string + dueTime: string + dueEndTime: string + endDate: string + isBadHabit: boolean + slipAlertEnabled: boolean + reminderEnabled: boolean + scheduledReminders: ScheduledReminderTime[] + checklistItems: Array<{ text: string; isChecked: boolean }> +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function applyScheduleFields( + req: Record, + data: HabitFormData, +): void { + if (data.dueDate) req.dueDate = data.dueDate + if (data.isFlexible) { + req.isFlexible = true + if (data.frequencyUnit) req.frequencyUnit = data.frequencyUnit + if (data.frequencyQuantity) req.frequencyQuantity = data.frequencyQuantity + } else if (data.frequencyUnit) { + req.frequencyUnit = data.frequencyUnit + req.frequencyQuantity = data.frequencyQuantity ?? undefined + if (data.days?.length) req.days = data.days + if (data.endDate) req.endDate = data.endDate + } +} + +function applyReminderFields( + req: Record, + data: HabitFormData, + reminderTimes: number[], +): void { + if (data.dueTime) { + req.dueTime = data.dueTime + if (data.dueEndTime) req.dueEndTime = data.dueEndTime + req.reminderEnabled = data.reminderEnabled + req.reminderTimes = reminderTimes + return + } + if (data.reminderEnabled && (data.scheduledReminders?.length ?? 0) > 0) { + req.reminderEnabled = true + req.scheduledReminders = data.scheduledReminders ?? undefined + } +} + +// --------------------------------------------------------------------------- +// Create: sub-habit request +// --------------------------------------------------------------------------- + +export function buildSubHabitRequest( + data: HabitFormData, + reminderTimes: number[], + tagIds: string[], +): CreateSubHabitRequest { + const req = { title: data.title } as Record + if (data.description) req.description = data.description + if (!data.isGeneral) { + applyScheduleFields(req, data) + applyReminderFields(req, data, reminderTimes) + } + if (data.isBadHabit) { + req.isBadHabit = true + req.slipAlertEnabled = data.slipAlertEnabled + } + if (data.checklistItems?.length) req.checklistItems = data.checklistItems + if (tagIds.length) req.tagIds = tagIds + return req as unknown as CreateSubHabitRequest +} + +// --------------------------------------------------------------------------- +// Create: top-level habit request +// --------------------------------------------------------------------------- + +export function buildCreateHabitRequest( + data: HabitFormData, + reminderTimes: number[], + tagIds: string[], + goalIds: string[], + subHabits: string[], +): CreateHabitRequest { + const req: Record = { + title: data.title, + isBadHabit: data.isBadHabit, + } + if (data.description) req.description = data.description + if (data.isGeneral) { + req.isGeneral = true + } else { + req.dueDate = data.dueDate + applyScheduleFields(req, data) + applyReminderFields(req, data, reminderTimes) + } + if (data.isBadHabit) req.slipAlertEnabled = data.slipAlertEnabled + if (data.checklistItems?.length) req.checklistItems = data.checklistItems + if (tagIds.length) req.tagIds = tagIds + if (goalIds.length) req.goalIds = goalIds + const filtered = subHabits.filter((s) => s.trim()) + if (filtered.length) req.subHabits = filtered + return req as unknown as CreateHabitRequest +} + +// --------------------------------------------------------------------------- +// Update: edit habit request +// --------------------------------------------------------------------------- + +function applyUpdateScheduleFields( + request: UpdateHabitRequest, + data: HabitFormData, + isOneTime: boolean, + originalEndDate: string, +): void { + if (data.dueDate) request.dueDate = data.dueDate + if (isOneTime) return + 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) { + request.clearEndDate = true + } +} + +function applyUpdateReminderFields( + request: UpdateHabitRequest, + data: HabitFormData, + reminderTimes: number[], +): void { + if (data.dueTime) { + request.dueTime = data.dueTime + request.dueEndTime = data.dueEndTime || undefined + request.reminderEnabled = data.reminderEnabled + request.reminderTimes = reminderTimes + return + } + if (data.reminderEnabled && (data.scheduledReminders?.length ?? 0) > 0) { + request.reminderEnabled = true + request.scheduledReminders = data.scheduledReminders ?? undefined + return + } + request.reminderEnabled = false +} + +export function buildUpdateHabitRequest( + data: HabitFormData, + isOneTime: boolean, + originalEndDate: string, + reminderTimes: number[], + selectedGoalIds: string[], +): UpdateHabitRequest { + const request: UpdateHabitRequest = { + title: data.title, + isBadHabit: data.isBadHabit, + isGeneral: data.isGeneral, + isFlexible: data.isFlexible, + } + if (data.description) request.description = data.description + + if (!data.isGeneral) { + applyUpdateScheduleFields(request, data, isOneTime, originalEndDate) + applyUpdateReminderFields(request, data, reminderTimes) + } + + request.slipAlertEnabled = data.isBadHabit ? data.slipAlertEnabled : false + if (data.checklistItems?.length) request.checklistItems = data.checklistItems + request.goalIds = selectedGoalIds + return request +} diff --git a/apps/mobile/lib/plural.ts b/apps/mobile/lib/plural.ts new file mode 100644 index 000000000..9830ece58 --- /dev/null +++ b/apps/mobile/lib/plural.ts @@ -0,0 +1,17 @@ +/** + * Handle pipe-separated plural strings from shared i18n JSON files. + * Format: "singular | plural" or "zero | singular | plural". + */ +export function plural(text: string, count: number): string { + if (!text.includes(' | ')) return text + const forms = text.split(' | ').map(s => s.trim()) + if (forms.length === 2) { + return count === 1 ? forms[0]! : forms[1]! + } + if (forms.length === 3) { + if (count === 0) return forms[0]! + if (count === 1) return forms[1]! + return forms[2]! + } + return text +} diff --git a/apps/mobile/lib/query-client.ts b/apps/mobile/lib/query-client.ts index 7712b9822..82e74a6c1 100644 --- a/apps/mobile/lib/query-client.ts +++ b/apps/mobile/lib/query-client.ts @@ -4,13 +4,18 @@ import AsyncStorage from '@react-native-async-storage/async-storage' export const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 1000 * 60 * 2, // 2 minutes - gcTime: 1000 * 60 * 30, // 30 minutes - retry: 2, - refetchOnWindowFocus: false, // not applicable on mobile, but set explicitly + staleTime: 1000 * 60 * 5, // 5 minutes (matches web) + gcTime: 1000 * 60 * 60 * 24, // 24 hours (matches web, supports offline) + retry: (failureCount, error) => { + // Don't retry on 401 (auth errors) + if (error instanceof Error && error.message.includes('401')) return false + return failureCount < 3 + }, + refetchOnWindowFocus: false, // not applicable on mobile + refetchOnReconnect: true, }, mutations: { - retry: 1, + retry: false, }, }, }) diff --git a/apps/mobile/stores/ui-store.ts b/apps/mobile/stores/ui-store.ts index 3fca40867..ca792fd31 100644 --- a/apps/mobile/stores/ui-store.ts +++ b/apps/mobile/stores/ui-store.ts @@ -30,15 +30,25 @@ interface UIState { // Select mode (bulk operations) isSelectMode: boolean selectedHabitIds: Set + manuallySelectedIds: Set lastCreatedHabitId: string | null toggleSelectMode: () => void toggleHabitSelection: (id: string) => void + /** Cascade-aware toggle: selects/deselects all descendants with the parent */ + toggleSelectionCascade: ( + habitId: string, + getDescendantIds: (id: string) => string[], + isAncestorSelected: (id: string) => boolean, + ) => void + selectAllHabits: (allIds: string[]) => void clearSelection: () => void setLastCreatedHabitId: (id: string | null) => void - // Create modal (shared between layout BottomNav and pages) + // Create modals (shared between layout BottomNav and pages) showCreateModal: boolean setShowCreateModal: (show: boolean) => void + showCreateGoalModal: boolean + setShowCreateGoalModal: (show: boolean) => void // Search searchQuery: string @@ -98,12 +108,14 @@ export const useUIStore = create((set, get) => ({ // -- Select mode ------------------------------------------------------------ isSelectMode: false, selectedHabitIds: new Set(), + manuallySelectedIds: new Set(), lastCreatedHabitId: null, toggleSelectMode: () => set((state) => ({ isSelectMode: !state.isSelectMode, selectedHabitIds: state.isSelectMode ? new Set() : state.selectedHabitIds, + manuallySelectedIds: state.isSelectMode ? new Set() : state.manuallySelectedIds, })), toggleHabitSelection: (id) => @@ -117,8 +129,40 @@ export const useUIStore = create((set, get) => ({ return { selectedHabitIds: next } }), + toggleSelectionCascade: (habitId, getDescendantIds, isAncestorSelected) => + set((state) => { + // If an ancestor is already selected, don't allow toggling the child + if (isAncestorSelected(habitId)) return state + + const selected = new Set(state.selectedHabitIds) + const manual = new Set(state.manuallySelectedIds) + const descendants = getDescendantIds(habitId) + + if (selected.has(habitId)) { + // Deselect: remove the habit and auto-selected descendants + selected.delete(habitId) + manual.delete(habitId) + for (const id of descendants) { + if (!manual.has(id)) selected.delete(id) + } + } else { + // Select: add the habit and all descendants + selected.add(habitId) + manual.add(habitId) + for (const id of descendants) selected.add(id) + } + + return { selectedHabitIds: selected, manuallySelectedIds: manual } + }), + + selectAllHabits: (allIds) => + set({ + selectedHabitIds: new Set(allIds), + manuallySelectedIds: new Set(allIds), + }), + clearSelection: () => - set({ isSelectMode: false, selectedHabitIds: new Set() }), + set({ isSelectMode: false, selectedHabitIds: new Set(), manuallySelectedIds: new Set() }), setLastCreatedHabitId: (id) => { if (createdHabitTimer) clearTimeout(createdHabitTimer) @@ -128,9 +172,11 @@ export const useUIStore = create((set, get) => ({ } }, - // -- Create modal ----------------------------------------------------------- + // -- Create modals ---------------------------------------------------------- showCreateModal: false, setShowCreateModal: (show) => set({ showCreateModal: show }), + showCreateGoalModal: false, + setShowCreateGoalModal: (show) => set({ showCreateGoalModal: show }), // -- Search ----------------------------------------------------------------- searchQuery: '', diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 032388395..b7f7f4291 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -277,11 +277,11 @@ export default function TodayPage() { const { data: streakInfo } = useStreakInfo() const { tags } = useTags() - // Show general on today preference (local storage) - const [showGeneralOnToday, _setShowGeneralOnToday] = useState(() => { + // Show general on today preference (local storage, read-only) + const showGeneralOnToday = useMemo(() => { if (typeof globalThis === 'undefined' || typeof globalThis.localStorage === 'undefined') return true // NOSONAR - SSR guard return localStorage.getItem('orbit_show_general_on_today') !== 'false' - }) + }, []) // Bulk mutation hooks const bulkDelete = useBulkDeleteHabits() @@ -308,7 +308,7 @@ export default function TodayPage() { // Local state const [showCompleted, setShowCompleted] = useState(false) - const [searchQuery, setLocalSearchQuery] = useState(searchQueryStore) + const [localSearchQuery, setLocalSearchQuery] = useState(searchQueryStore) const [selectedFrequency, setSelectedFrequency] = useState<'Day' | 'Week' | 'Month' | 'Year' | 'none' | null>(null) const [selectedTagIds, setSelectedTagIds] = useState([]) const [showControlsMenu, setShowControlsMenu] = useState(false) @@ -438,13 +438,13 @@ export default function TodayPage() { if (searchDebounceTimer.current) clearTimeout(searchDebounceTimer.current) searchDebounceTimer.current = setTimeout(() => { - setSearchQuery(searchQuery) + setSearchQuery(localSearchQuery) }, 300) return () => { if (searchDebounceTimer.current) clearTimeout(searchDebounceTimer.current) } - }, [searchQuery, setSearchQuery]) + }, [localSearchQuery, setSearchQuery]) // Build filters const filters = useMemo(() => { @@ -753,14 +753,14 @@ export default function TodayPage() {
setLocalSearchQuery(e.target.value)} /> - {searchQuery && ( + {localSearchQuery && (