UX fixes: completed-task ordering, editable links, Ctrl+K discoverability#9
Conversation
…iscoverability - All Tasks: completed tasks now always sort below incomplete ones - Project Notes: links are now editable (inline edit form) and the edit/delete controls are always visible (not hover-only, so they work on touch) - Sidebar: add a Search… button showing the Ctrl/Cmd+K shortcut to surface the command palette - ProjectDetail: show a 'drag the handle to reorder' hint when Manual sort mode is active Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Visit the preview URL for this PR (updated for commit 9b0cc0f): https://my-productivity-hub-5a3ba--pr9-fix-ux-tweaks-97113ifu.web.app (expires Sun, 07 Jun 2026 18:56:10 GMT) 🔥 via Firebase Hosting GitHub Action 🌎 Sign: 4ef75aef15076a8cc05555b91f31935d9a13db8e |
There was a problem hiding this comment.
Code Review
This pull request introduces several enhancements, including inline editing for project resources, a search shortcut in the sidebar, manual task sorting guidance, and improved task sorting logic to keep completed tasks at the bottom. Feedback highlights opportunities to improve robustness: replacing the deprecated navigator.platform with navigator.userAgent for OS detection, coercing task completion states to booleans to avoid sorting bugs with undefined values, and tracking the resource editing state by URL instead of array index to prevent UI state mismatches.
| const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); | ||
| const SEARCH_SHORTCUT = isMac ? '⌘K' : 'Ctrl K'; |
There was a problem hiding this comment.
The navigator.platform property is deprecated and may return spoofed or empty values in modern browsers. It is recommended to use navigator.userAgent with a case-insensitive regex instead to detect macOS/iOS devices reliably.
| const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); | |
| const SEARCH_SHORTCUT = isMac ? '⌘K' : 'Ctrl K'; | |
| const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent); | |
| const SEARCH_SHORTCUT = isMac ? '⌘K' : 'Ctrl K'; |
| // Always keep completed tasks below incomplete ones. | ||
| if (a.completed !== b.completed) return a.completed ? 1 : -1; |
There was a problem hiding this comment.
If some tasks have completed as undefined (e.g., legacy or partially initialized tasks) and others have completed as false, the comparison a.completed !== b.completed will evaluate to true even though both are incomplete. Coercing both values to booleans ensures a stable and correct sort order.
| // Always keep completed tasks below incomplete ones. | |
| if (a.completed !== b.completed) return a.completed ? 1 : -1; | |
| // Always keep completed tasks below incomplete ones. | |
| const aComp = !!a.completed; | |
| const bComp = !!b.completed; | |
| if (aComp !== bComp) return aComp ? 1 : -1; |
| const [editingIndex, setEditingIndex] = useState(null); | ||
| const [editLabel, setEditLabel] = useState(''); | ||
| const [editUrl, setEditUrl] = useState(''); | ||
| const [editError, setEditError] = useState(''); |
There was a problem hiding this comment.
Using the array index to track the editing state (editingIndex) can lead to correctness bugs if the list of resources is modified (e.g., an item is deleted or reordered) while editing. Since URLs are typically unique in a list of resources, tracking the editing state using the resource's URL (editingUrl) is much more robust and prevents UI state mismatch.
| const [editingIndex, setEditingIndex] = useState(null); | |
| const [editLabel, setEditLabel] = useState(''); | |
| const [editUrl, setEditUrl] = useState(''); | |
| const [editError, setEditError] = useState(''); | |
| const [editingUrl, setEditingUrl] = useState(null); | |
| const [editLabel, setEditLabel] = useState(''); | |
| const [editUrl, setEditUrl] = useState(''); | |
| const [editError, setEditError] = useState(''); |
| const startEdit = (index) => { | ||
| setEditingIndex(index); | ||
| setEditLabel(resources[index].label || ''); | ||
| setEditUrl(resources[index].url || ''); | ||
| setEditError(''); | ||
| }; | ||
|
|
||
| const handleUpdateResource = async (e) => { | ||
| e.preventDefault(); | ||
| setEditError(''); | ||
| const url = normalizeUrl(editUrl); | ||
| if (!url) { | ||
| setEditError('Enter a URL.'); | ||
| return; | ||
| } | ||
| try { | ||
| // eslint-disable-next-line no-new | ||
| new URL(url); | ||
| } catch { | ||
| setEditError('That doesn’t look like a valid URL.'); | ||
| return; | ||
| } | ||
| if (!projectRef) return; | ||
| const label = editLabel.trim() || url.replace(/^https?:\/\//i, ''); | ||
| const next = resources.map((r, i) => (i === editingIndex ? { label, url } : r)); | ||
| try { | ||
| await updateDoc(projectRef, { resources: next }); | ||
| setEditingIndex(null); | ||
| } catch (err) { | ||
| console.error('Error updating resource:', err); | ||
| setEditError('Could not save. Try again.'); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Refactor startEdit and handleUpdateResource to use the resource's URL (editingUrl) instead of the array index. This ensures that the correct resource is updated even if the array is modified concurrently.
const startEdit = (res) => {
setEditingUrl(res.url);
setEditLabel(res.label || '');
setEditUrl(res.url || '');
setEditError('');
};
const handleUpdateResource = async (e) => {
e.preventDefault();
setEditError('');
const url = normalizeUrl(editUrl);
if (!url) {
setEditError('Enter a URL.');
return;
}
try {
// eslint-disable-next-line no-new
new URL(url);
} catch {
setEditError('That doesn’t look like a valid URL.');
return;
}
if (!projectRef) return;
const label = editLabel.trim() || url.replace(/^https?:\/\//i, '');
const next = resources.map((r) => (r.url === editingUrl ? { label, url } : r));
try {
await updateDoc(projectRef, { resources: next });
setEditingUrl(null);
} catch (err) {
console.error('Error updating resource:', err);
setEditError('Could not save. Try again.');
}
};| {resources.map((res, index) => | ||
| editingIndex === index ? ( | ||
| <li key={`edit-${index}`} className="p-2.5 bg-gray-900/40 border border-gray-700/40 rounded-xl"> | ||
| <form onSubmit={handleUpdateResource} className="grid grid-cols-1 sm:grid-cols-[1fr_1.5fr_auto] gap-2"> | ||
| <input | ||
| type="text" | ||
| value={editLabel} | ||
| onChange={(e) => setEditLabel(e.target.value)} | ||
| placeholder="Label (optional)" | ||
| className="bg-gray-800 border border-gray-700 rounded-lg px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50" | ||
| /> | ||
| <input | ||
| type="text" | ||
| value={editUrl} | ||
| onChange={(e) => setEditUrl(e.target.value)} | ||
| placeholder="https://…" | ||
| className="bg-gray-800 border border-gray-700 rounded-lg px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50" | ||
| autoFocus | ||
| /> | ||
| <div className="flex gap-1"> | ||
| <button type="submit" aria-label="Save link" className="flex-1 sm:flex-none px-2.5 py-1.5 bg-emerald-600 hover:bg-emerald-700 rounded-lg text-xs font-semibold"> | ||
| <Check size={14} /> | ||
| </button> | ||
| <button type="button" onClick={() => setEditingIndex(null)} aria-label="Cancel" className="flex-1 sm:flex-none px-2.5 py-1.5 bg-gray-600 hover:bg-gray-700 rounded-lg text-xs font-semibold"> | ||
| <X size={14} /> | ||
| </button> | ||
| </div> | ||
| </form> | ||
| {editError && <p className="text-red-400 text-xs mt-1.5">{editError}</p>} | ||
| </li> | ||
| ) : ( | ||
| <li | ||
| key={`${res.url}-${index}`} | ||
| className="flex items-center gap-3 p-2.5 bg-gray-900/40 border border-gray-700/30 rounded-xl hover:border-gray-600/50 transition-colors" | ||
| > | ||
| <Trash2 size={14} /> | ||
| </button> | ||
| </li> | ||
| ))} | ||
| <ExternalLink size={14} className="text-gray-500 flex-shrink-0" /> | ||
| <a | ||
| href={res.url} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="flex-grow min-w-0 truncate text-sm text-blue-300 hover:text-blue-200 hover:underline" | ||
| title={res.url} | ||
| > | ||
| {res.label || res.url} | ||
| </a> | ||
| <div className="flex items-center gap-0.5 flex-shrink-0"> | ||
| <button | ||
| onClick={() => startEdit(index)} | ||
| aria-label={`Edit ${res.label || res.url}`} | ||
| className="p-1 text-gray-500 hover:text-white rounded-md transition-colors" | ||
| > | ||
| <Edit2 size={14} /> | ||
| </button> | ||
| <button | ||
| onClick={() => handleRemoveResource(index)} | ||
| aria-label={`Remove ${res.label || res.url}`} | ||
| className="p-1 text-gray-500 hover:text-red-400 rounded-md transition-colors" | ||
| > | ||
| <Trash2 size={14} /> | ||
| </button> | ||
| </div> | ||
| </li> | ||
| ) | ||
| )} |
There was a problem hiding this comment.
Update the list rendering to use editingUrl instead of editingIndex and pass the resource object res to startEdit.
{resources.map((res, index) =>
editingUrl === res.url ? (
<li key={`edit-${res.url}`} className="p-2.5 bg-gray-900/40 border border-gray-700/40 rounded-xl">
<form onSubmit={handleUpdateResource} className="grid grid-cols-1 sm:grid-cols-[1fr_1.5fr_auto] gap-2">
<input
type="text"
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
placeholder="Label (optional)"
className="bg-gray-800 border border-gray-700 rounded-lg px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
<input
type="text"
value={editUrl}
onChange={(e) => setEditUrl(e.target.value)}
placeholder="https://…"
className="bg-gray-800 border border-gray-700 rounded-lg px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
autoFocus
/>
<div className="flex gap-1">
<button type="submit" aria-label="Save link" className="flex-1 sm:flex-none px-2.5 py-1.5 bg-emerald-600 hover:bg-emerald-700 rounded-lg text-xs font-semibold">
<Check size={14} />
</button>
<button type="button" onClick={() => setEditingUrl(null)} aria-label="Cancel" className="flex-1 sm:flex-none px-2.5 py-1.5 bg-gray-600 hover:bg-gray-700 rounded-lg text-xs font-semibold">
<X size={14} />
</button>
</div>
</form>
{editError && <p className="text-red-400 text-xs mt-1.5">{editError}</p>}
</li>
) : (
<li
key={`${res.url}-${index}`}
className="flex items-center gap-3 p-2.5 bg-gray-900/40 border border-gray-700/30 rounded-xl hover:border-gray-600/50 transition-colors"
>
<ExternalLink size={14} className="text-gray-500 flex-shrink-0" />
<a
href={res.url}
target="_blank"
rel="noopener noreferrer"
className="flex-grow min-w-0 truncate text-sm text-blue-300 hover:text-blue-200 hover:underline"
title={res.url}
>
{res.label || res.url}
</a>
<div className="flex items-center gap-0.5 flex-shrink-0">
<button
onClick={() => startEdit(res)}
aria-label={`Edit ${res.label || res.url}`}
className="p-1 text-gray-500 hover:text-white rounded-md transition-colors"
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleRemoveResource(index)}
aria-label={`Remove ${res.label || res.url}`}
className="p-1 text-gray-500 hover:text-red-400 rounded-md transition-colors"
>
<Trash2 size={14} />
</button>
</div>
</li>
)
)}
Addresses reported issues:
Ctrl/Cmd+Kshortcut for the command palette, and a "drag the handle to reorder" hint when the Manual sort mode is selected.Build is clean.
🤖 Generated with Claude Code