Fixed charts
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s

This commit is contained in:
2026-01-20 11:24:41 -07:00
parent fff0e14a4b
commit a4071d6e40
5 changed files with 238 additions and 71 deletions

View File

@@ -1,12 +1,12 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import CategoryChart from '../../components/CategoryChart.vue';
import TagChart from '../../components/TagChart.vue';
import ClientChart from '../../components/ClientChart.vue';
import MemberChart from '../../components/MemberChart.vue';
import { db } from '../../db';
import { timeEntries, members, users, clients, categories, invoices } from '../../db/schema';
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
import { timeEntries, members, users, clients, tags, timeEntryTags, invoices } from '../../db/schema';
import { eq, and, gte, lte, sql, desc, inArray, exists } from 'drizzle-orm';
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
const user = Astro.locals.user;
@@ -37,9 +37,9 @@ const teamMembers = await db.select({
.where(eq(members.organizationId, userMembership.organizationId))
.all();
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, userMembership.organizationId))
const allTags = await db.select()
.from(tags)
.where(eq(tags.organizationId, userMembership.organizationId))
.all();
const allClients = await db.select()
@@ -49,7 +49,7 @@ const allClients = await db.select()
const url = new URL(Astro.request.url);
const selectedMemberId = url.searchParams.get('member') || '';
const selectedCategoryId = url.searchParams.get('category') || '';
const selectedTagId = url.searchParams.get('tag') || '';
const selectedClientId = url.searchParams.get('client') || '';
const timeRange = url.searchParams.get('range') || 'week';
const customFrom = url.searchParams.get('from');
@@ -102,28 +102,65 @@ if (selectedMemberId) {
conditions.push(eq(timeEntries.userId, selectedMemberId));
}
if (selectedCategoryId) {
conditions.push(eq(timeEntries.categoryId, selectedCategoryId));
if (selectedTagId) {
conditions.push(exists(
db.select()
.from(timeEntryTags)
.where(and(
eq(timeEntryTags.timeEntryId, timeEntries.id),
eq(timeEntryTags.tagId, selectedTagId)
))
));
}
if (selectedClientId) {
conditions.push(eq(timeEntries.clientId, selectedClientId));
}
const entries = await db.select({
const entriesData = await db.select({
entry: timeEntries,
user: users,
client: clients,
category: categories,
})
.from(timeEntries)
.innerJoin(users, eq(timeEntries.userId, users.id))
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
.where(and(...conditions))
.orderBy(desc(timeEntries.startTime))
.all();
// Fetch tags for these entries
const entryIds = entriesData.map(e => e.entry.id);
const tagsMap = new Map<string, typeof allTags>();
if (entryIds.length > 0) {
// Process in chunks if too many entries, but for now simple inArray
// Sqlite has limits on variables, but usually ~999. Assuming reasonable page size or volume.
// If entryIds is massive, this might fail, but for a dashboard report it's usually acceptable or needs pagination/limits.
// However, `inArray` can be empty, so we checked length.
const entryTagsData = await db.select({
timeEntryId: timeEntryTags.timeEntryId,
tag: tags
})
.from(timeEntryTags)
.innerJoin(tags, eq(timeEntryTags.tagId, tags.id))
.where(inArray(timeEntryTags.timeEntryId, entryIds))
.all();
for (const item of entryTagsData) {
if (!tagsMap.has(item.timeEntryId)) {
tagsMap.set(item.timeEntryId, []);
}
tagsMap.get(item.timeEntryId)!.push(item.tag);
}
}
const entries = entriesData.map(e => ({
...e,
tags: tagsMap.get(e.entry.id) || []
}));
const statsByMember = teamMembers.map(member => {
const memberEntries = entries.filter(e => e.user.id === member.id);
const totalTime = memberEntries.reduce((sum, e) => {
@@ -140,9 +177,9 @@ const statsByMember = teamMembers.map(member => {
};
}).sort((a, b) => b.totalTime - a.totalTime);
const statsByCategory = allCategories.map(category => {
const categoryEntries = entries.filter(e => e.category.id === category.id);
const totalTime = categoryEntries.reduce((sum, e) => {
const statsByTag = allTags.map(tag => {
const tagEntries = entries.filter(e => e.tags.some(t => t.id === tag.id));
const totalTime = tagEntries.reduce((sum, e) => {
if (e.entry.endTime) {
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
}
@@ -150,9 +187,9 @@ const statsByCategory = allCategories.map(category => {
}, 0);
return {
category,
tag,
totalTime,
entryCount: categoryEntries.length,
entryCount: tagEntries.length,
};
}).sort((a, b) => b.totalTime - a.totalTime);
@@ -336,14 +373,14 @@ function getTimeRangeLabel(range: string) {
</div>
<div class="form-control">
<label class="label font-medium" for="reports-category">
Category
<label class="label font-medium" for="reports-tag">
Tag
</label>
<select id="reports-category" name="category" class="select select-bordered" onchange="this.form.submit()">
<option value="">All Categories</option>
{allCategories.map(category => (
<option value={category.id} selected={selectedCategoryId === category.id}>
{category.name}
<select id="reports-tag" name="tag" class="select select-bordered" onchange="this.form.submit()">
<option value="">All Tags</option>
{allTags.map(tag => (
<option value={tag.id} selected={selectedTagId === tag.id}>
{tag.name}
</option>
))}
</select>
@@ -546,21 +583,21 @@ function getTimeRangeLabel(range: string) {
{totalTime > 0 && (
<>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Category Distribution Chart - Only show when no category filter */}
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (
{/* Tag Distribution Chart - Only show when no tag filter */}
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:chart-pie" class="w-6 h-6" />
Category Distribution
Tag Distribution
</h2>
<div class="h-64 w-full">
<CategoryChart
<TagChart
client:visible
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
name: s.category.name,
tags={statsByTag.filter(s => s.totalTime > 0).map(s => ({
name: s.tag.name,
totalTime: s.totalTime,
color: s.category.color || '#3b82f6'
color: s.tag.color || '#3b82f6'
}))}
/>
</div>
@@ -654,33 +691,33 @@ function getTimeRangeLabel(range: string) {
</div>
)}
{/* Stats by Category - Only show if there's data and no category filter */}
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (
{/* Stats by Tag - Only show if there's data and no tag filter */}
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:tag" class="w-6 h-6" />
By Category
By Tag
</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Category</th>
<th>Tag</th>
<th>Total Time</th>
<th>Entries</th>
<th>% of Total</th>
</tr>
</thead>
<tbody>
{statsByCategory.filter(s => s.totalTime > 0).map(stat => (
{statsByTag.filter(s => s.totalTime > 0).map(stat => (
<tr>
<td>
<div class="flex items-center gap-2">
{stat.category.color && (
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.category.color}`}></span>
{stat.tag.color && (
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.tag.color}`}></span>
)}
<span>{stat.category.name}</span>
<span>{stat.tag.name}</span>
</div>
</td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
@@ -774,7 +811,7 @@ function getTimeRangeLabel(range: string) {
<th>Date</th>
<th>Member</th>
<th>Client</th>
<th>Category</th>
<th>Tags</th>
<th>Description</th>
<th>Duration</th>
</tr>
@@ -791,11 +828,16 @@ function getTimeRangeLabel(range: string) {
<td>{e.user.name}</td>
<td>{e.client.name}</td>
<td>
<div class="flex items-center gap-2">
{e.category.color && (
<span class="w-3 h-3 rounded-full" style={`background-color: ${e.category.color}`}></span>
)}
<span>{e.category.name}</span>
<div class="flex flex-wrap gap-1">
{e.tags.map(tag => (
<div class="badge badge-sm badge-outline flex items-center gap-1">
{tag.color && (
<span class="w-2 h-2 rounded-full" style={`background-color: ${tag.color}`}></span>
)}
<span>{tag.name}</span>
</div>
))}
{e.tags.length === 0 && <span class="opacity-50">-</span>}
</div>
</td>
<td>{e.entry.description || '-'}</td>