import { useCallback, useEffect, useRef, useState } from 'react' import type { SaveStatus } from '../../hooks/useAutoSave' import { BREAKS, ENCOURAGEMENTS, GREETING, MILESTONES, TIPS, WELCOME_BACK, milestoneLine, pick, type Line, } from './tips' // The kitten's expression. Maps to a Lottie animation when assets are present, // otherwise to an emoji placeholder (see PetalCompanion). export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate' export type BubbleTone = 'cheer' | 'tip' | 'break' export interface Bubble extends Line { tone: BubbleTone } interface Signals { wordCount: number saveStatus: SaveStatus // Monotonic counters bumped by the app on each editor change / accepted // suggestion — lets the companion react without a full event bus. editTick: number acceptTick: number } // Timing knobs (ms). Tuned to feel present but never naggy. const IDLE_MS = 75_000 // no edits → kitten naps const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles const BUBBLE_MS = 6_500 // how long a bubble lingers const CHEER_MS = 4_500 // shorter for quick cheers const now = () => Date.now() // useCompanion is the behavior engine: it watches writing signals and decides // when the kitten speaks, what mood it shows, and how to pace itself so the // companion feels alive without interrupting. UI-agnostic — returns state only. export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Signals) { const [mood, setMood] = useState('idle') const [bubble, setBubble] = useState(null) const lastActivity = useRef(now()) const sessionStart = useRef(now()) const lastProactive = useRef(0) const lastTip = useRef(0) const lastBreak = useRef(0) const nextMilestone = useRef(0) // index into MILESTONES const sleeping = useRef(false) const bubbleTimer = useRef>(undefined) const moodTimer = useRef>(undefined) // Show a bubble + talking mood, then settle back. `proactive` messages respect // the spacing floor; user-triggered ones (cheers) always go through. const say = useCallback((b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean }) => { const t = now() if (opts?.proactive) { if (bubble) return if (t - lastProactive.current < PROACTIVE_GAP) return lastProactive.current = t } sleeping.current = false setBubble(b) setMood(opts?.celebrate ? 'celebrate' : 'talking') clearTimeout(bubbleTimer.current) clearTimeout(moodTimer.current) const dur = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS bubbleTimer.current = setTimeout(() => setBubble(null), dur) moodTimer.current = setTimeout(() => setMood('idle'), dur) }, [bubble]) const dismiss = useCallback(() => { clearTimeout(bubbleTimer.current) clearTimeout(moodTimer.current) setBubble(null) setMood('idle') }, []) // Opening hello (once), after a short beat so it doesn't race the first paint. useEffect(() => { const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200) return () => clearTimeout(id) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Edits: record activity and wake from a nap with a warm welcome-back. const firstEdit = useRef(true) useEffect(() => { if (firstEdit.current) { firstEdit.current = false return } lastActivity.current = now() if (sleeping.current) { sleeping.current = false sessionStart.current = now() // a fresh stretch starts on return say({ ...WELCOME_BACK, tone: 'cheer' }) } else if (mood === 'sleeping') { setMood('idle') } // eslint-disable-next-line react-hooks/exhaustive-deps }, [editTick]) // Accepts: always cheer (it's a direct response to her action). const firstAccept = useRef(true) useEffect(() => { if (firstAccept.current) { firstAccept.current = false return } say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { celebrate: true }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [acceptTick]) // Word-count milestones: cheer the first time each threshold is crossed. useEffect(() => { while ( nextMilestone.current < MILESTONES.length && wordCount >= MILESTONES[nextMilestone.current] ) { const n = MILESTONES[nextMilestone.current] nextMilestone.current += 1 // Skip silently if we're just loading a long doc (no edits yet). if (!firstEdit.current) say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [wordCount]) // Occasional gentle cheer on a successful save (kept rare so it isn't noise). useEffect(() => { if (saveStatus === 'saved' && Math.random() < 0.18) { say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { proactive: true }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [saveStatus]) // Heartbeat: nap when idle, suggest breaks, and drop the odd writing tip. useEffect(() => { const id = setInterval(() => { const t = now() const idleFor = t - lastActivity.current if (idleFor > IDLE_MS) { sleeping.current = true if (!bubble) setMood('sleeping') return // resting — no nudges while she's away } // Active: time for a break? if (t - sessionStart.current > BREAK_MS && t - lastBreak.current > BREAK_MS) { lastBreak.current = t sessionStart.current = t say({ ...pick(BREAKS), tone: 'break' }, { proactive: true }) return } // Otherwise an occasional tip. if (t - lastTip.current > TIP_MIN_GAP) { lastTip.current = t say({ ...pick(TIPS), tone: 'tip' }, { proactive: true }) } }, 10_000) return () => clearInterval(id) }, [bubble, say]) useEffect( () => () => { clearTimeout(bubbleTimer.current) clearTimeout(moodTimer.current) }, [], ) return { mood, bubble, dismiss } }