Schema fixes
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m59s

This commit is contained in:
2026-01-20 12:08:06 -07:00
parent 55eb03165e
commit 815c08dd50
17 changed files with 1381 additions and 307 deletions

View File

@@ -0,0 +1,3 @@
DROP TABLE `time_entry_tags`;--> statement-breakpoint
ALTER TABLE `time_entries` ADD `tag_id` text REFERENCES tags(id);--> statement-breakpoint
CREATE INDEX `time_entries_tag_id_idx` ON `time_entries` (`tag_id`);

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,13 @@
"when": 1768934194146,
"tag": "0000_lazy_rictor",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1768935234392,
"tag": "0001_demonic_red_skull",
"breakpoints": true
}
]
}

View File

@@ -12,7 +12,7 @@ const emit = defineEmits<{
const description = ref("");
const selectedClientId = ref("");
const selectedTags = ref<string[]>([]);
const selectedTagId = ref<string | null>(null);
const startDate = ref("");
const startTime = ref("");
const endDate = ref("");
@@ -26,11 +26,10 @@ startDate.value = today;
endDate.value = today;
function toggleTag(tagId: string) {
const index = selectedTags.value.indexOf(tagId);
if (index > -1) {
selectedTags.value.splice(index, 1);
if (selectedTagId.value === tagId) {
selectedTagId.value = null;
} else {
selectedTags.value.push(tagId);
selectedTagId.value = tagId;
}
}
@@ -97,7 +96,7 @@ async function submitManualEntry() {
clientId: selectedClientId.value,
startTime: startDateTime,
endTime: endDateTime,
tags: selectedTags.value,
tagId: selectedTagId.value,
}),
});
@@ -112,7 +111,7 @@ async function submitManualEntry() {
description.value = "";
selectedClientId.value = "";
selectedTags.value = [];
selectedTagId.value = null;
startDate.value = today;
endDate.value = today;
startTime.value = "";
@@ -136,7 +135,7 @@ async function submitManualEntry() {
function clearForm() {
description.value = "";
selectedClientId.value = "";
selectedTags.value = [];
selectedTagId.value = null;
startDate.value = today;
endDate.value = today;
startTime.value = "";
@@ -300,7 +299,7 @@ function clearForm() {
@click="toggleTag(tag.id)"
:class="[
'badge badge-lg cursor-pointer transition-all hover:scale-105',
selectedTags.includes(tag.id)
selectedTagId === tag.id
? 'badge-primary shadow-lg shadow-primary/20'
: 'badge-outline hover:bg-base-300/50',
]"

View File

@@ -7,6 +7,7 @@ const props = defineProps<{
startTime: number;
description: string | null;
clientId: string;
tagId?: string;
} | null;
clients: { id: string; name: string }[];
tags: { id: string; name: string; color: string | null }[];
@@ -17,7 +18,7 @@ const startTime = ref<number | null>(null);
const elapsedTime = ref(0);
const description = ref("");
const selectedClientId = ref("");
const selectedTags = ref<string[]>([]);
const selectedTagId = ref<string | null>(null);
let interval: ReturnType<typeof setInterval> | null = null;
function formatTime(ms: number) {
@@ -46,11 +47,10 @@ function formatTime(ms: number) {
}
function toggleTag(tagId: string) {
const index = selectedTags.value.indexOf(tagId);
if (index > -1) {
selectedTags.value.splice(index, 1);
if (selectedTagId.value === tagId) {
selectedTagId.value = null;
} else {
selectedTags.value.push(tagId);
selectedTagId.value = tagId;
}
}
@@ -60,6 +60,7 @@ onMounted(() => {
startTime.value = props.initialRunningEntry.startTime;
description.value = props.initialRunningEntry.description || "";
selectedClientId.value = props.initialRunningEntry.clientId;
selectedTagId.value = props.initialRunningEntry.tagId || null;
elapsedTime.value = Date.now() - startTime.value;
interval = setInterval(() => {
elapsedTime.value = Date.now() - startTime.value!;
@@ -83,7 +84,7 @@ async function startTimer() {
body: JSON.stringify({
description: description.value,
clientId: selectedClientId.value,
tags: selectedTags.value,
tagId: selectedTagId.value,
}),
});
@@ -109,7 +110,7 @@ async function stopTimer() {
startTime.value = null;
description.value = "";
selectedClientId.value = "";
selectedTags.value = [];
selectedTagId.value = null;
window.location.reload();
}
}
@@ -163,7 +164,7 @@ async function stopTimer() {
@click="toggleTag(tag.id)"
:class="[
'badge badge-lg cursor-pointer transition-all hover:scale-105',
selectedTags.includes(tag.id)
selectedTagId === tag.id
? 'badge-primary shadow-lg shadow-primary/20'
: 'badge-outline hover:bg-base-300/50',
]"

View File

@@ -97,47 +97,6 @@ export const clients = sqliteTable(
}),
);
export const timeEntries = sqliteTable(
"time_entries",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
userId: text("user_id").notNull(),
organizationId: text("organization_id").notNull(),
clientId: text("client_id").notNull(),
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
endTime: integer("end_time", { mode: "timestamp" }),
description: text("description"),
invoiceId: text("invoice_id"),
isManual: integer("is_manual", { mode: "boolean" }).default(false),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
},
(table: any) => ({
userFk: foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
}),
orgFk: foreignKey({
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
clientFk: foreignKey({
columns: [table.clientId],
foreignColumns: [clients.id],
}),
userIdIdx: index("time_entries_user_id_idx").on(table.userId),
organizationIdIdx: index("time_entries_organization_id_idx").on(
table.organizationId,
),
clientIdIdx: index("time_entries_client_id_idx").on(table.clientId),
startTimeIdx: index("time_entries_start_time_idx").on(table.startTime),
invoiceIdIdx: index("time_entries_invoice_id_idx").on(table.invoiceId),
}),
);
export const tags = sqliteTable(
"tags",
{
@@ -163,26 +122,50 @@ export const tags = sqliteTable(
}),
);
export const timeEntryTags = sqliteTable(
"time_entry_tags",
export const timeEntries = sqliteTable(
"time_entries",
{
timeEntryId: text("time_entry_id").notNull(),
tagId: text("tag_id").notNull(),
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
userId: text("user_id").notNull(),
organizationId: text("organization_id").notNull(),
clientId: text("client_id").notNull(),
tagId: text("tag_id"),
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
endTime: integer("end_time", { mode: "timestamp" }),
description: text("description"),
invoiceId: text("invoice_id"),
isManual: integer("is_manual", { mode: "boolean" }).default(false),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
},
(table: any) => ({
pk: primaryKey({ columns: [table.timeEntryId, table.tagId] }),
timeEntryFk: foreignKey({
columns: [table.timeEntryId],
foreignColumns: [timeEntries.id],
userFk: foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
}),
orgFk: foreignKey({
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
clientFk: foreignKey({
columns: [table.clientId],
foreignColumns: [clients.id],
}),
tagFk: foreignKey({
columns: [table.tagId],
foreignColumns: [tags.id],
}),
timeEntryIdIdx: index("time_entry_tags_time_entry_id_idx").on(
table.timeEntryId,
userIdIdx: index("time_entries_user_id_idx").on(table.userId),
organizationIdIdx: index("time_entries_organization_id_idx").on(
table.organizationId,
),
tagIdIdx: index("time_entry_tags_tag_id_idx").on(table.tagId),
clientIdIdx: index("time_entries_client_id_idx").on(table.clientId),
tagIdIdx: index("time_entries_tag_id_idx").on(table.tagId),
startTimeIdx: index("time_entries_start_time_idx").on(table.startTime),
invoiceIdIdx: index("time_entries_invoice_id_idx").on(table.invoiceId),
}),
);

View File

@@ -1,15 +1,15 @@
import { db } from "../db";
import { clients, tags as tagsTable } from "../db/schema";
import { eq, and, inArray } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
export async function validateTimeEntryResources({
organizationId,
clientId,
tagIds,
tagId,
}: {
organizationId: string;
clientId: string;
tagIds?: string[];
tagId?: string | null;
}) {
const client = await db
.select()
@@ -23,20 +23,20 @@ export async function validateTimeEntryResources({
return { valid: false, error: "Invalid client" };
}
if (tagIds && tagIds.length > 0) {
const validTags = await db
if (tagId) {
const validTag = await db
.select()
.from(tagsTable)
.where(
and(
inArray(tagsTable.id, tagIds),
eq(tagsTable.id, tagId),
eq(tagsTable.organizationId, organizationId),
),
)
.all();
.get();
if (validTags.length !== tagIds.length) {
return { valid: false, error: "Invalid tags" };
if (!validTag) {
return { valid: false, error: "Invalid tag" };
}
}

View File

@@ -1,12 +1,7 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import {
clients,
members,
timeEntries,
timeEntryTags,
} from "../../../../db/schema";
import { eq, and, inArray } from "drizzle-orm";
import { clients, members, timeEntries } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ params, locals, redirect }) => {
const user = locals.user;
@@ -57,22 +52,7 @@ export const POST: APIRoute = async ({ params, locals, redirect }) => {
return new Response("Not authorized", { status: 403 });
}
const clientEntries = await db
.select({ id: timeEntries.id })
.from(timeEntries)
.where(eq(timeEntries.clientId, id))
.all();
const entryIds = clientEntries.map((e) => e.id);
if (entryIds.length > 0) {
await db
.delete(timeEntryTags)
.where(inArray(timeEntryTags.timeEntryId, entryIds))
.run();
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
}
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
await db.delete(clients).where(eq(clients.id, id)).run();

View File

@@ -5,7 +5,6 @@ import {
invoiceItems,
timeEntries,
members,
timeEntryTags,
tags,
} from "../../../../db/schema";
import {
@@ -48,7 +47,11 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
// Set end date to end of day
endDate.setHours(23, 59, 59, 999);
const invoice = await db.select().from(invoices).where(eq(invoices.id, id)).get();
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, id))
.get();
if (!invoice) {
return new Response("Invoice not found", { status: 404 });
@@ -60,8 +63,8 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
)
eq(members.organizationId, invoice.organizationId),
),
)
.get();
@@ -70,7 +73,9 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
}
if (invoice.status !== "draft") {
return new Response("Can only import time into draft invoices", { status: 400 });
return new Response("Can only import time into draft invoices", {
status: 400,
});
}
const entries = await db
@@ -79,8 +84,7 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
tag: tags,
})
.from(timeEntries)
.leftJoin(timeEntryTags, eq(timeEntries.id, timeEntryTags.timeEntryId))
.leftJoin(tags, eq(timeEntryTags.tagId, tags.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(
and(
eq(timeEntries.organizationId, invoice.organizationId),
@@ -88,8 +92,8 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
isNull(timeEntries.invoiceId),
isNotNull(timeEntries.endTime),
gte(timeEntries.startTime, startDate),
lte(timeEntries.startTime, endDate)
)
lte(timeEntries.startTime, endDate),
),
)
.orderBy(desc(timeEntries.startTime));
@@ -238,16 +242,20 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
let discountAmount = 0;
if (invoice.discountType === "percentage") {
discountAmount = Math.round(subtotal * ((invoice.discountValue || 0) / 100));
discountAmount = Math.round(
subtotal * ((invoice.discountValue || 0) / 100),
);
} else {
discountAmount = Math.round((invoice.discountValue || 0) * 100);
if (invoice.discountValue && invoice.discountValue > 0) {
discountAmount = Math.round((invoice.discountValue || 0) * 100);
discountAmount = Math.round((invoice.discountValue || 0) * 100);
}
}
const taxableAmount = Math.max(0, subtotal - discountAmount);
const taxAmount = Math.round(taxableAmount * ((invoice.taxRate || 0) / 100));
const taxAmount = Math.round(
taxableAmount * ((invoice.taxRate || 0) / 100),
);
const total = subtotal - discountAmount + taxAmount;
await db

View File

@@ -1,14 +1,7 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import {
timeEntries,
members,
users,
clients,
tags,
timeEntryTags,
} from "../../../db/schema";
import { eq, and, gte, lte, desc, inArray } from "drizzle-orm";
import { timeEntries, members, users, clients, tags } from "../../../db/schema";
import { eq, and, gte, lte, desc } from "drizzle-orm";
export const GET: APIRoute = async ({ request, locals, cookies }) => {
const user = locals.user;
@@ -114,37 +107,16 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
entry: timeEntries,
user: users,
client: clients,
tag: tags,
})
.from(timeEntries)
.innerJoin(users, eq(timeEntries.userId, users.id))
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(and(...conditions))
.orderBy(desc(timeEntries.startTime))
.all();
// Fetch tags for these entries
const entryIds = entries.map((e) => e.entry.id);
const tagsMap = new Map<string, string[]>();
if (entryIds.length > 0) {
const entryTags = await db
.select({
entryId: timeEntryTags.timeEntryId,
tagName: tags.name,
})
.from(timeEntryTags)
.innerJoin(tags, eq(timeEntryTags.tagId, tags.id))
.where(inArray(timeEntryTags.timeEntryId, entryIds))
.all();
for (const tag of entryTags) {
if (!tagsMap.has(tag.entryId)) {
tagsMap.set(tag.entryId, []);
}
tagsMap.get(tag.entryId)!.push(tag.tagName);
}
}
// Generate CSV
const headers = [
"Date",
@@ -153,7 +125,7 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
"Duration (h)",
"Member",
"Client",
"Tags",
"Tag",
"Description",
];
const rows = entries.map((e) => {
@@ -165,7 +137,7 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Hours
}
const tagsStr = tagsMap.get(e.entry.id)?.join("; ") || "";
const tagsStr = e.tag?.name || "";
return [
start.toLocaleDateString(),

View File

@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { tags, members, timeEntryTags } from "../../../../db/schema";
import { tags, members, timeEntries } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ params, locals, redirect }) => {
@@ -44,8 +44,11 @@ export const POST: APIRoute = async ({ params, locals, redirect }) => {
});
}
// Delete associations first
await db.delete(timeEntryTags).where(eq(timeEntryTags.tagId, id));
// Remove tag from time entries
await db
.update(timeEntries)
.set({ tagId: null })
.where(eq(timeEntries.tagId, id));
// Delete the tag
await db.delete(tags).where(eq(tags.id, id));

View File

@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
import { timeEntries, members } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import {
@@ -17,7 +17,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
const body = await request.json();
const { description, clientId, startTime, endTime, tags } = body;
const { description, clientId, startTime, endTime, tagId } = body;
// Validation
if (!clientId) {
@@ -74,7 +74,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
const resourceValidation = await validateTimeEntryResources({
organizationId: member.organizationId,
clientId,
tagIds: Array.isArray(tags) ? tags : undefined,
tagId: tagId || null,
});
if (!resourceValidation.valid) {
@@ -93,22 +93,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
userId: locals.user.id,
organizationId: member.organizationId,
clientId,
tagId: tagId || null,
startTime: startDate,
endTime: endDate,
description: description || null,
isManual: true,
});
// Insert tags if provided
if (tags && Array.isArray(tags) && tags.length > 0) {
await db.insert(timeEntryTags).values(
tags.map((tagId: string) => ({
timeEntryId: id,
tagId,
})),
);
}
return new Response(
JSON.stringify({
success: true,

View File

@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
import { timeEntries, members } from "../../../db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { nanoid } from "nanoid";
import { validateTimeEntryResources } from "../../../lib/validation";
@@ -11,7 +11,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
const body = await request.json();
const description = body.description || "";
const clientId = body.clientId;
const tags = body.tags || [];
const tagId = body.tagId || null;
if (!clientId) {
return new Response("Client is required", { status: 400 });
@@ -42,7 +42,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
const validation = await validateTimeEntryResources({
organizationId: member.organizationId,
clientId,
tagIds: tags,
tagId,
});
if (!validation.valid) {
@@ -57,19 +57,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
userId: locals.user.id,
organizationId: member.organizationId,
clientId,
tagId,
startTime,
description,
isManual: false,
});
if (tags.length > 0) {
await db.insert(timeEntryTags).values(
tags.map((tagId: string) => ({
timeEntryId: id,
tagId,
})),
);
}
return new Response(JSON.stringify({ id, startTime }), { status: 200 });
};

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, tags, timeEntryTags, users } from '../../../../db/schema';
import { eq, and, desc, sql, inArray } from 'drizzle-orm';
import { clients, timeEntries, members, tags, users } from '../../../../db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
import { formatTimeRange } from '../../../../lib/formatTime';
const user = Astro.locals.user;
@@ -38,44 +38,19 @@ const client = await db.select()
if (!client) return Astro.redirect('/dashboard/clients');
// Get recent activity
const recentEntriesData = await db.select({
const recentEntries = await db.select({
entry: timeEntries,
user: users,
tag: tags,
})
.from(timeEntries)
.leftJoin(users, eq(timeEntries.userId, users.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.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)`
@@ -206,27 +181,25 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<thead>
<tr>
<th>Description</th>
<th>Tags</th>
<th>Tag</th>
<th>User</th>
<th>Date</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{recentEntries.map(({ entry, tags, user: entryUser }) => (
{recentEntries.map(({ entry, tag, user: entryUser }) => (
<tr>
<td>{entry.description || '-'}</td>
<td>
<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>
{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>
) : '-'}
</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, tags, timeEntryTags } from '../../db/schema';
import { eq, desc, and, isNull, gte, sql, inArray } from 'drizzle-orm';
import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
import { formatDuration } from '../../lib/formatTime';
const user = Astro.locals.user;
@@ -84,42 +84,18 @@ if (currentOrg) {
stats.totalClients = clientCount?.count || 0;
const recentEntriesData = await db.select({
stats.recentEntries = await db.select({
entry: timeEntries,
client: clients,
tag: tags,
})
.from(timeEntries)
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.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;
@@ -229,14 +205,14 @@ const hasMembership = userOrgs.length > 0;
</h2>
{stats.recentEntries.length > 0 ? (
<ul class="space-y-3 mt-4">
{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'}`}>
{stats.recentEntries.map(({ entry, client, tag }) => (
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${tag?.color || '#3b82f6'}`}>
<div class="font-semibold text-sm">{client.name}</div>
<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) => (
{tag ? (
<span class="badge badge-xs badge-outline">{tag.name}</span>
)) : <span class="italic opacity-50">No tags</span>}
) : <span class="italic opacity-50">No tag</span>}
</span>
<span>• {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
</div>

View File

@@ -5,8 +5,8 @@ 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, tags, timeEntryTags, invoices } from '../../db/schema';
import { eq, and, gte, lte, sql, desc, inArray, exists } from 'drizzle-orm';
import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema';
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
const user = Astro.locals.user;
@@ -103,64 +103,27 @@ if (selectedMemberId) {
}
if (selectedTagId) {
conditions.push(exists(
db.select()
.from(timeEntryTags)
.where(and(
eq(timeEntryTags.timeEntryId, timeEntries.id),
eq(timeEntryTags.tagId, selectedTagId)
))
));
conditions.push(eq(timeEntries.tagId, selectedTagId));
}
if (selectedClientId) {
conditions.push(eq(timeEntries.clientId, selectedClientId));
}
const entriesData = await db.select({
const entries = await db.select({
entry: timeEntries,
user: users,
client: clients,
tag: tags,
})
.from(timeEntries)
.innerJoin(users, eq(timeEntries.userId, users.id))
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.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) => {
@@ -178,7 +141,7 @@ const statsByMember = teamMembers.map(member => {
}).sort((a, b) => b.totalTime - a.totalTime);
const statsByTag = allTags.map(tag => {
const tagEntries = entries.filter(e => e.tags.some(t => t.id === tag.id));
const tagEntries = entries.filter(e => e.tag?.id === tag.id);
const totalTime = tagEntries.reduce((sum, e) => {
if (e.entry.endTime) {
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
@@ -811,7 +774,7 @@ function getTimeRangeLabel(range: string) {
<th>Date</th>
<th>Member</th>
<th>Client</th>
<th>Tags</th>
<th>Tag</th>
<th>Description</th>
<th>Duration</th>
</tr>
@@ -828,17 +791,16 @@ function getTimeRangeLabel(range: string) {
<td>{e.user.name}</td>
<td>{e.client.name}</td>
<td>
<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>
{e.tag ? (
<div class="badge badge-sm badge-outline flex items-center gap-1">
{e.tag.color && (
<span class="w-2 h-2 rounded-full" style={`background-color: ${e.tag.color}`}></span>
)}
<span>{e.tag.name}</span>
</div>
) : (
<span class="opacity-50">-</span>
)}
</td>
<td>{e.entry.description || '-'}</td>
<td class="font-mono">

View File

@@ -4,7 +4,7 @@ import { Icon } from 'astro-icon/components';
import Timer from '../../components/Timer.vue';
import ManualEntry from '../../components/ManualEntry.vue';
import { db } from '../../db';
import { timeEntries, clients, members, tags, timeEntryTags, users } from '../../db/schema';
import { timeEntries, clients, members, tags, users } from '../../db/schema';
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
import { formatTimeRange } from '../../lib/formatTime';
@@ -99,10 +99,12 @@ const entries = await db.select({
entry: timeEntries,
client: clients,
user: users,
tag: tags,
})
.from(timeEntries)
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
.leftJoin(users, eq(timeEntries.userId, users.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(and(...conditions))
.orderBy(orderBy)
.limit(pageSize)
@@ -112,9 +114,11 @@ const entries = await db.select({
const runningEntry = await db.select({
entry: timeEntries,
client: clients,
tag: tags,
})
.from(timeEntries)
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(and(
eq(timeEntries.userId, user.id),
sql`${timeEntries.endTime} IS NULL`
@@ -165,6 +169,7 @@ const paginationPages = getPaginationPages(page, totalPages);
startTime: runningEntry.entry.startTime.getTime(),
description: runningEntry.entry.description,
clientId: runningEntry.entry.clientId,
tagId: runningEntry.tag?.id,
} : null}
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}