import React, { createContext, useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react'; import * as Font from 'expo-font'; import { Animated, Alert, ActivityIndicator, BackHandler, Easing, Image, ImageSourcePropType, KeyboardAvoidingView, Linking, Modal, NativeModules, Platform, Pressable, ScrollView, StyleProp, StyleSheet, Text, TextInput, TouchableOpacity, useWindowDimensions, View, ViewStyle, ImageStyle, } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; import * as Haptics from 'expo-haptics'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system'; import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; import { DateTimePickerAndroid } from '@react-native-community/datetimepicker'; import type { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import { SafeAreaProvider, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SystemBars } from 'react-native-edge-to-edge'; import { registerBackgroundWeatherTask } from './services/background/backgroundWeatherTask'; import { enqueueAiCall, runAiCall } from './services/orchestration/aiQueue'; import { readRefreshStamp, isStampFresh, markRefreshCompleted } from './services/orchestration/refreshStamp'; import { CARE_TASK_ICONS, getCareTaskIconSource } from './constants/careTaskIcons'; import { PlantModeScreen } from './screens/PlantModeScreen'; // PhotoIntelDebugScreen — COMMENTED OUT (2026-08-15). Not in use; rewire before re-enabling. // import { PhotoIntelDebugScreen } from './screens/PhotoIntelDebugScreen'; import { createManualPlantProfile, createGardenId, defaultAppSettings, createPlantSetupProfile, createRoomProfile, loadAppSettings, loadCareTaskActionStates, loadFieldAlbums, loadGardenInfrastructure, loadPlantNotificationStates, loadSavedPlantProfiles, loadWishlistItems, migratePlantSpaceLinks, normalizeStringArray, saveAppSettings, saveCareTaskActionStates, saveGardenInfrastructure, savePlantNotificationStates, saveSavedPlantProfiles, saveFieldAlbums, saveWishlistItems, updatePlantSetupProfile, updateRoomProfile, updateSavedPlantProfile, DEFAULT_AFTER_WORK_TIME, } from './services/gardenStorage'; import { findSpaceForPlant, getPlantsForSpace, normalizeGardenName, resolvePlantSpaceId as resolveStoredPlantSpaceId, } from './services/gardenSelectors'; import { analyzeProblem, type ProblemScanContextType, type ProblemScanInput, type ProblemScanResult } from './services/problemScanService'; import { analyzeAndStorePhoto, retryPhotoAnalysis, recoverStuckAnalyses } from './services/plantIntelligence/photoIntelligenceExtractor'; import { buildInvestigationContextPacket, deleteIntelligenceProfile } from './services/plantIntelligence/plantIntelligenceService'; import { buildCareContextPacket, buildCohortSignature, buildCohortCarePacket, renderCareContextPacket, determineSpaceCohorts, forceSharedContainerCohorts, foldNewcomerIntoCohorts, classifyDivergence, evaluateDivergingMember, fetchCohortCare, COHORT_GROUPING_THRESHOLD, fingerprintContextPacket } from './services/care'; import { evaluateWeatherWarnings, buildCadenceFyi, enrichAlertAdvice, enrichAlertTips, alertSignature } from './services/care'; import { loadWeatherWarningsCache, saveWeatherWarningsCache, reconcileWeatherWarnings } from './services/care/weatherWarningCache'; import { preserveStableInspectGuidance } from './services/plantEnrichment'; import type { WeatherWarning, WeatherFyi, WeatherAlertInput } from './services/care'; import { loadAlertAdviceCache, saveAlertAdviceCache, pruneAlertAdviceCache } from './services/care/alertAdviceCache'; import type { WeatherSnapshot, GroupableCareAction, Cohort } from './types/care'; import { openCohortHistoryEntry, appendCohortHistoryEntry } from './types/cohortHistory'; import type { CohortCareResult } from './services/care'; import { getWeather, readCachedWeather, getMonthlyRainNormal, getRainHistory, compressWeatherSignals, lightRegionFromGardenLocation } from './services/weather'; import { CareMetricsModal } from './components/CareMetricsModal'; import type { AnalysisStatus, PhotoAnalysisInput } from './services/plantIntelligence/types'; import { persistPhoto, migrateCachePhotos, deletePhotosForPlant } from './services/photoStorage'; import { runPhotoDegradation, previewDegradation, formatBytes } from './services/photoStorage/degradationService'; import { createInvestigation, getAllInvestigations, deleteInvestigation, linkMemoryToInvestigation, addPhotosToInvestigation, linkPlantToInvestigation, updateInvestigation } from './services/investigation'; import type { Investigation, InvestigationPhoto, InvestigationPhotoCategory, StepExecution } from './services/investigation'; import { getSpaceArtwork } from './assets/spaceArtwork'; import type { AppSettings, ArtificialLightType, CareReminderPreferences, CareTaskActionState, CareTaskActionStatus, CareTaskDetailMetadata, CareTaskNotificationQuickAction, CareTaskSource, ChildAgeRange, FieldAlbum, FieldDiaryEntry, GardenContextPhoto, GardenInfrastructureState, HumidityProfile, LightProfile, OutdoorLightQuality, OutdoorSunTiming, PetAccessMode, PetType, PlantEvent, PlantEventType, PlantNotification, PlantNotificationState, PlantSetupProfile, RoomProfile, SeasonProfile, SpaceType, WishlistItem, WishlistVisibility } from './types/garden'; import type { SavedPlantIdentificationSuggestion, SavedPlantProfile } from './types/plantScan'; import { TabKey, IdentifyMode, IdentifyImage, IdentifyRequest, SamplePlant, GardenItem, gardenPlants, savedGardenKey, sampleGardenKey, GardenSection, PlantFilter, PlantSort, SpaceSort, GardenForm, GardenDetail, PlantMemoryEventType, PlantEventFormState, PlantFormTarget, SetupFormTarget, SpaceFormTarget, DetailFormTarget, DetailHighlightTarget, PlantIntelligenceAction, PlantKnowledgeItem, PlantKnowledgeCategory, PlantKnowledgeSummary, PlantIntelligenceSummary, HomeGardenSnapshotSummary, SpaceSummary, CareTiming, CareTaskItem, CareFeedbackState, NotificationSnoozeOption, Option, PlantFormState, SpaceFormState, SetupFormState, plantFilterOptions, plantSortOptions, plantTypeOptions, growthStageOptions, timeOwnedOptions, plantingMediumOptions, treatmentPreferenceOptions, spaceSortOptions, spaceTypeOptions, windowDirections, outdoorSunTimingOptions, outdoorLightQualityOptions, artificialLightTypeOptions, outdoorSpaceTypes, SAVED_PREFIX, } from './types/appTypes'; import { brand, green, lime, dark, parchment, line, brown, haptic, pressWithHaptic } from './constants/theme'; import { NotificationCenterContext, SettingsContext } from './constants/contexts'; import { UiIcon, Pill, Card, EmptyState, GardenChoiceChips, GardenMultiChips } from './components/BaseUI'; import { PixieAlert } from './components/PixieAlert'; import { FormTextField, FormImagePicker, ContextPhotoStack, SaveCancelActions } from './components/FormUI'; import { SpaceCard } from './components/GardenCards'; import { CareTaskIcon, CareTaskCard, CareUpcomingGroup, CareCompletedRow, CareFeedback, CareTaskDetailSections, getCareTaskDetailStatus, getJournalTaskBrief } from './components/CareComponents'; import { NotificationCenter, NotificationSnoozeSheet, getTaskForNotification, getNotificationScheduleLabel, getNotificationSnoozeOptionLabel, notificationSnoozeOptions } from './components/NotificationCenter'; import { SettingsPanel, SettingsChip, SettingsSection } from './components/SettingsPanel'; import { isOutdoorSpaceType, isIndoorSpaceType, formatGardenValue, formatSpaceType, formatSpaceLight, formatAmbientArtificialLight, formatHumidityProfile, formatSpaceStatusSummary, formatPlantType, formatLocationContext, formatIdentificationProvider, formatLightProfile, formatOutdoorSunTimings, formatDedicatedArtificialLight, formatSetupSpaceEnvironment } from './utils/formatters'; import { cleanPlantDisplayName, getSavedPlantDisplayName, getSamplePlantDisplayName, cleanScientificName, formatDetailDate, formatRelativeDate, formatPlantEventDate, getPlantEventTypeLabel, getSavedPlantEvents, getAllSavedPlantEvents, memoryCategoryGroups, plantMemoryEventOptions, plantMemoryDefaultTitles } from './utils/plantHelpers'; import { maxContextPhotos, makeContextPhoto, normalizeContextPhotos, contextPhotoUris, formatPhotoCount, hasPlantSetupPhoto, hasSpaceEnvironmentPhoto, isSpaceEnvironmentComplete } from './utils/contextPhotoHelpers'; import { makeEmptyPlantForm, makeEmptySpaceForm, makeEmptySetupForm, makeEmptyPlantEventForm, lightLevelFromProfile, humidityEstimateFromProfile } from './utils/formFactories'; import { CarePlanScreen } from './screens/CarePlanScreen'; import { CareCheckInModal, type CareCheckInData } from './components/CareCheckInModal'; import { CaseTreatmentModal } from './components/CaseTreatmentModal'; import type { CareLogEntry } from './types/care'; import { CommunityScreen } from './screens/CommunityScreen'; import { IdentifyScreen } from './screens/IdentifyScreen'; import { ProfileScreen } from './screens/ProfileScreen'; import { img } from './constants/images'; import { navAspect, navShellAspect, headerActionOffsetX, headerNotificationOffsetX, headerActionTop, kitProductTrustMessage, minStableBottomInset, maxStableBottomInset, edgeToEdge, showCareValidationReset, dueTodayQuickActions, futureTaskQuickActions, useStableBottomInset } from './constants/layout'; // ---- Demo rooms (default state) ---- function isInGroundPotType(potType?: PlantSetupProfile['potType']) { return potType === 'ground' || potType === 'raised_bed' || potType === 'garden_bed'; } /** * Parse a signed day-delta out of a per-plant cohort-adjustment text (Bekky, * 2026-08-27). The AI writes things like "dries faster, water a bit more" or * "keep dry, less water" — we map that to a small signed number applied ON TOP * of the cohort's shared cadence. Non-fatal: if no delta can be inferred, * returns null and the caller falls back to the raw text. */ /** * AI RE-LOOK hash (Bekky-approved 2026-08-30): content-hash of a cohort's * crew + cadence + joint anchor as seen by the per-cohort care pass. Stored on * cohort.lastCareHash after a judgment; when the CURRENT hash differs, the * crew changed (merge/split/nudge, or a new joint watering re-anchored the * group) and the AI should look again — rename judgment, reunification, * fresh-ahead plans. Same hash = unchanged crew, already judged. */ function cohortCareHash( action: GroupableCareAction, plantIds: string[], sharedCadence: number | undefined, ): string { // NOTE (Bekky, 2026-09-07, cohort-engine refactor — Gap 2): `lastAnchor` is // deliberately NOT part of the hash. Watering a group stamps lastAnchor, and // if it were in the hash, every watering would mark the cohort stale and // trigger a relook — reshuffling a happy group for no reason. The anchor // still drives due-date math (read live), it just never invalidates the // "already judged" stamp. Character = membership + cadence; temporal state // (when last watered) is NOT character. const payload = `${action}#${[...plantIds].sort().join('|')}#${sharedCadence ?? '-'}`; let h = 5381; for (let i2 = 0; i2 < payload.length; i2++) h = ((h << 5) + h + payload.charCodeAt(i2)) >>> 0; return h.toString(36); } function inferCohortDelta(text: string): number | null { const t = text.toLowerCase(); // Drier / less water / keep dry → fewer days (water less often). if (/drier|less water|keep dry|less often|not too wet|dry out/.test(t)) return -1; // Wetter / more water / dries fast / thirstier → more days. if (/dries fast|dries faster|more water|thirsty|drinks more|needs more/.test(t)) return 1; // Explicit signed numbers like "+2" / "-1" (rare). const m = t.match(/([+-]\s*\d+)/); if (m) { const n = parseInt(m[1].replace(/\s/g, ''), 10); if (Number.isFinite(n) && Math.abs(n) <= 7) return n; } return null; } function cleanSpaceNotesForDisplay(notes?: string) { return notes?.replace(/\s*Former space type: (Garden Bed|Raised Bed|Plant Shelf)\./g, '').trim(); } function getPotTypeOptionsForSpace(spaceType?: SpaceType | null) { if (isIndoorSpaceType(spaceType)) { return potTypeOptions.filter(option => ['nursery_pot', 'plastic', 'terracotta', 'ceramic', 'self_watering', 'hanging', 'other', 'unsure'].includes(option.value as string)); } if (isOutdoorSpaceType(spaceType)) { return potTypeOptions.filter(option => ['ground', 'raised_bed', 'garden_bed', 'outdoor_container', 'other', 'unsure'].includes(option.value as string)); } return potTypeOptions.filter(option => ['nursery_pot', 'plastic', 'terracotta', 'ceramic', 'self_watering', 'hanging', 'other', 'unsure'].includes(option.value as string)); } function getEffectivePotTypeForSpace(potType: PlantSetupProfile['potType'], spaceType?: SpaceType | null): PlantSetupProfile['potType'] { return getPotTypeOptionsForSpace(spaceType).some(option => option.value === potType) ? potType : 'unsure'; } function formatAttentionCount(count: number) { return `${count} ${count === 1 ? 'Needs Attention' : 'Need Attention'}`; } function normalizeLightHoursInput(value: string): number | undefined { const numericValue = Number(value.replace(/[^0-9.]/g, '')); if (!Number.isFinite(numericValue) || numericValue <= 0) return undefined; return Math.min(24, Math.round(numericValue * 10) / 10); } const lightLevels: { value: RoomProfile['lightLevel']; label: string }[] = [ { value: 'low', label: 'Low' }, { value: 'medium', label: 'Medium' }, { value: 'bright_indirect', label: 'Bright Indirect' }, { value: 'direct_sun', label: 'Direct Sun' }, { value: 'grow_light', label: 'Grow Light' }, { value: 'unsure', label: 'Unsure' }, ]; const lightProfileOptions: { value: LightProfile; label: string }[] = [ { value: 'low_light', label: 'Low Light' }, { value: 'medium_light', label: 'Medium Light' }, { value: 'bright_indirect', label: 'Bright Indirect' }, { value: 'direct_sun', label: 'Direct Sun' }, ]; const humidityOptions: { value: RoomProfile['humidityEstimate']; label: string }[] = [ { value: 'dry', label: 'Dry' }, { value: 'normal', label: 'Normal' }, { value: 'humid', label: 'Humid' }, { value: 'unsure', label: 'Unsure' }, ]; const humidityProfileOptions: { value: HumidityProfile; label: string }[] = [ { value: 'dry', label: 'Dry' }, { value: 'average', label: 'Average' }, { value: 'humid', label: 'Humid' }, ]; const temperatureOptions: { value: RoomProfile['temperatureEstimate']; label: string }[] = [ { value: 'cool', label: 'Cool' }, { value: 'normal', label: 'Normal' }, { value: 'warm', label: 'Warm' }, { value: 'fluctuates', label: 'Fluctuates' }, { value: 'unsure', label: 'Unsure' }, ]; const distanceOptions: { value: PlantSetupProfile['distanceFromWindow']; label: string }[] = [ { value: 'windowsill', label: 'Windowsill' }, { value: 'one_to_three_ft', label: '1-3 ft' }, { value: 'four_to_six_ft', label: '4-6 ft' }, { value: 'six_plus_ft', label: '6+ ft' }, { value: 'not_applicable', label: 'N/A' }, { value: 'unsure', label: 'Unsure' }, ]; const potTypeOptions: { value: PlantSetupProfile['potType']; label: string }[] = [ { value: 'plastic', label: 'Plastic' }, { value: 'ceramic', label: 'Ceramic' }, { value: 'terracotta', label: 'Terracotta' }, { value: 'nursery_pot', label: 'Nursery Pot' }, { value: 'self_watering', label: 'Self Watering' }, { value: 'hanging', label: 'Hanging' }, { value: 'raised_bed', label: 'Raised Bed' }, { value: 'garden_bed', label: 'Garden Bed' }, { value: 'outdoor_container', label: 'Outdoor Container' }, { value: 'ground', label: 'In Ground' }, { value: 'other', label: 'Other' }, { value: 'unsure', label: 'Unsure' }, ]; const potSizeOptions: { value: PlantSetupProfile['potSize']; label: string }[] = [ { value: 'small', label: 'Small' }, { value: 'medium', label: 'Medium' }, { value: 'large', label: 'Large' }, { value: 'extra_large', label: 'XL' }, { value: 'custom', label: 'Custom' }, { value: 'not_applicable', label: 'N/A' }, { value: 'unsure', label: 'Unsure' }, ]; const drainageOptions: { value: PlantSetupProfile['drainage']; label: string }[] = [ { value: 'has_holes', label: 'Has Holes' }, { value: 'no_holes', label: 'No Holes' }, { value: 'not_applicable', label: 'N/A' }, { value: 'unsure', label: 'Unsure' }, ]; const mediumOptions: { value: PlantSetupProfile['mediumType']; label: string }[] = [ { value: 'potting_mix', label: 'Potting Mix' }, { value: 'cactus_mix', label: 'Cactus/Succulent Mix' }, { value: 'orchid_bark', label: 'Orchid Mix' }, { value: 'seed_starting', label: 'Seed Starting Mix' }, { value: 'garden_soil', label: 'Garden Soil' }, { value: 'soil', label: 'Soil' }, { value: 'coco_coir', label: 'Coco Coir' }, { value: 'sphagnum_moss', label: 'Sphagnum Moss' }, { value: 'compost', label: 'Compost' }, { value: 'worm_castings', label: 'Worm Castings' }, { value: 'bark_chips', label: 'Bark Chips' }, { value: 'perlite', label: 'Perlite' }, { value: 'vermiculite', label: 'Vermiculite' }, { value: 'pumice', label: 'Pumice' }, { value: 'leca', label: 'LECA' }, { value: 'sand', label: 'Sand' }, { value: 'charcoal', label: 'Charcoal' }, { value: 'lava_rock', label: 'Lava Rock' }, { value: 'water', label: 'Water' }, { value: 'hydroponic', label: 'Hydroponic System' }, { value: 'unsure', label: 'Unsure' }, { value: 'custom', label: 'Custom' }, ]; const wateringMethodOptions: { value: PlantSetupProfile['wateringMethod']; label: string }[] = [ { value: 'top_watering', label: 'Top Watering' }, { value: 'bottom_watering', label: 'Bottom Watering' }, { value: 'watering_can', label: 'Watering Can' }, { value: 'hose', label: 'Hose' }, { value: 'soak', label: 'Soak' }, { value: 'self_watering', label: 'Self-Watering' }, { value: 'wick', label: 'Wick' }, { value: 'drip', label: 'Drip' }, { value: 'sprinkler', label: 'Sprinkler' }, { value: 'mist', label: 'Mist' }, { value: 'humidifier', label: 'Humidifier' }, { value: 'unsure', label: 'Unsure' }, { value: 'custom', label: 'Custom' }, ]; const tabs: { key: TabKey; label: string; icon: ImageSourcePropType }[] = [ { key: 'home', label: 'Home', icon: img.navHomeIcon }, { key: 'profile', label: 'Garden', icon: img.navGardenIcon }, { key: 'identify', label: 'Scan', icon: img.navScanIcon }, { key: 'care', label: 'Care', icon: img.navCareIcon }, { key: 'community', label: 'Community', icon: img.navCommunityIcon }, ]; const navStateImages: Record = { home: img.navSelectedHome, profile: img.navSelectedGarden, identify: img.navSelectedScan, care: img.navSelectedCare, community: img.navSelectedCommunity, }; const HELPER_INTRO_DURATION_MS = 2580; const problemLabels = ['Leaf spot', 'Fruit issue', 'Pest close-up', 'Stem', 'Soil', 'Mold', 'Other']; const problemScanSymptomOptions = [ { value: 'yellow_leaves', label: 'Yellow leaves' }, { value: 'brown_tips', label: 'Brown tips' }, { value: 'drooping', label: 'Drooping' }, { value: 'spots_patches', label: 'Spots or patches' }, { value: 'bugs_pests', label: 'Bugs or pests' }, { value: 'webbing', label: 'Webbing' }, { value: 'mold_fungus', label: 'Mold or fungus' }, { value: 'wilting', label: 'Wilting' }, { value: 'slow_growth', label: 'Slow growth' }, { value: 'leaf_damage', label: 'Leaf damage' }, { value: 'other', label: 'Other' }, ]; const symptomLabelMap: Record = { yellow_leaves: 'Yellow leaves', brown_tips: 'Brown tips', drooping: 'Drooping', spots_patches: 'Spots or patches', bugs_pests: 'Bugs or pests', webbing: 'Webbing', mold_fungus: 'Mold or fungus', wilting: 'Wilting', slow_growth: 'Slow growth', leaf_damage: 'Leaf damage', other: 'Other', }; function NotificationBell({ containerStyle, bellStyle }: { containerStyle?: StyleProp; bellStyle?: StyleProp }) { const { unreadCount, openNotificationCenter } = useContext(NotificationCenterContext); const openNotifications = () => { haptic.light(); openNotificationCenter(); }; return ( {unreadCount > 0 ? ( {unreadCount > 9 ? '9+' : unreadCount} ) : null} ); } function SettingsButton({ style }: { style?: StyleProp }) { const { openSettings } = useContext(SettingsContext); const open = () => { haptic.light(); openSettings(); }; return ( ); } function HeaderActionBar({ showSettings = true, showNotifications = true }: { showSettings?: boolean; showNotifications?: boolean }) { return ( {showSettings ? : null} {showNotifications ? : null} ); } function Header({ title, subtitle, back = false, backLabel = 'Garden', onBack, backButtonStyle, }: { title: string; subtitle?: string; back?: boolean; backLabel?: string; onBack?: () => void; backButtonStyle?: StyleProp; }) { return ( {!back ? : null} {back ? ( Back ) : null} {subtitle ? {subtitle} : null} ); } // Renders the top-level tab titles (Garden / Care / Community) in the logo's // whimsical cursive script (Pacifico) + two-tone forest/lime rhythm — matching // the Home "PixieSprout" wordmark without being an image. No leaf accent // (Bekky, 2026-08-13: the leaf sprig by headers was an old mistake — removed). function DecorativeHeaderTitle({ title }: { title: string }) { // Only apply the decorative style to the top-level tab headers. const tabTitles = ['Garden', 'Care', 'Community']; if (!tabTitles.includes(title)) { // Detail titles (plant/space/kit names): NEVER truncate — wrap to more // lines so a long name always shows in full (Bekky, 2026-08-13). No // numberOfLines / ellipsizeMode here. return {title}; } // Two-tone rhythm like the logo: first half forest green, second half lime. // Rendered as TWO sibling Texts with NO gap (gap:0) so the word stays // continuous with no space in the middle. No nesting, no numberOfLines — // nesting + numberOfLines is a known RN bug that truncates longer words // (Care/Community) to "..." even when there's room (Bekky, 2026-08-13). const mid = Math.ceil(title.length / 2); const first = title.slice(0, mid); const rest = title.slice(mid); return ( {first} {rest} ); } function DecorativeBackground() { return ( ); } function HomeHeader() { return ( ); } function FairyMascot() { const float = useRef(new Animated.Value(0)).current; useEffect(() => { const animation = Animated.loop( Animated.sequence([ Animated.timing(float, { toValue: 1, duration: 1600, easing: Easing.inOut(Easing.quad), useNativeDriver: true }), Animated.timing(float, { toValue: 0, duration: 1600, easing: Easing.inOut(Easing.quad), useNativeDriver: true }), ]) ); animation.start(); return () => animation.stop(); }, [float]); const translateY = float.interpolate({ inputRange: [0, 1], outputRange: [0, -5] }); const rotate = float.interpolate({ inputRange: [0, 1], outputRange: ['-1deg', '1deg'] }); return ; } function Screen({ children, scrollRef, scrollable = true, onScroll }: { children: React.ReactNode; scrollRef?: React.RefObject; scrollable?: boolean; onScroll?: (e: import('react-native').NativeSyntheticEvent) => void }) { if (!scrollable) { // Static page — no outer scroll. Only the journal's inner ScrollView // scrolls (Bekky, 2026-08-13: the whole Home page must be static). return ( {children} ); } return ( {children} ); } function Home({ onTaskPress, gardenSummary, careTasks, careTaskStates, }: { onTaskPress: (taskId: string) => void; gardenSummary: HomeGardenSnapshotSummary; careTasks: CareTaskItem[]; careTaskStates: Record; }) { const dueTodayTasks = careTasks.filter(task => task.timing === 'today' && careTaskStates[task.id]?.status !== 'completed' && careTaskStates[task.id]?.status !== 'skipped'); // Make the notebook card reach down so its torn edge lands with a comfortable // gap above the nav bar (Bekky, 2026-08-13: move it back up from the nav bar). const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const careNavBarH = Math.round(screenWidth * 0.95 * 0.236) + 16; // Torn edge extends down almost to the nav bar (Bekky, 2026-08-13: bring // back the "almost touching the nav bar" look — matches the Care journal's // original paper-bottom gap of -8). const carePaperBottomY = screenHeight - careNavBarH - 8; // Stack above the journal: content paddingTop (8) + homeHeader (118) + hero // (185 + marginBottom 11) = 322 (journal top would be 322 if flush with hero). // NEW (Bekky 2026-08-14): pull the journal UP by `journalTuck` px so its top // sprig slides behind the hero card's bottom edge (hero is opaque) — the // sprig fades/disappears behind the hero's foot. Keeps the tear 8px above the // snapshot and the snapshot's bottom pinned at carePaperBottomY (nav-bar // target) on the fixed non-scrollable screen. const journalTuck = 24; // px the journal slides up under the hero const journalTopY = 322 - journalTuck; // Tear is baked at 0.769 of card height. // tearY = journalTopY + 0.769 * H // snapshotTop = tearY + 8 // snapshotBottom = snapshotTop + snapshotH = carePaperBottomY // → journalTopY + 0.769H + 8 + snapshotH = carePaperBottomY // → H = (carePaperBottomY - snapshotH - journalTopY - 8) / 0.769 const snapshotH = screenWidth * (533 / 1600); const homeJournalHeight = Math.max(200, (carePaperBottomY - snapshotH - journalTopY - 8) / 0.769); // Snapshot marginTop (relative to just after the journal) that puts its top // exactly 8px below the tear: const tearY = journalTopY + 0.769 * homeJournalHeight; const snapshotTopY = tearY + 8; const journalBottomY = journalTopY + homeJournalHeight; const snapshotMarginTop = snapshotTopY - journalBottomY; // The paper's writing area clears the spiral (left ~0.15) and the decorative // tabs (right ~0.18) — same fractions as the Care journal's content box // (CONTENT_LEFT_FRAC 0.15, CONTENT_RIGHT_FRAC 0.82). The card now spans the // FULL screen width (escapes the Screen's 18px padding via negative margins) // to match the Care journal's width (Bekky, 2026-08-13). const cardWidth = screenWidth; const paperLeftInset = cardWidth * 0.15; const paperRightInset = cardWidth * 0.18; // The paper body starts at ~0.169 of the image height (measured from the // asset, 2026-08-13). Inset the text down a bit (~0.19) so the "Today's // Tasks" header sits comfortably below the top edge (Bekky, 2026-08-13). const paperTopInset = homeJournalHeight * 0.19; return ( Hi Grower! Your plants are looking lovely today. {/* Today's Tasks — a cropped notebook poking up (torn bottom edge). Rows mirror the Care tab journal formatting (Caveat font, 3-line layout) but WITHOUT checkboxes/actions — the whole row is tappable and deep-links to that exact task on the Care tab (Bekky, 2026-08-13). Now sits directly BELOW the hero (swapped with Garden Snapshot, Bekky 2026-08-14). marginTop 0 — no overlap above it anymore. */} Today's Tasks {dueTodayTasks.length ? ( dueTodayTasks.map(task => ( onTaskPress(task.id))} accessibilityRole="button" accessibilityLabel={`${task.type} ${task.plantName}, due ${task.dueLabel}`} > {/* LINE 1 — full task title. The emoji sits in a fixed-width slot so the space name below can align to the first letter of the task type (Bekky 2026-08-21: "B" of Back Entryway under the "W" of Water). */} {task.type === 'Water' ? '💧' : task.type === 'Fertilize' ? '🌱' : task.type === 'Recheck' ? '🩺' : '🌿'} {task.type} {task.plantName} {/* LINE 2 — space name, indented to sit directly under the first letter of the task type (matches emoji slot). */} {task.spaceName ? {task.spaceName} : null} {/* LINE 3 — the sentence description. REMOVED for schedule care tasks (Bekky, 2026-08-20): it duplicated the title ("Water Mexican Heather" twice). Only diagnosis/treatment briefs add info, so show those; schedule rows stay clean. */} {task.source === 'diagnosis' && getJournalTaskBrief(task) ? ( {getJournalTaskBrief(task)} ) : null} {/* Per-space weather-adjusted cadence note (Bekky, 2026-08-23): a small green line when weather moved the due date. */} {task.weatherNote ? {task.weatherNote} : null} )) ) : ( ~ No care tasks due today. Pixie will let you know when your plants need attention. ~ )} {/* Garden Snapshot — now at the BOTTOM, above the nav bar (swapped with Today's Tasks, Bekky 2026-08-14). Its top sits EXACTLY 8px below the journal's torn edge (same gap the tear had above the nav bar in the original layout), and its bottom lands at the nav-bar target. INFORMATIVE ONLY (Bekky 2026-08-21): not a link — the Care Journal is directly above it, so a second link to the same place is redundant. Being a plain View also stops the count-reset bug (tapping it no longer navigates to My Garden, which used to overwrite the accurate needs-care count with a text-matching override that returned 0). */} Garden Snapshot {gardenSummary.plantCount} {gardenSummary.plantCount === 1 ? 'Plant' : 'Plants'} {gardenSummary.spaceCount} {gardenSummary.spaceCount === 1 ? 'Space' : 'Spaces'} {gardenSummary.needsCareCount} {gardenSummary.needsCareCount === 1 ? 'Needs Attention' : 'Need Attention'} ); } function getCareTiming(sourceText: string, statusText = ''): CareTiming | null { const text = `${sourceText} ${statusText}`.toLowerCase(); if (!text.trim()) return null; if (text.includes('overdue') || text.includes('today') || statusText.toLowerCase().includes('needs') || statusText.toLowerCase().includes('attention')) return 'today'; if (text.includes('tomorrow')) return 'tomorrow'; const daysMatch = text.match(/\bin\s+(\d+)\s+days?\b/); if (daysMatch) { const days = Number(daysMatch[1]); return days <= 7 ? 'this_week' : 'later'; } if (text.includes('this week') || text.includes('weekly')) return 'this_week'; if (text.includes('later') || text.includes('next month')) return 'later'; return null; } function getCareTaskIsOverdue(sourceText: string, statusText = '') { return `${sourceText} ${statusText}`.toLowerCase().includes('overdue'); } function getCareDueLabel(timing: CareTiming, sourceText: string) { if (sourceText.toLowerCase().includes('overdue')) return 'Overdue'; if (timing === 'today') return 'Today'; if (timing === 'tomorrow') return 'Tomorrow'; const daysMatch = sourceText.toLowerCase().match(/\bin\s+(\d+)\s+days?\b/); if (daysMatch) return `In ${daysMatch[1]} days`; if (timing === 'this_week') return 'This Week'; return 'Later'; } function getCareDateKey(offsetDays = 0) { const date = new Date(); date.setDate(date.getDate() + offsetDays); // Format LOCAL date components (M4). toISOString() is UTC — for a user at // GMT+7 the UTC date can be a day behind the local date, making "due today" // land a day late. Build the key from local year/month/day instead. const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } /** Add `offsetDays` to a local YYYY-MM-DD key → a new local YYYY-MM-DD key. */ function getCareDateKeyAfter(dateKey: string, offsetDays: number) { const date = new Date(`${dateKey}T00:00:00`); if (Number.isNaN(date.getTime())) return getCareDateKey(offsetDays); date.setDate(date.getDate() + offsetDays); const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } const detailBackButtonTranslateY = 82; function getCareDayOffset(dateKey?: string) { if (!dateKey) return 1; const today = new Date(`${getCareDateKey(0)}T00:00:00`); const target = new Date(`${dateKey}T00:00:00`); if (Number.isNaN(target.getTime())) return 1; return Math.max(1, Math.round((target.getTime() - today.getTime()) / 86400000)); } function getCareDueKey(timing: CareTiming, sourceText: string) { if (timing === 'today') return getCareDateKey(0); if (timing === 'tomorrow') return getCareDateKey(1); const daysMatch = sourceText.toLowerCase().match(/\bin\s+(\d+)\s+days?\b/); if (daysMatch) return getCareDateKey(Number(daysMatch[1])); return timing; } function getCareDueDateFromKey(dueKey: string) { return /^\d{4}-\d{2}-\d{2}$/.test(dueKey) ? dueKey : null; } function getCareTaskTypeKey(type: string) { return type.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); } function getCareTaskType(sourceText: string) { const text = sourceText.toLowerCase(); if (text.includes('reassess') || text.includes('review')) return 'Reassess'; if (text.includes('growth')) return 'Check Growth'; if (text.includes('propagat') || text.includes('cutting')) return 'Propagate'; if (text.includes('harvest') || text.includes('pick')) return 'Harvest'; if (text.includes('repot') || text.includes('pot up')) return 'Repot'; if (text.includes('mist')) return 'Mist'; if (text.includes('prune') || text.includes('trim')) return 'Prune'; if (text.includes('neem') || text.includes('treat')) return 'Treat'; if (text.includes('water')) return 'Water'; if (text.includes('fertiliz')) return 'Fertilize'; if (text.includes('rotate')) return 'Rotate'; if (text.includes('pest') || text.includes('inspect') || text.includes('check') || text.includes('leaves')) return 'Inspect'; return 'Care Check'; } function getCareTaskIcon(type: string) { return getCareTaskIconSource(type, img.titleLeaf); } function buildCareTask(input: { key: string; plantName: string; spaceId?: string | null; statusText?: string; careText?: string; roomById: Map; }): CareTaskItem | null { // TODO: Replace text parsing with explicit care schedule records when the Care Completion pass adds them. const sourceText = (input.careText || '').trim(); const statusText = (input.statusText || '').trim(); const timing = getCareTiming(sourceText, statusText); if (!timing) return null; const isOverdue = getCareTaskIsOverdue(sourceText, statusText); const type = getCareTaskType(`${sourceText} ${statusText}`); const room = input.spaceId ? input.roomById.get(input.spaceId) : undefined; const dueKey = getCareDueKey(timing, sourceText); const dueDate = getCareDueDateFromKey(dueKey); const taskTypeKey = getCareTaskTypeKey(type); const source: CareTaskSource = 'plant_care_text'; const metadata: CareTaskDetailMetadata = { plantId: input.key, spaceId: input.spaceId || null, taskType: type, dueDate, source, relatedDiagnosisId: null, relatedProgressPhotoIds: [], sensorContext: null, weatherContext: null, potSoilContext: null, aiRecommendationId: null, }; return { id: `${input.key}:${taskTypeKey}:${dueKey}`, plantId: input.key, type, plantName: input.plantName, spaceId: input.spaceId, spaceName: room?.name, timing, dueLabel: getCareDueLabel(timing, sourceText), dueDate, isOverdue, source, metadata, notificationQuickActions: timing === 'today' ? dueTodayQuickActions : futureTaskQuickActions, reminderPreferences: defaultAppSettings.reminderPreferences, sourceText: sourceText || statusText, icon: getCareTaskIcon(type), }; } function buildCareTasks(input: { savedPlants: SavedPlantProfile[]; samplePlants: SamplePlant[]; rooms: RoomProfile[]; }) { const roomById = new Map(input.rooms.map(room => [room.id, room])); // NOTE (Bekky, 2026-08-20): a saved plant produces NO care notifications // until the user generates its care guidance (careSchedule). The regex // text-parsing path below would otherwise create tasks from wateringSchedule/ // careSummary text even before care is generated. Gate saved plants on having // a careSchedule; sample plants (demo garden) keep their static text tasks. const savedTasks = input.savedPlants .filter(plant => Boolean(plant.careSchedule)) .map(plant => buildCareTask({ key: `saved:${plant.id}`, plantName: getSavedPlantDisplayName(plant), spaceId: plant.spaceId || null, statusText: plant.careDifficulty === 'fussy' ? 'needs attention' : '', careText: plant.wateringSchedule || plant.careSummary || '', roomById, })) .filter((task): task is CareTaskItem => Boolean(task)); const sampleTasks = input.samplePlants .map(plant => buildCareTask({ key: `sample:${plant.name}`, plantName: getSamplePlantDisplayName(plant), spaceId: plant.spaceId || null, statusText: plant.status, careText: plant.care, roomById, })) .filter((task): task is CareTaskItem => Boolean(task)); return [...savedTasks, ...sampleTasks]; } /** * Generate dated care tasks from each plant's STRUCTURED care schedule * (source:'care_schedule'). This is the intelligent healthy-plant path — * the AI produced the cadence given the plant's real context (environment, * setup, placement, region, weather). Falls back to the regex text-parsing * path (buildCareTasks) for plants without a schedule yet. */ function buildScheduledCareTasks(input: { savedPlants: SavedPlantProfile[]; rooms: RoomProfile[]; /** Weather snapshot for the garden location (Bekky, 2026-08-23). When present, * outdoor plants get a soil-depth nudge: drought -> water more often, heavy * rain -> less. null = no weather available (care proceeds without it). */ weather?: WeatherSnapshot | null; /** Location's season profile (hot/cold months) derived from climate normals * (Bekky, 2026-08-24). Used as the month fallback in seasonModifier when no * daily forecast is available — a location-accurate "which months are hot/cold * here" instead of a hardcoded global month range. Deterministic, no AI. * null = no garden location / not fetched (falls back to calendar month). */ season?: SeasonProfile | null; }): CareTaskItem[] { const roomById = new Map(input.rooms.map(room => [room.id, room])); const tasks: CareTaskItem[] = []; const weather = input.weather; const season = input.season; // GROUP JOINT REFERENCE DATE (Bekky, 2026-09-01 — the heather fix, part 4, // v3): a cohort member's due date must anchor on ONE group-wide reference // date, NOT each plant's own lastDone. Members of the same group were watered // at slightly different times (Flossflower a day or two before Heather), so // anchoring each on its OWN lastDone makes them compute DIFFERENT due dates // and the SAME group splits across Due Today and Upcoming (the opposite-skip // bug, 2026-09-01). The group reference = the group's lastAnchor when it // exists (the group was watered together), else the OLDEST member lastDone // (the thirstiest member sets the group's clock — everyone else is tightened // to that joint date, which the doctrine allows: "shared may TIGHTEN, never // STRETCH"). Keyed by `${cohortId}:${action}`. const groupRefByCohortAction = new Map(); for (const room of input.rooms) { for (const action of ['water', 'fertilize'] as const) { for (const cohort of room.cohorts?.[action] || []) { const anchor = cohort.lastAnchor?.[action]; if (anchor) { groupRefByCohortAction.set(`${cohort.id}:${action}`, anchor); continue; } // No group anchor yet — use the OLDEST member lastDone as the group's // clock. The thirstiest member sets the joint date; the fresher members // are tightened to it (watered together, slightly early for them — the // doctrine allows tightening, never stretching). let oldest = ''; for (const pid of cohort.plantIds) { const plant = input.savedPlants.find(p => p.id === pid); const done = plant?.careSchedule?.lastDone?.[action]; if (done && (!oldest || done < oldest)) oldest = done; } if (oldest) groupRefByCohortAction.set(`${cohort.id}:${action}`, oldest); } } } // Weather-based soil-depth nudge (Bekky, 2026-08-23, simplified): a signed // day-delta for OUTDOOR plants based on the real forecast. Drought (no rain + // heat over the next few days) -> soil dries deeper -> water more often (-1). // Heavy rain -> soil stays wet deeper -> water less often (+1). Indoor plants // are unaffected (rain outside is irrelevant to an indoor pot). Rule-based, // no AI call, derived live from the cached forecast. // Weather cadence modifier, EXPOSURE-AWARE (Bekky, 2026-08-23): how rain and // sun actually reach an outdoor plant depends on the space's covering (roof/ // walls/facing) and elevation. A solid roof deflects rain AND sun; glass // deflects rain but lets sun through (heat builds); lattice/vines let some of // both plus a lot of wind through; an open sky gets it all. Elevation adds // wind/sun intensity higher up. Returns { delta, reason } so the task can show // a per-space note explaining WHY the cadence moved (Bekky, 2026-08-23). const weatherModifier = (room: RoomProfile | undefined): { delta: number; reason?: string } => { if (!weather || !room) return { delta: 0 }; const spaceName = room.name || 'this space'; const signals = compressWeatherSignals(weather); const exp = room.exposure || {}; const roof = exp.roofType || 'none'; const isSolid = roof === 'solid'; const isGlass = roof === 'glass'; const isPermeable = roof === 'permeable'; // Rain-reach: how much of the measured rain actually reaches the soil. // Solid/glass roofs deflect most; permeable lets some through; open sky all. const rainReach = isSolid || isGlass ? 0.1 : isPermeable ? 0.5 : 1; const effectiveSaturation = Math.round(signals.saturationRisk * rainReach); // Wind-reach: how much wind reaches the plants (dries faster). const openSides = exp.walls === 'three' ? 0.25 : exp.walls === 'two' ? 0.5 : exp.walls === 'one' ? 0.75 : 1; const windDries = isPermeable ? openSides * 1.2 : isSolid ? openSides * 0.5 : openSides; // Sun-heat: how much heat stress actually lands. A solid roof blocks it; // GLASS TRAPS heat (the greenhouse effect — heat builds, not light). Clear // glass traps the most; UV-tinted/frosted glass cuts UV + heat build and // reduces light (so it's gentler than clear). Permeable partial; open sky full. const sunReach = isSolid ? 0.3 : isGlass ? (room.exposure?.glassTint === 'tinted' || room.exposure?.glassTint === 'frosted' ? 0.85 : 1.15) : isPermeable ? 0.7 : 1; // Elevation: higher floors get more wind + sun (less ground shelter). const elevationBoost = room.elevation === 'floor_4_plus' ? 1.15 : room.elevation === 'floor_2_3' ? 1.1 : room.elevation === 'floor_1' ? 1.05 : 1; const effectiveDrying = Math.round(signals.dryingPressure * (isSolid ? 0.5 : 1) * elevationBoost); let delta = 0; let reason: string | undefined; if (isOutdoorSpaceType(room.spaceType)) { // OUTDOOR: the full 7-day forecast, read as a delta from the seasonal // normal. Saturation risk (rain vs normal) is the dominant signal — when // soil is getting rained on directly every day, it doesn't need water at // all (Bekky's "the app is crazy" fix). Stretch a lot, not a weak +1. if (effectiveSaturation >= 2) { delta += 2; reason = `It's been raining a lot in ${spaceName} — the soil is likely saturated, so we've pushed your watering back.`; } else if (effectiveSaturation === 1) { delta += 1; reason = `Some rain in ${spaceName} — we've eased your watering back a little.`; } // COMPOUNDING WETNESS (Bekky, 2026-09-01): even if today's forecast is // dry, the soil may already be soaked from past rain. The forecast only // looks forward — this folds in the ACTUAL observed rain history so a // week of daily rain reads as "already saturated" even on a dry day. if (signals.compoundingWetness >= 2) { delta += 2; reason = `The soil in ${spaceName} is already wet from recent rain — we've held off watering.`; } else if (signals.compoundingWetness === 1) { delta += 1; reason = `The soil in ${spaceName} is still damp from recent rain — we've eased your watering back.`; } // Cold-damp: cold + wet soil won't dry and can shock roots → stretch. if (signals.coldDamp) { delta += 1; reason = `Cold and damp in ${spaceName} — the soil won't dry, so we've held off watering.`; } // Heat stress: heat streak dries soil fast → pull in. if (signals.heatStress && effectiveSaturation < 1 && signals.compoundingWetness === 0) { delta -= 1; reason = `A heat streak in ${spaceName} is drying the soil — we've moved your watering up.`; } // Low drying pressure (cloudy/humid/still) → soil stays wet → stretch. if (effectiveDrying <= 1 && (effectiveSaturation >= 1 || signals.compoundingWetness >= 1)) { delta += 1; reason = `Cloudy and humid in ${spaceName} — the soil isn't drying, so we've held off watering.`; } // High drying pressure + no rain + no past wetness → desiccation → pull in. if (effectiveDrying >= 3 && effectiveSaturation === 0 && signals.compoundingWetness === 0) { delta -= 1; reason = `Dry and windy in ${spaceName} — we've moved your watering up.`; } } else { // INDOOR with devices: the weather's real effect is humidity/dampness, // not power outages (Bekky, 2026-09-01). A storm/cold-damp/windy forecast // means the air is damp and still → soil won't dry → stretch watering. // Device state (what exists, its reach) is static user data; the weather // is dynamic → compressed into signals; the AI combines them. if (signals.coldDamp) { delta += 1; reason = `Cold and damp outside — the air in ${spaceName} is humid, so the soil won't dry as fast.`; } if (signals.stormy) { delta += 1; reason = `Stormy weather — windows are likely closed and the fan less likely to run, so ${spaceName} stays damp.`; } if (signals.saturationRisk >= 2) { delta += 1; reason = `Heavy rain outside — the air in ${spaceName} is humid, so the soil won't dry as fast.`; } // Heat stress indoors: AC runs → dries air → water more (handled by // seasonModifier's AC logic; here we just note the heat). if (signals.heatStress) { reason = reason || `It's hot — the AC in ${spaceName} may be drying the air.`; } } return { delta, reason }; }; // Season + indoor-climate modifier (Bekky, 2026-08-21, Phase 3 + climate // devices): a signed day-delta applied ON TOP of the AI baseline + feedback // adjustment. Derived live (not stored) so it always reflects the current // conditions + the plant's room (AC/heater/fan/humidifier/window). Rule-based, // no AI call. // // Phase 3.5 (Bekky, 2026-08-23): "the chips tell us how OFTEN you use the // device; the rest is thermostat-inferred." We keep the user-set FREQUENCY // chips (never/occasionally/all_the_time) but decide WHETHER a device RUNS // today from the REAL forecast temperature instead of guessing from the // month. Air con runs when it's genuinely hot, heater when genuinely cold. // Falls back to month-based season only when no weather snapshot is available. const seasonModifier = (room: RoomProfile | undefined): number => { const indoor = room ? isIndoorSpaceType(room.spaceType) : false; // Today's effective outdoor temp from the real forecast (avg of today + next // day, or fall back to the season by month). const forecastTemp = weather?.daily?.[0]?.tempMaxC; const nowMonth = new Date().getMonth(); // Location-accurate month fallback (Bekky, 2026-08-24): when there's no daily // forecast, decide "is it hot/cold season here" from the location's climate // season profile (hot/cold months derived from 30-yr normals), instead of a // hardcoded global month range. Falls back to a broad calendar-season rule // only when the location has no season profile (no GPS / fetch failed). const inHotSeason = season?.hotMonths?.includes(nowMonth) ?? (nowMonth >= 4 && nowMonth <= 8); const inColdSeason = season?.coldMonths?.includes(nowMonth) ?? (nowMonth === 11 || nowMonth <= 1); const hotDay = forecastTemp != null ? forecastTemp >= 28 : inHotSeason; const coldDay = forecastTemp != null ? forecastTemp <= 12 : inColdSeason; if (!indoor) { // Outdoor: a genuinely hot day -> plants dry faster (shorter cadence); a // cold day -> dormancy/longer. if (hotDay) return -1; if (coldDay) return +1; return 0; } // Indoor: fold in the explicit climate devices + window (Bekky, 2026-08-21). // 2026-08-23: each device is a FREQUENCY (never/occasionally/all_the_time). // 2026-08-23 (climate-reach): whether a serious device (A/C, heater) runs is // decided by the user's FELT ENGAGEMENT reach (only-extreme / when-hot-cold / // a-bit-warm / basically-always / never), converted to °C against the real // forecast — NOT a hardcoded 28/12 for everyone (Bekky: same person reaches at // different temps in DaLat vs Phuket; a "turbo+16" A/C is desperation). // 2026-08-23 (no-freq): the reach chip REPLACED the frequency chip for serious // devices — reach already encodes when it runs (threshold °C), so the freq // gate was redundant (an A/C only ever runs hot, a heater only cold). Reach // 'never' = don't have it (no effect). The threshold sets both WHEN and how // OFTEN (a more sensitive threshold -> more days it runs). const dev = room?.climateDevices; // FELT ENGAGEMENT -> the ambient °C at which the user reaches for the device. // Higher = more tolerant (reaches only when it's genuinely hot/cold). const reachThreshold = (r: string | undefined): { hotC: number; coldC: number } => { switch (r) { case 'only_extreme': return { hotC: 32, coldC: 4 }; case 'when_bit_warm': return { hotC: 24, coldC: 15 }; case 'basically_always': return { hotC: 21, coldC: 18 }; case 'when_hot_or_cold': default: return { hotC: 28, coldC: 12 }; } }; // Heater type -> how strongly it dries the air (radiant mild, forced-air // strong, woodstove intense + intermittent when actually burning). const heatStrength = (heat: string | undefined): number => heat === 'woodstove_fireplace' ? 1.5 : heat === 'forced_air' ? 1 : 0.5; // Reach -> intensity of air-drying when the device runs (how hard it dries). // only_extreme runs rarely (0.7); the rest dry at full strength. The reach // threshold already controls how often via how many days clear the °C bar. const reachStrength = (r: string | undefined): number => r === 'only_extreme' ? 0.7 : 1; let delta = 0; // Air con runs when it's actually hot FOR THIS USER (dries air) -> water more. const ac = dev?.airCon && typeof dev.airCon === 'object' ? dev.airCon as { reach?: string } : undefined; if (ac && ac.reach !== 'never' && forecastTemp != null) { const { hotC } = reachThreshold(ac.reach); if (forecastTemp >= hotC) delta -= reachStrength(ac.reach); } // Heater runs when it's actually cold FOR THIS USER; drying strength scales // by heat type (radiant/forced-air/woodstove) -> water more accordingly. const heaterDev = dev?.heater && typeof dev.heater === 'object' ? dev.heater as { reach?: string; heatType?: string } : undefined; if (heaterDev && heaterDev.reach !== 'never' && forecastTemp != null) { const { coldC } = reachThreshold(heaterDev.reach); if (forecastTemp <= coldC) delta -= reachStrength(heaterDev.reach) * heatStrength(heaterDev.heatType); } // Fan increases airflow/evaporation -> water slightly more often. if (dev?.fan && dev.fan !== 'never') delta -= (dev.fan === 'all_the_time' ? 1 : 0.5); // Humidifier raises humidity -> water less often. if (dev?.humidifier && dev.humidifier !== 'never') delta += (dev.humidifier === 'all_the_time' ? 1 : 0.5); // Window open: brings in outdoor air. All the time = strong outdoor-air // influence (humid in summer -> water less often; dry in winter -> more // often). Occasionally = mild. Seldom = mostly closed (sealed room). // Window presence is derived from windowDirections (includes 'none' = no window). const hasWindow = room?.windowDirections?.length ? !room.windowDirections.includes('none') : false; const freq = room?.windowOpenFrequency; if (hasWindow && freq) { const strength = freq === 'all_the_time' ? 1 : freq === 'occasionally' ? 0.5 : 0; if (hotDay) delta += strength; // warm outdoor air -> water less often if (coldDay) delta -= strength; // cold dry air -> water more often } return Math.max(-3, Math.min(3, delta)); }; for (const plant of input.savedPlants) { const schedule = plant.careSchedule; if (!schedule || !Array.isArray(schedule.scheduled) || schedule.scheduled.length === 0) continue; const plantName = getSavedPlantDisplayName(plant); const spaceId = plant.spaceId || null; const room = spaceId ? roomById.get(spaceId) : undefined; // Inspection is NOT a notification (Bekky, 2026-08-20): it's not scheduled // unless a plant is sick, and then it's a Case, not care. So skip the // schedule's inspect action entirely — no standalone Inspect task, and no // "and inspect for pests" bundling into Water/Fertilize. The three // notification types are Water, Fertilize, and Cases. for (const item of schedule.scheduled) { if (item.action === 'inspect') continue; const type = item.action === 'water' ? 'Water' : item.action === 'fertilize' ? 'Fertilize' : 'Inspect'; const taskTypeKey = getCareTaskTypeKey(type); // Per-plant cadence (Bekky, 2026-08-21, Phase 2): the AI baseline // everyDays + the FEEDBACK ADJUSTMENT computed from the careLog + slider // state. The manual `overrides` is REMOVED — it clashed with the // intelligence. The user's behavior (via the log) is now the override. // // INTELLIGENT GROUPING (Bekky, 2026-08-27, Chunk 2): when the plant is in // a cohort for this action, the COHORT's SHARED cadence drives the sweep // (water a whole section together) — BUT never past the plant's own // thirst (health floor, Bekky 2026-08-30: "a straggler gets the care in // time no matter what"): shared may TIGHTEN, never STRETCH. The rich per-plant guidance stays individual; // only the cadence is shared. If the plant has a cohortAdjustment for // this action, it's applied as a small delta ON TOP of the shared cadence // ("keep the rosemary drier"). Standalone plants (no cohort, or under the // 4-plant threshold) keep their own per-plant everyDays. const actionKey = item.action === 'water' ? 'water' : item.action === 'fertilize' ? 'fertilize' : null; // Hoisted cohort lookup (Bekky, 2026-09-01): needed both for the cadence // (below) AND for the group-joint-date math (further down). Declared here // so it's in scope for both. // // FIX (2026-09-01, the heather split — root cause): the cohort is found by // scanning room.cohorts[actionKey] for one that CONTAINS this plant, NOT via // plant.cohortIds. `cohortIds` is declared in the type but NEVER populated // anywhere in the codebase, so plant.cohortIds?.[actionKey] was always // undefined → the cohort lookup silently failed → the group-joint-date fix // was bypassed → each member fell back to its OWN lastDone → the same group // split across Due Today and Upcoming. Matching plant.id (raw) against // cohort.plantIds (raw, per the diag log) is the same link the bucketing // logic in CarePlanScreen uses (cohortByPlantId), so the task loop and the // bucketing now agree on which cohort a plant belongs to. const cohortId = actionKey ? plant.cohortIds?.[actionKey] : undefined; const cohort = (cohortId && actionKey ? room?.cohorts?.[actionKey]?.find(c => c.id === cohortId) : undefined) || (actionKey ? room?.cohorts?.[actionKey]?.find(c => c.plantIds.includes(plant.id)) : undefined); let baseDays = item.everyDays; if (actionKey) { if (cohort && cohort.sharedCadence?.[actionKey]) { // Cohort cadence wins (Bekky: "cohort first because it's the most // important"). Rich guidance stays per-plant; only the cadence shares. // HEALTH FLOOR (Bekky, 2026-08-30: "a straggler gets the care in time // no matter what"): the group may not STRETCH a plant past its own // thirst — but it MAY tighten it. Floor the effective cadence at the // plant's own baseline; AI reunification plans stay advisory. baseDays = Math.min(item.everyDays, cohort.sharedCadence[actionKey] as number); // Cohort-wide feedback (Bekky, 2026-08-27, Chunk 2): a signed delta // from the cohort's wet/dry feedback, applied on top of the shared // cadence for the whole group. const cohortFeedback = cohort.feedbackAdjustment?.[actionKey]; if (cohortFeedback) baseDays = baseDays + cohortFeedback; const adjust = plant.cohortAdjustment?.[actionKey]; if (adjust) { // Small signed delta on top (e.g. "+2, dries slower" / "-1, drier"). const delta = Number(adjust); if (Number.isFinite(delta)) baseDays = baseDays + delta; } } } const feedbackDays = schedule.feedbackAdjustment?.[item.action] ?? 0; // Phase 3: season + indoor-climate modifier (AC/heater) on top. const seasonDays = item.action === 'water' ? seasonModifier(room) : 0; // Phase 3.5: weather-based soil-depth nudge (drought/rain) for outdoor plants. // Returns { delta, reason } — the reason becomes a per-space note on the task. const weatherNudge = item.action === 'water' ? weatherModifier(room) : { delta: 0 }; const weatherDays = weatherNudge.delta; const weatherNote = item.action === 'water' && weatherDays !== 0 ? weatherNudge.reason : undefined; const everyDays = Math.max(1, Math.min(365, baseDays + feedbackDays + seasonDays + weatherDays)); // Anchor (Bekky, 2026-08-20): if the user set a "last done" date for this // action (e.g. "I just fertilized it last Tuesday"), the NEXT due date is // that lastDone + cadence — NOT today + cadence. Otherwise fall back to // today (day zero). A computed due date in the past clamps to today so the // task reads as due now, not as a stale/backdated reminder. const lastDone = schedule.lastDone?.[item.action]; // GROUP JOINT DATE (Bekky, 2026-09-01 — the heather fix, part 4): a cohort // member's due date must come from the GROUP's anchor + shared cadence, NOT // the plant's own lastDone. Otherwise members drift apart (one watered 2 days // ago is due "In 2 days", another 5 days ago "In 5 days") and the SAME group // lands in both Due Today and Upcoming at different offsets. The group's // joint date = lastAnchor + effective cadence, so all members are due the // same day. HEALTH FLOOR: a member whose own lastDone is OLDER than the // group's anchor (thirstier than the group) can't wait for the joint date — // it's a straggler, due on its own thirst day (lastDone + own baseline). let rawDueKey: string; const cohortAnchor = actionKey ? cohort?.lastAnchor?.[actionKey] : undefined; const sharedCadence = actionKey ? cohort?.sharedCadence?.[actionKey] : undefined; if (actionKey && sharedCadence) { // GROUP JOINT DATE (Bekky, 2026-09-01 — the heather fix, part 4, v3): // a cohort member's due date MUST use the cohort's SHARED cadence, NOT // the per-plant everyDays. everyDays folds in each plant's own // feedback/season/weather deltas, so two members of the same group // compute DIFFERENT "group next dates" and the same group splits across // Due Today and Upcoming (the opposite-skip-state bug, 2026-09-01). // The shared cadence is the ONE number the whole group converges on. // // v3 (2026-09-01): the group's joint date is anchored on ONE group-wide // reference date — the group's lastAnchor when it exists, else the // MOST-RECENT member lastDone (precomputed in groupRefByCohortAction). // Anchoring each member on its OWN lastDone was the v2 flaw: members // were watered at slightly different times, so they still diverged. // The freshest member sets the group's clock; everyone else waits for // that joint date. HEALTH FLOOR: a member whose own lastDone is OLDER // than the group reference (thirstier than the freshest member) can't // wait for the joint date — it's a straggler, due on its own thirst day // (lastDone + own baseline). const groupRef = cohortId && actionKey ? groupRefByCohortAction.get(`${cohortId}:${actionKey}`) : undefined; if (groupRef) { if (lastDone && lastDone < groupRef) { // Thirstier than the group — can't wait. Straggler, due on own thirst. rawDueKey = getCareDateKeyAfter(lastDone, item.everyDays); } else { // In-sync or no group anchor yet — due on the shared cadence from // the group reference date. rawDueKey = getCareDateKeyAfter(groupRef, sharedCadence); } } else { // No reference date at all — due from today on the shared cadence. rawDueKey = getCareDateKey(sharedCadence); } } else { rawDueKey = lastDone ? getCareDateKeyAfter(lastDone, everyDays) : getCareDateKey(everyDays); } const todayKey = getCareDateKey(0); const dueKey = rawDueKey < todayKey ? todayKey : rawDueKey; const dueOffset = getCareDayOffset(dueKey); // ROLLING 7-DAY WINDOW (Bekky, 2026-09-02): tasks due within the next // 7 days (rolling from today) show in Upcoming. Previously 5 days — felt // a bit short. Tasks beyond 7 days are skipped (they appear when they // enter the window). // // WEATHER-ADJUSTED TASKS NEVER VANISH (Bekky, 2026-09-02 — the // disappearing-Upcoming-space bug): a task whose due date weather pushed // past the window is NOT dropped. It's kept, grayed, and carries its // weatherNote so the gardener sees "this moved because of rain" instead // of the whole space silently disappearing. It's flagged weatherAdjusted // so the UI can gray it out. const weatherAdjusted = Boolean(weatherNote) && dueOffset > 7; if (dueOffset > 7 && !weatherAdjusted) continue; if (weatherAdjusted && dueOffset > 14) continue; // hard ceiling — don't show absurd 3-week pushes const timing: CareTiming = dueOffset <= 1 ? 'today' : (dueOffset <= 7 ? 'this_week' : 'later'); const id = `sched:${plant.id}:${taskTypeKey}:${dueKey}`; const metadata: CareTaskDetailMetadata = { plantId: plant.id, spaceId, taskType: type, dueDate: dueKey, source: 'care_schedule', relatedDiagnosisId: null, relatedProgressPhotoIds: [], sensorContext: null, weatherContext: null, potSoilContext: null, aiRecommendationId: null, }; // Inspection is not a notification (Bekky, 2026-08-20) — the description // is just the action + plant name. const sourceText = `${type} ${plantName}`; tasks.push({ id, plantId: plant.id, type, plantName, spaceId, spaceName: room?.name, timing, dueLabel: getCareDayOffset(dueKey) <= 1 ? 'Today' : `In ${getCareDayOffset(dueKey)} days`, dueDate: dueKey, isOverdue: false, source: 'care_schedule', metadata, notificationQuickActions: timing === 'today' ? dueTodayQuickActions : futureTaskQuickActions, reminderPreferences: defaultAppSettings.reminderPreferences, sourceText, icon: getCareTaskIcon(type), weatherNote, weatherAdjusted, }); } } return tasks; } /** * Derive care tasks from each OPEN investigation's treatment plan. When a * diagnosis returns a treatmentPlan, each step (treat now / recheck later) * becomes a Care-tab task with source 'diagnosis' + relatedDiagnosisId set. * The recheck step becomes the "Check In" appointment reminder. */ function buildTreatmentCareTasks(input: { investigations: Investigation[]; savedPlants: SavedPlantProfile[]; roomById: Map; }): CareTaskItem[] { const tasks: CareTaskItem[] = []; const plantById = new Map(input.savedPlants.map(p => [p.id, p])); for (const inv of input.investigations) { if (!inv.treatmentPlan || !inv.plantId) continue; // Only surface tasks while the case is still active. if (inv.status !== 'open' && inv.status !== 'monitoring') continue; const plant = plantById.get(inv.plantId); const plantName = plant ? getSavedPlantDisplayName(plant) : inv.title || 'Plant'; const spaceId = plant?.spaceId || null; const room = spaceId ? input.roomById.get(spaceId) : undefined; for (const step of inv.treatmentPlan.steps) { // A recheck step is auto-cleared once a progress update has been added — // doing the check-in IS the completion of the recheck task. if (step.kind === 'recheck' && inv.progressUpdates && inv.progressUpdates.length > 0) continue; const offset = step.kind === 'recheck' ? (inv.treatmentPlan.recheckDays ?? 7) : step.dueOffsetDays; const timing = offset <= 0 ? 'today' : (offset <= 7 ? 'this_week' : 'later'); const dueKey = getCareDateKey(offset); const type = step.kind === 'recheck' ? 'Recheck' : 'Treat'; const taskTypeKey = getCareTaskTypeKey(type); const id = `dx:${inv.investigationId}:${step.stepId}`; const metadata: CareTaskDetailMetadata = { plantId: inv.plantId, spaceId, taskType: type, dueDate: dueKey, source: 'diagnosis', relatedDiagnosisId: inv.investigationId, relatedProgressPhotoIds: [], sensorContext: null, weatherContext: null, potSoilContext: null, aiRecommendationId: step.stepId, }; tasks.push({ id, plantId: inv.plantId, type, plantName, spaceId, spaceName: room?.name, timing, dueLabel: offset <= 0 ? 'Today' : `In ${offset} days`, dueDate: dueKey, isOverdue: false, source: 'diagnosis', metadata, notificationQuickActions: timing === 'today' ? dueTodayQuickActions : futureTaskQuickActions, reminderPreferences: defaultAppSettings.reminderPreferences, sourceText: step.action, icon: getCareTaskIcon(type), }); } } return tasks; } function getCareNotificationId(task: CareTaskItem) { return `care:${task.id}`; } function createCareTaskActionState( task: CareTaskItem, status: Exclude, options?: { snoozeOffsetDays?: number; now?: string } ): CareTaskActionState { const now = options?.now || new Date().toISOString(); const snoozeOffsetDays = options?.snoozeOffsetDays || 1; const snoozedDueDate = getCareDateKey(snoozeOffsetDays); return { taskId: task.id, plantId: task.plantId, spaceId: task.spaceId || null, taskType: task.type, dueDate: status === 'snoozed' ? snoozedDueDate : task.dueDate, source: task.source, metadata: { ...task.metadata, dueDate: status === 'snoozed' ? snoozedDueDate : task.dueDate || null, }, notificationQuickActions: task.notificationQuickActions, reminderPreferences: task.reminderPreferences, status, completedAt: status === 'completed' ? now : undefined, snoozedUntil: status === 'snoozed' ? snoozedDueDate : undefined, skippedAt: status === 'skipped' ? now : undefined, updatedAt: now, }; } function buildCareDebugSeedState(tasks: CareTaskItem[], afterWorkReminderTime = defaultAppSettings.reminderPreferences.afterWorkReminderTime) { const now = new Date().toISOString(); const careTaskStates: Record = {}; const notificationStates: Record = {}; const dueTodayTask = tasks.find(task => task.timing === 'today' && task.plantName.toLowerCase().includes('golden pothos')) || tasks.find(task => task.timing === 'today'); const snoozedTask = tasks.find(task => task.id !== dueTodayTask?.id && task.timing === 'tomorrow') || tasks.find(task => task.id !== dueTodayTask?.id); const completedTask = tasks.find(task => task.id !== dueTodayTask?.id && task.id !== snoozedTask?.id && task.timing === 'this_week') || tasks.find(task => task.id !== dueTodayTask?.id && task.id !== snoozedTask?.id); if (dueTodayTask) { notificationStates[getCareNotificationId(dueTodayTask)] = { id: getCareNotificationId(dueTodayTask), isRead: false, isDismissed: false, updatedAt: now, }; } if (snoozedTask) { careTaskStates[snoozedTask.id] = createCareTaskActionState(snoozedTask, 'snoozed', { snoozeOffsetDays: 1, now }); notificationStates[getCareNotificationId(snoozedTask)] = { id: getCareNotificationId(snoozedTask), isRead: true, isDismissed: false, snoozedUntil: getNotificationSnoozeDateTime(notificationSnoozeOptions[3], afterWorkReminderTime), updatedAt: now, }; } if (completedTask) { careTaskStates[completedTask.id] = createCareTaskActionState(completedTask, 'completed', { now }); notificationStates[getCareNotificationId(completedTask)] = { id: getCareNotificationId(completedTask), isRead: true, isDismissed: false, updatedAt: now, }; } return { careTaskStates, notificationStates }; } function buildCareNotifications(input: { tasks: CareTaskItem[]; actionStates: Record; notificationStates: Record; }): PlantNotification[] { return input.tasks.map(task => { const actionState = input.actionStates[task.id]; const notificationState = input.notificationStates[getCareNotificationId(task)]; const status = actionState?.status; const isCompleted = status === 'completed'; const isSkipped = status === 'skipped'; const isSnoozed = status === 'snoozed' || Boolean(notificationState?.snoozedUntil); const title = isCompleted ? `${task.type} completed` : `${task.type} ${task.plantName}`; const category = isCompleted ? 'Achievement' : task.isOverdue || task.timing === 'today' ? 'Action Needed' : 'Reminder'; const message = isCompleted ? `${task.plantName} was marked complete.` : `${task.spaceName || 'No space assigned'} - ${task.actionLabel || task.dueLabel}`; return { id: getCareNotificationId(task), type: 'care_reminder', title, message, createdAt: task.dueDate ? `${task.dueDate}T08:00:00.000Z` : new Date().toISOString(), scheduledFor: notificationState?.snoozedUntil || task.dueDate || null, relatedPlantId: task.plantId, relatedTaskId: task.id, category, reason: isCompleted ? 'This care task was completed.' : `Reminder for ${task.plantName}: ${task.sourceText || 'saved care timing'}.`, relatedEntityType: 'care_task', relatedEntityId: task.id, actionLabel: isCompleted || isSkipped ? 'Open' : 'Complete', destination: task.plantId.startsWith(SAVED_PREFIX) ? 'plant_detail' : 'care', priority: task.isOverdue ? 'high' : 'normal', isRead: Boolean(notificationState?.isRead || isCompleted || isSkipped), isDismissed: Boolean(notificationState?.isDismissed || isSkipped), actionType: isCompleted || isSkipped ? 'none' : isSnoozed ? 'open_related' : 'complete', }; }); } function parseReminderTime(value?: string | null) { // Single source of truth (L6): defaultAppSettings.reminderPreferences // .afterWorkReminderTime is the canonical '5:30 PM' default. const text = (value || defaultAppSettings.reminderPreferences.afterWorkReminderTime || '').trim(); const amPmMatch = text.match(/^(\d{1,2})(?::(\d{2}))?\s*(AM|PM)$/i); if (amPmMatch) { let hour = Number(amPmMatch[1]); const minute = Number(amPmMatch[2] || 0); const meridiem = amPmMatch[3].toUpperCase(); if (meridiem === 'PM' && hour < 12) hour += 12; if (meridiem === 'AM' && hour === 12) hour = 0; return { hour, minute }; } const clockMatch = text.match(/^(\d{1,2})(?::(\d{2}))?$/); if (clockMatch) return { hour: Number(clockMatch[1]), minute: Number(clockMatch[2] || 0) }; return { hour: 17, minute: 30 }; } function getNotificationSnoozeDateTime(option: NotificationSnoozeOption, afterWorkReminderTime?: string | null) { const next = new Date(); if (option.key === 'one_hour') { next.setHours(next.getHours() + 1); } else if (option.key === 'after_work') { const { hour, minute } = parseReminderTime(afterWorkReminderTime); next.setHours(hour, minute, 0, 0); if (next.getTime() <= Date.now()) next.setDate(next.getDate() + 1); } else if (option.key === 'need_supplies') { next.setDate(next.getDate() + (option.days || 3)); next.setHours(9, 0, 0, 0); } else if (option.key === 'custom' && option.customDate) { return option.customDate.toISOString(); } else { next.setDate(next.getDate() + 1); next.setHours(9, 0, 0, 0); } return next.toISOString(); } function BottomNav({ tab, onChange }: { tab: TabKey; onChange: (key: TabKey) => void }) { const insets = useSafeAreaInsets(); const { width: screenWidth } = useWindowDimensions(); const stableBottomInset = useStableBottomInset(insets.bottom); // Nearly edge-to-edge (95% width), centered, preserving the asset's 1876:415 // (~4.52) aspect, anchored just above the Android system-nav inset. const navWidth = screenWidth * 0.95; const navHeight = navWidth * navAspect; const tabWidth = navWidth / tabs.length; return ( {tabs.map((t, index) => ( onChange(t.key), t.key === 'identify' ? haptic.scan : haptic.light)} activeOpacity={1} accessibilityRole="button" accessibilityLabel={t.label} > ))} ); } function buildHomeGardenSnapshotSummary(input: { savedPlants: SavedPlantProfile[]; samplePlants: SamplePlant[]; rooms: RoomProfile[]; careTaskStates: Record; /** The SAME tasks the Care tab renders (built via buildScheduledCareTasks + buildCareTasks + buildTreatmentCareTasks). Counting from this list keeps the Home snapshot's "needs care" exactly in sync with the Care tab (Bekky, 2026-08-20: previously it counted from a text-parsing buildCareTask path that disagreed with the Care tab, showing "needs care 0" while Care had 2 due). */ careTasks?: CareTaskItem[]; }): HomeGardenSnapshotSummary { const roomById = new Map(input.rooms.map(room => [room.id, room])); // NOTE (Bekky, 2026-08-20): a saved plant produces NO care notifications // until the user generates its care guidance (careSchedule). The regex // text-parsing path below would otherwise create tasks from wateringSchedule/ // careSummary text even before care is generated. Gate saved plants on having // a careSchedule; sample plants (demo garden) keep their static text tasks. const savedTasks = input.savedPlants .filter(plant => Boolean(plant.careSchedule)) .map(plant => buildCareTask({ key: `saved:${plant.id}`, plantName: getSavedPlantDisplayName(plant), spaceId: plant.spaceId || null, statusText: plant.careDifficulty === 'fussy' ? 'needs attention' : '', careText: plant.wateringSchedule || plant.careSummary || '', roomById, })) .filter((task): task is CareTaskItem => Boolean(task)); const sampleTasks = input.samplePlants .map(plant => buildCareTask({ key: `sample:${plant.name}`, plantName: getSamplePlantDisplayName(plant), spaceId: plant.spaceId || null, statusText: plant.status, careText: plant.care, roomById, })) .filter((task): task is CareTaskItem => Boolean(task)); // When the Care tab's task list is provided, count needs-care from THAT list // so the Home snapshot mirrors the Care tab exactly. Otherwise fall back to // the legacy text-parsing tasks (sample/demo garden). const snapshotTasks = input.careTasks?.length ? input.careTasks : [...savedTasks, ...sampleTasks]; const needsCarePlantIds = new Set( snapshotTasks .filter(task => { const state = input.careTaskStates[task.id]; return task.timing === 'today' && state?.status !== 'completed' && state?.status !== 'skipped' && state?.status !== 'snoozed'; }) // Normalize the saved: prefix (M2) — buildCareTask emits "saved:" while // scheduled/treatment tasks emit raw ids. Without this, the same plant // appearing in both paths is counted twice, inflating needsCareCount. .map(task => task.plantId?.startsWith(SAVED_PREFIX) ? task.plantId.slice(SAVED_PREFIX.length) : task.plantId), ); return { plantCount: input.savedPlants.length + input.samplePlants.length, spaceCount: input.rooms.length, needsCareCount: needsCarePlantIds.size, }; } function AppContent() { const [tab, setTab] = useState('home'); const [pendingCheckInInvId, setPendingCheckInInvId] = useState(null); const [savedPlants, setSavedPlants] = useState([]); const [savedPlantsLoaded, setSavedPlantsLoaded] = useState(false); const [wishlistItems, setWishlistItems] = useState([]); const [wishlistItemsLoaded, setWishlistItemsLoaded] = useState(false); // Field Diary albums (Bekky, 2026-08-25): one album per walk at a broad place. const [fieldAlbums, setFieldAlbums] = useState([]); const [fieldAlbumsLoaded, setFieldAlbumsLoaded] = useState(false); const [homeGardenRooms, setHomeGardenRooms] = useState([]); // Per-plant setup profiles (pot/medium/light) — needed so cohort grouping + // care packets carry the dry-down/retention signals (Bekky, 2026-08-27). const [plantSetups, setPlantSetups] = useState([]); const [homeGardenSamplePlants, setHomeGardenSamplePlants] = useState(gardenPlants); const [homeCareTaskStates, setHomeCareTaskStates] = useState>({}); const [notificationStates, setNotificationStates] = useState>({}); const [notificationCenterVisible, setNotificationCenterVisible] = useState(false); const [notificationSnoozeTarget, setNotificationSnoozeTarget] = useState<{ task: CareTaskItem; notification: PlantNotification } | null>(null); const [focusedCareTaskId, setFocusedCareTaskId] = useState(null); const [appSettings, setAppSettings] = useState(defaultAppSettings); // True once loadAppSettings() has resolved (Bekky, 2026-09-05): the weather // effect must NOT run before this, or it sees gardenLocation=null and WIPES // the persisted warnings cache (including tips) to [] on every open. const [settingsLoaded, setSettingsLoaded] = useState(false); const [settingsVisible, setSettingsVisible] = useState(false); const [otaPushedBanner, setOtaPushedBanner] = useState(false); // One-time "fresh update landed" popup (Bekky, 2026-08-31): a NEW flag path // (distinct from the old otaBannerPath, which the device already dismissed) // so this shows exactly once after the fresh reship, then burns. Confirms the // new update ID actually landed on the phone. const [updatedPopupVisible, setUpdatedPopupVisible] = useState(false); // Photo-update loop: after completing a care task, offer a quick photo check-in. const [careMetricsTask, setCareMetricsTask] = useState(null); // CARE CHECK-IN modal (Bekky, 2026-08-20): the task + its resolved readiness // data currently shown in the check-in modal. null = modal closed. const [careCheckInTask, setCareCheckInTask] = useState(null); // CASE-TREATMENT MODAL (Bekky, 2026-09-02): the case whose treatment steps are // due today, plus the list of stepIds shown in the modal (steps due now, not // yet recorded). null = closed. const [caseTreatmentCase, setCaseTreatmentCase] = useState(null); const [caseTreatmentDueStepIds, setCaseTreatmentDueStepIds] = useState([]); // Accumulates THIS session's step executions emitted by the case-treatment // modal's "Complete selected (N)" batch (Bekky, 2026-09-02): all N onComplete // calls read the SAME stale `caseTreatmentCase`, so we merge the queue into a // full list on every write (dedup by stepId, last-write-wins) to avoid one // append clobbering the others. Cleared on each fresh open of the modal. const caseStepExecQueueRef = useRef([]); // Undo banner driven from the check-in modal completion (Bekky, 2026-08-20): // when a task is completed via the modal, the completion happens here at the // App level, so the CarePlanScreen's local banner never fires. This state // drives the undo banner at the top of the Care page instead. const [careFeedbackExternal, setCareFeedbackExternal] = useState(null); const careFeedbackExternalTimer = useRef | null>(null); // Weather-triggered care warnings (Bekky, 2026-08-23): computed from the // stored GARDEN location + Open-Meteo forecast. Fetched on a weekly cadence // (6h cache), non-fatal — if no garden location or weather fails, no warnings. const [weatherWarnings, setWeatherWarnings] = useState([]); // The raw weather snapshot (Bekky, 2026-08-23): kept so the cadence builder // can apply the weather-based soil-depth nudge (drought -> water more often, // heavy rain -> less) to outdoor plants. null = no weather available. const [weatherSnapshot, setWeatherSnapshot] = useState(null); // CADENCE FYI lines (Bekky, 2026-09-02, Chunk 2b): brief non-interactive // lines explaining why a cadence shifted for a space (regular weather). The // "harmonization home" — they live pinned at the top of the notification bell. const [cadenceFyi, setCadenceFyi] = useState([]); // Weather alert modal (Bekky, 2026-09-02, Chunk 2b): tap an alert (in the bell // or Settings Weather section) re-opens this modal. null = modal closed. The // modal IS the alert — it does NOT open plant detail (no action info there). const [weatherAlertOpen, setWeatherAlertOpen] = useState(null); // AI alert-ADVICE cache (Bekky, 2026-09-02, Chunk 2b / Option B): signature → // short actionable advice. Persisted; reuses the same advice for a persisting // warning, re-calls only on trigger/severity change (new signature), and is // pruned when the warning passes. Loaded once, pruned + saved after each tick. const [alertAdviceCache, setAlertAdviceCache] = useState>({}); // Ref mirror so the enrichment effect reads the LATEST cache without the // cache being a dependency (avoids re-triggering the whole enrichment loop on // every advice write). Updated in step with setAlertAdviceCache. const alertAdviceCacheRef = useRef>({}); // Ref map plantId → setup (polish, 2026-09-05): mirrors plantSetups so the // tips' planting-context builder reads the LATEST setup without plantSetups // being a dependency of every handler. Synced whenever plantSetups changes. const setupByPlantIdRef = useRef>(new Map()); // Keep setupByPlantIdRef in sync with plantSetups. useEffect(() => { setupByPlantIdRef.current = new Map(plantSetups.map(s => [s.plantId, s])); }, [plantSetups]); const updateAlertAdviceCache = (next: Record) => { alertAdviceCacheRef.current = next; setAlertAdviceCache(next); }; // RICH "how to protect" tips (Bekky, 2026-09-05): fetched LAZILY when the // user taps "Want tips on protecting them? 🌱" and shown in a POP-UP MODAL // (polish, 2026-09-05) — the card stays compact: a button + plant list, not // a fat inline paragraph. Runs through the priority AI queue as HIGH // (user-initiated) so it jumps ahead of background NORMAL calls. Persisted // on the warning; after dismiss the button becomes "See tips 🌱" (reopens // the modal without refetching) until the event passes or it escalates. const [tipsLoadingId, setTipsLoadingId] = useState(null); // Which warning's tips are shown in the tips pop-up modal (null = closed). const [tipsModalId, setTipsModalId] = useState(null); // The warning object whose tips are currently in the modal. const tipsModal = tipsModalId ? weatherWarnings.find(w => w.id === tipsModalId) ?? null : null; // Build a per-warning PLANTING context string (polish, 2026-09-05): how each // exposed plant is planted (potted / in-ground / raised bed) + which share a // container, so the AI reasons correctly without hardcoded examples. const buildPlantingContext = useCallback((w: WeatherWarning): string => { const lines: string[] = []; // Map each exposed plant name → its profile + setup. const exposed = (w.plants || []).slice(); const profiles = savedPlants.filter(p => { const key = savedGardenKey(p.id); const setup = setupByPlantIdRef.current.get(key); const name = (p.name || p.commonName).toLowerCase(); return exposed.some(n => n.toLowerCase() === name); }); // Potted vs in-ground per plant. const potted: string[] = []; const inGround: string[] = []; for (const p of profiles) { const setup = setupByPlantIdRef.current.get(savedGardenKey(p.id)); const name = p.name || p.commonName; const how = setup?.plantedIn === 'ground' || setup?.inGroundKind === 'ground' ? 'ground' : setup?.plantedIn === 'pot' ? 'pot' : undefined; if (how === 'ground') inGround.push(name); else if (how === 'pot') potted.push(name); } if (potted.length) lines.push(`Potted: ${potted.join(', ')} — these can be moved to shelter.`); if (inGround.length) lines.push(`In the ground: ${inGround.join(', ')} — these cannot be moved; secure/shelter them where they are.`); // Shared containers (roommates are one unit — can't be separated). const groups = new Map(); for (const p of profiles) { const sc = p.sharedContainerId; if (!sc) continue; const name = p.name || p.commonName; if (!groups.has(sc)) groups.set(sc, []); groups.get(sc)!.push(name); } for (const [ , members ] of groups) { if (members.length > 1) { lines.push(`These share one container — treat as one unit: ${members.join(', ')}. If you move one, move them together.`); } } return lines.join(' '); }, [savedPlants, setupByPlantIdRef]); const fetchAlertTips = async (w: WeatherWarning) => { // If we already have tips, just open the modal (no refetch). if (w.tips) { setTipsModalId(w.id); return; } if (tipsLoadingId === w.id) return; // already loading setTipsLoadingId(w.id); try { const region = lightRegionFromGardenLocation(appSettings.privacy.gardenLocation); const regionLine = region?.regionLabel ? `${region.regionLabel}${region.climateType ? ` — ${region.climateType} climate` : ''}` : undefined; const day = weatherSnapshot?.daily?.find(d => d.date === w.date); const weatherDetail = day ? `wind up to ${day.windSpeedKmh ?? '?'} km/h, ${day.precipitationMm ?? '?'} mm rain, high ${day.tempMaxC.toFixed(0)}°C` : undefined; const tips = await runAiCall(() => enrichAlertTips({ plantNames: w.plants || (w.plantName ? [w.plantName] : []), spaceName: w.spaceName, trigger: w.trigger === 'wind' ? 'heavy_rain' : w.trigger, region: regionLine, weatherDetail, plantingContext: buildPlantingContext(w), subjectSig: alertSignature(w), }), 'high'); if (tips) { // Attach tips to the warning; open the modal right away. console.log(`[PIXIE-DEBUG fetch] got tips for ${w.id} len=${tips.length}`); setWeatherWarnings(prev => { const next = prev.map(pw => pw.id === w.id ? { ...pw, tips } : pw); saveWeatherWarningsCache(next).catch(() => {}); return next; }); setTipsModalId(w.id); } else { console.log(`[PIXIE-DEBUG fetch] tips NULL for ${w.id}`); } } finally { setTipsLoadingId(null); } }; // Care-refresh-in-progress indicator (Bekky, 2026-08-23): true while a batch // of care refreshes (e.g. after setting the garden location) is running, so // the UI can show "Updating care…" and clear it when done. const [careRefreshing, setCareRefreshing] = useState(false); const careRefreshCountRef = useRef(0); // Per-plant in-flight guard (expert review 2026-09-03, Fix 4): prevents // duplicate concurrent care refreshes for the SAME plant when multiple // triggers fire (extreme weather + care complete + garden-location set). const careRefreshInFlightRef = useRef>(new Set()); // Tracks the last extreme-weather signature (Bekky, 2026-08-23): when a NEW // frost/heat/storm/heavy-rain alert appears that wasn't there before, we // refresh the affected plants' care so the AI adapts. Stored as a ref so the // 6h weather fetch doesn't re-trigger a refresh for the same event. const lastExtremeWeatherSigRef = useRef(''); const cohortTriggerInFlightRef = useRef(false); // SETTLE-IN-ONE-PASS (Bekky, 2026-09-07): the cohort effect writes to // homeGardenRooms/savedPlants as it works, which re-triggers the effect // (they're in its dep array) → it re-runs and re-judges → visible // flip-flopping (Rosemary in/out, cadence 5→4→5). This ref stores a // signature of the EXTERNAL inputs (plants, rooms, weather, setups) the // last time the effect ran. If the signature is unchanged, the effect's own // writes caused the re-run → skip it (the work already settled). Only a // genuinely NEW external change (plant added, weather changed, etc.) re-runs. const cohortLastSigRef = useRef(''); // Restore an App-level completed/snoozed task back to its prior state (Undo). const restoreAppCareTaskState = (task: CareTaskItem, previousState?: CareTaskActionState) => { const next = { ...homeCareTaskStates }; if (previousState) next[task.id] = previousState; else delete next[task.id]; setHomeCareTaskStates(next); saveCareTaskActionStates(next).catch(() => undefined); setCareFeedbackExternal(null); }; const otaBannerPath = `${FileSystem.documentDirectory || ''}pixiesprout-garden/ota-pushed-banner.json`; // Fresh-reship confirmation flag (Bekky, 2026-08-31): a NEW path so the // one-time "update landed" popup shows once after this reship, then burns. const updatedPopupPath = `${FileSystem.documentDirectory || ''}pixiesprout-garden/updated-popup-20260831.json`; // const [showPhotoIntelDebug, setShowPhotoIntelDebug] = useState(false); // COMMENTED OUT (2026-08-15) const [selectedSavedPlantId, setSelectedSavedPlantId] = useState(null); const [selectedSpaceDetailId, setSelectedSpaceDetailId] = useState(null); const [scrollToCareGuidancePlantId, setScrollToCareGuidancePlantId] = useState(null); const [gardenResetToken, setGardenResetToken] = useState(0); const [scanModeActive, setScanModeActive] = useState(false); const [pendingProblemScanContext, setPendingProblemScanContext] = useState<{ imageUri: string; symptoms: string[]; userNote?: string; aiResult?: ProblemScanResult; investigationId?: string; } | null>(null); const [investigations, setInvestigations] = useState([]); const screenOpacity = useRef(new Animated.Value(1)).current; const tabHistoryRef = useRef([]); // LASTDONE UNIFICATION (Bekky, 2026-08-30): remembers each plant+action's // anchor BEFORE a completion overwrote it, so an undo can restore exactly // that. In-memory only — undo never survives a cold start (by design). const preCompletionAnchorsRef = useRef>(new Map()); const activeTabLabel = tab === 'identify' ? 'Scan' : tab; const isIdentify = tab === 'identify'; const isScanRoute = activeTabLabel === 'Scan' || tab === 'identify' || scanModeActive; // One-time OTA confirmation banner: show once on app launch, self-deletes after "Okay". useEffect(() => { (async () => { try { if (!FileSystem.documentDirectory) return; const info = await FileSystem.getInfoAsync(otaBannerPath); if (!info.exists) { setOtaPushedBanner(true); } } catch { // Fail safe: don't block the app if the check errors. } })(); }, [otaBannerPath]); const dismissOtaBanner = async () => { setOtaPushedBanner(false); try { if (FileSystem.documentDirectory) { await FileSystem.makeDirectoryAsync(`${FileSystem.documentDirectory}pixiesprout-garden/`, { intermediates: true }).catch(() => {}); await FileSystem.writeAsStringAsync(otaBannerPath, JSON.stringify({ dismissedAt: Date.now() })); } } catch { // Best-effort persistence; modal still closes. } }; // One-time "fresh update landed" popup (Bekky, 2026-08-31): shows once after // the fresh reship, then burns (flag file written on dismiss). Confirms the // new update ID actually reached the phone. useEffect(() => { (async () => { try { if (!FileSystem.documentDirectory) return; const info = await FileSystem.getInfoAsync(updatedPopupPath); if (!info.exists) { setUpdatedPopupVisible(true); } } catch { // Fail safe: don't block the app if the check errors. } })(); }, [updatedPopupPath]); const dismissUpdatedPopup = async () => { setUpdatedPopupVisible(false); try { if (FileSystem.documentDirectory) { await FileSystem.makeDirectoryAsync(`${FileSystem.documentDirectory}pixiesprout-garden/`, { intermediates: true }).catch(() => {}); await FileSystem.writeAsStringAsync(updatedPopupPath, JSON.stringify({ dismissedAt: Date.now() })); } } catch { // Best-effort persistence; modal still closes. } }; useEffect(() => { edgeToEdge?.setScanMode?.(isIdentify); return () => edgeToEdge?.setScanMode?.(false); }, [isIdentify]); useEffect(() => { if (!isScanRoute) return; setSettingsVisible(false); setNotificationCenterVisible(false); setNotificationSnoozeTarget(null); }, [isScanRoute]); useEffect(() => { let active = true; // Migrate cache photos to permanent storage FIRST (M13) — this reads+writes // saved-plants.json. If it ran concurrently with the load/persistence below, // the persistence effect could clobber the migrated URIs with stale in-memory // ones (lost update). Serializing migration before load eliminates the race. migrateCachePhotos().then(() => { if (!active) return; return loadSavedPlantProfiles(); }).then(plants => { if (!active || !plants) return; setSavedPlants(plants); setSavedPlantsLoaded(true); }).catch(() => { // Best-effort — if migration fails, still load the plants. if (!active) return; loadSavedPlantProfiles().then(plants => { if (!active || !plants) return; setSavedPlants(plants); setSavedPlantsLoaded(true); }); }); return () => { active = false; }; }, []); // Care-intelligence enrichment is MANUAL-only as of 2026-08-15 (Bekky's // direction): existing plants keep whatever data they already have; new // plants get their first-fetch guaranteed at save time; and re-enrichment // happens ONLY when the user taps "Save and refresh Pixie Intelligence" // (change-detection guarded). No startup backfill — plants never re-enrich // automatically looking for data. useEffect(() => { let active = true; loadWishlistItems().then(items => { if (!active) return; setWishlistItems(items); setWishlistItemsLoaded(true); }); return () => { active = false; }; }, []); useEffect(() => { let active = true; loadFieldAlbums().then(albums => { if (!active) return; setFieldAlbums(albums); setFieldAlbumsLoaded(true); }); return () => { active = false; }; }, []); useEffect(() => { let active = true; Promise.all([loadGardenInfrastructure(), loadCareTaskActionStates(), loadPlantNotificationStates(), loadAppSettings(), getAllInvestigations()]).then(([state, taskStates, loadedNotificationStates, loadedAppSettings, loadedInvestigations]) => { if (!active) return; const loadedRooms = state.rooms.length ? state.rooms : []; const loadedSamplePlants = state.samplePlantsSeeded ? state.samplePlants.map(storedPlant => { const defaultPlant = gardenPlants.find(plant => plant.id === storedPlant.id || plant.name === storedPlant.name); return defaultPlant ? { ...defaultPlant, ...storedPlant, image: defaultPlant.image } : null; }).filter((plant): plant is SamplePlant => Boolean(plant)) : gardenPlants; setHomeGardenRooms(loadedRooms); setPlantSetups(state.plantSetups || []); setHomeGardenSamplePlants(loadedSamplePlants); setHomeCareTaskStates(taskStates); setNotificationStates(loadedNotificationStates); setAppSettings(loadedAppSettings); setSettingsLoaded(true); setInvestigations(loadedInvestigations); }); // Register the silent background weather task (Bekky, 2026-09-03): the OS // runs it as early as it allows so the day's forecast is cached before the // user opens the app — no notification, no 5am ping. Idempotent + non-fatal. registerBackgroundWeatherTask(); // Migrate any cache photos to permanent storage — now handled serially in // the savedPlants load effect above (M13), so this fire-and-forget call is // removed to avoid a duplicate concurrent write to saved-plants.json. // Phase 4 — file degradation: compress old full-res photos to reclaim space. // Runs once per launch. The JSON memory always survives; photos only compress. (async () => { try { const preview = await previewDegradation(180); if (preview.compressedCount > 0) { const result = await runPhotoDegradation(180); if (result.compressedCount > 0 && result.reclaimedBytes > 0) { Alert.alert( '✨ Pixie tidied up some photos', `Compressed ${result.compressedCount} older photo${result.compressedCount === 1 ? '' : 's'} to save about ${formatBytes(result.reclaimedBytes)}. Your plant history is all still here — nothing was lost.` ); } } } catch { // Degradation is non-fatal } })(); return () => { active = false; }; }, []); useEffect(() => { if (!savedPlantsLoaded) return; saveSavedPlantProfiles(savedPlants).catch(err => { // M14 — don't swallow persistence failures silently; log for diagnosis. if (typeof __DEV__ !== 'undefined' && __DEV__) { console.warn('[Pixie] saveSavedPlantProfiles failed', err); } }); }, [savedPlants, savedPlantsLoaded]); /** * COHORT GENERATION TRIGGER (Bekky, 2026-08-27, Chunk 2). For every space with * at least 4 plants (the grouping threshold) that doesn't yet have cohorts, * gather each plant's care package and ask the AI to derive the space's * cohorts. Runs once per space (deduped by existing cohorts), in the * background, never blocking. Persists the derived cohorts back onto the room. */ useEffect(() => { if (!savedPlantsLoaded) return; if (cohortTriggerInFlightRef.current) return; // one at a time // SETTLE-IN-ONE-PASS (Bekky, 2026-09-07): build a signature of the EXTERNAL // inputs. If it matches the last run, this re-run was caused by the effect's // OWN writes (homeGardenRooms/savedPlants changed as it worked) → skip it; // the work already settled. Only a genuinely new external change re-runs. // NOTE: the signature deliberately EXCLUDES fields the effect itself writes // (r.cohorts, p.deviationCount) — otherwise its own writes would change the // signature and re-trigger it, defeating the purpose. const sig = JSON.stringify({ plants: savedPlants.map(p => [p.id, p.spaceId, p.sharedContainerId, p.careSchedule?.lastDone]), rooms: homeGardenRooms.map(r => [r.id, r.name, r.cohortsEnabled]), weather: weatherSnapshot ? [weatherSnapshot.weatherCode, weatherSnapshot.temperatureC] : null, setups: plantSetups.map(s => [s.plantId, s.potType]), }); if (sig === cohortLastSigRef.current) return; // nothing external changed cohortLastSigRef.current = sig; cohortTriggerInFlightRef.current = true; (async () => { try { // Find spaces with 4+ plants that don't yet have cohorts. const groupedSpaceIds = new Set( homeGardenRooms .filter(r => r.cohorts && (r.cohorts.water?.length || r.cohorts.fertilize?.length)) .map(r => r.id) ); for (const room of homeGardenRooms) { // INTELLIGENT GROUPINGS TOGGLE (Bekky, 2026-08-30): a space with the // toggle OFF is opted out — skip it entirely (never AI-grouped). if (room.cohortsEnabled === false) continue; // ========================================================= // COHORT ENGINE — SPACE STATE MACHINE (Bekky, 2026-09-07, // cohort-engine refactor). Each space is in exactly ONE state and // runs exactly ONE action. This replaces the old tangle of // hasStaleCrew / groupedSpaceIds / sameShape checks that couldn't // tell "settled" from "has a newcomer" from "has a stale crew". // ========================================================= const allCohorts = [ ...(room.cohorts?.water || []), ...(room.cohorts?.fertilize || []) ]; const hasCohorts = allCohorts.length > 0; const spacePlants = savedPlants.filter(p => resolveStoredPlantSpaceId(p, homeGardenRooms) === room.id); // SHARED CONTAINER FORCED COHORTS (Bekky, 2026-09-04): a shared pot // is a PHYSICAL FACT — water one, you water both. Compute the // container map FIRST so both the <4-plant path and the full-grouping // path can use it. const containerByPlantForRoom: Record = {}; spacePlants.forEach(p => { if (p.sharedContainerId) containerByPlantForRoom[p.id] = p.sharedContainerId; }); const containerGroups = new Map(); for (const [pid, cid] of Object.entries(containerByPlantForRoom)) { if (!cid) continue; const list = containerGroups.get(cid) || []; list.push(pid); containerGroups.set(cid, list); } const realContainerGroups = [...containerGroups.values()].filter(g => g.length >= 2); // ---- STATE A: UNGROUPED (room has no cohorts) ---- if (!hasCohorts) { if (spacePlants.length < COHORT_GROUPING_THRESHOLD) { // Below the AI-grouping threshold: only a shared container can // force a cohort here (no AI call — that's the point of the // threshold). No shared container → leave the space standalone. if (!realContainerGroups.length) continue; // CHURN GUARD: if the room already has a cohort holding ALL // members of a container (for either action), it's already // grouped — skip so we don't mint a fresh // `${action}_shared__` id every open. const alreadyGrouped = realContainerGroups.some(members => (['water', 'fertilize'] as const).some(action => (room.cohorts?.[action] || []).some(c => members.every(m => c.plantIds.includes(m))) ) ); if (alreadyGrouped) continue; // Build the forced shared-container cohort(s) directly (no AI). // Name the cohort after its members ("Goji Berry + Maidenhair // Fern") instead of the generic "Shared container" (Bekky, 2026-09-07). const plantNameById: Record = {}; spacePlants.forEach(p => { plantNameById[p.id] = p.name || p.commonName || p.scientificName || ''; }); const forced = forceSharedContainerCohorts({}, containerByPlantForRoom, new Date().toISOString(), plantNameById); const hasAny = (forced.water?.length || forced.fertilize?.length); if (!hasAny) continue; const nextRoom = { ...room, cohorts: { ...(room.cohorts || {}), ...forced }, updatedAt: new Date().toISOString(), }; setHomeGardenRooms(current => current.map(r => r.id === room.id ? nextRoom : r)); const loaded = await loadGardenInfrastructure(); const persistedRooms = (loaded.rooms || []).map(r => r.id === room.id ? nextRoom : r); await saveGardenInfrastructure({ ...loaded, rooms: persistedRooms }).catch(() => {}); continue; // no AI care pass for a <4-plant space } // ≥4 plants, ungrouped → FULL AI GROUPING (falls through to the // shared full-grouping block below). } else { // ---- Room HAS cohorts. Determine its state. ---- // AI RE-LOOK (Bekky-approved 2026-08-30): a grouped space re-enters // this pass when any cohort is STALE — never judged (missing hash), // or crew/cadence changed since the last judgment (hash mismatch). // NOTE (2026-09-07): lastAnchor is NOT in the hash, so watering a // group never marks it stale (Gap 2). const hasStaleCrew = allCohorts.some(c => { for (const action of ['water', 'fertilize'] as const) { const cur = cohortCareHash(action, c.plantIds, c.sharedCadence?.[action]); if (cur !== c.lastCareHashes?.[action]) return true; } return false; }); // NEWCOMER (Bekky, 2026-09-07, Gap 1): a plant in this space that // is NOT a member of any cohort. It must be evaluated against the // existing groups (lightweight fold-in), NOT skipped. const hasNewcomer = spacePlants.some(p => !allCohorts.some(c => c.plantIds.includes(p.id)) ); // REMOVAL STAMP FIX (Bekky, 2026-09-07): a plant moved to another // space is no longer in this room's `spacePlants`, but it may still // sit in a cohort's `plantIds`. That changes the cohort's hash → // would mark it stale → STATE C fires a full regroup. But the group // is just one lighter (Bekky: "they're just one less plant"). So // prune removed members from their cohorts AND update the stamp to // the new hash, so the crew settles immediately (mirrors the fold-in // stamp fix). Only fires when a member is actually gone. const spacePlantIds = new Set(spacePlants.map(p => p.id)); let removalChanged = false; const removalPruned: Partial> = {}; for (const action of ['water', 'fertilize'] as const) { const list = room.cohorts?.[action] || []; const pruned = list.flatMap(c => { const kept = c.plantIds.filter(id => spacePlantIds.has(id)); if (kept.length === c.plantIds.length) return [c]; // no removal removalChanged = true; // Bug 5 fix: a removal that drops a cohort below 2 members leaves // an invalid 1-member cohort — drop it entirely (the plant goes // standalone). A cohort is a GROUP; one member is not a group. if (kept.length < 2) return []; const newHash = cohortCareHash(action, kept, c.sharedCadence?.[action]); return [{ ...c, plantIds: kept, lastCareHashes: { ...(c.lastCareHashes || {}), [action]: newHash }, updatedAt: new Date().toISOString(), }]; }); removalPruned[action] = pruned; } if (removalChanged) { const prunedRoom = { ...room, cohorts: removalPruned, updatedAt: new Date().toISOString() }; setHomeGardenRooms(current => current.map(r => r.id === room.id ? prunedRoom : r)); const loadedPrune = await loadGardenInfrastructure(); const persistedPrune = (loadedPrune.rooms || []).map(r => r.id === room.id ? prunedRoom : r); await saveGardenInfrastructure({ ...loadedPrune, rooms: persistedPrune }).catch(() => {}); } // PIXIE-DIAG (2026-08-31): print the exact hash inputs + stored // stamp for every cohort on every open. NOT gated on __DEV__. for (const c of allCohorts) { for (const action of ['water', 'fertilize'] as const) { const cur = cohortCareHash(action, c.plantIds, c.sharedCadence?.[action]); console.log(`[Pixie-diag] room=${room.name} cohort=${c.name} action=${action} plantIds=${JSON.stringify(c.plantIds)} cadence=${c.sharedCadence?.[action]} anchor=${c.lastAnchor?.[action]} curHash=${cur} stored=${c.lastCareHashes?.[action]} stale=${cur !== c.lastCareHashes?.[action]}`); } } console.log(`[Pixie-diag] room=${room.name} hasStaleCrew=${hasStaleCrew} hasNewcomer=${hasNewcomer} grouped=${groupedSpaceIds.has(room.id)}`); // ---- STATE B: HAS NEWCOMER → LIGHTWEIGHT FOLD-IN (Gap 1) ---- // Only in ≥4-plant grouped spaces (Bekky, 2026-09-07): in a <4-plant // space the only cohort is a shared container, so a non-roommate // newcomer stands alone — no fold-in. Fold-in-plan-first (health). // ROOMMATE ABSORPTION (Bug 6 fix): a newcomer that SHARES a container // with an existing cohort member must be absorbed into that shared- // container cohort directly (no AI call — a shared pot is a physical // fact, and the "shared pot is never split" rule is hard). This works // even in a <4-plant space, where STATE B's ≥4 gate would otherwise // skip it. if (hasNewcomer) { for (const newcomer of spacePlants) { if (allCohorts.some(c => c.plantIds.includes(newcomer.id))) continue; // already a member if (!newcomer.sharedContainerId) continue; // not a roommate // Find a cohort that already holds a roommate of this newcomer. const roommateCohort = allCohorts.find(c => c.plantIds.some(pid => { const p = spacePlants.find(x => x.id === pid); return p?.sharedContainerId === newcomer.sharedContainerId; }) ); if (!roommateCohort) continue; // Determine which action the roommate cohort belongs to (water or // fertilize) by checking membership in each action's list. const targetAction: GroupableCareAction = (room.cohorts?.water || []).some(c => c.id === roommateCohort.id) ? 'water' : 'fertilize'; const targetList = (room.cohorts?.[targetAction] || []).map(c => { if (c.id !== roommateCohort.id) return c; const newPlantIds = c.plantIds.includes(newcomer.id) ? c.plantIds : [...c.plantIds, newcomer.id]; const newHash = cohortCareHash(targetAction, newPlantIds, c.sharedCadence?.[targetAction]); return { ...c, plantIds: newPlantIds, lastCareHashes: { ...(c.lastCareHashes || {}), [targetAction]: newHash }, updatedAt: new Date().toISOString() }; }); const absorbedRoom = { ...room, cohorts: { ...(room.cohorts || {}), [targetAction]: targetList }, updatedAt: new Date().toISOString() }; setHomeGardenRooms(current => current.map(r => r.id === room.id ? absorbedRoom : r)); const loadedAbsorb = await loadGardenInfrastructure(); const persistedAbsorb = (loadedAbsorb.rooms || []).map(r => r.id === room.id ? absorbedRoom : r); await saveGardenInfrastructure({ ...loadedAbsorb, rooms: persistedAbsorb }).catch(() => {}); } } if (hasNewcomer && spacePlants.length >= COHORT_GROUPING_THRESHOLD) { const setupByPlantIdFold = new Map(plantSetups.map(s => [s.plantId, s])); for (const newcomer of spacePlants) { if (allCohorts.some(c => c.plantIds.includes(newcomer.id))) continue; // already a member const fold = await enqueueAiCall(() => foldNewcomerIntoCohorts({ spaceId: room.id, spaceName: room.name, newcomer: { plantId: newcomer.id, plantName: newcomer.name || newcomer.commonName, packageText: buildCohortSignature({ plant: newcomer, room, setup: setupByPlantIdFold.get(savedGardenKey(newcomer.id)), weather: weatherSnapshot ?? undefined, neighborNames: new Map(spacePlants.map(p => [p.id, p.name || p.commonName || ''])), }), }, existingCohorts: room.cohorts || {}, })); if (!fold) continue; // AI says stand alone // Fold the newcomer into the target cohort (add to plantIds) + // write a ledger join entry with the fold-in plan. const targetAction = fold.action; const targetList = (room.cohorts?.[targetAction] || []).map(c => { if (c.id !== fold.cohortId) return c; const newPlantIds = c.plantIds.includes(newcomer.id) ? c.plantIds : [...c.plantIds, newcomer.id]; // STAMP UPDATE (Bekky, 2026-09-07): adding a member changes the // cohort's hash. Update the "already judged" stamp NOW so the // cohort settles immediately and STATE C (full regroup) does // NOT fire on the next open — the fold-in already handled it. const newHash = cohortCareHash(targetAction, newPlantIds, c.sharedCadence?.[targetAction]); return { ...c, plantIds: newPlantIds, lastCareHashes: { ...(c.lastCareHashes || {}), [targetAction]: newHash }, updatedAt: new Date().toISOString(), }; }); const foldedRoom = { ...room, cohorts: { ...(room.cohorts || {}), [targetAction]: targetList }, updatedAt: new Date().toISOString(), }; setHomeGardenRooms(current => current.map(r => r.id === room.id ? foldedRoom : r)); const loadedFold = await loadGardenInfrastructure(); const persistedFold = (loadedFold.rooms || []).map(r => r.id === room.id ? foldedRoom : r); await saveGardenInfrastructure({ ...loadedFold, rooms: persistedFold }).catch(() => {}); // Ledger join entry with the fold-in plan (health-first). const todayKeyFold = getCareDateKey(0); const openEntry = openCohortHistoryEntry(newcomer, fold.cohortId, targetAction); if (!openEntry) { updatePlantProfile(newcomer.id, { cohortHistory: appendCohortHistoryEntry(newcomer, { cohortId: fold.cohortId, name: (room.cohorts?.[targetAction] || []).find(c => c.id === fold.cohortId)?.name || '', action: targetAction, joined: todayKeyFold, source: 'care', foldPlan: fold.foldInNote, }), }); } } continue; // fold-in done; no full regroup } // ---- STATE B2: HAS DEVIATING MEMBER → PROPORTIONAL RETHINK (Gap 3) ---- // Divergence = a member's own due date repeatedly lands OUTSIDE the // group's shared window (deviationCount >= 2, consecutive). Scope is // proportional (Bekky, 2026-09-07): 1 → individual (AI fold-in vs // exit); 2+ same direction → cadence nudge (median drift first, AI on // repeat); 2+ mixed or majority → full regroup. Weather-caused // divergence is DEFERRED (never reshuffles). // forceRegroup (Bug 1 fix): a divergence classified as 'regroup' must // reach the full regroup even though hasStaleCrew is false (divergence // doesn't change the membership/cadence hash). This flag bypasses the // STATE C gate below. let forceRegroup = false; const weatherActive = (() => { if (!weatherSnapshot) return false; const sig = compressWeatherSignals(weatherSnapshot); return sig.heatStress || sig.saturationRisk >= 2 || sig.coldDamp; })(); // Build the diverging-member list per cohort+action. const divergingByCohort = new Map(); for (const action of ['water', 'fertilize'] as const) { for (const cohort of room.cohorts?.[action] || []) { const shared = cohort.sharedCadence?.[action]; if (!shared) continue; const members: { plantId: string; direction: 1 | -1 }[] = []; // DIVERGENCE SIGNAL (Bekky, 2026-09-07, Gap 3): a member is // diverging when its OWN next-due date lands OUTSIDE the group's // shared window (>1 day tolerance). Compute it fresh each pass // and increment/reset deviationCount accordingly. Weather-caused // divergence is DEFERRED (never counted) — weather is temporary // and cohort-wide. // Group reference date: the cohort's lastAnchor when it exists, // else the OLDEST member lastDone (the thirstiest sets the clock). const anchor = cohort.lastAnchor?.[action]; let groupRef = anchor; if (!groupRef) { let oldest = ''; for (const mpid of cohort.plantIds) { const mplant = spacePlants.find(p => p.id === mpid); const done = mplant?.careSchedule?.lastDone?.[action]; if (done && (!oldest || done < oldest)) oldest = done; } if (oldest) groupRef = oldest; } const groupNext = groupRef ? getCareDateKeyAfter(groupRef, shared) : getCareDateKey(shared); for (const pid of cohort.plantIds) { const plant = spacePlants.find(p => p.id === pid); if (!plant) continue; // Shared-container roommates can't exit (forced cohort) — skip. if (plant.sharedContainerId) continue; const ownLastDone = plant.careSchedule?.lastDone?.[action]; const ownEvery = plant.careSchedule?.scheduled?.find(s => s.action === action)?.everyDays; const ownNext = ownLastDone && ownEvery ? getCareDateKeyAfter(ownLastDone, ownEvery) : undefined; // Weather-defer: while a weather signal is active, don't count // (the weatherModifier already stretches the whole space). if (weatherActive) { // Reset any stale counter so a past divergence doesn't linger // through a weather event. if ((plant.deviationCount?.[action] ?? 0) !== 0) { setSavedPlants(current => current.map(pl => pl.id === pid ? { ...pl, deviationCount: { ...(pl.deviationCount || {}), [action]: 0 }, } : pl)); } continue; } const ownOffset = ownNext ? getCareDayOffset(ownNext) : undefined; const groupOffset = getCareDayOffset(groupNext); const diverging = ownOffset !== undefined && Math.abs(ownOffset - groupOffset) > 1; const count = plant.deviationCount?.[action] ?? 0; const nextCount = diverging ? count + 1 : 0; if (nextCount !== count) { setSavedPlants(current => current.map(pl => pl.id === pid ? { ...pl, deviationCount: { ...(pl.deviationCount || {}), [action]: nextCount }, } : pl)); } if (nextCount < 2) continue; // not diverging yet // Direction: + = needs more water (dries faster), - = needs less. // Parse a NUMERIC cohortAdjustment first (inferCohortDelta stores // "1"/"-1" for clear cases); fall back to text keywords. (Bug 2 // fix: numeric strings never matched the text regex → every // member defaulted to -1 → mixed-direction divergence was // silently misclassified as same-direction.) const adj = plant.cohortAdjustment?.[action]; let direction: 1 | -1 = -1; if (adj) { const num = parseInt(adj, 10); if (Number.isFinite(num) && num !== 0) { direction = num > 0 ? 1 : -1; } else if (/dries fast|more water|thirsty|drinks more|needs more/.test(adj)) { direction = 1; } } members.push({ plantId: pid, direction }); } if (members.length) divergingByCohort.set(`${cohort.id}:${action}`, { action, members }); } } if (divergingByCohort.size) { const setupByPlantIdDiv = new Map(plantSetups.map(s => [s.plantId, s])); for (const [key, { action, members }] of divergingByCohort) { const cohort = (room.cohorts?.[action] || []).find(c => `${c.id}:${action}` === key); if (!cohort) continue; const cls = classifyDivergence(cohort.plantIds.length, members.map(m => ({ plantId: m.plantId, action, direction: m.direction }))); if (cls === 'settled') continue; if (cls === 'individual') { // ONE diverging member → AI decides fold-back vs exit. const member = members[0]; const plant = spacePlants.find(p => p.id === member.plantId); if (!plant) continue; const evalRes = await enqueueAiCall(() => evaluateDivergingMember({ spaceId: room.id, spaceName: room.name, plant: { plantId: plant.id, plantName: plant.name || plant.commonName, packageText: buildCohortSignature({ plant, room, setup: setupByPlantIdDiv.get(savedGardenKey(plant.id)), weather: weatherSnapshot ?? undefined, neighborNames: new Map(spacePlants.map(p => [p.id, p.name || p.commonName || ''])), }), }, cohort, action, deviationCount: plant.deviationCount?.[action] ?? 0, })); if (!evalRes) continue; // non-fatal: keep in cohort if (evalRes.decision === 'fold-back') { // Fold-back: reset the deviation counter (reunification plan). setSavedPlants(current => current.map(pl => pl.id === plant.id ? { ...pl, deviationCount: { ...(pl.deviationCount || {}), [action]: 0 }, cohortAdjustment: { ...(pl.cohortAdjustment || {}), [action]: evalRes.plan || pl.cohortAdjustment?.[action] || '' }, } : pl)); } else { // Exit: remove from cohort + ledger reasonLeft + reset counter. const todayKeyDiv = getCareDateKey(0); const openEntry = openCohortHistoryEntry(plant, cohort.id, action); if (openEntry) { updatePlantProfile(plant.id, { cohortHistory: appendCohortHistoryEntry(plant, { ...openEntry, left: todayKeyDiv, reasonLeft: evalRes.reason || 'diverged from the group rhythm' }), }); } const newList = (room.cohorts?.[action] || []).map(c => c.id === cohort.id ? (() => { const kept = c.plantIds.filter(id => id !== plant.id); // STAMP UPDATE (Bug 4 fix): removing a member changes the // hash. Update the "already judged" stamp so the crew // settles (just one lighter) and STATE C does NOT fire a // regroup on the next open. const newHash = cohortCareHash(action, kept, c.sharedCadence?.[action]); return { ...c, plantIds: kept, lastCareHashes: { ...(c.lastCareHashes || {}), [action]: newHash }, updatedAt: new Date().toISOString() }; })() : c); const exitRoom = { ...room, cohorts: { ...(room.cohorts || {}), [action]: newList }, updatedAt: new Date().toISOString() }; setHomeGardenRooms(current => current.map(r => r.id === room.id ? exitRoom : r)); const loadedExit = await loadGardenInfrastructure(); const persistedExit = (loadedExit.rooms || []).map(r => r.id === room.id ? exitRoom : r); await saveGardenInfrastructure({ ...loadedExit, rooms: persistedExit }).catch(() => {}); setSavedPlants(current => current.map(pl => pl.id === plant.id ? { ...pl, deviationCount: { ...(pl.deviationCount || {}), [action]: 0 }, } : pl)); } } else if (cls === 'cadence') { // 2+ diverging, SAME direction → cadence nudge. First instance: // adjust shared cadence by the median drift (no AI). If it // happens again (deviationCount still high after nudge), the // next pass falls through to regroup via the stale-crew path. const drifts = members.map(m => { const plant = spacePlants.find(p => p.id === m.plantId); const adj = plant?.cohortAdjustment?.[action]; const n = adj ? parseInt(adj, 10) : 0; return Number.isFinite(n) ? n : (m.direction === 1 ? 1 : -1); }); const sorted = [...drifts].sort((a, b) => a - b); const median = sorted.length % 2 === 1 ? sorted[Math.floor(sorted.length / 2)] : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2; const newCadence = Math.max(1, Math.round((cohort.sharedCadence?.[action] ?? 7) + median)); const nudgedList = (room.cohorts?.[action] || []).map(c => c.id === cohort.id ? (() => { // STAMP UPDATE (Bug 3 fix): the nudge changes sharedCadence, // which is part of the hash. Update the "already judged" // stamp so the crew settles and STATE C does NOT fire a // regroup on the next open (design principle 3: adjust the // cadence, NOT reshuffle membership). const newHash = cohortCareHash(action, c.plantIds, newCadence); return { ...c, sharedCadence: { ...(c.sharedCadence || {}), [action]: newCadence }, lastCareHashes: { ...(c.lastCareHashes || {}), [action]: newHash }, updatedAt: new Date().toISOString() }; })() : c); const nudgeRoom = { ...room, cohorts: { ...(room.cohorts || {}), [action]: nudgedList }, updatedAt: new Date().toISOString() }; setHomeGardenRooms(current => current.map(r => r.id === room.id ? nudgeRoom : r)); const loadedNudge = await loadGardenInfrastructure(); const persistedNudge = (loadedNudge.rooms || []).map(r => r.id === room.id ? nudgeRoom : r); await saveGardenInfrastructure({ ...loadedNudge, rooms: persistedNudge }).catch(() => {}); // Reset the diverging members' counters (the nudge addressed it). setSavedPlants(current => current.map(pl => members.some(m => m.plantId === pl.id) ? { ...pl, deviationCount: { ...(pl.deviationCount || {}), [action]: 0 }, } : pl)); } // 'regroup' → falls through to STATE C (full regroup below). } // If any cohort classified as 'regroup', we fall through to the // full regroup. Otherwise continue (handled individually/nudge). const anyRegroup = [...divergingByCohort.entries()].some(([key, { action, members }]) => { const cohort = (room.cohorts?.[action] || []).find(c => `${c.id}:${action}` === key); return cohort && classifyDivergence(cohort.plantIds.length, members.map(m => ({ plantId: m.plantId, action, direction: m.direction }))) === 'regroup'; }); if (anyRegroup) forceRegroup = true; // Bug 1 fix: bypass the hasStaleCrew gate if (!anyRegroup) continue; } // ---- STATE C: HAS STALE CREW → FULL REGROUP (falls through) ---- // Bug 1 fix: also fire on forceRegroup (divergence-classified-as-regroup), // which hasStaleCrew alone would miss. if (!hasStaleCrew && !forceRegroup) continue; // ---- STATE D: SETTLED — do nothing ---- } // ========================================================= // FULL AI GROUPING + CARE PASS — reached from STATE A (ungrouped, // ≥4 plants) and STATE C (grouped, stale crew). // ========================================================= // Build each plant's COMPACT grouping signature (Bekky, 2026-08-27): // only the dry-down/root/light/stage/time fields grouping needs — not // the full verbose packet, which was timing out the AI (big + slow). const setupByPlantId = new Map(plantSetups.map(s => [s.plantId, s])); const packages = spacePlants.map(plant => { const setup = setupByPlantId.get(savedGardenKey(plant.id)); return { plantId: plant.id, plantName: plant.name || plant.commonName, packageText: buildCohortSignature({ plant, room, setup, weather: weatherSnapshot ?? undefined, neighborNames: new Map(spacePlants.map(p => [p.id, p.name || p.commonName || ''])), }), }; }); const result = await enqueueAiCall(() => determineSpaceCohorts({ spaceId: room.id, spaceName: room.name, plantPackages: packages, })); if (!result) continue; // SHARED CONTAINER FORCED COHORTS (Bekky, 2026-09-04): BEFORE the // stability guard and the hasAny early-return, merge roommates so a // shared pot is never split by the AI. Physical fact overrides AI // judgment: water one, you water both. A shared container creates a // forced cohort even if the AI found no other genuine groups. // (containerByPlantForRoom is hoisted above the threshold check so a // <4-plant space with a shared container also gets its forced cohort.) if (Object.keys(containerByPlantForRoom).length) { // Name the cohort after its members (Bekky, 2026-09-07). const plantNameById: Record = {}; spacePlants.forEach(p => { plantNameById[p.id] = p.name || p.commonName || p.scientificName || ''; }); result.cohorts = forceSharedContainerCohorts(result.cohorts, containerByPlantForRoom, new Date().toISOString(), plantNameById); } const hasAny = (result.cohorts.water?.length || result.cohorts.fertilize?.length); if (!hasAny) continue; // AI found no genuine groups → leave standalone // ========================================================= // STITCH 2 — STABILITY GUARD (Bekky, 2026-08-31, "not trigger-happy") // A regroup is HEAVYWEIGHT. If EVERY crew the AI just re-reported // already exists as an old cohort object (same members), this look // found the SAME crews: rebuild on the OLD objects carried forward — // names, anchors, feedback, per-chore stamps, history all survive — // persist in place, and skip the AI care pass + per-plant writes // entirely. Small date mismatches are absorbed by FOLD-IN PLANS // (stitch 3), never by reshuffling furniture around the plants. // ================================================ const oldCrewIds = new Set(allCohorts.flatMap(c => c.plantIds)); const freshCrews = [ ...(result.cohorts.water || []), ...(result.cohorts.fertilize || []), ] as Cohort[]; const allCrewsKnown = freshCrews.every(fc => allCohorts.some(oc => oc.plantIds.length === fc.plantIds.length && oc.plantIds.every(id => fc.plantIds.includes(id)) ) ); const newcomerIds = [...new Set(freshCrews.flatMap(c => c.plantIds))] .filter(id => !oldCrewIds.has(id)); const sameShape = allCrewsKnown && newcomerIds.length === 0; // STABILITY GUARD + STAMP BACKFILL (Bekky, 2026-08-31/09-01): when a // regroup re-reported the SAME crews (no newcomers; every fresh crew // matches an existing cohort), this is NOT a real change — it's the // grouping AI re-running on an already-settled room. The room must // stay settled, for two reasons: // 1. PRESERVE IDENTITY — carry the OLD cohort objects forward (names, // anchors, feedbackAdjustment, cohortHistory, existing stamps) // instead of adopting the freshly-renamed ones. A regroup AI can // word a settled group differently ("5-day bed" vs "moderate // garden bed") — adopting those renames every open is the churn // the user reported. // 2. BACKFILL JUDGE-STAMPS — any cohort formed before the per-action // stamp landed has `lastCareHashes` absent, which makes // hasStaleCrew stay true forever → the space re-enters this pass // EVERY open. Write the missing stamp(s) NOW (a deterministic // hash — no AI call, no rename) so the room settles and STAYS // settled across opens. Future watering still bumps the anchor → // invalidates the stamp → triggers exactly ONE care re-look, then // it re-settles (Bekky's "reconsider, but don't constantly // shuffle" behavior). if (sameShape) { const stabilized: Partial> = { ...(room.cohorts || {}), }; for (const actionKey of ['water', 'fertilize'] as const) { const freshList = result.cohorts[actionKey]; if (!freshList?.length) continue; stabilized[actionKey] = freshList.map((nc: Cohort) => { const prior = allCohorts.find(oc => oc.plantIds.length === nc.plantIds.length && oc.plantIds.every(id => nc.plantIds.includes(id)) ); if (!prior) return nc; // SAME crew → keep the OLD OBJECT WHOLESALE (name, anchors, // feedback, history); refresh only the note. const stampedCohort: Cohort = { ...prior, note: nc.note ?? prior.note }; const stamped = { ...(stampedCohort.lastCareHashes || {}) }; let changed = false; for (const action of ['water', 'fertilize'] as const) { if (!stamped[action]) { stamped[action] = cohortCareHash( action, prior.plantIds, prior.sharedCadence?.[action], ); changed = true; } } if (changed) stampedCohort.lastCareHashes = stamped; return stampedCohort; }); } const stabilizedRoom = { ...room, cohorts: stabilized, updatedAt: new Date().toISOString(), }; setHomeGardenRooms(current => current.map(r => r.id === room.id ? stabilizedRoom : r)); const loaded = await loadGardenInfrastructure(); const persistedRooms = (loaded.rooms || []).map(r => r.id === room.id ? stabilizedRoom : r); await saveGardenInfrastructure({ ...loaded, rooms: persistedRooms }).catch(() => {}); continue; // SAME CREWS → room stays settled; no regroup, no care pass, no rename } // ================================================ // LEDGER CAPTURE (stitch 6): the OLD crews of this space, BEFORE // the regroup replaces them. Anyone in an old crew who does not // reappear in the new same-action crews gets a leave-event below, // with the AI's reason when the grouping AI supplied one. // ================================================ const priorCohortsCapture: Array<{ cohort: Cohort; action: GroupableCareAction }> = [ ...(room.cohorts?.water || []).map(c => ({ cohort: c, action: 'water' as const })), ...(room.cohorts?.fertilize || []).map(c => ({ cohort: c, action: 'fertilize' as const })), ]; const exitReasons = new Map(); for (const [pid2, reason2] of Object.entries(result.exits || {})) { if (typeof reason2 === 'string' && reason2.trim()) exitReasons.set(pid2, reason2.trim()); } const todayKey = getCareDateKey(0); // Apply the derived cohorts onto the room + persist immediately. const nextRoom = { ...room, cohorts: { ...(room.cohorts || {}), ...result.cohorts }, updatedAt: new Date().toISOString(), }; setHomeGardenRooms(current => current.map(r => r.id === room.id ? nextRoom : r)); const loaded = await loadGardenInfrastructure(); const persistedRooms = (loaded.rooms || []).map(r => r.id === room.id ? nextRoom : r); await saveGardenInfrastructure({ ...loaded, rooms: persistedRooms }).catch(() => {}); // Cohort CARE (Bekky, 2026-08-27): after grouping finds the cohorts, // fetch ONE rich ACTION-SCOPED care call per cohort (water/fertilize) // to set the shared cadence + per-plant deltas. Builds on the grouping // result — "break into chunks, each call builds on the previous." const roomPlants = spacePlants; const plantById = new Map(roomPlants.map(p => [p.id, p])); // ================================================ // LEDGER WRITES — EXITS (stitch 6, Bekky 2026-08-31): anyone in an // OLD same-action crew who did NOT make it into the new crews gets // their ledger chapter CLOSED, with the AI's reason when the // grouping AI supplied one. Append-only — past stays preserved. // ================================================ for (const { cohort: oldC, action: oldAction } of priorCohortsCapture) { const newSameActionIds = new Set(((nextRoom.cohorts[oldAction] || []) as Cohort[]).flatMap(c => c.plantIds)); for (const pid of oldC.plantIds) { if (newSameActionIds.has(pid)) continue; // still in a crew for this action const pl = plantById.get(pid); if (!pl) continue; const openEntry = openCohortHistoryEntry(pl, oldC.id, oldAction); if (!openEntry) continue; // no open chapter (never joined / already closed) updatePlantProfile(pid, { cohortHistory: appendCohortHistoryEntry(pl, { ...openEntry, left: todayKey, reasonLeft: exitReasons.get(pid) || 'moved by a regroup of this space' }), }); } } const refinedCohorts = { ...(nextRoom.cohorts || {}) }; for (const action of ['water', 'fertilize'] as const) { const cohortList = refinedCohorts[action]; if (!cohortList?.length) continue; for (const cohort of cohortList) { const members = cohort.plantIds .map(id => plantById.get(id)) .filter((p): p is SavedPlantProfile => Boolean(p)); if (!members.length) continue; // PER-COHORT DEDUPE (AI RE-LOOK, Bekky-approved 2026-08-30): when // this cohort+action was ALREADY judged under its current facts, // the re-look pass leaves it alone — the AI runs only for stale // crews. Keeps re-entry cost at zero for unchanged groups. const curHash = cohortCareHash(action, cohort.plantIds, cohort.sharedCadence?.[action]); // PIXIE-DIAG (2026-08-31): NOT gated on __DEV__ — must fire in prod OTA. console.log(`[Pixie-diag] CARE-PASS room=${room.name} cohort=${cohort.name} action=${action} curHash=${curHash} stored=${cohort.lastCareHashes?.[action]} willRun=${curHash !== cohort.lastCareHashes?.[action]}`); if (curHash === cohort.lastCareHashes?.[action]) continue; const care = await enqueueAiCall(() => fetchCohortCare({ cohort, action, plants: members.map(p => ({ plant: p, room, setup: setupByPlantId.get(savedGardenKey(p.id)), })), weather: weatherSnapshot ?? undefined, spaceName: room.name, })); // PIXIE-DIAG (2026-09-01): the stamp at line 2192 ONLY writes when // `care` is truthy. If the AI returns null (flaky/network), the // stamp is silently skipped → cohort stays `stored=undefined` → // hasStaleCrew stays true → regroup AI renames the room every open. // Log the NULL case explicitly so we can SEE whether persistence is // being starved by a failed AI call versus a failed save. console.log(`[Pixie-diag] CARE-RESULT room=${room.name} cohort=${cohort.name} action=${action} care=${care ? 'ok' : 'NULL'} saved=${care ? (care.sharedCadenceDays ?? '?') : '-'}`); if (!care) continue; // ================================================ // LEDGER WRITES — JOINS (stitch 6, Bekky 2026-08-31): every new // member gets an open ledger chapter NOW — own join date, real // date-deviation vs the existing crew, and the AI's fold-in plan // the moment it lands. Health-first: facts inform the plan; the // AI judges; no one is forced past their thirst window. // ================================================ const newMembers = cohort.plantIds.filter(pid => { const p = plantById.get(pid); return p && !openCohortHistoryEntry(p, cohort.id, action); }); for (const pid of newMembers) { const pl = plantById.get(pid); if (!pl) continue; const own = pl.careSchedule?.lastDone?.[action]; let deviationDays: number | undefined; if (own) { const others = cohort.plantIds .filter(x => x !== pid) .map(x => plantById.get(x)?.careSchedule?.lastDone?.[action]) .filter((d): d is string => typeof d === 'string'); if (others.length) { const earliest = [...others].sort()[0]; const diff = Math.round((new Date(own + 'T12:00:00').getTime() - new Date(earliest + 'T12:00:00').getTime()) / 86400000); deviationDays = Math.max(-30, Math.min(30, diff)); } } const base = { cohortId: cohort.id, name: cohort.name, action, joined: todayKey, source: 'care' as const }; const entry = care.foldIns?.[pid] ? { ...base, foldPlan: care.foldIns[pid], deviationDays } : { ...base, deviationDays }; updatePlantProfile(pid, { cohortHistory: appendCohortHistoryEntry(pl, entry), }); } // Refine the cohort's shared cadence + attach per-plant deltas. const refinedCohort = { ...cohort, sharedCadence: { ...(cohort.sharedCadence || {}), [action]: care.sharedCadenceDays }, note: care.note || cohort.note, // RENAME ON CREW CHANGE (Bekky, 2026-08-30, §2c): the care AI // may rename the cohort when the crew's character changed // (merge/split); cadence-only refinements keep the old name. ...(care.renamedTo ? { name: care.renamedTo } : {}), // Judge-stamp: this cohort+action was seen under its CURRENT // facts; future re-look runs skip until crew/cadence/anchor // changes again. // SPLIT STAMP (Bekky, 2026-08-31, stitch 1): water and fertilize each get // their OWN "already judged" note — one chore's judgment never // invalidates the other, so settled groups settle FOR REAL. lastCareHashes: { ...(cohort.lastCareHashes || {}), [action]: cohortCareHash(action, cohort.plantIds, care.sharedCadenceDays), }, updatedAt: new Date().toISOString(), }; const idx = refinedCohorts[action]!.findIndex(c => c.id === cohort.id); if (idx >= 0) refinedCohorts[action]![idx] = refinedCohort; // Per-plant cohortAdjustment from the care call (e.g. "keep the // rosemary drier" → a small signed day-delta). for (const [pid, text] of Object.entries(care.adjustments)) { const plant = plantById.get(pid); if (!plant) continue; const plantRoom = homeGardenRooms.find(r => r.id === resolveStoredPlantSpaceId(plant, homeGardenRooms)); const targetRoom = plantRoom || room; const currentCohorts = targetRoom.cohorts || {}; const cohortInRoom = (currentCohorts[action] || []).find(c => c.id === cohort.id); if (!cohortInRoom) continue; const delta = inferCohortDelta(text); const nextPlants = savedPlants.map(pl => { if (pl.id !== pid) return pl; return { ...pl, cohortAdjustment: { ...(pl.cohortAdjustment || {}), [action]: typeof delta === 'number' ? String(delta) : text, }, }; }); setSavedPlants(nextPlants); } // STRAGGLER REUNIFICATION PLANS (Bekky, 2026-08-30, §B3): the AI's // bridge lines land in the SAME per-plant adjustment channel (a // reunion plan IS a per-plant care nuance) as RAW text — delta // inference is deliberately bypassed for these. for (const [pid, plan] of Object.entries(care.reunification || {})) { if (!plantById.get(pid)) continue; setSavedPlants(current => current.map(pl => pl.id === pid ? { ...pl, cohortAdjustment: { ...(pl.cohortAdjustment || {}), [action]: plan, }, } : pl)); } } } if (JSON.stringify(refinedCohorts) !== JSON.stringify(nextRoom.cohorts || {})) { const roomWithRefined = { ...nextRoom, cohorts: refinedCohorts }; setHomeGardenRooms(current => current.map(r => r.id === room.id ? roomWithRefined : r)); const loaded2 = await loadGardenInfrastructure(); const persisted2 = (loaded2.rooms || []).map(r => r.id === room.id ? roomWithRefined : r); await saveGardenInfrastructure({ ...loaded2, rooms: persisted2 }).catch(() => {}); } } } catch (err) { if (typeof __DEV__ !== 'undefined' && __DEV__) { console.warn('[Pixie] cohort generation failed', err); } } finally { cohortTriggerInFlightRef.current = false; } })(); }, [savedPlants, savedPlantsLoaded, homeGardenRooms, weatherSnapshot, plantSetups]); useEffect(() => { if (!wishlistItemsLoaded) return; saveWishlistItems(wishlistItems).catch(err => { if (typeof __DEV__ !== 'undefined' && __DEV__) { console.warn('[Pixie] saveWishlistItems failed', err); } }); }, [wishlistItems, wishlistItemsLoaded]); useEffect(() => { if (!fieldAlbumsLoaded) return; saveFieldAlbums(fieldAlbums).catch(err => { if (typeof __DEV__ !== 'undefined' && __DEV__) { console.warn('[Pixie] saveFieldAlbums failed', err); } }); }, [fieldAlbums, fieldAlbumsLoaded]); const changeTab = (nextTab: TabKey, options?: { keepSavedSelection?: boolean }) => { if (tab === 'profile' && nextTab !== 'profile') { } if (nextTab === 'home') { } if (nextTab === 'profile') { } if (nextTab === tab) { if (nextTab === 'profile') { setSelectedSavedPlantId(null); setGardenResetToken(token => token + 1); } return; } tabHistoryRef.current.push(tab); if (nextTab === 'profile' && !options?.keepSavedSelection) { setSelectedSavedPlantId(null); } screenOpacity.stopAnimation(); screenOpacity.setValue(0); setTab(nextTab); Animated.timing(screenOpacity, { toValue: 1, duration: 120, easing: Easing.out(Easing.quad), useNativeDriver: true, }).start(); }; useEffect(() => { const subscription = BackHandler.addEventListener('hardwareBackPress', () => { if (notificationSnoozeTarget) { setNotificationSnoozeTarget(null); return true; } if (notificationCenterVisible) { setNotificationCenterVisible(false); return true; } if (settingsVisible) { setSettingsVisible(false); return true; } const previousTab = tabHistoryRef.current.pop(); if (!previousTab) return false; // Clear any stale detail selection when backing out of the profile tab // (M9) — otherwise a previously-opened plant/space id can re-surface. if (previousTab === 'profile') { setSelectedSavedPlantId(null); setSelectedSpaceDetailId(null); setGardenResetToken(token => token + 1); } screenOpacity.stopAnimation(); screenOpacity.setValue(0); setTab(previousTab); Animated.timing(screenOpacity, { toValue: 1, duration: 120, easing: Easing.out(Easing.quad), useNativeDriver: true, }).start(); return true; }); return () => subscription.remove(); }, [notificationCenterVisible, notificationSnoozeTarget, screenOpacity, settingsVisible]); const appCareTasks = [ ...buildCareTasks({ savedPlants, samplePlants: homeGardenSamplePlants, rooms: homeGardenRooms }), ...buildScheduledCareTasks({ savedPlants, rooms: homeGardenRooms, weather: weatherSnapshot, season: appSettings.privacy.gardenLocation?.season ?? null }), ...buildTreatmentCareTasks({ investigations, savedPlants, roomById: new Map(homeGardenRooms.map(room => [room.id, room])), }), ]; // Home snapshot counts "needs care" from the SAME task list the Care tab // renders (appCareTasks), so the two always agree (Bekky, 2026-08-20). const homeGardenSummary = buildHomeGardenSnapshotSummary({ savedPlants, samplePlants: homeGardenSamplePlants, rooms: homeGardenRooms, careTaskStates: homeCareTaskStates, careTasks: appCareTasks, }); // Spaces that actually contain plants — the only spaces relevant to pet-access safety. // A pet having access to a plant-free space doesn't affect PixieSprout, so we only offer // spaces-with-plants as pet-access options. const petAccessibleRooms = homeGardenRooms.filter(room => savedPlants.some(plant => plant.spaceId === room.id) ); const plantNotifications = buildCareNotifications({ tasks: appCareTasks, actionStates: homeCareTaskStates, notificationStates, }); const appCareTaskById = new Map(appCareTasks.map(task => [task.id, task])); // Intelligent grouping cohorts (Chunk 2, Bekky 2026-08-27): surfaces each // space's derived cohorts (water + fertilize) to the Care tab as sweepable // units. Only spaces with at least one cohort appear. Resolves plant ids to // display names via a lookup so the UI never needs the raw id list. const spaceCohorts: Array<{ spaceId: string; spaceName: string; cohorts: Partial>; }> = homeGardenRooms .filter(room => { // INTELLIGENT GROUPINGS TOGGLE (Bekky, 2026-08-30): OFF = cohorts go // DORMANT, not deleted — the data stays on the room (reversibility), but // the Care tab renders this space per-plant (no cohort rows; per-plant // rows are the natural fallback since cohort plants still have tasks). if (room.cohortsEnabled === false) return false; return Boolean(room.cohorts && (room.cohorts.water?.length || room.cohorts.fertilize?.length)); }) .map(room => ({ spaceId: room.id, spaceName: room.name, cohorts: (room.cohorts || {}), })); // Per-plant cohort metadata for the Care tab (Chunk A, Bekky 2026-08-29): maps // a RAW plantId → { name, deltas } where deltas[action] is the AI-reasoning // sentence for why this plant's care is similar-but-slightly-different from // its group. `cohortAdjustment[action]` stores either a signed number string // (e.g. "-1") or the raw AI text — render a friendly phrase for numbers, the // raw text otherwise. Keyed by raw plant id (the cohort's plantIds are raw). // IMPORTANT: EVERY saved plant is included (with its real name) so cohort // members always resolve to a display name — a member with no delta must NOT // fall back to its raw id and get dropped (Bekky, 2026-08-29: "all the plants // have gone away"). Deltas are added only when present. // PER-PLANT LASTDONE MAP (Bekky, 2026-08-30, Chunk B core §B1): raw plantId → // lastDone map, for the Care tab's cooldown/rejoin notes (the Care screen has // no plant records — this is the one lightweight bridge it needs). const plantLastDone: Record>> = useMemo(() => { const map: Record>> = {}; for (const plant of savedPlants) { const ld = plant.careSchedule?.lastDone; if (!ld) continue; const entry: Partial> = {}; if (ld.water) entry.water = ld.water; if (ld.fertilize) entry.fertilize = ld.fertilize; if (entry.water || entry.fertilize) map[plant.id] = entry; } return map; }, [savedPlants]); const cohortPlantMeta: Record> }> = useMemo(() => { const meta: Record> }> = {}; for (const plant of savedPlants) { const entry: { name: string; photoUri?: string; deltas?: Partial> } = { name: plant.name || plant.commonName || 'Plant', // Plant photo for the cohort members modal tiles (Bekky, 2026-09-01): // same resolution as the garden page tiles — user photo, else scan photo. photoUri: plant.photoUri || plant.identification?.imageUri || plant.identification?.imageUris?.[0] || undefined, }; const adj = plant.cohortAdjustment; if (adj) { const deltas: Partial> = {}; for (const action of ['water', 'fertilize'] as const) { const raw = adj[action]; if (!raw) continue; const n = parseInt(raw, 10); if (Number.isFinite(n)) { deltas[action] = n < 0 ? `needs a little less water than the group (${Math.abs(n)} day${Math.abs(n) === 1 ? '' : 's'} drier)` : n > 0 ? `needs a little more water than the group (+${n} day${n === 1 ? '' : 's'})` : 'matches the group'; } else { deltas[action] = raw; } } if (Object.keys(deltas).length) entry.deltas = deltas; } meta[plant.id] = entry; } return meta; }, [savedPlants]); // Hydrate persisted weather warnings on load (Bekky, 2026-09-05): so the // bell shows them from the FIRST render instead of "no notifications then // suddenly there's these". The compute effect below reconciles + persists. useEffect(() => { let cancelled = false; (async () => { const persisted = await loadWeatherWarningsCache(); if (!cancelled && persisted.length) setWeatherWarnings(persisted); })(); return () => { cancelled = true; }; }, []); // Weather warnings (Bekky, 2026-08-23): fetch weather for the stored GARDEN // location and evaluate frost/heat/storm/heavy-rain warnings. Runs when the // garden location or plants change. Non-fatal — no location or weather failure // just leaves warnings empty. The 6h cache in weatherService prevents hammering. useEffect(() => { // Do NOT run until settings have loaded (Bekky, 2026-09-05): before that, // gardenLocation is null and this would WIPE the persisted warnings cache // (including tips) to [] on every open — the tips-persistence bug. if (!settingsLoaded) return; const gardenLocation = appSettings.privacy.gardenLocation; if (!gardenLocation || gardenLocation.latitude == null || gardenLocation.longitude == null) { setWeatherWarnings([]); saveWeatherWarningsCache([]).catch(() => {}); return; } let cancelled = false; (async () => { // Hydrate from the once-per-day cache FIRST so care tasks compute with // weather from the very first render (no "weather-less then weather-ful" // snap when the network resolve lands — the vanishing-upcoming-space bug, // Bekky 2026-09-02). Non-blocking; the fresh fetch below supersedes it. try { const cached = await readCachedWeather(gardenLocation.latitude!, gardenLocation.longitude!, 'rough'); if (cancelled) return; if (cached && cached.fetchedAt) { setWeatherSnapshot(cached); } } catch { /* non-fatal */ } const weather = await getWeather(gardenLocation.latitude!, gardenLocation.longitude!, 'rough'); if (cancelled || !weather) return; // Monthly rain normal (Bekky, 2026-09-01): the 30-yr average precip for // the current month, so the cadence can read the forecast as a DELTA from // the seasonal normal ("raining way more than normal this week → soil // saturated → stretch a lot"). Best-effort — absent on failure. const monthlyRainNormalMm = await getMonthlyRainNormal(gardenLocation.latitude!, gardenLocation.longitude!); if (cancelled) return; if (monthlyRainNormalMm != null) weather.monthlyRainNormalMm = monthlyRainNormalMm; // Rain history (Bekky, 2026-09-01): the ACTUAL observed precipitation for // the past few days, so the cadence can reason about CUMULATIVE + // COMPOUNDING wetness (the forecast only looks forward — it can't know // the soil is already soaked from three days of rain). Best-effort. const rainHistory = await getRainHistory(gardenLocation.latitude!, gardenLocation.longitude!); if (cancelled) return; if (rainHistory) weather.rainHistory = rainHistory; setWeatherSnapshot(weather); const warnings = evaluateWeatherWarnings({ plants: savedPlants, rooms: homeGardenRooms, weather, }); // Reconcile against the persisted set (Bekky, 2026-09-05): carry over // advice for warnings that already had it (no flicker), drop warnings // whose event passed, keep active ones. Persist so the next open hydrates // instantly and doesn't re-ping. if (!cancelled) { const persisted = await loadWeatherWarningsCache(); console.log(`[PIXIE-DEBUG compute] fresh=${warnings.length} persisted=${persisted.length} persistedTips=${persisted.filter(w=>w.tips).length}`); const merged = reconcileWeatherWarnings(warnings, persisted); setWeatherWarnings(merged); saveWeatherWarningsCache(merged).catch(() => {}); } // Cadence FYI (Bekky, 2026-09-02, Chunk 2b): brief non-interactive lines // explaining WHY a cadence shifted (regular weather — rain/heat). The // cadence AI already adjusted; this just tells the story. No new AI call. if (!cancelled) setCadenceFyi(buildCadenceFyi(weather, homeGardenRooms)); // Extreme-weather-triggered care refresh (Bekky, 2026-08-23): if a NEW // alert-level warning appeared (frost/heat/storm/heavy-rain) that wasn't // in the last fetch, refresh the affected OUTDOOR plants' care so the AI // adapts its guidance to the extreme event. "Outdoor" is determined by the // plant's SPACE (balcony/patio/garden/orchard), NOT the scan-time // locationContext (park/trail/street-tree are where a plant was IDENTIFIED, // not where it lives in the garden). Indoor plants are excluded — they // already get their climate context from the devices (fan/AC/heater/ // humidifier) + season, and an outdoor heatwave doesn't change an indoor // pot's care. The signature dedupes so the 6h fetch doesn't re-trigger // for the same event. const alerts = warnings.filter(w => w.severity === 'alert'); const sig = alerts.map(w => `${w.trigger}:${w.plantId || w.spaceId || ''}:${w.date || ''}`).sort().join('|'); if (sig && sig !== lastExtremeWeatherSigRef.current) { lastExtremeWeatherSigRef.current = sig; const affectedPlantIds = new Set(alerts.map(w => w.plantId).filter(Boolean)); for (const plant of savedPlants) { const room = plant.spaceId ? homeGardenRooms.find(r => r.id === plant.spaceId) : undefined; const isOutdoor = isOutdoorSpaceType(room?.spaceType); if (isOutdoor && affectedPlantIds.has(plant.id) && plant.careSchedule) { refreshCareGuidanceInBackground(plant.id); } } } })(); return () => { cancelled = true; }; }, [appSettings.privacy.gardenLocation, savedPlants, homeGardenRooms, settingsLoaded]); // WEATHER ALERT POP-UP on app open (Bekky, 2026-09-02, Chunk 2b): show a // can't-miss PixieAlert for any UNSEEN alert-level weather warning (frost/ // storm/heavy-rain on delicate plants). Dismiss = seen, persisted per-warning // id (weather-alert-seen-). A NEW alert after one was seen pops again // (escalation). useEffect(() => { if (!savedPlantsLoaded || !weatherWarnings.length) return; const alerts = weatherWarnings.filter(w => w.severity === 'alert'); if (!alerts.length) return; (async () => { try { if (!FileSystem.documentDirectory) return; // Load the set of already-seen alert ids. const seenPath = `${FileSystem.documentDirectory}pixiesprout-garden/weather-alerts-seen.json`; const seenInfo = await FileSystem.getInfoAsync(seenPath); let seen: string[] = []; if (seenInfo.exists) { try { const raw = await FileSystem.readAsStringAsync(seenPath); const parsed = JSON.parse(raw); if (Array.isArray(parsed)) seen = parsed; } catch { /* corrupt flag file — treat as empty */ } } // Pop the first alert the user hasn't seen yet (the highest-severity one). const unseen = alerts.find(w => !seen.includes(w.id)); if (unseen) { // Persist as seen BEFORE showing so a crash can't re-pop the same one // endlessly; the user still sees it and can dismiss. const nextSeen = [...seen, unseen.id]; await FileSystem.makeDirectoryAsync(`${FileSystem.documentDirectory}pixiesprout-garden/`, { intermediates: true }).catch(() => {}); await FileSystem.writeAsStringAsync(seenPath, JSON.stringify(nextSeen)); setWeatherAlertOpen(unseen); } } catch { // Fail safe: don't block the app if the check/persist errors. } })(); }, [savedPlantsLoaded, weatherWarnings]); // Hydrate the persistent alert-advice cache once on load, so advice survives // app restarts. After hydration the enrichment effect above picks it up. useEffect(() => { let cancelled = false; (async () => { const cache = await loadAlertAdviceCache(); if (!cancelled && Object.keys(cache).length) updateAlertAdviceCache(cache); })(); return () => { cancelled = true; }; }, []); // WEATHER ALERT ADVICE — cache + AI enrichment (Bekky, 2026-09-02, Option B). // Persist AI-enriched SHORT actionable advice per alert SIGNATURE. The same // warning reuses its cached advice (no re-call); a change or escalation = new // signature = new call. Prune + persist whenever the active alert set changes, // so a passed warning's advice auto-disappears. // In-flight guard (expert review 2026-09-03, C5): makes the effect idempotent // by construction — if a pass is already running, skip re-entry. Previously // saved only implicitly by the in-flight dedup, which was fragile. const advicePassRunningRef = useRef(false); useEffect(() => { if (!savedPlantsLoaded) return; if (advicePassRunningRef.current) return; // already running — skip re-entry advicePassRunningRef.current = true; const alerts = weatherWarnings.filter(w => w.severity === 'alert'); const activeSigs = new Set(alerts.map(alertSignature)); // Always read the LATEST cache from the ref (cache is NOT a dep so we don't // re-trigger the whole loop on every advice write). let cache = alertAdviceCacheRef.current; // 1) Attach any already-cached advice onto the current warnings. Guarded so // we only setState when something actually CHANGED — otherwise the new // array reference from .map() re-triggers this effect forever (infinite // re-render loop, expert review 2026-09-03). if (Object.keys(cache).length) { setWeatherWarnings(prev => { let changed = false; const next = prev.map(w => { if (w.severity === 'alert' && cache[alertSignature(w)] && w.advice !== cache[alertSignature(w)]) { changed = true; return { ...w, advice: cache[alertSignature(w)] }; } return w; }); return changed ? next : prev; // return SAME ref if nothing changed }); } let cancelled = false; (async () => { try { // Prune entries whose warning is no longer active (warning passed). const pruned = pruneAlertAdviceCache(cache, activeSigs); if (Object.keys(pruned).length !== Object.keys(cache).length) { cache = pruned; updateAlertAdviceCache(pruned); saveAlertAdviceCache(pruned).catch(() => {}); } // For each alert WITHOUT cached advice, fetch a fresh short sentence. // CACHE-COMPLETENESS GATE (expert review 2026-09-03, Fix 1): always // enrich any alert whose signature is missing from the cache, REGARDLESS // of the refresh stamp. This closes the "new alert after background // pass" gap — if the background ran at 5am and a storm warning hits at // 3pm, the new signature isn't in cache, so it gets advice now instead // of waiting until tomorrow. The stamp only gates the BULK re-fetch // cost, not per-alert gaps. const stamp = await readRefreshStamp(); const stampFresh = isStampFresh(stamp); // Only run the bulk pass when the stamp is stale/absent. But even when // fresh, we still fill any cache-missing signatures (the completeness // guarantee). We track whether we actually fetched anything so the // stamp is only marked on genuine completion. let anyAdvice = false; for (const w of alerts) { const sig = alertSignature(w); if (cache[sig]) continue; // already have advice for this signature // If the stamp is fresh, we still fill cache-missing signatures (the // completeness guarantee) — but we don't re-run the whole bulk pass. // The stamp only suppresses re-fetching signatures we already have. const trigger = w.trigger === 'wind' ? 'heavy_rain' : w.trigger; const input: WeatherAlertInput = { plantName: w.plantName || w.spaceName || 'your plants', spaceName: w.spaceName, trigger, date: w.date, detail: w.message, subjectSig: sig, }; const advice = await enqueueAiCall(() => enrichAlertAdvice(input)); if (cancelled || !advice) continue; anyAdvice = true; cache = { ...cache, [sig]: advice }; // Attach it live onto the warning currently being shown anywhere. setWeatherWarnings(prev => prev.map(pw => pw.id === w.id ? { ...pw, advice } : pw )); } // BATCH the cache write (expert review 2026-09-03, Fix 2): write ONCE // after the loop instead of per-alert, collapsing N race windows to one. if (anyAdvice) { updateAlertAdviceCache(cache); saveAlertAdviceCache(cache).catch(() => {}); // Persist the warnings WITH their advice (Bekky, 2026-09-05): so the // next open hydrates the full card (advice included) instead of // re-attaching it one-at-a-time (the "loads in three pieces" flicker). setWeatherWarnings(prev => { saveWeatherWarningsCache(prev).catch(() => {}); return prev; }); } // Mark the stamp only if the pass genuinely COMPLETED: either there // were no alerts to enrich, or at least one alert got advice. If the // AI proxy returned null for every alert (silent failure), do NOT // stamp fresh — the on-open fallback must run again next open so // alerts still get AI advice (expert review 2026-09-03, null-path). // Also guarded on !cancelled so a torn-down effect doesn't stamp. if (!cancelled && !stampFresh && (alerts.length === 0 || anyAdvice)) await markRefreshCompleted(); } catch { // Non-fatal — advice is enrichment; the deterministic message still shows. } })(); return () => { cancelled = true; advicePassRunningRef.current = false; }; }, [savedPlantsLoaded, weatherWarnings]); // task IDs from the due key (sched:::). When the cadence // changes, the OLD id vanishes → its persisted action + notification state // would orphan forever. Prune any sched-derived state that no longer maps to // a live schedule task (leaving diagnosis/wishlist/other tasks untouched). // Only runs once data has loaded so a not-yet-loaded state can't be wiped. useEffect(() => { if (!savedPlantsLoaded) return; const liveSchedTaskIds = new Set( appCareTasks .filter(t => t.source === 'care_schedule') .map(t => t.id) ); let careChanged = false; const nextCare: Record = {}; for (const [id, action] of Object.entries(homeCareTaskStates)) { // Prune only orphaned schedule tasks (ids with the sched: prefix that are // no longer a live schedule task); keep everything else untouched. if (id.startsWith('sched:') && !liveSchedTaskIds.has(id)) { careChanged = true; continue; } nextCare[id] = action; } let notifChanged = false; const nextNotif: Record = {}; for (const [id, state] of Object.entries(notificationStates)) { // Care-reminder notification ids are `care:`. Prune only those // whose task id is a sched-derived id no longer live. if (id.startsWith('care:sched:')) { const taskId = id.slice('care:'.length); if (!liveSchedTaskIds.has(taskId)) { notifChanged = true; continue; } } nextNotif[id] = state; } if (careChanged) { setHomeCareTaskStates(nextCare); saveCareTaskActionStates(nextCare).catch(() => undefined); } if (notifChanged) { setNotificationStates(nextNotif); savePlantNotificationStates(nextNotif).catch(() => undefined); } }, [appCareTasks, homeCareTaskStates, notificationStates, savedPlantsLoaded]); const notificationBadgeCount = plantNotifications.filter(notification => { if (notification.isRead || notification.isDismissed || !notification.relatedTaskId) return false; const task = appCareTaskById.get(notification.relatedTaskId); const actionState = task ? homeCareTaskStates[task.id] : undefined; if (!task || actionState?.status === 'completed' || actionState?.status === 'skipped') return false; if (task.isOverdue || task.timing === 'today' || actionState?.status === 'snoozed' || notificationStates[notification.id]?.snoozedUntil) return true; return false; }).length; const persistNotificationStates = (next: Record) => { setNotificationStates(next); savePlantNotificationStates(next).catch(err => { if (typeof __DEV__ !== 'undefined' && __DEV__) console.warn('[Pixie] savePlantNotificationStates failed', err); }); }; const persistAppSettings = (next: AppSettings) => { const updated = { ...next, updatedAt: new Date().toISOString() }; setAppSettings(updated); saveAppSettings(updated).catch(err => { if (typeof __DEV__ !== 'undefined' && __DEV__) console.warn('[Pixie] saveAppSettings failed', err); }); }; const patchNotificationState = (notificationId: string, patch: Partial) => { const previous = notificationStates[notificationId]; const next = { ...notificationStates, [notificationId]: { id: notificationId, isRead: previous?.isRead || false, isDismissed: previous?.isDismissed || false, remindedAt: previous?.remindedAt, snoozedUntil: previous?.snoozedUntil, ...patch, updatedAt: new Date().toISOString(), }, }; persistNotificationStates(next); }; const setAppCareTaskStatus = ( task: CareTaskItem, status: Exclude, ) => { const action = createCareTaskActionState(task, status); const next = { ...homeCareTaskStates, [task.id]: action }; setHomeCareTaskStates(next); saveCareTaskActionStates(next).catch(err => { if (typeof __DEV__ !== 'undefined' && __DEV__) console.warn('[Pixie] saveCareTaskActionStates failed', err); }); }; // CARE CHECK-IN helpers (Bekky, 2026-08-20). Resolve a task's plant + its // readiness bucket so the modal can show the right chips + per-plant guidance. const resolveTaskPlant = (task: CareTaskItem): SavedPlantProfile | undefined => { const rawId = task.plantId?.startsWith(SAVED_PREFIX) ? task.plantId.slice(SAVED_PREFIX.length) : task.plantId; return rawId ? savedPlants.find(p => p.id === rawId) : undefined; }; /** Which of the 3 readiness buckets a plant belongs to (pot-inside / pot-outside / in-ground). * Resolved from the plant's ASSIGNED SPACE first (the real indoor/outdoor * signal — the room's spaceType), falling back to locationContext when the * plant isn't in a space. FIX (Bekky, 2026-08-21): a plant assigned to an * INDOOR space was still showing outdoor/rain chips because the resolver only * read locationContext (which can be 'outdoor'/'garden_yard' from an earlier * scan even after the plant is placed indoors). The assigned room's spaceType * is now the source of truth for indoor vs outdoor. */ const resolveReadinessBucket = (plant: SavedPlantProfile): 'potInside' | 'potOutside' | 'inGround' => { const ctx = plant.locationContext; // Trees and street trees are almost always in the ground. if (plant.plantType === 'tree' || ctx === 'street_tree' || ctx === 'public_park_trail') return 'inGround'; // If the plant is assigned to a space, that room's type is authoritative: // indoor room → potInside; outdoor room → potOutside (in-ground trees handled above). const room = plant.spaceId ? homeGardenRooms.find(r => r.id === plant.spaceId) : undefined; if (room) { return isIndoorSpaceType(room.spaceType) ? 'potInside' : 'potOutside'; } // No assigned space — fall back to the location context. // Indoors = houseplants, nursery stock, or anything stamped 'home'. const indoor = ctx === 'indoor' || ctx === 'nursery_store' || ctx === 'home'; return indoor ? 'potInside' : 'potOutside'; }; const buildCareCheckInData = (task: CareTaskItem): CareCheckInData | null => { const plant = resolveTaskPlant(task); if (!plant?.careSchedule?.readiness) return null; const kit = plant.careSchedule.readiness; const bucket = resolveReadinessBucket(plant); const chips = kit.chips?.[bucket] || {}; const defaultDays = plant.careSchedule.scheduled.find(s => s.action === (task.type === 'Water' ? 'water' : 'fertilize'))?.everyDays ?? 7; // Strip any automatic-watering chips (Bekky 2026-08-21): the app can't know // the user's own self-watering/auto-watering state, so those reasons must // never appear — even if a previously-generated readiness kit stored one. const autoWaterRe = /self[- ]?water|reservoir|wick|drip|sprinkler|auto[- ]?water/i; const cleanChips = (labels: string[]) => labels.filter(label => !autoWaterRe.test(label)); // STANDARD blanket guidance (Bekky 2026-08-21): the AI-generated per-plant // guidance was a mess (redundant, sometimes empty, sometimes mentioned // auto-watering). Replace it with a clean, standard message based on the // plant's pot-vs-ground bucket — no AI call needed. const isInGround = bucket === 'inGround'; const guidanceWater = isInGround ? 'Check the soil a few centimetres down. If it feels dry at that depth, it\'s ready to water; if it\'s still damp, wait a day or two.' : 'Push a finger 2–3cm into the soil. If it feels dry at that depth, it\'s ready to water; if it\'s still damp, wait a day or two.'; const guidanceFertilize = 'Only feed when the plant is actively putting out new growth. If growth has stalled or it\'s dormant, it\'s not ready yet.'; const fertilizeFertilizer = plantSetups.find(s => s.plantId === task.plantId)?.fertilizer; return { guidanceWater, guidanceFertilize, waterChips: cleanChips(chips.water || []).map(label => ({ label, postponeDays: Math.max(1, Math.round(defaultDays * 0.5)) })), fertilizeChips: cleanChips(chips.fertilize || []).map(label => ({ label, postponeDays: Math.max(1, Math.round(defaultDays * 0.5)) })), defaultWaterDays: defaultDays, defaultFertilizeDays: defaultDays, fertilizeFertilizer, }; }; /** Fast lane — complete the task (crossed out + persists). NO photo/metrics * modal auto-pops after completing (Bekky, 2026-08-20): the CareMetricsModal * was still auto-triggering here and popping up after the confirm modal — * removed it. */ const setCareTaskStatusForComplete = (task: CareTaskItem) => { setAppCareTaskStatus(task, 'completed'); completeNotificationCareTaskFromTask(task); }; // CASE-TREATMENT OPEN — SHARED (Bekky, 2026-09-02): resolves a case's // not-yet-recorded treatment steps and opens the case-treatment multi-select // modal. Used by BOTH the Care-tab case headline (via a Task) AND the // case-detail "Log what I did" button (via an Investigation directly). // Accepts either a CareTaskItem (its metadata.relatedDiagnosisId resolves the // case) or an Investigation (used directly). // // DUE FILTER differs by entry point: a Care-tab task is a specific step due // today, so from there we only surface steps due NOW. From the case-detail // button (`allSteps=true`) we surface ALL un-recorded treatment steps — the // user may want to log what they did on a step that isn't strictly due today // (or that they worked ahead on). Both exclude already recorded steps. const openCaseTreatment = (target: { inv?: Investigation; task?: CareTaskItem }, allSteps = false) => { const inv = target.inv || (target.task?.metadata?.relatedDiagnosisId ? investigations.find(i => i.investigationId === target.task!.metadata!.relatedDiagnosisId) : undefined); if (!inv || !inv.treatmentPlan || !inv.treatmentPlan.steps.length) return; const alreadyRecorded = new Set((inv.stepExecutions || []).map(e => e.stepId)); const dueStepIds = inv.treatmentPlan.steps .filter(st => st.kind !== 'recheck') // treatment steps only .filter(st => allSteps || (st.dueOffsetDays || 0) <= 0) // due now (task) OR any (case-detail) .filter(st => !alreadyRecorded.has(st.stepId)) // not yet done/skipped .map(st => st.stepId); if (!dueStepIds.length) return; // Fresh open = fresh batch (the queue accumulates only this session's step // executions so a batch doesn't clobber; clear it on each open). caseStepExecQueueRef.current = []; setCaseTreatmentCase(inv); setCaseTreatmentDueStepIds(dueStepIds); }; // CASE-TREATMENT MODAL handler (Bekky, 2026-09-02): called once per step when // the user completes ('done') or skips ('skipped') a treatment step from the // case-treatment modal. Marks the matching `dx::` care task // completed/skipped, then persists the StepExecution onto the case so the AI // learns what the gardener actually used/did (the magic-bullet capture). // // BATCHING: "Complete selected (N)" fires one onComplete per step in a tight // loop, all reading the SAME stale `caseTreatmentCase`. We accumulate the // session's executions in a ref and always write the FULL merged list (dedup // by stepId, last-write-wins) so a batch doesn't clobber earlier appends. const handleCaseStepAction = (exec: StepExecution) => { if (!caseTreatmentCase) return; const inv = caseTreatmentCase; // 1. Mark the matching care task via the same completion/skip machinery the // CareCheckInModal uses. The task id is `dx:${inv.investigationId}:${stepId}`. const taskId = `dx:${inv.investigationId}:${exec.stepId}`; const task = appCareTasks.find(t => t.id === taskId); const previousState = homeCareTaskStates[taskId]; if (task) { setAppCareTaskStatus(task, exec.status === 'done' ? 'completed' : 'skipped'); } // 2. Accumulate this session's executions (dedup by stepId, last wins). caseStepExecQueueRef.current = [...caseStepExecQueueRef.current.filter(e => e.stepId !== exec.stepId), exec]; const mergedExecutions = [...(inv.stepExecutions || []), ...caseStepExecQueueRef.current]; // 3. Persist the full merged list + refresh local investigations. void updateInvestigation(inv.investigationId, { stepExecutions: mergedExecutions, }).then(updated => { if (updated) { setInvestigations(current => current.map(c => c.investigationId === inv.investigationId ? updated : c)); } else { // Fallback if the service couldn't persist: patch locally so the UI // still reflects this execution for the remainder of the session. setInvestigations(current => current.map(c => c.investigationId === inv.investigationId ? { ...c, stepExecutions: mergedExecutions } : c)); } }); // 4. Care feedback banner confirming the step was logged. setCareFeedbackExternal({ message: exec.status === 'done' ? `Treatment step logged${exec.usedProduct ? ` — used ${exec.usedProduct}` : ''}.` : 'Treatment step skipped.', undo: () => { if (task) restoreAppCareTaskState(task, previousState); }, }); if (careFeedbackExternalTimer.current) clearTimeout(careFeedbackExternalTimer.current); careFeedbackExternalTimer.current = setTimeout(() => setCareFeedbackExternal(null), 4200); }; /** * MANUAL GROUP ANCHOR (Bekky, 2026-08-30, chunk B-adjacent). Write the * lastDone anchor for a set of plants for one action — the shared mechanism * behind: cohort Complete-all, the Care-tab sweep, the space-profile Water-all, * and the group pencil date edit. Semantics (settled design): * - `anchored` = the plants whose anchor moves to `date` (e.g. today). * - Every touched plant gets an 'manual'-path careLog entry so the feedback * loop knows this was a SWEEP, not an observed per-plant check-in. * - Persisted immediately (the plant-save effect writes through, but we bump * `updatedAt` semantics by simply relying on the existing state write). * Deliberately NOT a task completion: works with zero pending tasks (the * post-vacation case) and is independent of the Intelligent Groupings toggle. */ const writeGroupAnchor = (input: { plantIds: string[]; action: 'water' | 'fertilize'; date?: string; // defaults to today (getCareDateKey(0)) triggerDate?: string; // for the log entry; defaults to the anchor date }) => { const { plantIds, action } = input; const date = input.date || getCareDateKey(0); if (!plantIds.length) return; setSavedPlants(current => current.map(plant => { if (!plantIds.includes(plant.id) || !plant.careSchedule) return plant; const schedule = plant.careSchedule; const entry = { action, triggerDate: input.triggerDate || date, completedDate: date, actualIntervalDays: 0, stateAtComplete: 'swept', path: 'manual' as const, }; const newLog = [...(schedule.careLog || []), entry]; // GROup anchor pre-state (Bekky, 2026-08-30): remember each member's // prior anchor so an UNDO of the group action can restore exactly it. preCompletionAnchorsRef.current.set(`${plant.id}:${action}`, (schedule.lastDone || {})[action] || ''); updatePlantProfile(plant.id, { careSchedule: { ...schedule, careLog: newLog, lastDone: { ...(schedule.lastDone || {}), [action]: date }, }, }); return plant; })); // GROUP ANCHOR AS FIRST-CLASS FACT (Bekky, 2026-08-30, Chunk B core §B1): // every group watering also stamps the COHORT's own lastAnchor so "when was // this group last watered" is answerable from the cohort alone (and so // cooldown/fold-back arithmetic has a group-side date to derive from). // The cohort is resolved by EXACT MEMBERSHIP — the group pencil/ sweeper // always passes the cohort's full plantIds, so a set-equality match is // unambiguous; if no cohort matches (e.g. an ad-hoc multi-plant sweep), // nothing is stamped (individual plants still got their own anchors above). try { const idSet = new Set(plantIds); setHomeGardenRooms(current => current.map(room => { const list = room.cohorts?.[action]; if (!list?.length) return room; const idx = list.findIndex(c => c.plantIds.length === plantIds.length && c.plantIds.every(id => idSet.has(id))); if (idx < 0) return room; const cohort = list[idx]; const updated: Cohort = { ...cohort, lastAnchor: { ...(cohort.lastAnchor || {}), [action]: date }, updatedAt: new Date().toISOString(), }; const nextList = [...list]; nextList[idx] = updated; return { ...room, cohorts: { ...(room.cohorts || {}), [action]: nextList }, updatedAt: new Date().toISOString() }; })); } catch (err) { if (typeof __DEV__ !== 'undefined' && __DEV__) console.warn('[Pixie] group anchor stamp failed', err); } }; const completeNotificationCareTaskFromTask = (task: CareTaskItem) => { const notif = plantNotifications.find(n => n.relatedTaskId === task.id); if (notif) patchNotificationState(notif.id, { isRead: true, isDismissed: false, snoozedUntil: undefined }); }; const onTaskPhotoUpdateForComplete = (task: CareTaskItem) => { // Mirror the photo-update loop: open the metrics/photo check-in for the // completed plant (only saved plants). const plant = resolveTaskPlant(task); if (!plant) return; setCareMetricsTask({ ...task, plantId: plant.id }); }; /** Learning lane — postpone the task by N days AND lengthen its cadence override so the next reminder is further out. */ const postponeCareTask = (task: CareTaskItem, stateAtComplete: string, postponeDays: number) => { // 1. Snooze the current task by postponeDays (closes it, reschedules the // reminder out by that many days). const action = createCareTaskActionState(task, 'snoozed', { snoozeOffsetDays: Math.max(1, postponeDays) }); const next = { ...homeCareTaskStates, [task.id]: action }; setHomeCareTaskStates(next); saveCareTaskActionStates(next).catch(() => undefined); // 2. Record the care log entry (Bekky, 2026-08-21): the feedback loop learns // the real interval + the moisture/growth state. The cadence adjustment // itself is decided by the background AI refresh, not a manual override. recordCareLogEntry(task, stateAtComplete, 'postpone'); }; /** Record a care event into the plant's careLog (Bekky, 2026-08-21). */ const recordCareLogEntry = (task: CareTaskItem, stateAtComplete: string, path: 'complete' | 'postpone' | 'early' | 'manual') => { const plant = resolveTaskPlant(task); if (!plant?.careSchedule) return; const actionKey: 'water' | 'fertilize' = task.type === 'Water' ? 'water' : 'fertilize'; const schedule = plant.careSchedule; const today = getCareDateKey(0); // The trigger date is the task's due date (when the notification fired). const triggerDate = task.metadata?.dueDate || today; const completedDate = today; const actualIntervalDays = Math.max(0, getCareDayOffset(triggerDate)); const entry = { action: actionKey, triggerDate, completedDate, actualIntervalDays, stateAtComplete, path, }; const newLog = [...(schedule.careLog || []), entry]; // Recompute the feedback adjustment from the updated log (Phase 2). const currentAdjustment = schedule.feedbackAdjustment?.[actionKey] ?? 0; const feedbackAdjustment = computeFeedbackAdjustment(newLog, actionKey, currentAdjustment); // LASTDONE UNIFICATION (Bekky, 2026-08-30): a real completion IS the // last-watered event — write the anchor in the SAME save as the log, so // lastDone stops lagging careLog. Keyed to 'complete'/'early' ONLY: a // postpone ("I did NOT water today") must never move the anchor. The manual // pencil path writes lastDone itself (saveCadenceOverride) and passes path // 'manual' — keep this write off that path so a manual date-edit can't be // clobbered back to today by its own log entry. The PREVIOUS anchor is kept // in an in-memory ref so an UNDO (same session only — undo banners never // survive a cold start) can restore exactly what the user had before. const lastDoneMap = { ...(schedule.lastDone || {}) }; if (path === 'complete' || path === 'early') { preCompletionAnchorsRef.current.set(`${plant.id}:${actionKey}`, lastDoneMap[actionKey] || ''); lastDoneMap[actionKey] = completedDate; } updatePlantProfile(plant.id, { careSchedule: { ...schedule, careLog: newLog, feedbackAdjustment, lastDone: lastDoneMap }, }); // Background AI refresh (Bekky, 2026-08-21, Phase 2): fire a care-guidance // recompute in the background so the AI baseline catches up with the user's // real behavior. Runs async, never blocks the user. refreshCareGuidanceInBackground(plant.id); }; /** * LASTDONE UNIFICATION — UNDO (Bekky, 2026-08-30). Reverting a completed * Water/Fertilize task must also revert the anchor it wrote: roll `lastDone` * back to the user's PREVIOUS anchor for that action (kept in * preCompletionAnchorsRef by recordCareLogEntry), drop the log entry this * completion added, and keep the recomputed feedback adjustment (it re-derives * from the log on any future write; leaving it is never worse than the stale * value it replaced). Same-session only — undo banners never survive a cold * start, and neither does this ref. */ const revertCareLogCompletion = (task: CareTaskItem) => { const plant = resolveTaskPlant(task); if (!plant?.careSchedule) return; const actionKey: 'water' | 'fertilize' = task.type === 'Water' ? 'water' : 'fertilize'; const schedule = plant.careSchedule; const today = getCareDateKey(0); const triggerDate = task.metadata?.dueDate || today; const previousLog = (schedule.careLog || []).filter(e => !(e.action === actionKey && e.completedDate === today && (e.path === 'complete' || e.path === 'early' || // GROUP-ANCHOR ROLLBACK (Bekky, 2026-08-30): group actions log // 'manual'/'swept' entries with triggerDate == anchor date (not the // task's due date), so match those by action + completion day. (e.path === 'manual' && e.stateAtComplete === 'swept' && e.triggerDate === (task.metadata?.dueDate || today)))) ); const refKey = `${plant.id}:${actionKey}`; const previousAnchor = preCompletionAnchorsRef.current.get(refKey); const lastDoneMap = { ...(schedule.lastDone || {}) }; if (previousAnchor) { lastDoneMap[actionKey] = previousAnchor; } else if (previousAnchor === '') { delete lastDoneMap[actionKey]; } preCompletionAnchorsRef.current.delete(refKey); updatePlantProfile(plant.id, { careSchedule: { ...schedule, careLog: previousLog, lastDone: lastDoneMap } }); }; /** * COHORT-WIDE WET/DRY FEEDBACK (Bekky, 2026-08-27, Chunk 2 — Step 6). The * user can say "this whole cohort still needs water" (wet) or "got too much * water" / "everyone dried out faster than expected" (dry). This adjusts the * COHORT's shared cadence (a signed day-delta per action) so the whole group * moves together — the sweeping-action equivalent of the per-plant feedback. * * Direction (mirrors per-plant): * - 'dry' (plant needs water more often) → shorten (negative delta) * - 'wet' (still wet, holds longer) → lengthen (positive delta) * Bounded ±7 days, moves 1 day per feedback (slow, safe convergence). * Finds the plant's cohort for the action and writes the delta back onto the * space's cohort (persisted with the room). */ const recordCohortFeedback = ( plant: SavedPlantProfile, action: 'water' | 'fertilize', signal: 'dry' | 'wet' ) => { const spaceId = plant.spaceId; if (!spaceId) return; const cohortId = plant.cohortIds?.[action]; if (!cohortId) return; const room = homeGardenRooms.find(r => r.id === spaceId); if (!room?.cohorts?.[action]) return; const list = room.cohorts[action] || []; const idx = list.findIndex(c => c.id === cohortId); if (idx < 0) return; const cohort = list[idx]; const current = cohort.feedbackAdjustment?.[action] ?? 0; const delta = signal === 'dry' ? -1 : +1; const next = Math.max(-7, Math.min(7, current + delta)); const updated = list.map((c, i) => i === idx ? { ...c, feedbackAdjustment: { ...(c.feedbackAdjustment || {}), [action]: next }, updatedAt: new Date().toISOString() } : c ); // Persist the updated cohorts back onto the room + the room list. const nextRoom = { ...room, cohorts: { ...room.cohorts, [action]: updated }, updatedAt: new Date().toISOString() }; const nextRooms = homeGardenRooms.map(r => r.id === spaceId ? nextRoom : r); setHomeGardenRooms(nextRooms); // IMMEDIATE persistence (Bekky, 2026-08-27): cohort feedback must survive // an app close. The App holds `homeGardenRooms` as a UI copy; the // authoritative garden state lives in storage. Load the full current state, // merge in the updated rooms, and save so the cadence change is never lost. loadGardenInfrastructure() .then(loaded => { const persistedRooms = (loaded.rooms || []).map(r => r.id === spaceId ? nextRoom : r); return saveGardenInfrastructure({ ...loaded, rooms: persistedRooms, }); }) .catch(err => { if (typeof __DEV__ !== 'undefined' && __DEV__) console.warn('[Pixie] saveCohortFeedback failed', err); }); }; /** * Background AI refresh (Bekky, 2026-08-21, Phase 2): recompute the plant's * care guidance from its real context + accumulated careLog. Fires after a * complete/postpone so the AI baseline reflects the user's behavior. Runs * async in the background — the user is never blocked. The feedback * adjustment (rule-based) applies immediately; this refines the baseline. */ const refreshCareGuidanceInBackground = (plantId: string) => { const plant = savedPlants.find(p => p.id === plantId); if (!plant?.careSchedule) return; // Per-plant in-flight guard (expert review 2026-09-03, Fix 4): if a refresh // for this plant is already running, skip — don't fire a duplicate. if (careRefreshInFlightRef.current.has(plantId)) return; careRefreshInFlightRef.current.add(plantId); const room = plant.spaceId ? homeGardenRooms.find(r => r.id === plant.spaceId) : undefined; const setup = plantSetups.find(s => s.plantId === savedGardenKey(plant.id)); const region = lightRegionFromGardenLocation(appSettings.privacy.gardenLocation); const packet = buildCareContextPacket({ plant, room, setup, region, weather: weatherSnapshot ?? undefined }); const contextPacket = renderCareContextPacket(packet); const commonName = plant.name || plant.commonName; const scientificName = plant.scientificName; const organicFirst = appSettings.carePreferences?.organicFirst; const location = appSettings.privacy.approximateLocationContext || null; // SHARED CONTAINER (Bekky, 2026-09-04): if this plant shares a container, // the auto-care refresh judges the WHOLE container as one unit and writes // the same schedule to all roommates. const roommatesForAuto = plant.sharedContainerId ? savedPlants.filter(p => p.id !== plant.id && p.sharedContainerId === plant.sharedContainerId) : []; const containerContext = roommatesForAuto.length ? roommatesForAuto.map(r => r.name || r.commonName || r.scientificName || 'Plant').join(', ') : undefined; const containerMemberIds = roommatesForAuto.length ? [plantId, ...roommatesForAuto.map(r => r.id)] : [plantId]; // Track the in-flight batch so the "Updating care…" indicator clears when // the last refresh completes (Bekky, 2026-08-23). careRefreshCountRef.current += 1; setCareRefreshing(true); const done = () => { careRefreshInFlightRef.current.delete(plantId); careRefreshCountRef.current = Math.max(0, careRefreshCountRef.current - 1); if (careRefreshCountRef.current === 0) setCareRefreshing(false); }; // Bounded silent retry (expert review 2026-09-03, Fix 4): up to 3 attempts // with linear backoff, mirroring the manual path. Never blocks the UI. const MAX_ATTEMPTS = 3; const BACKOFF_MS = 2000; const attempt = (n: number): Promise => import('./services/plantEnrichment').then(mod => mod.generateCareGuidance(commonName, scientificName, { organicFirst, location, contextPacket, containerContext, }).then(result => { if (result) return result; if (n < MAX_ATTEMPTS) { return new Promise(resolve => setTimeout(resolve, BACKOFF_MS * n)).then(() => attempt(n + 1)); } return null; }) ); attempt(1).then(result => { if (result) { // Preserve the user's observed history + anchors (the AI recompute must // not clobber them): careLog, feedbackAdjustment, lastDone (the manual // 'I watered it today' anchor — dropping it re-anchors tasks to today // and pushes them out of the 5-day window, making them disappear), and // any legacy overrides. // SHARED CONTAINER (Bekky, 2026-09-04): write the same schedule to every // roommate so the whole container shares one care plan. containerMemberIds.forEach(memberId => { const current = savedPlants.find(p => p.id === memberId)?.careSchedule; const merged = preserveStableInspectGuidance(current, result); if (merged) { updatePlantProfile(memberId, { careSchedule: merged }); } }); } done(); }).catch(() => { done(); }); }; /** * AUTO-CARE AFTER 24H GRACE (Bekky, 2026-09-03): a newly-added plant gets a * 24-hour window to let the user enter setup/environment/placement BEFORE * care is fetched (so we don't fire a bunch of calls while they set up each * section). After 24h, if the plant still has NO care schedule, generate it * automatically in the background using whatever context exists. Once care * exists, the manual "Generate Custom Care Guidance" button is replaced by * the guidance (code-driven only after that). In-flight guard prevents * duplicate concurrent calls; a failure just retries next open. */ const autoCareAttemptedRef = useRef>(new Set()); useEffect(() => { if (!savedPlantsLoaded) return; const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; const scan = () => { const now = Date.now(); for (const plant of savedPlants) { if (plant.careSchedule) continue; // already has care if (autoCareAttemptedRef.current.has(plant.id)) continue; // already tried this session const created = new Date(plant.createdAt).getTime(); // Missing/NaN createdAt = treat as eligible NOW (not a permanent skip — // expert review 2026-09-03, N2). Otherwise require the 24h grace window. if (!Number.isNaN(created) && now - created < TWENTY_FOUR_HOURS_MS) continue; // still in grace window // NOTE (expert review 2026-09-03, N3): do NOT add to autoCareAttemptedRef // here (before the call). If the call fails, we want the next 5-min scan // to retry. Only mark as attempted on SUCCESS below. // Generate care for a plant that has NONE (not refresh — the refresh // helper requires an existing careSchedule). Uses whatever context exists. const room = plant.spaceId ? homeGardenRooms.find(r => r.id === plant.spaceId) : undefined; const setup = plantSetups.find(s => s.plantId === savedGardenKey(plant.id)); const region = lightRegionFromGardenLocation(appSettings.privacy.gardenLocation); const packet = buildCareContextPacket({ plant, room, setup, region, weather: weatherSnapshot ?? undefined }); const contextPacket = renderCareContextPacket(packet); const commonName = plant.name || plant.commonName; const scientificName = plant.scientificName; const organicFirst = appSettings.carePreferences?.organicFirst; const location = appSettings.privacy.approximateLocationContext || null; // SHARED CONTAINER (Bekky, 2026-09-04): auto-care must ALSO be // container-aware — judge the whole container and write the same // schedule to every roommate, so one member's auto-care loops the // others in (the earlier bug: auto-care only wrote to the single plant). const roommatesForAutoCare = plant.sharedContainerId ? savedPlants.filter(p => p.id !== plant.id && p.sharedContainerId === plant.sharedContainerId) : []; const containerContext = roommatesForAutoCare.length ? roommatesForAutoCare.map(r => r.name || r.commonName || r.scientificName || 'Plant').join(', ') : undefined; const containerMemberIds = roommatesForAutoCare.length ? [plant.id, ...roommatesForAutoCare.map(r => r.id)] : [plant.id]; import('./services/plantEnrichment').then(mod => { mod.generateCareGuidance(commonName, scientificName, { organicFirst, location, contextPacket, containerContext, }).then(result => { if (result) { // Only mark as attempted on SUCCESS — a failure retries next scan. autoCareAttemptedRef.current.add(plant.id); // Write the SAME schedule to every roommate (container-aware). containerMemberIds.forEach(memberId => { const memberCurrent = savedPlants.find(p => p.id === memberId)?.careSchedule; const merged = mod.preserveStableInspectGuidance(memberCurrent, result); if (merged) updatePlantProfile(memberId, { careSchedule: merged }); }); } }).catch(() => { /* retry next scan */ }); }).catch(() => { /* retry next scan */ }); } }; scan(); // Re-scan periodically so a plant that crosses the 24h mark while the app // sits idle still gets auto-care (expert review 2026-09-03, N1). The // autoCareAttemptedRef prevents re-firing for already-tried plants. const interval = setInterval(scan, 5 * 60 * 1000); // every 5 min return () => clearInterval(interval); }, [savedPlants, savedPlantsLoaded]); /** * STARTUP ENRICHMENT RECOVERY (Bekky, 2026-09-03, Fix 3): on load, re-enrich * any plant that (a) has a `pendingEnrichment` flag (its enrichment was * interrupted by a mid-flight close), OR (b) LACKS enrichment data entirely, * OR (c) has updated context params that warrant a second look (change- * detection: contextFingerprint / contextPhotoFingerprint differ from the * current packet). This is ORGANIZED (runs through the concurrency limiter, * never a chaotic burst) and does NOT overwrite for no reason (plants with * fresh, unchanged enrichment are skipped). It is TARGETED recovery, NOT the * blanket startup backfill Bekky disabled on 2026-08-15 (that re-enriched * every plant looking for data; this only re-runs plants that genuinely * need it). */ const startupEnrichAttemptedRef = useRef>(new Set()); useEffect(() => { if (!savedPlantsLoaded) return; for (const plant of savedPlants) { if (startupEnrichAttemptedRef.current.has(plant.id)) continue; const room = plant.spaceId ? homeGardenRooms.find(r => r.id === plant.spaceId) : undefined; const setup = plantSetups.find(s => s.plantId === savedGardenKey(plant.id)); const region = lightRegionFromGardenLocation(appSettings.privacy.gardenLocation); const packet = buildCareContextPacket({ plant, room, setup, region, weather: weatherSnapshot ?? undefined }); const fingerprint = fingerprintContextPacket(packet); // BIO/BASICS are an ANNUAL snapshot (Bekky, 2026-09-04): the bio, light/ // water/soil needs, humidity, difficulty, fun fact, propagation, and pest // list are STATIC species facts. They must NOT be regenerated on context // change (weather/setup) — that was clobbering the bio while the user read // it. Refetch ONLY when: missing, >1yr old, or the species changed. const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; const enrichedAt = plant.enrichmentData?.enrichedAt ? new Date(plant.enrichmentData.enrichedAt).getTime() : NaN; const bioStale = !Number.isNaN(enrichedAt) && (Date.now() - enrichedAt) > ONE_YEAR_MS; const speciesKey = `${(plant.name || plant.commonName || '').toLowerCase()}|${(plant.scientificName || '').toLowerCase()}`; const speciesChanged = !!plant.enrichedSpeciesKey && plant.enrichedSpeciesKey !== speciesKey; const needsEnrichment = plant.pendingEnrichment === true || // interrupted pass !plant.enrichmentData || // lacks data bioStale || // annual snapshot expired speciesChanged; // species re-identified / renamed if (!needsEnrichment) continue; startupEnrichAttemptedRef.current.add(plant.id); const contextPacket = renderCareContextPacket(packet); const commonName = plant.name || plant.commonName; const scientificName = plant.scientificName; const organicFirst = appSettings.carePreferences?.organicFirst; const location = appSettings.privacy.approximateLocationContext || null; import('./services/plantEnrichment').then(mod => { mod.enrichPlantProfile(commonName, scientificName, { organicFirst, location, contextPacket, }).then(result => { if (result) { updatePlantProfile(plant.id, { enrichmentData: result.enrichmentData, propagationInfo: result.propagationInfo, pendingEnrichment: false, // completed — clear the recovery flag enrichedSpeciesKey: speciesKey, // remember what species this bio is for contextFingerprint: fingerprint, // store so next open skips if unchanged }); } }).catch(() => { /* retry next open */ }); }).catch(() => { /* retry next open */ }); } }, [savedPlants, savedPlantsLoaded]); /** * FEEDBACK LOOP (Bekky, 2026-08-21, Phase 2): compute a signed day-delta for * an action from the accumulated careLog + slider state. Rule-based, no AI * call — the AI baseline recompute (Phase 3) refines it further. * * Signals: * - Complete + "very dry" repeatedly → the plant dries faster than the * cadence → SHORTEN (negative delta). * - Complete + "really wet" → the cadence is too short → LENGTHEN. * - Postpone (damp/wet) → the plant holds water longer → LENGTHEN. * - Early complete (path 'early') → the user did it ahead of schedule → * the interval is fine or could lengthen slightly. * * The delta is bounded to ±7 days and only moves 1 day per event (slow, * safe convergence — never a sudden jump that could over/under-water). */ const computeFeedbackAdjustment = ( log: CareLogEntry[], action: 'water' | 'fertilize', current: number ): Partial> => { const entries = log.filter(e => e.action === action); if (entries.length === 0) return {}; const last = entries[entries.length - 1]; let delta = 0; if (action === 'water') { const state = last.stateAtComplete.toLowerCase(); if (last.path === 'complete') { if (state.includes('very dry') || state.includes('slightly dry')) delta = -1; // dries fast → shorten else if (state.includes('really wet') || state.includes('moist')) delta = +1; // holds water → lengthen } else if (last.path === 'postpone') { delta = +1; // held water → lengthen } else if (last.path === 'early') { delta = 0; // did it early, interval is fine } } else { // Fertilize: dormant/slow → lengthen (not ready); active/explosive → fine. const state = last.stateAtComplete.toLowerCase(); if (state.includes('dormant') || state.includes('slow')) delta = +1; else delta = 0; } const next = Math.max(-7, Math.min(7, current + delta)); return { [action]: next }; }; const resetCareAndNotificationDebugState = async () => { const seed = buildCareDebugSeedState(appCareTasks, appSettings.reminderPreferences.afterWorkReminderTime); setHomeCareTaskStates(seed.careTaskStates); setNotificationStates(seed.notificationStates); setNotificationCenterVisible(false); setNotificationSnoozeTarget(null); await Promise.all([ saveCareTaskActionStates(seed.careTaskStates), savePlantNotificationStates(seed.notificationStates), ]); }; const completeNotificationCareTask = (task: CareTaskItem, notification: PlantNotification) => { setAppCareTaskStatus(task, 'completed'); patchNotificationState(notification.id, { isRead: true, isDismissed: false, snoozedUntil: undefined }); }; const openNotificationSnooze = (task: CareTaskItem, notification: PlantNotification) => { patchNotificationState(notification.id, { isRead: true }); setNotificationSnoozeTarget({ task, notification }); }; const snoozeNotificationCareTask = (option: NotificationSnoozeOption) => { if (!notificationSnoozeTarget) return; const { notification } = notificationSnoozeTarget; setNotificationSnoozeTarget(null); patchNotificationState(notification.id, { isRead: true, remindedAt: new Date().toISOString(), snoozedUntil: getNotificationSnoozeDateTime(option, appSettings.reminderPreferences.afterWorkReminderTime), }); }; const remindNotificationLater = (notification: PlantNotification) => { patchNotificationState(notification.id, { isRead: true, remindedAt: new Date().toISOString(), snoozedUntil: getNotificationSnoozeDateTime({ key: 'after_work', label: 'Remind after work', actionLabel: 'Remind after work' }, appSettings.reminderPreferences.afterWorkReminderTime), }); }; const dismissNotification = (notification: PlantNotification) => { patchNotificationState(notification.id, { isRead: true, isDismissed: true }); }; const markNotificationRead = (notification: PlantNotification) => { if (notification.isRead) return; patchNotificationState(notification.id, { isRead: true }); }; const openRelatedNotification = (notification: PlantNotification, task?: CareTaskItem) => { patchNotificationState(notification.id, { isRead: true }); setNotificationCenterVisible(false); setNotificationSnoozeTarget(null); const relatedPlantId = task?.plantId || notification.relatedPlantId || ''; if (relatedPlantId.startsWith(SAVED_PREFIX)) { viewSavedPlant(relatedPlantId.slice(SAVED_PREFIX.length)); return; } if (notification.type === 'care_reminder' && task) { setFocusedCareTaskId(task.id); changeTab('care'); } }; const markAllNotificationsRead = () => { const now = new Date().toISOString(); const next = plantNotifications.reduce>((acc, notification) => { const previous = notificationStates[notification.id]; acc[notification.id] = { id: notification.id, isRead: true, isDismissed: previous?.isDismissed || false, remindedAt: previous?.remindedAt, snoozedUntil: previous?.snoozedUntil, updatedAt: now, }; return acc; }, { ...notificationStates }); persistNotificationStates(next); }; const savePlantProfile = (profile: SavedPlantProfile) => { // If there's a pending problem scan context, attach it to the new plant let enrichedProfile = profile; if (pendingProblemScanContext) { const now = new Date().toISOString(); const existingEvents = profile.events || profile.plantEvents || []; const newEvents = [...existingEvents]; // When we have an AI result, create one combined event (AI finding already contains the photo and analysis) // When there's no AI result, create a standalone observation event if (pendingProblemScanContext.aiResult) { const aiEvent: PlantEvent = { id: createGardenId('event'), plantId: savedGardenKey(profile.id), createdAt: now, eventDate: now, type: 'ai_finding', eventCategory: 'ai_finding', title: pendingProblemScanContext.aiResult.recommendedMemoryTitle, note: pendingProblemScanContext.aiResult.recommendedMemoryNotes || pendingProblemScanContext.userNote || undefined, photoUri: pendingProblemScanContext.imageUri || undefined, source: 'ai', updatedAt: now, visibility: 'private', trustRelevant: true, resolutionStatus: 'active', }; newEvents.unshift(aiEvent); } else { const observationTitle = pendingProblemScanContext.symptoms.length > 0 ? `Observed ${pendingProblemScanContext.symptoms.map(s => symptomLabelMap[s] || s).join(', ')}` : 'Problem scan observation'; const observationEvent: PlantEvent = { id: createGardenId('event'), plantId: savedGardenKey(profile.id), createdAt: now, eventDate: now, type: 'pest_observation', eventCategory: 'observation', title: observationTitle, note: pendingProblemScanContext.userNote || undefined, photoUri: pendingProblemScanContext.imageUri || undefined, source: 'user', updatedAt: now, visibility: 'private', trustRelevant: false, resolutionStatus: 'active', }; newEvents.unshift(observationEvent); } const sortedEvents = newEvents.sort( (a, b) => new Date(b.eventDate || b.createdAt).getTime() - new Date(a.eventDate || a.createdAt).getTime() ); enrichedProfile = { ...profile, events: sortedEvents, plantEvents: sortedEvents, updatedAt: now, } as SavedPlantProfile; // Link investigation to the newly created plant if (pendingProblemScanContext.investigationId) { linkPlantToInvestigation(pendingProblemScanContext.investigationId, profile.id).catch(() => {}); } // Clear the pending context setPendingProblemScanContext(null); } setSavedPlants(current => [enrichedProfile, ...current]); }; const saveWishlistItem = (item: WishlistItem) => { setWishlistItems(current => [item, ...current.filter(existing => existing.id !== item.id)]); }; const removeWishlistItem = (itemId: string) => { setWishlistItems(current => current.filter(item => item.id !== itemId)); }; // ── Field Diary (Bekky, 2026-08-25) ───────────────────────────────────── // One album per walk at a broad place. Adding an entry appends it to the // album for that placeLabel (creating it if needed). Each entry keeps its own // per-scan photos + precision. const saveFieldDiaryEntry = (entry: FieldDiaryEntry, placeLabel: string) => { const label = placeLabel.trim() || 'Unsaved place'; // Album key = DATE + LOCATION (Bekky, 2026-09-02): two walks in the same // city on different days must NOT merge into one album. Key on the entry's // calendar day (YYYY-MM-DD) + the place label, so each walk-day gets its own // album. (Clickable-to-maps on the location is deferred to the next APK.) const day = (entry.createdAt || new Date().toISOString()).slice(0, 10); // YYYY-MM-DD const albumKey = `${day}__${label}`; setFieldAlbums(current => { const existing = current.find(album => album.albumKey === albumKey); if (existing) { return current.map(album => album.albumKey === albumKey ? { ...album, entries: [entry, ...album.entries], count: (album.count || 0) + 1 } : album ); } const album: FieldAlbum = { id: `field_album_${Date.now()}`, albumKey, placeLabel: label, createdAt: entry.createdAt || new Date().toISOString(), entries: [entry], count: 1, }; return [album, ...current]; }); }; const removeFieldDiaryEntry = (albumId: string, entryId: string) => { setFieldAlbums(current => current.map(album => { if (album.id !== albumId) return album; const entries = album.entries.filter(e => e.id !== entryId); if (entries.length === 0) return album; // album emptied; caller handles removal return { ...album, entries, count: entries.length }; }).filter(album => album.entries.length > 0)); }; const removeFieldAlbum = (albumId: string) => { setFieldAlbums(current => current.filter(album => album.id !== albumId)); }; // Add a completed excursion album to the field journal (Bekky, 2026-09-03). // The album already carries route/distance/duration/excursionId. Prepend it // so the newest trip shows first. const saveFieldDiaryEntryAlbum = (album: FieldAlbum) => { setFieldAlbums(current => [album, ...current]); }; const savePlantFromWishlist = (item: WishlistItem) => { const commonName = cleanPlantDisplayName(item.commonName || 'Wishlist plant'); const scientificName = item.scientificName ? cleanScientificName(item.scientificName) : commonName; const now = new Date().toISOString(); const profile: SavedPlantProfile = { id: `wishlist_plant_${Date.now()}`, photoUri: item.imageUri, image: item.imageUri, commonName, scientificName, confidence: item.confidence, alternatives: [], scanResultId: item.scanResultId, plantType: item.plantType || 'unsure', locationContext: 'unsure', notes: item.notes, careSummary: 'No care plan generated from this wishlist item yet. Add care details in Garden when ready.', toxicityWarning: 'Safety and toxicity were not checked by this plant identification scan.', createdAt: now, updatedAt: now, source: 'plant_scan', // Carry the wishlist's rich scan data so the added garden plant is already // enriched (Bekky, 2026-08-25) — no re-scan needed. identification: item.scientificName || item.commonNames?.length || item.taxonomy || item.description || item.sourceUrl || item.referenceUrl ? { commonName: item.commonName, scientificName: item.scientificName, confidence: item.confidence, suggestions: (item.commonNames || []).slice(0, 4).map(name => ({ name })), description: item.description, sourceUrl: item.sourceUrl, referenceUrl: item.referenceUrl, referenceTitle: item.referenceTitle, referenceSource: item.referenceSource, } : undefined, enrichmentData: undefined, propagationInfo: item.propagation ? { canPropagate: item.propagation.canPropagate, methods: (item.propagation.methods || []).map(m => ({ method: (m.method as import('./types/propagation').RootingMethod) || 'water', difficulty: (m.difficulty as import('./types/propagation').PropagationDifficulty) || 'moderate', instructions: m.instructions || [], timeframe: m.timeframe || '', successRate: (m.successRate as import('./types/propagation').SuccessRate) || 'medium', risks: m.risks || '', warning: m.warning ?? null, })), notRecommended: (item.propagation.notRecommended || []).map(nr => ({ method: (nr.method as import('./types/propagation').RootingMethod) || 'water', reason: nr.reason || '', })), generalNote: item.propagation.generalNote, } : undefined, }; savePlantProfile(profile); removeWishlistItem(item.id); viewSavedPlant(profile.id); }; const updatePlantProfile = (plantId: string, patch: Partial) => { setSavedPlants(current => current.map(plant => { if (plant.id !== plantId) return plant; const nextPlant = updateSavedPlantProfile(plant, patch); return nextPlant; })); }; const deletePlantProfile = async (plantId: string) => { // Remove from saved plants setSavedPlants(current => current.filter(plant => plant.id !== plantId)); setSelectedSavedPlantId(current => current === plantId ? null : current); // Clean up investigations linked to this plant const allInvestigations = await getAllInvestigations(); for (const inv of allInvestigations) { if (inv.plantId === plantId) { await deleteInvestigation(inv.investigationId); } } setInvestigations(current => current.filter(inv => inv.plantId !== plantId)); // Clean up intelligence profile await deleteIntelligenceProfile(plantId); // Clean up photo records and files await deletePhotosForPlant(plantId); }; const viewSavedPlant = (plantId: string) => { setSelectedSavedPlantId(plantId); setSelectedSpaceDetailId(null); changeTab('profile', { keepSavedSelection: true }); }; const viewSpaceDetail = (spaceId: string) => { setSelectedSavedPlantId(null); setSelectedSpaceDetailId(spaceId); changeTab('profile'); }; const screens: Record = { home: { setFocusedCareTaskId(taskId); changeTab('care'); }} gardenSummary={homeGardenSummary} careTasks={appCareTasks} careTaskStates={homeCareTaskStates} />, care: ( { const invId = task.metadata?.relatedDiagnosisId; if (invId) { setPendingCheckInInvId(invId); changeTab('profile'); } }} onTaskRemind={(task) => { // Snooze by 1 day via the existing machinery. const next = { ...homeCareTaskStates, [task.id]: createCareTaskActionState(task, 'snoozed', { snoozeOffsetDays: 1 }) }; setHomeCareTaskStates(next); saveCareTaskActionStates(next).catch(err => { if (typeof __DEV__ !== 'undefined' && __DEV__) console.warn('[Pixie] saveCareTaskActionStates (snooze) failed', err); }); }} onTaskViewCase={(task) => { // Two distinct routes (Bekky, 2026-08-13): // - diagnosis tasks → "View Case" → open the investigation/case // - care tasks → "View Care" → open the plant detail + scroll to Care Guidance if (task.source === 'diagnosis') { const invId = task.metadata?.relatedDiagnosisId; if (invId) { setPendingCheckInInvId(invId); changeTab('profile'); } return; } // Care task: task.plantId is the savedGardenKey form ("saved:"); // the plant store uses the raw id, so strip the prefix before matching. const rawId = task.plantId?.startsWith(SAVED_PREFIX) ? task.plantId.slice(SAVED_PREFIX.length) : task.plantId; const plant = rawId ? savedPlants.find(p => p.id === rawId) : undefined; if (plant) { viewSavedPlant(plant.id); // After opening the plant detail, scroll to the Care Guidance section. setScrollToCareGuidancePlantId(plant.id); } }} onTaskPhotoUpdate={(task) => { // Photo-update loop: offer a quick photo check-in after completing a // care task. Only for saved plants (we need their context to build // the packet). Always skippable. // task.plantId is the savedGardenKey form ("saved:"); strip the // prefix before matching the plant store (mirrors onTaskViewCase). const rawId = task.plantId?.startsWith(SAVED_PREFIX) ? task.plantId.slice(SAVED_PREFIX.length) : task.plantId; const plant = rawId ? savedPlants.find(p => p.id === rawId) : undefined; if (plant) { // Pass a normalized task so CareMetricsModal's own plant lookup // (savedPlants.find(p => p.id === careMetricsTask.plantId)) matches. setCareMetricsTask(rawId === task.plantId ? task : { ...task, plantId: rawId }); } }} focusedTaskId={focusedCareTaskId} onFocusedTaskHandled={() => setFocusedCareTaskId(null)} checkInOpen={Boolean(careMetricsTask)} onTaskCheckInRequest={(task) => setCareCheckInTask(task)} onOpenCaseTreatment={(task) => { // CASE-TREATMENT OPEN (Bekky, 2026-09-02): a diagnosed Treat task opens // the case-treatment multi-select modal via the shared handler (also // used by the case-detail "Log what I did" button). openCaseTreatment({ task }); }} onOpenPlantDetail={(rawPlantId) => { // Members-modal tile → plant DETAIL page (Bekky, 2026-09-01), like the // garden tiles. rawPlantId is the raw plant id; viewSavedPlant expects it. const plant = savedPlants.find(p => p.id === rawPlantId); if (plant) viewSavedPlant(plant.id); }} externalFeedback={careFeedbackExternal} onExternalFeedbackClear={() => setCareFeedbackExternal(null)} spaceCohorts={spaceCohorts} cohortPlantMeta={cohortPlantMeta} onCompleteCohort={({ spaceId, action, plantIds }) => { // Complete every task of this action for the whole cohort in one tap // (Chunk A, Bekky 2026-08-29): the group is the unit of care. Completes // the due-today tasks of that action for all member plants directly. const type = action === 'water' ? 'Water' : 'Fertilize'; const tasks = appCareTasks.filter(t => t.type === type && (t.spaceId || 'no-space') === spaceId && plantIds.includes(t.plantId.startsWith(SAVED_PREFIX) ? t.plantId.slice(SAVED_PREFIX.length) : t.plantId) ); if (!tasks.length) return; const previousStates = Object.fromEntries(tasks.map(t => [t.id, homeCareTaskStates[t.id]])); const next = { ...homeCareTaskStates }; for (const task of tasks) next[task.id] = createCareTaskActionState(task, 'completed'); setHomeCareTaskStates(next); saveCareTaskActionStates(next).catch(() => undefined); // GROUP ANCHOR (Bekky, 2026-08-30): checking off the group moves every // member's lastDone anchor to today + a 'manual' sweep log entry — the // consultant's joint-watering anchor reset, user-triggered. Members // whose tasks were missing from the tasks list still get the anchor // (the plantIds ARE the cohort), so the group's rhythm stays unified. writeGroupAnchor({ plantIds, action }); setCareFeedbackExternal({ message: `${tasks.length} ${type.toLowerCase()} task${tasks.length === 1 ? '' : 's'} completed in this group.`, undo: () => { const restored = { ...homeCareTaskStates }; for (const task of tasks) { if (previousStates[task.id]) restored[task.id] = previousStates[task.id]; else delete restored[task.id]; } saveCareTaskActionStates(restored).catch(() => undefined); setHomeCareTaskStates(restored); // GROUP ANCHOR UNDO (Bekky, 2026-08-30): rolling back the group // check-off also rolls back the anchors + 'manual' log entries the // group write created (belt-and-braces; pre-anchor kept in ref). for (const task of tasks) revertCareLogCompletion(task); setCareFeedbackExternal(null); }, }); }} onSoilCheckComplete={({ spaceId, action, plantIds, stateAtComplete }) => { // SOIL-CHECK COMPLETE (Bekky, 2026-09-01, Chunk 2): the user checked // the soil in the soil-check modal and it's actually dry — complete the // SELECTED plants' tasks. Same as onCompleteCohort but for the subset // the user selected, and records the slider state in the care log. const type = action === 'water' ? 'Water' : 'Fertilize'; const tasks = appCareTasks.filter(t => t.type === type && (t.spaceId || 'no-space') === spaceId && plantIds.includes(t.plantId.startsWith(SAVED_PREFIX) ? t.plantId.slice(SAVED_PREFIX.length) : t.plantId) ); if (!tasks.length) return; const previousStates = Object.fromEntries(tasks.map(t => [t.id, homeCareTaskStates[t.id]])); const next = { ...homeCareTaskStates }; for (const task of tasks) next[task.id] = createCareTaskActionState(task, 'completed'); setHomeCareTaskStates(next); saveCareTaskActionStates(next).catch(() => undefined); // Record the care log entry with the real slider state (the feedback // loop learns the actual moisture/growth at completion). for (const task of tasks) { const dueKey = task.metadata?.dueDate; const isEarly = dueKey ? getCareDayOffset(dueKey) > 0 : false; recordCareLogEntry(task, stateAtComplete, isEarly ? 'early' : 'complete'); } writeGroupAnchor({ plantIds, action }); setCareFeedbackExternal({ message: `${tasks.length} ${type.toLowerCase()} task${tasks.length === 1 ? '' : 's'} completed after checking the soil.`, undo: () => { const restored = { ...homeCareTaskStates }; for (const task of tasks) { if (previousStates[task.id]) restored[task.id] = previousStates[task.id]; else delete restored[task.id]; } saveCareTaskActionStates(restored).catch(() => undefined); setHomeCareTaskStates(restored); for (const task of tasks) revertCareLogCompletion(task); setCareFeedbackExternal(null); }, }); }} onSoilCheckPostpone={({ spaceId, action, plantIds, stateAtComplete, postponeDays }) => { // SOIL-CHECK POSTPONE (Bekky, 2026-09-01, Chunk 2): the user checked // the soil and it's still wet — postpone the SELECTED plants' tasks. const type = action === 'water' ? 'Water' : 'Fertilize'; const tasks = appCareTasks.filter(t => t.type === type && (t.spaceId || 'no-space') === spaceId && plantIds.includes(t.plantId.startsWith(SAVED_PREFIX) ? t.plantId.slice(SAVED_PREFIX.length) : t.plantId) ); if (!tasks.length) return; const previousStates = Object.fromEntries(tasks.map(t => [t.id, homeCareTaskStates[t.id]])); const next = { ...homeCareTaskStates }; for (const task of tasks) { next[task.id] = createCareTaskActionState(task, 'snoozed', { snoozeOffsetDays: Math.max(1, postponeDays) }); recordCareLogEntry(task, stateAtComplete, 'postpone'); } setHomeCareTaskStates(next); saveCareTaskActionStates(next).catch(() => undefined); setCareFeedbackExternal({ message: `${tasks.length} ${type.toLowerCase()} task${tasks.length === 1 ? '' : 's'} postponed — the soil was still wet.`, undo: () => { const restored = { ...homeCareTaskStates }; for (const task of tasks) { if (previousStates[task.id]) restored[task.id] = previousStates[task.id]; else delete restored[task.id]; } saveCareTaskActionStates(restored).catch(() => undefined); setHomeCareTaskStates(restored); setCareFeedbackExternal(null); }, }); }} onCohortFeedback={({ spaceId, action, signal }) => { // Resolve a plant in this space+action cohort to apply the feedback // (recordCohortFeedback derives the cohort from the plant's cohortIds). const member = savedPlants.find( p => p.spaceId === spaceId && p.cohortIds?.[action] ); if (!member) return; recordCohortFeedback(member, action, signal); setCareFeedbackExternal({ message: signal === 'dry' ? `${action === 'water' ? 'Watering' : 'Fertilizing'} this group a little sooner — I've shortened the shared cadence.` : `${action === 'water' ? 'Watering' : 'Fertilizing'} this group a little later — I've lengthened the shared cadence.`, undo: () => {}, }); if (careFeedbackExternalTimer.current) clearTimeout(careFeedbackExternalTimer.current); careFeedbackExternalTimer.current = setTimeout(() => setCareFeedbackExternal(null), 4200); }} HeaderComponent={Header} ScreenComponent={Screen} /> ), community: , profile: ( changeTab('identify')} onSavePlant={savePlantProfile} onUpdatePlant={updatePlantProfile} onSpaceWaterAll={({ spaceId, plantIds, action, date }) => writeGroupAnchor({ plantIds, action, date })} onDeletePlant={deletePlantProfile} onSavePlantFromWishlist={savePlantFromWishlist} onRemoveWishlistItem={removeWishlistItem} savedPlants={savedPlants} wishlistItems={wishlistItems} fieldAlbums={fieldAlbums} onSaveFieldDiaryEntry={saveFieldDiaryEntry} onRemoveFieldDiaryEntry={removeFieldDiaryEntry} onRemoveFieldAlbum={removeFieldAlbum} onSaveFieldDiaryEntryAlbum={saveFieldDiaryEntryAlbum} selectedSavedPlantId={selectedSavedPlantId} selectedSpaceDetailId={selectedSpaceDetailId} gardenResetToken={gardenResetToken} approximateLocationContext={appSettings.privacy.approximateLocationContext} carePreferences={appSettings.carePreferences} weatherSnapshot={weatherSnapshot} region={lightRegionFromGardenLocation(appSettings.privacy.gardenLocation)} onSpaceDetailOpened={() => setSelectedSpaceDetailId(null)} investigations={investigations} setInvestigations={setInvestigations} openInvestigationId={pendingCheckInInvId} onOpenInvestigationHandled={() => setPendingCheckInInvId(null)} onOpenCaseTreatment={(inv) => openCaseTreatment({ inv }, true)} scrollToCareGuidancePlantId={scrollToCareGuidancePlantId} onScrollToCareGuidanceHandled={() => setScrollToCareGuidancePlantId(null)} gardenView={appSettings.gardenView} onGardenViewChange={(view) => persistAppSettings({ ...appSettings, gardenView: view })} HeaderComponent={Header} ScreenComponent={Screen} /> ), identify: ( ), }; return ( setNotificationCenterVisible(true) }}> setSettingsVisible(true) }}> {!isIdentify ? : null} {screens[tab]} setNotificationCenterVisible(false)} onMarkRead={markNotificationRead} onMarkAllRead={markAllNotificationsRead} onComplete={completeNotificationCareTask} onSnooze={openNotificationSnooze} onRemindLater={remindNotificationLater} onOpenRelated={openRelatedNotification} onDismiss={dismissNotification} /> setNotificationSnoozeTarget(null)} /> setSettingsVisible(false)} careRefreshing={careRefreshing} onGardenLocationSet={() => { // Refresh the garden's care guidance for all plants with a care // schedule, so the new location feeds the AI (Bekky, 2026-08-23). // Fire-and-forget; the in-flight dedup guard prevents crosstalk. for (const plant of savedPlants) { if (plant.careSchedule) refreshCareGuidanceInBackground(plant.id); } }} // onOpenPhotoIntelDebug={() => { setSettingsVisible(false); setShowPhotoIntelDebug(true); }} // COMMENTED OUT (2026-08-15) /> {/* WEATHER ALERT (Bekky, 2026-09-02, Chunk 2b): opens on app open for an unseen alert, and re-opens when the user taps an alert in the bell's Weather section or the Settings Weather section. The alert itself IS the info — it does NOT open plant detail (no action there). Title = urgent warning; message = protection instruction. */} w.id === weatherAlertOpen.id)?.advice : null) || (weatherAlertOpen ? weatherAlertOpen.message : '')} buttons={[{ text: 'Got it', onPress: () => setWeatherAlertOpen(null) }]} onRequestClose={() => setWeatherAlertOpen(null)} layout="column" > {weatherAlertOpen && weatherAlertOpen.spaceName ? ( {weatherAlertOpen.spaceName} ) : null} {weatherAlertOpen && weatherAlertOpen.plants && weatherAlertOpen.plants.length > 0 ? ( {weatherAlertOpen.plants.map((name, i) => ( • {name} ))} ) : null} {weatherAlertOpen ? ( { // Close the weather-alert modal, then open the tips modal // (avoid two stacked modals). setWeatherAlertOpen(null); fetchAlertTips(weatherAlertOpen); })} disabled={tipsLoadingId === weatherAlertOpen.id} accessibilityRole="button" accessibilityLabel={weatherAlertOpen.tips ? 'See tips on protecting these plants' : 'Get tips on protecting these plants'} > {tipsLoadingId === weatherAlertOpen.id ? 'Pixie’s thinking… 🌱' : (weatherAlertOpen.tips ? 'See tips 🌱' : 'Want tips on protecting them? 🌱')} ) : null} {/* TIPS POP-UP (polish, 2026-09-05): the rich "how to protect" advice appears in its OWN modal so the weather alert / cards stay compact. Opens right after the lazy fetch completes (or instantly when already fetched via "See tips 🌱"). Dismiss → button stays as "See tips 🌱" until the event passes or it escalates. */} setTipsModalId(null) }]} onRequestClose={() => setTipsModalId(null)} layout="column" /> {/* PhotoIntel debug screen — COMMENTED OUT (2026-08-15). Not in use; rewire before re-enabling. {showPhotoIntelDebug && ( setShowPhotoIntelDebug(false)} /> )} */} {careMetricsTask ? (() => { const plant = savedPlants.find(p => p.id === careMetricsTask.plantId); if (!plant) return null; const room = plant.spaceId ? homeGardenRooms.find(r => r.id === plant.spaceId) : undefined; const setup = plantSetups.find(s => s.plantId === savedGardenKey(plant.id)); const packet = buildCareContextPacket({ plant, room, setup, weather: weatherSnapshot ?? undefined }); return ( setCareMetricsTask(null)} onApplied={(update) => { if (update.careSchedule) { updatePlantProfile(plant.id, { careSchedule: update.careSchedule }); } }} /> ); })() : null} setCareCheckInTask(null)} onFertilizerRecorded={(fertilizer) => { // Persist what the user actually fed with (Bekky, 2026-09-02): // canonical value lives on the plant's setup; the complete-modal is // one of three surfaces that can set it. if (!careCheckInTask) return; const task = careCheckInTask; setPlantSetups(current => { const idx = current.findIndex(s => s.plantId === task.plantId); const fert = (fertilizer || '').trim(); if (idx >= 0) { const existing = current[idx]; const next = [...current]; next[idx] = updatePlantSetupProfile(existing, { fertilizer: fert || undefined }); return next; } return [createPlantSetupProfile({ plantId: task.plantId, fertilizer: fert || undefined, dedicatedArtificialLightTypes: [] }), ...current]; }); }} onSkip={() => { if (!careCheckInTask) return; const task = careCheckInTask; const previousState = homeCareTaskStates[task.id]; setAppCareTaskStatus(task, 'skipped'); setCareCheckInTask(null); setCareFeedbackExternal({ message: `${task.type} ${task.plantName} skipped.`, undo: () => restoreAppCareTaskState(task, previousState), }); if (careFeedbackExternalTimer.current) clearTimeout(careFeedbackExternalTimer.current); careFeedbackExternalTimer.current = setTimeout(() => setCareFeedbackExternal(null), 4200); }} onComplete={(stateAtComplete) => { if (!careCheckInTask) return; const task = careCheckInTask; // Complete the task as it would have been done. const previousState = homeCareTaskStates[task.id]; setCareTaskStatusForComplete(task); setCareCheckInTask(null); // Record the care log entry (Bekky, 2026-08-21): the feedback loop // learns the real interval + the moisture/growth state at completion. // If the task was completed BEFORE its due date, mark it 'early' so // the feedback loop knows the interval was fine (not too long). const dueKey = task.metadata?.dueDate; const isEarly = dueKey ? getCareDayOffset(dueKey) > 0 : false; recordCareLogEntry(task, stateAtComplete, isEarly ? 'early' : 'complete'); // Undo banner (Bekky, 2026-08-20): show it at the top of the Care // page after completing via the modal, mirroring the in-place undo. setCareFeedbackExternal({ message: `${task.type} ${task.plantName} completed.`, undo: () => { restoreAppCareTaskState(task, previousState); // LASTDONE UNIFICATION (Bekky, 2026-08-30): undo also rolls // back the anchor + log entry this completion wrote. revertCareLogCompletion(task); }, }); if (careFeedbackExternalTimer.current) clearTimeout(careFeedbackExternalTimer.current); careFeedbackExternalTimer.current = setTimeout(() => setCareFeedbackExternal(null), 4200); }} onPostpone={(stateAtComplete, postponeDays) => { if (!careCheckInTask) return; const task = careCheckInTask; const previousState = homeCareTaskStates[task.id]; postponeCareTask(task, stateAtComplete, postponeDays); setCareCheckInTask(null); // Cohesion (Bekky, 2026-08-20): postponing should feel the same as // completing — show the undo banner at the top so the user can // undo the deferral (restores the task + its cadence override). setCareFeedbackExternal({ message: `${task.type} ${task.plantName} postponed — I'll check back in ${postponeDays} day${postponeDays === 1 ? '' : 's'}.`, undo: () => restoreAppCareTaskState(task, previousState), }); if (careFeedbackExternalTimer.current) clearTimeout(careFeedbackExternalTimer.current); careFeedbackExternalTimer.current = setTimeout(() => setCareFeedbackExternal(null), 4200); }} /> setCaseTreatmentCase(null)} onComplete={handleCaseStepAction} onSkip={handleCaseStepAction} /> ); } export default function App() { Font.useFonts({ 'Fredoka': require('./assets/fonts/Fredoka.ttf'), 'Fredoka-SemiBold': require('./assets/fonts/Fredoka-SemiBold.ttf'), 'Fredoka-Bold': require('./assets/fonts/Fredoka-Bold.ttf'), 'Pacifico': require('./assets/fonts/Pacifico.ttf'), 'Caveat': require('./assets/fonts/Caveat.ttf'), 'Alegreya': require('./assets/fonts/Alegreya.ttf'), }); return ( ); } const styles = StyleSheet.create({ app: { flex: 1, backgroundColor: parchment, position: 'relative' }, weatherAlertPlantGrid: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8, alignSelf: 'stretch' }, weatherAlertPlantItem: { color: '#5D5A4E', fontSize: 14, lineHeight: 22, width: '50%', paddingRight: 8 }, weatherAlertSpace: { color: '#B85C4A', fontSize: 14, fontWeight: '700', marginTop: 8 }, weatherAlertTipsButton: { marginTop: 10, borderRadius: 999, borderWidth: 1, borderColor: '#B85C4A', backgroundColor: 'rgba(255,255,255,0.6)', paddingHorizontal: 14, paddingVertical: 8, alignSelf: 'flex-start' }, weatherAlertTipsButtonText: { color: '#B85C4A', fontSize: 14, fontWeight: '700' }, identifyApp: { backgroundColor: '#102A10' }, screenFadeLayer: { flex: 1, zIndex: 1, elevation: 1, backgroundColor: 'transparent' }, safe: { flex: 1, backgroundColor: 'transparent' }, content: { paddingHorizontal: 18, paddingTop: 8, paddingBottom: 160 }, contentStatic: { flex: 1, paddingHorizontal: 18, paddingTop: 8 }, botanicalOverlay: { ...StyleSheet.absoluteFillObject, width: '100%', height: '100%' }, homeHeader: { height: 118, alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'visible' }, homeHeaderLogo: { width: '100%', height: 96 }, header: { height: 84, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', overflow: 'visible', zIndex: 20, elevation: 20, position: 'relative' }, headerActionBar: { position: 'absolute', left: 0, right: 0, top: headerActionTop, height: 72, zIndex: 80, elevation: 80 }, headerSettingsAction: { position: 'absolute', left: headerActionOffsetX, top: 0, zIndex: 80, elevation: 80 }, headerNotificationAction: { position: 'absolute', right: headerNotificationOffsetX, top: 0, zIndex: 80, elevation: 80 }, headerSide: { width: 72, alignItems: 'flex-start', justifyContent: 'center', zIndex: 2, elevation: 2 }, headerBackSide: { width: 112 }, headerTitleWrap: { alignItems: 'center', justifyContent: 'center', gap: 2, flex: 1, zIndex: 1, elevation: 1 }, headerLogo: { width: 244, height: 82, transform: [{ translateX: 9 }] }, headerTitle: { maxWidth: '100%', fontSize: 26, lineHeight: 30, fontWeight: '800', color: dark, textAlign: 'center', flexShrink: 1, minWidth: 0 }, decoTitleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingTop: 6, flexWrap: 'nowrap', gap: 0 }, decoTitle: { fontSize: 30, lineHeight: 44, textAlign: 'center', flexShrink: 0 }, headerSubtitle: { maxWidth: '100%', fontSize: 11, lineHeight: 14, fontWeight: '900', color: '#7A704D', textAlign: 'center', flexShrink: 1 }, headerRight: { width: 72, alignItems: 'flex-end', justifyContent: 'center', zIndex: 40, elevation: 40 }, backToGardenButton: { minHeight: 42, flexDirection: 'row', alignItems: 'center', gap: 4, paddingLeft: 8, paddingRight: 6, transform: [{ translateY: detailBackButtonTranslateY }] }, kitDetailBackButton: { transform: [{ translateY: 0 }] }, backToGardenText: { color: dark, fontSize: 11, lineHeight: 14, fontWeight: '900', flex: 1, minWidth: 0 }, notificationBellButton: { width: 72, height: 72, alignItems: 'center', justifyContent: 'center', zIndex: 60, elevation: 60 }, notificationBell: { width: 31, height: 42 }, notificationBadge: { position: 'absolute', right: 2, top: 7, minWidth: 18, height: 18, borderRadius: 9, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 4, borderWidth: 1, borderColor: '#FFFDF7' }, notificationText: { color: '#fff', fontSize: 10, fontWeight: '900' }, settingsButton: { width: 72, height: 72, alignItems: 'center', justifyContent: 'center', backgroundColor: 'transparent' }, settingsButtonIcon: { width: 46, height: 46 }, notificationOverlay: { flex: 1, justifyContent: 'center', paddingHorizontal: 14, zIndex: 200, elevation: 200 }, notificationCenterPanel: { width: '100%', maxHeight: '86%', borderRadius: 24, borderWidth: 1, borderColor: 'rgba(228,220,196,0.95)', backgroundColor: '#FFFDF7', padding: 14, shadowColor: '#4A3A1C', shadowOpacity: 0.16, shadowRadius: 18, shadowOffset: { width: 0, height: 8 }, elevation: 220, zIndex: 220 }, notificationCenterHeader: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10, marginBottom: 8 }, notificationCenterTitleWrap: { flex: 1, minWidth: 0 }, notificationCenterTitle: { color: dark, fontSize: 20, lineHeight: 25, fontWeight: '900' }, notificationCenterSubtitle: { color: '#6A654F', fontSize: 12, lineHeight: 17, fontWeight: '700', marginTop: 1 }, notificationPreferenceNote: { alignSelf: 'flex-start', borderRadius: 14, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#F3F7E6', paddingVertical: 5, paddingHorizontal: 9, marginBottom: 8 }, notificationPreferenceText: { color: '#4D702C', fontSize: 11, lineHeight: 15, fontWeight: '900' }, notificationSection: { borderTopWidth: 1, borderColor: 'rgba(228,220,196,0.82)', paddingTop: 9, marginTop: 7 }, notificationSectionTitle: { color: dark, fontSize: 14, lineHeight: 18, fontWeight: '900', marginBottom: 5 }, notificationRow: { borderRadius: 16, borderWidth: 1, borderColor: 'rgba(228,220,196,0.88)', backgroundColor: '#FFF9EE', paddingVertical: 9, paddingHorizontal: 10, marginBottom: 7 }, notificationRowUnread: { borderColor: '#D6E7BE', backgroundColor: '#FBF8E9' }, notificationRowHeader: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }, notificationTitleWrap: { flex: 1, minWidth: 0, flexDirection: 'row', alignItems: 'center', gap: 6 }, notificationHeaderActions: { flexDirection: 'row', gap: 5, flexShrink: 0 }, notificationUnreadDot: { width: 7, height: 7, borderRadius: 4, backgroundColor: green, flexShrink: 0 }, notificationTitleColumn: { flex: 1, minWidth: 0 }, notificationCategoryText: { color: '#7A704D', fontSize: 9, lineHeight: 12, fontWeight: '900', textTransform: 'uppercase', marginBottom: 1 }, notificationTitle: { flex: 1, color: dark, fontSize: 13, lineHeight: 17, fontWeight: '900' }, notificationMessage: { color: '#5E5845', fontSize: 12, lineHeight: 17, fontWeight: '700', marginTop: 3 }, notificationReason: { color: '#7A704D', fontSize: 10, lineHeight: 14, fontWeight: '800', marginTop: 3 }, notificationMeta: { color: '#7A704D', fontSize: 10, lineHeight: 14, fontWeight: '800', marginTop: 4 }, notificationDismissButton: { minHeight: 24, borderRadius: 12, borderWidth: 1, borderColor: 'rgba(214,231,190,0.95)', backgroundColor: '#FFFDF7', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 8 }, notificationDismissText: { color: '#6A654F', fontSize: 10, fontWeight: '900' }, notificationActionRow: { flexDirection: 'row', gap: 6, marginTop: 8 }, notificationActionButton: { flex: 1, minHeight: 31, borderRadius: 16, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 7 }, notificationActionButtonQuiet: { flex: 1, minHeight: 31, borderRadius: 16, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#FFFDF7', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 7 }, notificationActionText: { color: '#fff', fontSize: 10, fontWeight: '900' }, notificationActionTextQuiet: { color: dark, fontSize: 10, fontWeight: '900' }, notificationEmpty: { color: '#6A654F', fontSize: 12, lineHeight: 16, fontWeight: '700', paddingVertical: 3, marginBottom: 3 }, notificationMarkReadButton: { alignSelf: 'center', minHeight: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 14, marginTop: 8 }, notificationMarkReadText: { color: '#7A704D', fontSize: 11, fontWeight: '900' }, notificationSnoozeHint: { color: '#7A704D', fontSize: 10, lineHeight: 14, fontWeight: '800', marginTop: 2 }, settingsOverlay: { flex: 1, justifyContent: 'center', paddingHorizontal: 14, zIndex: 230, elevation: 230 }, settingsPanel: { width: '100%', borderRadius: 24, borderWidth: 1, borderColor: 'rgba(228,220,196,0.95)', backgroundColor: '#FFFDF7', padding: 14, shadowColor: '#4A3A1C', shadowOpacity: 0.16, shadowRadius: 18, shadowOffset: { width: 0, height: 8 }, elevation: 240, zIndex: 240 }, settingsHeader: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10, marginBottom: 9, flexShrink: 0 }, settingsHeaderCopy: { flex: 1, minWidth: 0 }, settingsTitle: { color: dark, fontSize: 21, lineHeight: 26, fontWeight: '900' }, settingsSubtitle: { color: '#6A654F', fontSize: 12, lineHeight: 17, fontWeight: '700', marginTop: 1 }, settingsScrollView: { flexGrow: 0, flexShrink: 1, width: '100%' }, settingsScrollContent: { paddingBottom: 64 }, settingsSection: { borderTopWidth: 1, borderColor: 'rgba(228,220,196,0.82)', paddingTop: 10, paddingBottom: 10 }, settingsSectionTitle: { color: green, fontSize: 15, lineHeight: 19, fontWeight: '900', marginBottom: 4 }, settingsSectionNote: { color: '#6A654F', fontSize: 12, lineHeight: 17, fontWeight: '700', marginBottom: 9 }, settingsFieldLabel: { color: dark, fontSize: 12, lineHeight: 16, fontWeight: '900', marginTop: 7, marginBottom: 6 }, settingsInput: { minHeight: 44, borderRadius: 16, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 12, color: dark, fontSize: 13, fontWeight: '800', marginBottom: 5 }, settingsValueButton: { minHeight: 48, borderRadius: 18, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 13, paddingVertical: 9, marginBottom: 5, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 10 }, settingsValueText: { flex: 1, minWidth: 0, color: dark, fontSize: 15, lineHeight: 20, fontWeight: '900' }, settingsValuePlaceholder: { color: '#9A9073' }, settingsValueHint: { color: '#4D702C', fontSize: 11, lineHeight: 14, fontWeight: '900' }, settingsClearButton: { alignSelf: 'flex-start', minHeight: 30, borderRadius: 15, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#FFFDF7', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 10, marginTop: 2, marginBottom: 4 }, settingsClearText: { color: '#6A654F', fontSize: 11, fontWeight: '900' }, settingsHelper: { color: '#7A704D', fontSize: 11, lineHeight: 16, fontWeight: '700', marginTop: 3, marginBottom: 5 }, settingsChipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 7, marginBottom: 4 }, settingsChip: { maxWidth: '100%', minHeight: 34, borderRadius: 17, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 10, paddingVertical: 7 }, settingsChipActive: { backgroundColor: green, borderColor: green }, settingsChipDisabled: { backgroundColor: '#F4EFE2', borderColor: 'rgba(217,208,181,0.9)' }, settingsChipText: { color: dark, fontSize: 11, lineHeight: 14, fontWeight: '900' }, settingsChipTextActive: { color: '#fff' }, settingsChipTextDisabled: { color: '#7A704D' }, h1: { fontSize: 27, fontWeight: '800', color: dark, marginBottom: 3 }, body: { fontSize: 16, fontWeight: '500', color: '#2F302A', lineHeight: 22 }, card: { backgroundColor: 'rgba(255,253,247,0.96)', borderRadius: 24, borderWidth: 1, borderColor: 'rgba(217,208,181,0.9)', padding: 13, marginBottom: 11, shadowColor: '#000', shadowOpacity: 0.08, shadowRadius: 10, elevation: 3 }, hero: { minHeight: 185, overflow: 'visible', paddingBottom: 6 }, welcomeHero: { height: 185, overflow: 'visible' }, heroCopy: { width: '52%', zIndex: 2, paddingTop: 58 }, fairyMascot: { position: 'absolute', right: -13, top: -20, width: 238, height: 263, zIndex: 4, elevation: 4 }, sectionRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 7, gap: 8 }, sectionTitleWrap: { flexDirection: 'row', alignItems: 'center', gap: 7, flex: 1, minWidth: 0 }, sectionActions: { flexDirection: 'row', alignItems: 'center', gap: 8 }, section: { fontSize: 20, fontWeight: '800', color: dark }, pill: { backgroundColor: '#F4F0DB', borderRadius: 999, paddingVertical: 5, paddingHorizontal: 10, borderWidth: 1, borderColor: line }, pillActive: { backgroundColor: green }, pillText: { fontSize: 11, fontWeight: '800', color: dark }, pillTextActive: { color: '#fff' }, careSecondaryCard: { paddingVertical: 10 }, careTaskCard: { paddingVertical: 11, borderTopWidth: 1, borderColor: 'rgba(228,220,196,0.82)' }, careTaskCardExpanded: { backgroundColor: '#FFF9EE', borderRadius: 18, borderWidth: 1, borderColor: 'rgba(214,231,190,0.92)', paddingHorizontal: 10, marginTop: 7, marginBottom: 4 }, careTaskMainRow: { flexDirection: 'row', alignItems: 'center', gap: 10, minHeight: 66 }, careTaskIconBox: { width: 52, height: 52, borderRadius: 26, backgroundColor: '#F3F7E6', borderWidth: 1, borderColor: '#D6E7BE', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }, careTaskIconImage: { width: 68, height: 68 }, careTaskCopy: { flex: 1, minWidth: 0 }, careTaskStatusColumn: { width: 82, flexShrink: 0, alignItems: 'flex-end', gap: 5 }, careDetailsPill: { color: dark, fontSize: 10, lineHeight: 13, fontWeight: '900', borderRadius: 12, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#F3F7E6', paddingVertical: 3, paddingHorizontal: 7, overflow: 'hidden' }, careTaskHint: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '700', marginTop: 2 }, careExpandedPanel: { borderTopWidth: 1, borderColor: 'rgba(228,220,196,0.82)', paddingTop: 9, paddingBottom: 2 }, careDetailSections: { gap: 0 }, careActionButton: { flex: 1, minHeight: 32, borderRadius: 16, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 8 }, careActionButtonQuiet: { flex: 1, minHeight: 32, borderRadius: 16, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 8 }, careActionText: { color: '#fff', fontSize: 11, fontWeight: '900' }, careActionTextQuiet: { color: dark, fontSize: 11, fontWeight: '900' }, careFutureActionNote: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '800', marginTop: 8, paddingTop: 8, borderTopWidth: 1, borderColor: 'rgba(228,220,196,0.62)' }, careFeedbackBanner: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: '#F1F8E6', borderWidth: 1, borderColor: '#D6E7BE', borderRadius: 14, paddingVertical: 6, paddingHorizontal: 10, marginTop: 4, marginBottom: 3 }, careFeedbackText: { flex: 1, color: '#4D702C', fontSize: 12, lineHeight: 17, fontWeight: '800' }, careUndoButton: { minHeight: 28, borderRadius: 14, backgroundColor: '#FFFDF7', borderWidth: 1, borderColor: '#CFE3AE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 10 }, careUndoText: { color: '#4D702C', fontSize: 11, fontWeight: '900' }, careUpcomingGroup: { paddingTop: 5, borderTopWidth: 1, borderColor: 'rgba(228,220,196,0.82)' }, careGroupTitle: { color: dark, fontSize: 12, lineHeight: 16, fontWeight: '900', marginBottom: 1 }, careCompletedRowWrap: { borderTopWidth: 1, borderColor: 'rgba(228,220,196,0.82)' }, careCompletedRow: { flexDirection: 'row', alignItems: 'center', gap: 10, minHeight: 56, paddingVertical: 6 }, careCompactTitle: { color: '#233225', fontSize: 12, lineHeight: 15, fontWeight: '800' }, careCompactMeta: { color: '#7A704D', fontSize: 10, lineHeight: 13, fontWeight: '700' }, careCompactEmpty: { color: '#6A654F', fontSize: 12, lineHeight: 16, fontWeight: '600', paddingTop: 3, paddingBottom: 4 }, careDebugResetButton: { alignSelf: 'flex-start', minHeight: 30, borderRadius: 15, borderWidth: 1, borderColor: '#E7C778', backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 10, marginTop: 6 }, careDebugResetText: { color: '#8E5631', fontSize: 10, fontWeight: '900' }, careSheetOverlay: { flex: 1, justifyContent: 'flex-end' }, careSheetScrim: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(32,45,26,0.34)' }, careSheet: { margin: 14, marginBottom: 28, borderRadius: 22, borderWidth: 1, borderColor: 'rgba(228,220,196,0.95)', backgroundColor: '#FFFDF7', padding: 14 }, careSheetTitle: { color: dark, fontSize: 16, lineHeight: 21, fontWeight: '900', marginBottom: 3 }, careSheetSubtitle: { color: '#6A654F', fontSize: 12, lineHeight: 17, fontWeight: '700', marginBottom: 10 }, careSheetOption: { minHeight: 48, borderRadius: 999, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#F3F7E6', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16, marginBottom: 7 }, careSheetOptionText: { color: dark, fontSize: 15, fontWeight: '900', textAlign: 'center' }, careSheetCancel: { minHeight: 38, borderRadius: 999, borderWidth: 1, borderColor: '#BFD9B4', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12, marginTop: 2 }, careSheetCancelText: { color: '#7A704D', fontSize: 12, fontWeight: '900' }, careDetailCloseButton: { minHeight: 32, borderRadius: 999, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 11 }, careDetailCloseText: { color: dark, fontSize: 11, fontWeight: '900' }, careDetailStatus: { alignSelf: 'flex-start', color: '#4D702C', fontSize: 12, lineHeight: 17, fontWeight: '900', backgroundColor: '#F1F8E6', borderWidth: 1, borderColor: '#D6E7BE', borderRadius: 14, paddingVertical: 4, paddingHorizontal: 9, marginTop: 5 }, careGuidanceBlock: { borderTopWidth: 1, borderColor: 'rgba(228,220,196,0.82)', paddingTop: 10, marginTop: 8 }, careGuidanceTitle: { color: dark, fontSize: 14, lineHeight: 18, fontWeight: '900', marginBottom: 4 }, careGuidanceText: { color: '#4E4D3F', fontSize: 13, lineHeight: 19, fontWeight: '700' }, careDetailActionRow: { flexDirection: 'row', gap: 7, paddingTop: 10, marginTop: 10, borderTopWidth: 1, borderColor: 'rgba(228,220,196,0.82)' }, spaceDetailSecondaryAction: { flex: 0, marginTop: 4 }, assignPlantsPanel: { marginTop: 10, paddingTop: 9, borderTopWidth: 1, borderTopColor: 'rgba(228,220,196,0.82)' }, assignPlantRow: { flexDirection: 'row', alignItems: 'center', gap: 9, minHeight: 54, paddingVertical: 7, paddingHorizontal: 8, borderRadius: 18, borderWidth: 1, borderColor: 'rgba(228,220,196,0.82)', backgroundColor: '#FFFDF7', marginBottom: 7 }, assignPlantRowSelected: { borderColor: green, backgroundColor: '#EEF8DE' }, assignPlantText: { flex: 1, minWidth: 0 }, assignPlantState: { minWidth: 72, minHeight: 30, borderRadius: 15, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 8 }, assignPlantStateSelected: { backgroundColor: green, borderColor: green }, assignPlantStateText: { color: dark, fontSize: 11, fontWeight: '900' }, assignPlantStateTextSelected: { color: '#fff' }, plantRowImagePlaceholder: { borderRadius: 8, backgroundColor: '#F1F7E7', alignItems: 'center', justifyContent: 'center' }, plantRowImagePlaceholderText: { color: '#6B7E6E', fontSize: 9, lineHeight: 12, fontWeight: '900' }, searchIconImg: { width: 16, height: 16, flexShrink: 0 }, gardenSnapshotCard: { width: '100%', aspectRatio: 1600 / 533, borderRadius: 24, marginTop: 20, marginBottom: 12, overflow: 'hidden', justifyContent: 'center', backgroundColor: 'rgba(255,253,247,0.96)', shadowColor: '#000', shadowOpacity: 0.08, shadowRadius: 10, elevation: 6, zIndex: 6 }, gardenSnapshotFrame: { ...StyleSheet.absoluteFillObject, width: '100%', height: '100%' }, gardenSnapshotContent: { alignSelf: 'center', width: '61%', minHeight: 66, paddingHorizontal: 8, paddingVertical: 6, alignItems: 'center', justifyContent: 'center', gap: 7 }, gardenSnapshotTitle: { color: dark, fontSize: 15, lineHeight: 19, fontWeight: '900', textAlign: 'center', textShadowColor: 'rgba(255,255,248,0.98)', textShadowRadius: 4, textShadowOffset: { width: 0, height: 1 } }, gardenSnapshotStats: { width: '100%', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 5 }, gardenSnapshotStat: { flex: 1, minWidth: 0, alignItems: 'center', justifyContent: 'center' }, gardenSnapshotStatNumber: { color: dark, fontSize: 17, lineHeight: 21, fontWeight: '900', textAlign: 'center', textShadowColor: 'rgba(255,255,248,0.98)', textShadowRadius: 4, textShadowOffset: { width: 0, height: 1 } }, gardenSnapshotStatLabel: { color: '#4F6640', fontSize: 9, lineHeight: 12, fontWeight: '900', textAlign: 'center', textShadowColor: 'rgba(255,255,248,0.96)', textShadowRadius: 3 }, // Home "Today's Tasks" — a cropped notebook poking up (torn bottom edge). // The notebook image is the background; content sits on the cream paper. homeJournalCard: { width: '100%', marginLeft: -18, marginRight: 0, marginBottom: 12, borderRadius: 18, overflow: 'hidden', backgroundColor: 'transparent' }, homeJournalBg: { ...StyleSheet.absoluteFillObject, width: '100%', height: '100%' }, homeJournalContent: { ...StyleSheet.absoluteFillObject, paddingBottom: 24, zIndex: 2, elevation: 2 }, homeJournalScroll: { flex: 1 }, homeJournalScrollInner: { paddingBottom: 24 }, homeJournalTitle: { color: '#2E4B35', fontSize: 26, fontFamily: 'Caveat', lineHeight: 30, marginTop: 8, marginBottom: 12 }, homeJournalRow: { paddingVertical: 10, paddingHorizontal: 2, borderTopWidth: 1, borderColor: 'rgba(85,116,94,0.25)' }, homeJournalRowCopy: { flex: 1, minWidth: 0 }, homeJournalTitleRow: { flexDirection: 'row', alignItems: 'center' }, homeJournalRowEmoji: { width: 24, fontSize: 18, textAlign: 'center' }, homeJournalRowTitle: { color: '#2E4B35', fontSize: 21, fontFamily: 'Caveat', lineHeight: 25, flex: 1 }, homeJournalRowBrief: { color: '#5B7553', fontSize: 18, fontFamily: 'Caveat', lineHeight: 23, marginTop: 2 }, homeJournalRowMeta: { color: '#8A7B55', fontSize: 16, fontFamily: 'Caveat', lineHeight: 19, marginTop: 1, marginLeft: 24 }, homeJournalWeatherNote: { color: '#3E7D3A', fontSize: 15, fontFamily: 'Caveat', lineHeight: 19, marginTop: 1, marginLeft: 24, fontWeight: '600' }, homeJournalEmpty: { color: '#5B7553', fontSize: 22, fontFamily: 'Caveat', lineHeight: 28, paddingVertical: 10 }, small: { fontSize: 12, color: '#5D5A4E', lineHeight: 17 }, button: { minHeight: 48, borderRadius: 999, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16, marginTop: 8 }, buttonDisabled: { opacity: 0.55 }, buttonText: { color: '#fff', fontSize: 15, fontWeight: '900', textAlign: 'center' }, goal: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: '#FFE3DB' }, bold: { fontWeight: '900', color: dark }, checkRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12, borderTopWidth: 1, borderColor: line }, circle: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: '#C9C3A5' }, filter: { flexDirection: 'row', gap: 8, marginVertical: 8, flexWrap: 'wrap' }, postHead: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 10 }, avatar: { width: 42, height: 42, borderRadius: 21 }, postImage: { width: '100%', height: 170, borderRadius: 16, marginTop: 10 }, reactions: { fontWeight: '800', fontSize: 15, marginTop: 10, color: dark }, gardenSearch: { height: 44, borderRadius: 22, backgroundColor: 'rgba(255,253,247,0.96)', borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', flexDirection: 'row', alignItems: 'center', gap: 9, paddingHorizontal: 14, marginBottom: 8 }, gardenSearchText: { color: '#7A704D', fontSize: 13, fontWeight: '800' }, gardenSearchInput: { flex: 1, minWidth: 0, color: dark, fontSize: 13, fontWeight: '800', paddingVertical: 0 }, gardenSegmented: { flexDirection: 'row', gap: 8, marginBottom: 9 }, gardenSegment: { flex: 1, minHeight: 42, borderRadius: 21, borderWidth: 1, borderColor: line, backgroundColor: 'rgba(255,253,247,0.96)', alignItems: 'center', justifyContent: 'center' }, gardenSegmentActive: { backgroundColor: green, borderColor: green }, gardenSegmentText: { color: dark, fontSize: 13, fontWeight: '900' }, gardenSegmentTextActive: { color: '#fff' }, gardenDropdownRow: { flexDirection: 'row', gap: 8, marginBottom: 8 }, gardenDropdownButton: { flex: 1, minHeight: 39, borderRadius: 20, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 8 }, gardenDropdownButtonText: { color: dark, fontSize: 11, fontWeight: '900', textAlign: 'center' }, gardenDropdownMenu: { borderRadius: 18, borderWidth: 1, borderColor: line, backgroundColor: 'rgba(255,253,247,0.98)', overflow: 'hidden', marginBottom: 9 }, gardenDropdownOption: { minHeight: 38, justifyContent: 'center', paddingHorizontal: 13, borderBottomWidth: 1, borderBottomColor: 'rgba(191,217,180,0.55)' }, gardenDropdownOptionText: { color: dark, fontSize: 13, fontWeight: '800' }, gardenChoiceWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 7, marginBottom: 6 }, gardenChoiceChip: { borderRadius: 999, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 10, paddingVertical: 7 }, gardenChoiceChipActive: { backgroundColor: green, borderColor: green }, gardenChoiceText: { color: dark, fontSize: 12, fontWeight: '900' }, gardenChoiceTextActive: { color: '#fff' }, gardenInput: { minHeight: 42, borderRadius: 16, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 12, color: '#2F302A', fontSize: 13, fontWeight: '800', marginBottom: 8 }, gardenNotesInput: { minHeight: 70, paddingTop: 10, textAlignVertical: 'top' }, gardenFormPhoto: { width: '100%', height: 126, borderRadius: 16, backgroundColor: '#E8F5D3', marginBottom: 8 }, formLabel: { color: dark, fontSize: 13, fontWeight: '900', marginTop: 4, marginBottom: 6 }, formHelper: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '700', marginTop: -2, marginBottom: 7 }, formButtonRow: { flexDirection: 'row', gap: 12, marginTop: 7 }, plantPhotoControl: { marginTop: 6, marginBottom: 10 }, plantFormActions: { marginTop: 2 }, formSecondaryButton: { flex: 1, minHeight: 48, borderRadius: 999, borderWidth: 1, borderColor: '#6B8E6B', backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 }, formSecondaryButtonText: { color: dark, fontSize: 15, fontWeight: '900', textAlign: 'center' }, formSecondaryButtonDanger: { flex: 1, minHeight: 48, borderRadius: 999, borderWidth: 1, borderColor: '#F4C6B8', backgroundColor: '#FFE3DB', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 10 }, formSecondaryButtonDangerText: { color: '#8E3D2F', fontSize: 13, fontWeight: '900' }, setupTopCancelButton: { alignSelf: 'flex-start', flex: 0, minWidth: 112, marginBottom: 12 }, setupReadonlyContext: { borderRadius: 16, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', padding: 10, marginTop: 3, marginBottom: 8 }, setupLinkedKitEmpty: { borderRadius: 16, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#F3F7E6', padding: 10, marginBottom: 8 }, detailTargetHighlight: { borderRadius: 18, borderWidth: 1, borderColor: '#A9D27B', backgroundColor: '#F1F8E6', shadowColor: '#6BAF67', shadowOpacity: 0.16, shadowRadius: 8, elevation: 2 }, contextPhotoStackRow: { flexDirection: 'row', alignItems: 'center', gap: 10, borderRadius: 16, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#FFF9EE', padding: 9, marginBottom: 8 }, contextPhotoStack: { width: 74, height: 64, flexShrink: 0 }, contextPhotoStackLayer: { position: 'absolute', width: 58, height: 58, borderRadius: 14, backgroundColor: '#E8F5D3', borderWidth: 1, borderColor: '#CFE3AE' }, contextPhotoStackLayerBack: { left: 9, top: 0, transform: [{ rotate: '5deg' }] }, contextPhotoStackLayerMid: { left: 5, top: 3, transform: [{ rotate: '-4deg' }] }, contextPhotoStackImage: { position: 'absolute', left: 0, top: 6, width: 58, height: 58, borderRadius: 14, borderWidth: 1, borderColor: '#CFE3AE', backgroundColor: '#E8F5D3' }, contextPhotoStackCopy: { flex: 1, minWidth: 0 }, contextPhotoEmpty: { borderRadius: 16, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#F3F7E6', padding: 10, marginBottom: 8 }, contextPhotoItem: { flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 16, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF7', padding: 8, marginBottom: 8 }, contextPhotoThumb: { width: 48, height: 48, borderRadius: 12, backgroundColor: '#E8F5D3', flexShrink: 0 }, contextPhotoFields: { flex: 1, minWidth: 0 }, contextPhotoInput: { minHeight: 38, borderRadius: 14, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 10, color: '#2F302A', fontSize: 12, fontWeight: '800' }, contextPhotoRemove: { minHeight: 32, borderRadius: 16, borderWidth: 1, borderColor: '#F4C6B8', backgroundColor: '#FFE3DB', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 8 }, contextPhotoRemoveText: { color: '#8E3D2F', fontSize: 10, fontWeight: '900' }, contextPhotoAddButton: { flex: 0, marginTop: 2, marginBottom: 10 }, kitSafetyPanel: { borderRadius: 16, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', padding: 10, marginTop: 8, marginBottom: 8 }, setupReadonlyLabel: { color: green, fontSize: 11, lineHeight: 14, fontWeight: '900', textTransform: 'uppercase', marginBottom: 3 }, setupReadonlyText: { color: dark, fontSize: 13, lineHeight: 18, fontWeight: '900' }, setupReadonlyHint: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '700', marginTop: 3 }, spaceTypeGroupTitle: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '900', marginTop: 3, marginBottom: 5, textTransform: 'uppercase' }, gardenGrid: { width: '100%', gap: 12, paddingBottom: 12 }, gardenSpaceGroup: { width: '100%', marginBottom: 0 }, gardenSpaceGroupTitle: { color: green, fontSize: 14, lineHeight: 18, fontWeight: '900', marginBottom: 8, paddingHorizontal: 3 }, gardenPlantSpaceSubgroup: { width: '100%', marginBottom: 12 }, gardenPlantSpaceSubgroupTitle: { color: dark, fontSize: 13, lineHeight: 17, fontWeight: '900', marginBottom: 8, paddingHorizontal: 3 }, gardenSpaceGroupGrid: { width: '100%', gap: 12 }, gardenTile: { width: '100%', height: 104, borderRadius: 16, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF6', padding: 16, shadowColor: '#000', shadowOpacity: 0.08, shadowRadius: 9, shadowOffset: { width: 0, height: 3 }, elevation: 3 }, gardenTileDragSurface: { width: '100%', height: '100%', flexDirection: 'row', alignItems: 'center', gap: 16 }, gardenTilePlantFrame: { width: 64, height: 64, borderRadius: 12, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', flexShrink: 0, backgroundColor: '#E8F5D3' }, gardenTilePlant: { width: '100%', height: '100%' }, gardenTilePlantPlaceholder: { width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center', backgroundColor: '#F1F7E7' }, gardenTilePlantPlaceholderText: { color: '#6B7E6E', fontSize: 11, lineHeight: 14, fontWeight: '700' }, gardenTileCopy: { flex: 1, minWidth: 0, justifyContent: 'center' }, gardenPlantTileTitle: { maxWidth: '100%', color: '#1E5E3A', fontSize: 22, lineHeight: 27, fontWeight: '700', textAlign: 'left', flexShrink: 1 }, gardenPlantTileMeta: { color: '#6B7E6E', fontSize: 15, lineHeight: 20, fontWeight: '400', marginTop: 4, textAlign: 'left' }, gardenTileTitle: { maxWidth: '100%', color: dark, fontSize: 13, lineHeight: 17, fontWeight: '900', marginTop: 6, textAlign: 'center', flexShrink: 1 }, gardenTileMeta: { color: '#78694B', fontSize: 10, lineHeight: 13, fontWeight: '700', marginTop: 2, textAlign: 'center' }, wishlistList: { gap: 9, paddingBottom: 10 }, wishlistCard: { borderRadius: 22, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: 'rgba(255,253,247,0.96)', padding: 12, shadowColor: '#000', shadowOpacity: 0.07, shadowRadius: 8, elevation: 2 }, wishlistNotes: { color: '#5D5A4E', fontSize: 12, lineHeight: 17, fontWeight: '700', marginTop: 2, marginBottom: 9 }, wishlistActionRow: { flexDirection: 'row', gap: 8, marginTop: 4 }, wishlistPrimaryAction: { flex: 1, minHeight: 40, borderRadius: 20, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 10 }, wishlistPrimaryActionText: { color: '#fff', fontSize: 12, fontWeight: '900' }, wishlistQuietAction: { flex: 1, minHeight: 40, borderRadius: 20, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 10 }, wishlistQuietActionText: { color: dark, fontSize: 12, fontWeight: '900' }, gardenEntityRow: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 8 }, gardenEntityPhoto: { width: 68, height: 68, borderRadius: 18, backgroundColor: '#E8F5D3' }, spaceArtworkThumb: { backgroundColor: '#E8F5D3', overflow: 'hidden' }, gardenEntityIcon: { width: 68, height: 68, borderRadius: 18, backgroundColor: '#E8F5D3', alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: line }, gardenEntityIconText: { color: dark, fontSize: 28, fontWeight: '900' }, gardenEntityCopy: { flex: 1, minWidth: 0 }, gardenEntityTitle: { marginTop: 0, textAlign: 'left' }, spaceTagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 7 }, spaceEnvTag: { color: '#4E6A3E', backgroundColor: '#EAF3D6', borderRadius: 12, borderWidth: 1, borderColor: '#C9DFB4', paddingHorizontal: 8, paddingVertical: 4, fontSize: 10, lineHeight: 12, fontWeight: '900', overflow: 'hidden' }, spaceSummaryRow: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginTop: 2 }, spaceDetailSummaryHeader: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 }, spaceStatusPill: { borderRadius: 12, borderWidth: 1, paddingHorizontal: 9, paddingVertical: 5, fontSize: 10, lineHeight: 12, fontWeight: '900', overflow: 'hidden' }, spaceStatus_good: { color: '#2F6D3A', backgroundColor: '#E3F4D6', borderColor: '#BCDFA9' }, spaceStatus_needs: { color: '#8E5631', backgroundColor: '#FFF1CF', borderColor: '#E7C778' }, spaceStatus_empty: { color: '#75684D', backgroundColor: '#F4EFE2', borderColor: '#DED3B9' }, addPlantTile: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', gap: 10 }, addPlantTileIcon: { width: 40, height: 40, borderRadius: 20, backgroundColor: '#68AF67', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }, addPlantPlusHorizontal: { position: 'absolute', width: 20, height: 2.5, borderRadius: 1.25, backgroundColor: '#fff' }, addPlantPlusVertical: { position: 'absolute', width: 2.5, height: 20, borderRadius: 1.25, backgroundColor: '#fff' }, plantDetailHeroFrame: { width: '100%', height: 184, borderRadius: 24, borderWidth: 1, borderColor: 'rgba(191,217,180,0.58)', backgroundColor: '#EDF7DD', alignItems: 'center', justifyContent: 'center', paddingVertical: 0, marginBottom: 10, overflow: 'visible' }, plantDetailHero: { width: 156, height: 156, borderRadius: 22, alignSelf: 'center', backgroundColor: '#E8F5D3' }, plantDetailHeroPlaceholder: { alignItems: 'center', justifyContent: 'center', backgroundColor: '#F1F7E7', borderWidth: 1, borderColor: '#D6E7BE' }, plantDetailHeroPlaceholderText: { color: '#6B7E6E', fontSize: 14, lineHeight: 18, fontWeight: '900' }, kitDetailPhoto: { width: '100%', height: 230, borderRadius: 22, marginBottom: 10, backgroundColor: '#E8F5D3' }, spaceDetailArtworkBackplate: { width: '100%', height: 184, borderRadius: 24, borderWidth: 1, borderColor: 'rgba(191,217,180,0.58)', backgroundColor: '#EDF7DD', alignItems: 'center', justifyContent: 'center', paddingVertical: 0, marginBottom: 10, overflow: 'visible' }, spaceDetailArtwork: { width: 196, height: 196, borderRadius: 22, alignSelf: 'center' }, health: { flexDirection: 'row', alignItems: 'center', gap: 10 }, status: { backgroundColor: '#DDF4CF', borderRadius: 14, paddingHorizontal: 15, paddingVertical: 8, color: green, fontWeight: '900' }, statRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8, borderTopWidth: 1, borderColor: line, gap: 12 }, statKey: { fontWeight: '800', color: dark, fontSize: 13 }, statVal: { fontWeight: '700', color: '#4B4A3B', fontSize: 13, flexShrink: 1, textAlign: 'right' }, plantReferenceLink: { alignSelf: 'flex-start', minHeight: 38, borderRadius: 19, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12, marginTop: 12 }, plantReferenceLinkText: { color: green, fontSize: 13, fontWeight: '900' }, plantPhotoButton: { minHeight: 48, borderRadius: 24, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', marginTop: 12 }, plantPhotoButtonText: { color: dark, fontSize: 15, fontWeight: '900' }, setupButton: { flex: 1, minHeight: 48, borderRadius: 999, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 }, plantDetailEditButton: { marginTop: 2, marginBottom: 14 }, setupButtonText: { color: '#fff', fontSize: 15, fontWeight: '900', textAlign: 'center' }, trustNote: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '800', marginTop: 8 }, pixieKnownList: { gap: 5, marginTop: 3 }, pixieKnownItem: { color: dark, fontSize: 13, lineHeight: 18, fontWeight: '800' }, plantIntelligenceNextBox: { borderRadius: 16, borderWidth: 1, borderColor: line, backgroundColor: '#E8F5D3', padding: 10, marginTop: 10 }, plantIntelligenceNextButton: { alignSelf: 'flex-start', minHeight: 34, borderRadius: 17, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 13, marginTop: 8 }, plantIntelligenceNextButtonDisabled: { backgroundColor: '#F7F1E4' }, plantIntelligenceNextButtonText: { color: dark, fontSize: 12, fontWeight: '900' }, plantHistoryList: { gap: 8, marginTop: 4 }, plantHistoryRow: { flexDirection: 'row', alignItems: 'center', gap: 10, borderRadius: 16, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFF9EE', padding: 8 }, plantHistoryRowHighlight: { borderColor: 'rgba(122,170,100,0.5)', backgroundColor: '#F7FBF2' }, plantHistoryThumb: { width: 48, height: 48, borderRadius: 12, backgroundColor: '#E8F5D3', flexShrink: 0 }, plantHistoryThumbLarge: { width: 56, height: 56, borderRadius: 14 }, plantHistoryIcon: { width: 48, height: 48, borderRadius: 12, backgroundColor: '#F1F7E7', borderWidth: 1, borderColor: '#D6E7BE', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }, plantHistoryIconText: { color: green, fontSize: 14, fontWeight: '900' }, plantHistoryCopy: { flex: 1, minWidth: 0 }, plantHistoryRelative: { color: '#7A704D', fontSize: 12, lineHeight: 16, fontWeight: '800' }, plantHistoryTitle: { color: dark, fontSize: 13, lineHeight: 17, fontWeight: '900' }, plantHistoryMeta: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '800', marginTop: 1 }, plantHistoryNote: { color: '#5D5A4E', fontSize: 11, lineHeight: 15, fontWeight: '700', marginTop: 2 }, setupPreferenceRows: { marginBottom: 8 }, setupSummaryGrid: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 7, marginTop: 10, marginBottom: 7 }, setupSummaryLabel: { color: '#5D5A4E', fontSize: 12, lineHeight: 17, fontWeight: '800', paddingVertical: 2 }, setupSummaryPill: { borderRadius: 999, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', color: dark, fontSize: 11, lineHeight: 15, fontWeight: '900', paddingHorizontal: 9, paddingVertical: 6, overflow: 'hidden' }, deletePlantCard: { padding: 10 }, deletePlantButton: { minHeight: 48, borderRadius: 24, borderWidth: 1, borderColor: '#F4C6B8', backgroundColor: '#FFE3DB', alignItems: 'center', justifyContent: 'center' }, deletePlantButtonText: { color: '#8E3D2F', fontSize: 15, fontWeight: '900' }, deleteConfirmOverlay: { flex: 1, backgroundColor: 'rgba(32,45,26,0.32)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28 }, deleteConfirmBubble: { width: '100%', maxWidth: 360, borderRadius: 24, borderWidth: 1, borderColor: '#F4C6B8', backgroundColor: 'rgba(255,253,247,0.98)', padding: 18, shadowColor: '#000', shadowOpacity: 0.18, shadowRadius: 16, elevation: 12 }, deleteConfirmTitle: { color: dark, fontSize: 18, lineHeight: 23, fontWeight: '900', textAlign: 'center' }, deleteConfirmBody: { color: '#5D5A4E', fontSize: 13, lineHeight: 18, fontWeight: '800', textAlign: 'center', marginTop: 8 }, deleteConfirmActions: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 28, marginTop: 18 }, deleteNoButton: { flex: 1, minHeight: 48, borderRadius: 24, borderWidth: 1, borderColor: line, backgroundColor: '#E8F5D3', alignItems: 'center', justifyContent: 'center' }, deleteNoButtonText: { color: dark, fontSize: 15, fontWeight: '900' }, deleteYesButton: { flex: 1, minHeight: 48, borderRadius: 24, borderWidth: 1, borderColor: '#F4C6B8', backgroundColor: '#FFE3DB', alignItems: 'center', justifyContent: 'center' }, deleteYesButtonText: { color: '#8E3D2F', fontSize: 15, fontWeight: '900' }, gardenIconError: { color: '#8E3D2F', fontSize: 13, lineHeight: 18, fontWeight: '800', marginTop: 9 }, identifyScreen: { flex: 1, backgroundColor: '#FDF4DB' }, identifySafe: { flex: 1 }, cameraPreview: { ...StyleSheet.absoluteFillObject }, cameraFallback: { ...StyleSheet.absoluteFillObject, backgroundColor: '#143716' }, cameraVeil: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(9,30,12,0.18)' }, identifyPickerOverlay: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18, paddingBottom: 98 }, identifyPickerPanel: { width: '100%', maxWidth: 420, borderRadius: 24, borderWidth: 1, borderColor: 'rgba(120,198,106,0.58)', backgroundColor: 'rgba(255,253,247,0.96)', padding: 18, shadowColor: '#000', shadowOpacity: 0.2, shadowRadius: 18, elevation: 9, overflow: 'hidden' }, pickerTitle: { fontSize: 21, lineHeight: 26, fontWeight: '900', color: dark, textAlign: 'center', marginHorizontal: 12 }, pickerSubtitle: { fontSize: 13, lineHeight: 18, fontWeight: '700', color: '#68705C', textAlign: 'center', marginTop: 7, marginBottom: 15 }, modeCardStack: { gap: 10 }, modeCard: { minHeight: 82, borderRadius: 18, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFF9EE', flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 13, paddingVertical: 10 }, modeCardDisabled: { opacity: 0.58 }, modeIcon: { width: 56, height: 56, borderRadius: 28, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E8F5D3' }, modeIconDisabled: { backgroundColor: '#F1EDDD' }, modeIconImage: { width: 46, height: 46 }, modeIconImageDisabled: { opacity: 0.62 }, modeCopy: { flex: 1, minWidth: 0 }, modeTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' }, modeTitle: { fontSize: 17, fontWeight: '900', color: dark }, modeTitleDisabled: { color: '#706A58' }, modeSubtitle: { fontSize: 12, lineHeight: 16, fontWeight: '700', color: '#5D5A4E', marginTop: 2 }, modeSubtitleDisabled: { color: '#817A68' }, comingSoonText: { color: green, fontSize: 11, fontWeight: '900' }, permissionPanel: { alignSelf: 'center', marginTop: 118, width: '88%', borderRadius: 23, borderWidth: 1, borderColor: 'rgba(120,198,106,0.58)', backgroundColor: 'rgba(255,253,247,0.96)', alignItems: 'center', padding: 18, zIndex: 26 }, permissionTitle: { fontSize: 20, fontWeight: '900', color: dark, marginTop: 8 }, permissionText: { fontSize: 13, lineHeight: 18, fontWeight: '700', color: '#5D5A4E', textAlign: 'center', marginVertical: 10 }, permissionButton: { height: 46, borderRadius: 23, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18, alignSelf: 'stretch', marginTop: 4 }, permissionButtonText: { color: '#fff', fontWeight: '900' }, changeInline: { marginTop: 10 }, changeInlineText: { color: green, fontWeight: '900', textDecorationLine: 'underline' }, modePill: { position: 'absolute', top: 58, alignSelf: 'center', minHeight: 48, borderRadius: 24, backgroundColor: 'rgba(248,244,232,0.97)', borderWidth: 1, borderColor: 'rgba(191,217,180,0.9)', flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 16, shadowColor: '#000', shadowOpacity: 0.18, shadowRadius: 8, elevation: 5 }, modePillText: { color: dark, fontSize: 15, fontWeight: '900' }, changeText: { color: green, fontSize: 14, fontWeight: '900', textDecorationLine: 'underline' }, instructionWrap: { position: 'absolute', top: 121, left: 20, right: 20, alignItems: 'center', justifyContent: 'center' }, instructionLeaf: { width: 18, height: 18, opacity: 0.92 }, identifyInstruction: { flexShrink: 1, color: '#FFFDF7', fontSize: 14, lineHeight: 19, fontWeight: '900', textAlign: 'center', textShadowColor: 'rgba(0,0,0,0.45)', textShadowRadius: 5 }, viewfinder: { position: 'absolute', top: 294, alignSelf: 'center', width: 286, height: 342 }, cameraControls: { position: 'absolute', bottom: 244, left: 34, right: 34, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', zIndex: 22 }, cameraSideButton: { width: 58, height: 58, borderRadius: 29, alignItems: 'center', justifyContent: 'center' }, cameraSideButtonActive: { backgroundColor: '#E8F5D3', borderColor: green }, cameraSideButtonImage: { width: 58, height: 58 }, captureButton: { width: 76, height: 76, borderRadius: 38, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOpacity: 0.22, shadowRadius: 12, elevation: 8 }, captureButtonImage: { width: 76, height: 76 }, helperMascotContainer: { position: 'absolute', left: '5%', width: '60%', aspectRatio: 540 / 296, zIndex: 24 }, helperMascot: { width: '100%', height: '100%' }, problemPanel: { position: 'absolute', left: 12, right: 12, bottom: 204, borderRadius: 18, backgroundColor: 'rgba(255,253,247,0.94)', borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', padding: 10 }, problemPanelHeader: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }, problemTitle: { fontSize: 13, lineHeight: 17, fontWeight: '900', color: dark, maxWidth: 205 }, problemSubtext: { fontSize: 11, lineHeight: 15, fontWeight: '700', color: '#5D5A4E', marginTop: 2, maxWidth: 220 }, countChip: { borderRadius: 999, backgroundColor: '#E8F5D3', paddingHorizontal: 9, paddingVertical: 5 }, countChipText: { color: green, fontSize: 11, fontWeight: '900' }, photoTray: { gap: 8, paddingTop: 9, paddingBottom: 5 }, photoTrayItem: { width: 92 }, photoThumb: { width: 92, height: 68, borderRadius: 13, backgroundColor: '#DDE7D4' }, photoNumber: { position: 'absolute', top: 4, left: 4, width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center', backgroundColor: green }, photoNumberText: { color: '#fff', fontSize: 11, fontWeight: '900' }, labelTray: { gap: 4, paddingTop: 5 }, labelChip: { borderRadius: 999, borderWidth: 1, borderColor: brand.colors.softSage, backgroundColor: '#FFF9EE', paddingHorizontal: 7, paddingVertical: 3 }, labelChipActive: { backgroundColor: green, borderColor: green }, labelChipText: { fontSize: 9, fontWeight: '800', color: '#5D5A4E' }, labelChipTextActive: { color: '#fff' }, problemActions: { flexDirection: 'row', gap: 8, marginTop: 4 }, addAreaButton: { flex: 1, minHeight: 38, borderRadius: 19, borderWidth: 1, borderColor: brand.colors.softSage, alignItems: 'center', justifyContent: 'center', backgroundColor: '#FFF9EE' }, addAreaText: { color: green, fontSize: 12, fontWeight: '900' }, analyzeButton: { flex: 1, minHeight: 38, borderRadius: 19, alignItems: 'center', justifyContent: 'center', backgroundColor: green }, analyzeText: { color: '#fff', fontSize: 12, fontWeight: '900' }, handoffToast: { position: 'absolute', left: 20, right: 20, bottom: 206, borderRadius: 18, backgroundColor: 'rgba(255,253,247,0.95)', borderWidth: 1, borderColor: 'rgba(191,217,180,0.9)', paddingHorizontal: 12, paddingVertical: 10 }, handoffText: { color: dark, fontSize: 12, lineHeight: 16, fontWeight: '900', textAlign: 'center' }, nav: { position: 'absolute', zIndex: 30, overflow: 'visible' }, navShellBase: { position: 'absolute', left: 0, right: 0, backgroundColor: 'rgba(255,253,247,0.98)', borderWidth: 1, borderColor: 'rgba(191,217,180,0.82)' }, navScanSeat: { position: 'absolute', backgroundColor: 'rgba(255,253,247,0.98)', borderWidth: 1, borderColor: 'rgba(191,217,180,0.7)' }, navShellImage: { position: 'absolute', left: 0 }, navActiveHighlight: { position: 'absolute', zIndex: 1 }, navIcon: { position: 'absolute', zIndex: 2 }, navLabels: { position: 'absolute', left: 0, right: 0, flexDirection: 'row', zIndex: 3 }, navLabelSlot: { flex: 1, alignItems: 'center', justifyContent: 'center', minWidth: 0 }, navLabelText: { width: '94%', textAlign: 'center', fontWeight: '900' }, navRow: { flexDirection: 'row', zIndex: 20 }, navHitbox: { position: 'absolute', top: 0, zIndex: 60, elevation: 60, backgroundColor: 'transparent' }, memoryCategoryCard: { borderRadius: 16, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFF9EE', padding: 14, marginBottom: 8 }, memoryCategoryLabel: { color: dark, fontSize: 15, fontWeight: '900' }, memoryCategoryDesc: { color: '#7A704D', fontSize: 12, lineHeight: 16, fontWeight: '600', marginTop: 2 }, memoryBackLink: { color: green, fontSize: 13, fontWeight: '800', marginBottom: 8 }, problemScanSafeArea: { flex: 1, backgroundColor: '#102A10' }, problemScanScrollView: { flex: 1 }, problemScanScrollContent: { paddingHorizontal: 18, paddingTop: 12, paddingBottom: 80 }, problemScanHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 16 }, problemScanBackButton: { paddingVertical: 6, paddingRight: 12 }, problemScanBackText: { color: '#5B7553', fontSize: 14, fontWeight: '800' }, problemScanHeaderTitle: { color: '#2D4A2D', fontSize: 18, fontWeight: '900', flex: 1 }, problemScanPreviewImage: { width: '100%', height: 200, borderRadius: 16, marginBottom: 16, backgroundColor: '#1a3a1a' }, problemScanCard: { backgroundColor: 'rgba(255,253,247,0.96)', borderRadius: 24, borderWidth: 1, borderColor: 'rgba(217,208,181,0.9)', padding: 13, marginBottom: 11, shadowColor: '#000', shadowOpacity: 0.08, shadowRadius: 10, elevation: 3 }, problemScanBodyText: { color: '#5E5845', fontSize: 14, lineHeight: 20, fontWeight: '600' }, problemScanButtonRow: { flexDirection: 'row', gap: 10, marginTop: 8 }, problemScanSectionTitle: { color: '#78694B', fontSize: 13, fontWeight: '900', marginBottom: 6, marginTop: 4 }, problemScanListItem: { color: '#5E5845', fontSize: 13, lineHeight: 18, fontWeight: '600', marginLeft: 8, marginBottom: 3 }, problemScanConfidence: { color: '#5B7553', fontSize: 12, fontWeight: '700', marginTop: 4 }, problemScanUncertainty: { color: '#78694B', fontSize: 12, lineHeight: 16, fontWeight: '600', fontStyle: 'italic', marginTop: 6 }, problemScanSymptomChip: { borderRadius: 20, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#F3F7E6', paddingVertical: 8, paddingHorizontal: 14, marginRight: 8, marginBottom: 8 }, problemScanSymptomChipSelected: { borderColor: '#5B7553', backgroundColor: '#E8F0E4' }, problemScanSymptomChipText: { color: '#5B7553', fontSize: 13, fontWeight: '700' }, problemScanSymptomChipTextSelected: { color: '#2D4A2D' }, problemScanContextCard: { borderRadius: 16, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#F3F7E6', padding: 16, marginBottom: 12, alignItems: 'center' }, problemScanContextLabel: { color: '#2D4A2D', fontSize: 15, fontWeight: '900' }, problemScanContextDesc: { color: '#5B7553', fontSize: 12, fontWeight: '600', marginTop: 4 }, problemScanNoteInput: { borderRadius: 14, borderWidth: 1, borderColor: '#B8C4A8', backgroundColor: '#FFF', color: '#2D4A2D', fontSize: 14, fontWeight: '600', padding: 12, minHeight: 80, textAlignVertical: 'top', marginBottom: 16 }, problemScanAnalyzing: { color: '#5B7553', fontSize: 16, fontWeight: '800', textAlign: 'center', marginTop: 40 }, problemScanAnalyzingSub: { color: '#78694B', fontSize: 13, fontWeight: '600', textAlign: 'center', marginTop: 8 }, problemScanSaveButton: { flex: 1, height: 48, borderRadius: 999, backgroundColor: '#6B9E5C', alignItems: 'center', justifyContent: 'center' }, problemScanSaveButtonText: { color: '#fff', fontSize: 15, fontWeight: '900' }, problemScanScanAgainButton: { flex: 1, height: 48, borderRadius: 999, borderWidth: 1, borderColor: '#6B8E6B', backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center' }, problemScanScanAgainText: { color: '#2D4A2D', fontSize: 15, fontWeight: '900' }, problemScanPlantPicker: { marginTop: 12 }, problemScanPlantList: { gap: 8, marginTop: 8 }, problemScanPlantItem: { flexDirection: 'row', alignItems: 'center', gap: 10, borderRadius: 12, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#F3F7E6', padding: 10 }, problemScanPlantItemSelected: { borderColor: '#5B7553', backgroundColor: '#E8F0E4' }, problemScanPlantThumb: { width: 36, height: 36, borderRadius: 8 }, problemScanPlantName: { color: '#2D4A2D', fontSize: 14, fontWeight: '800' }, problemScanPlantScientific: { color: '#5B7553', fontSize: 11, fontWeight: '600', fontStyle: 'italic' }, problemScanAnalyzingWrap: { alignItems: 'center', gap: 12, paddingVertical: 32 }, problemScanAnalyzingTitle: { color: '#5B7553', fontSize: 16, fontWeight: '800' }, problemScanResultSummary: { color: '#2D4A2D', fontSize: 15, fontWeight: '800', lineHeight: 22, marginBottom: 12 }, problemScanSection: { marginTop: 12 }, problemScanListBullet: { color: '#5B7553', fontSize: 13, fontWeight: '900', marginRight: 6 }, problemScanListText: { color: '#5E5845', fontSize: 13, lineHeight: 18, fontWeight: '600', flex: 1 }, problemScanConfidenceLabel: { color: '#5B7553', fontSize: 12, fontWeight: '700' }, problemScanConfidenceValue: { color: '#5E5845', fontWeight: '600' }, // Investigation detail view styles invDetailSafe: { flex: 1 }, invDetailScroll: { flex: 1 }, invDetailSpacer: { width: 60 }, invDetailStatusRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }, invDetailTitleFlex: { flex: 1, marginRight: 8 }, invDetailBadge: { borderRadius: 6, paddingHorizontal: 10, paddingVertical: 4 }, invDetailBadgeText: { fontSize: 12, color: '#fff', fontWeight: '600' }, invDetailFadedText: { opacity: 0.6 }, invDetailDateText: { fontSize: 12, opacity: 0.5, marginTop: 4 }, invDetailPhotoGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, invDetailPhotoWrap: { position: 'relative' }, invDetailPhoto: { width: 100, height: 100, borderRadius: 8 }, invDetailPhotoOverlay: { position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'rgba(0,0,0,0.6)', borderBottomLeftRadius: 8, borderBottomRightRadius: 8, paddingVertical: 2, alignItems: 'center' }, invDetailPhotoLabel: { fontSize: 9, color: '#fff' }, invDetailPhotoEmoji: { fontSize: 24 }, invDetailSymptomRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, invDetailSymptomChip: { backgroundColor: '#F3F7E6', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4, borderWidth: 1, borderColor: '#D6E7BE' }, invDetailSymptomText: { fontSize: 13, color: '#5B7553' }, invDetailTimelineRow: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: 8 }, invDetailTimelineDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: '#4CAF50', marginTop: 5, marginRight: 10 }, invDetailTimelineContent: { flex: 1 }, invDetailTimelineTitle: { fontSize: 13, color: '#2E6B3F', fontWeight: '600' }, invDetailTimelineDetail: { fontSize: 12, color: '#78694B' }, invDetailTimelineDate: { fontSize: 11, color: '#9E9E9E' }, invDetailBottomSpacer: { height: 40 }, // Investigation list card styles invCardRow: { flexDirection: 'row', alignItems: 'center' }, invCardPhotoPlaceholder: { backgroundColor: '#E8F5D3', alignItems: 'center', justifyContent: 'center' }, invCardCopy: { flex: 1, marginLeft: 12 }, invCardMetaWrap: { marginTop: 6 }, invCardMetaRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, invCardBadge: { borderRadius: 12, paddingHorizontal: 8, paddingVertical: 3, marginLeft: 8 }, invCardBadgeText: { fontSize: 10, color: '#fff', fontWeight: '900' }, });