feat(ui): one CountBadge for all counters; counters on tab corners
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m30s

- CountBadge component (accent / muted, inline / corner, 99+, showZero);
  used in the nav bar, task list and list tabs, admin organizations and
  numeric badges of the mobile menu; old badge-count removed
- Counters sit on the top right corner of tabs; "Alle Aufgaben" gets one
  (desktop tab and mobile menu)
- Open/done task counters include tasks in shared lists, matching what
  "Alle Aufgaben" shows (nav counter was too low)
- Theme template derives --accent-dim/--accent-border from the accent
- Docs: ui-patterns counters section

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 22:54:15 +01:00
1 parent a354f3d008
commit a62b204e14
12 files changed
+167 -55

No files matched your search

+11 -4
View File
@@ -41,8 +41,15 @@ Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
## Zähler
CSS-Klasse `badge-count` in [index.css](../frontend/src/index.css): runder Zähler in der Form der Zähler der Navigationsleiste, aber **monochrom** (grau gefüllt) statt in der Akzentfarbe.
Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Klassen `count-badge*` in [index.css](../frontend/src/index.css). Es gibt keine anderen Zähler-Stile mehr.
- Verwendet für die Anzahl offener Einträge in den Tabs der Aufgabenlisten und der Listen.
- Die Zähler der Navigationsleiste bleiben blau (Akzent), weil sie auf neue Inhalte aufmerksam machen.
- Kein eckiges `badge-neutral` mehr für Zähler in Tabs.
- **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).
- `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`):**
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. So in der Navigation und an den Tabs. Zeilen mit solchen Tabs brauchen etwas Abstand (`gap-x-3`, `pt-2`), damit der Kreis nicht in den Nachbar-Tab ragt.
- `inline`: im Textfluss.
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
+8 -30
View File
@@ -43,6 +43,7 @@ import {
ThemeId,
} from './lib/theme';
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
import { CountBadge } from './components/CountBadge';
import { TriageDeck } from './features/triage/TriageDeck';
import { TasksView } from './features/triage/TasksView';
import { WorkspaceView } from './features/workspace/WorkspaceView';
@@ -905,11 +906,7 @@ export const App: React.FC = () => {
<Layers className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-2' : ''}`}>
<span>Triage</span>
{stats.inboxCount > 0 && (
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
{stats.inboxCount}
</span>
)}
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
</span>
</button>
@@ -923,11 +920,7 @@ export const App: React.FC = () => {
<FileText className="w-4 h-4" />
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
<span>Workspace</span>
{(stats.docsTodosOpenCount ?? 0) > 0 && (
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
{stats.docsTodosOpenCount}
</span>
)}
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
</span>
</button>
@@ -941,11 +934,7 @@ export const App: React.FC = () => {
<CheckSquare className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
<span>Aufgaben</span>
{stats.tasksOpenCount > 0 && (
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
{stats.tasksOpenCount}
</span>
)}
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
</span>
</button>
@@ -1002,6 +991,7 @@ export const App: React.FC = () => {
onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision}
initialSelectedTaskId={selectedTaskId}
openTasksCount={stats.tasksOpenCount}
/>
)}
@@ -1072,11 +1062,7 @@ export const App: React.FC = () => {
>
<div className="relative">
<Layers className="w-5 h-5" />
{stats.inboxCount > 0 && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
{stats.inboxCount}
</span>
)}
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
</div>
<span className="text-[10px] leading-tight">Triage</span>
</button>
@@ -1095,11 +1081,7 @@ export const App: React.FC = () => {
>
<div className="relative">
<FileText className="w-5 h-5" />
{(stats.docsTodosOpenCount ?? 0) > 0 && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
{stats.docsTodosOpenCount}
</span>
)}
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
</div>
<span className="text-[10px] leading-tight">Workspace</span>
</button>
@@ -1137,11 +1119,7 @@ export const App: React.FC = () => {
>
<div className="relative">
<CheckSquare className="w-5 h-5" />
{stats.tasksOpenCount > 0 && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
{stats.tasksOpenCount}
</span>
)}
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
</div>
<span className="text-[10px] leading-tight">Aufgaben</span>
</button>
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
interface CountBadgeProps {
count: number | undefined | null;
// accent: something waits for you (nav bar); muted: plain amount (tabs, lists)
variant?: 'accent' | 'muted';
// corner: notification badge on the top right of the nearest `relative` parent
position?: 'inline' | 'corner';
showZero?: boolean;
className?: string; // e.g. to fine-tune the corner offset
}
// Full class names: Tailwind drops component classes it cannot find literally in the source
const VARIANT_CLASS = { accent: 'count-badge-accent', muted: 'count-badge-muted' };
// The one counter of the app (Doku/ui-patterns.md); hides itself at 0 unless showZero
export const CountBadge: React.FC<CountBadgeProps> = ({
count,
variant = 'muted',
position = 'inline',
showZero = false,
className = '',
}) => {
if (count === undefined || count === null || (count <= 0 && !showZero)) return null;
const label = count > 99 ? '99+' : String(count);
return (
<span
className={`count-badge ${VARIANT_CLASS[variant]} ${position === 'corner' ? 'count-badge-corner' : ''} ${className}`}
aria-label={String(count)}
>
{label}
</span>
);
};
+4 -1
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Check, X } from 'lucide-react';
import { useMobileActions } from '../context/MobileActionContext';
import { triggerHaptic } from '../lib/api';
import { CountBadge } from './CountBadge';
interface MobileHeaderMenuProps {
activeTabLabel: string;
@@ -109,7 +110,9 @@ export const MobileHeaderMenu: React.FC<MobileHeaderMenuProps> = ({ activeTabLab
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{item.badge !== undefined && item.badge !== '' && (
{typeof item.badge === 'number' && !isAccent ? (
<CountBadge count={item.badge} showZero variant={isActive ? 'accent' : 'muted'} />
) : item.badge !== undefined && item.badge !== '' && (
<span
className={`px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${
isAccent
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { CountBadge } from '../CountBadge';
describe('CountBadge', () => {
it('hides itself at zero unless asked', () => {
expect(render(<CountBadge count={0} />).container.firstChild).toBeNull();
expect(render(<CountBadge count={undefined} />).container.firstChild).toBeNull();
expect(render(<CountBadge count={0} showZero />).container.textContent).toBe('0');
});
it('caps large numbers and keeps the real value for screen readers', () => {
const el = render(<CountBadge count={150} />).container.firstChild as HTMLElement;
expect(el.textContent).toBe('99+');
expect(el.getAttribute('aria-label')).toBe('150');
});
it('maps variant and position to the shared classes', () => {
const el = render(<CountBadge count={3} variant="accent" position="corner" />).container.firstChild as HTMLElement;
expect(el.className).toContain('count-badge-accent');
expect(el.className).toContain('count-badge-corner');
});
});
@@ -14,6 +14,7 @@ import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../li
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
import { useMobileActions } from '../../context/MobileActionContext';
interface SharedListsViewProps {
@@ -842,8 +843,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<div className="w-full space-y-3">
{/* Top Header: List Tabs & Actions (Desktop) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
{/* List Tabs */}
<div className="flex flex-wrap items-center gap-1">
{/* List Tabs (counters sit on the top right corner, hence the extra gap) */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-3 pt-2">
{lists.map((lst) => {
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
const isSelected = activeList?.id === lst.id;
@@ -860,9 +861,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
>
<span>{lst.icon}</span>
<span>{lst.title}</span>
{openCount > 0 && (
<span className="ml-1 badge-count">{openCount}</span>
)}
<AvatarStack
people={otherMembers(lst)}
size="xxs"
@@ -870,6 +868,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
className="ml-0.5"
/>
</button>
<CountBadge count={openCount} position="corner" />
{isSelected && lists.length > 1 && (
<button
onClick={() => handleDeleteList(lst.id)}
@@ -27,6 +27,7 @@ import {
import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
import { UserAvatar } from '../../components/UserAvatar';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { CountBadge } from '../../components/CountBadge';
interface AdminSettingsViewProps {
currentUser: User;
@@ -929,7 +930,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<div className="flex items-center gap-2 min-w-0">
<Building2 className="w-4 h-4 text-accent shrink-0" />
<span className="font-bold text-main truncate">{o.name}</span>
<span className="badge-count">{o.memberCount}</span>
<CountBadge count={o.memberCount} showZero />
</div>
)}
{renamingOrgId !== o.id && (
+11 -6
View File
@@ -32,6 +32,7 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
import { useMobileActions } from '../../context/MobileActionContext';
interface TasksViewProps {
@@ -40,6 +41,7 @@ interface TasksViewProps {
onOpenDocument: (docId: string, blockId?: string) => void;
refreshTrigger?: number;
initialSelectedTaskId?: string | null;
openTasksCount?: number; // all open tasks incl. shared lists (same as the nav bar counter)
}
export const TasksView: React.FC<TasksViewProps> = ({
@@ -48,6 +50,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
onOpenDocument,
refreshTrigger = 0,
initialSelectedTaskId,
openTasksCount,
}) => {
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
@@ -1036,6 +1039,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
id: 'list-all',
label: 'Alle Aufgaben',
icon: <Layers className="w-4 h-4" />,
badge: openTasksCount || undefined,
active: selectedListId === 'all',
onClick: () => setSelectedListId('all'),
},
@@ -1086,6 +1090,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
safeDiscarded.length,
kanbanTasks.length,
safeTaskLists,
openTasksCount,
currentSelectedList,
isSelectedListReadOnly,
setPrimaryAction,
@@ -1368,17 +1373,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
<div className="w-full space-y-3">
{/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
{/* Task Lists selector tabs */}
<div className="flex flex-wrap items-center gap-1">
{/* Task Lists selector tabs (counters sit on the top right corner, hence the extra gap) */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-3 pt-2">
<button
onClick={() => setSelectedListId('all')}
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors ${
className={`relative h-8 px-3 text-xs font-medium border rounded-btn transition-colors ${
selectedListId === 'all'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
Alle Aufgaben
<CountBadge count={openTasksCount} position="corner" />
</button>
{safeTaskLists.map((tl) => (
@@ -1394,9 +1400,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
>
<span>{tl.icon}</span>
<span>{tl.title}</span>
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
<span className="ml-1 badge-count">{tl.tasksCount}</span>
)}
{(() => {
const isShared = (tl.shares && tl.shares.length > 0) || (tl.members && tl.members.length > 1);
if (!isShared) return null;
@@ -1418,6 +1422,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
);
})()}
</button>
<CountBadge count={tl.tasksCount} position="corner" />
{selectedListId === tl.id && (
<button
onClick={() => handleDeleteTaskList(tl.id)}
+21 -4
View File
@@ -293,8 +293,9 @@ body {
font-weight: 500;
}
/* Round count bubble: same shape as the nav bar counters, but monochrome (tabs of task lists & lists) */
.badge-count {
/* Round counter, rendered by <CountBadge> (Doku/ui-patterns.md). Accent = needs attention (nav bar),
muted = plain amount (tabs, lists). Corner = notification badge on the top right of a relative parent. */
.count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -302,11 +303,27 @@ body {
height: 16px;
padding: 0 0.25rem;
border-radius: 9999px;
background-color: var(--text-muted);
color: var(--bg-panel);
font-size: 9px;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.count-badge-accent {
background-color: var(--accent-primary);
color: var(--text-on-accent);
}
.count-badge-muted {
background-color: var(--text-muted);
color: var(--bg-panel);
}
.count-badge-corner {
position: absolute;
top: -0.5rem;
right: -0.5rem;
z-index: 1;
/* ring in the page colour keeps the badge readable on top of borders */
box-shadow: 0 0 0 2px var(--bg-app);
}
.badge-ok {
+3 -2
View File
@@ -8,8 +8,9 @@
/* --- 1. AKZENTFARBE (Buttons, aktive Tabs, Fokus-Ränder, Badges) --- */
--accent-primary: #2563eb;
--accent-hover: #3b82f6;
--accent-dim: rgba(37, 99, 235, 0.14);
--accent-border: rgba(59, 130, 246, 0.45);
/* Tints follow --accent-primary automatically; set fixed values only if you want different ones */
--accent-dim: color-mix(in srgb, var(--accent-primary) 14%, transparent);
--accent-border: color-mix(in srgb, var(--accent-hover) 45%, transparent);
--text-on-accent: #ffffff;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
+4 -2
View File
@@ -4080,8 +4080,10 @@ func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) {
st := &domain.DashboardStats{}
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'inbox'`, userID).Scan(&st.InboxCount)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND is_completed = 0`, userID).Scan(&st.TasksOpenCount)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND is_completed = 1`, userID).Scan(&st.TasksDoneCount)
// Same tasks as GetTasks without a list ("Alle Aufgaben"): own ones plus those in shared task lists
taskScope := `(user_id = ? OR task_list_id IN (` + s.accessibleTaskListIDsQuery() + `)) AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 0`, userID, userID, userID, userID).Scan(&st.TasksOpenCount)
_ = 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(`
+42
View File
@@ -120,3 +120,45 @@ func assertAssignees(t *testing.T, store *Store, viewerID, taskID string, want .
}
}
}
// The open-task counter (nav badge, "Alle Aufgaben") counts what "Alle Aufgaben" shows,
// including tasks others created in a shared list
func TestDashboardCountsTasksOfSharedLists(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "stats.db"))
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
openSharing(t, store)
owner, _ := store.CreateUser("owner@example.com", "hash", "Owner", "member")
member, _ := store.CreateUser("member@example.com", "hash", "Member", "member")
tl, _ := store.CreateTaskList(owner.ID, "", "", "Haushalt", "🏠", "")
if _, err := store.AddTaskListShare(tl.ID, "user", member.ID, "editor", owner.ID); err != nil {
t.Fatalf("share: %v", err)
}
for i, title := range []string{"Bad putzen", "Müll raus"} {
th, _ := store.CreateThoughtWithDetails(owner.ID, title, "normal", "manual", "", tl.ID, "todo", "")
_, _ = store.UpdateThoughtTriage(owner.ID, th.ID, "keep", "task", "", "", tl.ID)
if i == 1 {
done := true
_, _ = store.UpdateThoughtDetails(owner.ID, th.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil)
}
}
own, _ := store.CreateThoughtWithDetails(member.ID, "Eigene Aufgabe", "normal", "manual", "", "", "todo", "")
_, _ = store.UpdateThoughtTriage(member.ID, own.ID, "keep", "task", "", "", "")
st, err := store.GetDashboardStats(member.ID)
if err != nil {
t.Fatalf("GetDashboardStats: %v", err)
}
tasks, _ := store.GetTasks(member.ID, "")
open := 0
for _, task := range tasks {
if !task.IsCompleted {
open++
}
}
if st.TasksOpenCount != 2 || st.TasksOpenCount != open || st.TasksDoneCount != 1 {
t.Fatalf("expected 2 open / 1 done matching GetTasks (%d open), got %d / %d", open, st.TasksOpenCount, st.TasksDoneCount)
}
}