feat(capture): quick dump list row follows the target (task lists for tasks)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 11:29:06 +01:00
1 parent 83b3f27b3e
commit c851b8e3ba
3 files changed
+131 -9

No files matched your search

+3
View File
@@ -6,6 +6,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
### Changed
- Quick Dump: Die Listenzeile unter den Zielen richtet sich nach dem Ziel. Bei „Direkt als Aufgabe“ wählst du die Aufgabenliste (oder „Alle Aufgaben“), bei „Direkt auf Liste“ die Einkaufs- oder Checkliste; bei Quick Dump und Notiz gibt es keine Zeile mehr. Die zuletzt gewählte Liste bleibt je Ziel für die Sitzung gemerkt.
## [0.32.0] - 2026-11-01 ## [0.32.0] - 2026-11-01
### Added ### Added
@@ -15,8 +15,9 @@ import {
Flame, Flame,
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { PriorityLevel, SharedList, Thought } from '../../lib/types'; import { PriorityLevel, SharedList, TaskList, Thought } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
import { readViewSession, writeViewSession } from '../../lib/viewSession';
import { useVoiceInput } from '../../lib/useVoiceInput'; import { useVoiceInput } from '../../lib/useVoiceInput';
import { VoiceWave } from '../../components/VoiceWave'; import { VoiceWave } from '../../components/VoiceWave';
@@ -47,7 +48,12 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const [priority, setPriority] = useState<PriorityLevel>('normal'); const [priority, setPriority] = useState<PriorityLevel>('normal');
const [dueDate, setDueDate] = useState(''); const [dueDate, setDueDate] = useState('');
const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox'); const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox');
const [targetListId, setTargetListId] = useState<string>(''); // Last chosen list per target, for this tab (lib/viewSession)
const remembered = useMemo(() => readViewSession<{ listId: string; taskListId: string }>('capture'), []);
const [targetListId, setTargetListId] = useState<string>(remembered.listId || '');
const [targetTaskListId, setTargetTaskListId] = useState<string>(remembered.taskListId || '');
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const writableTaskLists = useMemo(() => taskLists.filter((l) => l.permission !== 'viewer'), [taskLists]);
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker); const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
const [voiceError, setVoiceError] = useState<string | null>(null); const [voiceError, setVoiceError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -104,6 +110,22 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
} }
}, [writableLists, targetListId]); }, [writableLists, targetListId]);
useEffect(() => {
writeViewSession('capture', { listId: targetListId, taskListId: targetTaskListId });
}, [targetListId, targetTaskListId]);
// Task lists only matter once the options are open; loaded each time, so new lists show up
useEffect(() => {
if (!showOptions || hideTargetPicker) return;
api
.getTaskLists()
.then((tl) => setTaskLists(Array.isArray(tl) ? tl : []))
.catch(() => {});
}, [showOptions, hideTargetPicker]);
// A remembered task list that is gone (deleted, access removed) falls back to all tasks
const selectedTaskList = writableTaskLists.find((l) => l.id === targetTaskListId);
// Close dropdown on outside click // Close dropdown on outside click
useEffect(() => { useEffect(() => {
if (inlineOptions) return; if (inlineOptions) return;
@@ -151,7 +173,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const executeCapture = async ( const executeCapture = async (
overrideTarget?: CaptureTarget, overrideTarget?: CaptureTarget,
overrideListId?: string overrideListId?: string,
overrideTaskListId?: string
) => { ) => {
const trimmed = content.trim(); const trimmed = content.trim();
if (!trimmed || isSubmitting) return; if (!trimmed || isSubmitting) return;
@@ -200,6 +223,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
{ {
target: effectiveTarget, target: effectiveTarget,
targetListId: effectiveTarget === 'list_item' ? effectiveListId : '', targetListId: effectiveTarget === 'list_item' ? effectiveListId : '',
taskListId:
effectiveTarget === 'task' ? (overrideTaskListId ?? (selectedTaskList ? selectedTaskList.id : '')) : '',
} }
); );
onCaptured(created, effectiveTarget); onCaptured(created, effectiveTarget);
@@ -219,7 +244,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0]; const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
const getTargetLabel = () => { const getTargetLabel = () => {
if (target === 'task') return 'Direkt-Aufgabe'; if (target === 'task') return selectedTaskList ? `${selectedTaskList.icon || '📋'} ${selectedTaskList.title}` : 'Direkt-Aufgabe';
if (target === 'list_item') return selectedList ? `${selectedList.icon} ${selectedList.title}` : 'Auf Liste'; if (target === 'list_item') return selectedList ? `${selectedList.icon} ${selectedList.title}` : 'Auf Liste';
if (target === 'document') return 'Direkt-Notiz'; if (target === 'document') return 'Direkt-Notiz';
return 'Quick Dump'; return 'Quick Dump';
@@ -430,7 +455,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button" type="button"
onClick={() => { onClick={() => {
setTarget('task'); setTarget('task');
if (content.trim()) { // With task lists the row below picks one (and saves); otherwise save right away
if (content.trim() && writableTaskLists.length === 0) {
executeCapture('task'); executeCapture('task');
} }
}} }}
@@ -441,7 +467,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}`} }`}
> >
<CheckSquare className="w-3.5 h-3.5 text-accent flex-shrink-0" /> <CheckSquare className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span className="truncate">Direkt als Aufgabe</span> <span className="truncate">Direkt als Aufgabe{writableTaskLists.length > 0 ? '…' : ''}</span>
</button> </button>
{/* Option 3: Direct List Item */} {/* Option 3: Direct List Item */}
@@ -484,15 +510,48 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</div> </div>
</div> </div>
{/* Optional Sub-Row: Shared List Picker */} {/* Sub-row for a direct task: which task list */}
{writableLists.length > 0 && ( {target === 'task' && writableTaskLists.length > 0 && (
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
<span className="text-muted mr-1 flex items-center gap-1">
<CheckSquare className="w-3 h-3 text-accent" />
Aufgabenliste:
</span>
{[{ id: '', icon: '📥', title: 'Alle Aufgaben' }, ...writableTaskLists].map((tl) => {
const isSelected = (selectedTaskList?.id || '') === tl.id;
return (
<button
key={tl.id || 'all'}
type="button"
onClick={() => {
setTargetTaskListId(tl.id);
if (content.trim()) {
executeCapture('task', undefined, tl.id);
}
}}
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
isSelected
? 'bg-accent text-on-accent border-accent font-medium'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<span>{tl.icon || '📋'}</span>
<span>{tl.title}</span>
</button>
);
})}
</div>
)}
{/* Sub-row for a list item: which list (shopping, checklist …) */}
{target === 'list_item' && writableLists.length > 0 && (
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5"> <div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
<span className="text-muted mr-1 flex items-center gap-1"> <span className="text-muted mr-1 flex items-center gap-1">
<ShoppingCart className="w-3 h-3 text-accent" /> <ShoppingCart className="w-3 h-3 text-accent" />
Liste: Liste:
</span> </span>
{writableLists.map((lst) => { {writableLists.map((lst) => {
const isSelected = target === 'list_item' && targetListId === lst.id; const isSelected = targetListId === lst.id;
return ( return (
<button <button
key={lst.id} key={lst.id}
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
const captureThought = vi.fn(async (content: string) => ({ id: 't1', content }));
vi.mock('../../../lib/api', () => ({
api: {
getTaskLists: vi.fn(async () => [
{ id: 'tkl_1', title: 'Homelab', icon: '🖥️', permission: 'owner' },
{ id: 'tkl_2', title: 'Fremd', icon: '🔒', permission: 'viewer' },
]),
captureThought: (...args: unknown[]) => captureThought(...(args as [string])),
getAuthStatus: vi.fn(async () => ({ voice: { mode: 'off' } })),
},
triggerHaptic: vi.fn(),
}));
import { InstantCaptureBar } from '../InstantCaptureBar';
const lists = [{ id: 'lst_1', title: 'Wocheneinkauf', icon: '🛒', isOwner: true, listType: 'shopping', items: [] }] as any;
describe('InstantCaptureBar', () => {
beforeEach(() => {
captureThought.mockClear();
sessionStorage.clear();
});
it('shows the list row that fits the target and saves into the chosen task list', async () => {
await act(async () => {
render(<InstantCaptureBar onCaptured={() => {}} lists={lists} defaultShowOptions />);
});
// Quick Dump: no list row
expect(screen.queryByText('Wocheneinkauf')).toBeNull();
expect(screen.queryByText('Homelab')).toBeNull();
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Backup prüfen' } });
fireEvent.click(screen.getByRole('button', { name: /Direkt als Aufgabe/ }));
// With task lists, choosing the target does not save yet; read-only lists are not offered
expect(captureThought).not.toHaveBeenCalled();
expect(screen.queryByText('Wocheneinkauf')).toBeNull();
expect(screen.queryByText('Fremd')).toBeNull();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Homelab/ }));
});
expect(captureThought).toHaveBeenCalledWith('Backup prüfen', 'normal', 'manual', '', {
target: 'task',
targetListId: '',
taskListId: 'tkl_1',
});
});
it('offers the shopping lists for a list item', async () => {
await act(async () => {
render(<InstantCaptureBar onCaptured={() => {}} lists={lists} defaultShowOptions />);
});
fireEvent.click(screen.getByRole('button', { name: /Direkt auf Liste/ }));
// The list chip (the target pill shows the chosen list as well)
expect(screen.getAllByRole('button', { name: /Wocheneinkauf/ }).length).toBe(2);
expect(screen.queryByText('Homelab')).toBeNull();
});
});