feat(ui): one CountBadge for all counters; counters on tab corners
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m30s
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:
1 parent
a354f3d008
commit
a62b204e14
12 files changed
+167
-55
No files matched your search
+11
-4
@@ -41,8 +41,15 @@ Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
|
|||||||
|
|
||||||
## Zähler
|
## 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.
|
- **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ü).
|
||||||
- Die Zähler der Navigationsleiste bleiben blau (Akzent), weil sie auf neue Inhalte aufmerksam machen.
|
- **Farbe (`variant`):**
|
||||||
- Kein eckiges `badge-neutral` mehr für Zähler in Tabs.
|
- `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
@@ -43,6 +43,7 @@ import {
|
|||||||
ThemeId,
|
ThemeId,
|
||||||
} from './lib/theme';
|
} from './lib/theme';
|
||||||
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
|
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
|
||||||
|
import { CountBadge } from './components/CountBadge';
|
||||||
import { TriageDeck } from './features/triage/TriageDeck';
|
import { TriageDeck } from './features/triage/TriageDeck';
|
||||||
import { TasksView } from './features/triage/TasksView';
|
import { TasksView } from './features/triage/TasksView';
|
||||||
import { WorkspaceView } from './features/workspace/WorkspaceView';
|
import { WorkspaceView } from './features/workspace/WorkspaceView';
|
||||||
@@ -905,11 +906,7 @@ export const App: React.FC = () => {
|
|||||||
<Layers className="w-4 h-4" />
|
<Layers className="w-4 h-4" />
|
||||||
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-2' : ''}`}>
|
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-2' : ''}`}>
|
||||||
<span>Triage</span>
|
<span>Triage</span>
|
||||||
{stats.inboxCount > 0 && (
|
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -923,11 +920,7 @@ export const App: React.FC = () => {
|
|||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
|
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
|
||||||
<span>Workspace</span>
|
<span>Workspace</span>
|
||||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -941,11 +934,7 @@ export const App: React.FC = () => {
|
|||||||
<CheckSquare className="w-4 h-4" />
|
<CheckSquare className="w-4 h-4" />
|
||||||
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
|
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
|
||||||
<span>Aufgaben</span>
|
<span>Aufgaben</span>
|
||||||
{stats.tasksOpenCount > 0 && (
|
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -1002,6 +991,7 @@ export const App: React.FC = () => {
|
|||||||
onOpenDocument={handleOpenDocument}
|
onOpenDocument={handleOpenDocument}
|
||||||
refreshTrigger={refreshRevision}
|
refreshTrigger={refreshRevision}
|
||||||
initialSelectedTaskId={selectedTaskId}
|
initialSelectedTaskId={selectedTaskId}
|
||||||
|
openTasksCount={stats.tasksOpenCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1072,11 +1062,7 @@ export const App: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Layers className="w-5 h-5" />
|
<Layers className="w-5 h-5" />
|
||||||
{stats.inboxCount > 0 && (
|
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] leading-tight">Triage</span>
|
<span className="text-[10px] leading-tight">Triage</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1095,11 +1081,7 @@ export const App: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<FileText className="w-5 h-5" />
|
<FileText className="w-5 h-5" />
|
||||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] leading-tight">Workspace</span>
|
<span className="text-[10px] leading-tight">Workspace</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1137,11 +1119,7 @@ export const App: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<CheckSquare className="w-5 h-5" />
|
<CheckSquare className="w-5 h-5" />
|
||||||
{stats.tasksOpenCount > 0 && (
|
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] leading-tight">Aufgaben</span>
|
<span className="text-[10px] leading-tight">Aufgaben</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { Check, X } from 'lucide-react';
|
import { Check, X } from 'lucide-react';
|
||||||
import { useMobileActions } from '../context/MobileActionContext';
|
import { useMobileActions } from '../context/MobileActionContext';
|
||||||
import { triggerHaptic } from '../lib/api';
|
import { triggerHaptic } from '../lib/api';
|
||||||
|
import { CountBadge } from './CountBadge';
|
||||||
|
|
||||||
interface MobileHeaderMenuProps {
|
interface MobileHeaderMenuProps {
|
||||||
activeTabLabel: string;
|
activeTabLabel: string;
|
||||||
@@ -109,7 +110,9 @@ export const MobileHeaderMenu: React.FC<MobileHeaderMenuProps> = ({ activeTabLab
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<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
|
<span
|
||||||
className={`px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${
|
className={`px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${
|
||||||
isAccent
|
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 { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
|
|
||||||
interface SharedListsViewProps {
|
interface SharedListsViewProps {
|
||||||
@@ -842,8 +843,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<div className="w-full space-y-3">
|
<div className="w-full space-y-3">
|
||||||
{/* Top Header: List Tabs & Actions (Desktop) */}
|
{/* 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">
|
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
|
||||||
{/* List Tabs */}
|
{/* List Tabs (counters sit on the top right corner, hence the extra gap) */}
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-3 pt-2">
|
||||||
{lists.map((lst) => {
|
{lists.map((lst) => {
|
||||||
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
|
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
|
||||||
const isSelected = activeList?.id === lst.id;
|
const isSelected = activeList?.id === lst.id;
|
||||||
@@ -860,9 +861,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
>
|
>
|
||||||
<span>{lst.icon}</span>
|
<span>{lst.icon}</span>
|
||||||
<span>{lst.title}</span>
|
<span>{lst.title}</span>
|
||||||
{openCount > 0 && (
|
|
||||||
<span className="ml-1 badge-count">{openCount}</span>
|
|
||||||
)}
|
|
||||||
<AvatarStack
|
<AvatarStack
|
||||||
people={otherMembers(lst)}
|
people={otherMembers(lst)}
|
||||||
size="xxs"
|
size="xxs"
|
||||||
@@ -870,6 +868,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
className="ml-0.5"
|
className="ml-0.5"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<CountBadge count={openCount} position="corner" />
|
||||||
{isSelected && lists.length > 1 && (
|
{isSelected && lists.length > 1 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteList(lst.id)}
|
onClick={() => handleDeleteList(lst.id)}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
|
import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
|
||||||
import { UserAvatar } from '../../components/UserAvatar';
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
|
||||||
interface AdminSettingsViewProps {
|
interface AdminSettingsViewProps {
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
@@ -929,7 +930,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Building2 className="w-4 h-4 text-accent shrink-0" />
|
<Building2 className="w-4 h-4 text-accent shrink-0" />
|
||||||
<span className="font-bold text-main truncate">{o.name}</span>
|
<span className="font-bold text-main truncate">{o.name}</span>
|
||||||
<span className="badge-count">{o.memberCount}</span>
|
<CountBadge count={o.memberCount} showZero />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renamingOrgId !== o.id && (
|
{renamingOrgId !== o.id && (
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
|||||||
import { TaskDetailModal } from './TaskDetailModal';
|
import { TaskDetailModal } from './TaskDetailModal';
|
||||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
|
|
||||||
interface TasksViewProps {
|
interface TasksViewProps {
|
||||||
@@ -40,6 +41,7 @@ interface TasksViewProps {
|
|||||||
onOpenDocument: (docId: string, blockId?: string) => void;
|
onOpenDocument: (docId: string, blockId?: string) => void;
|
||||||
refreshTrigger?: number;
|
refreshTrigger?: number;
|
||||||
initialSelectedTaskId?: string | null;
|
initialSelectedTaskId?: string | null;
|
||||||
|
openTasksCount?: number; // all open tasks incl. shared lists (same as the nav bar counter)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TasksView: React.FC<TasksViewProps> = ({
|
export const TasksView: React.FC<TasksViewProps> = ({
|
||||||
@@ -48,6 +50,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
onOpenDocument,
|
onOpenDocument,
|
||||||
refreshTrigger = 0,
|
refreshTrigger = 0,
|
||||||
initialSelectedTaskId,
|
initialSelectedTaskId,
|
||||||
|
openTasksCount,
|
||||||
}) => {
|
}) => {
|
||||||
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
||||||
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
||||||
@@ -1036,6 +1039,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
id: 'list-all',
|
id: 'list-all',
|
||||||
label: 'Alle Aufgaben',
|
label: 'Alle Aufgaben',
|
||||||
icon: <Layers className="w-4 h-4" />,
|
icon: <Layers className="w-4 h-4" />,
|
||||||
|
badge: openTasksCount || undefined,
|
||||||
active: selectedListId === 'all',
|
active: selectedListId === 'all',
|
||||||
onClick: () => setSelectedListId('all'),
|
onClick: () => setSelectedListId('all'),
|
||||||
},
|
},
|
||||||
@@ -1086,6 +1090,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
safeDiscarded.length,
|
safeDiscarded.length,
|
||||||
kanbanTasks.length,
|
kanbanTasks.length,
|
||||||
safeTaskLists,
|
safeTaskLists,
|
||||||
|
openTasksCount,
|
||||||
currentSelectedList,
|
currentSelectedList,
|
||||||
isSelectedListReadOnly,
|
isSelectedListReadOnly,
|
||||||
setPrimaryAction,
|
setPrimaryAction,
|
||||||
@@ -1368,17 +1373,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
<div className="w-full space-y-3">
|
<div className="w-full space-y-3">
|
||||||
{/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */}
|
{/* 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">
|
<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 */}
|
{/* Task Lists selector tabs (counters sit on the top right corner, hence the extra gap) */}
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-3 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedListId('all')}
|
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'
|
selectedListId === 'all'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Alle Aufgaben
|
Alle Aufgaben
|
||||||
|
<CountBadge count={openTasksCount} position="corner" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{safeTaskLists.map((tl) => (
|
{safeTaskLists.map((tl) => (
|
||||||
@@ -1394,9 +1400,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
>
|
>
|
||||||
<span>{tl.icon}</span>
|
<span>{tl.icon}</span>
|
||||||
<span>{tl.title}</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);
|
const isShared = (tl.shares && tl.shares.length > 0) || (tl.members && tl.members.length > 1);
|
||||||
if (!isShared) return null;
|
if (!isShared) return null;
|
||||||
@@ -1418,6 +1422,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</button>
|
</button>
|
||||||
|
<CountBadge count={tl.tasksCount} position="corner" />
|
||||||
{selectedListId === tl.id && (
|
{selectedListId === tl.id && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteTaskList(tl.id)}
|
onClick={() => handleDeleteTaskList(tl.id)}
|
||||||
|
|||||||
+21
-4
@@ -293,8 +293,9 @@ body {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Round count bubble: same shape as the nav bar counters, but monochrome (tabs of task lists & lists) */
|
/* Round counter, rendered by <CountBadge> (Doku/ui-patterns.md). Accent = needs attention (nav bar),
|
||||||
.badge-count {
|
muted = plain amount (tabs, lists). Corner = notification badge on the top right of a relative parent. */
|
||||||
|
.count-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -302,11 +303,27 @@ body {
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background-color: var(--text-muted);
|
|
||||||
color: var(--bg-panel);
|
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
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 {
|
.badge-ok {
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
/* --- 1. AKZENTFARBE (Buttons, aktive Tabs, Fokus-Ränder, Badges) --- */
|
/* --- 1. AKZENTFARBE (Buttons, aktive Tabs, Fokus-Ränder, Badges) --- */
|
||||||
--accent-primary: #2563eb;
|
--accent-primary: #2563eb;
|
||||||
--accent-hover: #3b82f6;
|
--accent-hover: #3b82f6;
|
||||||
--accent-dim: rgba(37, 99, 235, 0.14);
|
/* Tints follow --accent-primary automatically; set fixed values only if you want different ones */
|
||||||
--accent-border: rgba(59, 130, 246, 0.45);
|
--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;
|
--text-on-accent: #ffffff;
|
||||||
|
|
||||||
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
|
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
|
||||||
|
|||||||
@@ -4080,8 +4080,10 @@ func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
|
|||||||
func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) {
|
func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) {
|
||||||
st := &domain.DashboardStats{}
|
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 = '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)
|
// Same tasks as GetTasks without a list ("Alle Aufgaben"): own ones plus those in shared task lists
|
||||||
_ = 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)
|
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 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(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount)
|
||||||
_ = s.db.QueryRow(`
|
_ = s.db.QueryRow(`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user