diff --git a/drizzle/0001_demonic_red_skull.sql b/drizzle/0001_demonic_red_skull.sql new file mode 100644 index 0000000..093f834 --- /dev/null +++ b/drizzle/0001_demonic_red_skull.sql @@ -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`); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..90a85ba --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1219 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "837a4e18-b319-465d-9e30-2614b4850fb5", + "prevId": "8343b003-264b-444a-9782-07d736dd3407", + "tables": { + "api_tokens": { + "name": "api_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'*'" + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_tokens_token_unique": { + "name": "api_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "api_tokens_user_id_idx": { + "name": "api_tokens_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_tokens_user_id_users_id_fk": { + "name": "api_tokens_user_id_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clients": { + "name": "clients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "street": { + "name": "street", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "zip": { + "name": "zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "clients_organization_id_idx": { + "name": "clients_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "clients_organization_id_organizations_id_fk": { + "name": "clients_organization_id_organizations_id_fk", + "tableFrom": "clients", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invoice_items": { + "name": "invoice_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "unit_price": { + "name": "unit_price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "invoice_items_invoice_id_idx": { + "name": "invoice_items_invoice_id_idx", + "columns": [ + "invoice_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invoice_items_invoice_id_invoices_id_fk": { + "name": "invoice_items_invoice_id_invoices_id_fk", + "tableFrom": "invoice_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'invoice'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "issue_date": { + "name": "issue_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "subtotal": { + "name": "subtotal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "discount_value": { + "name": "discount_value", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'percentage'" + }, + "discount_amount": { + "name": "discount_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "tax_rate": { + "name": "tax_rate", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "tax_amount": { + "name": "tax_amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "invoices_organization_id_idx": { + "name": "invoices_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invoices_client_id_idx": { + "name": "invoices_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invoices_organization_id_organizations_id_fk": { + "name": "invoices_organization_id_organizations_id_fk", + "tableFrom": "invoices", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_client_id_clients_id_fk": { + "name": "invoices_client_id_clients_id_fk", + "tableFrom": "invoices", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "members": { + "name": "members", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "members_user_id_organization_id_pk": { + "columns": [ + "user_id", + "organization_id" + ], + "name": "members_user_id_organization_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "street": { + "name": "street", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "zip": { + "name": "zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_tax_rate": { + "name": "default_tax_rate", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'USD'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey_challenges": { + "name": "passkey_challenges", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "passkey_challenges_challenge_unique": { + "name": "passkey_challenges_challenge_unique", + "columns": [ + "challenge" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkeys": { + "name": "passkeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkeys_user_id_idx": { + "name": "passkeys_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_settings": { + "name": "site_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "site_settings_key_unique": { + "name": "site_settings_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rate": { + "name": "rate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tags_organization_id_idx": { + "name": "tags_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tags_organization_id_organizations_id_fk": { + "name": "tags_organization_id_organizations_id_fk", + "tableFrom": "tags", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "time_entries": { + "name": "time_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_manual": { + "name": "is_manual", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "time_entries_user_id_idx": { + "name": "time_entries_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "time_entries_organization_id_idx": { + "name": "time_entries_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "time_entries_client_id_idx": { + "name": "time_entries_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "time_entries_tag_id_idx": { + "name": "time_entries_tag_id_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + }, + "time_entries_start_time_idx": { + "name": "time_entries_start_time_idx", + "columns": [ + "start_time" + ], + "isUnique": false + }, + "time_entries_invoice_id_idx": { + "name": "time_entries_invoice_id_idx", + "columns": [ + "invoice_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "time_entries_user_id_users_id_fk": { + "name": "time_entries_user_id_users_id_fk", + "tableFrom": "time_entries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "time_entries_organization_id_organizations_id_fk": { + "name": "time_entries_organization_id_organizations_id_fk", + "tableFrom": "time_entries", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "time_entries_client_id_clients_id_fk": { + "name": "time_entries_client_id_clients_id_fk", + "tableFrom": "time_entries", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "time_entries_tag_id_tags_id_fk": { + "name": "time_entries_tag_id_tags_id_fk", + "tableFrom": "time_entries", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_site_admin": { + "name": "is_site_admin", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index eb036fd..987bc2d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/src/components/ManualEntry.vue b/src/components/ManualEntry.vue index 9b587e6..1d0146f 100644 --- a/src/components/ManualEntry.vue +++ b/src/components/ManualEntry.vue @@ -12,7 +12,7 @@ const emit = defineEmits<{ const description = ref(""); const selectedClientId = ref(""); -const selectedTags = ref([]); +const selectedTagId = ref(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', ]" diff --git a/src/components/Timer.vue b/src/components/Timer.vue index 29a18d2..84b9fd5 100644 --- a/src/components/Timer.vue +++ b/src/components/Timer.vue @@ -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(null); const elapsedTime = ref(0); const description = ref(""); const selectedClientId = ref(""); -const selectedTags = ref([]); +const selectedTagId = ref(null); let interval: ReturnType | 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', ]" diff --git a/src/db/schema.ts b/src/db/schema.ts index adf0224..b3b834c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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), }), ); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 8445ccd..1bba30e 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -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" }; } } diff --git a/src/pages/api/clients/[id]/delete.ts b/src/pages/api/clients/[id]/delete.ts index cfc3f30..855f69b 100644 --- a/src/pages/api/clients/[id]/delete.ts +++ b/src/pages/api/clients/[id]/delete.ts @@ -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(); diff --git a/src/pages/api/invoices/[id]/import-time.ts b/src/pages/api/invoices/[id]/import-time.ts index 817c59f..61de21d 100644 --- a/src/pages/api/invoices/[id]/import-time.ts +++ b/src/pages/api/invoices/[id]/import-time.ts @@ -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 diff --git a/src/pages/api/reports/export.ts b/src/pages/api/reports/export.ts index e9dd50a..416b587 100644 --- a/src/pages/api/reports/export.ts +++ b/src/pages/api/reports/export.ts @@ -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(); - - 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(), diff --git a/src/pages/api/tags/[id]/delete.ts b/src/pages/api/tags/[id]/delete.ts index d0e8546..0d95f85 100644 --- a/src/pages/api/tags/[id]/delete.ts +++ b/src/pages/api/tags/[id]/delete.ts @@ -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)); diff --git a/src/pages/api/time-entries/manual.ts b/src/pages/api/time-entries/manual.ts index c74cdd7..52e15a6 100644 --- a/src/pages/api/time-entries/manual.ts +++ b/src/pages/api/time-entries/manual.ts @@ -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, diff --git a/src/pages/api/time-entries/start.ts b/src/pages/api/time-entries/start.ts index 6a4320d..40e9f6d 100644 --- a/src/pages/api/time-entries/start.ts +++ b/src/pages/api/time-entries/start.ts @@ -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 }); }; diff --git a/src/pages/dashboard/clients/[id]/index.astro b/src/pages/dashboard/clients/[id]/index.astro index 2e8f161..579c97e 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, 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(); - -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)` @@ -206,27 +181,25 @@ const totalEntriesCount = totalEntriesResult?.count || 0; Description - Tags + Tag User Date Duration - {recentEntries.map(({ entry, tags, user: entryUser }) => ( + {recentEntries.map(({ entry, tag, user: entryUser }) => ( {entry.description || '-'} -
- {tags.length > 0 ? tags.map(tag => ( -
- {tag.color && ( - - )} - {tag.name} -
- )) : '-'} -
+ {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 d4a5c3e..1bce981 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, 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(); - - 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; {stats.recentEntries.length > 0 ? (
    - {stats.recentEntries.map(({ entry, client, tags }) => ( -
  • + {stats.recentEntries.map(({ entry, client, tag }) => ( +
  • {client.name}
    - {tags.length > 0 ? tags.map((tag: any) => ( + {tag ? ( {tag.name} - )) : No tags} + ) : No tag} • {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 c3d8721..6b1b745 100644 --- a/src/pages/dashboard/reports.astro +++ b/src/pages/dashboard/reports.astro @@ -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(); - -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) { Date Member Client - Tags + Tag Description Duration @@ -828,17 +791,16 @@ function getTimeRangeLabel(range: string) { {e.user.name} {e.client.name} -
    - {e.tags.map(tag => ( -
    - {tag.color && ( - - )} - {tag.name} -
    - ))} - {e.tags.length === 0 && -} -
    + {e.tag ? ( +
    + {e.tag.color && ( + + )} + {e.tag.name} +
    + ) : ( + - + )} {e.entry.description || '-'} diff --git a/src/pages/dashboard/tracker.astro b/src/pages/dashboard/tracker.astro index 0e29e0e..a8218bb 100644 --- a/src/pages/dashboard/tracker.astro +++ b/src/pages/dashboard/tracker.astro @@ -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 }))}