refactor: split StatusDashboard component

This commit is contained in:
Aleix Soler 2025-07-16 18:05:37 +02:00
parent 77442bec67
commit 5b8bccd6c4
8 changed files with 428 additions and 370 deletions

View file

@ -0,0 +1,77 @@
import { TFile } from "obsidian";
import { NoteStatus } from "@/types/noteStatus";
import { StatusBadge } from "@/components/atoms/StatusBadge";
import { GroupLabel } from "@/components/atoms/GroupLabel";
interface CurrentNoteInfo {
file: TFile | null;
statuses: Record<string, NoteStatus[]>;
lastModified: number;
}
interface CurrentNoteSectionProps {
currentNote: CurrentNoteInfo;
}
export const CurrentNoteSection = ({
currentNote,
}: CurrentNoteSectionProps) => {
return (
<div className="status-dashboard-section">
<div className="status-dashboard-section-header">
<h3>Current Note</h3>
</div>
<div className="status-dashboard-current-note">
{currentNote.file ? (
<>
<div className="current-note-info">
<div className="current-note-name">
{currentNote.file.basename}
</div>
<div className="current-note-path">
{currentNote.file.path}
</div>
<div className="current-note-modified">
Last modified:{" "}
{new Date(
currentNote.lastModified,
).toLocaleString()}
</div>
</div>
<div className="current-note-statuses">
{Object.entries(currentNote.statuses).map(
([tag, statuses]) => (
<div
key={tag}
className="current-note-tag-group"
>
<GroupLabel name={tag} />
<div className="current-note-status-list">
{statuses.length > 0 ? (
statuses.map((status) => (
<StatusBadge
key={status.name}
status={status}
/>
))
) : (
<span className="current-note-no-status">
No status assigned
</span>
)}
</div>
</div>
),
)}
</div>
</>
) : (
<div className="current-note-empty">
No note currently active
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,59 @@
import { BaseNoteStatusService } from "@/core/noteStatusService";
import settingsService from "@/core/settingsService";
interface QuickActionsPanelProps {
onRefresh: () => void;
}
export const QuickActionsPanel = ({ onRefresh }: QuickActionsPanelProps) => {
return (
<div className="status-dashboard-section">
<div className="status-dashboard-section-header">
<h3>Quick Actions</h3>
</div>
<div className="quick-actions">
<button
className="quick-action-btn"
onClick={() => {
const leaf =
BaseNoteStatusService.app.workspace.getLeaf();
leaf.setViewState({
type: "grouped-status-view",
active: true,
});
}}
>
📊 View Grouped Statuses
</button>
<button
className="quick-action-btn"
onClick={() => {
const files =
BaseNoteStatusService.app.vault.getMarkdownFiles();
const filesWithoutStatus = files.filter((file) => {
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(
file,
);
const frontmatter = cachedMetadata?.frontmatter;
return (
!frontmatter ||
!frontmatter[settingsService.settings.tagPrefix]
);
});
console.log(
`Found ${filesWithoutStatus.length} notes without status:`,
filesWithoutStatus.map((f) => f.path),
);
}}
>
🔍 Find Notes Without Status
</button>
<button className="quick-action-btn" onClick={onRefresh}>
🔄 Refresh Data
</button>
</div>
</div>
);
};

View file

@ -1,137 +1,27 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { TFile } from "obsidian";
import { NoteStatus } from "@/types/noteStatus";
import {
BaseNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
import { StatusBadge } from "@/components/atoms/StatusBadge";
import { GroupLabel } from "@/components/atoms/GroupLabel";
import { useState, useEffect, useCallback } from "react";
import { BaseNoteStatusService } from "@/core/noteStatusService";
import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService";
interface VaultStats {
totalNotes: number;
notesWithStatus: number;
statusDistribution: Record<string, number>;
tagDistribution: Record<string, number>;
recentChanges: Array<{
file: TFile;
status: NoteStatus;
timestamp: number;
action: "added" | "removed";
}>;
}
interface CurrentNoteInfo {
file: TFile | null;
statuses: Record<string, NoteStatus[]>;
lastModified: number;
}
import { VaultStatsCard } from "./VaultStatsCard";
import { CurrentNoteSection } from "./CurrentNoteSection";
import { StatusDistributionChart } from "./StatusDistributionChart";
import { QuickActionsPanel } from "./QuickActionsPanel";
import { useVaultStats } from "./useVaultStats";
import { useCurrentNote } from "./useCurrentNote";
export const StatusDashboard = () => {
const [vaultStats, setVaultStats] = useState<VaultStats>({
totalNotes: 0,
notesWithStatus: 0,
statusDistribution: {},
tagDistribution: {},
recentChanges: [],
});
const [currentNote, setCurrentNote] = useState<CurrentNoteInfo>({
file: null,
statuses: {},
lastModified: 0,
});
const [isLoading, setIsLoading] = useState(true);
const calculateVaultStats = useCallback((): VaultStats => {
const files = BaseNoteStatusService.app.vault.getMarkdownFiles();
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
const statusMetadataKeys = [settingsService.settings.tagPrefix];
let notesWithStatus = 0;
const statusDistribution: Record<string, number> = {};
const tagDistribution: Record<string, number> = {};
// Initialize counters
availableStatuses.forEach((status) => {
statusDistribution[status.name] = 0;
});
statusMetadataKeys.forEach((tag) => {
tagDistribution[tag] = 0;
});
files.forEach((file) => {
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
let hasAnyStatus = false;
statusMetadataKeys.forEach((key) => {
const value = frontmatter[key];
if (value) {
hasAnyStatus = true;
tagDistribution[key]++;
const statusNames = Array.isArray(value) ? value : [value];
statusNames.forEach((statusName) => {
const statusStr = statusName.toString();
if (statusDistribution.hasOwnProperty(statusStr)) {
statusDistribution[statusStr]++;
}
});
}
});
if (hasAnyStatus) {
notesWithStatus++;
}
});
return {
totalNotes: files.length,
notesWithStatus,
statusDistribution,
tagDistribution,
recentChanges: [], // TODO: Implement recent changes tracking
};
}, []);
const updateCurrentNote = useCallback(() => {
const activeFile = BaseNoteStatusService.app.workspace.getActiveFile();
if (!activeFile) {
setCurrentNote({ file: null, statuses: {}, lastModified: 0 });
return;
}
const noteStatusService = new NoteStatusService(activeFile);
noteStatusService.populateStatuses();
setCurrentNote({
file: activeFile,
statuses: noteStatusService.statuses,
lastModified: activeFile.stat.mtime,
});
}, []);
const { vaultStats, updateVaultStats } = useVaultStats();
const { currentNote, updateCurrentNote } = useCurrentNote();
const loadData = useCallback(() => {
setIsLoading(true);
try {
const stats = calculateVaultStats();
setVaultStats(stats);
updateVaultStats();
updateCurrentNote();
} finally {
setIsLoading(false);
}
}, [calculateVaultStats, updateCurrentNote]);
}, [updateVaultStats, updateCurrentNote]);
useEffect(() => {
loadData();
@ -173,26 +63,6 @@ export const StatusDashboard = () => {
};
}, [loadData, updateCurrentNote]);
const statusChart = useMemo(() => {
const total = Object.values(vaultStats.statusDistribution).reduce(
(sum, count) => sum + count,
0,
);
if (total === 0) return [];
return Object.entries(vaultStats.statusDistribution)
.filter(([_, count]) => count > 0)
.map(([statusName, count]) => ({
name: statusName,
count,
percentage: Math.round((count / total) * 100),
}))
.sort((a, b) => b.count - a.count);
}, [vaultStats.statusDistribution]);
const availableStatuses = BaseNoteStatusService.getAllAvailableStatuses();
const statusMap = new Map(availableStatuses.map((s) => [s.name, s]));
if (isLoading) {
return (
<div className="status-dashboard">
@ -217,227 +87,10 @@ export const StatusDashboard = () => {
</div>
<div className="status-dashboard-content">
{/* Current Note Section */}
<div className="status-dashboard-section">
<div className="status-dashboard-section-header">
<h3>Current Note</h3>
</div>
<div className="status-dashboard-current-note">
{currentNote.file ? (
<>
<div className="current-note-info">
<div className="current-note-name">
{currentNote.file.basename}
</div>
<div className="current-note-path">
{currentNote.file.path}
</div>
<div className="current-note-modified">
Last modified:{" "}
{new Date(
currentNote.lastModified,
).toLocaleString()}
</div>
</div>
<div className="current-note-statuses">
{Object.entries(currentNote.statuses).map(
([tag, statuses]) => (
<div
key={tag}
className="current-note-tag-group"
>
<GroupLabel name={tag} />
<div className="current-note-status-list">
{statuses.length > 0 ? (
statuses.map(
(status) => (
<StatusBadge
key={
status.name
}
status={
status
}
/>
),
)
) : (
<span className="current-note-no-status">
No status assigned
</span>
)}
</div>
</div>
),
)}
</div>
</>
) : (
<div className="current-note-empty">
No note currently active
</div>
)}
</div>
</div>
{/* Vault Overview Section */}
<div className="status-dashboard-section">
<div className="status-dashboard-section-header">
<h3>Vault Overview</h3>
</div>
<div className="vault-overview">
<div className="vault-stats-grid">
<div className="vault-stat-card">
<div className="vault-stat-number">
{vaultStats.totalNotes}
</div>
<div className="vault-stat-label">
Total Notes
</div>
</div>
<div className="vault-stat-card">
<div className="vault-stat-number">
{vaultStats.notesWithStatus}
</div>
<div className="vault-stat-label">
Notes with Status
</div>
</div>
<div className="vault-stat-card">
<div className="vault-stat-number">
{vaultStats.totalNotes > 0
? Math.round(
(vaultStats.notesWithStatus /
vaultStats.totalNotes) *
100,
)
: 0}
%
</div>
<div className="vault-stat-label">Coverage</div>
</div>
<div className="vault-stat-card">
<div className="vault-stat-number">
{Object.values(
vaultStats.statusDistribution,
).reduce((sum, count) => sum + count, 0)}
</div>
<div className="vault-stat-label">
Total Statuses
</div>
</div>
</div>
</div>
</div>
{/* Status Distribution Section */}
<div className="status-dashboard-section">
<div className="status-dashboard-section-header">
<h3>Status Distribution</h3>
</div>
<div className="status-distribution">
{statusChart.length > 0 ? (
<div className="status-chart">
{statusChart.map(
({ name, count, percentage }) => {
const status = statusMap.get(name);
return (
<div
key={name}
className="status-chart-item"
>
<div className="status-chart-info">
{status && (
<StatusBadge
status={status}
/>
)}
<span className="status-chart-count">
{count} notes (
{percentage}%)
</span>
</div>
<div className="status-chart-bar">
<div
className="status-chart-fill"
style={{
width: `${percentage}%`,
backgroundColor:
status?.color ||
"var(--interactive-accent)",
}}
/>
</div>
</div>
);
},
)}
</div>
) : (
<div className="status-distribution-empty">
No status data available
</div>
)}
</div>
</div>
{/* Quick Actions Section */}
<div className="status-dashboard-section">
<div className="status-dashboard-section-header">
<h3>Quick Actions</h3>
</div>
<div className="quick-actions">
<button
className="quick-action-btn"
onClick={() => {
const leaf =
BaseNoteStatusService.app.workspace.getLeaf();
leaf.setViewState({
type: "grouped-status-view",
active: true,
});
}}
>
📊 View Grouped Statuses
</button>
<button
className="quick-action-btn"
onClick={() => {
// Find notes without status
const files =
BaseNoteStatusService.app.vault.getMarkdownFiles();
const filesWithoutStatus = files.filter(
(file) => {
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(
file,
);
const frontmatter =
cachedMetadata?.frontmatter;
return (
!frontmatter ||
!frontmatter[
settingsService.settings
.tagPrefix
]
);
},
);
console.log(
`Found ${filesWithoutStatus.length} notes without status:`,
filesWithoutStatus.map((f) => f.path),
);
}}
>
🔍 Find Notes Without Status
</button>
<button className="quick-action-btn" onClick={loadData}>
🔄 Refresh Data
</button>
</div>
</div>
<CurrentNoteSection currentNote={currentNote} />
<VaultStatsCard vaultStats={vaultStats} />
<StatusDistributionChart vaultStats={vaultStats} />
<QuickActionsPanel onRefresh={loadData} />
</div>
</div>
);

View file

@ -0,0 +1,76 @@
import { useMemo } from "react";
import { BaseNoteStatusService } from "@/core/noteStatusService";
import { StatusBadge } from "@/components/atoms/StatusBadge";
import { VaultStats } from "./useVaultStats";
interface StatusDistributionChartProps {
vaultStats: VaultStats;
}
export const StatusDistributionChart = ({
vaultStats,
}: StatusDistributionChartProps) => {
const statusChart = useMemo(() => {
const total = Object.values(vaultStats.statusDistribution).reduce(
(sum, count) => sum + count,
0,
);
if (total === 0) return [];
return Object.entries(vaultStats.statusDistribution)
.filter(([_, count]) => count > 0)
.map(([statusName, count]) => ({
name: statusName,
count,
percentage: Math.round((count / total) * 100),
}))
.sort((a, b) => b.count - a.count);
}, [vaultStats.statusDistribution]);
const availableStatuses = BaseNoteStatusService.getAllAvailableStatuses();
const statusMap = new Map(availableStatuses.map((s) => [s.name, s]));
return (
<div className="status-dashboard-section">
<div className="status-dashboard-section-header">
<h3>Status Distribution</h3>
</div>
<div className="status-distribution">
{statusChart.length > 0 ? (
<div className="status-chart">
{statusChart.map(({ name, count, percentage }) => {
const status = statusMap.get(name);
return (
<div key={name} className="status-chart-item">
<div className="status-chart-info">
{status && (
<StatusBadge status={status} />
)}
<span className="status-chart-count">
{count} notes ({percentage}%)
</span>
</div>
<div className="status-chart-bar">
<div
className="status-chart-fill"
style={{
width: `${percentage}%`,
backgroundColor:
status?.color ||
"var(--interactive-accent)",
}}
/>
</div>
</div>
);
})}
</div>
) : (
<div className="status-distribution-empty">
No status data available
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,54 @@
import { VaultStats } from "./useVaultStats";
interface VaultStatsCardProps {
vaultStats: VaultStats;
}
export const VaultStatsCard = ({ vaultStats }: VaultStatsCardProps) => {
return (
<div className="status-dashboard-section">
<div className="status-dashboard-section-header">
<h3>Vault Overview</h3>
</div>
<div className="vault-overview">
<div className="vault-stats-grid">
<div className="vault-stat-card">
<div className="vault-stat-number">
{vaultStats.totalNotes}
</div>
<div className="vault-stat-label">Total Notes</div>
</div>
<div className="vault-stat-card">
<div className="vault-stat-number">
{vaultStats.notesWithStatus}
</div>
<div className="vault-stat-label">
Notes with Status
</div>
</div>
<div className="vault-stat-card">
<div className="vault-stat-number">
{vaultStats.totalNotes > 0
? Math.round(
(vaultStats.notesWithStatus /
vaultStats.totalNotes) *
100,
)
: 0}
%
</div>
<div className="vault-stat-label">Coverage</div>
</div>
<div className="vault-stat-card">
<div className="vault-stat-number">
{Object.values(
vaultStats.statusDistribution,
).reduce((sum, count) => sum + count, 0)}
</div>
<div className="vault-stat-label">Total Statuses</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,44 @@
import { useState, useCallback } from "react";
import { TFile } from "obsidian";
import { NoteStatus } from "@/types/noteStatus";
import {
BaseNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
interface CurrentNoteInfo {
file: TFile | null;
statuses: Record<string, NoteStatus[]>;
lastModified: number;
}
export const useCurrentNote = () => {
const [currentNote, setCurrentNote] = useState<CurrentNoteInfo>({
file: null,
statuses: {},
lastModified: 0,
});
const updateCurrentNote = useCallback(() => {
const activeFile = BaseNoteStatusService.app.workspace.getActiveFile();
if (!activeFile) {
setCurrentNote({ file: null, statuses: {}, lastModified: 0 });
return;
}
const noteStatusService = new NoteStatusService(activeFile);
noteStatusService.populateStatuses();
setCurrentNote({
file: activeFile,
statuses: noteStatusService.statuses,
lastModified: activeFile.stat.mtime,
});
}, []);
return {
currentNote,
updateCurrentNote,
};
};

View file

@ -0,0 +1,95 @@
import { useState, useCallback } from "react";
import { TFile } from "obsidian";
import { NoteStatus } from "@/types/noteStatus";
import { BaseNoteStatusService } from "@/core/noteStatusService";
import settingsService from "@/core/settingsService";
export interface VaultStats {
totalNotes: number;
notesWithStatus: number;
statusDistribution: Record<string, number>;
tagDistribution: Record<string, number>;
recentChanges: Array<{
file: TFile;
status: NoteStatus;
timestamp: number;
action: "added" | "removed";
}>;
}
export const useVaultStats = () => {
const [vaultStats, setVaultStats] = useState<VaultStats>({
totalNotes: 0,
notesWithStatus: 0,
statusDistribution: {},
tagDistribution: {},
recentChanges: [],
});
const calculateVaultStats = useCallback((): VaultStats => {
const files = BaseNoteStatusService.app.vault.getMarkdownFiles();
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
const statusMetadataKeys = [settingsService.settings.tagPrefix];
let notesWithStatus = 0;
const statusDistribution: Record<string, number> = {};
const tagDistribution: Record<string, number> = {};
availableStatuses.forEach((status) => {
statusDistribution[status.name] = 0;
});
statusMetadataKeys.forEach((tag) => {
tagDistribution[tag] = 0;
});
files.forEach((file) => {
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
let hasAnyStatus = false;
statusMetadataKeys.forEach((key) => {
const value = frontmatter[key];
if (value) {
hasAnyStatus = true;
tagDistribution[key]++;
const statusNames = Array.isArray(value) ? value : [value];
statusNames.forEach((statusName) => {
const statusStr = statusName.toString();
if (statusDistribution.hasOwnProperty(statusStr)) {
statusDistribution[statusStr]++;
}
});
}
});
if (hasAnyStatus) {
notesWithStatus++;
}
});
return {
totalNotes: files.length,
notesWithStatus,
statusDistribution,
tagDistribution,
recentChanges: [],
};
}, []);
const updateVaultStats = useCallback(() => {
const stats = calculateVaultStats();
setVaultStats(stats);
}, [calculateVaultStats]);
return {
vaultStats,
updateVaultStats,
};
};

View file

@ -532,33 +532,33 @@
hyphens: auto;
}
/* styles/components/groupped-status-view.css */
.groupped-status-view-container {
/* styles/components/grouped-status-view.css */
.grouped-status-view-container {
height: 100%;
overflow: hidden;
}
.groupped-status-view {
.grouped-status-view {
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--background-primary);
}
.groupped-status-header {
.grouped-status-header {
padding: var(--size-4-2) var(--size-4-3);
border-bottom: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
flex-shrink: 0;
}
.groupped-status-filters {
.grouped-status-filters {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
}
.groupped-status-note-filter {
.grouped-status-note-filter {
display: flex;
flex-direction: column;
}
.groupped-status-note-input {
.grouped-status-note-input {
width: 100%;
padding: var(--size-2-1) var(--size-2-3);
border: 1px solid var(--background-modifier-border);