201 lines
5.4 KiB
TypeScript
201 lines
5.4 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useDatabase } from './useDatabase';
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
const WEEK_MS = 7 * DAY_MS;
|
|
const MONTH_MS = 30 * DAY_MS;
|
|
|
|
export interface DailyCompletion {
|
|
label: string;
|
|
count: number;
|
|
}
|
|
|
|
export interface CategoryStat {
|
|
id: string;
|
|
name: string;
|
|
color: string;
|
|
count: number;
|
|
}
|
|
|
|
export interface PriorityStat {
|
|
priority: string;
|
|
label: string;
|
|
count: number;
|
|
}
|
|
|
|
export interface TaskStats {
|
|
loading: boolean;
|
|
totalCompleted: number;
|
|
completedLast7: number;
|
|
completedLast30: number;
|
|
activeCount: number;
|
|
overdueCount: number;
|
|
completionRate: number;
|
|
daily: DailyCompletion[];
|
|
byCategory: CategoryStat[];
|
|
byPriority: PriorityStat[];
|
|
currentStreak: number;
|
|
}
|
|
|
|
export const PRIORITY_LABELS: Record<string, string> = {
|
|
high: 'High',
|
|
medium: 'Medium',
|
|
low: 'Low',
|
|
none: 'No priority',
|
|
};
|
|
|
|
function startOfDay(ts: number): number {
|
|
const d = new Date(ts);
|
|
d.setHours(0, 0, 0, 0);
|
|
return d.getTime();
|
|
}
|
|
|
|
function dayLabel(dayStart: number): string {
|
|
const d = new Date(dayStart);
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const diff = Math.round((today.getTime() - d.getTime()) / DAY_MS);
|
|
if (diff === 0) return 'Today';
|
|
if (diff === 1) return 'Yest';
|
|
return d.toLocaleDateString(undefined, { weekday: 'short' });
|
|
}
|
|
|
|
const EMPTY_STATS: TaskStats = {
|
|
loading: true,
|
|
totalCompleted: 0,
|
|
completedLast7: 0,
|
|
completedLast30: 0,
|
|
activeCount: 0,
|
|
overdueCount: 0,
|
|
completionRate: 0,
|
|
daily: [],
|
|
byCategory: [],
|
|
byPriority: [],
|
|
currentStreak: 0,
|
|
};
|
|
|
|
export function useStats(): TaskStats {
|
|
const { collections, isReady } = useDatabase();
|
|
const [stats, setStats] = useState<TaskStats>(EMPTY_STATS);
|
|
|
|
useEffect(() => {
|
|
if (!isReady) return;
|
|
let mounted = true;
|
|
|
|
const compute = () => {
|
|
Promise.all([collections.tasks.query().fetch(), collections.categories.query().fetch()])
|
|
.then(([allTasks, categories]) => {
|
|
const now = Date.now();
|
|
const todayStart = startOfDay(now);
|
|
const weekStart = todayStart - WEEK_MS;
|
|
const monthStart = todayStart - MONTH_MS;
|
|
|
|
const categoryCounts = new Map<string, number>();
|
|
const priorityCounts = new Map<string, number>();
|
|
const dayBucket = new Map<number, number>();
|
|
|
|
let totalCompleted = 0;
|
|
let completedLast7 = 0;
|
|
let completedLast30 = 0;
|
|
let activeCount = 0;
|
|
let overdueCount = 0;
|
|
|
|
for (const t of allTasks) {
|
|
if (t.completed) {
|
|
if (t.completedAt == null) continue;
|
|
const ts = t.completedAt as number;
|
|
totalCompleted++;
|
|
categoryCounts.set(t.categoryId, (categoryCounts.get(t.categoryId) ?? 0) + 1);
|
|
priorityCounts.set(t.priority, (priorityCounts.get(t.priority) ?? 0) + 1);
|
|
if (ts >= weekStart) completedLast7++;
|
|
if (ts >= monthStart) completedLast30++;
|
|
const day = startOfDay(ts);
|
|
dayBucket.set(day, (dayBucket.get(day) ?? 0) + 1);
|
|
} else {
|
|
activeCount++;
|
|
if (t.dueDate > 0 && t.dueDate < now) overdueCount++;
|
|
}
|
|
}
|
|
|
|
const daily: DailyCompletion[] = [];
|
|
for (let i = 6; i >= 0; i--) {
|
|
const dayStart = todayStart - i * DAY_MS;
|
|
daily.push({
|
|
label: dayLabel(dayStart),
|
|
count: dayBucket.get(dayStart) ?? 0,
|
|
});
|
|
}
|
|
|
|
const categoryMap = new Map(categories.map(c => [c.id, c]));
|
|
const byCategory: CategoryStat[] = Array.from(categoryMap.values())
|
|
.map(c => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
color: c.color,
|
|
count: categoryCounts.get(c.id) ?? 0,
|
|
}))
|
|
.filter(c => c.count > 0)
|
|
.sort((a, b) => b.count - a.count);
|
|
|
|
const priorityOrder = ['high', 'medium', 'low', 'none'];
|
|
const byPriority: PriorityStat[] = priorityOrder
|
|
.map(p => ({
|
|
priority: p,
|
|
label: PRIORITY_LABELS[p],
|
|
count: priorityCounts.get(p) ?? 0,
|
|
}))
|
|
.filter(p => p.count > 0);
|
|
|
|
let streak = 0;
|
|
const todayCount = dayBucket.get(todayStart) ?? 0;
|
|
let cursor = todayCount > 0 ? todayStart : todayStart - DAY_MS;
|
|
while ((dayBucket.get(cursor) ?? 0) > 0 && streak < 365) {
|
|
streak++;
|
|
cursor -= DAY_MS;
|
|
}
|
|
|
|
if (mounted) {
|
|
setStats({
|
|
loading: false,
|
|
totalCompleted,
|
|
completedLast7,
|
|
completedLast30,
|
|
activeCount,
|
|
overdueCount,
|
|
completionRate:
|
|
totalCompleted > 0
|
|
? Math.round((totalCompleted / (totalCompleted + activeCount)) * 100)
|
|
: 0,
|
|
daily,
|
|
byCategory,
|
|
byPriority,
|
|
currentStreak: streak,
|
|
});
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (mounted) setStats(s => ({ ...s, loading: false }));
|
|
});
|
|
};
|
|
|
|
compute();
|
|
|
|
const subscription = collections.tasks
|
|
.query()
|
|
.observe()
|
|
.subscribe({
|
|
next: () => {
|
|
compute();
|
|
},
|
|
error: () => {},
|
|
});
|
|
|
|
return () => {
|
|
mounted = false;
|
|
subscription.unsubscribe();
|
|
};
|
|
}, [collections, isReady]);
|
|
|
|
return stats;
|
|
}
|