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

View File

@@ -7,6 +7,7 @@ const props = defineProps<{
startTime: number; startTime: number;
description: string | null; description: string | null;
clientId: string; clientId: string;
tagId?: string;
} | null; } | null;
clients: { id: string; name: string }[]; clients: { id: string; name: string }[];
tags: { id: string; name: string; color: string | null }[]; tags: { id: string; name: string; color: string | null }[];
@@ -17,7 +18,7 @@ const startTime = ref<number | null>(null);
const elapsedTime = ref(0); const elapsedTime = ref(0);
const description = ref(""); const description = ref("");
const selectedClientId = ref(""); const selectedClientId = ref("");
const selectedTags = ref<string[]>([]); const selectedTagId = ref<string | null>(null);
let interval: ReturnType<typeof setInterval> | null = null; let interval: ReturnType<typeof setInterval> | null = null;
function formatTime(ms: number) { function formatTime(ms: number) {
@@ -46,11 +47,10 @@ function formatTime(ms: number) {
} }
function toggleTag(tagId: string) { function toggleTag(tagId: string) {
const index = selectedTags.value.indexOf(tagId); if (selectedTagId.value === tagId) {
if (index > -1) { selectedTagId.value = null;
selectedTags.value.splice(index, 1);
} else { } else {
selectedTags.value.push(tagId); selectedTagId.value = tagId;
} }
} }
@@ -60,6 +60,7 @@ onMounted(() => {
startTime.value = props.initialRunningEntry.startTime; startTime.value = props.initialRunningEntry.startTime;
description.value = props.initialRunningEntry.description || ""; description.value = props.initialRunningEntry.description || "";
selectedClientId.value = props.initialRunningEntry.clientId; selectedClientId.value = props.initialRunningEntry.clientId;
selectedTagId.value = props.initialRunningEntry.tagId || null;
elapsedTime.value = Date.now() - startTime.value; elapsedTime.value = Date.now() - startTime.value;
interval = setInterval(() => { interval = setInterval(() => {
elapsedTime.value = Date.now() - startTime.value!; elapsedTime.value = Date.now() - startTime.value!;
@@ -83,7 +84,7 @@ async function startTimer() {
body: JSON.stringify({ body: JSON.stringify({
description: description.value, description: description.value,
clientId: selectedClientId.value, clientId: selectedClientId.value,
tags: selectedTags.value, tagId: selectedTagId.value,
}), }),
}); });
@@ -109,7 +110,7 @@ async function stopTimer() {
startTime.value = null; startTime.value = null;
description.value = ""; description.value = "";
selectedClientId.value = ""; selectedClientId.value = "";
selectedTags.value = []; selectedTagId.value = null;
window.location.reload(); window.location.reload();
} }
} }
@@ -163,7 +164,7 @@ async function stopTimer() {
@click="toggleTag(tag.id)" @click="toggleTag(tag.id)"
:class="[ :class="[
'badge badge-lg cursor-pointer transition-all hover:scale-105', '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-primary shadow-lg shadow-primary/20'
: 'badge-outline hover:bg-base-300/50', : '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( export const tags = sqliteTable(
"tags", "tags",
{ {
@@ -163,26 +122,50 @@ export const tags = sqliteTable(
}), }),
); );
export const timeEntryTags = sqliteTable( export const timeEntries = sqliteTable(
"time_entry_tags", "time_entries",
{ {
timeEntryId: text("time_entry_id").notNull(), id: text("id")
tagId: text("tag_id").notNull(), .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) => ({ (table: any) => ({
pk: primaryKey({ columns: [table.timeEntryId, table.tagId] }), userFk: foreignKey({
timeEntryFk: foreignKey({ columns: [table.userId],
columns: [table.timeEntryId], foreignColumns: [users.id],
foreignColumns: [timeEntries.id], }),
orgFk: foreignKey({
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
clientFk: foreignKey({
columns: [table.clientId],
foreignColumns: [clients.id],
}), }),
tagFk: foreignKey({ tagFk: foreignKey({
columns: [table.tagId], columns: [table.tagId],
foreignColumns: [tags.id], foreignColumns: [tags.id],
}), }),
timeEntryIdIdx: index("time_entry_tags_time_entry_id_idx").on( userIdIdx: index("time_entries_user_id_idx").on(table.userId),
table.timeEntryId, 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 { db } from "../db";
import { clients, tags as tagsTable } from "../db/schema"; 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({ export async function validateTimeEntryResources({
organizationId, organizationId,
clientId, clientId,
tagIds, tagId,
}: { }: {
organizationId: string; organizationId: string;
clientId: string; clientId: string;
tagIds?: string[]; tagId?: string | null;
}) { }) {
const client = await db const client = await db
.select() .select()
@@ -23,20 +23,20 @@ export async function validateTimeEntryResources({
return { valid: false, error: "Invalid client" }; return { valid: false, error: "Invalid client" };
} }
if (tagIds && tagIds.length > 0) { if (tagId) {
const validTags = await db const validTag = await db
.select() .select()
.from(tagsTable) .from(tagsTable)
.where( .where(
and( and(
inArray(tagsTable.id, tagIds), eq(tagsTable.id, tagId),
eq(tagsTable.organizationId, organizationId), eq(tagsTable.organizationId, organizationId),
), ),
) )
.all(); .get();
if (validTags.length !== tagIds.length) { if (!validTag) {
return { valid: false, error: "Invalid tags" }; return { valid: false, error: "Invalid tag" };
} }
} }

View File

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

View File

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

View File

@@ -1,14 +1,7 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { db } from "../../../db"; import { db } from "../../../db";
import { import { timeEntries, members, users, clients, tags } from "../../../db/schema";
timeEntries, import { eq, and, gte, lte, desc } from "drizzle-orm";
members,
users,
clients,
tags,
timeEntryTags,
} from "../../../db/schema";
import { eq, and, gte, lte, desc, inArray } from "drizzle-orm";
export const GET: APIRoute = async ({ request, locals, cookies }) => { export const GET: APIRoute = async ({ request, locals, cookies }) => {
const user = locals.user; const user = locals.user;
@@ -114,37 +107,16 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
entry: timeEntries, entry: timeEntries,
user: users, user: users,
client: clients, client: clients,
tag: tags,
}) })
.from(timeEntries) .from(timeEntries)
.innerJoin(users, eq(timeEntries.userId, users.id)) .innerJoin(users, eq(timeEntries.userId, users.id))
.innerJoin(clients, eq(timeEntries.clientId, clients.id)) .innerJoin(clients, eq(timeEntries.clientId, clients.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(and(...conditions)) .where(and(...conditions))
.orderBy(desc(timeEntries.startTime)) .orderBy(desc(timeEntries.startTime))
.all(); .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 // Generate CSV
const headers = [ const headers = [
"Date", "Date",
@@ -153,7 +125,7 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
"Duration (h)", "Duration (h)",
"Member", "Member",
"Client", "Client",
"Tags", "Tag",
"Description", "Description",
]; ];
const rows = entries.map((e) => { 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 duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Hours
} }
const tagsStr = tagsMap.get(e.entry.id)?.join("; ") || ""; const tagsStr = e.tag?.name || "";
return [ return [
start.toLocaleDateString(), start.toLocaleDateString(),

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,8 @@
import DashboardLayout from '../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import { db } from '../../db'; import { db } from '../../db';
import { organizations, members, timeEntries, clients, tags, timeEntryTags } from '../../db/schema'; import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
import { eq, desc, and, isNull, gte, sql, inArray } from 'drizzle-orm'; import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
import { formatDuration } from '../../lib/formatTime'; import { formatDuration } from '../../lib/formatTime';
const user = Astro.locals.user; const user = Astro.locals.user;
@@ -84,42 +84,18 @@ if (currentOrg) {
stats.totalClients = clientCount?.count || 0; stats.totalClients = clientCount?.count || 0;
const recentEntriesData = await db.select({ stats.recentEntries = await db.select({
entry: timeEntries, entry: timeEntries,
client: clients, client: clients,
tag: tags,
}) })
.from(timeEntries) .from(timeEntries)
.innerJoin(clients, eq(timeEntries.clientId, clients.id)) .innerJoin(clients, eq(timeEntries.clientId, clients.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(eq(timeEntries.organizationId, currentOrg.organizationId)) .where(eq(timeEntries.organizationId, currentOrg.organizationId))
.orderBy(desc(timeEntries.startTime)) .orderBy(desc(timeEntries.startTime))
.limit(5) .limit(5)
.all(); .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; const hasMembership = userOrgs.length > 0;
@@ -229,14 +205,14 @@ const hasMembership = userOrgs.length > 0;
</h2> </h2>
{stats.recentEntries.length > 0 ? ( {stats.recentEntries.length > 0 ? (
<ul class="space-y-3 mt-4"> <ul class="space-y-3 mt-4">
{stats.recentEntries.map(({ entry, client, tags }) => ( {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: ${tags[0]?.color || '#3b82f6'}`}> <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="font-semibold text-sm">{client.name}</div>
<div class="text-xs text-base-content/60 mt-1 flex flex-wrap gap-2 items-center"> <div class="text-xs text-base-content/60 mt-1 flex flex-wrap gap-2 items-center">
<span class="flex gap-1 flex-wrap"> <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="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>
<span>• {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span> <span>• {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
</div> </div>

View File

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

View File

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