import { useCallback, useEffect, useRef, useState } from 'react'; import { deleteField } from 'firebase/firestore'; import { Plus, X } from 'lucide-react'; import type { ChecklistItem, Particle } from '@/api/types'; import { particlePath, parseParticlePath, toFirestoreDocPath, type ParticlePath, } from '@/lib/particle-path'; import { updateParticle, updateParticleProperties, } from '@/lib/firestore-particles'; import { cn } from '@/lib/utils'; import { Checkbox } from '@/components/ui/checkbox'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { HumanAvatar } from '@/components/human-avatar'; import { useNetwork } from '@/hooks/use-networks'; import { useFixedDwell } from '@/hooks/use-fixed-dwell'; import { useLiveDraftField } from '@/hooks/use-live-draft-field'; import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { resolveHumanDisplay } from '@/lib/humans'; type TaskParticle = Extract; interface TaskParticleViewProps { particle: TaskParticle; containerPath: ParticlePath; paused: boolean; onEnded: () => void; onProgress?: (ratio: number) => void; } const DWELL_DURATION_S = 8; const UNASSIGNED = 'unassigned'; function useParticleDocPath( containerPath: ParticlePath, particleId: string, ): string { const { networkId, segments } = parseParticlePath(containerPath); return toFirestoreDocPath(particlePath(networkId, [...segments, particleId])); } export function TaskParticleView({ particle, containerPath, paused, onEnded, onProgress, }: TaskParticleViewProps) { const { networkId } = parseParticlePath(containerPath); const network = useNetwork(networkId); const docPath = useParticleDocPath(containerPath, particle.id); const { title, notes, checklist = [], assigned_to, done, } = particle.properties; // Suspend playback while any field inside the card has focus so typing // doesn't race the dwell timer or get eaten by global key handlers. const [editing, setEditing] = useState(false); useSuspendPlayback(editing, `task-edit-${particle.id}`); useFixedDwell({ id: particle.id, durationS: DWELL_DURATION_S, paused, onEnded, onProgress, }); const titleField = useLiveDraftField({ remoteValue: title, commit: (value) => updateParticleProperties<'task'>(docPath, { title: value }), }); const notesField = useLiveDraftField({ remoteValue: notes ?? '', commit: (value) => updateParticleProperties<'task'>(docPath, { notes: value }), }); // Checklist writes replace the whole array (merged against the latest live // value); concurrent edits to the same checklist are last-write-wins. const checklistRef = useRef(checklist); useEffect(() => { checklistRef.current = checklist; }, [checklist]); const writeChecklist = useCallback( (items: ChecklistItem[]) => updateParticleProperties<'task'>(docPath, { checklist: items }), [docPath], ); const handleToggleDone = useCallback( (checked: boolean) => updateParticleProperties<'task'>(docPath, { done: checked }), [docPath], ); const handleToggleItem = useCallback( (index: number, checked: boolean) => { const items = checklistRef.current.map((item, i) => i === index ? { ...item, done: checked } : item, ); void writeChecklist(items); }, [writeChecklist], ); const handleCommitItemText = useCallback( (index: number, text: string) => { const items = checklistRef.current.map((item, i) => i === index ? { ...item, text } : item, ); void writeChecklist(items); }, [writeChecklist], ); const handleRemoveItem = useCallback( (index: number) => { void writeChecklist(checklistRef.current.filter((_, i) => i !== index)); }, [writeChecklist], ); const handleAddItem = useCallback( (text: string) => { void writeChecklist([...checklistRef.current, { text, done: false }]); }, [writeChecklist], ); const handleAssign = useCallback( (value: string) => { if (value === UNASSIGNED) { void updateParticle(docPath, 'properties.assigned_to', deleteField()); } else { void updateParticleProperties<'task'>(docPath, { assigned_to: value }); } }, [docPath], ); const assignee = resolveHumanDisplay(assigned_to, network?.humans); const doneCount = checklist.filter((item) => item.done).length; return (
setEditing(true)} onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false); }} >
handleToggleDone(checked === true)} className="mt-1.5 size-5 rounded-full border-white/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500" aria-label={done ? 'Mark task as not done' : 'Mark task as done'} /> titleField.onChange(e.target.value)} onFocus={titleField.onFocus} onBlur={titleField.onBlur} placeholder="Task title" className={cn( 'w-full bg-transparent text-2xl font-semibold text-white outline-none placeholder:text-white/30', done && 'text-white/50 line-through', )} />