96 lines
2.9 KiB
TypeScript
96 lines
2.9 KiB
TypeScript
import { query } from './_generated/server.js';
|
|
import { requireOwner } from './lib.js';
|
|
import { dashboardRangeValidator } from './validators.js';
|
|
|
|
const rangeDays = {
|
|
'7d': 7,
|
|
'30d': 30,
|
|
'90d': 90,
|
|
'1y': 365
|
|
} as const;
|
|
|
|
function dayKey(date: Date) {
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function daysAgo(days: number) {
|
|
const date = new Date();
|
|
date.setHours(0, 0, 0, 0);
|
|
date.setDate(date.getDate() - days);
|
|
return dayKey(date);
|
|
}
|
|
|
|
export const summary = query({
|
|
args: { range: dashboardRangeValidator },
|
|
handler: async (ctx, args) => {
|
|
const owner = await requireOwner(ctx);
|
|
const selectedDays = rangeDays[args.range];
|
|
const entries = await ctx.db
|
|
.query('entries')
|
|
.withIndex('by_owner_and_entryDate', (q) => q.eq('owner', owner))
|
|
.order('desc')
|
|
.take(Math.min(800, selectedDays * 3));
|
|
const activeEntries = entries.filter((entry) => !entry.archived);
|
|
const rangeStart = daysAgo(selectedDays - 1);
|
|
const last7Start = daysAgo(6);
|
|
const last30Start = daysAgo(29);
|
|
const rangedEntries = activeEntries.filter((entry) => entry.entryDate >= rangeStart);
|
|
const last7 = activeEntries.filter((entry) => entry.entryDate >= last7Start);
|
|
const last30 = activeEntries.filter((entry) => entry.entryDate >= last30Start);
|
|
const moodCounts: Record<string, number> = {};
|
|
const dailyCounts = new Map<string, number>();
|
|
const tagCounts: Record<string, number> = {};
|
|
|
|
for (const entry of rangedEntries) {
|
|
moodCounts[entry.mood] = (moodCounts[entry.mood] ?? 0) + 1;
|
|
dailyCounts.set(entry.entryDate, (dailyCounts.get(entry.entryDate) ?? 0) + 1);
|
|
|
|
for (const tagName of entry.tagNames) {
|
|
tagCounts[tagName] = (tagCounts[tagName] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
let currentStreak = 0;
|
|
for (let offset = 0; offset < 365; offset += 1) {
|
|
const key = daysAgo(offset);
|
|
if (!activeEntries.some((entry) => entry.entryDate === key)) break;
|
|
currentStreak += 1;
|
|
}
|
|
|
|
const dailySeries = Array.from({ length: selectedDays }, (_, index) => {
|
|
const date = daysAgo(selectedDays - 1 - index);
|
|
return { date, count: dailyCounts.get(date) ?? 0 };
|
|
});
|
|
|
|
return {
|
|
totalEntries: activeEntries.length,
|
|
entriesThisWeek: last7.length,
|
|
entriesThisMonth: last30.length,
|
|
entriesInRange: rangedEntries.length,
|
|
range: args.range,
|
|
currentStreak,
|
|
averageWords:
|
|
activeEntries.length === 0
|
|
? 0
|
|
: Math.round(
|
|
activeEntries.reduce(
|
|
(sum, entry) => sum + entry.plainText.split(/\s+/).filter(Boolean).length,
|
|
0
|
|
) / activeEntries.length
|
|
),
|
|
moodCounts,
|
|
dailySeries,
|
|
topTags: Object.entries(tagCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 8)
|
|
.map(([name, count]) => ({ name, count })),
|
|
insight:
|
|
last7.length >= 5
|
|
? 'You have a strong writing rhythm this week.'
|
|
: last7.length >= 2
|
|
? 'A few reflections this week are already building momentum.'
|
|
: 'A short entry today is enough to restart the thread.'
|
|
};
|
|
}
|
|
});
|