feat(ui): open list items counter on Listen, none on Workspace
New stat listItemsOpenCount: unchecked items in all lists the user sees (own, shared directly or through a group), shown as accent counter on Listen in the header and the mobile bottom bar. Workspace loses its counter. Stats refresh on list events and after deleting an item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6d720c0c36
commit
c4fc455850
8 files changed
+75
-19
No files matched your search
+1
-1
@@ -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/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
|
||||
|
||||
+1
-1
@@ -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`):**
|
||||
|
||||
+13
-12
@@ -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' : ''}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Workspace</span>
|
||||
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
<span>Workspace</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -1028,7 +1026,10 @@ export const App: React.FC = () => {
|
||||
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
<span>Listen</span>
|
||||
<span className={`relative inline-flex items-center ${(stats.listItemsOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Listen</span>
|
||||
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1166,10 +1167,7 @@ export const App: React.FC = () => {
|
||||
}`}
|
||||
title="Workspace"
|
||||
>
|
||||
<div className="relative">
|
||||
<FileText className="w-5 h-5" />
|
||||
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||
</div>
|
||||
<FileText className="w-5 h-5" />
|
||||
<span className="text-[10px] leading-tight">Workspace</span>
|
||||
</button>
|
||||
|
||||
@@ -1223,7 +1221,10 @@ export const App: React.FC = () => {
|
||||
}`}
|
||||
title="Listen"
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
<div className="relative">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Listen</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -357,6 +357,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
)
|
||||
);
|
||||
await api.deleteListItem(activeList.id, itemId);
|
||||
onRefreshGlobal();
|
||||
};
|
||||
|
||||
const handleClearCompleted = async () => {
|
||||
|
||||
@@ -490,6 +490,7 @@ export interface DashboardStats {
|
||||
tasksDoneCount: number;
|
||||
docsCount: number;
|
||||
docsTodosOpenCount?: number;
|
||||
listItemsOpenCount?: number;
|
||||
listsCount: number;
|
||||
discardedCount: number;
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user