From a133b271d02ab340e514a60e30209719804ec2de Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Mon, 20 Jul 2026 01:54:39 -0400 Subject: [PATCH] ability to parse user and label --- src/views/components/AddTaskModal.jsx | 94 +++++- .../components/VoiceToTask/VoicePanel.jsx | 296 +++++++++++++++--- .../components/VoiceToTask/parseVoiceTask.js | 1 + .../components/VoiceToTask/useVoiceToTask.js | 24 ++ .../components/VoiceToTask/voiceNormalizer.js | 80 ++++- 5 files changed, 436 insertions(+), 59 deletions(-) diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index bda067c..63ec27f 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -1,6 +1,7 @@ import { Add } from '@mui/icons-material' import { Box, Button, Typography } from '@mui/joy' import { useMediaQuery } from '@mui/material' +import { useQueryClient } from '@tanstack/react-query' import * as chrono from 'chrono-node' import moment from 'moment' import { useCallback, useEffect, useRef, useState } from 'react' @@ -25,6 +26,7 @@ import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import { useDocumentScanner } from '../../hooks/useDocumentScanner' import { localAIService } from '../../service/LocalAIService' import { voiceInputService } from '../../service/VoiceInputService' +import { CreateLabel } from '../../utils/Fetcher' import { TASK_COLOR } from '../../utils/Colors' import AdvancedOptionsSection, { AdvancedOptionsTrigger, @@ -69,6 +71,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { useCircleMembers() const { isLoading: isProjectsLoading } = useProjects() const createChoreMutation = useCreateChore() + const queryClient = useQueryClient() const { data: userProfile } = useUserProfile() @@ -92,6 +95,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { const richTextEditorRef = useRef(null) const latestRef = useRef({}) + // Picker edits made on a voice task card, applied once after the reparse + // that follows landing the spoken text in the smart input + const pendingVoiceOverridesRef = useRef(null) const [priority, setPriority] = useState(0) const [dueDate, setDueDate] = useState(null) const [description, setDescription] = useState(null) @@ -492,6 +498,28 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { ) setRenderedParts(parts) + + const overrides = pendingVoiceOverridesRef.current + if (overrides) { + pendingVoiceOverridesRef.current = null + if ('priority' in overrides) setPriority(overrides.priority || 0) + if ('frequency' in overrides) setFrequency(overrides.frequency) + if ('labelIds' in overrides) setLabelsV2(overrides.labelIds || []) + if ('assignees' in overrides || 'isAnyone' in overrides) { + setIsAnyoneTask(!!overrides.isAnyone) + setAssignees(overrides.assignees || []) + } + if ('dueDate' in overrides) { + if (overrides.dueDate) { + syncDueDateStates(overrides.dueDate) + } else { + setDueDate(null) + setDueDateOnly(null) + setDueTime(null) + setUseCustomTime(false) + } + } + } }, [userLabels, renderHighlightedSentence, circleMembers, userProfile], ) @@ -595,23 +623,73 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { } } + // Creates labels that were spoken but don't exist yet. Returns a Map of + // lowercase name → label id covering both created and already-existing ones. + const createMissingLabels = async newLabels => { + const resolved = new Map( + (userLabels || []).map(l => [l.name.toLowerCase(), l.id]), + ) + let createdAny = false + for (const label of newLabels) { + const key = label.name.toLowerCase() + if (resolved.has(key)) continue + try { + const resp = await CreateLabel({ + name: label.name, + color: label.color || '#3b82f6', + }) + const data = await resp.json() + const created = data?.res ?? data + if (created?.id) { + resolved.set(key, created.id) + createdAny = true + } + } catch (error) { + console.error('Error creating label:', error) + } + } + if (createdAny) { + queryClient.invalidateQueries({ queryKey: ['labels'] }) + } + return resolved + } + // Single voice-captured task: land it in the smart input so the user - // reviews it with the normal pickers before creating. - const handleVoiceSingle = text => { + // reviews it with the normal pickers before creating. Setting taskText + // (rather than calling processText directly) lets the reparse effect run + // exactly once, consuming any picker overrides from the panel. + const handleVoiceSingle = async (text, overrides = {}) => { setShowVoice(false) - processText(text) + if (Object.keys(overrides).length > 0) { + pendingVoiceOverridesRef.current = overrides + } + setTaskText(text) + const labels = parseLabels(text, userLabels || []) + if (labels.newLabels?.length) { + // Once the labels query refetches, the reparse links them automatically + await createMissingLabels(labels.newLabels) + } } // Multiple voice-captured tasks: they were reviewed as cards in the panel, // so create them all directly. const handleVoiceCreateMany = async parsedTasks => { const notificationTemplates = getDefaultNotification() + const allNewLabels = parsedTasks.flatMap(t => t.newLabels || []) + const labelIdsByName = + allNewLabels.length > 0 ? await createMissingLabels(allNewLabels) : null for (const parsed of parsedTasks) { - const chore = buildChorePayload(parsed, { - userProfile, - projectId, - notificationTemplates, - }) + const extraLabelIds = (parsed.newLabels || []) + .map(nl => labelIdsByName?.get(nl.name.toLowerCase())) + .filter(id => id != null && !parsed.labelIds.includes(id)) + const chore = buildChorePayload( + { ...parsed, labelIds: [...parsed.labelIds, ...extraLabelIds] }, + { + userProfile, + projectId, + notificationTemplates, + }, + ) try { const result = await createChoreMutation.mutateAsync(chore) if (result?._pendingCreate) { diff --git a/src/views/components/VoiceToTask/VoicePanel.jsx b/src/views/components/VoiceToTask/VoicePanel.jsx index f271fa6..b33391a 100644 --- a/src/views/components/VoiceToTask/VoicePanel.jsx +++ b/src/views/components/VoiceToTask/VoicePanel.jsx @@ -1,5 +1,6 @@ import { CalendarMonth, + Check, Close, Flag, GraphicEq, @@ -13,7 +14,13 @@ import { } from '@mui/icons-material' import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy' import moment from 'moment' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { TASK_COLOR } from '../../../utils/Colors' +import AssigneePickerField from '../AssigneePickerField' +import DueDatePickerField from '../DueDatePickerField' +import LabelsPickerField from '../LabelsPickerField' +import PriorityPickerField from '../PriorityPickerField' +import RepeatPickerField from '../RepeatPickerField' import { parseVoiceTask } from './parseVoiceTask' import { useVoiceToTask } from './useVoiceToTask' import './VoicePanel.css' @@ -27,6 +34,22 @@ const HIGHLIGHT_CLASS = { dueDate: 'highlight-date', } +const PRIORITY_COLORS = { + 0: TASK_COLOR.NO_PRIORITY, + 1: TASK_COLOR.PRIORITY_1, + 2: TASK_COLOR.PRIORITY_2, + 3: TASK_COLOR.PRIORITY_3, + 4: TASK_COLOR.PRIORITY_4, +} + +const PRIORITY_LABELS = { + 0: '--', + 1: 'P1', + 2: 'P2', + 3: 'P3', + 4: 'P4', +} + const renderTranscript = (text, highlights) => { const parts = [] let lastIndex = 0 @@ -58,41 +81,62 @@ const formatDue = dueDate => { : m.format('MMM D, h:mm A') } -const buildChips = (parsed, { members, currentUserId }) => { +// Compact description for picker-overridden frequencies where the parser's +// human name no longer applies +const describeFrequency = f => { + if (!f) return null + if (f.frequencyType === 'interval') { + const unit = f.frequencyMetadata?.unit || 'days' + return f.frequency > 1 + ? `Every ${f.frequency} ${unit}` + : `Every ${unit.replace(/s$/, '')}` + } + const names = { + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + yearly: 'Yearly', + days_of_the_week: 'Custom days', + day_of_the_month: 'Monthly', + } + return names[f.frequencyType] || 'Repeats' +} + +const buildChips = (effective, frequencyLabel, { members, currentUserId }) => { const chips = [] - if (parsed.dueDate) { + if (effective.dueDate) { chips.push({ key: 'due', color: 'warning', icon: , - label: formatDue(parsed.dueDate), + label: formatDue(effective.dueDate), }) } - if (parsed.frequencyName) { + if (frequencyLabel) { chips.push({ key: 'repeat', color: 'success', icon: , - label: parsed.frequencyName, + label: frequencyLabel, }) } - if (parsed.priority > 0) { + if (effective.priority > 0) { chips.push({ key: 'priority', color: 'danger', icon: , - label: `P${parsed.priority}`, + label: `P${effective.priority}`, }) } - if (parsed.points != null) { + if (effective.points != null) { chips.push({ key: 'points', color: 'primary', icon: , - label: `${parsed.points} pts`, + label: `${effective.points} pts`, }) } - parsed.labelNames.forEach(name => { + effective.labelNames.forEach(name => { chips.push({ key: `label-${name}`, color: 'primary', @@ -100,7 +144,15 @@ const buildChips = (parsed, { members, currentUserId }) => { label: name, }) }) - if (parsed.isAnyone) { + effective.newLabels.forEach(label => { + chips.push({ + key: `new-label-${label.name}`, + color: 'warning', + icon: , + label: `${label.name} · new`, + }) + }) + if (effective.isAnyone) { chips.push({ key: 'assignee', color: 'neutral', @@ -108,10 +160,10 @@ const buildChips = (parsed, { members, currentUserId }) => { label: 'Anyone', }) } else if ( - parsed.assignees.length > 0 && - parsed.assignees[0].userId !== currentUserId + effective.assignees.length > 0 && + effective.assignees[0].userId !== currentUserId ) { - const member = members.find(m => m.userId === parsed.assignees[0].userId) + const member = members.find(m => m.userId === effective.assignees[0].userId) if (member) { chips.push({ key: 'assignee', @@ -124,18 +176,69 @@ const buildChips = (parsed, { members, currentUserId }) => { return chips } -const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => { - const [editing, setEditing] = useState(false) +const TaskPreviewCard = ({ + segment, + parseCtx, + onRemove, + onUpdate, + onPatch, +}) => { + const [expanded, setExpanded] = useState(false) const [draft, setDraft] = useState(segment.text) + const dueEditRef = useRef(null) const parsed = useMemo( () => parseVoiceTask(segment.text, parseCtx), [segment.text, parseCtx], ) - const chips = useMemo(() => buildChips(parsed, parseCtx), [parsed, parseCtx]) + const overrides = useMemo(() => segment.overrides || {}, [segment.overrides]) + const effective = useMemo( + () => ({ ...parsed, ...overrides }), + [parsed, overrides], + ) - const commitEdit = () => { - setEditing(false) + const frequencyLabel = + 'frequency' in overrides + ? describeFrequency(effective.frequency) + : parsed.frequencyName + const chips = useMemo( + () => buildChips(effective, frequencyLabel, parseCtx), + [effective, frequencyLabel, parseCtx], + ) + + const due = effective.dueDate ? moment(effective.dueDate) : null + const dueDateOnly = due ? due.format('YYYY-MM-DD') : null + const hasCustomTime = !!due && due.format('HH:mm') !== '23:59' + const dueTime = hasCustomTime ? due.format('HH:mm') : null + + // DueDatePickerField's Apply fires date/custom-time/time callbacks in + // sequence; collect them in one microtask so they land as a single patch + const queueDuePatch = patch => { + if (!dueEditRef.current) { + dueEditRef.current = { + date: dueDateOnly, + time: dueTime, + custom: hasCustomTime, + } + queueMicrotask(() => { + const { date, time, custom } = dueEditRef.current + dueEditRef.current = null + if (!date) { + onPatch({ dueDate: null }) + } else { + onPatch({ + dueDate: + custom && time + ? moment(`${date}T${time}`).format('YYYY-MM-DDTHH:mm:00') + : moment(date).endOf('day').format('YYYY-MM-DDTHH:mm:ss'), + }) + } + }) + } + Object.assign(dueEditRef.current, patch) + } + + const commitText = () => { if (draft.trim() !== segment.text) onUpdate(draft) } @@ -145,7 +248,7 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => { sx={{ borderRadius: 'md', border: '1px solid', - borderColor: 'divider', + borderColor: expanded ? 'primary.outlinedBorder' : 'divider', bgcolor: 'background.surface', p: 1.25, display: 'flex', @@ -154,34 +257,45 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => { }} > - {editing ? ( + {expanded ? ( setDraft(e.target.value)} onKeyDown={e => { - if (e.key === 'Enter') commitEdit() - if (e.key === 'Escape') { - setDraft(segment.text) - setEditing(false) - } + if (e.key === 'Enter') commitText() + if (e.key === 'Escape') setDraft(segment.text) }} - onBlur={commitEdit} + onBlur={commitText} sx={{ flex: 1 }} /> ) : ( { setDraft(segment.text) - setEditing(true) + setExpanded(true) }} > {parsed.title || segment.text} )} + {expanded && ( + { + commitText() + setExpanded(false) + }} + sx={{ '--IconButton-size': '28px' }} + > + + + )} { - {chips.length > 0 && ( - + + {!expanded && chips.length > 0 && ( + { + setDraft(segment.text) + setExpanded(true) + }} + > {chips.map(chip => ( { ))} )} + + {expanded && ( + + + queueDuePatch({ date: e.target.value || null }) + } + onDueTimeChange={e => + queueDuePatch({ time: e.target.value || null }) + } + onUseCustomTimeChange={checked => + queueDuePatch({ custom: checked }) + } + onClear={() => onPatch({ dueDate: null })} + /> + onPatch({ frequency: f })} + onClear={() => onPatch({ frequency: null })} + /> + onPatch({ priority: p })} + onClear={() => onPatch({ priority: 0 })} + priorityColors={PRIORITY_COLORS} + priorityLabels={PRIORITY_LABELS} + /> + a.userId)} + isAnyone={effective.isAnyone} + onChange={userIds => { + if (userIds.includes('anyone')) { + onPatch({ isAnyone: true, assignees: [] }) + } else { + onPatch({ + isAnyone: false, + assignees: userIds.map(userId => ({ userId })), + }) + } + }} + onClear={() => onPatch({ isAnyone: false, assignees: [] })} + currentUserId={parseCtx.currentUserId} + members={parseCtx.members} + /> + onPatch({ labelIds: ids })} + onClear={() => onPatch({ labelIds: [] })} + labels={parseCtx.userLabels} + /> + + )} ) } @@ -214,10 +409,10 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => { /** * Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal. * - * Hold the mic to speak, or tap once for hands-free. Pauses and spoken - * separators ("also") split the transcript into task cards. A single captured - * task lands in the smart input for review; multiple tasks are created - * directly from the review list. + * Opens straight into hands-free listening. Pauses and spoken separators + * ("also") split the transcript into task cards; tapping a card opens inline + * pickers whose edits override the parsed values. A single captured task + * lands in the smart input for review; multiple are created directly. */ const VoicePanel = ({ open, @@ -235,13 +430,15 @@ const VoicePanel = ({ segments, micPressDown, micPressUp, - startListening, + startHandsFree, removeSegment, updateSegment, + patchSegment, reset, isNative, } = useVoiceToTask({ members }) const [creating, setCreating] = useState(false) + const autoStartedRef = useRef(false) const parseCtx = useMemo( () => ({ userLabels, members, currentUserId: userProfile?.id }), @@ -253,6 +450,15 @@ const VoicePanel = ({ [partialText, parseCtx], ) + // Start capturing the moment the panel opens — the mic tap that opened it + // is the only tap needed + useEffect(() => { + if (open && !autoStartedRef.current) { + autoStartedRef.current = true + startHandsFree() + } + }, [open, startHandsFree]) + if (!open) return null const isListening = phase === 'listening' @@ -263,10 +469,15 @@ const VoicePanel = ({ onClose() } + const mergedTask = segment => ({ + ...parseVoiceTask(segment.text, parseCtx), + ...(segment.overrides || {}), + }) + const handleCreateAll = async () => { setCreating(true) try { - await onCreateMany(segments.map(s => parseVoiceTask(s.text, parseCtx))) + await onCreateMany(segments.map(mergedTask)) } finally { setCreating(false) } @@ -331,7 +542,7 @@ const VoicePanel = ({ size='sm' variant='outlined' color='neutral' - onClick={startListening} + onClick={startHandsFree} > Try Again @@ -347,7 +558,7 @@ const VoicePanel = ({ display: 'flex', flexDirection: 'column', gap: 0.75, - maxHeight: 260, + maxHeight: 300, overflowY: 'auto', }} > @@ -358,6 +569,7 @@ const VoicePanel = ({ parseCtx={parseCtx} onRemove={() => removeSegment(segment.id)} onUpdate={text => updateSegment(segment.id, text)} + onPatch={patch => patchSegment(segment.id, patch)} /> ))} @@ -470,7 +682,9 @@ const VoicePanel = ({ size='sm' variant='solid' color='primary' - onClick={() => onUseSingle(segments[0].text)} + onClick={() => + onUseSingle(segments[0].text, segments[0].overrides || {}) + } > Use Task diff --git a/src/views/components/VoiceToTask/parseVoiceTask.js b/src/views/components/VoiceToTask/parseVoiceTask.js index ad46871..7b889ee 100644 --- a/src/views/components/VoiceToTask/parseVoiceTask.js +++ b/src/views/components/VoiceToTask/parseVoiceTask.js @@ -124,6 +124,7 @@ export const parseVoiceTask = ( points: points.result ?? null, labelIds, labelNames: (labels.result || []).map(label => label.name), + newLabels: labels.newLabels || [], assignees, isAnyone, frequency: repeat.result, diff --git a/src/views/components/VoiceToTask/useVoiceToTask.js b/src/views/components/VoiceToTask/useVoiceToTask.js index 45cf0fe..6739a33 100644 --- a/src/views/components/VoiceToTask/useVoiceToTask.js +++ b/src/views/components/VoiceToTask/useVoiceToTask.js @@ -135,6 +135,13 @@ export function useVoiceToTask({ members = [] } = {}) { return true }, [commitSegment, stopListening]) + // One-tap entry: start listening already locked into hands-free mode + const startHandsFree = useCallback(async () => { + if (phaseRef.current === 'listening') return + const ok = await startListening() + if (ok) setIsLocked(true) + }, [startListening]) + const micPressDown = useCallback(() => { pressStartedAtRef.current = Date.now() if (phaseRef.current === 'listening') { @@ -180,6 +187,21 @@ export function useVoiceToTask({ members = [] } = {}) { [applySegments], ) + // Picker edits on a card are stored as overrides that win over whatever a + // re-parse of the spoken text would produce + const patchSegment = useCallback( + (id, patch) => { + applySegments( + segmentsRef.current.map(s => + s.id === id + ? { ...s, overrides: { ...(s.overrides || {}), ...patch } } + : s, + ), + ) + }, + [applySegments], + ) + const reset = useCallback(() => { voiceInputService.stop() if (watchdogRef.current) { @@ -208,9 +230,11 @@ export function useVoiceToTask({ members = [] } = {}) { micPressDown, micPressUp, startListening, + startHandsFree, stopListening, removeSegment, updateSegment, + patchSegment, reset, isNative: voiceInputService.isNative, } diff --git a/src/views/components/VoiceToTask/voiceNormalizer.js b/src/views/components/VoiceToTask/voiceNormalizer.js index 1fffc8d..a7cef88 100644 --- a/src/views/components/VoiceToTask/voiceNormalizer.js +++ b/src/views/components/VoiceToTask/voiceNormalizer.js @@ -61,28 +61,88 @@ const normalizeLabels = text => '#$1', ) +// "assign to Sarah" / "assigned to Sarah" / "assign Sarah" / "for Sarah". +// Speech engines spell names their own way ("Sara" for Sarah) and add +// punctuation, so exact display-name matching alone misses real speech — +// an edit-distance-1 fuzzy pass catches those, but only after an explicit +// assign verb so ordinary words never convert. +const ASSIGN_VERB = '(?:assign(?:ed|ee)?(?:\\s+(?:this|it))?(?:\\s+to)?|for)' +const STRICT_ASSIGN_VERB = '(?:assign(?:ed|ee)?(?:\\s+(?:this|it))?(?:\\s+to)?)' + +const levenshtein = (a, b) => { + if (Math.abs(a.length - b.length) > 1) return 2 + const prev = Array.from({ length: b.length + 1 }, (_, i) => i) + for (let i = 1; i <= a.length; i++) { + let diag = prev[0] + prev[0] = i + for (let j = 1; j <= b.length; j++) { + const tmp = prev[j] + prev[j] = Math.min( + prev[j] + 1, + prev[j - 1] + 1, + diag + (a[i - 1] === b[j - 1] ? 0 : 1), + ) + diag = tmp + } + } + return prev[b.length] +} + +const memberNameVariants = member => + [ + member.displayName, + member.displayName?.split(/\s+/)[0], + member.username, + ].filter(n => n && n.length > 1) + +const findMemberFuzzy = (candidate, members) => { + const c = candidate.toLowerCase() + let close = null + for (const member of members) { + for (const name of memberNameVariants(member)) { + const n = name.toLowerCase() + if (n === c) return member + if (!close && n.length >= 4 && c.length >= 4 && levenshtein(n, c) <= 1) { + close = member + } + } + } + return close +} + const normalizeAssignees = (text, members = []) => { - const assignVerb = '(?:assign(?:ed)?\\s+(?:this\\s+|it\\s+)?to|for)' let out = text.replace( - new RegExp(`\\b${assignVerb}\\s+(?:anyone|anybody|everyone)\\b`, 'gi'), + new RegExp( + `\\b${ASSIGN_VERB}\\s+(?:anyone|anybody|everyone)\\b[,.]?`, + 'gi', + ), '@Anyone', ) + // Exact pass — full display name first so "assign to Mo Tarbin" doesn't + // leave a dangling "Tarbin" for (const member of members) { - const displayName = member.displayName - if (!displayName) continue - const firstName = displayName.split(/\s+/)[0] - // Full display name first so "assign to Mo Tarbin" doesn't leave "Tarbin" - const names = [...new Set([displayName, firstName])].filter( - n => n.length > 1, + if (!member.displayName) continue + const names = [...new Set(memberNameVariants(member))].sort( + (a, b) => b.length - a.length, ) for (const name of names) { out = out.replace( - new RegExp(`\\b${assignVerb}\\s+${escapeRegex(name)}\\b`, 'gi'), - `@${displayName}`, + new RegExp(`\\b${ASSIGN_VERB}\\s+${escapeRegex(name)}\\b[,.]?`, 'gi'), + `@${member.displayName}`, ) } } + + // Fuzzy pass — requires an assign verb (not bare "for") so only clearly + // intended names get corrected + out = out.replace( + new RegExp(`\\b${STRICT_ASSIGN_VERB}\\s+([\\p{L}][\\p{L}'-]*)[,.]?`, 'giu'), + (match, candidate) => { + const member = findMemberFuzzy(candidate, members) + return member ? `@${member.displayName}` : match + }, + ) return out }