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
|
||||
|
||||
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
@@ -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>
|
||||
|
||||
@@ -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 { 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 && (
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 --- */
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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