diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 374827a..0fd0306 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -30,7 +30,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` | | POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) | | GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) | -| GET | `/stats` | Zähler fürs Dashboard | +| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) | | GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` | | GET | `/export` | Datenexport als JSON; mit `?format=zip` dieselben Daten als `mindsift-backup.json` in einer ZIP (so lädt die Oberfläche das Backup) | | GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/.md` | diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index f89a889..892457e 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -53,7 +53,7 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla - **Form:** runder Kreis, mindestens 16 px, ab 100 als „99+“ (der Screenreader liest die echte Zahl). Bei 0 ausgeblendet, außer mit `showZero` (z. B. Mitgliederzahl, mobiles Menü). - **Farbe (`variant`):** - - `accent`: Hier wartet etwas auf dich. Navigationsleiste (Triage, Workspace, Aufgaben). + - `accent`: Hier wartet etwas auf dich. Navigationsleiste: Triage (Inbox), Aufgaben (offene Aufgaben), Listen (offene Einträge aller eigenen und geteilten Listen). Workspace hat keinen Zähler. - `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü. - Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an. - **Position (`position`):** diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b2fc5a8..4567a39 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -141,6 +141,7 @@ export const App: React.FC = () => { tasksDoneCount: 0, docsCount: 0, docsTodosOpenCount: 0, + listItemsOpenCount: 0, listsCount: 0, discardedCount: 0, }); @@ -550,14 +551,14 @@ export const App: React.FC = () => { } }, [user?.id]); - // Refresh stats on task/doc real-time events + // Refresh stats on task/doc/list real-time events (counters in the navigation) useEffect(() => { const handleWs = (e: any) => { const msg = e.detail; if (!msg) return; if ( typeof msg.event === 'string' && - (msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_') || msg.event.startsWith('SPACE_')) + ['TASK_', 'DOC_', 'SPACE_', 'ITEM_', 'LIST_'].some((prefix) => msg.event.startsWith(prefix)) ) { api.getMe().then((res) => { setStats(res.stats); @@ -1000,10 +1001,7 @@ export const App: React.FC = () => { className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`} > - 0 ? 'pr-2' : ''}`}> - Workspace - - + Workspace @@ -1166,10 +1167,7 @@ export const App: React.FC = () => { }`} title="Workspace" > -
- - -
+ Workspace @@ -1223,7 +1221,10 @@ export const App: React.FC = () => { }`} title="Listen" > - +
+ + +
Listen diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 59e24b3..fca492a 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -357,6 +357,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba ) ); await api.deleteListItem(activeList.id, itemId); + onRefreshGlobal(); }; const handleClearCompleted = async () => { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index da9c332..a372c14 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -490,6 +490,7 @@ export interface DashboardStats { tasksDoneCount: number; docsCount: number; docsTodosOpenCount?: number; + listItemsOpenCount?: number; listsCount: number; discardedCount: number; } diff --git a/internal/domain/models.go b/internal/domain/models.go index ba70a8e..aeb3653 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -520,6 +520,7 @@ type DashboardStats struct { DocsCount int `json:"docsCount"` DocsTodosOpenCount int `json:"docsTodosOpenCount"` ListsCount int `json:"listsCount"` + ListItemsOpenCount int `json:"listItemsOpenCount"` // unchecked items in all lists the user sees DiscardedCount int `json:"discardedCount"` } diff --git a/internal/storage/system.go b/internal/storage/system.go index 61f1945..9554149 100644 --- a/internal/storage/system.go +++ b/internal/storage/system.go @@ -18,15 +18,18 @@ func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 1`, userID, userID, userID, userID).Scan(&st.TasksDoneCount) _ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ? AND is_archived = 0`, userID).Scan(&st.DocsCount) _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount) - _ = s.db.QueryRow(` - SELECT COUNT(DISTINCT l.id) - FROM lists l + // Own lists and lists shared with the user directly or through a group + visibleLists := ` + SELECT l.id + FROM lists l LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( (ls.target_type = 'user' AND ls.target_id = ?) OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) ) - WHERE l.user_id = ? OR ls.id IS NOT NULL`, - userID, userID, userID).Scan(&st.ListsCount) + WHERE l.user_id = ? OR ls.id IS NOT NULL` + _ = s.db.QueryRow(`SELECT COUNT(DISTINCT id) FROM (`+visibleLists+`)`, userID, userID, userID).Scan(&st.ListsCount) + _ = s.db.QueryRow(`SELECT COUNT(*) FROM list_items WHERE is_completed = 0 AND list_id IN (`+visibleLists+`)`, + userID, userID, userID).Scan(&st.ListItemsOpenCount) // Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared) docRows, err := s.db.Query(` diff --git a/internal/storage/system_test.go b/internal/storage/system_test.go new file mode 100644 index 0000000..aa7d3ca --- /dev/null +++ b/internal/storage/system_test.go @@ -0,0 +1,49 @@ +package storage + +import ( + "path/filepath" + "testing" +) + +func TestOpenListItemsCountCoversSharedLists(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "stats.db")) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + openSharing(t, store) + anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member") + ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member") + carla, _ := store.CreateUser("carla@example.com", "hash", "Carla", "member") + + addItems := func(owner, listID string, open, done int) { + for i := 0; i < open+done; i++ { + item, _ := store.CreateListItem(listID, "Eintrag", "", "", i, owner) + if i >= open { + _, _ = store.ToggleListItem(item.ID, true, owner, "") + } + } + } + own, _ := store.CreateList(anna.ID, "Einkauf", "🛒", "shopping") + addItems(anna.ID, own.ID, 2, 1) + direct, _ := store.CreateList(ben.ID, "Bens Liste", "", "shopping") + addItems(ben.ID, direct.ID, 1, 2) + if _, err := store.AddListShare(ben.ID, direct.ID, "user", anna.ID, "viewer"); err != nil { + t.Fatalf("share: %v", err) + } + viaGroup, _ := store.CreateList(carla.ID, "WG-Putzplan", "", "shopping") + addItems(carla.ID, viaGroup.ID, 3, 0) + team, _ := store.CreateTeam("", "WG", "") + _ = store.AddTeamMember(team.ID, anna.ID, "member") + _ = store.AddTeamMember(team.ID, carla.ID, "admin") + if _, err := store.AddListShare(carla.ID, viaGroup.ID, "team", team.ID, "editor"); err != nil { + t.Fatalf("team share: %v", err) + } + foreign, _ := store.CreateList(ben.ID, "Privat", "", "shopping") + addItems(ben.ID, foreign.ID, 5, 0) + + st, _ := store.GetDashboardStats(anna.ID) + if st.ListItemsOpenCount != 6 || st.ListsCount != 3 { + t.Errorf("want 6 open items in 3 lists (own 2 + direct 1 + group 3), got %d in %d", st.ListItemsOpenCount, st.ListsCount) + } +}