Merge dev into main (v0.33.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 26s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 26s
This commit is contained in:
commit
cd26626d84
10 files changed
+205
-17
No files matched your search
+8
-1
@@ -6,6 +6,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.33.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- „Als Notiz vertiefen“ bei einer Aufgabe: Die Aufgabe bleibt in ihrer Liste und wird zum ersten Häkchen der neuen Notiz, verknüpft wie eine Aufgabe aus einer Notiz („In Notiz“); Abhaken wirkt an beiden Stellen. Ein Gedanke aus der Triage wird wie bisher zur Notiz.
|
||||||
|
- 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
|
||||||
@@ -590,7 +596,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.32.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.33.0...dev
|
||||||
|
[0.33.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.32.0...v0.33.0
|
||||||
[0.32.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.31.0...v0.32.0
|
[0.32.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.31.0...v0.32.0
|
||||||
[0.31.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.30.0...v0.31.0
|
[0.31.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.30.0...v0.31.0
|
||||||
[0.30.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.29.0...v0.30.0
|
[0.30.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.29.0...v0.30.0
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
|
|||||||
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
|
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
|
||||||
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Farben in „Spalten anpassen“ über den Punkt vor dem Namen: Grau, Anthrazit (Textfarbe des Themes), Grün, Gelb, Blau, Rot, Lila, Akzentfarbe (`columns[].color` = `gray`/`slate`/`green`/`yellow`/`blue`/`red`/`purple`/`accent`; ältere Werte `emerald`/`amber` gelten als Grün/Gelb, Spalten ohne Farbe behalten die ihrer Rolle). Der Punkt erscheint im Spaltenkopf, vor dem Status in der Listenansicht und im Detail-Fenster ([ManageColumnsModal.tsx](../../frontend/src/features/triage/ManageColumnsModal.tsx), `columnDotClass`). Blau und Lila kommen aus `--color-info` / `--color-purple`; Themes können sie setzen, sonst gelten Standardwerte aus `index.css`. Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
|
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Farben in „Spalten anpassen“ über den Punkt vor dem Namen: Grau, Anthrazit (Textfarbe des Themes), Grün, Gelb, Blau, Rot, Lila, Akzentfarbe (`columns[].color` = `gray`/`slate`/`green`/`yellow`/`blue`/`red`/`purple`/`accent`; ältere Werte `emerald`/`amber` gelten als Grün/Gelb, Spalten ohne Farbe behalten die ihrer Rolle). Der Punkt erscheint im Spaltenkopf, vor dem Status in der Listenansicht und im Detail-Fenster ([ManageColumnsModal.tsx](../../frontend/src/features/triage/ManageColumnsModal.tsx), `columnDotClass`). Blau und Lila kommen aus `--color-info` / `--color-purple`; Themes können sie setzen, sonst gelten Standardwerte aus `index.css`. Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
|
||||||
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit optionaler Uhrzeit und Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
|
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit optionaler Uhrzeit und Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
|
||||||
- Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
|
- „Als Notiz vertiefen“ legt aus einer Aufgabe eine Notiz an (Titel, Notizen, Unteraufgaben, Fälligkeit, Priorität als Inhalt). Die Aufgabe bleibt in ihrer Liste und wird mit dem ersten Häkchen der Notiz verknüpft (`converted_type = document_block`); ein Gedanke aus der Triage wird dagegen ganz zur Notiz. Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
|
||||||
|
|
||||||
## Aufgabenlisten und Freigabe
|
## Aufgabenlisten und Freigabe
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.32.0",
|
"version": "0.33.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.32.0",
|
"version": "0.33.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.32.0",
|
"version": "0.33.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -666,8 +666,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
|
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
|
||||||
onRefreshStats();
|
onRefreshStats();
|
||||||
if (updated.convertedRefId) {
|
// The task stays in its list, linked to the note's first checkbox (docId); open that note
|
||||||
onOpenDocument(updated.convertedRefId);
|
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||||
|
const docId = updated.docId || (updated.convertedType === 'document' ? updated.convertedRefId : '');
|
||||||
|
if (docId) {
|
||||||
|
onOpenDocument(docId);
|
||||||
} else {
|
} else {
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// "Als Notiz vertiefen": a task stays in its list, linked to the note's first checkbox; a thought from
|
||||||
|
// the inbox still turns into the note.
|
||||||
|
func TestDeepenTaskIntoNoteKeepsTheTask(t *testing.T) {
|
||||||
|
store, err := NewStore(filepath.Join(t.TempDir(), "deepen.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||||
|
tl, _ := store.CreateTaskList(anna.ID, "", "", "Homelab", "", "")
|
||||||
|
|
||||||
|
th, _ := store.CreateThoughtWithDetails(anna.ID, "Backup-Konzept", "high", "manual", "", tl.ID, "todo", "Restic oder Borg?")
|
||||||
|
if _, err := store.UpdateThoughtTriage(anna.ID, th.ID, "keep", "task", "", "", tl.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
deepened, err := store.UpdateThoughtTriage(anna.ID, th.ID, "keep", "document", "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if deepened.Status != domain.StatusKept || deepened.ConvertedType != "document_block" || deepened.DocID == "" || deepened.TaskListID != tl.ID {
|
||||||
|
t.Fatalf("task should stay in its list, linked to the note: %+v", deepened)
|
||||||
|
}
|
||||||
|
tasks, _ := store.GetTasks(anna.ID, tl.ID)
|
||||||
|
if len(tasks) != 1 || tasks[0].ID != th.ID {
|
||||||
|
t.Fatalf("task missing from its list: %+v", tasks)
|
||||||
|
}
|
||||||
|
doc, err := store.GetDocumentByID(anna.ID, deepened.DocID)
|
||||||
|
if err != nil || doc == nil || len(doc.Blocks) == 0 || doc.Blocks[0].ID != deepened.BlockID || doc.Blocks[0].Type != "todo" {
|
||||||
|
t.Fatalf("first block should be the linked checkbox: %+v %v", doc, err)
|
||||||
|
}
|
||||||
|
// Completing the task ticks the checkbox in the note
|
||||||
|
if store.SyncDocBlockAndTask(anna.ID, th.ID, true) != deepened.DocID {
|
||||||
|
t.Fatal("completing the task should tick its block")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A thought from the inbox becomes the note as before
|
||||||
|
inbox, _ := store.CreateThought(anna.ID, "Idee für Blogartikel", "normal", "manual", "")
|
||||||
|
asNote, err := store.UpdateThoughtTriage(anna.ID, inbox.ID, "keep", "document", "", "", "")
|
||||||
|
if err != nil || asNote.Status != domain.StatusConverted || asNote.ConvertedType != "document" {
|
||||||
|
t.Fatalf("inbox thought should turn into a note: %+v %v", asNote, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -310,6 +310,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
newStatus = domain.StatusConverted
|
newStatus = domain.StatusConverted
|
||||||
convertedType = "task"
|
convertedType = "task"
|
||||||
} else if convertTo == "document" {
|
} else if convertTo == "document" {
|
||||||
|
isTask := thought.Status == domain.StatusKept || (thought.Status == domain.StatusConverted && thought.ConvertedType == "task")
|
||||||
newStatus = domain.StatusConverted
|
newStatus = domain.StatusConverted
|
||||||
convertedType = "document"
|
convertedType = "document"
|
||||||
title := thought.Content
|
title := thought.Content
|
||||||
@@ -373,6 +374,13 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
doc, err := s.CreateDocument(userID, "", title, "📄", "", blocks, thought.Tags, false)
|
doc, err := s.CreateDocument(userID, "", title, "📄", "", blocks, thought.Tags, false)
|
||||||
if err == nil && doc != nil {
|
if err == nil && doc != nil {
|
||||||
convertedRefID = doc.ID
|
convertedRefID = doc.ID
|
||||||
|
// A task stays in its list and becomes the note's first checkbox (same link as a task made
|
||||||
|
// from a note block); a thought from the inbox turns into the note
|
||||||
|
if isTask {
|
||||||
|
newStatus = domain.StatusKept
|
||||||
|
convertedType = "document_block"
|
||||||
|
convertedRefID = doc.ID + ":" + blocks[0].ID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if convertTo == "list_item" && targetListID != "" {
|
} else if convertTo == "list_item" && targetListID != "" {
|
||||||
// Same rule as adding an item in the list itself: owner or editor
|
// Same rule as adding an item in the list itself: owner or editor
|
||||||
|
|||||||
Reference in new issue
Block a user