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:
1 parent
83b3f27b3e
commit
c851b8e3ba
3 files changed
+131
-9
No files matched your search
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user