ability to parse user and label

This commit is contained in:
Mo Tarbin
2026-07-20 01:54:39 -04:00
parent e0257445ef
commit a133b271d0
5 changed files with 436 additions and 59 deletions

View File

@@ -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) {

View File

@@ -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: <CalendarMonth sx={{ fontSize: 12 }} />,
label: formatDue(parsed.dueDate),
label: formatDue(effective.dueDate),
})
}
if (parsed.frequencyName) {
if (frequencyLabel) {
chips.push({
key: 'repeat',
color: 'success',
icon: <Repeat sx={{ fontSize: 12 }} />,
label: parsed.frequencyName,
label: frequencyLabel,
})
}
if (parsed.priority > 0) {
if (effective.priority > 0) {
chips.push({
key: 'priority',
color: 'danger',
icon: <Flag sx={{ fontSize: 12 }} />,
label: `P${parsed.priority}`,
label: `P${effective.priority}`,
})
}
if (parsed.points != null) {
if (effective.points != null) {
chips.push({
key: 'points',
color: 'primary',
icon: <Toll sx={{ fontSize: 12 }} />,
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: <Sell sx={{ fontSize: 12 }} />,
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 }) => {
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
{editing ? (
{expanded ? (
<Input
size='sm'
autoFocus
value={draft}
onChange={e => 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 }}
/>
) : (
<Typography
level='title-sm'
sx={{ flex: 1, cursor: 'text', wordBreak: 'break-word' }}
sx={{ flex: 1, cursor: 'pointer', wordBreak: 'break-word' }}
onClick={() => {
setDraft(segment.text)
setEditing(true)
setExpanded(true)
}}
>
{parsed.title || segment.text}
</Typography>
)}
{expanded && (
<IconButton
size='sm'
variant='soft'
color='primary'
onClick={() => {
commitText()
setExpanded(false)
}}
sx={{ '--IconButton-size': '28px' }}
>
<Check fontSize='small' />
</IconButton>
)}
<IconButton
size='sm'
variant='plain'
@@ -192,8 +306,20 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
<Close fontSize='small' />
</IconButton>
</Box>
{chips.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{!expanded && chips.length > 0 && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
cursor: 'pointer',
}}
onClick={() => {
setDraft(segment.text)
setExpanded(true)
}}
>
{chips.map(chip => (
<Chip
key={chip.key}
@@ -207,6 +333,75 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
))}
</Box>
)}
{expanded && (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 1,
overflowX: 'auto',
pt: 0.5,
'&::-webkit-scrollbar': { display: 'none' },
}}
>
<DueDatePickerField
emptyDisplay='icon'
dueDateOnly={dueDateOnly}
dueTime={dueTime}
useCustomTime={hasCustomTime}
onDueDateChange={e =>
queueDuePatch({ date: e.target.value || null })
}
onDueTimeChange={e =>
queueDuePatch({ time: e.target.value || null })
}
onUseCustomTimeChange={checked =>
queueDuePatch({ custom: checked })
}
onClear={() => onPatch({ dueDate: null })}
/>
<RepeatPickerField
emptyDisplay='icon'
value={effective.frequency}
onChange={f => onPatch({ frequency: f })}
onClear={() => onPatch({ frequency: null })}
/>
<PriorityPickerField
emptyDisplay='icon'
value={effective.priority}
onChange={p => onPatch({ priority: p })}
onClear={() => onPatch({ priority: 0 })}
priorityColors={PRIORITY_COLORS}
priorityLabels={PRIORITY_LABELS}
/>
<AssigneePickerField
emptyDisplay='icon'
values={effective.assignees.map(a => 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}
/>
<LabelsPickerField
emptyDisplay='icon'
values={effective.labelIds}
onChange={ids => onPatch({ labelIds: ids })}
onClear={() => onPatch({ labelIds: [] })}
labels={parseCtx.userLabels}
/>
</Box>
)}
</Box>
)
}
@@ -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
</Button>
@@ -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)}
/>
))}
</Box>
@@ -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
</Button>

View File

@@ -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,

View File

@@ -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,
}

View File

@@ -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
}