Merge branch 'feat/single-list-shortcut' into dev

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 20:36:11 +01:00
commit ef16778ef5
3 files changed
+232 -21

No files matched your search

@@ -38,6 +38,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
hideTargetPicker = false,
fixedTarget,
}) => {
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
const [content, setContent] = useState('');
const [priority, setPriority] = useState<PriorityLevel>('normal');
const [dueDate, setDueDate] = useState('');
@@ -77,10 +78,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}, [autoFocusTrigger]);
useEffect(() => {
if (!targetListId && lists.length > 0) {
setTargetListId(lists[0].id);
if (!targetListId && writableLists.length > 0) {
setTargetListId(writableLists[0].id);
}
}, [lists, targetListId]);
}, [writableLists, targetListId]);
// Close dropdown on outside click
useEffect(() => {
@@ -180,7 +181,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const effectiveTarget = fixedTarget || overrideTarget || target;
const effectiveListId =
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
overrideListId || targetListId || (writableLists.length > 0 ? writableLists[0].id : '');
const capturedPriority = priority;
const capturedDueDate = dueDate;
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
@@ -236,7 +237,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
await executeCapture();
};
const selectedList = lists.find((l) => l.id === targetListId) || lists[0];
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
const getTargetLabel = () => {
if (target === 'task') return 'Direkt-Aufgabe';
@@ -449,8 +450,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button"
onClick={() => {
setTarget('list_item');
if (!targetListId && lists.length > 0) {
setTargetListId(lists[0].id);
if (!targetListId && writableLists.length > 0) {
setTargetListId(writableLists[0].id);
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
@@ -485,13 +486,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</div>
{/* Optional Sub-Row: Shared List Picker */}
{lists.length > 0 && (
{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>
{lists.map((lst) => {
{writableLists.map((lst) => {
const isSelected = target === 'list_item' && targetListId === lst.id;
return (
<button
+59 -12
View File
@@ -221,6 +221,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
onDeckChange,
onFocusCapture,
}) => {
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
const [cards, setCards] = useState<Thought[]>(deck);
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
const [showDateModal, setShowDateModal] = useState(false);
@@ -702,8 +705,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
Quick Dump direkt einer Liste zuordnen:
</p>
<div className="flex flex-wrap gap-1.5">
{lists.length > 0 ? (
lists.map((lst) => (
{writableLists.length > 0 ? (
writableLists.map((lst) => (
<button
key={lst.id}
onClick={() =>
@@ -746,17 +749,35 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</button>
<button
disabled={writableLists.length === 0}
onClick={() => {
if (singleWritableList) {
handleAction('keep', {
convertTo: 'list_item',
targetListId: singleWritableList.id,
});
return;
}
setShowDateModal(false);
setShowListSelector((prev) => !prev);
}}
title="Auf eine geteilte Liste setzen"
title={
writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? `Auf ${singleWritableList.title}`
: 'Auf eine geteilte Liste setzen'
}
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
showListSelector ? 'border-accent text-accent bg-accent-dim' : ''
writableLists.length === 0
? 'opacity-40 cursor-not-allowed'
: showListSelector
? 'border-accent text-accent bg-accent-dim'
: ''
}`}
>
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span>Auf Liste</span>
<span>{singleWritableList ? `Auf ${singleWritableList.title}` : 'Auf Liste'}</span>
</button>
<button
@@ -810,7 +831,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<SwipeableQueueItem
key={item.id}
item={item}
lists={lists}
lists={writableLists}
onBringToFront={handleBringToFront}
onOpenActionSheet={openActionSheetForCard}
onAction={handleAction}
@@ -854,13 +875,39 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setSheetMode('list')}
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
disabled={writableLists.length === 0}
onClick={() => {
if (singleWritableList) {
handleAction('keep', {
convertTo: 'list_item',
targetListId: singleWritableList.id,
thoughtOverride: actionSheetCard,
});
return;
}
setSheetMode('list');
}}
title={
writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? `Auf ${singleWritableList.title}`
: 'Auf Liste setzen'
}
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 ${
writableLists.length === 0 ? 'opacity-40 cursor-not-allowed' : ''
}`}
>
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
<div>
<div className="min-w-0">
<div className="text-sm font-semibold text-main">Auf Liste</div>
<div className="text-[11px] text-muted">Einkauf / Geteilt</div>
<div className="text-[11px] text-muted truncate">
{writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? singleWritableList.title
: 'Einkauf / Geteilt'}
</div>
</div>
</button>
@@ -999,8 +1046,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
Auf welche Liste soll dieser Punkt gesetzt werden?
</p>
<div className="max-h-60 overflow-y-auto space-y-1.5">
{lists.length > 0 ? (
lists.map((lst) => (
{writableLists.length > 0 ? (
writableLists.map((lst) => (
<button
key={lst.id}
type="button"
@@ -0,0 +1,163 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { TriageDeck } from '../TriageDeck';
import { api } from '../../../lib/api';
import { SharedList, Thought } from '../../../lib/types';
const sampleDeck: Thought[] = [
{
id: 'th_1',
userId: 'u_1',
content: 'Hafermilch und Kaffeebohnen',
status: 'inbox',
priority: 'normal',
source: 'manual',
isCompleted: false,
tags: [],
createdAt: '2026-10-29T10:00:00Z',
},
];
const ownList: SharedList = {
id: 'lst_own',
title: 'Wocheneinkauf',
icon: '🛒',
listType: 'shopping',
isOwner: true,
role: 'owner',
items: [],
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
};
const secondEditableList: SharedList = {
id: 'lst_edit',
title: 'Baumarkt',
icon: '🔨',
listType: 'checklist',
isOwner: false,
role: 'editor',
items: [],
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
};
const viewerList: SharedList = {
id: 'lst_view',
title: 'WG-Archiv',
icon: '📋',
listType: 'checklist',
isOwner: false,
role: 'viewer',
items: [],
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
};
describe('TriageDeck single-list shortcut', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(api, 'triageAction').mockResolvedValue(sampleDeck[0]);
});
it('directly assigns to the single own list without opening the selector', async () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[ownList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
expect((btn as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(btn);
await waitFor(() => {
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
convertTo: 'list_item',
targetListId: 'lst_own',
dueDate: undefined,
});
expect(onDeckChange).toHaveBeenCalledTimes(1);
});
expect(screen.queryByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeNull();
});
it('opens the list selector when two writable lists exist instead of assigning directly', () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[ownList, secondEditableList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /^Auf Liste$/i });
fireEvent.click(btn);
expect(api.triageAction).not.toHaveBeenCalled();
expect(onDeckChange).not.toHaveBeenCalled();
expect(screen.getByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeTruthy();
expect(screen.getByRole('button', { name: /Wocheneinkauf/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Baumarkt/i })).toBeTruthy();
});
it('treats one own list and one viewer-only shared list as a single writable list', async () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[viewerList, ownList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
fireEvent.click(btn);
await waitFor(() => {
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
convertTo: 'list_item',
targetListId: 'lst_own',
dueDate: undefined,
});
expect(onDeckChange).toHaveBeenCalledTimes(1);
});
});
it('disables the list button when no writable list exists', () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[viewerList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /^Auf Liste$/i }) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
expect(btn.title).toBe('Keine Liste vorhanden');
fireEvent.click(btn);
expect(api.triageAction).not.toHaveBeenCalled();
expect(onDeckChange).not.toHaveBeenCalled();
});
});