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

8
pnpm-lock.yaml generated
View File

@@ -1459,8 +1459,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.9.15:
resolution: {integrity: sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==}
baseline-browser-mapping@2.9.16:
resolution: {integrity: sha512-KeUZdBuxngy825i8xvzaK1Ncnkx0tBmb3k8DkEuqjKRkmtvNTjey2ZsNeh8Dw4lfKvbCOu9oeNx2TKm2vHqcRw==}
hasBin: true
bcryptjs@3.0.3:
@@ -5264,7 +5264,7 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.9.15: {}
baseline-browser-mapping@2.9.16: {}
bcryptjs@3.0.3: {}
@@ -5319,7 +5319,7 @@ snapshots:
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.9.15
baseline-browser-mapping: 2.9.16
caniuse-lite: 1.0.30001765
electron-to-chromium: 1.5.267
node-releases: 2.0.27

View File

@@ -0,0 +1,67 @@
<template>
<div style="position: relative; height: 100%; width: 100%">
<Doughnut :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { Doughnut } from "vue-chartjs";
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
type ChartOptions,
} from "chart.js";
ChartJS.register(ArcElement, Tooltip, Legend);
interface TagData {
name: string;
totalTime: number;
color: string;
}
const props = defineProps<{
tags: TagData[];
}>();
const chartData = computed(() => ({
labels: props.tags.map((t) => t.name),
datasets: [
{
data: props.tags.map((t) => t.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: props.tags.map((t) => t.color),
borderColor: "#1e293b", // Matches typical dark mode bg
borderWidth: 2,
},
],
}));
const chartOptions: ChartOptions<"doughnut"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "right",
labels: {
color: "#e2e8f0",
usePointStyle: true,
padding: 20,
},
},
tooltip: {
callbacks: {
label: function (context) {
const minutes = Math.round(context.raw as number);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${hours}h ${mins}m`;
},
},
},
},
cutout: "70%",
};
</script>

View File

@@ -2,8 +2,8 @@
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../../db';
import { clients, timeEntries, members, categories, users } from '../../../../db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
import { clients, timeEntries, members, tags, timeEntryTags, users } from '../../../../db/schema';
import { eq, and, desc, sql, inArray } from 'drizzle-orm';
import { formatTimeRange } from '../../../../lib/formatTime';
const user = Astro.locals.user;
@@ -38,19 +38,44 @@ const client = await db.select()
if (!client) return Astro.redirect('/dashboard/clients');
// Get recent activity
const recentEntries = await db.select({
const recentEntriesData = await db.select({
entry: timeEntries,
category: categories,
user: users,
})
.from(timeEntries)
.leftJoin(categories, eq(timeEntries.categoryId, categories.id))
.leftJoin(users, eq(timeEntries.userId, users.id))
.where(eq(timeEntries.clientId, client.id))
.orderBy(desc(timeEntries.startTime))
.limit(10)
.all();
// Fetch tags for these entries
const entryIds = recentEntriesData.map(e => e.entry.id);
const tagsMap = new Map<string, { name: string; color: string | null }[]>();
if (entryIds.length > 0) {
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 recentEntries = recentEntriesData.map(e => ({
...e,
tags: tagsMap.get(e.entry.id) || []
}));
// Calculate total time tracked
const totalTimeResult = await db.select({
totalDuration: sql<number>`sum(CASE WHEN ${timeEntries.endTime} IS NOT NULL THEN ${timeEntries.endTime} - ${timeEntries.startTime} ELSE 0 END)`
@@ -181,23 +206,27 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<thead>
<tr>
<th>Description</th>
<th>Category</th>
<th>Tags</th>
<th>User</th>
<th>Date</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{recentEntries.map(({ entry, category, user: entryUser }) => (
{recentEntries.map(({ entry, tags, user: entryUser }) => (
<tr>
<td>{entry.description || '-'}</td>
<td>
{category ? (
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" style={`background-color: ${category.color}`}></span>
<span>{category.name}</span>
<div class="flex flex-wrap gap-1">
{tags.length > 0 ? 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>
)) : '-'}
</div>
) : '-'}
</td>
<td>{entryUser?.name || 'Unknown'}</td>
<td>{entry.startTime.toLocaleDateString()}</td>

View File

@@ -2,8 +2,8 @@
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../db';
import { organizations, members, timeEntries, clients, categories } from '../../db/schema';
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
import { organizations, members, timeEntries, clients, tags, timeEntryTags } from '../../db/schema';
import { eq, desc, and, isNull, gte, sql, inArray } from 'drizzle-orm';
import { formatDuration } from '../../lib/formatTime';
const user = Astro.locals.user;
@@ -84,18 +84,42 @@ if (currentOrg) {
stats.totalClients = clientCount?.count || 0;
stats.recentEntries = await db.select({
const recentEntriesData = await db.select({
entry: timeEntries,
client: clients,
category: categories,
})
.from(timeEntries)
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
.where(eq(timeEntries.organizationId, currentOrg.organizationId))
.orderBy(desc(timeEntries.startTime))
.limit(5)
.all();
const entryIds = recentEntriesData.map(e => e.entry.id);
const tagsMap = new Map<string, { name: string; color: string | null }[]>();
if (entryIds.length > 0) {
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);
}
}
stats.recentEntries = recentEntriesData.map(e => ({
...e,
tags: tagsMap.get(e.entry.id) || []
}));
}
const hasMembership = userOrgs.length > 0;
@@ -205,11 +229,16 @@ const hasMembership = userOrgs.length > 0;
</h2>
{stats.recentEntries.length > 0 ? (
<ul class="space-y-3 mt-4">
{stats.recentEntries.map(({ entry, client, category }) => (
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${category.color || '#3b82f6'}`}>
{stats.recentEntries.map(({ entry, client, tags }) => (
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${tags[0]?.color || '#3b82f6'}`}>
<div class="font-semibold text-sm">{client.name}</div>
<div class="text-xs text-base-content/60 mt-1">
{category.name} • {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}
<div class="text-xs text-base-content/60 mt-1 flex flex-wrap gap-2 items-center">
<span class="flex gap-1 flex-wrap">
{tags.length > 0 ? tags.map((tag: any) => (
<span class="badge badge-xs badge-outline">{tag.name}</span>
)) : <span class="italic opacity-50">No tags</span>}
</span>
<span>• {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
</div>
</li>
))}

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>
<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>{e.category.name}</span>
<span>{tag.name}</span>
</div>
))}
{e.tags.length === 0 && <span class="opacity-50">-</span>}
</div>
</td>
<td>{e.entry.description || '-'}</td>