feat: implement ActivitiesCard component to display recent activities with enhanced status and time display

Support the new Status and Performed By
This commit is contained in:
Mo Tarbin
2025-05-29 01:01:07 -04:00
parent 5c000f50aa
commit 1f2d38730d
9 changed files with 492 additions and 33 deletions

View File

@@ -0,0 +1,42 @@
.smart-task-display {
position: absolute;
width: 100%;
z-index: 10;
pointer-events: none;
padding: 0px;
margin: 0;
border: none;
overflow-y: auto;
word-wrap: break-word;
white-space: pre-wrap;
box-sizing: border-box;
}
.smart-task-common {
font-size: 1.2em;
line-height: 1.2em;
font-family: inherit;
caret-color: #f08080;
}
.highlight-date {
color: #f08080;
}
.highlight-repeat {
color: #90ee90;
}
.highlight-label {
color: #add8e6;
}
.highlight-priority {
color: #ffb6c1;
}
.task-input {
position: relative;
width: 100%;
border-radius: 8px;
box-sizing: border-box;
}

View File

@@ -0,0 +1,273 @@
import { useColorScheme } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import AutocompleteDropdown from '../TestView/AutocompleteDropdown'
import './SmartTaskTitleInput.css'
const renderHighlightedText = (text, cursorPosition) => {
const parts = []
let lastIndex = 0
const regex = /(Tomorrow)|(#\w+)|(P\d)/gi
let match
while ((match = regex.exec(text)) !== null) {
const matchedText = match[0]
const matchIndex = match.index
if (matchIndex > lastIndex) {
parts.push(text.substring(lastIndex, matchIndex))
}
let className = ''
if (matchedText.toLowerCase() === 'tomorrow') {
className = 'highlight-date'
} else if (matchedText.startsWith('#')) {
className = 'highlight-label'
} else if (matchedText.startsWith('P')) {
className = 'highlight-priority'
}
parts.push(
<span key={matchIndex} className={className}>
{matchedText}
</span>,
)
lastIndex = regex.lastIndex
}
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex))
}
return parts
}
const SmartTaskTitleInput = ({
value,
placeholder,
autoFocus,
onChange,
suggestions,
onEnterPressed,
customRenderer,
}) => {
const { mode, setMode } = useColorScheme()
const titleInputRef = useRef(null)
const [cursorPosition, setCursorPosition] = useState(value?.length)
const dropdownRef = useRef(null)
const [lastWord, setLastWord] = useState('')
const [suggestionTrigger, setSuggestionTrigger] = useState('P')
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(0)
useEffect(() => {
setCursorPosition(prevPos => Math.min(prevPos, value.length))
if (
titleInputRef.current &&
document.activeElement === titleInputRef.current
) {
requestAnimationFrame(() => {
titleInputRef.current.setSelectionRange(cursorPosition, cursorPosition)
})
}
}, [value])
useEffect(() => {
// set focus on the input when the component is mounted:
if (titleInputRef.current) {
titleInputRef.current.focus()
titleInputRef.current.setSelectionRange(cursorPosition, cursorPosition)
}
}, [])
const handleSuggestionChange = text => {
// if the last word start with '@' or '#' or 'P':
const lastWord = text.split(' ').pop()
if (
lastWord.startsWith('@') ||
lastWord.startsWith('#') ||
lastWord.startsWith('!')
) {
setSuggestionTrigger(lastWord[0])
// last word without the first character:
setLastWord(lastWord.slice(1))
setShowSuggestions(true)
} else {
setShowSuggestions(false)
}
}
const handleTextareaChange = e => {
handleSuggestionChange(e.target.value)
onChange(e.target.value)
setCursorPosition(e.target.selectionStart)
}
const handleTextareaKeyDown = e => {
if (showSuggestions) {
const currentSuggestions = suggestions[suggestionTrigger].options.filter(
option => {
if (typeof option === 'string') {
return option.toLowerCase().includes(lastWord.toLowerCase())
}
return option[suggestions[suggestionTrigger].display]
.toLowerCase()
.includes(lastWord.toLowerCase())
},
)
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
const newIndex =
e.key === 'ArrowDown'
? (selectedSuggestionIndex + 1) % currentSuggestions.length
: (selectedSuggestionIndex - 1 + currentSuggestions.length) %
currentSuggestions.length
setSelectedSuggestionIndex(newIndex)
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
const selectedSuggestion = currentSuggestions[selectedSuggestionIndex]
const suggestionValue = suggestions[suggestionTrigger].display
? selectedSuggestion[suggestions[suggestionTrigger].display]
: selectedSuggestion
if (suggestionValue) {
const newValue = `${value.slice(0, cursorPosition - lastWord.length)}${suggestionValue} ${value.slice(cursorPosition)}`
onChange(newValue)
titleInputRef.current.value = newValue
setShowSuggestions(false)
const newCursorPosition = cursorPosition + suggestionValue.length + 1
titleInputRef.current.setSelectionRange(
newCursorPosition,
newCursorPosition,
)
}
} else if (e.key === 'Escape') {
e.preventDefault()
setShowSuggestions(false)
}
} else {
if (e.key === 'Enter') {
e.preventDefault()
if (onEnterPressed) {
onEnterPressed(value)
}
}
}
const currentPos = e.target.selectionStart
setCursorPosition(currentPos)
}
const handleTextareaClick = e => {
const currentPos = e.target.selectionStart
setCursorPosition(currentPos)
}
const handleDisplayClick = e => {
const range = document.caretRangeFromPoint(e.clientX, e.clientY)
if (range) {
const offset = range.startOffset
setCursorPosition(offset)
if (titleInputRef.current) {
titleInputRef.current.focus()
titleInputRef.current.setSelectionRange(offset, offset)
}
}
}
return (
<div>
<div className='task-input overflow-auto rounded border'>
<textarea
ref={titleInputRef}
autoFocus={autoFocus}
rows={1}
value={value}
onChange={handleTextareaChange}
onKeyDown={handleTextareaKeyDown}
onClick={handleTextareaClick}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
// opacity: 100,
zIndex: 1,
resize: 'none',
overflow: 'hidden',
padding: '0.5rem',
boxSizing: 'border-box',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
backgroundColor: 'transparent',
color: mode === 'dark' ? '#cbd5e1' : '#1a202c',
caretColor: mode === 'dark' ? '#fff' : '#000',
}}
/>
<div
className='smart-task-display smart-task-common'
ref={dropdownRef}
style={{
position: 'relative',
zIndex: 1,
minHeight: '100%',
padding: '0.5rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
}}
onClick={handleDisplayClick}
>
{placeholder && !value && (
<span className='pointer-events-none text-gray-400'>
{placeholder}
</span>
)}
{customRenderer
? customRenderer
: renderHighlightedText(value, cursorPosition)}
</div>
</div>
{showSuggestions && (
<AutocompleteDropdown
currentValue={lastWord}
suggestions={suggestions[suggestionTrigger]}
selectedIndex={selectedSuggestionIndex}
onMouseEnterSuggestion={index => {
setSelectedSuggestionIndex(index)
}}
onSelectSuggestion={suggestion => {
const suggestionValue = suggestions[suggestionTrigger].display
? suggestion[suggestions[suggestionTrigger].display]
: suggestion
const newValue = `${value.slice(0, cursorPosition)}${suggestionValue}${value.slice(cursorPosition)}`
onChange(newValue)
titleInputRef?.current?.focus()
setCursorPosition(cursorPosition + suggestion.length)
titleInputRef.current.value = newValue
titleInputRef.current.setSelectionRange(
cursorPosition + suggestionValue.length,
cursorPosition + suggestionValue.length,
)
setShowSuggestions(false)
}}
parentRefer={dropdownRef}
/>
)}
</div>
)
}
export default SmartTaskTitleInput

