This commit is contained in:
12
README.md
12
README.md
@@ -2,9 +2,9 @@
|
|||||||
A modern time tracking application built with Astro, Vue, and DaisyUI.
|
A modern time tracking application built with Astro, Vue, and DaisyUI.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
Frameword: Astro
|
- Framework: Astro
|
||||||
Runtime: Node
|
- Runtime: Node
|
||||||
UI Library: Vue 3
|
- UI Library: Vue 3
|
||||||
CSS and Styles: DaisyUI + Tailwind CSS
|
- CSS and Styles: DaisyUI + Tailwind CSS
|
||||||
Database: libSQL
|
- Database: libSQL
|
||||||
ORM: Drizzle ORM
|
- ORM: Drizzle ORM
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronus",
|
"name": "chronus",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.3.0",
|
"version": "2.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
real,
|
real,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
foreignKey,
|
foreignKey,
|
||||||
|
index,
|
||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
@@ -57,6 +58,10 @@ export const members = sqliteTable(
|
|||||||
columns: [table.organizationId],
|
columns: [table.organizationId],
|
||||||
foreignColumns: [organizations.id],
|
foreignColumns: [organizations.id],
|
||||||
}),
|
}),
|
||||||
|
userIdIdx: index("members_user_id_idx").on(table.userId),
|
||||||
|
organizationIdIdx: index("members_organization_id_idx").on(
|
||||||
|
table.organizationId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -84,6 +89,9 @@ export const clients = sqliteTable(
|
|||||||
columns: [table.organizationId],
|
columns: [table.organizationId],
|
||||||
foreignColumns: [organizations.id],
|
foreignColumns: [organizations.id],
|
||||||
}),
|
}),
|
||||||
|
organizationIdIdx: index("clients_organization_id_idx").on(
|
||||||
|
table.organizationId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,6 +113,9 @@ export const categories = sqliteTable(
|
|||||||
columns: [table.organizationId],
|
columns: [table.organizationId],
|
||||||
foreignColumns: [organizations.id],
|
foreignColumns: [organizations.id],
|
||||||
}),
|
}),
|
||||||
|
organizationIdIdx: index("categories_organization_id_idx").on(
|
||||||
|
table.organizationId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -143,6 +154,12 @@ export const timeEntries = sqliteTable(
|
|||||||
columns: [table.categoryId],
|
columns: [table.categoryId],
|
||||||
foreignColumns: [categories.id],
|
foreignColumns: [categories.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),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -164,6 +181,9 @@ export const tags = sqliteTable(
|
|||||||
columns: [table.organizationId],
|
columns: [table.organizationId],
|
||||||
foreignColumns: [organizations.id],
|
foreignColumns: [organizations.id],
|
||||||
}),
|
}),
|
||||||
|
organizationIdIdx: index("tags_organization_id_idx").on(
|
||||||
|
table.organizationId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -183,6 +203,10 @@ export const timeEntryTags = sqliteTable(
|
|||||||
columns: [table.tagId],
|
columns: [table.tagId],
|
||||||
foreignColumns: [tags.id],
|
foreignColumns: [tags.id],
|
||||||
}),
|
}),
|
||||||
|
timeEntryIdIdx: index("time_entry_tags_time_entry_id_idx").on(
|
||||||
|
table.timeEntryId,
|
||||||
|
),
|
||||||
|
tagIdIdx: index("time_entry_tags_tag_id_idx").on(table.tagId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -198,6 +222,7 @@ export const sessions = sqliteTable(
|
|||||||
columns: [table.userId],
|
columns: [table.userId],
|
||||||
foreignColumns: [users.id],
|
foreignColumns: [users.id],
|
||||||
}),
|
}),
|
||||||
|
userIdIdx: index("sessions_user_id_idx").on(table.userId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -232,6 +257,7 @@ export const apiTokens = sqliteTable(
|
|||||||
columns: [table.userId],
|
columns: [table.userId],
|
||||||
foreignColumns: [users.id],
|
foreignColumns: [users.id],
|
||||||
}),
|
}),
|
||||||
|
userIdIdx: index("api_tokens_user_id_idx").on(table.userId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -267,6 +293,10 @@ export const invoices = sqliteTable(
|
|||||||
columns: [table.clientId],
|
columns: [table.clientId],
|
||||||
foreignColumns: [clients.id],
|
foreignColumns: [clients.id],
|
||||||
}),
|
}),
|
||||||
|
organizationIdIdx: index("invoices_organization_id_idx").on(
|
||||||
|
table.organizationId,
|
||||||
|
),
|
||||||
|
clientIdIdx: index("invoices_client_id_idx").on(table.clientId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -287,5 +317,6 @@ export const invoiceItems = sqliteTable(
|
|||||||
columns: [table.invoiceId],
|
columns: [table.invoiceId],
|
||||||
foreignColumns: [invoices.id],
|
foreignColumns: [invoices.id],
|
||||||
}),
|
}),
|
||||||
|
invoiceIdIdx: index("invoice_items_invoice_id_idx").on(table.invoiceId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
83
src/lib/validation.ts
Normal file
83
src/lib/validation.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { db } from "../db";
|
||||||
|
import { clients, categories, tags as tagsTable } from "../db/schema";
|
||||||
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function validateTimeEntryResources({
|
||||||
|
organizationId,
|
||||||
|
clientId,
|
||||||
|
categoryId,
|
||||||
|
tagIds,
|
||||||
|
}: {
|
||||||
|
organizationId: string;
|
||||||
|
clientId: string;
|
||||||
|
categoryId: string;
|
||||||
|
tagIds?: string[];
|
||||||
|
}) {
|
||||||
|
const [client, category] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.id, clientId),
|
||||||
|
eq(clients.organizationId, organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get(),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(categories)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(categories.id, categoryId),
|
||||||
|
eq(categories.organizationId, organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return { valid: false, error: "Invalid client" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return { valid: false, error: "Invalid category" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagIds && tagIds.length > 0) {
|
||||||
|
const validTags = await db
|
||||||
|
.select()
|
||||||
|
.from(tagsTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(tagsTable.id, tagIds),
|
||||||
|
eq(tagsTable.organizationId, organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (validTags.length !== tagIds.length) {
|
||||||
|
return { valid: false, error: "Invalid tags" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTimeRange(
|
||||||
|
start: string | number | Date,
|
||||||
|
end: string | number | Date,
|
||||||
|
) {
|
||||||
|
const startDate = new Date(start);
|
||||||
|
const endDate = new Date(end);
|
||||||
|
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
return { valid: false, error: "Invalid date format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate <= startDate) {
|
||||||
|
return { valid: false, error: "End time must be after start time" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, startDate, endDate };
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
|||||||
return redirect("/signup?error=missing_fields");
|
return redirect("/signup?error=missing_fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return redirect("/signup?error=password_too_short");
|
||||||
|
}
|
||||||
|
|
||||||
const existingUser = await db
|
const existingUser = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../db';
|
import { db } from "../../../db";
|
||||||
import { timeEntries, members, timeEntryTags, categories, clients } from '../../../db/schema';
|
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq } from "drizzle-orm";
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from "nanoid";
|
||||||
|
import {
|
||||||
|
validateTimeEntryResources,
|
||||||
|
validateTimeRange,
|
||||||
|
} from "../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
if (!locals.user) {
|
if (!locals.user) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
JSON.stringify({ error: 'Unauthorized' }),
|
|
||||||
{
|
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@@ -20,67 +21,47 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: "Client is required" }), {
|
||||||
JSON.stringify({ error: 'Client is required' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!categoryId) {
|
if (!categoryId) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: "Category is required" }), {
|
||||||
JSON.stringify({ error: 'Category is required' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!startTime) {
|
if (!startTime) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: "Start time is required" }), {
|
||||||
JSON.stringify({ error: 'Start time is required' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!endTime) {
|
if (!endTime) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: "End time is required" }), {
|
||||||
JSON.stringify({ error: 'End time is required' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const startDate = new Date(startTime);
|
const timeValidation = validateTimeRange(startTime, endTime);
|
||||||
const endDate = new Date(endTime);
|
|
||||||
|
|
||||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
if (
|
||||||
return new Response(
|
!timeValidation.valid ||
|
||||||
JSON.stringify({ error: 'Invalid date format' }),
|
!timeValidation.startDate ||
|
||||||
{
|
!timeValidation.endDate
|
||||||
|
) {
|
||||||
|
return new Response(JSON.stringify({ error: timeValidation.error }), {
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endDate <= startDate) {
|
const { startDate, endDate } = timeValidation;
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'End time must be after start time' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user's organization
|
// Get user's organization
|
||||||
const member = await db
|
const member = await db
|
||||||
@@ -91,57 +72,24 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: "No organization found" }), {
|
||||||
JSON.stringify({ error: 'No organization found' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify category belongs to organization
|
const resourceValidation = await validateTimeEntryResources({
|
||||||
const category = await db
|
organizationId: member.organizationId,
|
||||||
.select()
|
clientId,
|
||||||
.from(categories)
|
categoryId,
|
||||||
.where(
|
tagIds: Array.isArray(tags) ? tags : undefined,
|
||||||
and(
|
});
|
||||||
eq(categories.id, categoryId),
|
|
||||||
eq(categories.organizationId, member.organizationId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!category) {
|
if (!resourceValidation.valid) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: resourceValidation.error }), {
|
||||||
JSON.stringify({ error: 'Invalid category' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify client belongs to organization
|
|
||||||
const client = await db
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(clients.id, clientId),
|
|
||||||
eq(clients.organizationId, member.organizationId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Invalid client' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = nanoid();
|
const id = nanoid();
|
||||||
@@ -166,7 +114,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
tags.map((tagId: string) => ({
|
tags.map((tagId: string) => ({
|
||||||
timeEntryId: id,
|
timeEntryId: id,
|
||||||
tagId,
|
tagId,
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,17 +127,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 201,
|
status: 201,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating manual time entry:', error);
|
console.error("Error creating manual time entry:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Failed to create time entry' }),
|
JSON.stringify({ error: "Failed to create time entry" }),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { db } from "../../../db";
|
import { db } from "../../../db";
|
||||||
import {
|
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
|
||||||
timeEntries,
|
|
||||||
members,
|
|
||||||
timeEntryTags,
|
|
||||||
categories,
|
|
||||||
} 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";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
if (!locals.user) return new Response("Unauthorized", { status: 401 });
|
if (!locals.user) return new Response("Unauthorized", { status: 401 });
|
||||||
@@ -48,19 +44,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
return new Response("No organization found", { status: 400 });
|
return new Response("No organization found", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const category = await db
|
const validation = await validateTimeEntryResources({
|
||||||
.select()
|
organizationId: member.organizationId,
|
||||||
.from(categories)
|
clientId,
|
||||||
.where(
|
categoryId,
|
||||||
and(
|
tagIds: tags,
|
||||||
eq(categories.id, categoryId),
|
});
|
||||||
eq(categories.organizationId, member.organizationId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!category) {
|
if (!validation.valid) {
|
||||||
return new Response("Invalid category", { status: 400 });
|
return new Response(validation.error, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = new Date();
|
const startTime = new Date();
|
||||||
|
|||||||
@@ -41,52 +41,48 @@ if (currentOrg) {
|
|||||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
const weekEntries = await db.select()
|
const weekStats = await db.select({
|
||||||
|
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
|
||||||
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(timeEntries.organizationId, currentOrg.organizationId),
|
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||||
gte(timeEntries.startTime, weekAgo)
|
gte(timeEntries.startTime, weekAgo),
|
||||||
|
sql`${timeEntries.endTime} IS NOT NULL`
|
||||||
))
|
))
|
||||||
.all();
|
.get();
|
||||||
|
|
||||||
stats.totalTimeThisWeek = weekEntries.reduce((sum, e) => {
|
stats.totalTimeThisWeek = weekStats?.totalDuration || 0;
|
||||||
if (e.endTime) {
|
|
||||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
|
||||||
}
|
|
||||||
return sum;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const monthEntries = await db.select()
|
const monthStats = await db.select({
|
||||||
|
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
|
||||||
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(timeEntries.organizationId, currentOrg.organizationId),
|
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||||
gte(timeEntries.startTime, monthAgo)
|
gte(timeEntries.startTime, monthAgo),
|
||||||
|
sql`${timeEntries.endTime} IS NOT NULL`
|
||||||
))
|
))
|
||||||
.all();
|
.get();
|
||||||
|
|
||||||
stats.totalTimeThisMonth = monthEntries.reduce((sum, e) => {
|
stats.totalTimeThisMonth = monthStats?.totalDuration || 0;
|
||||||
if (e.endTime) {
|
|
||||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
|
||||||
}
|
|
||||||
return sum;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const activeCount = await db.select()
|
const activeCount = await db.select({ count: sql<number>`count(*)` })
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(timeEntries.organizationId, currentOrg.organizationId),
|
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||||
isNull(timeEntries.endTime)
|
isNull(timeEntries.endTime)
|
||||||
))
|
))
|
||||||
.all();
|
.get();
|
||||||
|
|
||||||
stats.activeTimers = activeCount.length;
|
stats.activeTimers = activeCount?.count || 0;
|
||||||
|
|
||||||
const clientCount = await db.select()
|
const clientCount = await db.select({ count: sql<number>`count(*)` })
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.organizationId, currentOrg.organizationId))
|
.where(eq(clients.organizationId, currentOrg.organizationId))
|
||||||
.all();
|
.get();
|
||||||
|
|
||||||
stats.totalClients = clientCount.length;
|
stats.totalClients = clientCount?.count || 0;
|
||||||
|
|
||||||
stats.recentEntries = await db.select({
|
stats.recentEntries = await db.select({
|
||||||
entry: timeEntries,
|
entry: timeEntries,
|
||||||
|
|||||||
Reference in New Issue
Block a user