diff --git a/CHANGELOG.md b/CHANGELOG.md index 4940ced..234ccb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.54.0] - 2026-11-02 + +### Added +- KI-Agenten können über MCP Unteraufgaben anlegen, abhaken, wieder öffnen, umbenennen und entfernen (`add_subtask`, `update_subtask`). Dafür reicht das Recht „Aufgaben ändern“; ist die Aufgabe mit einer Notiz verknüpft, ziehen die Checkboxen dort mit. +- Am Handy lässt sich die Liste über den Titel oben wechseln: Ein Tipp auf den Namen der offenen Aufgabenliste bzw. Liste klappt alle Listen mit ihren Zählern auf. + +### Fixed +- Notizen: Beim Klick in einen Punkt wurde die Zeile ein paar Pixel höher, sodass alles darunter kurz nach unten sprang. +- Aufgaben-Details am Handy: Die Herkunfts-Badge eines API-Schlüssels ragte über den Knopf „Beobachten“. Am Handy steht die Herkunft jetzt nur noch unten; der Knopf „Beobachten“ ist so hoch wie die Knöpfe daneben. + ## [0.53.0] - 2026-11-01 ### Added @@ -797,7 +807,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. - 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.53.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.54.0...dev +[0.54.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.53.0...v0.54.0 [0.53.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...v0.53.0 [0.52.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...v0.52.0 [0.51.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.50.0...v0.51.0 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index addcef6..ffffefb 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -94,6 +94,8 @@ Betreiber können MCP abschalten (`MCP_ENABLED=false` oder Schalter in der Admin | `create_task` | `tasks:write` | Aufgabe anlegen (`content`, `taskListId`, `priority`, `dueDate`, `notes`); Quelle `agent` | | `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) | | `complete_task` | `tasks:write` | Abhaken (`completed` false = wieder öffnen) | +| `add_subtask` | `tasks:write` | Unteraufgabe anhängen (`taskId`, `title`) | +| `update_subtask` | `tasks:write` | Unteraufgabe abhaken (`completed`), umbenennen (`title`) oder entfernen (`delete`) | | `delete_task` | `tasks:write` | Aufgabe löschen (z. B. versehentlich angelegte); sie kommt in den Papierkorb der Aufgaben | | `list_lists` | `lists:read` | Geteilte Listen mit offenen Einträgen | | `create_list` | `lists:write` | Liste anlegen (`title`, `icon`, `listType` shopping/checklist/vacation) | diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index 9c81430..956cc36 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -77,6 +77,14 @@ Komponente: [OriginBadge.tsx](../frontend/src/components/OriginBadge.tsx) - Höchstens eine Badge pro Eintrag: der Schlüssel, der angelegt hat, sonst der, der zuletzt geändert hat. Sie bleibt dauerhaft, auch nach eigenem Bearbeiten und nach der Triage. - Details (angelegt / zuletzt geändert, mit Schlüssel und Zeit) per Mouseover und per Tippen (kleines Info-Fenster, am Handy mit vergrößerter Trefferfläche); in der Aufgaben-Detailansicht stehen sie zusätzlich unten (`originDetails`). Ein Tipp auf die Badge öffnet nie den Eintrag. - Größen: `sm` in Aufgabenzeilen, Inbox-Liste und Detailansicht, `xs` auf Kanban-Karten und in Listen, `md` auf den Triage-Karten. +- In der Aufgaben-Detailansicht steht die Badge am Handy nicht im Kopf (kein Platz neben den Knöpfen); dort zeigen nur die Details unten die Herkunft. + +## Liste wechseln (Handy) + +Komponente: [ListSwitcher.tsx](../frontend/src/components/ListSwitcher.tsx) + +- In der Kopfleiste am Handy (Aufgaben, Listen) ist der Titel der offenen Liste ein Knopf mit Pfeil. Ein Tipp klappt alle Listen auf, mit Icon, Zähler (`CountBadge`, `muted`) und Haken an der offenen; ein Tipp auf eine Liste wechselt dorthin. +- Schließt bei Tipp daneben und mit Escape. Am Desktop bleiben die Reiter über der Liste. ## Mikrofon in Eingabezeilen diff --git a/README.md b/README.md index fc665d8..2ab8789 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.53.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.54.0-16a34a?style=flat-square)](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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dcde0d0..589419e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.53.0", + "version": "0.54.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.53.0", + "version": "0.54.0", "license": "AGPL-3.0-only", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 438abdc..26d5290 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.53.0", + "version": "0.54.0", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/frontend/src/components/ListSwitcher.tsx b/frontend/src/components/ListSwitcher.tsx new file mode 100644 index 0000000..1f3847e --- /dev/null +++ b/frontend/src/components/ListSwitcher.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Check, ChevronDown } from 'lucide-react'; +import { CountBadge } from './CountBadge'; + +export interface ListSwitcherItem { + id: string; + title: string; + icon?: string; + count?: number; +} + +interface ListSwitcherProps { + // Shown on the button: the list that is open now + label: string; + items: ListSwitcherItem[]; + activeId: string; + onSelect: (id: string) => void; +} + +// Title of the open list in the phone header (Doku/ui-patterns.md): a tap opens all lists to switch to +export const ListSwitcher: React.FC = ({ label, items, activeId, onSelect }) => { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const close = (e: Event) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('pointerdown', close); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('pointerdown', close); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + return ( +
+ + {open && ( +
+ {items.map((item) => { + const active = item.id === activeId; + return ( + + ); + })} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/__tests__/ListSwitcher.test.tsx b/frontend/src/components/__tests__/ListSwitcher.test.tsx new file mode 100644 index 0000000..95b6646 --- /dev/null +++ b/frontend/src/components/__tests__/ListSwitcher.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ListSwitcher } from '../ListSwitcher'; + +const items = [ + { id: 'a', title: 'Haushalt', icon: '🏠', count: 3 }, + { id: 'b', title: 'Arbeit' }, +]; + +describe('ListSwitcher', () => { + it('opens all lists on a tap and switches to the chosen one', () => { + const onSelect = vi.fn(); + render(); + expect(screen.queryByRole('listbox')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: /Haushalt/ })); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(2); + expect(options[0].getAttribute('aria-selected')).toBe('true'); + + fireEvent.click(screen.getByRole('option', { name: /Arbeit/ })); + expect(onSelect).toHaveBeenCalledWith('b'); + expect(screen.queryByRole('listbox')).toBeNull(); + }); + + it('closes without a change on the open list, Escape or a tap outside', () => { + const onSelect = vi.fn(); + render(); + const button = screen.getByRole('button', { name: /Haushalt/ }); + + fireEvent.click(button); + fireEvent.click(screen.getByRole('option', { name: /Haushalt/ })); + expect(screen.queryByRole('listbox')).toBeNull(); + + fireEvent.click(button); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('listbox')).toBeNull(); + + fireEvent.click(button); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole('listbox')).toBeNull(); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 0fec8c6..4db8067 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -16,6 +16,7 @@ import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { CountBadge } from '../../components/CountBadge'; +import { ListSwitcher } from '../../components/ListSwitcher'; import { OriginBadge } from '../../components/OriginBadge'; import { VoiceInputButton } from '../../components/VoiceInputButton'; import { FormSheet } from '../../components/FormSheet'; @@ -1170,9 +1171,21 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
- - {activeList ? [activeList.icon, activeList.title].filter(Boolean).join(' ') : '🛒 Einkaufsliste'} - + {activeList ? ( + ({ + id: lst.id, + title: lst.title, + icon: lst.icon, + count: deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length, + }))} + activeId={activeList.id} + onSelect={setActiveListId} + /> + ) : ( + 🛒 Einkaufsliste + )} {openItems.length > 0 && ( {openItems.length} offen diff --git a/frontend/src/features/settings/ApiKeysPanel.tsx b/frontend/src/features/settings/ApiKeysPanel.tsx index 7495147..3a4bcea 100644 --- a/frontend/src/features/settings/ApiKeysPanel.tsx +++ b/frontend/src/features/settings/ApiKeysPanel.tsx @@ -632,7 +632,8 @@ http_headers = { "X-API-Key" = "${exampleToken}" }` Werkzeuge: list_task_lists, create_task_list,{' '} list_tasks, get_task,{' '} create_task, update_task,{' '} - complete_task, delete_task,{' '} + complete_task, add_subtask,{' '} + update_subtask, delete_task,{' '} list_lists, create_list,{' '} get_list, add_list_item,{' '} check_list_item, update_list_item,{' '} diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index 45d838f..1cded09 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -236,7 +236,8 @@ const TaskDetailModalContent: React.FC > - + {/* Phones: squeezed to a single letter next to the buttons, so left out */} + {readOnly ? 'Aufgabe ansehen' : task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'} {readOnly && ( @@ -245,7 +246,10 @@ const TaskDetailModalContent: React.FC Nur Lesen )} - + {/* Phones: no room next to the buttons; the details stand at the bottom anyway */} + + +
@@ -254,7 +258,7 @@ const TaskDetailModalContent: React.FC onClick={handleToggleWatch} disabled={watchBusy} title={isWatching ? 'Nicht mehr beobachten' : 'Beobachten'} - className={`btn-action min-h-[44px] sm:min-h-0 h-11 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 ${ + className={`btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 ${ isWatching ? 'border-accent text-accent' : '' }`} > diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 6b70182..973001f 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -40,6 +40,7 @@ import { VoiceInputButton } from '../../components/VoiceInputButton'; import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './ManageColumnsModal'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { CountBadge } from '../../components/CountBadge'; +import { ListSwitcher } from '../../components/ListSwitcher'; import { FormSheet } from '../../components/FormSheet'; import { EmojiIconButton } from '../../components/EmojiIconButton'; import { useMobileActions } from '../../context/MobileActionContext'; @@ -1953,9 +1954,15 @@ export const TasksView: React.FC = ({ {/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
- - {currentSelectedList ? [currentSelectedList.icon, currentSelectedList.title].filter(Boolean).join(' ') : '📋 Alle Aufgaben'} - + ({ id: tl.id, title: tl.title, icon: tl.icon, count: tl.tasksCount })), + ]} + activeId={selectedListId} + onSelect={setSelectedListId} + /> {viewMode === 'kanban' ? 'Kanban' diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 3208ba2..5c3158a 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -1437,6 +1437,8 @@ export const OutlinerBlock: React.FC = ({ )}
+ {/* Display block: inline, the focused field would sit on a text baseline and leave a gap below + it, so the row grew by a few pixels on every click */}