diff --git a/src/locales/en.ts b/src/locales/en.ts index 59d65ef..5186da5 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -698,10 +698,14 @@ export const en = { searchTagsLabel: "Search tags...", noTagsFoundLabel: "No tags found.", searchProjectsLabel: "Search projects...", - noProjectsFoundLabel: "No projects found.", - deletedProjectLabel: "Deleted project", - deletedTagLabel: "Deleted tag", - }, + noProjectsFoundLabel: "No projects found.", + deletedProjectLabel: "Deleted project", + deletedTagLabel: "Deleted tag", + startRequiredError: "Start date and time are required.", + endRequiredError: "End date and time must both be filled.", + invalidEndTimeError: "End time is invalid.", + endBeforeStartError: "End must be after start.", + }, reports: { title: "Reports", diff --git a/src/locales/fa.ts b/src/locales/fa.ts index d10c8e5..af1ed3a 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -695,10 +695,14 @@ export const fa = { searchTagsLabel: "جست‌وجوی تگ‌ها...", noTagsFoundLabel: "تگی پیدا نشد.", searchProjectsLabel: "جست‌وجوی پروژه‌ها...", - noProjectsFoundLabel: "پروژه‌ای پیدا نشد.", - deletedProjectLabel: "پروژه حذف‌شده", - deletedTagLabel: "تگ حذف‌شده", - }, + noProjectsFoundLabel: "پروژه‌ای پیدا نشد.", + deletedProjectLabel: "پروژه حذف‌شده", + deletedTagLabel: "تگ حذف‌شده", + startRequiredError: "تاریخ و زمان شروع الزامی است.", + endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.", + invalidEndTimeError: "زمان پایان معتبر نیست.", + endBeforeStartError: "پایان باید بعد از شروع باشد.", + }, reports: { title: "گزارش‌ها", description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`, diff --git a/src/pages/Timesheet.tsx b/src/pages/Timesheet.tsx index 77af790..63b4d30 100644 --- a/src/pages/Timesheet.tsx +++ b/src/pages/Timesheet.tsx @@ -134,7 +134,7 @@ const getTimeCursorPosition = (digitCount: number) => { return Math.min(digitCount + 2, 8); }; -const handleFormattedTimeInputChange = ( +const handleFormattedTimeInputChange = ( event: React.ChangeEvent, onChange: (value: string) => void, ) => { @@ -149,8 +149,20 @@ const handleFormattedTimeInputChange = ( window.requestAnimationFrame(() => { if (document.activeElement !== input) return; input.setSelectionRange(nextCursor, nextCursor); - }); -}; + }); +}; + +const selectTimeSegment = (input: HTMLInputElement) => { + const cursor = input.selectionStart ?? 0; + const start = cursor <= 2 ? 0 : cursor <= 5 ? 3 : 6; + const end = start + 2; + + window.requestAnimationFrame(() => { + if (document.activeElement === input) { + input.setSelectionRange(start, end); + } + }); +}; const isValidTimeValue = (value: string) => { if (!/^\d{2}:\d{2}:\d{2}$/.test(value)) return false; @@ -445,27 +457,38 @@ const serializeEntryDraft = (state: EntryFormState) => const toggleTagId = (currentTags: string[], tagId: string) => currentTags.includes(tagId) ? currentTags.filter((currentId) => currentId !== tagId) : [...currentTags, tagId]; -const buildPayloadFromState = ( - state: EntryFormState, - options: { includeWorkspace: boolean; workspaceId?: string }, -): { payload?: TimeEntryPayload; error?: string } => { - const startDateTime = combineDateAndTime(state.startDate, state.startTime); - if (!startDateTime) { - return { error: "Start date and time are required." }; - } +const buildPayloadFromState = ( + state: EntryFormState, + options: { includeWorkspace: boolean; workspaceId?: string; messages?: Partial> }, +): { payload?: TimeEntryPayload; error?: string } => { + const messages = { + startRequired: "Start date and time are required.", + endRequired: "End date and time must both be filled.", + invalidEndTime: "End time is invalid.", + endBeforeStart: "End must be after start.", + ...options.messages, + }; + const startDateTime = combineDateAndTime(state.startDate, state.startTime); + if (!startDateTime) { + return { error: messages.startRequired }; + } let endDateTime: string | null = null; const hasEndValue = Boolean(state.endDate || state.endTime); if (hasEndValue) { - if (!state.endDate || !state.endTime) { - return { error: "End date and time must both be filled." }; - } - - endDateTime = combineDateAndTime(state.endDate, state.endTime); - if (!endDateTime) { - return { error: "End time is invalid." }; - } - } + if (!state.endDate || !state.endTime) { + return { error: messages.endRequired }; + } + + endDateTime = combineDateAndTime(state.endDate, state.endTime); + if (!endDateTime) { + return { error: messages.invalidEndTime }; + } + + if (new Date(endDateTime).getTime() <= new Date(startDateTime).getTime()) { + return { error: messages.endBeforeStart }; + } + } const payload: TimeEntryPayload = { description: state.description.trim(), @@ -601,19 +624,21 @@ const getTagDisplayDetails = (entry: TimeEntry, activeTags: Tag[]) => { }).filter(Boolean) as Array<{ id: string; name: string; color: string; isDeleted: boolean }>; }; -function TimeField({ - label, - value, - onChange, - placeholder, - compact = false, -}: { - label: string; - value: string; - onChange: (value: string) => void; - placeholder?: string; - compact?: boolean; -}) { +function TimeField({ + label, + value, + onChange, + onBlur, + placeholder, + compact = false, +}: { + label: string; + value: string; + onChange: (value: string) => void; + onBlur?: () => void; + placeholder?: string; + compact?: boolean; +}) { return (
@@ -624,12 +649,15 @@ function TimeField({ value={value} maxLength={8} placeholder={placeholder || "HH:MM:SS"} - className={compact ? "h-9 px-2 text-xs" : ""} - onChange={(event) => handleFormattedTimeInputChange(event, onChange)} - /> -
- ); -} + className={compact ? "h-9 px-2 text-xs" : ""} + onChange={(event) => handleFormattedTimeInputChange(event, onChange)} + onFocus={(event) => selectTimeSegment(event.currentTarget)} + onClick={(event) => selectTimeSegment(event.currentTarget)} + onBlur={onBlur} + /> + + ); +} function BillableIconButton({ checked, @@ -667,10 +695,11 @@ function BillableIconButton({ function TagMultiSelect({ tags, selectedTags, - onToggleTag, - emptyHint, - title, - compact = false, + onToggleTag, + emptyHint, + title, + onDropdownClose, + compact = false, portalOwnerId, className = "", buttonClassName = "", @@ -678,9 +707,10 @@ function TagMultiSelect({ }: { tags: Tag[]; selectedTags: string[]; - onToggleTag: (tagId: string) => void; - emptyHint: string; - title: string; + onToggleTag: (tagId: string) => void; + emptyHint: string; + title: string; + onDropdownClose?: () => void; compact?: boolean; portalOwnerId?: string; className?: string; @@ -691,7 +721,8 @@ function TagMultiSelect({ const [searchQuery, setSearchQuery] = useState(""); const wrapperRef = useRef(null); const buttonRef = useRef(null); - const dropdownRef = useRef(null); + const dropdownRef = useRef(null); + const wasOpenRef = useRef(false); const [dropdownStyle, setDropdownStyle] = useState({}); useEffect(() => { @@ -741,10 +772,17 @@ function TagMultiSelect({ }; }, [isOpen]); - useEffect(() => { - if (!isOpen) { - setSearchQuery(""); - } + useEffect(() => { + if (wasOpenRef.current && !isOpen) { + onDropdownClose?.(); + } + wasOpenRef.current = isOpen; + }, [isOpen, onDropdownClose]); + + useEffect(() => { + if (!isOpen) { + setSearchQuery(""); + } }, [isOpen]); const selectedTagObjects = tags.filter((tag) => selectedTags.includes(tag.id)); @@ -1066,19 +1104,21 @@ function ProjectInlineSelect({ ); } -function CompactDateTimeField({ +function CompactDateTimeField({ label, dateValue, timeValue, onDateChange, - onTimeChange, -}: { + onTimeChange, + onTimeBlur, +}: { label: string; dateValue: string; timeValue: string; onDateChange: (value: string) => void; - onTimeChange: (value: string) => void; -}) { + onTimeChange: (value: string) => void; + onTimeBlur?: () => void; +}) { return (
@@ -1098,25 +1138,32 @@ function CompactDateTimeField({ value={timeValue} maxLength={8} placeholder="HH:MM:SS" - className="h-9 min-w-[88px] border-0 bg-transparent px-2 text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" - onChange={(event) => onTimeChange(formatTimeInputValue(event.target.value))} - /> + className="h-9 min-w-[88px] border-0 bg-transparent px-2 text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" + onChange={(event) => onTimeChange(formatTimeInputValue(event.target.value))} + onFocus={(event) => selectTimeSegment(event.currentTarget)} + onClick={(event) => selectTimeSegment(event.currentTarget)} + onBlur={onTimeBlur} + />
); } -function InlineTimeRangeField({ +function InlineTimeRangeField({ startTime, endTime, onStartTimeChange, - onEndTimeChange, -}: { + onEndTimeChange, + onStartTimeBlur, + onEndTimeBlur, +}: { startTime: string; endTime: string; onStartTimeChange: (value: string) => void; - onEndTimeChange: (value: string) => void; -}) { + onEndTimeChange: (value: string) => void; + onStartTimeBlur?: () => void; + onEndTimeBlur?: () => void; +}) { return (
handleFormattedTimeInputChange(event, onStartTimeChange)} - /> + className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" + onChange={(event) => handleFormattedTimeInputChange(event, onStartTimeChange)} + onFocus={(event) => selectTimeSegment(event.currentTarget)} + onClick={(event) => selectTimeSegment(event.currentTarget)} + onBlur={onStartTimeBlur} + /> - handleFormattedTimeInputChange(event, onEndTimeChange)} - /> + className="h-9 min-w-[68px] border-0 bg-transparent dark:bg-transparent px-0 text-center text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" + onChange={(event) => handleFormattedTimeInputChange(event, onEndTimeChange)} + onFocus={(event) => selectTimeSegment(event.currentTarget)} + onClick={(event) => selectTimeSegment(event.currentTarget)} + onBlur={onEndTimeBlur} + />
); } -function DateRangePopover({ +function DateRangePopover({ startDate, endDate, onStartDateChange, - onEndDateChange, - portalOwnerId, -}: { + onEndDateChange, + onCommit, + portalOwnerId, +}: { startDate: string; endDate: string; - onStartDateChange: (value: string) => void; - onEndDateChange: (value: string) => void; - portalOwnerId?: string; -}) { + onStartDateChange: (value: string) => void; + onEndDateChange: (value: string) => void; + onCommit?: () => void; + portalOwnerId?: string; +}) { const [isOpen, setIsOpen] = useState(false); const wrapperRef = useRef(null); const buttonRef = useRef(null); @@ -1219,8 +1274,22 @@ function DateRangePopover({ className="rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-700 dark:bg-slate-950" >
- - + { + onStartDateChange(value); + window.setTimeout(() => onCommit?.(), 0); + }} + /> + { + onEndDateChange(value); + window.setTimeout(() => onCommit?.(), 0); + }} + />
, document.body, @@ -1246,11 +1315,12 @@ function DeleteEntryButton({ ); } -function EntryEditorFields({ +function EntryEditorFields({ state, - onChange, - onToggleTag, - onProjectChange, + onChange, + onToggleTag, + onProjectChange, + onCommit, projects, tags, t, @@ -1259,9 +1329,10 @@ function EntryEditorFields({ portalOwnerId, }: { state: EntryFormState; - onChange: (patch: Partial) => void; - onToggleTag: (tagId: string) => void; - onProjectChange?: (projectId: string) => void; + onChange: (patch: Partial, saveMode?: "none" | "immediate" | "debounce") => void; + onToggleTag: (tagId: string) => void; + onProjectChange?: (projectId: string) => void; + onCommit?: () => void; projects: Project[]; tags: Tag[]; t: any; @@ -1274,9 +1345,11 @@ function EntryEditorFields({ return (
- onChange({ description: event.target.value })} + { + onChange({ description: event.target.value }, "debounce"); + }} placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"} className="h-12 w-[200px] 2xl:w-[400px] shrink-0 rounded-none border-0 bg-transparent px-0 pe-3 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 truncate dark:bg-transparent dark:text-slate-100 dark:placeholder:text-slate-600" /> @@ -1286,9 +1359,9 @@ function EntryEditorFields({
(onProjectChange ? onProjectChange(projectId) : onChange({ projectId }))} + projects={projects} + value={state.projectId} + onChange={(projectId) => (onProjectChange ? onProjectChange(projectId) : onChange({ projectId }, "immediate"))} placeholder={t.timesheet?.projectLabel || "Project"} searchPlaceholder={t.timesheet?.searchProjectsLabel || t.projects?.searchPlaceholder || "Search projects..."} emptyLabel={t.timesheet?.noProjectsFoundLabel || "No projects found."} @@ -1316,16 +1389,17 @@ function EntryEditorFields({ compactDisplayMode="chips" portalOwnerId={portalOwnerId} className="w-full min-w-0 overflow-hidden 2xl:w-auto 2xl:max-w-none" - buttonClassName="w-full min-w-0 max-w-full justify-start overflow-hidden 2xl:w-auto 2xl:max-w-none" - /> + buttonClassName="w-full min-w-0 max-w-full justify-start overflow-hidden 2xl:w-auto 2xl:max-w-none" + onDropdownClose={onCommit} + />
onChange({ isBillable: checked })} - label={t.timesheet?.billable || "Billable"} - compact + checked={state.isBillable} + onChange={(checked) => onChange({ isBillable: checked }, "immediate")} + label={t.timesheet?.billable || "Billable"} + compact />
@@ -1333,19 +1407,21 @@ function EntryEditorFields({ onChange({ startTime: value })} - onEndTimeChange={(value) => onChange({ endTime: value })} - /> + onStartTimeChange={(value) => onChange({ startTime: value })} + onEndTimeChange={(value) => onChange({ endTime: value })} + onStartTimeBlur={onCommit} + onEndTimeBlur={onCommit} + />
onChange({ startDate: value })} - onEndDateChange={(value) => onChange({ endDate: value })} - portalOwnerId={portalOwnerId} - /> + onStartDateChange={(value) => onChange({ startDate: value }, "immediate")} + onEndDateChange={(value) => onChange({ endDate: value }, "immediate")} + portalOwnerId={portalOwnerId} + />
); @@ -1358,8 +1434,10 @@ function EntryEditorFields({ {t.timesheet?.descriptionLabel || "Description"} onChange({ description: event.target.value })} + value={state.description} + onChange={(event) => { + onChange({ description: event.target.value }, "debounce"); + }} placeholder={t.timesheet?.descriptionPlaceholder || "What are you working on?"} className={compact ? "h-9 px-2 text-xs placeholder:text-slate-300 dark:placeholder:text-slate-600" : "placeholder:text-slate-300 dark:placeholder:text-slate-600"} /> @@ -1369,9 +1447,11 @@ function EntryEditorFields({ - onChange({ projectId: String(value) })} + { + onChange({ projectId: String(value) }, "immediate"); + }} options={[ { value: "", label: t.timesheet?.noProject || "No project" }, ...projects.map((project) => ({ @@ -1390,40 +1470,48 @@ function EntryEditorFields({
- onChange({ startDate: date })} - inputClassName={compact ? "h-9 px-2 text-xs" : undefined} - /> + { + onChange({ startDate: date }, "immediate"); + }} + inputClassName={compact ? "h-9 px-2 text-xs" : undefined} + /> onChange({ startTime: value })} - compact={compact} - /> + onChange={(value) => onChange({ startTime: value })} + onBlur={onCommit} + compact={compact} + />
onChange({ endDate: date })} - inputClassName={compact ? "h-9 px-2 text-xs" : undefined} - /> + value={state.endDate} + onChange={(date) => { + onChange({ endDate: date }, "immediate"); + }} + inputClassName={compact ? "h-9 px-2 text-xs" : undefined} + /> onChange({ endTime: value })} - compact={compact} - /> + value={state.endTime} + onChange={(value) => onChange({ endTime: value })} + onBlur={onCommit} + compact={compact} + />
onChange({ isBillable: checked })} + checked={state.isBillable} + onChange={(checked) => { + onChange({ isBillable: checked }, "immediate"); + }} label={t.timesheet?.billable || "Billable"} />
@@ -1431,7 +1519,8 @@ function EntryEditorFields({ (() => buildEntryFormState(entry)); const [validationMessage, setValidationMessage] = useState(""); const syncedSignatureRef = useRef(serializeEntryDraft(buildEntryFormState(entry))); - const rowRef = useRef(null); - const isSavingRef = useRef(false); - const pendingSignatureRef = useRef(null); + const rowRef = useRef(null); + const isSavingRef = useRef(false); + const pendingSignatureRef = useRef(null); + const descriptionSaveTimeoutRef = useRef(null); const editorOwnerId = `time-entry-editor-${entry.id}`; const timesheetCopy = (t.timesheet as { saveError?: string; saveSuccess?: string }) || {}; const saveErrorText = timesheetCopy.saveError || "Failed to save time entry"; @@ -1482,14 +1572,24 @@ function RecordedEntryCard({ [draft.tags, entry, tags], ); - useEffect(() => { - const nextDraft = buildEntryFormState(entry); + useEffect(() => { + if (descriptionSaveTimeoutRef.current) { + window.clearTimeout(descriptionSaveTimeoutRef.current); + descriptionSaveTimeoutRef.current = null; + } + const nextDraft = buildEntryFormState(entry); const nextSignature = serializeEntryDraft(nextDraft); syncedSignatureRef.current = nextSignature; pendingSignatureRef.current = nextSignature; setDraft(nextDraft); setValidationMessage(""); - }, [entry]); + }, [entry]); + + useEffect(() => () => { + if (descriptionSaveTimeoutRef.current) { + window.clearTimeout(descriptionSaveTimeoutRef.current); + } + }, []); const isInsideEditorContext = useCallback((target: EventTarget | null) => { if (!(target instanceof Node)) return false; @@ -1497,18 +1597,25 @@ function RecordedEntryCard({ return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${editorOwnerId}"]`)); }, [editorOwnerId]); - const commitDraft = useCallback(async () => { - const currentSignature = serializeEntryDraft(draft); - if (currentSignature === syncedSignatureRef.current) { - setValidationMessage(""); - return false; + const validationMessages = useMemo(() => ({ + startRequired: t.timesheet?.startRequiredError || "Start date and time are required.", + endRequired: t.timesheet?.endRequiredError || "End date and time must both be filled.", + invalidEndTime: t.timesheet?.invalidEndTimeError || "End time is invalid.", + endBeforeStart: t.timesheet?.endBeforeStartError || "End must be after start.", + }), [t.timesheet]); + + const commitDraftState = useCallback(async (nextDraft: EntryFormState) => { + const currentSignature = serializeEntryDraft(nextDraft); + if (currentSignature === syncedSignatureRef.current) { + setValidationMessage(""); + return false; } if (isSavingRef.current || pendingSignatureRef.current === currentSignature) { return false; } - const { payload, error } = buildPayloadFromState(draft, { includeWorkspace: false }); + const { payload, error } = buildPayloadFromState(nextDraft, { includeWorkspace: false, messages: validationMessages }); if (!payload) { setValidationMessage(error || ""); return false; @@ -1518,7 +1625,7 @@ function RecordedEntryCard({ isSavingRef.current = true; pendingSignatureRef.current = currentSignature; try { - const updatedEntry = await updateTimeEntry(entry.id, payload); + const updatedEntry = await updateTimeEntry(entry.id, payload); const updatedDraft = buildEntryFormState(updatedEntry); const updatedSignature = serializeEntryDraft(updatedDraft); syncedSignatureRef.current = updatedSignature; @@ -1527,59 +1634,43 @@ function RecordedEntryCard({ onEntryUpdated(updatedEntry); toast.success(saveSuccessText); return true; - } catch (error) { - console.error(error); - pendingSignatureRef.current = null; - toast.error(saveErrorText); - return false; + } catch (error) { + console.error(error); + pendingSignatureRef.current = null; + toast.error(error instanceof Error ? error.message : saveErrorText); + return false; } finally { isSavingRef.current = false; } - }, [draft, entry.id, onEntryUpdated, saveErrorText, saveSuccessText]); - - const commitPatchedDraft = useCallback(async (patch: Partial) => { - const nextDraft = { ...draft, ...patch }; - const nextSignature = serializeEntryDraft(nextDraft); - setDraft(nextDraft); - - if (nextSignature === syncedSignatureRef.current) { - setValidationMessage(""); - return false; - } - - if (isSavingRef.current || pendingSignatureRef.current === nextSignature) { - return false; - } - - const { payload, error } = buildPayloadFromState(nextDraft, { includeWorkspace: false }); - if (!payload) { - setValidationMessage(error || ""); - return false; - } - - setValidationMessage(""); - isSavingRef.current = true; - pendingSignatureRef.current = nextSignature; - - try { - const updatedEntry = await updateTimeEntry(entry.id, payload); - const updatedDraft = buildEntryFormState(updatedEntry); - const updatedSignature = serializeEntryDraft(updatedDraft); - syncedSignatureRef.current = updatedSignature; - pendingSignatureRef.current = updatedSignature; - setDraft(updatedDraft); - onEntryUpdated(updatedEntry); - toast.success(saveSuccessText); - return true; - } catch (error) { - console.error(error); - pendingSignatureRef.current = null; - toast.error(saveErrorText); - return false; - } finally { - isSavingRef.current = false; - } - }, [draft, entry.id, onEntryUpdated, saveErrorText, saveSuccessText]); + }, [entry.id, onEntryUpdated, saveErrorText, saveSuccessText, validationMessages]); + + const commitDraft = useCallback(async () => commitDraftState(draft), [commitDraftState, draft]); + + const commitPatchedDraft = useCallback(async (patch: Partial) => { + const nextDraft = { ...draft, ...patch }; + setDraft(nextDraft); + return commitDraftState(nextDraft); + }, [commitDraftState, draft]); + + const handleDraftChange = useCallback((patch: Partial, saveMode: "none" | "immediate" | "debounce" = "none") => { + setDraft((current) => { + const nextDraft = { ...current, ...patch }; + if (descriptionSaveTimeoutRef.current) { + window.clearTimeout(descriptionSaveTimeoutRef.current); + descriptionSaveTimeoutRef.current = null; + } + + if (saveMode === "immediate") { + window.setTimeout(() => void commitDraftState(nextDraft), 0); + } else if (saveMode === "debounce") { + descriptionSaveTimeoutRef.current = window.setTimeout(() => { + void commitDraftState(nextDraft); + }, 700); + } + + return nextDraft; + }); + }, [commitDraftState]); useEffect(() => { const handlePointerDown = (event: MouseEvent) => { @@ -1608,11 +1699,12 @@ function RecordedEntryCard({ className="border-b border-slate-200 bg-white px-4 py-4 dark:border-slate-700 dark:bg-slate-900/95" >
- setDraft((current) => ({ ...current, ...patch }))} - onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))} - onProjectChange={(projectId) => void commitPatchedDraft({ projectId })} + handleDraftChange({ tags: toggleTagId(draft.tags, tagId) })} + onProjectChange={(projectId) => void commitPatchedDraft({ projectId })} projects={editorProjects} tags={editorTags} t={t} @@ -1658,11 +1750,12 @@ function RecordedEntryCard({ return (
- setDraft((current) => ({ ...current, ...patch }))} - onToggleTag={(tagId) => setDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))} - onProjectChange={(projectId) => void commitPatchedDraft({ projectId })} + handleDraftChange({ tags: toggleTagId(draft.tags, tagId) })} + onProjectChange={(projectId) => void commitPatchedDraft({ projectId })} projects={editorProjects} tags={editorTags} t={t} @@ -1723,11 +1816,14 @@ function MobileRecordedEntryCard({ const entryTags = getTagDisplayDetails(entry, tags); const wrapperRef = useRef(null); const buttonRef = useRef(null); - const dropdownRef = useRef(null); - const touchStartXRef = useRef(null); - const [menuOpen, setMenuOpen] = useState(false); - const [swipeOffset, setSwipeOffset] = useState(0); - const [menuStyle, setMenuStyle] = useState({}); + const dropdownRef = useRef(null); + const touchStartXRef = useRef(null); + const touchStartYRef = useRef(null); + const touchGestureRef = useRef<"pending" | "swipe" | "scroll">("pending"); + const [menuOpen, setMenuOpen] = useState(false); + const [swipeOffset, setSwipeOffset] = useState(0); + const [touchGesture, setTouchGesture] = useState<"pending" | "swipe" | "scroll">("pending"); + const [menuStyle, setMenuStyle] = useState({}); useEffect(() => { if (!menuOpen) return; @@ -1778,25 +1874,53 @@ function MobileRecordedEntryCard({ }; }, [menuOpen]); - const closeSwipe = () => { - touchStartXRef.current = null; - setSwipeOffset(0); - }; + const closeSwipe = () => { + touchStartXRef.current = null; + touchStartYRef.current = null; + touchGestureRef.current = "pending"; + setTouchGesture("pending"); + setSwipeOffset(0); + }; const handleTouchStart = (event: React.TouchEvent) => { - if (menuOpen) { - setMenuOpen(false); - } - touchStartXRef.current = event.touches[0]?.clientX ?? null; - }; - - const handleTouchMove = (event: React.TouchEvent) => { - if (touchStartXRef.current === null) return; - const delta = (event.touches[0]?.clientX ?? 0) - touchStartXRef.current; - setSwipeOffset(Math.max(-88, Math.min(88, delta))); - }; - - const handleTouchEnd = () => { + if (menuOpen) { + setMenuOpen(false); + } + touchStartXRef.current = event.touches[0]?.clientX ?? null; + touchStartYRef.current = event.touches[0]?.clientY ?? null; + touchGestureRef.current = "pending"; + setTouchGesture("pending"); + }; + + const handleTouchMove = (event: React.TouchEvent) => { + if (touchStartXRef.current === null || touchStartYRef.current === null) return; + const deltaX = (event.touches[0]?.clientX ?? 0) - touchStartXRef.current; + const deltaY = (event.touches[0]?.clientY ?? 0) - touchStartYRef.current; + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + + if (touchGestureRef.current === "pending" && (absX > 8 || absY > 8)) { + touchGestureRef.current = absX > absY + 8 ? "swipe" : "scroll"; + setTouchGesture(touchGestureRef.current); + } + + if (touchGestureRef.current === "scroll") { + setSwipeOffset(0); + return; + } + + if (touchGestureRef.current === "swipe") { + event.preventDefault(); + setSwipeOffset(Math.max(-88, Math.min(88, deltaX))); + } + }; + + const handleTouchEnd = () => { + if (touchGestureRef.current !== "swipe") { + closeSwipe(); + return; + } + if (swipeOffset <= -72) { closeSwipe(); onDelete(entry); @@ -1821,9 +1945,9 @@ function MobileRecordedEntryCard({
-
([]); const [tags, setTags] = useState([]); @@ -2108,10 +2236,11 @@ export default function Timesheet() { const [isStartingTimer, setIsStartingTimer] = useState(false); const desktopTimerRef = useRef(null); const mobileTimerRef = useRef(null); - const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT)); - const pendingTimerSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT)); - const isTimerSavingRef = useRef(false); - const timerEditorOwnerId = "running-timer-editor"; + const timerDraftSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT)); + const pendingTimerSignatureRef = useRef(serializeTimerDraft(EMPTY_TIMER_DRAFT)); + const isTimerSavingRef = useRef(false); + const timerDescriptionSaveTimeoutRef = useRef(null); + const timerEditorOwnerId = "running-timer-editor"; const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; entry: TimeEntry | null }>({ isOpen: false, @@ -2145,10 +2274,21 @@ export default function Timesheet() { () => buildProjectOptionsForEntry(projects, editingEntry, formState.projectId, deletedProjectLabel), [deletedProjectLabel, editingEntry, formState.projectId, projects], ); - const modalTags = useMemo( - () => buildTagOptionsForEntry(tags, editingEntry, formState.tags), - [editingEntry, formState.tags, tags], - ); + const modalTags = useMemo( + () => buildTagOptionsForEntry(tags, editingEntry, formState.tags), + [editingEntry, formState.tags, tags], + ); + const entryValidationMessages = useMemo(() => ({ + startRequired: extendedTimesheet.startRequiredError || "Start date and time are required.", + endRequired: extendedTimesheet.endRequiredError || "End date and time must both be filled.", + invalidEndTime: extendedTimesheet.invalidEndTimeError || "End time is invalid.", + endBeforeStart: extendedTimesheet.endBeforeStartError || "End must be after start.", + }), [ + extendedTimesheet.endBeforeStartError, + extendedTimesheet.endRequiredError, + extendedTimesheet.invalidEndTimeError, + extendedTimesheet.startRequiredError, + ]); useEffect(() => { if (!runningEntry) return; @@ -2298,7 +2438,12 @@ export default function Timesheet() { void loadRunningEntry(); }, [loadRunningEntry]); - useEffect(() => { + useEffect(() => { + if (timerDescriptionSaveTimeoutRef.current) { + window.clearTimeout(timerDescriptionSaveTimeoutRef.current); + timerDescriptionSaveTimeoutRef.current = null; + } + if (!runningEntry) { setTimerClockAnchor(null); timerDraftSignatureRef.current = serializeTimerDraft(EMPTY_TIMER_DRAFT); @@ -2310,8 +2455,14 @@ export default function Timesheet() { const nextDraft = buildTimerDraftState(runningEntry); timerDraftSignatureRef.current = serializeTimerDraft(nextDraft); pendingTimerSignatureRef.current = timerDraftSignatureRef.current; - setTimerDraft(nextDraft); - }, [runningEntry]); + setTimerDraft(nextDraft); + }, [runningEntry]); + + useEffect(() => () => { + if (timerDescriptionSaveTimeoutRef.current) { + window.clearTimeout(timerDescriptionSaveTimeoutRef.current); + } + }, []); const isInsideTimerEditorContext = useCallback((target: EventTarget | null) => { if (!(target instanceof Node)) return false; @@ -2319,15 +2470,15 @@ export default function Timesheet() { return target instanceof Element && Boolean(target.closest(`[data-entry-editor-owner="${timerEditorOwnerId}"]`)); }, [timerEditorOwnerId]); - const commitTimerDraft = useCallback(async () => { - const saveErrorText = extendedTimesheet.saveError || "Failed to save time entry"; - const saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved"; - - if (!runningEntry) return false; - - const currentSignature = serializeTimerDraft(timerDraft); - if (currentSignature === timerDraftSignatureRef.current) { - return false; + const commitTimerDraftState = useCallback(async (nextDraft: TimerDraftState) => { + const saveErrorText = extendedTimesheet.saveError || "Failed to save time entry"; + const saveSuccessText = extendedTimesheet.saveSuccess || "Time entry saved"; + + if (!runningEntry) return false; + + const currentSignature = serializeTimerDraft(nextDraft); + if (currentSignature === timerDraftSignatureRef.current) { + return false; } if (isTimerSavingRef.current || pendingTimerSignatureRef.current === currentSignature) { @@ -2338,12 +2489,12 @@ export default function Timesheet() { pendingTimerSignatureRef.current = currentSignature; try { - const updatedEntry = await updateTimeEntry(runningEntry.id, { - description: timerDraft.description.trim(), - project_id: timerDraft.projectId || null, - tags: timerDraft.tags, - is_billable: timerDraft.isBillable, - }); + const updatedEntry = await updateTimeEntry(runningEntry.id, { + description: nextDraft.description.trim(), + project_id: nextDraft.projectId || null, + tags: nextDraft.tags, + is_billable: nextDraft.isBillable, + }); const syncedDraft = buildTimerDraftState(updatedEntry); const syncedSignature = serializeTimerDraft(syncedDraft); @@ -2360,12 +2511,34 @@ export default function Timesheet() { } catch (error) { console.error(error); pendingTimerSignatureRef.current = null; - toast.error(saveErrorText); - return false; + toast.error(error instanceof Error ? error.message : saveErrorText); + return false; } finally { isTimerSavingRef.current = false; } - }, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry, timerDraft]); + }, [extendedTimesheet.saveError, extendedTimesheet.saveSuccess, runningEntry]); + + const commitTimerDraft = useCallback(async () => commitTimerDraftState(timerDraft), [commitTimerDraftState, timerDraft]); + + const updateTimerDraft = useCallback((patch: Partial, saveMode: "none" | "immediate" | "debounce" = "none") => { + setTimerDraft((current) => { + const nextDraft = { ...current, ...patch }; + if (timerDescriptionSaveTimeoutRef.current) { + window.clearTimeout(timerDescriptionSaveTimeoutRef.current); + timerDescriptionSaveTimeoutRef.current = null; + } + + if (saveMode === "immediate") { + window.setTimeout(() => void commitTimerDraftState(nextDraft), 0); + } else if (saveMode === "debounce") { + timerDescriptionSaveTimeoutRef.current = window.setTimeout(() => { + void commitTimerDraftState(nextDraft); + }, 700); + } + + return nextDraft; + }); + }, [commitTimerDraftState]); useEffect(() => { if (!runningEntry) return; @@ -2407,13 +2580,15 @@ export default function Timesheet() { setFormState(buildEntryFormState(entry)); }; - const handleSaveEntryModal = async () => { - if (modalMode === "manual" && !activeWorkspace?.id) return; + const handleSaveEntryModal = async (event?: React.FormEvent) => { + event?.preventDefault(); + if (modalMode === "manual" && !activeWorkspace?.id) return; - const { payload, error } = buildPayloadFromState(formState, { - includeWorkspace: modalMode === "manual", - workspaceId: activeWorkspace?.id, - }); + const { payload, error } = buildPayloadFromState(formState, { + includeWorkspace: modalMode === "manual", + workspaceId: activeWorkspace?.id, + messages: entryValidationMessages, + }); if (!payload) { toast.error(error || (t.timesheet?.saveError || "Failed to save time entry")); @@ -2436,9 +2611,9 @@ export default function Timesheet() { setModalMode(null); setEditingEntry(null); setFormState(EMPTY_FORM); - } catch (error) { - console.error(error); - toast.error(t.timesheet?.saveError || "Failed to save time entry"); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : (t.timesheet?.saveError || "Failed to save time entry")); } finally { setIsSaving(false); } @@ -2535,8 +2710,9 @@ export default function Timesheet() { setDiscardTimerModal({ isOpen: false, entry: null }); }; - const confirmDelete = async () => { - if (!deleteModal.entry) return; + const confirmDelete = async (event?: React.FormEvent) => { + event?.preventDefault(); + if (!deleteModal.entry) return; try { setIsDeleting(true); @@ -2567,8 +2743,9 @@ export default function Timesheet() { } }; - const confirmRestart = async () => { - if (!restartModal.entry) return; + const confirmRestart = async (event?: React.FormEvent) => { + event?.preventDefault(); + if (!restartModal.entry) return; try { setIsRestarting(true); @@ -2709,7 +2886,7 @@ export default function Timesheet() { setTimerDraft((current) => ({ ...current, description: event.target.value }))} + onChange={(event) => updateTimerDraft({ description: event.target.value }, "debounce")} disabled={isStartingTimer} className="h-12 rounded-none border-0 bg-transparent px-5 text-sm shadow-none placeholder:text-slate-300 focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent dark:placeholder:text-slate-600" /> @@ -2718,7 +2895,7 @@ export default function Timesheet() {
setTimerDraft((current) => ({ ...current, projectId: String(value) }))} + onChange={(value) => updateTimerDraft({ projectId: String(value) }, "immediate")} options={[ { value: "", label: t.timesheet?.projectLabel || "Project" }, ...runningTimerProjects.map((project) => ({ @@ -2740,9 +2917,8 @@ export default function Timesheet() { - setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) })) - } + onToggleTag={(tagId) => updateTimerDraft({ tags: toggleTagId(timerDraft.tags, tagId) })} + onDropdownClose={() => void commitTimerDraft()} emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."} title={t.tags?.title || "Tags"} compact @@ -2755,7 +2931,7 @@ export default function Timesheet() {
setTimerDraft((current) => ({ ...current, isBillable: checked }))} + onChange={(checked) => updateTimerDraft({ isBillable: checked }, "immediate")} label={t.timesheet?.billable || "Billable"} disabled={isStartingTimer} compact @@ -2817,7 +2993,7 @@ export default function Timesheet() { setTimerDraft((current) => ({ ...current, description: event.target.value }))} + onChange={(event) => updateTimerDraft({ description: event.target.value }, "debounce")} disabled={isStartingTimer} className="h-10 border-slate-200 bg-slate-50 text-sm placeholder:text-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:placeholder:text-slate-600" /> @@ -2825,7 +3001,7 @@ export default function Timesheet() {
setTimerDraft((current) => ({ ...current, projectId: String(value) }))} + onChange={(value) => updateTimerDraft({ projectId: String(value) }, "immediate")} options={[ { value: "", label: t.timesheet?.projectLabel || "Project" }, ...runningTimerProjects.map((project) => ({ @@ -2852,9 +3028,8 @@ export default function Timesheet() { - setTimerDraft((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) })) - } + onToggleTag={(tagId) => updateTimerDraft({ tags: toggleTagId(timerDraft.tags, tagId) })} + onDropdownClose={() => void commitTimerDraft()} emptyHint={t.timesheet?.noTagsHint || "Create tags first from the Tags page."} title={t.tags?.title || "Tags"} compact @@ -2863,7 +3038,7 @@ export default function Timesheet() { setTimerDraft((current) => ({ ...current, isBillable: checked }))} + onChange={(checked) => updateTimerDraft({ isBillable: checked }, "immediate")} label={t.timesheet?.billable || "Billable"} disabled={isStartingTimer} compact @@ -3045,22 +3220,24 @@ export default function Timesheet() { - + } > - setFormState((current) => ({ ...current, ...patch }))} - onToggleTag={(tagId) => setFormState((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))} - projects={modalProjects} - tags={modalTags} - t={t} - isRtl={isRtl} - /> - +
+ setFormState((current) => ({ ...current, ...patch }))} + onToggleTag={(tagId) => setFormState((current) => ({ ...current, tags: toggleTagId(current.tags, tagId) }))} + projects={modalProjects} + tags={modalTags} + t={t} + isRtl={isRtl} + /> + + {deleteModal.entry && ( {t.actions?.cancel || "Cancel"} - + } > -
+

{extendedTimesheet.deleteConfirmMessage || t.timesheet?.deleteConfirmMessage || "Are you sure you want to delete this time entry?"}

@@ -3092,7 +3269,7 @@ export default function Timesheet() { {deleteModal.entry.end_time ? ` - ${formatDateTime(deleteModal.entry.end_time, lang)}` : ""}

-
+ )} @@ -3107,13 +3284,13 @@ export default function Timesheet() { - + } > -
+

{extendedTimesheet.restartConfirmMessage || t.timesheet?.restartConfirmMessage || "Start a new running timer from this entry?"}

@@ -3126,7 +3303,7 @@ export default function Timesheet() { {restartModal.entry.end_time ? ` - ${formatDateTime(restartModal.entry.end_time, lang)}` : ""}

-
+ )} @@ -3141,13 +3318,20 @@ export default function Timesheet() { - + } > -
+
{ + event.preventDefault(); + void handleDiscardTimerDraft(); + }} + className="space-y-3" + >

{extendedTimesheet.discardConfirmMessage || t.timesheet?.discardConfirmMessage || "Are you sure you want to discard this running timer?"}

@@ -3159,7 +3343,7 @@ export default function Timesheet() { {formatDateTime(discardTimerModal.entry.start_time, lang)}

-
+ )}