From a4071d6e40eac9b62bd72d85aec8e5a5c43be317 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Tue, 20 Jan 2026 11:24:41 -0700 Subject: [PATCH] Fixed charts --- pnpm-lock.yaml | 8 +- src/components/TagChart.vue | 67 ++++++++++ src/pages/dashboard/clients/[id]/index.astro | 55 ++++++-- src/pages/dashboard/index.astro | 47 +++++-- src/pages/dashboard/reports.astro | 132 ++++++++++++------- 5 files changed, 238 insertions(+), 71 deletions(-) create mode 100644 src/components/TagChart.vue diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e97124..51c753b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/TagChart.vue b/src/components/TagChart.vue new file mode 100644 index 0000000..baa8563 --- /dev/null +++ b/src/components/TagChart.vue @@ -0,0 +1,67 @@ + + + diff --git a/src/pages/dashboard/clients/[id]/index.astro b/src/pages/dashboard/clients/[id]/index.astro index c42e630..2e8f161 100644 --- a/src/pages/dashboard/clients/[id]/index.astro +++ b/src/pages/dashboard/clients/[id]/index.astro @@ -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(); + +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`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; Description - Category + Tags User Date Duration - {recentEntries.map(({ entry, category, user: entryUser }) => ( + {recentEntries.map(({ entry, tags, user: entryUser }) => ( {entry.description || '-'} - {category ? ( -
- - {category.name} -
- ) : '-'} +
+ {tags.length > 0 ? tags.map(tag => ( +
+ {tag.color && ( + + )} + {tag.name} +
+ )) : '-'} +
{entryUser?.name || 'Unknown'} {entry.startTime.toLocaleDateString()} diff --git a/src/pages/dashboard/index.astro b/src/pages/dashboard/index.astro index 17b1b4c..d4a5c3e 100644 --- a/src/pages/dashboard/index.astro +++ b/src/pages/dashboard/index.astro @@ -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(); + + 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; {stats.recentEntries.length > 0 ? (
    - {stats.recentEntries.map(({ entry, client, category }) => ( -
  • + {stats.recentEntries.map(({ entry, client, tags }) => ( +
  • {client.name}
    -
    - {category.name} • {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'} +
    + + {tags.length > 0 ? tags.map((tag: any) => ( + {tag.name} + )) : No tags} + + • {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}
  • ))} diff --git a/src/pages/dashboard/reports.astro b/src/pages/dashboard/reports.astro index 29d0007..c3d8721 100644 --- a/src/pages/dashboard/reports.astro +++ b/src/pages/dashboard/reports.astro @@ -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(); + +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) {
    -