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]
|
||||
|
||||
### 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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
Flame,
|
||||
Loader2,
|
||||
} 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 { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||
import { useVoiceInput } from '../../lib/useVoiceInput';
|
||||
import { VoiceWave } from '../../components/VoiceWave';
|
||||
|
||||
@@ -47,7 +48,12 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
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 [voiceError, setVoiceError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -104,6 +110,22 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (inlineOptions) return;
|
||||
@@ -151,7 +173,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
|
||||
const executeCapture = async (
|
||||
overrideTarget?: CaptureTarget,
|
||||
overrideListId?: string
|
||||
overrideListId?: string,
|
||||
overrideTaskListId?: string
|
||||
) => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || isSubmitting) return;
|
||||
@@ -200,6 +223,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
{
|
||||
target: effectiveTarget,
|
||||
targetListId: effectiveTarget === 'list_item' ? effectiveListId : '',
|
||||
taskListId:
|
||||
effectiveTarget === 'task' ? (overrideTaskListId ?? (selectedTaskList ? selectedTaskList.id : '')) : '',
|
||||
}
|
||||
);
|
||||
onCaptured(created, effectiveTarget);
|
||||
@@ -219,7 +244,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
|
||||
|
||||
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 === 'document') return 'Direkt-Notiz';
|
||||
return 'Quick Dump';
|
||||
@@ -430,7 +455,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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');
|
||||
}
|
||||
}}
|
||||
@@ -441,7 +467,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Option 3: Direct List Item */}
|
||||
@@ -484,15 +510,48 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional Sub-Row: Shared List Picker */}
|
||||
{writableLists.length > 0 && (
|
||||
{/* Sub-row for a direct task: which task list */}
|
||||
{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">
|
||||
<span className="text-muted mr-1 flex items-center gap-1">
|
||||
<ShoppingCart className="w-3 h-3 text-accent" />
|
||||
Liste:
|
||||
</span>
|
||||
{writableLists.map((lst) => {
|
||||
const isSelected = target === 'list_item' && targetListId === lst.id;
|
||||
const isSelected = targetListId === lst.id;
|
||||
return (
|
||||
<button
|
||||
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