/** * Care Plan screen for PixieSprout. * * ONE continuous full-page gardener's journal. "Due Today", "Upcoming" * (Tomorrow / This Week / Later) and "Recently Completed" all live INSIDE the * same journal — no separate cards. */ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { haptic, pressWithHaptic } from '../constants/theme'; import { GardenJournal } from '../components/GardenJournal'; import { CareFeedback, CohortGroup, DueTodaySpaceGroup, JournalEmpty, JournalSection, JournalSectionTitle, UpcomingDayGroup, canCompleteCareTaskEarly, } from '../components/CareComponents'; import type { CareFeedbackState, CareTaskActionState, CareTaskActionStatus, CareTaskItem, } from '../types/appTypes'; import { SAVED_PREFIX, savedGardenKey } from '../types/appTypes'; import type { WeatherWarning } from '../services/care'; import type { Cohort, GroupableCareAction, WeatherSnapshot } from '../types/care'; import { compressWeatherSignals } from '../services/weather'; import { saveCareTaskActionStates } from '../services/gardenStorage'; type Props = { careTaskStates: Record; /** PER-PLANT LAST-WATERED/-fed MAP (Bekky, 2026-08-30, Chunk B core §B1): * RAW plantId → { water?: 'YYYY-MM-DD', fertilize?: 'YYYY-MM-DD' } from each * plant's careSchedule.lastDone. Powers the cooldown/rejoin note math in * buildSkippedMembers (the Care tab otherwise has no plant records). */ plantLastDone?: Record>>; onCareTaskStatesChange: (states: Record) => void; onDebugReset: () => Promise; careTasks: CareTaskItem[]; /** Weather-triggered care warnings (frost/heat/storm/heavy-rain) computed at * the App level from the stored garden location (Bekky, 2026-08-23). */ weatherWarnings?: WeatherWarning[]; /** Weather snapshot (Bekky, 2026-09-01, Chunk 2): used to decide whether a * cohort's soil can dry (extreme rain / overwatering / frost / cold-damp → * soil can't dry → the cohort opens the check-in modal instead of the quick * buttons). */ weather?: WeatherSnapshot | null; createCareTaskActionState: (task: CareTaskItem, status: Exclude) => CareTaskActionState; showCareValidationReset: boolean; /** Recheck task: open the case's check-in flow (progress photo). */ onTaskCheckIn?: (task: CareTaskItem) => void; /** Recheck task: snooze / remind later. */ onTaskRemind?: (task: CareTaskItem) => void; /** Treatment step: open the linked case (investigation) in the Cases tab. */ onTaskViewCase?: (task: CareTaskItem) => void; /** After completing a care task, offer a quick photo check-in (photo-update loop). */ onTaskPhotoUpdate?: (task: CareTaskItem) => void; /** Task id to auto-expand when the Care tab mounts (deep-link from a notification). */ focusedTaskId?: string | null; onFocusedTaskHandled?: () => void; /** True while the Quick Check-In modal is open — pauses the undo banner's * auto-dismiss so it doesn't vanish behind the modal (Bekky, 2026-08-18). */ checkInOpen?: boolean; /** * External undo banner (Bekky, 2026-08-20): when a task is completed via the * check-in modal, the completion happens at the App level (setCareTaskStatusForComplete), * so the local `careFeedback` banner never fires. App drives this prop to show * the same undo banner at the top of the Care page. When set, it overrides the * local banner. */ externalFeedback?: CareFeedbackState | null; onExternalFeedbackClear?: () => void; /** * CARE CHECK-IN intercept (Bekky, 2026-08-20): for schedule-based Water and * Fertilize tasks, tapping Complete opens the check-in modal instead of * completing directly. When set, completeCareTask routes those tasks through * this callback (the parent shows the modal); when unset, tasks complete as * before. Diagnosis/Recheck and Inspect tasks are never intercepted. */ onTaskCheckInRequest?: (task: CareTaskItem) => void; /** CASE-TREATMENT intercept (Bekky, 2026-09-02): for diagnosed Treat tasks * (source 'diagnosis'), tapping Complete opens the case-treatment multi-select * modal instead of completing directly. When set, completeCareTask routes * Treat diagnosis tasks through this callback (the parent shows the modal). * Recheck tasks keep the existing check-in behavior and are never routed here. */ onOpenCaseTreatment?: (task: CareTaskItem) => void; /** Open a plant's DETAIL page (Bekky, 2026-09-01): the members-modal tiles * navigate to the plant detail, like the garden tiles do. */ onOpenPlantDetail?: (rawPlantId: string) => void; /** Intelligent grouping cohorts (Chunk 2, Bekky 2026-08-27): per-space cohort * groups for water/fertilize, plus a lookup to resolve cohort plant ids into * display names. Absent on spaces with no cohorts (or under the grouping * threshold). */ spaceCohorts?: Array<{ spaceId: string; spaceName: string; cohorts: Partial>; }>; /** Cohort-wide wet/dry feedback (Bekky, 2026-08-27, Chunk 2 — Step 6): called * from the cohort detail modal when the user says a whole cohort dries too * fast ('dry') or holds water too long ('wet'). The parent adjusts the * cohort's shared cadence. Args: the cohort's spaceId + action + the signal. */ onCohortFeedback?: (input: { spaceId: string; action: GroupableCareAction; signal: 'dry' | 'wet' }) => void; /** Per-plant cohort metadata (Chunk A, Bekky 2026-08-29): maps a RAW plantId → * { name, deltas } where `deltas[action]` is the AI-generated reasoning * sentence for why this plant's care is similar-but-slightly-different from * its group for THAT action (e.g. "this one's in a terracotta pot so it dries * faster"). A plant can be in a water cohort AND a fertilize cohort with * different deltas, so the delta is per-action. Keyed by raw plant id. */ cohortPlantMeta?: Record> }>; /** Complete every task of an action for a whole COHORT in one tap (Chunk A, * Bekky 2026-08-29): the group is the unit of care — check off the whole * group = all members done. Args: the cohort's spaceId + action + the raw * plantIds of the cohort's members. */ onCompleteCohort?: (input: { spaceId: string; action: GroupableCareAction; plantIds: string[] }) => void; /** SOIL-CHECK COMPLETE (Bekky, 2026-09-01, Chunk 2): complete the SELECTED * plants of a cohort after the user checks the soil in the soil-check modal. * Args: spaceId + action + the selected raw plantIds + the slider state. */ onSoilCheckComplete?: (input: { spaceId: string; action: GroupableCareAction; plantIds: string[]; stateAtComplete: string }) => void; /** SOIL-CHECK POSTPONE (Bekky, 2026-09-01, Chunk 2): postpone the SELECTED * plants of a cohort after the user checks the soil. Args: spaceId + action + * the selected raw plantIds + the slider state + the postpone days. */ onSoilCheckPostpone?: (input: { spaceId: string; action: GroupableCareAction; plantIds: string[]; stateAtComplete: string; postponeDays: number }) => void; /** LASTDONE UNIFICATION (Bekky, 2026-08-30): undo of an in-place completion * must also roll back the careLog entry + lastDone anchor that completion * wrote (handled in App, which owns the plant profiles). Args: the task. */ onRevertCareLogCompletion?: (task: CareTaskItem) => void; /** MANUAL SWEEP (Bekky, 2026-08-30): the Care-tab "complete all in space" * sweep writes 'manual'-path careLog entries + moves every member's * lastDone anchor (handled in App). Args: spaceId + action + raw plantIds. */ onSweepAnchors?: (input: { spaceId: string; action: GroupableCareAction; plantIds: string[] }) => void; // Components passed from parent (to avoid circular deps with img/styles) HeaderComponent: React.ComponentType<{ title: string }>; ScreenComponent: React.ComponentType<{ children: React.ReactNode }>; }; export function CarePlanScreen({ careTaskStates, plantLastDone, onCareTaskStatesChange, onDebugReset, careTasks, weatherWarnings = [], weather = null, createCareTaskActionState, showCareValidationReset, onTaskCheckIn, onTaskRemind, onTaskViewCase, onTaskPhotoUpdate, onTaskCheckInRequest, onOpenCaseTreatment, onOpenPlantDetail, focusedTaskId, onFocusedTaskHandled, checkInOpen = false, externalFeedback, onExternalFeedbackClear, HeaderComponent: Header, spaceCohorts = [], onCohortFeedback, cohortPlantMeta, onCompleteCohort, onSoilCheckComplete, onSoilCheckPostpone, onRevertCareLogCompletion, onSweepAnchors, }: Props) { const hasCompletionBaselineRef = useRef(false); const [careFeedback, setCareFeedback] = useState(null); const [expandedCohorts, setExpandedCohorts] = useState>(new Set()); const toggleCohort = (key: string) => setExpandedCohorts(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); const careFeedbackTimer = useRef | null>(null); // Tasks completed DURING this session — shown crossed out now, but hidden on // next launch (Bekky, 2026-08-12). Completion persists to storage so it never // re-notifies, but the crossed-out entry only lingers for the current session. const [sessionCompletedIds, setSessionCompletedIds] = useState>(new Set()); useEffect(() => () => { if (careFeedbackTimer.current) clearTimeout(careFeedbackTimer.current); }, []); // Deep-link: when a focused task id arrives (from a notification tap), just // mark it handled — there is no expandable row state anymore (Bekky, 2026-08-12). useEffect(() => { if (focusedTaskId) { onFocusedTaskHandled?.(); } }, [focusedTaskId]); // Detect in-session completions from ANY path (including the check-in modal, // which completes at the App level) and add them to the strikethrough set so // the completed task shows crossed-out instead of being filtered out. We only // track tasks that were NOT already completed when the screen mounted, so // pre-completed tasks from a previous session stay hidden (Bekky, 2026-08-20). const mountedCompletedRef = useRef>(new Set()); useEffect(() => { // Snapshot which tasks are already completed on first mount. if (!hasCompletionBaselineRef.current) { hasCompletionBaselineRef.current = true; // audit-fix: an empty baseline is still initialized mountedCompletedRef.current = new Set( careTasks.filter(t => careTaskStates[t.id]?.status === 'completed').map(t => t.id) ); } }, [careTasks, careTaskStates]); useEffect(() => { // Any task that just became 'completed' (not skipped) and wasn't completed // at mount is a fresh in-session completion → show it crossed out. const newlyCompleted = careTasks .filter(t => careTaskStates[t.id]?.status === 'completed' && !mountedCompletedRef.current.has(t.id)) .map(t => t.id); if (newlyCompleted.length) { setSessionCompletedIds(prev => { const next = new Set(prev); newlyCompleted.forEach(id => next.add(id)); return next; }); } }, [careTaskStates, careTasks]); // Keep ALL tasks in their timing group — completed tasks STAY in place with // the check + pencil line-through (Bekky, 2026-08-12), they do NOT move to a // separate "Recently Completed" section. Only skipped tasks are hidden. // A completed task is shown crossed-out ONLY if it was completed during THIS // session; tasks already completed before launch are hidden entirely (they // persist so they never re-notify, but the crossed-out entry doesn't linger). const activeTasks = careTasks.filter(task => { const st = careTaskStates[task.id]?.status; if (st === 'skipped') return false; if (st === 'completed') return sessionCompletedIds.has(task.id); return true; }); // Sort each timing bucket by dueDate ascending (soonest first) so the Care // page reads chronologically — a 3-days-from-now task always precedes a // 7-days-from-now one (Bekky, 2026-08-15). Tasks without a dueDate sort last. const byDueDate = (a: CareTaskItem, b: CareTaskItem) => { const ta = a.dueDate ? new Date(a.dueDate).getTime() : Number.MAX_SAFE_INTEGER; const tb = b.dueDate ? new Date(b.dueDate).getTime() : Number.MAX_SAFE_INTEGER; return ta - tb; }; const dueToday = activeTasks.filter(task => task.timing === 'today').sort(byDueDate); const upcomingTomorrow = activeTasks.filter(task => task.timing === 'tomorrow').sort(byDueDate); const upcomingWeek = activeTasks.filter(task => task.timing === 'this_week').sort(byDueDate); const upcomingLater = activeTasks.filter(task => task.timing === 'later').sort(byDueDate); // DUE TODAY GROUPING (Bekky, 2026-08-22 redesign): group due-today tasks by // SPACE (one collapsible per space), NOT action. Each space shows its distinct // plants. A plant needing both water + fertilize groups its tasks together as // a small dropdown. The count is DISTINCT plants (a plant with 2 tasks counts // once). Spaces ordered by their soonest task. const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [expandedPlants, setExpandedPlants] = useState>(new Set()); const toggleGroup = (key: string) => setExpandedGroups(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); const togglePlant = (plantId: string) => setExpandedPlants(prev => { const next = new Set(prev); if (next.has(plantId)) next.delete(plantId); else next.add(plantId); return next; }); const dueTodayGroups = (() => { // Group by space. const bySpace = new Map(); for (const task of dueToday) { const key = task.spaceId || 'no-space'; const arr = bySpace.get(key) || []; arr.push(task); bySpace.set(key, arr); } return Array.from(bySpace.entries()) .map(([spaceId, tasks]) => { // Within the space, group tasks by DISTINCT plant. const byPlant = new Map(); for (const t of tasks) { const arr = byPlant.get(t.plantId) || []; arr.push(t); byPlant.set(t.plantId, arr); } const plants = Array.from(byPlant.entries()).map(([plantId, plantTasks]) => ({ plantId, plantName: plantTasks[0].plantName, tasks: plantTasks, })); // Space soonest-task ordering. const soonest = tasks.reduce((min, t) => (t.dueDate && (!min || t.dueDate < min) ? t.dueDate : min), null as string | null); return { spaceId, spaceName: tasks[0].spaceName || 'No space', plants, soonest, }; }) .sort((a, b) => (a.soonest || '9999').localeCompare(b.soonest || '9999')); })(); // ── COHORT INTEGRATION (Bekky, 2026-08-29) ──────────────────────────────── // Groups replace individual plants ONLY for plants that are in a cohort. // Plants NOT in a cohort stay as individual rows, side by side, same listing. // A cohort's plantIds are RAW ids; care task plantId is `saved:`, so we // match via savedGardenKey. Build a lookup: rawPlantId → { cohort, action }. const cohortByPlantId = useMemo(() => { // Keyed by `${plantId}:${action}` — a plant can be in MULTIPLE cohorts across // actions (water AND fertilize), so a plantId→single-cohort map would mis-match // (Bekky, 2026-08-29 root cause: a Water task looked up a plant and got its // fertilize cohort, the action check failed, and it fell to standalone). const map = new Map(); for (const space of spaceCohorts) { for (const action of ['water', 'fertilize'] as const) { for (const cohort of space.cohorts[action] || []) { for (const pid of cohort.plantIds) { // Index by BOTH the raw id and the savedGardenKey form, so matching // works regardless of which format the persisted cohort data uses. map.set(`${pid}:${action}`, { cohort, action }); map.set(`${savedGardenKey(pid)}:${action}`, { cohort, action }); } } } } return map; }, [spaceCohorts]); // Find the care task for a cohort plant + action (to open its rich check-in). // Care task plantId is the RAW plant.id (App.tsx buildScheduledCareTasks ~1238), // NOT the savedGardenKey form — so match on the raw id directly (Bekky, 2026-08-29). const findTaskForCohortPlant = (rawPlantId: string, action: GroupableCareAction): CareTaskItem | undefined => { const type = action === 'water' ? 'Water' : 'Fertilize'; return careTasks.find(t => t.plantId === rawPlantId && t.type === type); }; // Split a space's due-today plants into cohort rows + standalone plants. // A cohort row shows the FULL member count (all plants in the cohort — the // group is due together on the same day, Bekky 2026-08-29), NOT just the // members with a due-today task. The due label comes from the due tasks. // Non-cohort plants stay individual. const splitDueTodayByCohort = (space: { spaceId: string; plants: { plantId: string; plantName: string; tasks: CareTaskItem[] }[] }) => { const cohortRows: { cohort: Cohort; action: GroupableCareAction; plantIds: string[]; plantNames: string[]; dueLabel: string; dueDate: string | null; tasks: CareTaskItem[]; }[] = []; const caseRows: { caseId: string; plantId: string; plantName: string; tasks: CareTaskItem[]; }[] = []; const standalone: { plantId: string; plantName: string; tasks: CareTaskItem[] }[] = []; // Build the full cohort membership for this space from spaceCohorts. const spaceCohort = spaceCohorts.find(sc => sc.spaceId === space.spaceId); const cohortDefs: { cohort: Cohort; action: GroupableCareAction }[] = []; if (spaceCohort) { for (const action of ['water', 'fertilize'] as const) { for (const cohort of spaceCohort.cohorts[action] || []) { cohortDefs.push({ cohort, action }); } } } // Map raw plantId → display name (from the space's plants, plus the real // names in cohortPlantMeta as fallback so window-less members never show // their raw id like "plant_scan_..."). const nameById = new Map(); for (const plant of space.plants) nameById.set(plant.plantId, plant.plantName); for (const id of Object.keys(cohortPlantMeta || {})) { if (!nameById.has(id) && cohortPlantMeta?.[id]?.name) nameById.set(id, cohortPlantMeta[id]!.name); } // Build a cohort row for each cohort definition (full membership). Members // whose display name can't be resolved are dropped — never shown as a raw id. for (const def of cohortDefs) { const memberIds = def.cohort.plantIds; // Parallel arrays, aligned by index. Drop any member whose resolved name // is a raw id (e.g. "plant_scan_..." — not a real named garden plant). const resolvable: { id: string; name: string }[] = []; for (const id of memberIds) { const name = nameById.get(id) || id; if (/^plant_scan_/.test(name)) continue; resolvable.push({ id, name }); } const resolvableIds = resolvable.map(r => r.id); const memberNames = resolvable.map(r => r.name); // The due tasks for this cohort+action among the space's due-today plants. const dueTasks = space.plants .flatMap(p => p.tasks) .filter(t => { const rawId = t.plantId.startsWith(SAVED_PREFIX) ? t.plantId.slice(SAVED_PREFIX.length) : t.plantId; return t.type === (def.action === 'water' ? 'Water' : 'Fertilize') && memberIds.includes(rawId); }); cohortRows.push({ cohort: def.cohort, action: def.action, plantIds: resolvableIds, plantNames: memberNames, dueLabel: dueTasks[0]?.dueLabel || 'Due today', dueDate: dueTasks[0]?.dueDate || null, tasks: dueTasks, }); } // CASE-TREATMENT grouping (Bekky, 2026-09-02): a diagnosed Treat task's // steps are separate Care tasks sharing the same relatedDiagnosisId. Group // them into ONE row per case — "🩺 Osmanthus · 2 treatments due today" — // a tappable headline (no chevron) that opens the case-treatment modal. const caseGroups = new Map(); for (const plant of space.plants) { for (const task of plant.tasks) { if (task.source !== 'diagnosis' || task.type !== 'Treat') continue; const caseId = task.metadata?.relatedDiagnosisId; if (!caseId) continue; const existing = caseGroups.get(caseId); if (existing) { existing.tasks.push(task); } else { caseGroups.set(caseId, { caseId, plantId: plant.plantId, plantName: plant.plantName, tasks: [task] }); } } } caseRows.push(...caseGroups.values()); // Standalone = plants NOT in any cohort for their due actions AND not a // diagnosis-treat case (those are grouped into caseRows above). for (const plant of space.plants) { const inCohort = plant.tasks.some(t => { const rawId = t.plantId.startsWith(SAVED_PREFIX) ? t.plantId.slice(SAVED_PREFIX.length) : t.plantId; const action = t.type === 'Water' ? 'water' : t.type === 'Fertilize' ? 'fertilize' : null; return action ? cohortByPlantId.has(`${rawId}:${action}`) : false; }); const inCase = plant.tasks.some(t => t.source === 'diagnosis' && t.type === 'Treat' && t.metadata?.relatedDiagnosisId); // A treatment step in a cohort (shouldn't happen but defensive) — exclude // from case grouping if the same treat task is also in a cohort. if (!inCohort && !inCase) standalone.push(plant); } return { cohortRows, caseRows, standalone }; }; // Same split for UPCOMING tasks (by space). A cohort row shows the FULL member // count; the due label comes from the upcoming tasks of that cohort+action. const splitUpcomingByCohort = (spaceKey: string, tasks: CareTaskItem[]) => { const cohortRows: { cohort: Cohort; action: GroupableCareAction; plantIds: string[]; plantNames: string[]; dueLabel: string; dueDate: string | null; tasks: CareTaskItem[]; }[] = []; const standalone: CareTaskItem[] = []; const spaceCohort = spaceCohorts.find(sc => sc.spaceId === spaceKey); const cohortDefs: { cohort: Cohort; action: GroupableCareAction }[] = []; if (spaceCohort) { for (const action of ['water', 'fertilize'] as const) { for (const cohort of spaceCohort.cohorts[action] || []) { cohortDefs.push({ cohort, action }); } } } // Name lookup from the tasks (they carry plantName) + real names in // cohortPlantMeta so window-less members never show a raw id. const nameById = new Map(); for (const t of tasks) { const rawId = t.plantId.startsWith(SAVED_PREFIX) ? t.plantId.slice(SAVED_PREFIX.length) : t.plantId; if (!nameById.has(rawId)) nameById.set(rawId, t.plantName); } for (const id of Object.keys(cohortPlantMeta || {})) { if (!nameById.has(id) && cohortPlantMeta?.[id]?.name) nameById.set(id, cohortPlantMeta[id]!.name); } for (const def of cohortDefs) { const memberIds = def.cohort.plantIds; // Drop any member whose resolved name is a raw id (never show plant_scan_). const resolvable: { id: string; name: string }[] = []; for (const id of memberIds) { const name = nameById.get(id) || id; if (/^plant_scan_/.test(name)) continue; resolvable.push({ id, name }); } const resolvableIds = resolvable.map(r => r.id); const memberNames = resolvable.map(r => r.name); const dueTasks = tasks.filter(t => { const rawId = t.plantId.startsWith(SAVED_PREFIX) ? t.plantId.slice(SAVED_PREFIX.length) : t.plantId; return t.type === (def.action === 'water' ? 'Water' : 'Fertilize') && memberIds.includes(rawId); }); cohortRows.push({ cohort: def.cohort, action: def.action, plantIds: resolvableIds, plantNames: memberNames, dueLabel: dueTasks[0]?.dueLabel || 'Due soon', dueDate: dueTasks[0]?.dueDate || null, tasks: dueTasks, }); } // Standalone = tasks whose plant is NOT in a cohort for that action. for (const t of tasks) { const rawId = t.plantId.startsWith(SAVED_PREFIX) ? t.plantId.slice(SAVED_PREFIX.length) : t.plantId; const action = t.type === 'Water' ? 'water' : t.type === 'Fertilize' ? 'fertilize' : null; const inCohort = action ? cohortByPlantId.has(`${rawId}:${action}`) : false; if (!inCohort) standalone.push(t); } return { cohortRows, standalone }; }; // A cohort row is actionable when at least one member's task is due today or // within the ≤3-day early window (Bekky, 2026-08-29). Groups beyond the window // are informational only — no Complete-all, no wet/dry feedback. const isActionableRow = (row: { tasks: CareTaskItem[] }) => row.tasks.some(t => t.timing === 'today' || canCompleteCareTaskEarly(t)); // SOIL-CAN'T-DRY (Bekky, 2026-09-01, Chunk 2): when the weather keeps the soil // from drying (extreme rain / overwatering risk / frost / cold-damp), the // cohort's quick buttons (Complete-all + the two wet/dry feedback pills) are // HIDDEN — the user must CHECK THE SOIL first via the check-in modal (slider // + postpone) before deciding. Uses the same compressed weather signals as the // cadence so the UI and the intelligence agree. const soilCanDry = (): boolean => { if (!weather) return true; // no weather → assume soil can dry (normal path) const sig = compressWeatherSignals(weather); // High saturation risk, compounding wetness, or cold-damp → soil can't dry. if (sig.saturationRisk >= 2) return false; if (sig.compoundingWetness >= 2) return false; if (sig.coldDamp) return false; return true; }; // Members held out this round (Bekky, 2026-08-29): a recent manual water (last // watered) put them off the shared schedule, so they stay in the group but skip // this watering with a note. A member is "skipped" when they're in the cohort // but have NO due task this round. The note carries their last-watered date. const buildSkippedMembers = ( row: { cohort?: Cohort; action?: GroupableCareAction; plantIds: string[]; tasks: CareTaskItem[] } ): Record => { const skipped: Record = {}; // COOLDOWN / REJOIN MATH (Bekky, 2026-08-30, Chunk B core §B1): a member // with no due task this round is in cooldown — usually because an individual // watering made them fresher than the group. With the group's first-class // anchor (§B1) + shared cadence we can TELL the user when rejoin happens: // groupNext = lastAnchor + sharedCadence (the group's next joint date). const groupAnchor = row.cohort && row.action ? row.cohort.lastAnchor?.[row.action] : undefined; const shared = row.cohort && row.action ? row.cohort.sharedCadence?.[row.action] : undefined; const groupNextDate = groupAnchor && shared ? new Date(new Date(groupAnchor + 'T12:00:00').getTime() + shared * 86400000) : null; const fmtShort = (d: Date) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); const dueRaw = new Set(row.tasks.map(t => t.plantId.startsWith(SAVED_PREFIX) ? t.plantId.slice(SAVED_PREFIX.length) : t.plantId )); for (const pid of row.plantIds) { if (dueRaw.has(pid)) continue; // MEMBER-SIDE TRUTH (§B1): lastDone from the plant record (plantLastDone // prop, fed from App) wins over task-completion archaeology — completions // write lastDone directly (chunk B-adjacent unification). const memberDone = plantLastDone?.[pid]?.[row.action ?? 'water']; const memberDate = memberDone ? new Date(memberDone + 'T12:00:00') : null; const date = memberDate ? fmtShort(memberDate) : (() => { const last = careTasks .filter(t => t.plantId === pid && careTaskStates[t.id]?.status === 'completed' && careTaskStates[t.id]?.completedAt) .sort((a, b) => (careTaskStates[b.id]?.completedAt || '').localeCompare(careTaskStates[a.id]?.completedAt || ''))[0]; return last && careTaskStates[last.id]?.completedAt ? new Date(careTaskStates[last.id]!.completedAt!).toLocaleDateString() : 'recently'; })(); // REJOIN LINE (design §2): when the group's next joint date is known, the // note carries the PLAN, not just the exclusion. Drift is a detour, not // an exit — Bekky's doctrine, now user-visible. const rejoin = groupNextDate ? ` · rejoins the group ${fmtShort(groupNextDate)}` : ''; skipped[pid] = `skip this round — last watered ${date}${rejoin}`; } return skipped; }; // Open a cohort plant's rich check-in modal (Bekky, 2026-08-29): find the // care task and route it through the existing check-in intercept. const openCohortPlantCheckIn = (rawPlantId: string, action: GroupableCareAction) => { const task = findTaskForCohortPlant(rawPlantId, action); if (task && onTaskCheckInRequest) onTaskCheckInRequest(task); }; // UPCOMING COLLAPSE GROUPING (Bekky, 2026-08-20 polish): the Upcoming section // no longer lists every plant exhaustively. It groups so it's scannable: // - By space (default): each space in its green box; WITHIN the space, tasks // group by action + due day. Spaces are ordered by their SOONEST task // (a room with something due tomorrow sorts before a room whose soonest // is in 3 days). // - By action: group by action, then by due day. Each group expands to // reveal the plants + their space tag. // Each group row is a collapsible dropdown — tap to reveal the plants. No // bulk "complete all" here (that's Due Today); and the check-in modal only // fires when the user completes, which the underlying JournalUpcomingRow // already gates to tasks due within 3 days (canCompleteCareTaskEarly). const [upcomingExpanded, setUpcomingExpanded] = useState>(new Set()); const [upcomingFilter, setUpcomingFilter] = useState<'space' | 'action'>('space'); const toggleUpcoming = (key: string) => setUpcomingExpanded(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); const upcomingTasks = [...upcomingTomorrow, ...upcomingWeek, ...upcomingLater]; const actionOrder = ['Water', 'Fertilize', 'Cases', 'Inspect']; const actionRankOf = (action: string) => { const i = actionOrder.indexOf(action); return i === -1 ? actionOrder.length : i; }; // By-space: spaces ordered by soonest task; within each, group by DUE DAY // (not action — a space sorted by space is read by time). Each plant keeps // its own action emoji; a plant with 2 actions on the same day is grouped // together (Bekky, 2026-08-22). const upcomingBySpace = (() => { const bySpace = new Map(); for (const t of upcomingTasks) { const key = t.spaceId || 'no-space'; const arr = bySpace.get(key) || []; arr.push(t); bySpace.set(key, arr); } const spaces: { spaceKey: string; spaceName: string; soonest: string; groups: { key: string; dueLabel: string; dueDate: string | null; tasks: CareTaskItem[] }[]; }[] = []; for (const [spaceKey, tasks] of bySpace) { // within the space: group by DUE DAY only. const groups = new Map(); for (const t of tasks) { const gk = t.dueDate || t.id; const arr = groups.get(gk) || []; arr.push(t); groups.set(gk, arr); } const groupArr = Array.from(groups.entries()) .map(([key, gTasks]) => ({ key, dueLabel: gTasks[0].dueLabel || 'Due soon', dueDate: gTasks[0].dueDate || null, tasks: gTasks.sort(byDueDate), })) .sort((a, b) => (a.dueDate || '9999').localeCompare(b.dueDate || '9999')); // soonest = earliest due across this space's groups const soonest = groupArr.reduce((m, g) => (g.dueDate && g.dueDate < m ? g.dueDate : m), '9999'); spaces.push({ spaceKey, spaceName: tasks[0].spaceName || 'No space', soonest, groups: groupArr }); } // spaces ordered by soonest task first return spaces.sort((a, b) => a.soonest.localeCompare(b.soonest)); })(); // ByAction: group by action, then by due day; each reveals plants + space tag. const upcomingByAction = (() => { const byAction = new Map(); for (const t of upcomingTasks) { const arr = byAction.get(t.type) || []; arr.push(t); byAction.set(t.type, arr); } return Array.from(byAction.entries()) .map(([action, tasks]) => { const groups = new Map(); for (const t of tasks) { const key = t.dueDate || t.id; const arr = groups.get(key) || []; arr.push(t); groups.set(key, arr); } return { action, groups: Array.from(groups.entries()) .map(([key, gTasks]) => ({ key, dueLabel: gTasks[0].dueLabel || 'Due soon', dueDate: gTasks[0].dueDate || null, tasks: gTasks.sort(byDueDate), })) .sort((a, b) => (a.dueDate || '9999').localeCompare(b.dueDate || '9999')), }; }) .sort((a, b) => actionRankOf(a.action) - actionRankOf(b.action)); })(); const showCareFeedbackHandler = (feedback: CareFeedbackState) => { setCareFeedback(feedback); if (careFeedbackTimer.current) clearTimeout(careFeedbackTimer.current); // Don't start the countdown if the check-in modal is already open — it // would hide the banner behind the modal (Bekky, 2026-08-18). if (!checkInOpen) { careFeedbackTimer.current = setTimeout(() => setCareFeedback(null), 4200); } }; // Pause the undo banner's auto-dismiss while the Quick Check-In modal is open, // and restart the countdown once it closes — so the banner is still there when // the user dismisses the modal (Bekky, 2026-08-18). useEffect(() => { if (checkInOpen) { if (careFeedbackTimer.current) clearTimeout(careFeedbackTimer.current); careFeedbackTimer.current = null; } else if (careFeedback) { if (careFeedbackTimer.current) clearTimeout(careFeedbackTimer.current); careFeedbackTimer.current = setTimeout(() => setCareFeedback(null), 4200); } }, [checkInOpen]); const restoreCareTaskState = (taskId: string, previousState?: CareTaskActionState) => { const next = { ...careTaskStates }; if (previousState) next[taskId] = previousState; else delete next[taskId]; saveCareTaskActionStates(next).catch(() => undefined); onCareTaskStatesChange(next); // Undo: remove from the session-completed set so the task shows as pending // again (Bekky, 2026-08-12). setSessionCompletedIds(prev => { const s = new Set(prev); s.delete(taskId); return s; }); setCareFeedback(null); }; const setCareTaskStatus = ( task: CareTaskItem, status: Exclude, options?: { feedback?: string; persist?: boolean } ) => { const previousState = careTaskStates[task.id]; const action = createCareTaskActionState(task, status); const next = { ...careTaskStates, [task.id]: action }; onCareTaskStatesChange(next); // Completion PERSISTS across app restarts (Bekky, 2026-08-12): the checkmark // + line-through stay until the task is genuinely done. We save to storage. saveCareTaskActionStates(next).catch(() => undefined); showCareFeedbackHandler({ message: options?.feedback || `${task.type} ${task.plantName} ${status}.`, undo: () => { restoreCareTaskState(task.id, previousState); // LASTDONE UNIFICATION (Bekky, 2026-08-30): undo also rolls back the // careLog entry + lastDone anchor this completion wrote (App-side). if (status === 'completed') onRevertCareLogCompletion?.(task); }, }); }; const completeCareTask = (task: CareTaskItem) => { // CONFIRM-THE-ACTION intercept (Bekky, 2026-08-20): for schedule-based // Water and Fertilize tasks, tapping Complete opens the CareCheckInModal // ("Is it too dry? Did it just rain?"). The user confirms it was actually // needed (Complete → strikethrough + undo banner) or postpones (re-schedule). // Diagnosis/Recheck and Inspect tasks complete directly. const interceptable = onTaskCheckInRequest && task.source === 'care_schedule' && (task.type === 'Water' || task.type === 'Fertilize'); if (interceptable) { onTaskCheckInRequest(task); return; } // CASE-TREATMENT intercept (Bekky, 2026-09-02): a diagnosed Treat task opens // the case-treatment multi-select modal (record what was used, skip steps, // mark outcomes) instead of completing directly. Recheck tasks keep their // existing check-in behavior and are never routed here. if (onOpenCaseTreatment && task.source === 'diagnosis' && task.type === 'Treat') { onOpenCaseTreatment(task); return; } // Direct completion (non-intercepted tasks): mark done, undo banner, and // the handwritten strike-through + pencil check persist until the app is // closed and reopened. setSessionCompletedIds(prev => new Set(prev).add(task.id)); setCareTaskStatus(task, 'completed', { feedback: `${task.type} ${task.plantName} completed.` }); }; // "Water all" / "Fertilize all" at the SPACE level (Bekky, 2026-08-22): an // outdoor gardener watered 20 plants on the balcony in one pass — making // them tap each individually would be tedious and bossy. Completes every // task of that action in the space directly (no per-plant modal — they // didn't check each plant closely anyway). A single undo banner covers the // whole batch. const completeAllInSpace = (spaceId: string, action: string) => { const tasks = dueToday.filter(t => (t.spaceId || 'no-space') === spaceId && t.type === action); const previousStates = Object.fromEntries(tasks.map(t => [t.id, careTaskStates[t.id]])); const next = { ...careTaskStates }; for (const task of tasks) next[task.id] = createCareTaskActionState(task, 'completed'); onCareTaskStatesChange(next); saveCareTaskActionStates(next).catch(() => undefined); setSessionCompletedIds(prev => new Set([...prev, ...tasks.map(t => t.id)])); // LASTDONE UNIFICATION + MANUAL SWEEP (Bekky, 2026-08-30): a space sweep is // a MANUAL pass — the user didn't inspect each pot. Log 'manual' entries + // write each member's anchor (today; the sweep may complete tasks that were // already touched individually — dedupe is last-write-wins, harmless). // Fine print (Bekky): the banner says this was a manual pass, not AI care. onSweepAnchors?.({ spaceId, action: action === 'Water' ? 'water' : 'fertilize', plantIds: tasks.map(t => t.plantId.startsWith(SAVED_PREFIX) ? t.plantId.slice(SAVED_PREFIX.length) : t.plantId), }); showCareFeedbackHandler({ message: `${tasks.length} ${action.toLowerCase()} task${tasks.length === 1 ? '' : 's'} completed — manual pass, not AI-checked.`, undo: () => { const restored = { ...careTaskStates }; for (const task of tasks) { if (previousStates[task.id]) restored[task.id] = previousStates[task.id]; else delete restored[task.id]; } saveCareTaskActionStates(restored).catch(() => undefined); onCareTaskStatesChange(restored); setSessionCompletedIds(prev => { const s = new Set(prev); tasks.forEach(t => s.delete(t.id)); return s; }); setCareFeedback(null); }, }); }; const resetCareValidationState = () => { onDebugReset().then(() => { setCareFeedback(null); haptic.success(); }).catch(() => haptic.warning()); }; return ( {/* Care header in normal flow on top (correct position, uniform with other tabs). The journal fills the space below it. */}
{/* Undo banner — rendered once at the top so it appears whether the completed task was Due Today or Upcoming (Bekky, 2026-08-18). Shows the external (App-driven, from check-in modal completion) banner when present, else the local one (Bekky, 2026-08-20). */} {externalFeedback ? ( ) : careFeedback ? : null} {/* ===== Weather warnings (Bekky, 2026-08-23) ===== */} {weatherWarnings.length > 0 ? ( {/* GROUP same-alert spaces into ONE card (Bekky, 2026-09-07): when the same weather (same trigger + date) hits multiple spaces, show ONE card listing the space names instead of N near-identical cards. Grouped by trigger+date; severity = the highest in the group; space names deduped + ordered as they appear. */} {Array.from( weatherWarnings.reduce((groups, w) => { const key = `${w.trigger}:${w.date || ''}`; const g = groups.get(key) || { warnings: [] as WeatherWarning[] }; g.warnings.push(w); groups.set(key, g); return groups; }, new Map()).values() ).map(({ warnings }, gi) => { const severity = warnings.some(w => w.severity === 'alert') ? 'alert' : warnings.some(w => w.severity === 'warning') ? 'warning' : 'info'; const spaceNames: string[] = []; const seen = new Set(); for (const w of warnings) { if (w.spaceName && !seen.has(w.spaceName)) { seen.add(w.spaceName); spaceNames.push(w.spaceName); } } const first = warnings[0]; const msg = first.advice || first.message; return ( {first.title} {/* Space names so it's clear WHICH spaces need sheltering (Bekky, 2026-09-07). Multiple spaces → one card, names listed. */} {spaceNames.length ? {spaceNames.join(' & ')} : null} {/* No per-plant list (Bekky, 2026-09-07): just the space name + the instruction. Listing every plant in the space is noise. */} {msg} ); })} ) : null} {/* ===== Due Today ===== */} {dueTodayGroups.length ? dueTodayGroups.map((space, gi) => { const { cohortRows, caseRows, standalone } = splitDueTodayByCohort(space); const spaceKey = space.spaceId || `no-space-${gi}`; return ( {gi > 0 ? : null} {/* Space title rendered once (Bekky, 2026-08-29): cohort rows + standalone plants live under the same space header. */} {space.spaceName} {/* CASE-TREATMENT headline rows (Bekky, 2026-09-02): one tappable headline per case — "🩺 Osmanthus · 2 treatments due today" — NO chevron. Tapping it opens the case-treatment multi-select modal. A single-step case reads "1 treatment due today". */} {caseRows.map(row => { const count = row.tasks.length; return ( onOpenCaseTreatment?.(row.tasks[0]), haptic.success)} accessibilityRole="button" accessibilityLabel={`${row.plantName}, ${count} treatment${count === 1 ? '' : 's'} due today`} > 🩺 {row.plantName} {count} {count === 1 ? 'treatment' : 'treatments'} due today ); })} {/* Cohort rows first — grouped plants render as sweepable units. Only cohorts with ACTUAL due tasks render here (Bekky, 2026-09-01, bug 3): a cohort with no due task this round is NOT due today — it belongs in Upcoming, not here with a fake "Due today" label and an empty, non-actionable row. */ } {cohortRows.filter(row => row.tasks.length > 0).map(row => { const ck = `${spaceKey}:${row.cohort.id}:${row.action}`; const actionable = isActionableRow(row); // Per-plant AI-reasoning deltas for this cohort's members. const deltas: Record = {}; // Plant photos for the members modal tiles (Bekky, 2026-09-01): // same photo the garden page tiles show, keyed by raw plantId. const photos: Record = {}; for (const pid of row.plantIds) { const meta = cohortPlantMeta?.[pid]; if (meta?.deltas?.[row.action]) deltas[pid] = meta.deltas[row.action] as string; if (meta?.photoUri) photos[pid] = meta.photoUri; } return ( toggleCohort(ck)} onPlantPress={(rawId) => openCohortPlantCheckIn(rawId, row.action)} onPlantDetailPress={(rawId) => onOpenPlantDetail?.(rawId)} onWetDryFeedback={onCohortFeedback ? (signal) => onCohortFeedback({ spaceId: space.spaceId, action: row.action, signal }) : undefined} plantDeltas={deltas} plantPhotos={photos} onCompleteAll={onCompleteCohort ? () => onCompleteCohort({ spaceId: space.spaceId, action: row.action, plantIds: row.plantIds }) : undefined} actionable={actionable} soilCanDry={soilCanDry()} onSoilCheckComplete={onSoilCheckComplete ? (ids, stateAtComplete) => onSoilCheckComplete({ spaceId: space.spaceId, action: row.action, plantIds: ids, stateAtComplete }) : undefined} onSoilCheckPostpone={onSoilCheckPostpone ? (ids, stateAtComplete, postponeDays) => onSoilCheckPostpone({ spaceId: space.spaceId, action: row.action, plantIds: ids, stateAtComplete, postponeDays }) : undefined} skippedMembers={actionable ? buildSkippedMembers({ cohort: row.cohort, action: row.action, plantIds: row.plantIds, tasks: row.tasks }) : undefined} /> ); })} {/* Standalone plants (not in a cohort) — individual rows, as before. */} {standalone.length ? ( toggleGroup(spaceKey)} expandedPlants={expandedPlants} onTogglePlant={togglePlant} onComplete={completeCareTask} onCompleteAll={action => completeAllInSpace(space.spaceId || 'no-space', action)} onViewCase={onTaskViewCase} hideTitle /> ) : null} ); }) : } {/* ===== Upcoming ===== */} {/* Title + filter on the SAME row (Bekky, 2026-08-20): the filter pills sit right-aligned beside the "Upcoming" title, styled like the View Care / View Cases buttons — fine Caveat handwritten font + a fine delicate box outline, NOT the traditional pill style. */} Upcoming {(['space', 'action'] as const).map(mode => ( setUpcomingFilter(mode))} > {mode === 'space' ? 'By space' : 'By action'} ))} {upcomingTasks.length === 0 ? ( ) : upcomingFilter === 'space' ? ( upcomingBySpace.map(space => { // Split this space's upcoming tasks into cohort rows + standalone. const { cohortRows, standalone } = splitUpcomingByCohort(space.spaceKey, space.groups.flatMap(g => g.tasks)); // Re-group the standalone tasks by due day (as before). const standaloneByDay = new Map(); for (const t of standalone) { const gk = t.dueDate || t.id; const arr = standaloneByDay.get(gk) || []; arr.push(t); standaloneByDay.set(gk, arr); } const standaloneGroups = Array.from(standaloneByDay.entries()) .map(([key, tasks]) => ({ key, dueLabel: tasks[0].dueLabel || 'Due soon', dueDate: tasks[0].dueDate || null, tasks: tasks.sort(byDueDate), })) .sort((a, b) => (a.dueDate || '9999').localeCompare(b.dueDate || '9999')); return ( {/* Unassigned plants get NO space header (Bekky, 2026-08-20). */} {space.spaceKey !== 'no-space' ? ( {space.spaceName} ) : null} {/* Cohort rows first — grouped plants render as sweepable units. Sorted chronologically; groups with no real due date are dropped (Bekky, 2026-08-29: a group only exists when its members share an actual task deadline — no "Due soon" mystery). */} {cohortRows .filter(row => row.dueDate && row.plantIds.length > 0) .sort((a, b) => (a.dueDate || '9999').localeCompare(b.dueDate || '9999')) .map(row => { const ck = `upcoming:${space.spaceKey}:${row.cohort.id}:${row.action}`; const actionable = isActionableRow(row); // Per-plant AI-reasoning deltas for this cohort's members. const deltas: Record = {}; // Plant photos for the members modal tiles (Bekky, 2026-09-01). const photos: Record = {}; for (const pid of row.plantIds) { const meta = cohortPlantMeta?.[pid]; if (meta?.deltas?.[row.action]) deltas[pid] = meta.deltas[row.action] as string; if (meta?.photoUri) photos[pid] = meta.photoUri; } return ( toggleCohort(ck)} onPlantPress={(rawId) => openCohortPlantCheckIn(rawId, row.action)} onPlantDetailPress={(rawId) => onOpenPlantDetail?.(rawId)} onWetDryFeedback={onCohortFeedback ? (signal) => onCohortFeedback({ spaceId: space.spaceKey, action: row.action, signal }) : undefined} plantDeltas={deltas} plantPhotos={photos} onCompleteAll={onCompleteCohort ? () => onCompleteCohort({ spaceId: space.spaceKey, action: row.action, plantIds: row.plantIds }) : undefined} actionable={actionable} soilCanDry={soilCanDry()} onSoilCheckComplete={onSoilCheckComplete ? (ids, stateAtComplete) => onSoilCheckComplete({ spaceId: space.spaceKey, action: row.action, plantIds: ids, stateAtComplete }) : undefined} onSoilCheckPostpone={onSoilCheckPostpone ? (ids, stateAtComplete, postponeDays) => onSoilCheckPostpone({ spaceId: space.spaceKey, action: row.action, plantIds: ids, stateAtComplete, postponeDays }) : undefined} skippedMembers={actionable ? buildSkippedMembers({ cohort: row.cohort, action: row.action, plantIds: row.plantIds, tasks: row.tasks }) : undefined} /> ); })} {/* Standalone plants (not in a cohort) — day-grouped, as before. */} {standaloneGroups.map(group => { const gk = `space:${space.spaceKey}:${group.key}`; const open = upcomingExpanded.has(gk); return ( toggleUpcoming(gk)} tasks={group.tasks} actionStates={careTaskStates} onComplete={completeCareTask} onViewCase={onTaskViewCase} /> ); })} ); }) ) : ( upcomingByAction.map(actionGroup => ( {/* Action section header owns the action word (Bekky, 2026-08-22): rows below only show the plant name + space, never repeat it. */} {actionGroup.action === 'Water' ? '💧' : actionGroup.action === 'Fertilize' ? '🌱' : '🩺'} {actionGroup.action} {actionGroup.groups.map(group => { const gk = `action:${actionGroup.action}:${group.key}`; const open = upcomingExpanded.has(gk); return ( toggleUpcoming(gk)} tasks={group.tasks} actionStates={careTaskStates} onComplete={completeCareTask} onViewCase={onTaskViewCase} /> ); })} )) )} {/* ===== Recently Completed ===== REMOVED (Bekky, 2026-08-12): completed tasks now STAY in their timing group with the check + pencil line-through, so there is no separate "Recently Completed" section. */} {showCareValidationReset ? ( Reset care validation state ) : null} ); } const s = StyleSheet.create({ // SPACE-LEVEL COMPLETE-ALL (Bekky, 2026-08-30): compact action buttons under // each space's Due Today section. spaceCompleteAllBtn: { paddingVertical: 8, paddingHorizontal: 14, borderRadius: 999, borderWidth: 1.5, borderColor: '#2E7DB8', backgroundColor: '#EAF4FC', alignSelf: 'flex-start', }, spaceCompleteAllBtnText: { color: '#2E7DB8', fontSize: 14, fontWeight: '800' }, screen: { flex: 1, backgroundColor: 'transparent', }, headerWrap: { paddingHorizontal: 18, // match the other tabs' Screen content padding so the // gear/bell sit inset like every other tab paddingTop: 8, // match the other tabs' content paddingTop so the header sits // at the same height as every other tab position: 'relative', // contain the absolutely-positioned action bar so the // gear/bell respect the padding and sit inset like every other tab zIndex: 10, // keep the header above the journal's sprig that pokes up behind it }, debugResetButton: { minHeight: 44, borderRadius: 14, borderWidth: 1, borderColor: '#FF9800', alignItems: 'center', justifyContent: 'center', marginTop: 12 }, debugResetText: { color: '#FF9800', fontSize: 13, fontWeight: '700' }, // Upcoming header row — title + filter on the same line (Bekky, 2026-08-20) upcomingHeaderRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 6, marginBottom: 4, }, upcomingTitle: { color: '#2E6B3F', fontSize: 24, fontFamily: 'Alegreya', }, // Filter pills — styled like the View Care / View Cases buttons (Bekky, // 2026-08-20): fine Caveat handwritten font + a fine delicate box outline, // NOT the traditional pill style used elsewhere in the app. filterRow: { flexDirection: 'row', gap: 6, }, filterPill: { minHeight: 20, borderRadius: 4, borderWidth: 1, borderColor: 'rgba(85,116,94,0.4)', backgroundColor: 'rgba(253,247,235,0.6)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 7, }, filterPillActive: { backgroundColor: 'rgba(46,107,63,0.88)', borderColor: 'rgba(46,107,63,0.88)', }, filterPillText: { color: '#2E4B35', fontSize: 13, fontFamily: 'Caveat', }, filterPillTextActive: { color: '#fff', }, // Space/action group box — the small green bounding box (Bekky, 2026-08-20) spaceBox: { borderWidth: 1.6, borderColor: 'rgba(85,116,94,0.55)', borderRadius: 12, backgroundColor: 'rgba(253,247,235,0.3)', paddingHorizontal: 10, paddingVertical: 6, marginBottom: 8, }, spaceBoxTitle: { color: '#2E6B3F', fontSize: 18, fontFamily: 'Caveat', marginBottom: 2, }, // CASE-TREATMENT headline row (Bekky, 2026-09-02): one tappable headline per // case in Due Today — stethoscope icon + plant name + "N treatments due today", // NO chevron. Matches the journal row visual language (Caveat, parchment). caseRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 8, paddingHorizontal: 2, borderTopWidth: 1, borderColor: 'rgba(85,116,94,0.25)', }, caseRowIcon: { fontSize: 20, width: 26, textAlign: 'center', }, caseRowCopy: { flex: 1, minWidth: 0, }, caseRowTitle: { color: '#2E4B35', fontSize: 21, fontFamily: 'Caveat', lineHeight: 25, }, caseRowSub: { color: '#8A7B55', fontSize: 14, fontFamily: 'Caveat', lineHeight: 18, }, // Faint divider between spaces in Due Today when expanded (Bekky, 2026-08-20). spaceDivider: { height: 1, backgroundColor: 'rgba(85,116,94,0.18)', marginVertical: 8, }, // Weather warnings (Bekky, 2026-08-23) — real-time frost/heat/storm/heavy-rain // alerts at the top of the Care journal, above Due Today. weatherWarnings: { marginBottom: 10, gap: 8, }, weatherWarning: { borderRadius: 12, borderWidth: 1, paddingHorizontal: 12, paddingVertical: 10, }, weatherWarningAlert: { backgroundColor: '#FCE9E6', borderColor: '#B85C4A', }, weatherWarningWarn: { backgroundColor: '#FFF3E0', borderColor: '#E0A33A', }, weatherWarningInfo: { backgroundColor: '#E8F0E4', borderColor: 'rgba(85,116,94,0.55)', }, weatherWarningTitle: { color: '#2E6B3F', fontSize: 24, fontFamily: 'Alegreya', // NO fontWeight — Alegreya ships a single weight; adding fontWeight makes // React Native fall back to the system sans-serif (same trap as // JournalSectionTitle.sectionTitle in CareComponents.tsx). Let Alegreya's // own weight render so the storm header matches the "Due Today" / "Upcoming" // header font (Bekky, 2026-09-07). marginBottom: 4, }, weatherWarningSpace: { color: '#B85C4A', fontSize: 13, fontWeight: '700', marginBottom: 4, }, weatherWarningMsg: { color: '#5D5A4E', fontSize: 13, lineHeight: 18, marginTop: 6, }, });