View File

@@ -1,4 +1,10 @@
import { DndContext, closestCenter } from '@dnd-kit/core'
import {
DndContext,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
@@ -24,7 +30,7 @@ import {
ListItem,
Typography,
} from '@mui/joy'
import React, { useState } from 'react'
import { useState } from 'react'
import { CompleteSubTask } from '../../utils/Fetcher'
function SortableItem({
@@ -39,7 +45,17 @@ function SortableItem({
editMode,
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: task.id })
useSortable({
id: task.id,
// Add touch sensor options for better mobile scrolling
options: {
activationConstraint: {
// Require a small movement before activating drag to allow scrolling
delay: 250,
tolerance: 5,
},
},
})
const [isEditing, setIsEditing] = useState(false)
const [editedText, setEditedText] = useState(task.name)
@@ -58,7 +74,8 @@ function SortableItem({
alignItems: 'center',
gap: '0.5rem',
flexDirection: { xs: 'column', sm: 'row' },
touchAction: 'none',
// Enable default touch behavior for scrolling
touchAction: 'auto',
paddingLeft: `${level * 24}px`,
}
@@ -102,7 +119,15 @@ function SortableItem({
<>
<ListItem ref={setNodeRef} style={style} {...attributes}>
{editMode && (
<IconButton {...listeners} {...attributes} size='sm'>
<IconButton
{...listeners}
{...attributes}
size='sm'
// Add data attribute for selective activation
data-drag-handle='true'
// Only restrict touch actions on the drag handle
sx={{ touchAction: 'none' }}
>
<DragIndicator />
</IconButton>
)}
@@ -119,7 +144,7 @@ function SortableItem({
)}
{!hasChildren && level > 0 && (
<Box sx={{ width: 28 }} /> // Spacer for alignment not sure of better way for now it's good
<Box sx={{ width: 28 }} /> // Spacer for alignment
)}
<Box
@@ -269,6 +294,17 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
const topLevelTasks = tasks.filter(task => task.parentId === null)
// Create sensors for touch handling
const sensors = useSensors(
useSensor(PointerSensor, {
// Configure for better mobile scrolling
activationConstraint: {
delay: 100,
tolerance: 8,
},
}),
)
const handleToggle = taskId => {
const updatedTask = tasks.find(task => task.id === taskId)
const newCompletedAt = updatedTask.completedAt
@@ -405,9 +441,21 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
return (
<>
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<DndContext
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
sensors={sensors}
>
<SortableContext items={tasks} strategy={verticalListSortingStrategy}>
<List sx={{ padding: 0 }}>
<List
sx={{
padding: 0,
// Improve scrolling behavior on mobile
maxHeight: 'inherit',
overflow: 'visible',
WebkitOverflowScrolling: 'touch',
}}
>
{topLevelTasks
.sort((a, b) => a.orderId - b.orderId)
.map((task, index) => (