From 3825282ae496529b5954e4165034f223e41e9a31 Mon Sep 17 00:00:00 2001 From: Friedrich482 Date: Tue, 7 Jul 2026 18:02:11 +0200 Subject: [PATCH 01/13] feat: git branches support - added a utility function `get-current-git-branch` that gets the git branch of the current workspace. The branch can be string | null - we don't have a git branch if: either the project is not version controlled or the file is not a part of the current workspace folder, which was already taken in account - updated the global state object type to also include the branch name - now remaining is to include the branches on the server side --- apps/vscode-extension/src/types-schemas.ts | 2 + .../utils/branch/get-current-git-branch.ts | 33 +++ .../src/utils/branch/git.d.ts | 209 ++++++++++++++++++ .../files/get-current-file-properties.ts | 7 + .../utils/files/update-current-file-obj.ts | 4 +- .../src/utils/periodic-sync-data.ts | 10 +- 6 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 apps/vscode-extension/src/utils/branch/get-current-git-branch.ts create mode 100644 apps/vscode-extension/src/utils/branch/git.d.ts diff --git a/apps/vscode-extension/src/types-schemas.ts b/apps/vscode-extension/src/types-schemas.ts index d7383b5d..495e85af 100644 --- a/apps/vscode-extension/src/types-schemas.ts +++ b/apps/vscode-extension/src/types-schemas.ts @@ -22,6 +22,7 @@ export type FileData = { projectPath: string; languageSlug: string; fileName: string; + branchName: string | null; }; export const globalStateInitialDataSchema = z.object({ @@ -43,6 +44,7 @@ export const globalStateInitialDataSchema = z.object({ languageSlug: z.string().min(1), projectName: z.string().min(1), fileName: z.string().min(1), + branchName: z.string().min(1).nullable(), }), ), diff --git a/apps/vscode-extension/src/utils/branch/get-current-git-branch.ts b/apps/vscode-extension/src/utils/branch/get-current-git-branch.ts new file mode 100644 index 00000000..ec65c33e --- /dev/null +++ b/apps/vscode-extension/src/utils/branch/get-current-git-branch.ts @@ -0,0 +1,33 @@ +import vscode from "vscode"; + +import { GitExtension } from "./git"; + +export const getCurrentGitBranch = (projectPath: string) => { + const extension = vscode.extensions.getExtension("vscode.git"); + + if (!extension || !extension.isActive) { + return null; + } + + const git = extension.exports.getAPI(1); + + const repository = git.repositories.find( + (repository) => repository.rootUri.path === projectPath, + ); + + if (!repository) { + return null; + } + + const currentBranch = repository.state.HEAD; + if (!currentBranch) { + return null; + } + + const branchName = currentBranch.name; + if (!branchName) { + return null; + } + + return branchName; +}; diff --git a/apps/vscode-extension/src/utils/branch/git.d.ts b/apps/vscode-extension/src/utils/branch/git.d.ts new file mode 100644 index 00000000..12903279 --- /dev/null +++ b/apps/vscode-extension/src/utils/branch/git.d.ts @@ -0,0 +1,209 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event, Uri } from "vscode"; + +export interface Git { + readonly path: string; +} + +export interface InputBox { + value: string; +} + +export const enum RefType { + Head, + RemoteHead, + Tag, +} + +export interface Ref { + readonly type: RefType; + readonly name?: string; + readonly commit?: string; + readonly remote?: string; +} + +export interface UpstreamRef { + readonly remote: string; + readonly name: string; +} + +export interface Branch extends Ref { + readonly upstream?: UpstreamRef; + readonly ahead?: number; + readonly behind?: number; +} + +export interface Commit { + readonly hash: string; + readonly message: string; + readonly parents: string[]; +} + +export interface Submodule { + readonly name: string; + readonly path: string; + readonly url: string; +} + +export interface Remote { + readonly name: string; + readonly fetchUrl?: string; + readonly pushUrl?: string; + readonly isReadOnly: boolean; +} + +export const enum Status { + INDEX_MODIFIED, + INDEX_ADDED, + INDEX_DELETED, + INDEX_RENAMED, + INDEX_COPIED, + + MODIFIED, + DELETED, + UNTRACKED, + IGNORED, + + ADDED_BY_US, + ADDED_BY_THEM, + DELETED_BY_US, + DELETED_BY_THEM, + BOTH_ADDED, + BOTH_DELETED, + BOTH_MODIFIED, +} + +export interface Change { + /** + * Returns either `originalUri` or `renameUri`, depending + * on whether this change is a rename change. When + * in doubt always use `uri` over the other two alternatives. + */ + readonly uri: Uri; + readonly originalUri: Uri; + readonly renameUri: Uri | undefined; + readonly status: Status; +} + +export interface RepositoryState { + readonly HEAD: Branch | undefined; + readonly refs: Ref[]; + readonly remotes: Remote[]; + readonly submodules: Submodule[]; + readonly rebaseCommit: Commit | undefined; + + readonly mergeChanges: Change[]; + readonly indexChanges: Change[]; + readonly workingTreeChanges: Change[]; + + readonly onDidChange: Event; +} + +export interface RepositoryUIState { + readonly selected: boolean; + readonly onDidChange: Event; +} + +export interface Repository { + readonly rootUri: Uri; + readonly inputBox: InputBox; + readonly state: RepositoryState; + readonly ui: RepositoryUIState; + + getConfigs(): Promise<{ key: string; value: string }[]>; + getConfig(key: string): Promise; + setConfig(key: string, value: string): Promise; + + getObjectDetails( + treeish: string, + path: string, + ): Promise<{ mode: string; object: string; size: number }>; + detectObjectType( + object: string, + ): Promise<{ mimetype: string; encoding?: string }>; + buffer(ref: string, path: string): Promise; + show(ref: string, path: string): Promise; + getCommit(ref: string): Promise; + + clean(paths: string[]): Promise; + + diffWithHEAD(path: string): Promise; + diffWith(ref: string, path: string): Promise; + diffIndexWithHEAD(path: string): Promise; + diffIndexWith(ref: string, path: string): Promise; + diffBlobs(object1: string, object2: string): Promise; + diffBetween(ref1: string, ref2: string, path: string): Promise; + + hashObject(data: string): Promise; + + createBranch(name: string, checkout: boolean, ref?: string): Promise; + deleteBranch(name: string): Promise; + getBranch(name: string): Promise; + setBranchUpstream(name: string, upstream: string): Promise; + + getMergeBase(ref1: string, ref2: string): Promise; + + status(): Promise; + checkout(treeish: string): Promise; + + addRemote(name: string, url: string): Promise; + removeRemote(name: string): Promise; + + fetch(remote?: string, ref?: string): Promise; + pull(): Promise; +} + +export interface API { + readonly git: Git; + readonly repositories: Repository[]; + readonly onDidOpenRepository: Event; + readonly onDidCloseRepository: Event; +} + +export interface GitExtension { + /** + * Returns a specific API version. + * + * @param version Version number. + * @returns API instance + */ + getAPI(version: 1): API; +} + +export const enum GitErrorCodes { + BadConfigFile = "BadConfigFile", + AuthenticationFailed = "AuthenticationFailed", + NoUserNameConfigured = "NoUserNameConfigured", + NoUserEmailConfigured = "NoUserEmailConfigured", + NoRemoteRepositorySpecified = "NoRemoteRepositorySpecified", + NotAGitRepository = "NotAGitRepository", + NotAtRepositoryRoot = "NotAtRepositoryRoot", + Conflict = "Conflict", + StashConflict = "StashConflict", + UnmergedChanges = "UnmergedChanges", + PushRejected = "PushRejected", + RemoteConnectionError = "RemoteConnectionError", + DirtyWorkTree = "DirtyWorkTree", + CantOpenResource = "CantOpenResource", + GitNotFound = "GitNotFound", + CantCreatePipe = "CantCreatePipe", + CantAccessRemote = "CantAccessRemote", + RepositoryNotFound = "RepositoryNotFound", + RepositoryIsLocked = "RepositoryIsLocked", + BranchNotFullyMerged = "BranchNotFullyMerged", + NoRemoteReference = "NoRemoteReference", + InvalidBranchName = "InvalidBranchName", + BranchAlreadyExists = "BranchAlreadyExists", + NoLocalChanges = "NoLocalChanges", + NoStashFound = "NoStashFound", + LocalChangesOverwritten = "LocalChangesOverwritten", + NoUpstreamBranch = "NoUpstreamBranch", + IsInSubmodule = "IsInSubmodule", + WrongCase = "WrongCase", + CantLockRef = "CantLockRef", + CantRebaseMultipleBranches = "CantRebaseMultipleBranches", +} diff --git a/apps/vscode-extension/src/utils/files/get-current-file-properties.ts b/apps/vscode-extension/src/utils/files/get-current-file-properties.ts index 15fd020c..5d7d2f87 100644 --- a/apps/vscode-extension/src/utils/files/get-current-file-properties.ts +++ b/apps/vscode-extension/src/utils/files/get-current-file-properties.ts @@ -1,6 +1,8 @@ import * as path from "path"; import vscode from "vscode"; +import { getCurrentGitBranch } from "../branch/get-current-git-branch"; + export const getCurrentFileProperties = ( document: vscode.TextDocument | undefined, ) => { @@ -10,6 +12,7 @@ export const getCurrentFileProperties = ( projectPath: null, absolutePath: null, fileName: null, + branchName: null, }; } @@ -27,9 +30,12 @@ export const getCurrentFileProperties = ( projectPath: null, absolutePath: null, fileName: null, + branchName: null, }; } + const branchName = getCurrentGitBranch(projectPath); + const absolutePath = path.normalize(fileUri.fsPath); const fileName = path.basename(absolutePath); @@ -38,5 +44,6 @@ export const getCurrentFileProperties = ( projectPath, absolutePath, fileName, + branchName, }; }; diff --git a/apps/vscode-extension/src/utils/files/update-current-file-obj.ts b/apps/vscode-extension/src/utils/files/update-current-file-obj.ts index 6fd6abfa..9c35f189 100644 --- a/apps/vscode-extension/src/utils/files/update-current-file-obj.ts +++ b/apps/vscode-extension/src/utils/files/update-current-file-obj.ts @@ -8,7 +8,7 @@ import { getCurrentFileProperties } from "./get-current-file-properties"; export const updateCurrentFileObj = ( document: vscode.TextDocument | undefined, ) => { - const { absolutePath, projectName, projectPath, fileName } = + const { absolutePath, projectName, projectPath, fileName, branchName } = getCurrentFileProperties(document); const currentLanguageSlug = getLanguageSlug(document); @@ -34,6 +34,7 @@ export const updateCurrentFileObj = ( projectPath, languageSlug: currentLanguageSlug, fileName, + branchName, }; } @@ -43,4 +44,5 @@ export const updateCurrentFileObj = ( currentFileData.languageSlug = currentLanguageSlug; currentFileData.projectName = projectName; currentFileData.projectPath = projectPath; + currentFileData.branchName = branchName; }; diff --git a/apps/vscode-extension/src/utils/periodic-sync-data.ts b/apps/vscode-extension/src/utils/periodic-sync-data.ts index fa4dce27..44e18ba2 100644 --- a/apps/vscode-extension/src/utils/periodic-sync-data.ts +++ b/apps/vscode-extension/src/utils/periodic-sync-data.ts @@ -49,7 +49,14 @@ export const periodicSyncData = async ( Object.entries(filesDataToUpsert).map( ([ filePath, - { elapsedTime, languageSlug, projectName, projectPath, fileName }, + { + elapsedTime, + languageSlug, + projectName, + projectPath, + fileName, + branchName, + }, ]) => [ filePath, { @@ -58,6 +65,7 @@ export const periodicSyncData = async ( projectName, projectPath, fileName, + branchName, }, ], ), From 0e1d70381cfafaa95f5ecf0deaf962973950a2ce Mon Sep 17 00:00:00 2001 From: Friedrich482 Date: Wed, 8 Jul 2026 01:00:55 +0200 Subject: [PATCH 02/13] feat: database migrations - added the branches table schema - updated the files schema to remove the project_id and replaced it by branch_id - generated and ran migrations scripts, adding necessary adjustments to safely put the branch table between projects and files, generate default branch main for each project link all files of a project to that main branch before safely deleting the project_id column from the files table --- apps/api/drizzle/0021_boring_chat.sql | 156 ++++ apps/api/drizzle/0022_sparkling_mephisto.sql | 14 + apps/api/drizzle/meta/0021_snapshot.json | 912 +++++++++++++++++++ apps/api/drizzle/meta/0022_snapshot.json | 878 ++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 16 +- apps/api/src/drizzle/schema/branches.ts | 30 + apps/api/src/drizzle/schema/files.ts | 7 +- apps/api/src/drizzle/schema/index.ts | 1 + 8 files changed, 2009 insertions(+), 5 deletions(-) create mode 100644 apps/api/drizzle/0021_boring_chat.sql create mode 100644 apps/api/drizzle/0022_sparkling_mephisto.sql create mode 100644 apps/api/drizzle/meta/0021_snapshot.json create mode 100644 apps/api/drizzle/meta/0022_snapshot.json create mode 100644 apps/api/src/drizzle/schema/branches.ts diff --git a/apps/api/drizzle/0021_boring_chat.sql b/apps/api/drizzle/0021_boring_chat.sql new file mode 100644 index 00000000..ed40e887 --- /dev/null +++ b/apps/api/drizzle/0021_boring_chat.sql @@ -0,0 +1,156 @@ +-- create the generate_ulid function (https://github.com/chrhansen/postgres-ulid) +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- ULID according to the spec: https://github.com/ulid/spec + -- generate_ulid() => 1GND65M56YS10B35V121ZS4D4 +-- With prefix output in base58 (still sortable in Base 58) + -- generate_ulid('base58', 'user') => user_BtnhF9Zpi2zMFsUfMXTCR +-- More usage: https://github.com/chrhansen/postgres-ulid#usage + +CREATE OR REPLACE FUNCTION generate_ulid(output_base text default 'base32', -- also base58 or uuid + prefix text default NULL, + delimiter text default '_') RETURNS text + LANGUAGE plpgsql + AS $$ +DECLARE + timestamp BYTEA := E'\\000\\000\\000\\000\\000\\000'; + unix_time BIGINT; + ulid BYTEA; + base32_alphabet TEXT := '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; -- Crockford's + base58_alphabet TEXT := '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; -- Bitcoin + ulid_base32_length INT := 26; + return_string TEXT := ''; +BEGIN + -- 6 timestamp bytes + unix_time := (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT; + FOR i IN 0..5 LOOP + timestamp := SET_BYTE(timestamp, i, (unix_time >> (40 - i * 8))::BIT(8)::INTEGER); + END LOOP; + + -- 10 entropy bytes + ulid := timestamp || gen_random_bytes(10); + + -- Remove the leading '\x' and just keep the last 32 hex-characters + return_string := SUBSTRING(ulid::text, 3, 32); + + CASE lower(output_base) + WHEN 'base32' THEN + return_string := hex_to_base(return_string, base32_alphabet); + IF ulid_base32_length - length(return_string) > 0 THEN + -- There will maximum be one (1) missing leading zero (0) + return_string := repeat('0', ulid_base32_length - length(return_string)) || return_string; + END IF; + WHEN 'base58' THEN + return_string := hex_to_base(return_string, base58_alphabet); + WHEN 'uuid' THEN + -- Do nothing, already a "hex-string". Can be cast to uuid: generate_ulid('uuid')::uuid + ELSE + RAISE EXCEPTION 'Unsupported output_base %', output_base + USING HINT = 'Should be base32, base58, or uuid.'; + END CASE; + + IF prefix IS NOT NULL THEN + return_string := prefix || delimiter || return_string; + END IF; + + RETURN return_string; +END; +$$; + +CREATE OR REPLACE FUNCTION hex_to_base(hexstr TEXT, base_alphabet TEXT) RETURNS TEXT AS $$ +DECLARE + bytes BYTEA := ('\x' || hexstr)::BYTEA; + leading_zeroes INT := 0; + -- There are max. 40 (base10) digits in a 32 digit hex-number + num DECIMAL(40,0) := 0; + base DECIMAL(40,0) := 1; + byte_value INT; + byte_val INT; + byte_values INT[] DEFAULT ARRAY[]::INT[]; + modulo INT; + encoded_str TEXT := ''; +BEGIN + -- This was built to convert 128-bit (32 hex-chars) UUIDs to another base, + -- so we'll only promise that. Postgres DECIMAL can be up to ~131000 digits + -- so bump the scale of 'num' and 'base' to convert bigger hex-numbers. + IF length(hexstr) != 32 THEN + RAISE EXCEPTION 'Hex-string >%< should be 32 characters long, but is % char(s).', hexstr, length(hexstr); + END IF; + + -- Convert the 32-digit 'hexstr', to the base10 ('normal' digits) 'num' + FOR hex_index IN REVERSE ((length(hexstr) / 2) - 1)..0 LOOP + byte_value := get_byte(bytes, hex_index); + IF byte_value = 0 THEN + leading_zeroes := leading_zeroes + 1; + ELSE + leading_zeroes := 0; + num := num + (base * byte_value); + END IF; + base := base * 256; -- Two (2) hex-digits: 16 * 16 = 256 + END LOOP; + + -- Convert the up to 40-digit 'num', to the digits in 'base_alphabet' + WHILE num > 0 LOOP + modulo := num % length(base_alphabet); + num := div(num, length(base_alphabet)); + byte_values := array_append(byte_values, modulo); + END LOOP; + + -- Convert the 'byte_values' using characters from 'base_alphabet'. By + -- prepending to 'encoded_str' the order of 'byte_values' is reversed. + FOREACH byte_val IN ARRAY byte_values + LOOP + encoded_str := SUBSTRING(base_alphabet, byte_val + 1, 1) || encoded_str; + END LOOP; + + -- Prepend first 'base_alphabet'-character to account for leading zeroes in 'hexstr' + encoded_str := repeat(SUBSTRING(base_alphabet, 1, 1), leading_zeroes) || encoded_str; + + RETURN encoded_str; +END; +$$ LANGUAGE plpgsql IMMUTABLE STRICT; + +CREATE TABLE + "branches" ( + "id" varchar(26) PRIMARY KEY NOT NULL, + "project_id" varchar(26) NOT NULL, + "name" text NOT NULL, + "time_spent" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now () NOT NULL, + "updated_at" timestamp DEFAULT now () NOT NULL + ); + +--> statement-breakpoint +ALTER TABLE "files" +ADD COLUMN "branch_id" varchar(26); + +--> statement-breakpoint +ALTER TABLE "branches" ADD CONSTRAINT "branches_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects" ("id") ON DELETE cascade ON UPDATE no action; + +--> statement-breakpoint +CREATE INDEX "branch_project_id_index" ON "branches" USING btree ("project_id"); + +--> statement-breakpoint +CREATE INDEX "branch_name_index" ON "branches" USING btree ("name"); + +--> statement-breakpoint +ALTER TABLE "files" ADD CONSTRAINT "files_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches" ("id") ON DELETE cascade ON UPDATE no action; + +INSERT INTO + "branches" (id, "project_id", name, "time_spent") +SELECT + LOWER(generate_ulid()), + id, + 'main', + time_spent +FROM + "projects"; + +UPDATE "files" f +SET + "branch_id" = b.id +FROM + "branches" b +WHERE + b.project_id = f.project_id + AND b.name = 'main'; \ No newline at end of file diff --git a/apps/api/drizzle/0022_sparkling_mephisto.sql b/apps/api/drizzle/0022_sparkling_mephisto.sql new file mode 100644 index 00000000..ba5a079b --- /dev/null +++ b/apps/api/drizzle/0022_sparkling_mephisto.sql @@ -0,0 +1,14 @@ +ALTER TABLE "files" +DROP CONSTRAINT "files_project_id_projects_id_fk"; + +--> statement-breakpoint +DROP INDEX "project_id_index"; + +--> statement-breakpoint +ALTER TABLE "files" +DROP COLUMN "project_id"; + +ALTER TABLE "files" +ALTER COLUMN branch_id +SET + NOT NULL \ No newline at end of file diff --git a/apps/api/drizzle/meta/0021_snapshot.json b/apps/api/drizzle/meta/0021_snapshot.json new file mode 100644 index 00000000..ffa91994 --- /dev/null +++ b/apps/api/drizzle/meta/0021_snapshot.json @@ -0,0 +1,912 @@ +{ + "id": "81e8d318-5b1f-41c9-a21d-69473296b71f", + "prevId": "f16e80d8-5e05-441b-97da-426ad183c181", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.branches": { + "name": "branches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_spent": { + "name": "time_spent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_project_id_index": { + "name": "branch_project_id_index", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_name_index": { + "name": "branch_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branches_project_id_projects_id_fk": { + "name": "branches_project_id_projects_id_fk", + "tableFrom": "branches", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_data": { + "name": "daily_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "time_spent": { + "name": "time_spent", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "daily_data_user_id_index": { + "name": "daily_data_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "daily_data_date_index": { + "name": "daily_data_date_index", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "daily_data_user_id_users_id_fk": { + "name": "daily_data_user_id_users_id_fk", + "tableFrom": "daily_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verifications": { + "name": "email_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now() + interval '30 minutes'" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "NULL" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_verifications_email_unique": { + "name": "email_verifications_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "language_id": { + "name": "language_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_spent": { + "name": "time_spent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_id_index": { + "name": "project_id_index", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "language_id_index": { + "name": "language_id_index", + "columns": [ + { + "expression": "language_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_name_index": { + "name": "file_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_path_index": { + "name": "file_path_index", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "files_project_id_projects_id_fk": { + "name": "files_project_id_projects_id_fk", + "tableFrom": "files", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_branch_id_branches_id_fk": { + "name": "files_branch_id_branches_id_fk", + "tableFrom": "files", + "tableTo": "branches", + "columnsFrom": [ + "branch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_language_id_languages_id_fk": { + "name": "files_language_id_languages_id_fk", + "tableFrom": "files", + "tableTo": "languages", + "columnsFrom": [ + "language_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.languages": { + "name": "languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "daily_data_id": { + "name": "daily_data_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "language_slug": { + "name": "language_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_spent": { + "name": "time_spent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "language_daily_data_id_index": { + "name": "language_daily_data_id_index", + "columns": [ + { + "expression": "daily_data_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "language_slug_index": { + "name": "language_slug_index", + "columns": [ + { + "expression": "language_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "languages_daily_data_id_daily_data_id_fk": { + "name": "languages_daily_data_id_daily_data_id_fk", + "tableFrom": "languages", + "tableTo": "daily_data", + "columnsFrom": [ + "daily_data_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_resets": { + "name": "password_resets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now() + interval '15 minutes'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_resets_user_id_index": { + "name": "password_resets_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "password_resets_email_index": { + "name": "password_resets_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_resets_user_id_users_id_fk": { + "name": "password_resets_user_id_users_id_fk", + "tableFrom": "password_resets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_resets_email_unique": { + "name": "password_resets_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "daily_data_id": { + "name": "daily_data_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_spent": { + "name": "time_spent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_daily_data_id_index": { + "name": "project_daily_data_id_index", + "columns": [ + { + "expression": "daily_data_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_name_index": { + "name": "project_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_path_index": { + "name": "project_path_index", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_daily_data_id_daily_data_id_fk": { + "name": "projects_daily_data_id_daily_data_id_fk", + "tableFrom": "projects", + "tableTo": "daily_data", + "columnsFrom": [ + "daily_data_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hashed_password": { + "name": "hashed_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_picture": { + "name": "profile_picture", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_id": { + "name": "google_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_email": { + "name": "google_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_method": { + "name": "auth_method", + "type": "auth_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "NULL" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_google_id_unique": { + "name": "users_google_id_unique", + "nullsNotDistinct": false, + "columns": [ + "google_id" + ] + }, + "users_google_email_unique": { + "name": "users_google_email_unique", + "nullsNotDistinct": false, + "columns": [ + "google_email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.auth_method": { + "name": "auth_method", + "schema": "public", + "values": [ + "email", + "google", + "both" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0022_snapshot.json b/apps/api/drizzle/meta/0022_snapshot.json new file mode 100644 index 00000000..f41dd8b8 --- /dev/null +++ b/apps/api/drizzle/meta/0022_snapshot.json @@ -0,0 +1,878 @@ +{ + "id": "5c08d2b8-7808-4f64-afc9-5c2b4aac00ab", + "prevId": "81e8d318-5b1f-41c9-a21d-69473296b71f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.branches": { + "name": "branches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_spent": { + "name": "time_spent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_project_id_index": { + "name": "branch_project_id_index", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_name_index": { + "name": "branch_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branches_project_id_projects_id_fk": { + "name": "branches_project_id_projects_id_fk", + "tableFrom": "branches", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_data": { + "name": "daily_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "time_spent": { + "name": "time_spent", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "daily_data_user_id_index": { + "name": "daily_data_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "daily_data_date_index": { + "name": "daily_data_date_index", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "daily_data_user_id_users_id_fk": { + "name": "daily_data_user_id_users_id_fk", + "tableFrom": "daily_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verifications": { + "name": "email_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now() + interval '30 minutes'" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "NULL" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_verifications_email_unique": { + "name": "email_verifications_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "language_id": { + "name": "language_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_spent": { + "name": "time_spent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "language_id_index": { + "name": "language_id_index", + "columns": [ + { + "expression": "language_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_name_index": { + "name": "file_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_path_index": { + "name": "file_path_index", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "files_branch_id_branches_id_fk": { + "name": "files_branch_id_branches_id_fk", + "tableFrom": "files", + "tableTo": "branches", + "columnsFrom": [ + "branch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_language_id_languages_id_fk": { + "name": "files_language_id_languages_id_fk", + "tableFrom": "files", + "tableTo": "languages", + "columnsFrom": [ + "language_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.languages": { + "name": "languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "daily_data_id": { + "name": "daily_data_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "language_slug": { + "name": "language_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_spent": { + "name": "time_spent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "language_daily_data_id_index": { + "name": "language_daily_data_id_index", + "columns": [ + { + "expression": "daily_data_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "language_slug_index": { + "name": "language_slug_index", + "columns": [ + { + "expression": "language_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "languages_daily_data_id_daily_data_id_fk": { + "name": "languages_daily_data_id_daily_data_id_fk", + "tableFrom": "languages", + "tableTo": "daily_data", + "columnsFrom": [ + "daily_data_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_resets": { + "name": "password_resets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now() + interval '15 minutes'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_resets_user_id_index": { + "name": "password_resets_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "password_resets_email_index": { + "name": "password_resets_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_resets_user_id_users_id_fk": { + "name": "password_resets_user_id_users_id_fk", + "tableFrom": "password_resets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_resets_email_unique": { + "name": "password_resets_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "daily_data_id": { + "name": "daily_data_id", + "type": "varchar(26)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_spent": { + "name": "time_spent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_daily_data_id_index": { + "name": "project_daily_data_id_index", + "columns": [ + { + "expression": "daily_data_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_name_index": { + "name": "project_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_path_index": { + "name": "project_path_index", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_daily_data_id_daily_data_id_fk": { + "name": "projects_daily_data_id_daily_data_id_fk", + "tableFrom": "projects", + "tableTo": "daily_data", + "columnsFrom": [ + "daily_data_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(26)", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hashed_password": { + "name": "hashed_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_picture": { + "name": "profile_picture", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_id": { + "name": "google_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_email": { + "name": "google_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_method": { + "name": "auth_method", + "type": "auth_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "NULL" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_google_id_unique": { + "name": "users_google_id_unique", + "nullsNotDistinct": false, + "columns": [ + "google_id" + ] + }, + "users_google_email_unique": { + "name": "users_google_email_unique", + "nullsNotDistinct": false, + "columns": [ + "google_email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.auth_method": { + "name": "auth_method", + "schema": "public", + "values": [ + "email", + "google", + "both" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index b552f6db..7d735e04 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -148,6 +148,20 @@ "when": 1771979205913, "tag": "0020_classy_archangel", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1783441300568, + "tag": "0021_boring_chat", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1783453691330, + "tag": "0022_sparkling_mephisto", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/api/src/drizzle/schema/branches.ts b/apps/api/src/drizzle/schema/branches.ts new file mode 100644 index 00000000..795ae29b --- /dev/null +++ b/apps/api/src/drizzle/schema/branches.ts @@ -0,0 +1,30 @@ +import { varchar } from "drizzle-orm/pg-core"; +import { integer } from "drizzle-orm/pg-core"; +import { text } from "drizzle-orm/pg-core"; +import { pgTable } from "drizzle-orm/pg-core"; +import { index } from "drizzle-orm/pg-core"; +import { ulid } from "ulid"; + +import { timestamps } from "../columns.helpers"; +import { projects } from "./projects"; + +export const branches = pgTable( + "branches", + { + id: varchar("id", { length: 26 }) + .primaryKey() + .notNull() + .$defaultFn(() => ulid().toLowerCase()), + projectId: varchar("project_id", { length: 26 }) + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + + name: text("name").notNull(), + timeSpent: integer("time_spent").notNull().default(0), + ...timestamps, + }, + (table) => [ + index("branch_project_id_index").on(table.projectId), + index("branch_name_index").on(table.name), + ], +); diff --git a/apps/api/src/drizzle/schema/files.ts b/apps/api/src/drizzle/schema/files.ts index 1402f658..771fb86c 100644 --- a/apps/api/src/drizzle/schema/files.ts +++ b/apps/api/src/drizzle/schema/files.ts @@ -2,8 +2,8 @@ import { index, integer, pgTable, text, varchar } from "drizzle-orm/pg-core"; import { ulid } from "ulid"; import { timestamps } from "../columns.helpers"; +import { branches } from "./branches"; import { languages } from "./languages"; -import { projects } from "./projects"; export const files = pgTable( "files", @@ -12,9 +12,9 @@ export const files = pgTable( .primaryKey() .notNull() .$defaultFn(() => ulid().toLowerCase()), - projectId: varchar("project_id", { length: 26 }) + branchId: varchar("branch_id", { length: 26 }) .notNull() - .references(() => projects.id, { onDelete: "cascade" }), + .references(() => branches.id, { onDelete: "cascade" }), languageId: varchar("language_id", { length: 26 }) .notNull() .references(() => languages.id, { onDelete: "cascade" }), @@ -25,7 +25,6 @@ export const files = pgTable( ...timestamps, }, (table) => [ - index("project_id_index").on(table.projectId), index("language_id_index").on(table.languageId), index("file_name_index").on(table.name), index("file_path_index").on(table.path), diff --git a/apps/api/src/drizzle/schema/index.ts b/apps/api/src/drizzle/schema/index.ts index 9e64946b..818eee58 100644 --- a/apps/api/src/drizzle/schema/index.ts +++ b/apps/api/src/drizzle/schema/index.ts @@ -1,3 +1,4 @@ +export * from "./branches"; export * from "./daily-data"; export * from "./email-verifications"; export * from "./files"; From 54f9c9f44b7fa8910f67dd37681ce07264c142fa Mon Sep 17 00:00:00 2001 From: Friedrich482 Date: Wed, 8 Jul 2026 12:22:40 +0200 Subject: [PATCH 03/13] feat: branches api - generated the branches module with the basic functions in the branches.service: create, findOne and update - updated the files service to use the branchId instead of the projectId - updated the extension service upsertFiles method to take the branches in account: upsert the projects first, then the branches and finally the files - updated the vscode extension to use "N/A" when the branch is null (for a non-version controlled project), so the branch is always a string - updated the files impacted by that change in both the extension and in the extension.dto in the api --- apps/api/src/app.module.ts | 2 + apps/api/src/branches/branches.dto.ts | 24 +++++ apps/api/src/branches/branches.module.ts | 11 +++ apps/api/src/branches/branches.service.ts | 74 +++++++++++++++ apps/api/src/extension/extension.dto.ts | 1 + apps/api/src/extension/extension.module.ts | 9 +- apps/api/src/extension/extension.service.ts | 92 ++++++++++++++++++- apps/api/src/files/files.dto.ts | 6 +- apps/api/src/files/files.service.ts | 17 ++-- apps/vscode-extension/src/types-schemas.ts | 4 +- .../files/get-current-file-properties.ts | 2 +- .../src/utils/files/initialize-files.ts | 1 + .../utils/files/update-current-file-obj.ts | 3 +- .../files/update-files-data-after-sync.ts | 1 + 14 files changed, 229 insertions(+), 18 deletions(-) create mode 100644 apps/api/src/branches/branches.dto.ts create mode 100644 apps/api/src/branches/branches.module.ts create mode 100644 apps/api/src/branches/branches.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index e450a956..64f0d1bb 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -9,6 +9,7 @@ import { AppController } from "./app.controller"; import { AppService } from "./app.service"; import { AppRouterModule } from "./app-router/app-router.module"; import { AuthModule } from "./auth/auth.module"; +import { BranchesModule } from "./branches/branches.module"; import { DailyDataModule } from "./daily-data/daily-data.module"; import { DrizzleModule } from "./drizzle/drizzle.module"; import { EmailModule } from "./email/email.module"; @@ -52,6 +53,7 @@ import { UsersModule } from "./users/users.module"; PasswordResetsModule, AnalyticsModule, ExtensionModule, + BranchesModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/branches/branches.dto.ts b/apps/api/src/branches/branches.dto.ts new file mode 100644 index 00000000..3205b53d --- /dev/null +++ b/apps/api/src/branches/branches.dto.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const CreateBranchDto = z.object({ + projectId: z.ulid(), + name: z.string().min(1), + timeSpent: z.number().int().positive(), +}); + +export const FindBranchDto = z.object({ + projectId: z.ulid(), + name: z.string().min(1), +}); + +export const UpdateBranchDto = z.object({ + projectId: z.ulid(), + name: z.string().min(1), + timeSpent: z.number().int().positive(), +}); + +export type CreateBranchDtoType = z.infer; + +export type FindBranchDtoType = z.infer; + +export type UpdateBranchDtoType = z.infer; diff --git a/apps/api/src/branches/branches.module.ts b/apps/api/src/branches/branches.module.ts new file mode 100644 index 00000000..963ad188 --- /dev/null +++ b/apps/api/src/branches/branches.module.ts @@ -0,0 +1,11 @@ +import { DrizzleModule } from "@/drizzle/drizzle.module"; +import { Module } from "@nestjs/common"; + +import { BranchesService } from "./branches.service"; + +@Module({ + imports: [DrizzleModule], + providers: [BranchesService], + exports: [BranchesService], +}) +export class BranchesModule {} diff --git a/apps/api/src/branches/branches.service.ts b/apps/api/src/branches/branches.service.ts new file mode 100644 index 00000000..02d367ef --- /dev/null +++ b/apps/api/src/branches/branches.service.ts @@ -0,0 +1,74 @@ +import { and, eq } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; + +import { DRIZZLE_ASYNC_PROVIDER } from "@/drizzle/constants"; +import { branches } from "@/drizzle/schema"; +import { Inject, Injectable } from "@nestjs/common"; + +import { + CreateBranchDtoType, + FindBranchDtoType, + UpdateBranchDtoType, +} from "./branches.dto"; + +@Injectable() +export class BranchesService { + constructor( + @Inject(DRIZZLE_ASYNC_PROVIDER) + private readonly db: NodePgDatabase, + ) {} + async create(createBranchDto: CreateBranchDtoType) { + const { projectId, name, timeSpent } = createBranchDto; + + const [createBranch] = await this.db + .insert(branches) + .values({ + projectId, + name, + timeSpent, + }) + .returning({ + id: branches.id, + name: branches.name, + timeSpent: branches.timeSpent, + }); + + return createBranch; + } + + async findOne(findBranchDto: FindBranchDtoType) { + const { name, projectId } = findBranchDto; + + const [branch] = await this.db + .select({ + id: branches.id, + name: branches.name, + timeSpent: branches.timeSpent, + }) + .from(branches) + .where(and(eq(branches.projectId, projectId), eq(branches.name, name))) + .limit(1); + + if (!branch) { + return null; + } + + return branch; + } + + async update(updateBranchDto: UpdateBranchDtoType) { + const { projectId, timeSpent, name } = updateBranchDto; + + const [updatedBranch] = await this.db + .update(branches) + .set({ timeSpent }) + .where(and(eq(branches.projectId, projectId), eq(branches.name, name))) + .returning({ + name: branches.name, + timeSpent: branches.timeSpent, + projectId: branches.projectId, + }); + + return updatedBranch; + } +} diff --git a/apps/api/src/extension/extension.dto.ts b/apps/api/src/extension/extension.dto.ts index d1766f2f..b3332f48 100644 --- a/apps/api/src/extension/extension.dto.ts +++ b/apps/api/src/extension/extension.dto.ts @@ -26,6 +26,7 @@ export const UpsertFilesDto = z.object({ projectName: z.string().min(1), projectPath: z.string().min(1), fileName: z.string().min(1), + branchName: z.string().min(1), }), ), targetedDate: DateStringDto, diff --git a/apps/api/src/extension/extension.module.ts b/apps/api/src/extension/extension.module.ts index 976c4d1f..6a550060 100644 --- a/apps/api/src/extension/extension.module.ts +++ b/apps/api/src/extension/extension.module.ts @@ -1,3 +1,4 @@ +import { BranchesModule } from "@/branches/branches.module"; import { DailyDataModule } from "@/daily-data/daily-data.module"; import { FilesModule } from "@/files/files.module"; import { LanguagesModule } from "@/languages/languages.module"; @@ -8,7 +9,13 @@ import { ExtensionRouter } from "./extension.router"; import { ExtensionService } from "./extension.service"; @Module({ - imports: [DailyDataModule, LanguagesModule, ProjectsModule, FilesModule], + imports: [ + DailyDataModule, + LanguagesModule, + ProjectsModule, + BranchesModule, + FilesModule, + ], providers: [ExtensionService, ExtensionRouter], exports: [ExtensionService, ExtensionRouter], }) diff --git a/apps/api/src/extension/extension.service.ts b/apps/api/src/extension/extension.service.ts index d6998794..978c3c74 100644 --- a/apps/api/src/extension/extension.service.ts +++ b/apps/api/src/extension/extension.service.ts @@ -1,3 +1,4 @@ +import { BranchesService } from "@/branches/branches.service"; import { DailyDataService } from "@/daily-data/daily-data.service"; import { FilesService } from "@/files/files.service"; import { LanguagesService } from "@/languages/languages.service"; @@ -17,6 +18,7 @@ export class ExtensionService { private readonly dailyDataService: DailyDataService, private readonly languagesService: LanguagesService, private readonly projectsService: ProjectsService, + private readonly branchesService: BranchesService, private readonly filesService: FilesService, ) {} @@ -234,6 +236,86 @@ export class ExtensionService { } } + // we need to create all branches of the projects + const branchesData = Object.entries(filesData) + .map((entry) => ({ + filePath: entry[0], + ...entry[1], + })) + .reduce( + (acc, curr) => { + acc[curr.projectPath] = { + ...acc[curr.projectPath], + [curr.branchName]: { + projectId: + acc[curr.projectPath]?.[curr.branchName]?.projectId ?? + updatedProjects[curr.projectPath].projectId, + timeSpent: + (acc[curr.projectPath]?.[curr.branchName]?.timeSpent ?? 0) + + curr.timeSpent, + }, + }; + + return acc; + }, + {} as { + [projectPath: string]: { + [branchName: string]: { + timeSpent: number; + projectId: string; + }; + }; + }, + ); + + const updatedBranches: { + [projectPath: string]: { + [branchName: string]: { timeSpent: number; branchId: string }; + }; + } = {}; + + // for each project + for (const [projectPath, branches] of Object.entries(branchesData)) { + // for each branch in that project + for (const [name, branch] of Object.entries(branches)) { + const existingBranch = await this.branchesService.findOne({ + name, + projectId: branch.projectId, + }); + + updatedBranches[projectPath][name] = { branchId: "", timeSpent: 0 }; + + if (!existingBranch) { + // if the branch doesn't exist, create it + const createdBranch = await this.branchesService.create({ + name, + timeSpent: branch.timeSpent, + projectId: branch.projectId, + }); + + updatedBranches[projectPath][name].branchId = createdBranch.id; + updatedBranches[projectPath][name].timeSpent = + createdBranch.timeSpent; + } else { + // else update it but only if the new time spent is greater than the existing one + if (existingBranch.timeSpent < branch.timeSpent) { + const updatedBranch = await this.branchesService.update({ + name, + projectId: branch.projectId, + timeSpent: branch.timeSpent, + }); + updatedBranches[projectPath][name].timeSpent = + updatedBranch.timeSpent; + } else { + updatedBranches[projectPath][name].timeSpent = + existingBranch.timeSpent; + } + + updatedBranches[projectPath][name].branchId = existingBranch.id; + } + } + } + const returningFilesData = await this.filesService.findAllOnDay({ dailyDataId: dailyDataForDay.id, }); @@ -250,7 +332,7 @@ export class ExtensionService { } const existingFileData = await this.filesService.findOne({ - projectId: updatedProjects[file.projectPath].projectId, + branchId: updatedBranches[file.projectPath][file.branchName].branchId, name: file.fileName, path, languageId: fileLanguage.languageId, @@ -259,7 +341,7 @@ export class ExtensionService { if (!existingFileData) { // if the data for this file doesn't exist, create it const createdFile = await this.filesService.create({ - projectId: updatedProjects[file.projectPath].projectId, + branchId: updatedBranches[file.projectPath][file.branchName].branchId, languageId: fileLanguage.languageId, name: file.fileName, path, @@ -272,12 +354,14 @@ export class ExtensionService { projectPath: file.projectPath, timeSpent: createdFile.timeSpent, fileName: createdFile.name, + branchName: file.branchName, }; } else { // else just update the file data but only if the new timeSpent is greater than the existing one if (existingFileData.timeSpent < file.timeSpent) { const updatedFile = await this.filesService.update({ - projectId: updatedProjects[file.projectPath].projectId, + branchId: + updatedBranches[file.projectPath][file.branchName].branchId, languageId: fileLanguage.languageId, path, timeSpent: file.timeSpent, @@ -290,6 +374,7 @@ export class ExtensionService { projectPath: file.projectPath, timeSpent: updatedFile.timeSpent, fileName: updatedFile.name, + branchName: file.branchName, }; } else { returningFilesData[path] = { @@ -298,6 +383,7 @@ export class ExtensionService { projectPath: file.projectPath, fileName: file.fileName, timeSpent: existingFileData.timeSpent, + branchName: file.branchName, }; } } diff --git a/apps/api/src/files/files.dto.ts b/apps/api/src/files/files.dto.ts index c63c682c..fdc5c4dc 100644 --- a/apps/api/src/files/files.dto.ts +++ b/apps/api/src/files/files.dto.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const CreateFileDto = z.object({ - projectId: z.ulid(), + branchId: z.ulid(), languageId: z.ulid(), path: z.string().min(1), name: z.string().min(1), @@ -9,7 +9,7 @@ export const CreateFileDto = z.object({ }); export const UpdateFileDto = z.object({ - projectId: z.ulid(), + branchId: z.ulid(), languageId: z.ulid(), timeSpent: z.number().int(), path: z.string().min(1), @@ -18,7 +18,7 @@ export const UpdateFileDto = z.object({ export const FindOneFileDto = z.object({ languageId: z.ulid(), - projectId: z.ulid(), + branchId: z.ulid(), name: z.string().min(1), path: z.string().min(1), }); diff --git a/apps/api/src/files/files.service.ts b/apps/api/src/files/files.service.ts index 871974fe..4b76091b 100644 --- a/apps/api/src/files/files.service.ts +++ b/apps/api/src/files/files.service.ts @@ -2,6 +2,7 @@ import { and, asc, eq } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { DRIZZLE_ASYNC_PROVIDER } from "@/drizzle/constants"; +import { branches } from "@/drizzle/schema"; import { files } from "@/drizzle/schema/files"; import { languages } from "@/drizzle/schema/languages"; import { projects } from "@/drizzle/schema/projects"; @@ -22,13 +23,13 @@ export class FilesService { ) {} async create(createFileDto: CreateFileDtoType) { - const { languageId, projectId, name, timeSpent, path } = createFileDto; + const { languageId, branchId, name, timeSpent, path } = createFileDto; const [createdFileData] = await this.db .insert(files) .values({ languageId, - projectId, + branchId, name, path, timeSpent, @@ -43,7 +44,7 @@ export class FilesService { } async findOne(findOneFileDto: FindOneFileDtoType) { - const { languageId, projectId, name, path } = findOneFileDto; + const { languageId, branchId, name, path } = findOneFileDto; const [fileData] = await this.db .select({ @@ -55,7 +56,7 @@ export class FilesService { .where( and( eq(files.languageId, languageId), - eq(files.projectId, projectId), + eq(files.branchId, branchId), eq(files.name, name), eq(files.path, path), ), @@ -80,10 +81,12 @@ export class FilesService { filePath: files.path, projectName: projects.name, projectPath: projects.path, + branchName: branches.name, }) .from(files) - .innerJoin(projects, eq(projects.id, files.projectId)) + .innerJoin(branches, eq(branches.id, files.branchId)) .innerJoin(languages, eq(languages.id, files.languageId)) + .innerJoin(projects, eq(projects.id, branches.projectId)) .where(eq(projects.dailyDataId, dailyDataId)) .orderBy(asc(files.timeSpent)); @@ -95,7 +98,7 @@ export class FilesService { } async update(updateFileDto: UpdateFileDtoType) { - const { timeSpent, projectId, languageId, name, path } = updateFileDto; + const { timeSpent, branchId, languageId, name, path } = updateFileDto; const [updatedFileData] = await this.db .update(files) @@ -104,7 +107,7 @@ export class FilesService { }) .where( and( - eq(files.projectId, projectId), + eq(files.branchId, branchId), eq(files.languageId, languageId), eq(files.name, name), eq(files.path, path), diff --git a/apps/vscode-extension/src/types-schemas.ts b/apps/vscode-extension/src/types-schemas.ts index 495e85af..0fd400e7 100644 --- a/apps/vscode-extension/src/types-schemas.ts +++ b/apps/vscode-extension/src/types-schemas.ts @@ -22,7 +22,7 @@ export type FileData = { projectPath: string; languageSlug: string; fileName: string; - branchName: string | null; + branchName: string; }; export const globalStateInitialDataSchema = z.object({ @@ -44,7 +44,7 @@ export const globalStateInitialDataSchema = z.object({ languageSlug: z.string().min(1), projectName: z.string().min(1), fileName: z.string().min(1), - branchName: z.string().min(1).nullable(), + branchName: z.string().min(1), }), ), diff --git a/apps/vscode-extension/src/utils/files/get-current-file-properties.ts b/apps/vscode-extension/src/utils/files/get-current-file-properties.ts index 5d7d2f87..db75820d 100644 --- a/apps/vscode-extension/src/utils/files/get-current-file-properties.ts +++ b/apps/vscode-extension/src/utils/files/get-current-file-properties.ts @@ -34,7 +34,7 @@ export const getCurrentFileProperties = ( }; } - const branchName = getCurrentGitBranch(projectPath); + const branchName = getCurrentGitBranch(projectPath) ?? "N/A"; const absolutePath = path.normalize(fileUri.fsPath); const fileName = path.basename(absolutePath); diff --git a/apps/vscode-extension/src/utils/files/initialize-files.ts b/apps/vscode-extension/src/utils/files/initialize-files.ts index 49129cd4..34e1a1d9 100644 --- a/apps/vscode-extension/src/utils/files/initialize-files.ts +++ b/apps/vscode-extension/src/utils/files/initialize-files.ts @@ -18,6 +18,7 @@ export const initializeFiles = (data: FileDataSync) => { projectPath: file.projectPath, languageSlug: file.languageSlug, fileName: file.fileName, + branchName: file.branchName, }; }); }; diff --git a/apps/vscode-extension/src/utils/files/update-current-file-obj.ts b/apps/vscode-extension/src/utils/files/update-current-file-obj.ts index 9c35f189..ece1182a 100644 --- a/apps/vscode-extension/src/utils/files/update-current-file-obj.ts +++ b/apps/vscode-extension/src/utils/files/update-current-file-obj.ts @@ -17,7 +17,8 @@ export const updateCurrentFileObj = ( !projectName || !projectPath || !currentLanguageSlug || - !fileName + !fileName || + !branchName ) { return; } diff --git a/apps/vscode-extension/src/utils/files/update-files-data-after-sync.ts b/apps/vscode-extension/src/utils/files/update-files-data-after-sync.ts index e307057f..1354903a 100644 --- a/apps/vscode-extension/src/utils/files/update-files-data-after-sync.ts +++ b/apps/vscode-extension/src/utils/files/update-files-data-after-sync.ts @@ -33,6 +33,7 @@ export const updateFilesDataAfterSync = ( projectPath: file.projectPath, languageSlug: file.languageSlug, fileName: file.fileName, + branchName: file.branchName, }; }); }; From 3deb102d17b72bfde7f640dda57e8d270e64fa02 Mon Sep 17 00:00:00 2001 From: Friedrich482 Date: Thu, 9 Jul 2026 02:08:01 +0200 Subject: [PATCH 04/13] feat: analytics branches - updated the projects analytics methods to include the new relationship in consideration of the branches, with the proper joins between tables and calculations if necessary - updated all the files that were affected by the change of the endpoints schemas of projects analytics in the dashboard - used the nullish coalescing assignment to initialize `updatedBranches[projectPath]` while avoiding runtime errors - added tests files to the list of files to ignore during the api build in its `tsconfig.build.json` file --- apps/api/src/analytics/analytics.module.ts | 9 +- .../analytics/dto/projects-analytics.dto.ts | 27 +- .../services/projects-analytics.service.ts | 277 ++++++++++++++---- apps/api/src/extension/extension.service.ts | 2 +- apps/api/tsconfig.build.json | 2 +- .../components/files-circle-packing-chart.tsx | 6 +- .../features/files-list/hooks/use-files.tsx | 6 +- .../hooks/use-languages-dropdown.tsx | 6 +- .../project-day-languages-chart.tsx | 2 +- .../project-general-stats-chart.tsx | 4 +- ...use-suspense-query-projects-lang-chart.tsx | 10 +- .../project-time-on-period-chart.tsx | 6 +- .../hooks/use-get-time-spent-on-project.tsx | 4 +- 13 files changed, 263 insertions(+), 98 deletions(-) diff --git a/apps/api/src/analytics/analytics.module.ts b/apps/api/src/analytics/analytics.module.ts index bd92ec04..67e66f32 100644 --- a/apps/api/src/analytics/analytics.module.ts +++ b/apps/api/src/analytics/analytics.module.ts @@ -1,3 +1,4 @@ +import { BranchesModule } from "@/branches/branches.module"; import { DailyDataModule } from "@/daily-data/daily-data.module"; import { DrizzleModule } from "@/drizzle/drizzle.module"; import { LanguagesModule } from "@/languages/languages.module"; @@ -11,7 +12,13 @@ import { GeneralAnalyticsService } from "./services/general-analytics.service"; import { ProjectsAnalyticsService } from "./services/projects-analytics.service"; @Module({ - imports: [DrizzleModule, DailyDataModule, LanguagesModule, ProjectsModule], + imports: [ + DrizzleModule, + DailyDataModule, + LanguagesModule, + ProjectsModule, + BranchesModule, + ], providers: [ GeneralAnalyticsRouter, AnalyticsRouter, diff --git a/apps/api/src/analytics/dto/projects-analytics.dto.ts b/apps/api/src/analytics/dto/projects-analytics.dto.ts index 111b09d9..e3bc574b 100644 --- a/apps/api/src/analytics/dto/projects-analytics.dto.ts +++ b/apps/api/src/analytics/dto/projects-analytics.dto.ts @@ -14,25 +14,29 @@ export const FindProjectByNameOnRangeDto = z.object({ userId: z.ulid(), start: DateStringDto, end: DateStringDto, - name: z.string().min(1), + projectName: z.string().min(1), + branches: z.array(z.string().min(1)).min(1).optional(), }); export const GetProjectLanguagesTimePerDayOfPeriodDto = z.object({ userId: z.ulid(), start: DateStringDto, end: DateStringDto, - name: z.string().min(1), + projectName: z.string().min(1), + branches: z.array(z.string().min(1)).min(1).optional(), }); export const GetProjectDailyStatsDto = z.object({ dateString: DateStringDto, - name: z.string().min(1), + projectName: z.string().min(1), + branches: z.array(z.string().min(1)).min(1).optional(), }); export const GetPeriodGeneralStatsForProjectDto = refineAndTransformDto( z.object({ ...BaseDto.shape, - name: z.string().min(1), + projectName: z.string().min(1), + branches: z.array(z.string().min(1)).min(1).optional(), todaysDateString: DateStringDto, }), ); @@ -41,7 +45,8 @@ export const GetProjectFilesOnPeriodBaseDto = z.object({ ...refineDto( z.object({ ...DateRangeDto.shape, - name: z.string().min(1), + projectName: z.string().min(1), + branches: z.array(z.string().min(1)).min(1).optional(), }), ).shape, }); @@ -76,28 +81,32 @@ export const GetPeriodProjectsDto = refineDto( export const GetProjectOnPeriodDto = refineDto( z.object({ ...DateRangeDto.shape, - name: z.string().min(1), + projectName: z.string().min(1), + branches: z.array(z.string().min(1)).min(1).optional(), }), ); export const GetProjectPerDayOfPeriodDto = refineAndTransformDto( z.object({ ...BaseDto.shape, - name: z.string().min(1), + projectName: z.string().min(1), + branches: z.array(z.string().min(1)).min(1).optional(), }), ); export const GetProjectLanguagesTimeOnPeriodDto = refineDto( z.object({ ...DateRangeDto.shape, - name: z.string().min(1), + projectName: z.string().min(1), + branches: z.array(z.string().min(1)).min(1).optional(), }), ); export const GetProjectLanguagesPerDayOfPeriodDto = refineAndTransformDto( z.object({ ...BaseDto.shape, - name: z.string().min(1), + projectName: z.string().min(1), + branches: z.array(z.string().min(1)).min(1).optional(), }), ); diff --git a/apps/api/src/analytics/services/projects-analytics.service.ts b/apps/api/src/analytics/services/projects-analytics.service.ts index dbf4f1f2..b06a6dbc 100644 --- a/apps/api/src/analytics/services/projects-analytics.service.ts +++ b/apps/api/src/analytics/services/projects-analytics.service.ts @@ -41,9 +41,16 @@ import { getProjectMostUsedLanguageOnPeriod } from "@/analytics/utils/projects/g import { getProjectPerDayOfPeriodGroupedByDays } from "@/analytics/utils/projects/get-project-per-day-of-period-grouped-by-days"; import { getProjectPerDayOfPeriodGroupedByMonths } from "@/analytics/utils/projects/get-project-per-day-of-period-grouped-by-months"; import { getProjectPerDayOfPeriodGroupedByWeeks } from "@/analytics/utils/projects/get-project-per-day-of-period-grouped-by-weeks"; +import { BranchesService } from "@/branches/branches.service"; import { DailyDataService } from "@/daily-data/daily-data.service"; import { DRIZZLE_ASYNC_PROVIDER } from "@/drizzle/constants"; -import { dailyData, files, languages, projects } from "@/drizzle/schema"; +import { + branches, + dailyData, + files, + languages, + projects, +} from "@/drizzle/schema"; import { ProjectsService } from "@/projects/projects.service"; import { Inject, Injectable } from "@nestjs/common"; import { convertToISODate } from "@repo/common/convert-to-iso-date"; @@ -58,25 +65,34 @@ export class ProjectsAnalyticsService { @Inject(DRIZZLE_ASYNC_PROVIDER) private readonly db: NodePgDatabase, private readonly projectsService: ProjectsService, + private readonly branchesService: BranchesService, private readonly dailyDataService: DailyDataService, ) {} async findProjectByNameOnRange( findProjectByNameOnRangeDto: FindProjectByNameOnRangeDtoType, ) { - const { userId, name, start, end } = findProjectByNameOnRangeDto; + const { + userId, + projectName, + branches: branchesArray, + start, + end, + } = findProjectByNameOnRangeDto; const data = await this.db .select({ date: dailyData.date, - timeSpent: sum(projects.timeSpent).mapWith(Number), + timeSpent: sum(branches.timeSpent).mapWith(Number), }) .from(projects) + .innerJoin(branches, eq(projects.id, branches.projectId)) .innerJoin(dailyData, eq(dailyData.id, projects.dailyDataId)) .where( and( eq(dailyData.userId, userId), - eq(projects.name, name), + eq(projects.name, projectName), + branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), ), ) @@ -95,8 +111,8 @@ export class ProjectsAnalyticsService { const formattedDate = convertToISODate(date); return ( dataByDate[formattedDate] ?? { - timeSpent: 0, date: formattedDate, + timeSpent: 0, } ); }); @@ -107,7 +123,13 @@ export class ProjectsAnalyticsService { async getLanguagesTimeOnPeriod( getProjectLanguagesTimeOnPeriodDto: GetProjectLanguagesTimeOnPeriodDtoType, ) { - const { userId, name, start, end } = getProjectLanguagesTimeOnPeriodDto; + const { + userId, + projectName, + branches: branchesArray, + start, + end, + } = getProjectLanguagesTimeOnPeriodDto; const aggregated = await this.db .select({ @@ -115,13 +137,15 @@ export class ProjectsAnalyticsService { totalTime: sum(files.timeSpent).mapWith(Number), }) .from(files) - .innerJoin(projects, eq(projects.id, files.projectId)) - .innerJoin(languages, eq(languages.id, files.languageId)) + .innerJoin(branches, eq(branches.id, files.branchId)) + .innerJoin(projects, eq(projects.id, branches.projectId)) .innerJoin(dailyData, eq(dailyData.id, projects.dailyDataId)) + .innerJoin(languages, eq(languages.id, files.languageId)) .where( and( eq(dailyData.userId, userId), - eq(projects.name, name), + eq(projects.name, projectName), + branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), ), ) @@ -142,8 +166,13 @@ export class ProjectsAnalyticsService { async getLanguagesTimePerDayOfPeriod( getProjectLanguagesTimePerDayOfPeriodDto: GetProjectLanguagesTimePerDayOfPeriodDtoType, ) { - const { userId, name, start, end } = - getProjectLanguagesTimePerDayOfPeriodDto; + const { + userId, + projectName, + branches: branchesArray, + start, + end, + } = getProjectLanguagesTimePerDayOfPeriodDto; const data = await this.db .select({ @@ -152,22 +181,22 @@ export class ProjectsAnalyticsService { date: dailyData.date, }) .from(files) - .innerJoin(projects, eq(projects.id, files.projectId)) - .innerJoin(languages, eq(languages.id, files.languageId)) + .innerJoin(branches, eq(branches.id, files.branchId)) + .innerJoin(projects, eq(projects.id, branches.projectId)) .innerJoin(dailyData, eq(dailyData.id, projects.dailyDataId)) + .innerJoin(languages, eq(languages.id, files.languageId)) .where( and( eq(dailyData.userId, userId), - eq(projects.name, name), + eq(projects.name, projectName), + branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), ), ); const languagesPerDayOfPeriod = data.reduce( (acc, { date, languageSlug, timeSpent }) => { - if (!acc[date]) { - acc[date] = {}; - } + acc[date] ??= {}; acc[date][languageSlug] = (acc[date][languageSlug] ?? 0) + timeSpent; return acc; }, @@ -214,13 +243,19 @@ export class ProjectsAnalyticsService { } async getProjectOnPeriod(getProjectOnPeriodDto: GetProjectOnPeriodDtoType) { - const { userId, start, end, name } = getProjectOnPeriodDto; + const { + userId, + start, + end, + projectName, + branches: branchesArray, + } = getProjectOnPeriodDto; const [userHasProjectsOfName] = await this.db .select({ name: projects.name, path: projects.path }) .from(projects) .innerJoin(dailyData, eq(projects.dailyDataId, dailyData.id)) - .where(and(eq(projects.name, name), eq(dailyData.userId, userId))) + .where(and(eq(projects.name, projectName), eq(dailyData.userId, userId))) .limit(1); if (!userHasProjectsOfName) { @@ -231,19 +266,21 @@ export class ProjectsAnalyticsService { .select({ name: projects.name, path: projects.path, - totalTimeSpent: sum(projects.timeSpent).mapWith(Number), + totalTimeSpent: sum(branches.timeSpent).mapWith(Number), }) .from(projects) - .innerJoin(dailyData, eq(projects.dailyDataId, dailyData.id)) + .innerJoin(dailyData, eq(dailyData.id, projects.dailyDataId)) + .innerJoin(branches, eq(projects.id, branches.projectId)) .where( and( eq(dailyData.userId, userId), + eq(projects.name, projectName), + branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), - eq(projects.name, name), ), ) .groupBy(projects.path, projects.name) - .orderBy(desc(sum(projects.timeSpent))); + .orderBy(desc(sum(branches.timeSpent))); if (!projectAggregatedOnPeriod) { return { @@ -259,14 +296,22 @@ export class ProjectsAnalyticsService { async getProjectPerDayOfPeriod( getProjectPerDayOfPeriodDto: GetProjectPerDayOfPeriodDtoType, ) { - const { userId, start, end, name, groupBy, periodResolution } = - getProjectPerDayOfPeriodDto; + const { + userId, + start, + end, + projectName, + branches, + groupBy, + periodResolution, + } = getProjectPerDayOfPeriodDto; const projectOnDaysOnPeriod = await this.findProjectByNameOnRange({ userId, start, end, - name, + projectName, + branches, }); if (projectOnDaysOnPeriod.length === 0) { @@ -297,13 +342,15 @@ export class ProjectsAnalyticsService { async getProjectLanguagesTimeOnPeriod( getProjectLanguagesTimeOnPeriod: GetProjectLanguagesTimeOnPeriodType, ) { - const { userId, start, end, name } = getProjectLanguagesTimeOnPeriod; + const { userId, start, end, projectName, branches } = + getProjectLanguagesTimeOnPeriod; const projectOnDaysOnPeriod = await this.findProjectByNameOnRange({ userId, start, end, - name, + projectName, + branches, }); if (projectOnDaysOnPeriod.length === 0) { @@ -315,7 +362,8 @@ export class ProjectsAnalyticsService { userId, start, end, - name, + projectName, + branches, }) ).totalTimeSpent; @@ -323,7 +371,8 @@ export class ProjectsAnalyticsService { userId, start, end, - name, + projectName, + branches, }); const projectLanguagesTimeOnPeriod = Object.entries(aggregatedLanguageTime) @@ -348,14 +397,22 @@ export class ProjectsAnalyticsService { async getProjectLanguagesPerDayOfPeriod( getProjectLanguagesPerDayOfPeriodDto: GetProjectLanguagesPerDayOfPeriodDtoType, ) { - const { userId, start, end, name, groupBy, periodResolution } = - getProjectLanguagesPerDayOfPeriodDto; + const { + userId, + start, + end, + projectName, + branches, + groupBy, + periodResolution, + } = getProjectLanguagesPerDayOfPeriodDto; const projectOnDaysOnPeriod = await this.findProjectByNameOnRange({ userId, start, end, - name, + projectName, + branches, }); if (projectOnDaysOnPeriod.length === 0) { @@ -367,7 +424,7 @@ export class ProjectsAnalyticsService { userId, start, end, - name, + projectName, }); switch (groupBy) { @@ -404,7 +461,12 @@ export class ProjectsAnalyticsService { async getProjectDailyStats( getProjectDailyStatsDto: GetProjectDailyStatsDtoType, ) { - const { dateString, name, userId } = getProjectDailyStatsDto; + const { + dateString, + projectName, + branches: branchesArray, + userId, + } = getProjectDailyStatsDto; const dayData = await this.dailyDataService.findOne({ userId, @@ -425,10 +487,15 @@ export class ProjectsAnalyticsService { projectPath: projects.path, }) .from(files) - .innerJoin(projects, eq(projects.id, files.projectId)) + .innerJoin(branches, eq(branches.id, files.branchId)) + .innerJoin(projects, eq(projects.id, branches.projectId)) .innerJoin(languages, eq(languages.id, files.languageId)) .where( - and(eq(projects.name, name), eq(projects.dailyDataId, dayData.id)), + and( + eq(projects.name, projectName), + branchesArray ? inArray(branches.name, branchesArray) : undefined, + eq(projects.dailyDataId, dayData.id), + ), ); if (rawProjectFilesOnDay.length === 0) { @@ -447,15 +514,52 @@ export class ProjectsAnalyticsService { {} as { [languageSlug: string]: number }, ); - const totalTimeSpent = ( + const projectId = ( await this.projectsService.findOne({ dailyDataId: dayData.id, - name, + name: projectName, path: rawProjectFilesOnDay[0].projectPath, }) - )?.timeSpent; + )?.id; + + if (!projectId) { + return { + formattedTotalTimeSpent: formatDuration(0), + finalData: [], + }; + } + + let totalTimeSpent = 0; + + // if there are branches, the total time spent is the sum of the times spent across all the branches + if (branchesArray) { + const branchesFound = await Promise.all( + branchesArray.map( + async (branch) => + await this.branchesService.findOne({ + projectId, + name: branch, + }), + ), + ); + + totalTimeSpent = branchesFound.reduce((acc, curr) => { + acc = acc + (curr?.timeSpent ?? 0); + return acc; + }, 0); + } else { + // otherwise it is just the time spent on the project + totalTimeSpent = + ( + await this.projectsService.findOne({ + dailyDataId: dayData.id, + name: projectName, + path: rawProjectFilesOnDay[0].projectPath, + }) + )?.timeSpent ?? 0; + } - if (!totalTimeSpent) { + if (totalTimeSpent === 0) { return { formattedTotalTimeSpent: formatDuration(0), finalData: [], @@ -481,7 +585,8 @@ export class ProjectsAnalyticsService { ) { const { userId, - name, + projectName, + branches, start, end, todaysDateString, @@ -490,30 +595,34 @@ export class ProjectsAnalyticsService { } = getPeriodGeneralStatsForProjectDto; const projectPerDayOfPeriod = await this.findProjectByNameOnRange({ - name, + projectName, + branches, start, end, userId, }); - if (projectPerDayOfPeriod.length === 0) + if (projectPerDayOfPeriod.length === 0) { return { avgTime: formatDuration(0), percentageToAvg: 0, mostActiveDate: "N/A", mostUsedLanguageSlug: "N/A", }; + } const { totalTimeSpent: totalTimeSpentOnPeriod, path } = await this.getProjectOnPeriod({ userId, start, end, - name, + projectName, + branches, }); const projectLanguagesTimeOnPeriod = await this.getLanguagesTimeOnPeriod({ - name, + projectName, + branches, start, end, userId, @@ -528,15 +637,43 @@ export class ProjectsAnalyticsService { )?.id; let timeSpentOnProjectToday = 0; + if (todaysDailyDataId) { - timeSpentOnProjectToday = - ( - await this.projectsService.findOne({ - dailyDataId: todaysDailyDataId, - name, - path, - }) - )?.timeSpent ?? 0; + const projectId = ( + await this.projectsService.findOne({ + dailyDataId: todaysDailyDataId, + name: projectName, + path, + }) + )?.id; + + // if there are branches the total time spent is the sum of the times spent across all the branches + if (branches && projectId) { + const branchesFound = await Promise.all( + branches.map( + async (branch) => + await this.branchesService.findOne({ + projectId, + name: branch, + }), + ), + ); + + timeSpentOnProjectToday = branchesFound.reduce((acc, curr) => { + acc = acc + (curr?.timeSpent ?? 0); + return acc; + }, 0); + } else if (projectId) { + // otherwise it is just the time spent in the project + timeSpentOnProjectToday = + ( + await this.projectsService.findOne({ + dailyDataId: todaysDailyDataId, + name: projectName, + path, + }) + )?.timeSpent ?? 0; + } } switch (groupBy) { @@ -556,7 +693,7 @@ export class ProjectsAnalyticsService { const timeSpentOnProjectTodaysWeek = ( await this.getProjectOnPeriod({ userId, - name, + projectName, start: convertToISODate(startOfWeek(new Date(todaysDateString))), end: convertToISODate(endOfWeek(new Date(todaysDateString))), }) @@ -578,7 +715,7 @@ export class ProjectsAnalyticsService { const timeSpentOnProjectTodaysMonth = ( await this.getProjectOnPeriod({ userId, - name, + projectName, start: convertToISODate(startOfMonth(new Date(todaysDateString))), end: convertToISODate(endOfMonth(new Date(todaysDateString))), }) @@ -638,7 +775,14 @@ export class ProjectsAnalyticsService { } : never > { - const { userId, name, start, end, type } = getProjectFilesOnPeriodDto; + const { + userId, + projectName, + branches: branchesArray, + start, + end, + type, + } = getProjectFilesOnPeriodDto; const baseQuery = this.db .select({ @@ -649,9 +793,10 @@ export class ProjectsAnalyticsService { path: files.path, }) .from(files) - .innerJoin(projects, eq(projects.id, files.projectId)) - .innerJoin(languages, eq(languages.id, files.languageId)) - .innerJoin(dailyData, eq(dailyData.id, projects.dailyDataId)); + .innerJoin(branches, eq(branches.id, files.branchId)) + .innerJoin(projects, eq(projects.id, branches.projectId)) + .innerJoin(dailyData, eq(dailyData.id, projects.dailyDataId)) + .innerJoin(languages, eq(languages.id, files.languageId)); // normal case if (type === "normal") { @@ -661,7 +806,8 @@ export class ProjectsAnalyticsService { .where( and( eq(dailyData.userId, userId), - eq(projects.name, name), + eq(projects.name, projectName), + branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), ), ) @@ -703,7 +849,8 @@ export class ProjectsAnalyticsService { .where( and( eq(dailyData.userId, userId), - eq(projects.name, name), + eq(projects.name, projectName), + branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), search ? ilike(files.name, `%${search}%`) : undefined, languagesArray @@ -744,13 +891,15 @@ export class ProjectsAnalyticsService { path: files.path, }) .from(files) - .innerJoin(projects, eq(projects.id, files.projectId)) + .innerJoin(branches, eq(branches.id, files.branchId)) + .innerJoin(projects, eq(projects.id, branches.projectId)) .innerJoin(dailyData, eq(dailyData.id, projects.dailyDataId)) .innerJoin(languages, eq(languages.id, files.languageId)) .where( and( eq(dailyData.userId, userId), - eq(projects.name, name), + eq(projects.name, projectName), + branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), search ? ilike(files.name, `%${search}%`) : undefined, languagesArray diff --git a/apps/api/src/extension/extension.service.ts b/apps/api/src/extension/extension.service.ts index 978c3c74..712884cc 100644 --- a/apps/api/src/extension/extension.service.ts +++ b/apps/api/src/extension/extension.service.ts @@ -282,7 +282,7 @@ export class ExtensionService { name, projectId: branch.projectId, }); - + updatedBranches[projectPath] ??= {}; updatedBranches[projectPath][name] = { branchId: "", timeSpent: 0 }; if (!existingBranch) { diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json index 64f86c6b..cb63862b 100644 --- a/apps/api/tsconfig.build.json +++ b/apps/api/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "exclude": ["node_modules", "test", "dist", "**/*.test.ts"] } diff --git a/apps/dashboard/src/features/files-circle-packing-chart/components/files-circle-packing-chart.tsx b/apps/dashboard/src/features/files-circle-packing-chart/components/files-circle-packing-chart.tsx index 01808258..8af991fe 100644 --- a/apps/dashboard/src/features/files-circle-packing-chart/components/files-circle-packing-chart.tsx +++ b/apps/dashboard/src/features/files-circle-packing-chart/components/files-circle-packing-chart.tsx @@ -13,7 +13,7 @@ import { Tree } from "../types-schemas"; import { CircularPacking } from "./circular-packing"; export const FilesCirclePackingChart = () => { - const { projectName: name } = useLoaderData(); + const { projectName } = useLoaderData(); const period = usePeriodStore((state) => state.period); const customRange = usePeriodStore((state) => state.customRange); @@ -27,14 +27,14 @@ export const FilesCirclePackingChart = () => { trpc.analytics.projects.getProjectFilesOnPeriod.queryOptions( period === "Custom Range" ? { - name, + projectName, start: customRange.start, end: customRange.end, amount: NUMBER_OF_FILES_TO_SHOW, type: "normal" as const, } : { - name, + projectName, start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, amount: NUMBER_OF_FILES_TO_SHOW, diff --git a/apps/dashboard/src/features/files-list/hooks/use-files.tsx b/apps/dashboard/src/features/files-list/hooks/use-files.tsx index ecad0282..af2a3308 100644 --- a/apps/dashboard/src/features/files-list/hooks/use-files.tsx +++ b/apps/dashboard/src/features/files-list/hooks/use-files.tsx @@ -12,7 +12,7 @@ export const useFiles = ( searchTerm: string, isSortedDesc: boolean, ) => { - const { projectName: name } = useLoaderData(); + const { projectName } = useLoaderData(); const period = usePeriodStore((state) => state.period); const customRange = usePeriodStore((state) => state.customRange); @@ -24,7 +24,7 @@ export const useFiles = ( trpc.analytics.projects.getProjectFilesOnPeriod.queryOptions( period === "Custom Range" ? { - name, + projectName, start: customRange.start, end: customRange.end, languages: languagesToFetch, @@ -33,7 +33,7 @@ export const useFiles = ( search: search || undefined, } : { - name, + projectName, start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, languages: languagesToFetch, diff --git a/apps/dashboard/src/features/files-list/hooks/use-languages-dropdown.tsx b/apps/dashboard/src/features/files-list/hooks/use-languages-dropdown.tsx index 78d4fcc0..13e8947e 100644 --- a/apps/dashboard/src/features/files-list/hooks/use-languages-dropdown.tsx +++ b/apps/dashboard/src/features/files-list/hooks/use-languages-dropdown.tsx @@ -11,7 +11,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { Entry } from "../types-schemas"; export const useLanguagesDropDown = () => { - const { projectName: name } = useLoaderData(); + const { projectName } = useLoaderData(); const trpc = useTRPC(); const period = usePeriodStore((state) => state.period); @@ -20,9 +20,9 @@ export const useLanguagesDropDown = () => { const { data: fetchedData } = useSuspenseQuery( trpc.analytics.projects.getProjectLanguagesTimeOnPeriod.queryOptions( period === "Custom Range" - ? { name, start: customRange.start, end: customRange.end } + ? { projectName, start: customRange.start, end: customRange.end } : { - name, + projectName, start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, }, diff --git a/apps/dashboard/src/features/project-day-languages-chart/components/project-day-languages-chart.tsx b/apps/dashboard/src/features/project-day-languages-chart/components/project-day-languages-chart.tsx index 789b6d0e..ac109b68 100644 --- a/apps/dashboard/src/features/project-day-languages-chart/components/project-day-languages-chart.tsx +++ b/apps/dashboard/src/features/project-day-languages-chart/components/project-day-languages-chart.tsx @@ -33,7 +33,7 @@ export const ProjectDayLanguagesChart = () => { const { data } = useSuspenseQuery( trpc.analytics.projects.getProjectDailyStats.queryOptions({ dateString, - name: projectName, + projectName, }), ); diff --git a/apps/dashboard/src/features/project-general-stats-charts/components/project-general-stats-chart.tsx b/apps/dashboard/src/features/project-general-stats-charts/components/project-general-stats-chart.tsx index d7a68d1e..1e310e8a 100644 --- a/apps/dashboard/src/features/project-general-stats-charts/components/project-general-stats-chart.tsx +++ b/apps/dashboard/src/features/project-general-stats-charts/components/project-general-stats-chart.tsx @@ -48,14 +48,14 @@ export const ProjectGeneralStatsChart = () => { start: customRange.start, end: customRange.end, todaysDateString, - name: projectName, + projectName, groupBy, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, todaysDateString, - name: projectName, + projectName, groupBy, }, ), diff --git a/apps/dashboard/src/features/project-languages-time-on-period-chart/hooks/use-suspense-query-projects-lang-chart.tsx b/apps/dashboard/src/features/project-languages-time-on-period-chart/hooks/use-suspense-query-projects-lang-chart.tsx index d9106fff..c2a705b5 100644 --- a/apps/dashboard/src/features/project-languages-time-on-period-chart/hooks/use-suspense-query-projects-lang-chart.tsx +++ b/apps/dashboard/src/features/project-languages-time-on-period-chart/hooks/use-suspense-query-projects-lang-chart.tsx @@ -8,7 +8,7 @@ import { getLanguageColor } from "@repo/ui/utils/get-language-color"; import { useSuspenseQuery } from "@tanstack/react-query"; export const useSuspenseQueryProjectsLangChart = () => { - const { projectName: name } = useLoaderData(); + const { projectName } = useLoaderData(); const period = usePeriodStore((state) => state.period); const customRange = usePeriodStore((state) => state.customRange); @@ -21,12 +21,12 @@ export const useSuspenseQueryProjectsLangChart = () => { ? { start: customRange.start, end: customRange.end, - name, + projectName, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, - name, + projectName, }, ), ); @@ -37,13 +37,13 @@ export const useSuspenseQueryProjectsLangChart = () => { ? { start: customRange.start, end: customRange.end, - name, + projectName, groupBy, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, - name, + projectName, groupBy, }, ), diff --git a/apps/dashboard/src/features/project-time-on-period-chart/components/project-time-on-period-chart.tsx b/apps/dashboard/src/features/project-time-on-period-chart/components/project-time-on-period-chart.tsx index ed381b23..e7ac22e4 100644 --- a/apps/dashboard/src/features/project-time-on-period-chart/components/project-time-on-period-chart.tsx +++ b/apps/dashboard/src/features/project-time-on-period-chart/components/project-time-on-period-chart.tsx @@ -52,7 +52,7 @@ const tooltipFormatter = (value: string, name: string) => : null; export const ProjectTimeOnPeriodChart = () => { - const { projectName: name } = useLoaderData(); + const { projectName } = useLoaderData(); const period = usePeriodStore((state) => state.period); const groupBy = usePeriodStore((state) => state.groupBy); @@ -69,13 +69,13 @@ export const ProjectTimeOnPeriodChart = () => { ? { start: customRange.start, end: customRange.end, - name, + projectName, groupBy, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, - name, + projectName, groupBy, }, ), diff --git a/apps/dashboard/src/features/project-title/hooks/use-get-time-spent-on-project.tsx b/apps/dashboard/src/features/project-title/hooks/use-get-time-spent-on-project.tsx index 143638c9..7a3f8f1a 100644 --- a/apps/dashboard/src/features/project-title/hooks/use-get-time-spent-on-project.tsx +++ b/apps/dashboard/src/features/project-title/hooks/use-get-time-spent-on-project.tsx @@ -20,12 +20,12 @@ export const useGetTimeSpentOnProject = () => { ? { start: customRange.start, end: customRange.end, - name: projectName, + projectName, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, - name: projectName, + projectName, }, ), ); From b1678c31ccff8f2cbf9d4ffc2672c394c97ef0f8 Mon Sep 17 00:00:00 2001 From: Friedrich482 Date: Thu, 9 Jul 2026 16:31:52 +0200 Subject: [PATCH 05/13] chore: backward compatibility - added a default value for branchName for backward compatibility reasons (a user still have an old version of the extension that doesn't send the branches to the server) - renamed back projectName to name for the same reasons to prevent an old version of the dashboard requests to be refused by the server --- .../analytics/dto/projects-analytics.dto.ts | 18 ++-- .../services/projects-analytics.service.ts | 86 ++++++++----------- apps/api/src/extension/extension.dto.ts | 3 +- .../components/files-circle-packing-chart.tsx | 6 +- .../features/files-list/hooks/use-files.tsx | 6 +- .../hooks/use-languages-dropdown.tsx | 6 +- .../project-day-languages-chart.tsx | 4 +- .../project-general-stats-chart.tsx | 6 +- ...use-suspense-query-projects-lang-chart.tsx | 10 +-- .../project-time-on-period-chart.tsx | 6 +- .../hooks/use-get-time-spent-on-project.tsx | 6 +- 11 files changed, 72 insertions(+), 85 deletions(-) diff --git a/apps/api/src/analytics/dto/projects-analytics.dto.ts b/apps/api/src/analytics/dto/projects-analytics.dto.ts index e3bc574b..5a312261 100644 --- a/apps/api/src/analytics/dto/projects-analytics.dto.ts +++ b/apps/api/src/analytics/dto/projects-analytics.dto.ts @@ -14,7 +14,7 @@ export const FindProjectByNameOnRangeDto = z.object({ userId: z.ulid(), start: DateStringDto, end: DateStringDto, - projectName: z.string().min(1), + name: z.string().min(1), branches: z.array(z.string().min(1)).min(1).optional(), }); @@ -22,20 +22,20 @@ export const GetProjectLanguagesTimePerDayOfPeriodDto = z.object({ userId: z.ulid(), start: DateStringDto, end: DateStringDto, - projectName: z.string().min(1), + name: z.string().min(1), branches: z.array(z.string().min(1)).min(1).optional(), }); export const GetProjectDailyStatsDto = z.object({ dateString: DateStringDto, - projectName: z.string().min(1), + name: z.string().min(1), branches: z.array(z.string().min(1)).min(1).optional(), }); export const GetPeriodGeneralStatsForProjectDto = refineAndTransformDto( z.object({ ...BaseDto.shape, - projectName: z.string().min(1), + name: z.string().min(1), branches: z.array(z.string().min(1)).min(1).optional(), todaysDateString: DateStringDto, }), @@ -45,7 +45,7 @@ export const GetProjectFilesOnPeriodBaseDto = z.object({ ...refineDto( z.object({ ...DateRangeDto.shape, - projectName: z.string().min(1), + name: z.string().min(1), branches: z.array(z.string().min(1)).min(1).optional(), }), ).shape, @@ -81,7 +81,7 @@ export const GetPeriodProjectsDto = refineDto( export const GetProjectOnPeriodDto = refineDto( z.object({ ...DateRangeDto.shape, - projectName: z.string().min(1), + name: z.string().min(1), branches: z.array(z.string().min(1)).min(1).optional(), }), ); @@ -89,7 +89,7 @@ export const GetProjectOnPeriodDto = refineDto( export const GetProjectPerDayOfPeriodDto = refineAndTransformDto( z.object({ ...BaseDto.shape, - projectName: z.string().min(1), + name: z.string().min(1), branches: z.array(z.string().min(1)).min(1).optional(), }), ); @@ -97,7 +97,7 @@ export const GetProjectPerDayOfPeriodDto = refineAndTransformDto( export const GetProjectLanguagesTimeOnPeriodDto = refineDto( z.object({ ...DateRangeDto.shape, - projectName: z.string().min(1), + name: z.string().min(1), branches: z.array(z.string().min(1)).min(1).optional(), }), ); @@ -105,7 +105,7 @@ export const GetProjectLanguagesTimeOnPeriodDto = refineDto( export const GetProjectLanguagesPerDayOfPeriodDto = refineAndTransformDto( z.object({ ...BaseDto.shape, - projectName: z.string().min(1), + name: z.string().min(1), branches: z.array(z.string().min(1)).min(1).optional(), }), ); diff --git a/apps/api/src/analytics/services/projects-analytics.service.ts b/apps/api/src/analytics/services/projects-analytics.service.ts index b06a6dbc..91ad03ec 100644 --- a/apps/api/src/analytics/services/projects-analytics.service.ts +++ b/apps/api/src/analytics/services/projects-analytics.service.ts @@ -74,7 +74,7 @@ export class ProjectsAnalyticsService { ) { const { userId, - projectName, + name, branches: branchesArray, start, end, @@ -91,7 +91,7 @@ export class ProjectsAnalyticsService { .where( and( eq(dailyData.userId, userId), - eq(projects.name, projectName), + eq(projects.name, name), branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), ), @@ -125,7 +125,7 @@ export class ProjectsAnalyticsService { ) { const { userId, - projectName, + name, branches: branchesArray, start, end, @@ -144,7 +144,7 @@ export class ProjectsAnalyticsService { .where( and( eq(dailyData.userId, userId), - eq(projects.name, projectName), + eq(projects.name, name), branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), ), @@ -168,7 +168,7 @@ export class ProjectsAnalyticsService { ) { const { userId, - projectName, + name, branches: branchesArray, start, end, @@ -188,7 +188,7 @@ export class ProjectsAnalyticsService { .where( and( eq(dailyData.userId, userId), - eq(projects.name, projectName), + eq(projects.name, name), branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), ), @@ -247,7 +247,7 @@ export class ProjectsAnalyticsService { userId, start, end, - projectName, + name, branches: branchesArray, } = getProjectOnPeriodDto; @@ -255,7 +255,7 @@ export class ProjectsAnalyticsService { .select({ name: projects.name, path: projects.path }) .from(projects) .innerJoin(dailyData, eq(projects.dailyDataId, dailyData.id)) - .where(and(eq(projects.name, projectName), eq(dailyData.userId, userId))) + .where(and(eq(projects.name, name), eq(dailyData.userId, userId))) .limit(1); if (!userHasProjectsOfName) { @@ -274,7 +274,7 @@ export class ProjectsAnalyticsService { .where( and( eq(dailyData.userId, userId), - eq(projects.name, projectName), + eq(projects.name, name), branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), ), @@ -296,21 +296,14 @@ export class ProjectsAnalyticsService { async getProjectPerDayOfPeriod( getProjectPerDayOfPeriodDto: GetProjectPerDayOfPeriodDtoType, ) { - const { - userId, - start, - end, - projectName, - branches, - groupBy, - periodResolution, - } = getProjectPerDayOfPeriodDto; + const { userId, start, end, name, branches, groupBy, periodResolution } = + getProjectPerDayOfPeriodDto; const projectOnDaysOnPeriod = await this.findProjectByNameOnRange({ userId, start, end, - projectName, + name, branches, }); @@ -342,14 +335,14 @@ export class ProjectsAnalyticsService { async getProjectLanguagesTimeOnPeriod( getProjectLanguagesTimeOnPeriod: GetProjectLanguagesTimeOnPeriodType, ) { - const { userId, start, end, projectName, branches } = + const { userId, start, end, name, branches } = getProjectLanguagesTimeOnPeriod; const projectOnDaysOnPeriod = await this.findProjectByNameOnRange({ userId, start, end, - projectName, + name, branches, }); @@ -362,7 +355,7 @@ export class ProjectsAnalyticsService { userId, start, end, - projectName, + name, branches, }) ).totalTimeSpent; @@ -371,7 +364,7 @@ export class ProjectsAnalyticsService { userId, start, end, - projectName, + name, branches, }); @@ -397,21 +390,14 @@ export class ProjectsAnalyticsService { async getProjectLanguagesPerDayOfPeriod( getProjectLanguagesPerDayOfPeriodDto: GetProjectLanguagesPerDayOfPeriodDtoType, ) { - const { - userId, - start, - end, - projectName, - branches, - groupBy, - periodResolution, - } = getProjectLanguagesPerDayOfPeriodDto; + const { userId, start, end, name, branches, groupBy, periodResolution } = + getProjectLanguagesPerDayOfPeriodDto; const projectOnDaysOnPeriod = await this.findProjectByNameOnRange({ userId, start, end, - projectName, + name, branches, }); @@ -424,7 +410,7 @@ export class ProjectsAnalyticsService { userId, start, end, - projectName, + name, }); switch (groupBy) { @@ -463,7 +449,7 @@ export class ProjectsAnalyticsService { ) { const { dateString, - projectName, + name, branches: branchesArray, userId, } = getProjectDailyStatsDto; @@ -492,7 +478,7 @@ export class ProjectsAnalyticsService { .innerJoin(languages, eq(languages.id, files.languageId)) .where( and( - eq(projects.name, projectName), + eq(projects.name, name), branchesArray ? inArray(branches.name, branchesArray) : undefined, eq(projects.dailyDataId, dayData.id), ), @@ -517,7 +503,7 @@ export class ProjectsAnalyticsService { const projectId = ( await this.projectsService.findOne({ dailyDataId: dayData.id, - name: projectName, + name, path: rawProjectFilesOnDay[0].projectPath, }) )?.id; @@ -553,7 +539,7 @@ export class ProjectsAnalyticsService { ( await this.projectsService.findOne({ dailyDataId: dayData.id, - name: projectName, + name, path: rawProjectFilesOnDay[0].projectPath, }) )?.timeSpent ?? 0; @@ -585,7 +571,7 @@ export class ProjectsAnalyticsService { ) { const { userId, - projectName, + name, branches, start, end, @@ -595,7 +581,7 @@ export class ProjectsAnalyticsService { } = getPeriodGeneralStatsForProjectDto; const projectPerDayOfPeriod = await this.findProjectByNameOnRange({ - projectName, + name, branches, start, end, @@ -616,12 +602,12 @@ export class ProjectsAnalyticsService { userId, start, end, - projectName, + name, branches, }); const projectLanguagesTimeOnPeriod = await this.getLanguagesTimeOnPeriod({ - projectName, + name, branches, start, end, @@ -642,7 +628,7 @@ export class ProjectsAnalyticsService { const projectId = ( await this.projectsService.findOne({ dailyDataId: todaysDailyDataId, - name: projectName, + name, path, }) )?.id; @@ -669,7 +655,7 @@ export class ProjectsAnalyticsService { ( await this.projectsService.findOne({ dailyDataId: todaysDailyDataId, - name: projectName, + name, path, }) )?.timeSpent ?? 0; @@ -693,7 +679,7 @@ export class ProjectsAnalyticsService { const timeSpentOnProjectTodaysWeek = ( await this.getProjectOnPeriod({ userId, - projectName, + name, start: convertToISODate(startOfWeek(new Date(todaysDateString))), end: convertToISODate(endOfWeek(new Date(todaysDateString))), }) @@ -715,7 +701,7 @@ export class ProjectsAnalyticsService { const timeSpentOnProjectTodaysMonth = ( await this.getProjectOnPeriod({ userId, - projectName, + name, start: convertToISODate(startOfMonth(new Date(todaysDateString))), end: convertToISODate(endOfMonth(new Date(todaysDateString))), }) @@ -777,7 +763,7 @@ export class ProjectsAnalyticsService { > { const { userId, - projectName, + name, branches: branchesArray, start, end, @@ -806,7 +792,7 @@ export class ProjectsAnalyticsService { .where( and( eq(dailyData.userId, userId), - eq(projects.name, projectName), + eq(projects.name, name), branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), ), @@ -849,7 +835,7 @@ export class ProjectsAnalyticsService { .where( and( eq(dailyData.userId, userId), - eq(projects.name, projectName), + eq(projects.name, name), branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), search ? ilike(files.name, `%${search}%`) : undefined, @@ -898,7 +884,7 @@ export class ProjectsAnalyticsService { .where( and( eq(dailyData.userId, userId), - eq(projects.name, projectName), + eq(projects.name, name), branchesArray ? inArray(branches.name, branchesArray) : undefined, between(dailyData.date, start, end), search ? ilike(files.name, `%${search}%`) : undefined, diff --git a/apps/api/src/extension/extension.dto.ts b/apps/api/src/extension/extension.dto.ts index b3332f48..932a9c79 100644 --- a/apps/api/src/extension/extension.dto.ts +++ b/apps/api/src/extension/extension.dto.ts @@ -26,7 +26,8 @@ export const UpsertFilesDto = z.object({ projectName: z.string().min(1), projectPath: z.string().min(1), fileName: z.string().min(1), - branchName: z.string().min(1), + // default value for backward compatibility + branchName: z.string().min(1).default("main"), }), ), targetedDate: DateStringDto, diff --git a/apps/dashboard/src/features/files-circle-packing-chart/components/files-circle-packing-chart.tsx b/apps/dashboard/src/features/files-circle-packing-chart/components/files-circle-packing-chart.tsx index 8af991fe..01808258 100644 --- a/apps/dashboard/src/features/files-circle-packing-chart/components/files-circle-packing-chart.tsx +++ b/apps/dashboard/src/features/files-circle-packing-chart/components/files-circle-packing-chart.tsx @@ -13,7 +13,7 @@ import { Tree } from "../types-schemas"; import { CircularPacking } from "./circular-packing"; export const FilesCirclePackingChart = () => { - const { projectName } = useLoaderData(); + const { projectName: name } = useLoaderData(); const period = usePeriodStore((state) => state.period); const customRange = usePeriodStore((state) => state.customRange); @@ -27,14 +27,14 @@ export const FilesCirclePackingChart = () => { trpc.analytics.projects.getProjectFilesOnPeriod.queryOptions( period === "Custom Range" ? { - projectName, + name, start: customRange.start, end: customRange.end, amount: NUMBER_OF_FILES_TO_SHOW, type: "normal" as const, } : { - projectName, + name, start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, amount: NUMBER_OF_FILES_TO_SHOW, diff --git a/apps/dashboard/src/features/files-list/hooks/use-files.tsx b/apps/dashboard/src/features/files-list/hooks/use-files.tsx index af2a3308..ecad0282 100644 --- a/apps/dashboard/src/features/files-list/hooks/use-files.tsx +++ b/apps/dashboard/src/features/files-list/hooks/use-files.tsx @@ -12,7 +12,7 @@ export const useFiles = ( searchTerm: string, isSortedDesc: boolean, ) => { - const { projectName } = useLoaderData(); + const { projectName: name } = useLoaderData(); const period = usePeriodStore((state) => state.period); const customRange = usePeriodStore((state) => state.customRange); @@ -24,7 +24,7 @@ export const useFiles = ( trpc.analytics.projects.getProjectFilesOnPeriod.queryOptions( period === "Custom Range" ? { - projectName, + name, start: customRange.start, end: customRange.end, languages: languagesToFetch, @@ -33,7 +33,7 @@ export const useFiles = ( search: search || undefined, } : { - projectName, + name, start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, languages: languagesToFetch, diff --git a/apps/dashboard/src/features/files-list/hooks/use-languages-dropdown.tsx b/apps/dashboard/src/features/files-list/hooks/use-languages-dropdown.tsx index 13e8947e..78d4fcc0 100644 --- a/apps/dashboard/src/features/files-list/hooks/use-languages-dropdown.tsx +++ b/apps/dashboard/src/features/files-list/hooks/use-languages-dropdown.tsx @@ -11,7 +11,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { Entry } from "../types-schemas"; export const useLanguagesDropDown = () => { - const { projectName } = useLoaderData(); + const { projectName: name } = useLoaderData(); const trpc = useTRPC(); const period = usePeriodStore((state) => state.period); @@ -20,9 +20,9 @@ export const useLanguagesDropDown = () => { const { data: fetchedData } = useSuspenseQuery( trpc.analytics.projects.getProjectLanguagesTimeOnPeriod.queryOptions( period === "Custom Range" - ? { projectName, start: customRange.start, end: customRange.end } + ? { name, start: customRange.start, end: customRange.end } : { - projectName, + name, start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, }, diff --git a/apps/dashboard/src/features/project-day-languages-chart/components/project-day-languages-chart.tsx b/apps/dashboard/src/features/project-day-languages-chart/components/project-day-languages-chart.tsx index ac109b68..ef0e01cc 100644 --- a/apps/dashboard/src/features/project-day-languages-chart/components/project-day-languages-chart.tsx +++ b/apps/dashboard/src/features/project-day-languages-chart/components/project-day-languages-chart.tsx @@ -20,7 +20,7 @@ import { getLanguageName } from "@repo/ui/utils/get-language-name"; import { useSuspenseQuery } from "@tanstack/react-query"; export const ProjectDayLanguagesChart = () => { - const { projectName } = useLoaderData(); + const { projectName: name } = useLoaderData(); const [date, setDate] = useState(new Date()); const dateString = useMemo(() => getLocaleDate(date), [date]); @@ -33,7 +33,7 @@ export const ProjectDayLanguagesChart = () => { const { data } = useSuspenseQuery( trpc.analytics.projects.getProjectDailyStats.queryOptions({ dateString, - projectName, + name, }), ); diff --git a/apps/dashboard/src/features/project-general-stats-charts/components/project-general-stats-chart.tsx b/apps/dashboard/src/features/project-general-stats-charts/components/project-general-stats-chart.tsx index 1e310e8a..af57d11b 100644 --- a/apps/dashboard/src/features/project-general-stats-charts/components/project-general-stats-chart.tsx +++ b/apps/dashboard/src/features/project-general-stats-charts/components/project-general-stats-chart.tsx @@ -37,7 +37,7 @@ export const ProjectGeneralStatsChart = () => { const groupBy = usePeriodStore((state) => state.groupBy); const customRange = usePeriodStore((state) => state.customRange); const todaysDateString = useMemo(() => getLocaleDate(new Date()), []); - const { projectName } = useLoaderData(); + const { projectName: name } = useLoaderData(); const trpc = useTRPC(); @@ -48,14 +48,14 @@ export const ProjectGeneralStatsChart = () => { start: customRange.start, end: customRange.end, todaysDateString, - projectName, + name, groupBy, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, todaysDateString, - projectName, + name, groupBy, }, ), diff --git a/apps/dashboard/src/features/project-languages-time-on-period-chart/hooks/use-suspense-query-projects-lang-chart.tsx b/apps/dashboard/src/features/project-languages-time-on-period-chart/hooks/use-suspense-query-projects-lang-chart.tsx index c2a705b5..d9106fff 100644 --- a/apps/dashboard/src/features/project-languages-time-on-period-chart/hooks/use-suspense-query-projects-lang-chart.tsx +++ b/apps/dashboard/src/features/project-languages-time-on-period-chart/hooks/use-suspense-query-projects-lang-chart.tsx @@ -8,7 +8,7 @@ import { getLanguageColor } from "@repo/ui/utils/get-language-color"; import { useSuspenseQuery } from "@tanstack/react-query"; export const useSuspenseQueryProjectsLangChart = () => { - const { projectName } = useLoaderData(); + const { projectName: name } = useLoaderData(); const period = usePeriodStore((state) => state.period); const customRange = usePeriodStore((state) => state.customRange); @@ -21,12 +21,12 @@ export const useSuspenseQueryProjectsLangChart = () => { ? { start: customRange.start, end: customRange.end, - projectName, + name, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, - projectName, + name, }, ), ); @@ -37,13 +37,13 @@ export const useSuspenseQueryProjectsLangChart = () => { ? { start: customRange.start, end: customRange.end, - projectName, + name, groupBy, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, - projectName, + name, groupBy, }, ), diff --git a/apps/dashboard/src/features/project-time-on-period-chart/components/project-time-on-period-chart.tsx b/apps/dashboard/src/features/project-time-on-period-chart/components/project-time-on-period-chart.tsx index e7ac22e4..ed381b23 100644 --- a/apps/dashboard/src/features/project-time-on-period-chart/components/project-time-on-period-chart.tsx +++ b/apps/dashboard/src/features/project-time-on-period-chart/components/project-time-on-period-chart.tsx @@ -52,7 +52,7 @@ const tooltipFormatter = (value: string, name: string) => : null; export const ProjectTimeOnPeriodChart = () => { - const { projectName } = useLoaderData(); + const { projectName: name } = useLoaderData(); const period = usePeriodStore((state) => state.period); const groupBy = usePeriodStore((state) => state.groupBy); @@ -69,13 +69,13 @@ export const ProjectTimeOnPeriodChart = () => { ? { start: customRange.start, end: customRange.end, - projectName, + name, groupBy, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, - projectName, + name, groupBy, }, ), diff --git a/apps/dashboard/src/features/project-title/hooks/use-get-time-spent-on-project.tsx b/apps/dashboard/src/features/project-title/hooks/use-get-time-spent-on-project.tsx index 7a3f8f1a..3f840d8b 100644 --- a/apps/dashboard/src/features/project-title/hooks/use-get-time-spent-on-project.tsx +++ b/apps/dashboard/src/features/project-title/hooks/use-get-time-spent-on-project.tsx @@ -7,7 +7,7 @@ import { useTRPC } from "@/utils/trpc"; import { useSuspenseQuery } from "@tanstack/react-query"; export const useGetTimeSpentOnProject = () => { - const { projectName } = useLoaderData(); + const { projectName: name } = useLoaderData(); const trpc = useTRPC(); @@ -20,12 +20,12 @@ export const useGetTimeSpentOnProject = () => { ? { start: customRange.start, end: customRange.end, - projectName, + name, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, - projectName, + name, }, ), ); From 96357e31b885ee5bd031a20794bc3efc5cd8c0b0 Mon Sep 17 00:00:00 2001 From: Friedrich482 Date: Sat, 11 Jul 2026 02:41:31 +0200 Subject: [PATCH 06/13] fix: branching - updated the general structure of the file sync data, which is also saved the global state of the extension and used as filesData in the extension, updated all affected functions to match that new shape - updated the `getFilesForDay` and `upsertFiles` methods of the extension.service to support that new data shape meanwhile keeping backward compatibility to avoid breaking the extension for consumers of old versions - updated the `findAll` method of the filesService to return an array instead of an object --- apps/api/src/extension/extension.dto.ts | 70 +++- apps/api/src/extension/extension.service.ts | 346 +++++++++++++++--- apps/api/src/files/files.service.ts | 8 +- apps/vscode-extension/src/types-schemas.ts | 26 +- .../utils/commands/init-extension-commands.ts | 95 ++++- .../src/utils/fetch-initial-data.ts | 5 +- .../utils/files/delete-files-data-content.ts | 4 +- .../src/utils/files/initialize-files.ts | 38 +- .../utils/files/update-current-file-obj.ts | 32 +- .../files/update-files-data-after-sync.ts | 60 +-- .../files/update-files-data-elapsed-time.ts | 20 +- .../files/update-files-data-frozen-states.ts | 96 ++--- .../src/utils/periodic-sync-data.ts | 71 ++-- .../src/utils/time/calculate-time.ts | 2 +- 14 files changed, 612 insertions(+), 261 deletions(-) diff --git a/apps/api/src/extension/extension.dto.ts b/apps/api/src/extension/extension.dto.ts index 932a9c79..43b65665 100644 --- a/apps/api/src/extension/extension.dto.ts +++ b/apps/api/src/extension/extension.dto.ts @@ -7,9 +7,16 @@ export const GetLanguagesTimeForDayDto = z.object({ dateString: DateStringDto, }); -export const GetFilesForDayDto = z.object({ - dateString: DateStringDto, -}); +export const GetFilesForDayDto = z.discriminatedUnion("type", [ + z.object({ + dateString: DateStringDto, + type: z.literal("old").optional().default("old"), + }), + z.object({ + dateString: DateStringDto, + type: z.literal("new"), + }), +]); export const UpsertLanguagesDto = z.object({ targetedDate: DateStringDto, @@ -17,27 +24,56 @@ export const UpsertLanguagesDto = z.object({ timeSpentPerLanguage: z.record(z.string().min(1), z.number().int()), }); -export const UpsertFilesDto = z.object({ - filesData: z.record( - z.string().min(1), - z.object({ - timeSpent: z.number().int().nonnegative(), - languageSlug: z.string().min(1), - projectName: z.string().min(1), - projectPath: z.string().min(1), - fileName: z.string().min(1), - // default value for backward compatibility - branchName: z.string().min(1).default("main"), - }), +const NewExpectedFilesDataDto = z.record( + z.string().min(1), // project path + z.record( + z.string().min(1), // branch name + z.record( + z.string().min(1), // absolute path of the file + z.object({ + timeSpent: z.number().int().nonnegative(), + languageSlug: z.string().min(1), + projectName: z.string().min(1), + fileName: z.string().min(1), + }), + ), ), - targetedDate: DateStringDto, -}); +); + +export const UpsertFilesDto = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("old").optional().default("old"), + filesData: z.record( + z.string().min(1), + z.object({ + timeSpent: z.number().int().nonnegative(), + languageSlug: z.string().min(1), + projectName: z.string().min(1), + projectPath: z.string().min(1), + fileName: z.string().min(1), + // default main for backward compatibility + branchName: z.string().min(1).default("main"), + }), + ), + targetedDate: DateStringDto, + }), + + z.object({ + type: z.literal("new"), + filesData: NewExpectedFilesDataDto, + targetedDate: DateStringDto, + }), +]); export type GetLanguagesTimeForDayDtoType = z.infer< typeof GetLanguagesTimeForDayDto > & UserId; +export type NewExpectedFilesDataDtoType = z.infer< + typeof NewExpectedFilesDataDto +>; + export type UpsertLanguagesDtoType = z.infer & UserId; diff --git a/apps/api/src/extension/extension.service.ts b/apps/api/src/extension/extension.service.ts index 712884cc..61aa531a 100644 --- a/apps/api/src/extension/extension.service.ts +++ b/apps/api/src/extension/extension.service.ts @@ -8,6 +8,7 @@ import { Injectable } from "@nestjs/common"; import { GetFilesForDayDtoType, GetLanguagesTimeForDayDtoType, + NewExpectedFilesDataDtoType, UpsertFilesDtoType, UpsertLanguagesDtoType, } from "./extension.dto"; @@ -47,7 +48,7 @@ export class ExtensionService { } async getFilesForDay(getFilesForDayDto: GetFilesForDayDtoType) { - const { userId, dateString } = getFilesForDayDto; + const { userId, dateString, type } = getFilesForDayDto; const dayData = await this.dailyDataService.findOne({ userId, @@ -62,7 +63,29 @@ export class ExtensionService { dailyDataId: dayData.id, }); - return filesData; + if (type === "old") { + const filesDataObject = Object.fromEntries( + filesData.map(({ filePath, ...rest }) => [filePath, rest]), + ); + + return filesDataObject; + } + + const returningData = filesData.reduce((acc, curr) => { + acc[curr.projectPath] ??= {}; + acc[curr.projectPath][curr.branchName] ??= {}; + + acc[curr.projectPath][curr.branchName][curr.filePath] = { + fileName: curr.fileName, + languageSlug: curr.languageSlug, + projectName: curr.projectName, + timeSpent: curr.timeSpent, + }; + + return acc; + }, {} as NewExpectedFilesDataDtoType); + + return returningData; } async upsertLanguages(upsertLanguagesDto: UpsertLanguagesDtoType) { @@ -150,7 +173,7 @@ export class ExtensionService { } async upsertFiles(upsertFilesDto: UpsertFilesDtoType) { - const { userId, filesData, targetedDate } = upsertFilesDto; + const { userId, filesData, targetedDate, type } = upsertFilesDto; const dailyDataForDay = await this.dailyDataService.findOne({ userId, @@ -163,12 +186,54 @@ export class ExtensionService { return {}; } - const projectsData = Object.entries(filesData) - .map((entry) => ({ - filePath: entry[0], - ...entry[1], - })) - .reduce( + let projectsData: { + [path: string]: { + name: string; + timeSpent: number; + dailyDataId: string; + }; + }; + + if (type === "old") { + projectsData = Object.entries(filesData) + .map((entry) => ({ + filePath: entry[0], + ...entry[1], + })) + .reduce( + (acc, curr) => { + acc[curr.projectPath] = { + name: acc[curr.projectPath]?.name ?? curr.projectName, + dailyDataId: + acc[curr.projectPath]?.dailyDataId ?? dailyDataForDay.id, + timeSpent: + (acc[curr.projectPath]?.timeSpent ?? 0) + curr.timeSpent, + }; + + return acc; + }, + {} as { + [path: string]: { + name: string; + timeSpent: number; + dailyDataId: string; + }; + }, + ); + } else { + const filesDataArray = Object.entries(filesData).flatMap( + ([projectPath, branches]) => + Object.entries(branches).flatMap(([branchName, files]) => + Object.entries(files).map(([filePath, file]) => ({ + projectPath, + branchName, + filePath, + ...file, + })), + ), + ); + + projectsData = filesDataArray.reduce( (acc, curr) => { acc[curr.projectPath] = { name: acc[curr.projectPath]?.name ?? curr.projectName, @@ -187,6 +252,7 @@ export class ExtensionService { }; }, ); + } const updatedProjects: { [projectPath: string]: { @@ -237,12 +303,60 @@ export class ExtensionService { } // we need to create all branches of the projects - const branchesData = Object.entries(filesData) - .map((entry) => ({ - filePath: entry[0], - ...entry[1], - })) - .reduce( + let branchesData: { + [projectPath: string]: { + [branchName: string]: { + timeSpent: number; + projectId: string; + }; + }; + }; + + if (type === "old") { + branchesData = Object.entries(filesData) + .map((entry) => ({ + filePath: entry[0], + ...entry[1], + })) + .reduce( + (acc, curr) => { + acc[curr.projectPath] = { + ...acc[curr.projectPath], + [curr.branchName]: { + projectId: + acc[curr.projectPath]?.[curr.branchName]?.projectId ?? + updatedProjects[curr.projectPath].projectId, + timeSpent: + (acc[curr.projectPath]?.[curr.branchName]?.timeSpent ?? 0) + + curr.timeSpent, + }, + }; + + return acc; + }, + {} as { + [projectPath: string]: { + [branchName: string]: { + timeSpent: number; + projectId: string; + }; + }; + }, + ); + } else { + const filesDataArray = Object.entries(filesData).flatMap( + ([projectPath, branches]) => + Object.entries(branches).flatMap(([branchName, files]) => + Object.entries(files).map(([filePath, file]) => ({ + projectPath, + branchName, + filePath, + ...file, + })), + ), + ); + + branchesData = filesDataArray.reduce( (acc, curr) => { acc[curr.projectPath] = { ...acc[curr.projectPath], @@ -267,6 +381,7 @@ export class ExtensionService { }; }, ); + } const updatedBranches: { [projectPath: string]: { @@ -316,79 +431,192 @@ export class ExtensionService { } } - const returningFilesData = await this.filesService.findAllOnDay({ + const filesDataFromDb = await this.filesService.findAllOnDay({ dailyDataId: dailyDataForDay.id, }); - for (const [path, file] of Object.entries(filesData)) { - const fileLanguage = await this.languagesService.findOne({ - dailyDataId: dailyDataForDay.id, - languageSlug: file.languageSlug, - }); + if (type === "old") { + const returningFilesData = Object.fromEntries( + filesDataFromDb.map(({ filePath, ...rest }) => [filePath, rest]), + ); - // must exist at this point - if (!fileLanguage) { - continue; - } + for (const [path, file] of Object.entries(filesData)) { + const fileLanguage = await this.languagesService.findOne({ + dailyDataId: dailyDataForDay.id, + languageSlug: file.languageSlug, + }); - const existingFileData = await this.filesService.findOne({ - branchId: updatedBranches[file.projectPath][file.branchName].branchId, - name: file.fileName, - path, - languageId: fileLanguage.languageId, - }); + // must exist at this point + if (!fileLanguage) { + continue; + } - if (!existingFileData) { - // if the data for this file doesn't exist, create it - const createdFile = await this.filesService.create({ + const existingFileData = await this.filesService.findOne({ branchId: updatedBranches[file.projectPath][file.branchName].branchId, - languageId: fileLanguage.languageId, name: file.fileName, path, - timeSpent: file.timeSpent, + languageId: fileLanguage.languageId, }); - returningFilesData[createdFile.path] = { - languageSlug: file.languageSlug, - projectName: file.projectName, - projectPath: file.projectPath, - timeSpent: createdFile.timeSpent, - fileName: createdFile.name, - branchName: file.branchName, - }; - } else { - // else just update the file data but only if the new timeSpent is greater than the existing one - if (existingFileData.timeSpent < file.timeSpent) { - const updatedFile = await this.filesService.update({ + if (!existingFileData) { + // if the data for this file doesn't exist, create it + const createdFile = await this.filesService.create({ branchId: updatedBranches[file.projectPath][file.branchName].branchId, languageId: fileLanguage.languageId, + name: file.fileName, path, timeSpent: file.timeSpent, - name: file.fileName, }); - returningFilesData[updatedFile.path] = { + returningFilesData[createdFile.path] = { languageSlug: file.languageSlug, projectName: file.projectName, projectPath: file.projectPath, - timeSpent: updatedFile.timeSpent, - fileName: updatedFile.name, + timeSpent: createdFile.timeSpent, + fileName: createdFile.name, branchName: file.branchName, }; } else { - returningFilesData[path] = { + // else just update the file data but only if the new timeSpent is greater than the existing one + if (existingFileData.timeSpent < file.timeSpent) { + const updatedFile = await this.filesService.update({ + branchId: + updatedBranches[file.projectPath][file.branchName].branchId, + languageId: fileLanguage.languageId, + path, + timeSpent: file.timeSpent, + name: file.fileName, + }); + + returningFilesData[updatedFile.path] = { + languageSlug: file.languageSlug, + projectName: file.projectName, + projectPath: file.projectPath, + timeSpent: updatedFile.timeSpent, + fileName: updatedFile.name, + branchName: file.branchName, + }; + } else { + returningFilesData[path] = { + languageSlug: file.languageSlug, + projectName: file.projectName, + projectPath: file.projectPath, + fileName: file.fileName, + timeSpent: existingFileData.timeSpent, + branchName: file.branchName, + }; + } + } + } + return returningFilesData; + } else { + const returningFilesData = filesDataFromDb.reduce((acc, curr) => { + acc[curr.projectPath] ??= {}; + acc[curr.projectPath][curr.branchName] ??= {}; + + acc[curr.projectPath][curr.branchName][curr.filePath] = { + fileName: curr.fileName, + languageSlug: curr.languageSlug, + projectName: curr.projectName, + timeSpent: curr.timeSpent, + }; + + return acc; + }, {} as NewExpectedFilesDataDtoType); + + const filesDataArray = Object.entries(filesData).flatMap( + ([projectPath, branches]) => + Object.entries(branches).flatMap(([branchName, files]) => + Object.entries(files).map(([filePath, file]) => ({ + projectPath, + branchName, + filePath, + ...file, + })), + ), + ); + + for (const file of filesDataArray) { + const fileLanguage = await this.languagesService.findOne({ + dailyDataId: dailyDataForDay.id, + languageSlug: file.languageSlug, + }); + + // must exist at this point + if (!fileLanguage) { + continue; + } + + const existingFileData = await this.filesService.findOne({ + branchId: updatedBranches[file.projectPath][file.branchName].branchId, + name: file.fileName, + path: file.filePath, + languageId: fileLanguage.languageId, + }); + + if (!existingFileData) { + // if the data for this file doesn't exist, create it + const createdFile = await this.filesService.create({ + branchId: + updatedBranches[file.projectPath][file.branchName].branchId, + languageId: fileLanguage.languageId, + name: file.fileName, + path: file.filePath, + timeSpent: file.timeSpent, + }); + + returningFilesData[file.projectPath] ??= {}; + returningFilesData[file.projectPath][file.branchName] ??= {}; + + returningFilesData[file.projectPath][file.branchName][ + createdFile.path + ] = { languageSlug: file.languageSlug, projectName: file.projectName, - projectPath: file.projectPath, - fileName: file.fileName, - timeSpent: existingFileData.timeSpent, - branchName: file.branchName, + timeSpent: createdFile.timeSpent, + fileName: createdFile.name, }; + } else { + // else just update the file data but only if the new timeSpent is greater than the existing one + if (existingFileData.timeSpent < file.timeSpent) { + const updatedFile = await this.filesService.update({ + branchId: + updatedBranches[file.projectPath][file.branchName].branchId, + languageId: fileLanguage.languageId, + path: file.filePath, + timeSpent: file.timeSpent, + name: file.fileName, + }); + + returningFilesData[file.projectPath] ??= {}; + returningFilesData[file.projectPath][file.branchName] ??= {}; + + returningFilesData[file.projectPath][file.branchName][ + updatedFile.path + ] = { + languageSlug: file.languageSlug, + projectName: file.projectName, + timeSpent: updatedFile.timeSpent, + fileName: updatedFile.name, + }; + } else { + returningFilesData[file.projectPath] ??= {}; + returningFilesData[file.projectPath][file.branchName] ??= {}; + + returningFilesData[file.projectPath][file.branchName][ + file.filePath + ] = { + languageSlug: file.languageSlug, + projectName: file.projectName, + fileName: file.fileName, + timeSpent: existingFileData.timeSpent, + }; + } } } - } - return returningFilesData; + return returningFilesData; + } } } diff --git a/apps/api/src/files/files.service.ts b/apps/api/src/files/files.service.ts index 4b76091b..930a917e 100644 --- a/apps/api/src/files/files.service.ts +++ b/apps/api/src/files/files.service.ts @@ -85,16 +85,12 @@ export class FilesService { }) .from(files) .innerJoin(branches, eq(branches.id, files.branchId)) - .innerJoin(languages, eq(languages.id, files.languageId)) .innerJoin(projects, eq(projects.id, branches.projectId)) + .innerJoin(languages, eq(languages.id, files.languageId)) .where(eq(projects.dailyDataId, dailyDataId)) .orderBy(asc(files.timeSpent)); - const filesDataObject = Object.fromEntries( - filesDataArray.map(({ filePath, ...rest }) => [filePath, rest]), - ); - - return filesDataObject; + return filesDataArray; } async update(updateFileDto: UpdateFileDtoType) { diff --git a/apps/vscode-extension/src/types-schemas.ts b/apps/vscode-extension/src/types-schemas.ts index 0fd400e7..893ad70f 100644 --- a/apps/vscode-extension/src/types-schemas.ts +++ b/apps/vscode-extension/src/types-schemas.ts @@ -19,10 +19,8 @@ export type FileData = { freezeStartTime: number | null; isFrozen: boolean; projectName: string; - projectPath: string; languageSlug: string; fileName: string; - branchName: string; }; export const globalStateInitialDataSchema = z.object({ @@ -37,15 +35,19 @@ export const globalStateInitialDataSchema = z.object({ timeSpentPerLanguage: z.record(z.string().min(1), z.number()), dayFilesData: z.record( - z.string().min(1), // the absolute path of the file - z.object({ - timeSpent: z.number(), - projectPath: z.string().min(1), - languageSlug: z.string().min(1), - projectName: z.string().min(1), - fileName: z.string().min(1), - branchName: z.string().min(1), - }), + z.string().min(1), // project path + z.record( + z.string().min(1), // branch + z.record( + z.string().min(1), // absolute path of the file + z.object({ + timeSpent: z.number(), + languageSlug: z.string().min(1), + projectName: z.string().min(1), + fileName: z.string().min(1), + }), + ), + ), ), updatedAt: z.union([ @@ -55,7 +57,7 @@ export const globalStateInitialDataSchema = z.object({ }), ), }); -export type FileMap = Record; +export type FileMap = Record>>; // first record: project path, second: branch name and third: file path export type GlobalStateData = z.infer; export type FileDataSync = GlobalStateData["dailyData"][string]["dayFilesData"]; diff --git a/apps/vscode-extension/src/utils/commands/init-extension-commands.ts b/apps/vscode-extension/src/utils/commands/init-extension-commands.ts index a6dd2fa0..a1a2df7c 100644 --- a/apps/vscode-extension/src/utils/commands/init-extension-commands.ts +++ b/apps/vscode-extension/src/utils/commands/init-extension-commands.ts @@ -21,17 +21,34 @@ export const initExtensionCommands = ( () => { const filesData = getTime(); - const formattedData = Object.entries( - Object.entries(filesData).reduce( - (acc, [, { elapsedTime, languageSlug }]) => { - acc[languageSlug] = (acc[languageSlug] || 0) + elapsedTime; + const data = Object.entries(filesData) + .flatMap(([projectPath, branches]) => + Object.entries(branches).flatMap(([branchName, files]) => + Object.entries(files).map(([filePath, file]) => ({ + projectPath, + branchName, + filePath, + ...file, + })), + ), + ) + .reduce( + (acc, curr) => { + acc[curr.languageSlug] = + (acc[curr.languageSlug] ?? 0) + curr.elapsedTime; + return acc; }, {} as Record, - ), - ) - .map(([key, elapsedTime]) => `${key}: ${elapsedTime} seconds`) + ); + + const formattedData = Object.entries(data) + .map( + ([languageSlug, elapsedTime]) => + `${languageSlug}: ${elapsedTime} seconds`, + ) .join("\n"); + logInfo(`Current Languages Data:\n${formattedData}`); }, ); @@ -39,17 +56,34 @@ export const initExtensionCommands = ( const showInitialLanguagesDataCommand = vscode.commands.registerCommand( "MoonCode.showInitialLanguagesData", () => { - const formattedData = Object.entries( - Object.entries(initialFilesData).reduce( - (acc, [, { timeSpent, languageSlug }]) => { - acc[languageSlug] = (acc[languageSlug] || 0) + timeSpent; + const data = Object.entries(initialFilesData) + .flatMap(([projectPath, branches]) => + Object.entries(branches).flatMap(([branchName, files]) => + Object.entries(files).map(([filePath, file]) => ({ + projectPath, + branchName, + filePath, + ...file, + })), + ), + ) + .reduce( + (acc, curr) => { + acc[curr.languageSlug] = + (acc[curr.languageSlug] ?? 0) + curr.timeSpent; + return acc; }, {} as Record, - ), - ) - .map(([key, elapsedTime]) => `${key}: ${elapsedTime} seconds`) + ); + + const formattedData = Object.entries(data) + .map( + ([languageSlug, elapsedTime]) => + `${languageSlug}: ${elapsedTime} seconds`, + ) .join("\n"); + logInfo(`Initial Languages Data:\n${formattedData}`); }, ); @@ -58,8 +92,23 @@ export const initExtensionCommands = ( "MoonCode.showCurrentFilesData", () => { const filesData = getTime(); - const formattedData = Object.entries(filesData) - .map(([key, { elapsedTime }]) => `${key}: ${elapsedTime} seconds`) + + const data = Object.entries(filesData).flatMap( + ([projectPath, branches]) => + Object.entries(branches).flatMap(([branchName, files]) => + Object.entries(files).map(([filePath, file]) => ({ + projectPath, + branchName, + filePath, + ...file, + })), + ), + ); + + const formattedData = data + .map( + ({ filePath, elapsedTime }) => `${filePath}: ${elapsedTime} seconds`, + ) .join("\n"); logInfo(`Current files data:\n${formattedData}`); }, @@ -78,7 +127,19 @@ export const initExtensionCommands = ( const showInitialFilesDataCommand = vscode.commands.registerCommand( "MoonCode.showInitialFilesData", () => { - const formattedData = Object.entries(initialFilesData) + const data = Object.entries(initialFilesData).flatMap( + ([projectPath, branches]) => + Object.entries(branches).flatMap(([branchName, files]) => + Object.entries(files).map(([filePath, file]) => ({ + projectPath, + branchName, + filePath, + ...file, + })), + ), + ); + + const formattedData = Object.entries(data) .map( ([key, { timeSpent: elapsedTime }]) => `${key}: ${elapsedTime} seconds`, diff --git a/apps/vscode-extension/src/utils/fetch-initial-data.ts b/apps/vscode-extension/src/utils/fetch-initial-data.ts index 3c620d45..bda4be27 100644 --- a/apps/vscode-extension/src/utils/fetch-initial-data.ts +++ b/apps/vscode-extension/src/utils/fetch-initial-data.ts @@ -22,9 +22,10 @@ export const fetchInitialData = async () => { dateString, }); - const dayFilesData = await trpc.extension.getFilesForDay.query({ + const dayFilesData = (await trpc.extension.getFilesForDay.query({ dateString, - }); + type: "new", + })) as FileDataSync; timeSpentFromServer = timeSpent; initialFilesDataFromServer = dayFilesData; diff --git a/apps/vscode-extension/src/utils/files/delete-files-data-content.ts b/apps/vscode-extension/src/utils/files/delete-files-data-content.ts index 7cc2555c..05d4f25e 100644 --- a/apps/vscode-extension/src/utils/files/delete-files-data-content.ts +++ b/apps/vscode-extension/src/utils/files/delete-files-data-content.ts @@ -1,7 +1,7 @@ import { filesData } from "@/constants"; export const deleteFilesDataContent = () => { - Object.keys(filesData).forEach((filePath) => { - delete filesData[filePath]; + Object.keys(filesData).forEach((projectPath) => { + delete filesData[projectPath]; }); }; diff --git a/apps/vscode-extension/src/utils/files/initialize-files.ts b/apps/vscode-extension/src/utils/files/initialize-files.ts index 34e1a1d9..de62be05 100644 --- a/apps/vscode-extension/src/utils/files/initialize-files.ts +++ b/apps/vscode-extension/src/utils/files/initialize-files.ts @@ -2,23 +2,27 @@ import { filesData } from "@/constants"; import { FileDataSync } from "@/types-schemas"; export const initializeFiles = (data: FileDataSync) => { - // initialize the time/other metadata for each file found - Object.keys(data).forEach((filePath) => { - const file = data[filePath]; - const now = performance.now(); + const now = performance.now(); - filesData[filePath] = { - elapsedTime: file.timeSpent, - frozenTime: null, - freezeStartTime: null, - isFrozen: false, - lastActivityTime: now, - startTime: now - file.timeSpent * 1000, - projectName: file.projectName, - projectPath: file.projectPath, - languageSlug: file.languageSlug, - fileName: file.fileName, - branchName: file.branchName, - }; + // initialize the time and other metadata for each file found + Object.entries(data).forEach(([projectPath, branches]) => { + Object.entries(branches).forEach(([branchName, files]) => { + Object.entries(files).forEach(([filePath, file]) => { + filesData[projectPath] ??= {}; + filesData[projectPath][branchName] ??= {}; + + filesData[projectPath][branchName][filePath] = { + elapsedTime: file.timeSpent, + frozenTime: null, + freezeStartTime: null, + isFrozen: false, + lastActivityTime: now, + startTime: now - file.timeSpent * 1000, + projectName: file.projectName, + languageSlug: file.languageSlug, + fileName: file.fileName, + }; + }); + }); }); }; diff --git a/apps/vscode-extension/src/utils/files/update-current-file-obj.ts b/apps/vscode-extension/src/utils/files/update-current-file-obj.ts index ece1182a..e127a249 100644 --- a/apps/vscode-extension/src/utils/files/update-current-file-obj.ts +++ b/apps/vscode-extension/src/utils/files/update-current-file-obj.ts @@ -23,27 +23,23 @@ export const updateCurrentFileObj = ( return; } - if (!filesData[absolutePath]) { - filesData[absolutePath] = { - elapsedTime: 0, - startTime: performance.now(), - lastActivityTime: performance.now(), - frozenTime: null, - freezeStartTime: null, - isFrozen: false, - projectName, - projectPath, - languageSlug: currentLanguageSlug, - fileName, - branchName, - }; - } + filesData[projectPath] ??= {}; + filesData[projectPath][branchName] ??= {}; + filesData[projectPath][branchName][absolutePath] ??= { + elapsedTime: 0, + startTime: performance.now(), + lastActivityTime: performance.now(), + frozenTime: null, + freezeStartTime: null, + isFrozen: false, + projectName, + languageSlug: currentLanguageSlug, + fileName, + }; - const currentFileData = filesData[absolutePath]; + const currentFileData = filesData[projectPath][branchName][absolutePath]; currentFileData.lastActivityTime = performance.now(); currentFileData.languageSlug = currentLanguageSlug; currentFileData.projectName = projectName; - currentFileData.projectPath = projectPath; - currentFileData.branchName = branchName; }; diff --git a/apps/vscode-extension/src/utils/files/update-files-data-after-sync.ts b/apps/vscode-extension/src/utils/files/update-files-data-after-sync.ts index 1354903a..8f36123d 100644 --- a/apps/vscode-extension/src/utils/files/update-files-data-after-sync.ts +++ b/apps/vscode-extension/src/utils/files/update-files-data-after-sync.ts @@ -1,39 +1,41 @@ import { filesData } from "@/constants"; +import { FileDataSync } from "@/types-schemas"; -import { RouterOutput } from "../trpc/client"; - -export const updateFilesDataAfterSync = ( - files: Awaited, -) => { +export const updateFilesDataAfterSync = (filesFromServer: FileDataSync) => { const now = performance.now(); - Object.keys(files).forEach((filePath) => { - const file = files[filePath]; + Object.entries(filesFromServer).forEach(([projectPath, branches]) => { + Object.entries(branches).forEach(([branchName, files]) => { + Object.entries(files).forEach(([filePath, file]) => { + filesData[projectPath] ??= {}; + filesData[projectPath][branchName] ??= {}; + + let entry = filesData[projectPath][branchName][filePath]; - if (filesData[filePath]) { - filesData[filePath].elapsedTime = file.timeSpent; + if (entry) { + entry.elapsedTime = file.timeSpent; - if (!filesData[filePath].isFrozen) { - filesData[filePath].startTime = now - file.timeSpent * 1000; - return; - } + if (!entry.isFrozen) { + entry.startTime = now - file.timeSpent * 1000; + return; + } - filesData[filePath].frozenTime = file.timeSpent; - return; - } + entry.frozenTime = file.timeSpent; + return; + } - filesData[filePath] = { - elapsedTime: file.timeSpent, - frozenTime: null, - freezeStartTime: null, - isFrozen: false, - lastActivityTime: now, - startTime: now - file.timeSpent * 1000, - projectName: file.projectName, - projectPath: file.projectPath, - languageSlug: file.languageSlug, - fileName: file.fileName, - branchName: file.branchName, - }; + filesData[projectPath][branchName][filePath] = { + elapsedTime: file.timeSpent, + frozenTime: null, + freezeStartTime: null, + isFrozen: false, + lastActivityTime: now, + startTime: now - file.timeSpent * 1000, + projectName: file.projectName, + languageSlug: file.languageSlug, + fileName: file.fileName, + }; + }); + }); }); }; diff --git a/apps/vscode-extension/src/utils/files/update-files-data-elapsed-time.ts b/apps/vscode-extension/src/utils/files/update-files-data-elapsed-time.ts index 686566cd..438e2c0d 100644 --- a/apps/vscode-extension/src/utils/files/update-files-data-elapsed-time.ts +++ b/apps/vscode-extension/src/utils/files/update-files-data-elapsed-time.ts @@ -4,15 +4,17 @@ export const updateFilesDataElapsedTime = () => { // Update all files times const now = performance.now(); - Object.keys(filesData).forEach((filePath) => { - const fileData = filesData[filePath]; - - // Only use frozenTime if it is not null (0 is valid) - fileData.elapsedTime = - // we check if the file is in a frozen state or not - fileData.isFrozen && fileData.frozenTime !== null - ? fileData.frozenTime - : Math.floor((now - fileData.startTime) / 1000); + Object.entries(filesData).forEach(([, branches]) => { + Object.entries(branches).forEach(([, files]) => { + Object.entries(files).forEach(([, file]) => { + // Only use frozenTime if it is not null (0 is valid) + file.elapsedTime = + // we check if the file is in a frozen state or not + file.isFrozen && file.frozenTime !== null + ? file.frozenTime + : Math.floor((now - file.startTime) / 1000); + }); + }); }); return filesData; diff --git a/apps/vscode-extension/src/utils/files/update-files-data-frozen-states.ts b/apps/vscode-extension/src/utils/files/update-files-data-frozen-states.ts index 96d8f469..901668d7 100644 --- a/apps/vscode-extension/src/utils/files/update-files-data-frozen-states.ts +++ b/apps/vscode-extension/src/utils/files/update-files-data-frozen-states.ts @@ -11,51 +11,55 @@ export const updateFilesDataFrozenStates = () => { const now = performance.now(); - Object.keys(filesData).forEach((filePath) => { - const fileData = filesData[filePath]; - // immediately freeze non active files - if ( - !latestFile || - !latestFile.absolutePath || - filePath !== latestFile.absolutePath - ) { - if (!fileData.isFrozen) { - fileData.freezeStartTime = now; - fileData.isFrozen = true; - fileData.frozenTime = Math.floor((now - fileData.startTime) / 1000); - } - return; - } - - const latestFileObj = filesData[latestFile.absolutePath]; - - // We track the time elapsed since when the latest file has been modified - // in a variable called `idleDuration` - const idleDuration = Math.floor( - (now - latestFileObj.lastActivityTime) / 1000, - ); - - // we freeze the time for the active file if the user is idling for more than `MAX_IDLE_TIME` - if (idleDuration >= MAX_IDLE_TIME && !latestFileObj.isFrozen) { - latestFileObj.frozenTime = Math.floor( - (now - latestFileObj.startTime) / 1000, - ); - latestFileObj.freezeStartTime = now; - latestFileObj.isFrozen = true; - } else if ( - // we unfreeze if it is active, marked as frozen and - // if the user hasn't been idled for more than `MAX_IDLE_TIME` seconds - idleDuration < MAX_IDLE_TIME && - latestFileObj.isFrozen && - latestFileObj.freezeStartTime - ) { - const freezeDuration = Math.floor( - (now - latestFileObj.freezeStartTime) / 1000, - ); - latestFileObj.startTime += Math.floor(freezeDuration * 1000); - latestFileObj.frozenTime = null; - latestFileObj.freezeStartTime = null; - latestFileObj.isFrozen = false; - } + Object.entries(filesData).forEach(([, branches]) => { + Object.entries(branches).forEach(([branchName, files]) => { + Object.entries(files).forEach(([filePath, file]) => { + // immediately freeze non active files + if ( + !latestFile || + !latestFile.absolutePath || + filePath !== latestFile.absolutePath || + branchName !== latestFile.branchName + ) { + if (!file.isFrozen) { + file.freezeStartTime = now; + file.isFrozen = true; + file.frozenTime = Math.floor((now - file.startTime) / 1000); + } + return; + } + + const latestFileObj = file; + + // We track the time elapsed since when the latest file has been modified + // in a variable called `idleDuration` + const idleDuration = Math.floor( + (now - latestFileObj.lastActivityTime) / 1000, + ); + + // we freeze the time for the active file if the user is idling for more than `MAX_IDLE_TIME` + if (idleDuration >= MAX_IDLE_TIME && !latestFileObj.isFrozen) { + latestFileObj.frozenTime = Math.floor( + (now - latestFileObj.startTime) / 1000, + ); + latestFileObj.freezeStartTime = now; + latestFileObj.isFrozen = true; + } else if ( + // we unfreeze if it is active, marked as frozen and + // if the user hasn't been idled for more than `MAX_IDLE_TIME` seconds + idleDuration < MAX_IDLE_TIME && + latestFileObj.isFrozen && + latestFileObj.freezeStartTime + ) { + const freezeDuration = Math.floor( + (now - latestFileObj.freezeStartTime) / 1000, + ); + latestFileObj.startTime += Math.floor(freezeDuration * 1000); + latestFileObj.frozenTime = null; + latestFileObj.freezeStartTime = null; + latestFileObj.isFrozen = false; + } + }); + }); }); }; diff --git a/apps/vscode-extension/src/utils/periodic-sync-data.ts b/apps/vscode-extension/src/utils/periodic-sync-data.ts index 44e18ba2..0e274727 100644 --- a/apps/vscode-extension/src/utils/periodic-sync-data.ts +++ b/apps/vscode-extension/src/utils/periodic-sync-data.ts @@ -1,6 +1,7 @@ import { isEqual } from "date-fns"; import vscode from "vscode"; +import { FileDataSync } from "@/types-schemas"; import { getLocaleDate } from "@repo/common/get-locale-date"; import { getLoginContext } from "./auth/login-context"; @@ -26,9 +27,21 @@ export const periodicSyncData = async ( [languageSlug: string]: number; }; - const filesDataToUpsert = getTime(); + const rawData = getTime(); - const timeSpentToday = Object.values(filesDataToUpsert).reduce( + const filesDataToUpsert = Object.entries(rawData).flatMap( + ([projectPath, branches]) => + Object.entries(branches).flatMap(([branchName, files]) => + Object.entries(files).map(([filePath, file]) => ({ + projectPath, + branchName, + filePath, + ...file, + })), + ), + ); + + const timeSpentToday = filesDataToUpsert.reduce( (acc, curr) => acc + curr.elapsedTime, 0, ); @@ -46,29 +59,32 @@ export const periodicSyncData = async ( timeSpentPerLanguage = timeSpentPerLanguageToday; const todayFilesData = Object.fromEntries( - Object.entries(filesDataToUpsert).map( - ([ - filePath, - { - elapsedTime, - languageSlug, - projectName, - projectPath, - fileName, + Object.entries(rawData).map(([projectPath, branches]) => [ + projectPath, + Object.fromEntries( + Object.entries(branches).map(([branchName, files]) => [ branchName, - }, - ]) => [ - filePath, - { - timeSpent: elapsedTime, - languageSlug, - projectName, - projectPath, - fileName, - branchName, - }, - ], - ), + Object.fromEntries( + Object.entries(files).map( + ([ + filePath, + { elapsedTime, fileName, languageSlug, projectName }, + ]) => [ + filePath, + { + projectPath, + branchName, + timeSpent: elapsedTime, + fileName, + languageSlug, + projectName, + }, + ], + ), + ), + ]), + ), + ]), ); const globalStateData = await getGlobalStateData(); @@ -89,6 +105,7 @@ export const periodicSyncData = async ( await trpc.extension.upsertFiles.mutate({ filesData: data.dayFilesData, targetedDate: dateString, + type: "new", }); } } @@ -117,10 +134,12 @@ export const periodicSyncData = async ( ); if (hasFilesDataChangedSinceLastSync) { - const files = await trpc.extension.upsertFiles.mutate({ + const files = (await trpc.extension.upsertFiles.mutate({ filesData: todayFilesData, targetedDate: todaysDateString, - }); + type: "new", + })) as FileDataSync; + updateFilesDataAfterSync(files); isServerSynced = true; diff --git a/apps/vscode-extension/src/utils/time/calculate-time.ts b/apps/vscode-extension/src/utils/time/calculate-time.ts index ceaea5fd..44ba6b6e 100644 --- a/apps/vscode-extension/src/utils/time/calculate-time.ts +++ b/apps/vscode-extension/src/utils/time/calculate-time.ts @@ -29,7 +29,7 @@ export const calculateTime = async (): Promise<() => FileMap> => { updateFilesDataFrozenStates(); } catch (error) { - logError(`Error in periodic check:${error}`); + logError(`Error in periodic check: ${error}`); } finally { timeoutId = setTimeout(runPeriodicCheck, 1000); } From d41a09629d39ddb6d680c3dc78ef55ea9b92ae26 Mon Sep 17 00:00:00 2001 From: Friedrich482 Date: Mon, 13 Jul 2026 20:39:38 +0200 Subject: [PATCH 07/13] feat: branching dashboard - added a new trpc procedure `analytics.projects.getProjectBranchesOnPeriod` - added a dropdown menu to select branches on projects - that dropdown only shows up if the project is version controlled - created a zustand store to keep track of projects selected - the store automatically updates the url to add the branches selected as search params - the branches state of the store is reset between navigations --- .../analytics/dto/projects-analytics.dto.ts | 12 +++ .../routers/projects-analytics.router.ts | 11 +++ .../services/projects-analytics.service.ts | 28 +++++++ .../src/components/common/link-with-query.tsx | 26 +++++- .../layout/navigation-reset-wrapper.tsx | 7 ++ .../components/branches-dropdown.tsx | 80 +++++++++++++++++++ .../components/project-title.tsx | 26 ++++++ .../src/stores/branches/branches-store.ts | 61 ++++++++++++++ .../src/stores/branches/types-schemas.ts | 3 + .../get-branches-store-values-from-url.ts | 19 +++++ .../src/stores/period/period-store.ts | 6 +- 11 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 apps/dashboard/src/features/project-title/components/branches-dropdown.tsx create mode 100644 apps/dashboard/src/stores/branches/branches-store.ts create mode 100644 apps/dashboard/src/stores/branches/types-schemas.ts create mode 100644 apps/dashboard/src/stores/branches/utils/get-branches-store-values-from-url.ts diff --git a/apps/api/src/analytics/dto/projects-analytics.dto.ts b/apps/api/src/analytics/dto/projects-analytics.dto.ts index 5a312261..ccaeb16d 100644 --- a/apps/api/src/analytics/dto/projects-analytics.dto.ts +++ b/apps/api/src/analytics/dto/projects-analytics.dto.ts @@ -86,6 +86,13 @@ export const GetProjectOnPeriodDto = refineDto( }), ); +export const GetProjectBranchesOnPeriodDto = refineDto( + z.object({ + ...DateRangeDto.shape, + name: z.string().min(1), + }), +); + export const GetProjectPerDayOfPeriodDto = refineAndTransformDto( z.object({ ...BaseDto.shape, @@ -147,6 +154,11 @@ export type GetPeriodProjectsDtoType = z.infer & export type GetProjectOnPeriodDtoType = z.infer & UserId; +export type GetProjectBranchesOnPeriodDtoType = z.infer< + typeof GetProjectBranchesOnPeriodDto +> & + UserId; + export type GetProjectPerDayOfPeriodDtoType = z.infer< typeof GetProjectPerDayOfPeriodDto > & diff --git a/apps/api/src/analytics/routers/projects-analytics.router.ts b/apps/api/src/analytics/routers/projects-analytics.router.ts index 1327590d..c18c0222 100644 --- a/apps/api/src/analytics/routers/projects-analytics.router.ts +++ b/apps/api/src/analytics/routers/projects-analytics.router.ts @@ -2,6 +2,7 @@ import { CheckProjectExistsDto, GetPeriodGeneralStatsForProjectDto, GetPeriodProjectsDto, + GetProjectBranchesOnPeriodDto, GetProjectDailyStatsDto, GetProjectFilesOnPeriodDto, GetProjectLanguagesPerDayOfPeriodDto, @@ -53,6 +54,16 @@ export class ProjectsAnalyticsRouter { }), ), + getProjectBranchesOnPeriod: this.trpcService + .protectedProcedure() + .input(GetProjectBranchesOnPeriodDto) + .query(async ({ ctx, input }) => + this.projectsAnalyticsService.getProjectBranchesOnPeriod({ + userId: ctx.user.sub, + ...input, + }), + ), + getProjectPerDayOfPeriod: this.trpcService .protectedProcedure() .input(GetProjectPerDayOfPeriodDto) diff --git a/apps/api/src/analytics/services/projects-analytics.service.ts b/apps/api/src/analytics/services/projects-analytics.service.ts index 91ad03ec..4dffbc03 100644 --- a/apps/api/src/analytics/services/projects-analytics.service.ts +++ b/apps/api/src/analytics/services/projects-analytics.service.ts @@ -7,6 +7,7 @@ import { } from "date-fns"; import { and, + asc, between, count, desc, @@ -22,6 +23,7 @@ import { FindProjectByNameOnRangeDtoType, GetPeriodGeneralStatsForProjectDtoType, GetPeriodProjectsDtoType, + GetProjectBranchesOnPeriodDtoType, GetProjectDailyStatsDtoType, GetProjectFilesOnPeriodDtoType, GetProjectLanguagesPerDayOfPeriodDtoType, @@ -293,6 +295,32 @@ export class ProjectsAnalyticsService { return projectAggregatedOnPeriod; } + async getProjectBranchesOnPeriod( + getProjectBranchesOnPeriodDto: GetProjectBranchesOnPeriodDtoType, + ) { + const { userId, start, end, name } = getProjectBranchesOnPeriodDto; + + const projectBranches = await this.db + .select({ + name: branches.name, + timeSpent: sum(branches.timeSpent).mapWith(Number), + }) + .from(branches) + .innerJoin(projects, eq(projects.id, branches.projectId)) + .innerJoin(dailyData, eq(dailyData.id, projects.dailyDataId)) + .where( + and( + eq(dailyData.userId, userId), + eq(projects.name, name), + between(dailyData.date, start, end), + ), + ) + .groupBy(branches.name) + .orderBy(asc(sum(branches.timeSpent))); + + return projectBranches; + } + async getProjectPerDayOfPeriod( getProjectPerDayOfPeriodDto: GetProjectPerDayOfPeriodDtoType, ) { diff --git a/apps/dashboard/src/components/common/link-with-query.tsx b/apps/dashboard/src/components/common/link-with-query.tsx index a7079e9c..c2de99a6 100644 --- a/apps/dashboard/src/components/common/link-with-query.tsx +++ b/apps/dashboard/src/components/common/link-with-query.tsx @@ -8,7 +8,31 @@ export const LinkWithQuery = ({ const [search, setSearch] = useState(useLocation().search); useEffect(() => { - setSearch(window.location.search); + const currentSearchParams = new URLSearchParams(window.location.search); + + // we transport those parameters specifically in the url between pages + const periodParam = currentSearchParams.get("period"); + const startParam = currentSearchParams.get("start"); + const endParam = currentSearchParams.get("end"); + const groupByParam = currentSearchParams.get("groupBy"); + + const newSearchParams = new URLSearchParams(); + + if (periodParam) { + newSearchParams.set("period", periodParam); + } + if (startParam) { + newSearchParams.set("start", startParam); + } + if (endParam) { + newSearchParams.set("end", endParam); + } + if (groupByParam) { + newSearchParams.set("groupBy", groupByParam); + } + + const queryString = newSearchParams.toString(); + setSearch(queryString ? `?${queryString}` : ""); }, [window.location.search]); return ( diff --git a/apps/dashboard/src/components/layout/navigation-reset-wrapper.tsx b/apps/dashboard/src/components/layout/navigation-reset-wrapper.tsx index ded166c0..3bfaa273 100644 --- a/apps/dashboard/src/components/layout/navigation-reset-wrapper.tsx +++ b/apps/dashboard/src/components/layout/navigation-reset-wrapper.tsx @@ -1,6 +1,8 @@ import { useLayoutEffect } from "react"; import { useLocation } from "react-router"; +import { useBranchesStore } from "@/stores/branches/branches-store"; + export const NavigationResetWrapper = ({ children, }: { @@ -8,9 +10,14 @@ export const NavigationResetWrapper = ({ }) => { const location = useLocation(); + const resetBranches = useBranchesStore((state) => state.resetBranches); + useLayoutEffect(() => { // Scroll to the top of the page when the route changes window.scrollTo({ top: 0, left: 0, behavior: "instant" }); + + // reset the branches of the branchesStore before going to another page/project + resetBranches(); }, [location.pathname]); return children; diff --git a/apps/dashboard/src/features/project-title/components/branches-dropdown.tsx b/apps/dashboard/src/features/project-title/components/branches-dropdown.tsx new file mode 100644 index 00000000..e8971bb3 --- /dev/null +++ b/apps/dashboard/src/features/project-title/components/branches-dropdown.tsx @@ -0,0 +1,80 @@ +import { useLoaderData } from "react-router"; +import { ChevronDown } from "lucide-react"; + +import { projectLoader } from "@/loaders/project-loader"; +import { useBranchesStore } from "@/stores/branches/branches-store"; +import { PERIODS_CONFIG } from "@/stores/period/constants"; +import { usePeriodStore } from "@/stores/period/period-store"; +import { useTRPC } from "@/utils/trpc"; +import { Button } from "@repo/ui/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@repo/ui/components/ui/dropdown-menu"; +import { useSuspenseQuery } from "@tanstack/react-query"; + +export const BranchesDropdown = () => { + const { projectName: name } = useLoaderData(); + + const trpc = useTRPC(); + + const period = usePeriodStore((state) => state.period); + const customRange = usePeriodStore((state) => state.customRange); + + const { data } = useSuspenseQuery( + trpc.analytics.projects.getProjectBranchesOnPeriod.queryOptions( + period === "Custom Range" + ? { + start: customRange.start, + end: customRange.end, + name, + } + : { + start: PERIODS_CONFIG[period].start, + end: PERIODS_CONFIG[period].end, + name, + }, + ), + ); + + const branches = useBranchesStore((state) => state.branches); + const handleCheckBranch = useBranchesStore( + (state) => state.handleCheckBranch, + ); + + const isProjectVersionControlled = data.every( + (entry) => entry.name !== "N/A", + ); + + return ( + isProjectVersionControlled && ( + + + + + + + {data.map((branch) => ( + entry === branch.name)} + onCheckedChange={() => handleCheckBranch(branch.name)} + onSelect={(e) => e.preventDefault()} + className="cursor-pointer gap-3 rounded-md py-1 text-base" + > + {branch.name} + + ))} + + + ) + ); +}; diff --git a/apps/dashboard/src/features/project-title/components/project-title.tsx b/apps/dashboard/src/features/project-title/components/project-title.tsx index 402665c2..bf81e842 100644 --- a/apps/dashboard/src/features/project-title/components/project-title.tsx +++ b/apps/dashboard/src/features/project-title/components/project-title.tsx @@ -7,6 +7,7 @@ import { PeriodDropDown } from "@/components/common/period-dropdown"; import { FallBackRender } from "@/components/errors/error-boundary"; import { SuspenseBoundary } from "@/components/errors/suspense-boundary"; +import { BranchesDropdown } from "./branches-dropdown"; import { ProjectName } from "./project-name"; import { TimeSpentOnProject } from "./time-spent-on-project"; @@ -41,6 +42,31 @@ export const ProjectTitle = () => ( +
+ ( + ( +

+ + {errorMessage} +

+ )} + /> + )} + > + + + +
+
+
( diff --git a/apps/dashboard/src/stores/branches/branches-store.ts b/apps/dashboard/src/stores/branches/branches-store.ts new file mode 100644 index 00000000..6218c674 --- /dev/null +++ b/apps/dashboard/src/stores/branches/branches-store.ts @@ -0,0 +1,61 @@ +import { create } from "zustand"; + +import { getBranchesStoreValuesFromURL } from "./utils/get-branches-store-values-from-url"; + +type BranchesStore = { + branches: string[] | undefined; + resetBranches: () => void; + handleCheckBranch: (branch: string) => void; +}; + +const { branches: initialBranches } = getBranchesStoreValuesFromURL(); + +export const useBranchesStore = create((set, get) => ({ + branches: initialBranches, + resetBranches: () => { + set({ branches: undefined }); + }, + handleCheckBranch: (branch) => { + const branches = get().branches; + + if (!branches) { + set({ branches: [branch] }); + updateURLFromState(get().branches); + + return; + } + + const isBranchInState = branches.some((entry) => entry === branch); + + if (!isBranchInState) { + set({ branches: [...branches, branch] }); + updateURLFromState(get().branches); + + return; + } + + if (branches.length === 1) { + set({ branches: undefined }); + updateURLFromState(get().branches); + + return; + } + + set({ branches: branches.filter((entry) => entry !== branch) }); + updateURLFromState(get().branches); + + return; + }, +})); + +const updateURLFromState = (branches: BranchesStore["branches"]) => { + const searchParams = new URLSearchParams(window.location.search); + + searchParams.delete("branch"); + + branches?.forEach((branch) => { + searchParams.append("branch", branch); + }); + + window.history.replaceState(null, "", `?${searchParams.toString()}`); +}; diff --git a/apps/dashboard/src/stores/branches/types-schemas.ts b/apps/dashboard/src/stores/branches/types-schemas.ts new file mode 100644 index 00000000..a81faf53 --- /dev/null +++ b/apps/dashboard/src/stores/branches/types-schemas.ts @@ -0,0 +1,3 @@ +import z from "zod"; + +export const BranchesSchema = z.array(z.string().min(1)).min(1); diff --git a/apps/dashboard/src/stores/branches/utils/get-branches-store-values-from-url.ts b/apps/dashboard/src/stores/branches/utils/get-branches-store-values-from-url.ts new file mode 100644 index 00000000..f5b90771 --- /dev/null +++ b/apps/dashboard/src/stores/branches/utils/get-branches-store-values-from-url.ts @@ -0,0 +1,19 @@ +import { BranchesSchema } from "../types-schemas"; + +export const getBranchesStoreValuesFromURL = (): { + branches: string[] | undefined; +} => { + // default value + const defaultBranches = undefined; + + const searchParams = new URLSearchParams(window.location.search); + const branchesParams = searchParams.getAll("branch"); + + const parsedBranches = BranchesSchema.safeParse(branchesParams); + + if (!parsedBranches.success) { + return { branches: defaultBranches }; + } + + return { branches: parsedBranches.data }; +}; diff --git a/apps/dashboard/src/stores/period/period-store.ts b/apps/dashboard/src/stores/period/period-store.ts index 1f22e398..9d9ef0aa 100644 --- a/apps/dashboard/src/stores/period/period-store.ts +++ b/apps/dashboard/src/stores/period/period-store.ts @@ -9,7 +9,7 @@ import { Period } from "./types-schemas"; import { correctGroupBy } from "./utils/correct-groupby"; import { getPeriodStoreValuesFromURL } from "./utils/get-period-store-values-from-url"; -type Store = { +type PeriodStore = { period: Period; setPeriod: (state: Period) => void; @@ -39,7 +39,7 @@ const correctedGroupBy = correctGroupBy( initialGroupBy, ); -export const usePeriodStore = create((set, get) => ({ +export const usePeriodStore = create((set, get) => ({ period: initialPeriod, setPeriod: (newPeriod) => { set({ @@ -82,7 +82,7 @@ export const usePeriodStore = create((set, get) => ({ ), })); -const updateURLFromState = (state: Store) => { +const updateURLFromState = (state: PeriodStore) => { const searchParams = new URLSearchParams(window.location.search); if (state.period !== "Custom Range") { From cbced08758def4dc1fcbbbb9ecff29d5ad22d20d Mon Sep 17 00:00:00 2001 From: Friedrich482 Date: Mon, 13 Jul 2026 22:04:21 +0200 Subject: [PATCH 08/13] feat: branching - pulled the selected branches from the store and added them to the queries in the dashboard for all trpc procedures where it can be passed --- .../components/files-circle-packing-chart.tsx | 5 +++++ apps/dashboard/src/features/files-list/hooks/use-files.tsx | 6 ++++++ .../features/files-list/hooks/use-languages-dropdown.tsx | 5 ++++- .../components/project-day-languages-chart.tsx | 4 ++++ .../components/project-general-stats-chart.tsx | 5 +++++ .../hooks/use-suspense-query-projects-lang-chart.tsx | 6 ++++++ .../components/project-time-on-period-chart.tsx | 4 ++++ .../project-title/hooks/use-get-time-spent-on-project.tsx | 4 ++++ 8 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/features/files-circle-packing-chart/components/files-circle-packing-chart.tsx b/apps/dashboard/src/features/files-circle-packing-chart/components/files-circle-packing-chart.tsx index 01808258..4bf2d48f 100644 --- a/apps/dashboard/src/features/files-circle-packing-chart/components/files-circle-packing-chart.tsx +++ b/apps/dashboard/src/features/files-circle-packing-chart/components/files-circle-packing-chart.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from "react"; import { useLoaderData } from "react-router"; import { projectLoader } from "@/loaders/project-loader"; +import { useBranchesStore } from "@/stores/branches/branches-store"; import { PERIODS_CONFIG } from "@/stores/period/constants"; import { usePeriodStore } from "@/stores/period/period-store"; import { useTRPC } from "@/utils/trpc"; @@ -18,6 +19,8 @@ export const FilesCirclePackingChart = () => { const period = usePeriodStore((state) => state.period); const customRange = usePeriodStore((state) => state.customRange); + const branches = useBranchesStore((state) => state.branches); + const [isGrouped, setIsGrouped] = useState(true); const handleGroupCheckboxChange = () => setIsGrouped((prev) => !prev); @@ -32,6 +35,7 @@ export const FilesCirclePackingChart = () => { end: customRange.end, amount: NUMBER_OF_FILES_TO_SHOW, type: "normal" as const, + branches, } : { name, @@ -39,6 +43,7 @@ export const FilesCirclePackingChart = () => { end: PERIODS_CONFIG[period].end, amount: NUMBER_OF_FILES_TO_SHOW, type: "normal" as const, + branches, }, ), ); diff --git a/apps/dashboard/src/features/files-list/hooks/use-files.tsx b/apps/dashboard/src/features/files-list/hooks/use-files.tsx index ecad0282..d5ebbad1 100644 --- a/apps/dashboard/src/features/files-list/hooks/use-files.tsx +++ b/apps/dashboard/src/features/files-list/hooks/use-files.tsx @@ -1,6 +1,7 @@ import { useLoaderData } from "react-router"; import { projectLoader } from "@/loaders/project-loader"; +import { useBranchesStore } from "@/stores/branches/branches-store"; import { PERIODS_CONFIG } from "@/stores/period/constants"; import { usePeriodStore } from "@/stores/period/period-store"; import { useTRPC } from "@/utils/trpc"; @@ -16,6 +17,9 @@ export const useFiles = ( const period = usePeriodStore((state) => state.period); const customRange = usePeriodStore((state) => state.customRange); + + const branches = useBranchesStore((state) => state.branches); + const trpc = useTRPC(); const search = searchTerm.trim().toLowerCase(); @@ -31,6 +35,7 @@ export const useFiles = ( page, type: "paginated", search: search || undefined, + branches, } : { name, @@ -40,6 +45,7 @@ export const useFiles = ( page, type: "paginated", search: search || undefined, + branches, }, ), ); diff --git a/apps/dashboard/src/features/files-list/hooks/use-languages-dropdown.tsx b/apps/dashboard/src/features/files-list/hooks/use-languages-dropdown.tsx index 78d4fcc0..cf5d9a31 100644 --- a/apps/dashboard/src/features/files-list/hooks/use-languages-dropdown.tsx +++ b/apps/dashboard/src/features/files-list/hooks/use-languages-dropdown.tsx @@ -1,6 +1,7 @@ import { useLoaderData } from "react-router"; import { projectLoader } from "@/loaders/project-loader"; +import { useBranchesStore } from "@/stores/branches/branches-store"; import { PERIODS_CONFIG } from "@/stores/period/constants"; import { usePeriodStore } from "@/stores/period/period-store"; import { useTRPC } from "@/utils/trpc"; @@ -16,15 +17,17 @@ export const useLanguagesDropDown = () => { const trpc = useTRPC(); const period = usePeriodStore((state) => state.period); const customRange = usePeriodStore((state) => state.customRange); + const branches = useBranchesStore((state) => state.branches); const { data: fetchedData } = useSuspenseQuery( trpc.analytics.projects.getProjectLanguagesTimeOnPeriod.queryOptions( period === "Custom Range" - ? { name, start: customRange.start, end: customRange.end } + ? { name, start: customRange.start, end: customRange.end, branches } : { name, start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, + branches, }, ), ); diff --git a/apps/dashboard/src/features/project-day-languages-chart/components/project-day-languages-chart.tsx b/apps/dashboard/src/features/project-day-languages-chart/components/project-day-languages-chart.tsx index ef0e01cc..885ea64d 100644 --- a/apps/dashboard/src/features/project-day-languages-chart/components/project-day-languages-chart.tsx +++ b/apps/dashboard/src/features/project-day-languages-chart/components/project-day-languages-chart.tsx @@ -6,6 +6,7 @@ import { CustomChartToolTip } from "@/components/common/custom-chart-tooltip"; import { DayChartTitle } from "@/components/common/day-chart-title"; import { chartConfig } from "@/constants"; import { projectLoader } from "@/loaders/project-loader"; +import { useBranchesStore } from "@/stores/branches/branches-store"; import { getNextDayDate } from "@/utils/get-next-day-date"; import { getPrevDayDate } from "@/utils/get-previous-day-date"; import { useTRPC } from "@/utils/trpc"; @@ -22,6 +23,8 @@ import { useSuspenseQuery } from "@tanstack/react-query"; export const ProjectDayLanguagesChart = () => { const { projectName: name } = useLoaderData(); + const branches = useBranchesStore((state) => state.branches); + const [date, setDate] = useState(new Date()); const dateString = useMemo(() => getLocaleDate(date), [date]); @@ -34,6 +37,7 @@ export const ProjectDayLanguagesChart = () => { trpc.analytics.projects.getProjectDailyStats.queryOptions({ dateString, name, + branches, }), ); diff --git a/apps/dashboard/src/features/project-general-stats-charts/components/project-general-stats-chart.tsx b/apps/dashboard/src/features/project-general-stats-charts/components/project-general-stats-chart.tsx index af57d11b..83594921 100644 --- a/apps/dashboard/src/features/project-general-stats-charts/components/project-general-stats-chart.tsx +++ b/apps/dashboard/src/features/project-general-stats-charts/components/project-general-stats-chart.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { useLoaderData } from "react-router"; import { projectLoader } from "@/loaders/project-loader"; +import { useBranchesStore } from "@/stores/branches/branches-store"; import { PERIODS_CONFIG } from "@/stores/period/constants"; import { usePeriodStore } from "@/stores/period/period-store"; import { useTRPC } from "@/utils/trpc"; @@ -36,6 +37,8 @@ export const ProjectGeneralStatsChart = () => { const period = usePeriodStore((state) => state.period); const groupBy = usePeriodStore((state) => state.groupBy); const customRange = usePeriodStore((state) => state.customRange); + const branches = useBranchesStore((state) => state.branches); + const todaysDateString = useMemo(() => getLocaleDate(new Date()), []); const { projectName: name } = useLoaderData(); @@ -50,6 +53,7 @@ export const ProjectGeneralStatsChart = () => { todaysDateString, name, groupBy, + branches, } : { start: PERIODS_CONFIG[period].start, @@ -57,6 +61,7 @@ export const ProjectGeneralStatsChart = () => { todaysDateString, name, groupBy, + branches, }, ), ); diff --git a/apps/dashboard/src/features/project-languages-time-on-period-chart/hooks/use-suspense-query-projects-lang-chart.tsx b/apps/dashboard/src/features/project-languages-time-on-period-chart/hooks/use-suspense-query-projects-lang-chart.tsx index d9106fff..75d06b53 100644 --- a/apps/dashboard/src/features/project-languages-time-on-period-chart/hooks/use-suspense-query-projects-lang-chart.tsx +++ b/apps/dashboard/src/features/project-languages-time-on-period-chart/hooks/use-suspense-query-projects-lang-chart.tsx @@ -1,6 +1,7 @@ import { useLoaderData } from "react-router"; import { projectLoader } from "@/loaders/project-loader"; +import { useBranchesStore } from "@/stores/branches/branches-store"; import { PERIODS_CONFIG } from "@/stores/period/constants"; import { usePeriodStore } from "@/stores/period/period-store"; import { useTRPC } from "@/utils/trpc"; @@ -13,6 +14,7 @@ export const useSuspenseQueryProjectsLangChart = () => { const period = usePeriodStore((state) => state.period); const customRange = usePeriodStore((state) => state.customRange); const groupBy = usePeriodStore((state) => state.groupBy); + const branches = useBranchesStore((state) => state.branches); const trpc = useTRPC(); const { data: pieChart } = useSuspenseQuery( @@ -22,11 +24,13 @@ export const useSuspenseQueryProjectsLangChart = () => { start: customRange.start, end: customRange.end, name, + branches, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, name, + branches, }, ), ); @@ -39,12 +43,14 @@ export const useSuspenseQueryProjectsLangChart = () => { end: customRange.end, name, groupBy, + branches, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, name, groupBy, + branches, }, ), ); diff --git a/apps/dashboard/src/features/project-time-on-period-chart/components/project-time-on-period-chart.tsx b/apps/dashboard/src/features/project-time-on-period-chart/components/project-time-on-period-chart.tsx index ed381b23..1d431b81 100644 --- a/apps/dashboard/src/features/project-time-on-period-chart/components/project-time-on-period-chart.tsx +++ b/apps/dashboard/src/features/project-time-on-period-chart/components/project-time-on-period-chart.tsx @@ -18,6 +18,7 @@ import { Payload } from "recharts/types/component/DefaultTooltipContent"; import { CustomChartToolTip } from "@/components/common/custom-chart-tooltip"; import { chartConfig } from "@/constants"; import { projectLoader } from "@/loaders/project-loader"; +import { useBranchesStore } from "@/stores/branches/branches-store"; import { PERIODS_CONFIG } from "@/stores/period/constants"; import { usePeriodStore } from "@/stores/period/period-store"; import { formatTickForGroupBy } from "@/utils/format-tick-for-groupby"; @@ -57,6 +58,7 @@ export const ProjectTimeOnPeriodChart = () => { const period = usePeriodStore((state) => state.period); const groupBy = usePeriodStore((state) => state.groupBy); const customRange = usePeriodStore((state) => state.customRange); + const branches = useBranchesStore((state) => state.branches); const [isBarChartVisible, setIsBarChartVisible] = useState(true); const handleClick = () => setIsBarChartVisible((prev) => !prev); @@ -71,12 +73,14 @@ export const ProjectTimeOnPeriodChart = () => { end: customRange.end, name, groupBy, + branches, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, name, groupBy, + branches, }, ), ); diff --git a/apps/dashboard/src/features/project-title/hooks/use-get-time-spent-on-project.tsx b/apps/dashboard/src/features/project-title/hooks/use-get-time-spent-on-project.tsx index 3f840d8b..7fb7c4c5 100644 --- a/apps/dashboard/src/features/project-title/hooks/use-get-time-spent-on-project.tsx +++ b/apps/dashboard/src/features/project-title/hooks/use-get-time-spent-on-project.tsx @@ -1,6 +1,7 @@ import { useLoaderData } from "react-router"; import { projectLoader } from "@/loaders/project-loader"; +import { useBranchesStore } from "@/stores/branches/branches-store"; import { PERIODS_CONFIG } from "@/stores/period/constants"; import { usePeriodStore } from "@/stores/period/period-store"; import { useTRPC } from "@/utils/trpc"; @@ -13,6 +14,7 @@ export const useGetTimeSpentOnProject = () => { const period = usePeriodStore((state) => state.period); const customRange = usePeriodStore((state) => state.customRange); + const branches = useBranchesStore((state) => state.branches); const { data } = useSuspenseQuery( trpc.analytics.projects.getProjectOnPeriod.queryOptions( @@ -21,11 +23,13 @@ export const useGetTimeSpentOnProject = () => { start: customRange.start, end: customRange.end, name, + branches, } : { start: PERIODS_CONFIG[period].start, end: PERIODS_CONFIG[period].end, name, + branches, }, ), ); From d4cfb9d9238ff29ab19525d448a8bf737c3ee300 Mon Sep 17 00:00:00 2001 From: Friedrich482 Date: Mon, 13 Jul 2026 23:57:34 +0200 Subject: [PATCH 09/13] test: branchesService - added tests for the branchesService and updated the tests of the filesService --- .../api/src/branches/branches.service.test.ts | 125 ++++++++++++++++++ apps/api/src/files/files.service.test.ts | 38 +----- 2 files changed, 132 insertions(+), 31 deletions(-) create mode 100644 apps/api/src/branches/branches.service.test.ts diff --git a/apps/api/src/branches/branches.service.test.ts b/apps/api/src/branches/branches.service.test.ts new file mode 100644 index 00000000..96c32173 --- /dev/null +++ b/apps/api/src/branches/branches.service.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { MockedDrizzle } from "@/common/tests/types"; +import { DRIZZLE_ASYNC_PROVIDER } from "@/drizzle/constants"; +import { Test } from "@nestjs/testing"; + +import { BranchesService } from "./branches.service"; + +describe("branchesService", () => { + let branchesService: BranchesService; + + let mockedDrizzle: MockedDrizzle; + + beforeEach(async () => { + mockedDrizzle = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn(), + insert: vi.fn().mockReturnThis(), + values: vi.fn().mockReturnThis(), + returning: vi.fn(), + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + groupBy: vi.fn().mockReturnThis(), + offset: vi.fn().mockReturnThis(), + as: vi.fn(), + execute: vi.fn(), + }; + + vi.clearAllMocks(); + + const moduleRef = await Test.createTestingModule({ + providers: [ + BranchesService, + { + provide: DRIZZLE_ASYNC_PROVIDER, + useValue: mockedDrizzle, + }, + ], + }).compile(); + + branchesService = moduleRef.get(BranchesService); + }); + + describe("create", () => { + it("should return the created branch", async () => { + const mockedEntry = { + projectId: "1", + name: "main", + timeSpent: 800, + }; + + const mockedCreatedBranch = { + id: "2", + name: "main", + timeSpent: 800, + }; + + mockedDrizzle.returning.mockResolvedValue([mockedCreatedBranch]); + + const createdBranch = await branchesService.create(mockedEntry); + + expect(createdBranch).toBeDefined(); + expect(createdBranch).toEqual(mockedCreatedBranch); + }); + }); + + describe("findOne", () => { + const mockedEntry = { + name: "main", + projectId: "1", + }; + + it("should return the branch found if it exists", async () => { + const mockedBranchFound = { + id: "2", + name: "main", + timeSpent: 800, + }; + + mockedDrizzle.limit.mockResolvedValue([mockedBranchFound]); + + const branchFound = await branchesService.findOne(mockedEntry); + + expect(branchFound).toBeDefined(); + expect(branchFound).toEqual(mockedBranchFound); + }); + + it("should return null if the branch doesn't exists", async () => { + mockedDrizzle.limit.mockResolvedValue([]); + + const branchFound = await branchesService.findOne(mockedEntry); + + expect(branchFound).toBeDefined(); + expect(branchFound).toBeNull(); + }); + }); + + describe("update", () => { + it("should return the updated branch", async () => { + const mockedEntry = { + projectId: "1", + timeSpent: 800, + name: "main", + }; + + const mockedUpdatedBranch = { + name: "main", + timeSpent: 800, + projectId: "1", + }; + + mockedDrizzle.returning.mockResolvedValue([mockedUpdatedBranch]); + + const updatedBranch = await branchesService.update(mockedEntry); + + expect(updatedBranch).toBeDefined(); + expect(updatedBranch).toEqual(mockedUpdatedBranch); + }); + }); +}); diff --git a/apps/api/src/files/files.service.test.ts b/apps/api/src/files/files.service.test.ts index 019882ea..79e1e62f 100644 --- a/apps/api/src/files/files.service.test.ts +++ b/apps/api/src/files/files.service.test.ts @@ -50,7 +50,7 @@ describe("filesService", () => { it("should return the created file", async () => { const mockedEntry = { languageId: "1", - projectId: "2", + branchId: "2", name: "package.json", timeSpent: 300, path: "/home/user/projects/mooncode/apps/api/package.json", @@ -74,7 +74,7 @@ describe("filesService", () => { describe("findOne", () => { const mockedEntry = { languageId: "1", - projectId: "2", + branchId: "2", name: "package.json", path: "/home/user/projects/mooncode/apps/api/package.json", }; @@ -113,6 +113,7 @@ describe("filesService", () => { filePath: "/home/user/projects/mooncode/apps/api/package.json", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "branching", }, { @@ -122,6 +123,7 @@ describe("filesService", () => { filePath: "/home/user/projects/mooncode/apps/api/main.ts", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "branching", }, { @@ -131,42 +133,16 @@ describe("filesService", () => { filePath: "/home/user/projects/factory/Dockerfile", projectName: "factory", projectPath: "/home/user/projects/factory", + branchName: "main", }, ]; - const mockedFoundFilesObject = { - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 600, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 2000, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1200, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", - }, - }; - mockedDrizzle.orderBy.mockResolvedValue(mockedFilesFound); const filesFound = await filesService.findAllOnDay({ dailyDataId: "1" }); expect(filesFound).toBeDefined(); - expect(Object.keys(filesFound).length).toBeGreaterThan(0); - expect(filesFound).toEqual(mockedFoundFilesObject); + expect(filesFound).toEqual(mockedFilesFound); }); }); @@ -176,7 +152,7 @@ describe("filesService", () => { name: "main.ts", path: "/home/user/projects/mooncode/apps/api/main.ts", timeSpent: 800, - projectId: "1", + branchId: "1", languageId: "5", }; From e9234835a8e25d265ff59a2190deac711d54add7 Mon Sep 17 00:00:00 2001 From: Friedrich482 Date: Wed, 15 Jul 2026 18:07:19 +0200 Subject: [PATCH 10/13] test: fix and update tests - updated and fixed all broken tests as well as added tests for new methods in the projects analytics and extension modules (router and service) - updated the `vitest.config.ts` file to generate the tests coverage report even if there are failing tests --- .../routers/projects-analytics.router.test.ts | 49 + .../projects-analytics.service.test.ts | 207 +- .../src/extension/extension.router.test.ts | 11 + .../src/extension/extension.service.test.ts | 4330 +++++++++++++---- apps/api/vitest.config.ts | 1 + 5 files changed, 3526 insertions(+), 1072 deletions(-) diff --git a/apps/api/src/analytics/routers/projects-analytics.router.test.ts b/apps/api/src/analytics/routers/projects-analytics.router.test.ts index 503b50fc..bc492041 100644 --- a/apps/api/src/analytics/routers/projects-analytics.router.test.ts +++ b/apps/api/src/analytics/routers/projects-analytics.router.test.ts @@ -23,6 +23,7 @@ describe("ProjectsAnalyticsRouter", () => { checkProjectExists: Mock; getPeriodProjects: Mock; getProjectOnPeriod: Mock; + getProjectBranchesOnPeriod: Mock; getProjectPerDayOfPeriod: Mock; getProjectLanguagesTimeOnPeriod: Mock; getProjectLanguagesPerDayOfPeriod: Mock; @@ -66,6 +67,7 @@ describe("ProjectsAnalyticsRouter", () => { checkProjectExists: vi.fn(), getPeriodProjects: vi.fn(), getProjectOnPeriod: vi.fn(), + getProjectBranchesOnPeriod: vi.fn(), getProjectPerDayOfPeriod: vi.fn(), getProjectLanguagesTimeOnPeriod: vi.fn(), getProjectLanguagesPerDayOfPeriod: vi.fn(), @@ -235,6 +237,53 @@ describe("ProjectsAnalyticsRouter", () => { }); }); + describe("getProjectBranchesOnPeriod", () => { + const mockedEntry = { + start: "2026-06-17", + end: "2026-06-21", + name: "mooncode", + userId: mockedPayload.sub, + }; + + const mockedOutput = [ + { + name: "main", + timeSpent: 1200, + }, + { + name: "test", + timeSpent: 4500, + }, + ]; + + it("should call the getProjectBranchesOnPeriod method of the ProjectsAnalyticsService", async () => { + projectsAnalyticsService.getProjectBranchesOnPeriod.mockResolvedValue( + mockedOutput, + ); + + await caller.getProjectBranchesOnPeriod(mockedEntry); + + expect( + projectsAnalyticsService.getProjectBranchesOnPeriod, + ).toHaveBeenCalled(); + expect( + projectsAnalyticsService.getProjectBranchesOnPeriod, + ).toHaveBeenCalledWith(mockedEntry); + }); + + it("should return the data under the expected shape", async () => { + projectsAnalyticsService.getProjectBranchesOnPeriod.mockResolvedValue( + mockedOutput, + ); + + const projectBranchesOnPeriod = + await caller.getProjectBranchesOnPeriod(mockedEntry); + + expect(projectBranchesOnPeriod).toBeDefined(); + expect(projectBranchesOnPeriod).toEqual(mockedOutput); + }); + }); + describe("getProjectPerDayOfPeriod", () => { const mockedEntry = { start: "2026-06-17", diff --git a/apps/api/src/analytics/services/projects-analytics.service.test.ts b/apps/api/src/analytics/services/projects-analytics.service.test.ts index 467a70d3..5453acd8 100644 --- a/apps/api/src/analytics/services/projects-analytics.service.test.ts +++ b/apps/api/src/analytics/services/projects-analytics.service.test.ts @@ -9,6 +9,7 @@ import * as getProjectLanguagesGroupedByWeeksUtils from "@/analytics/utils/proje import * as getProjectPerDayOfPeriodGroupedByDaysUtils from "@/analytics/utils/projects/get-project-per-day-of-period-grouped-by-days"; import * as getProjectPerDayOfPeriodGroupedByMonthsUtils from "@/analytics/utils/projects/get-project-per-day-of-period-grouped-by-months"; import * as getProjectPerDayOfPeriodGroupedByWeeksUtils from "@/analytics/utils/projects/get-project-per-day-of-period-grouped-by-weeks"; +import { BranchesService } from "@/branches/branches.service"; import { MockedDrizzle } from "@/common/tests/types"; import { DailyDataService } from "@/daily-data/daily-data.service"; import { DRIZZLE_ASYNC_PROVIDER } from "@/drizzle/constants"; @@ -33,6 +34,12 @@ describe("ProjectsAnalyticsService", () => { findOne: Mock; }; + let branchesService: { + create: Mock; + findOne: Mock; + update: Mock; + }; + let mockedDrizzle: MockedDrizzle; beforeEach(async () => { @@ -50,6 +57,12 @@ describe("ProjectsAnalyticsService", () => { findOne: vi.fn(), }; + branchesService = { + create: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + }; + mockedDrizzle = { select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), @@ -74,6 +87,7 @@ describe("ProjectsAnalyticsService", () => { ProjectsAnalyticsService, { provide: DailyDataService, useValue: dailyDataService }, { provide: ProjectsService, useValue: projectsService }, + { provide: BranchesService, useValue: branchesService }, { provide: DRIZZLE_ASYNC_PROVIDER, useValue: mockedDrizzle, @@ -88,6 +102,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", }; @@ -177,6 +192,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", }; @@ -225,6 +241,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", }; @@ -454,6 +471,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", }; @@ -488,6 +506,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", }; @@ -518,6 +537,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", }; @@ -536,6 +556,36 @@ describe("ProjectsAnalyticsService", () => { }); }); + describe("getProjectBranchesOnPeriod", () => { + it("should return an array of branches with their name and the time spent on each of them on the specified period", async () => { + const mockedEntry = { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + }; + + const mockedOutput = [ + { + name: "main", + timeSpent: 1200, + }, + { + name: "test", + timeSpent: 4500, + }, + ]; + + mockedDrizzle.orderBy.mockResolvedValue(mockedOutput); + + const projectBranchesOnPeriod = + await projectsAnalyticsService.getProjectBranchesOnPeriod(mockedEntry); + + expect(projectBranchesOnPeriod).toBeDefined(); + expect(projectBranchesOnPeriod).toEqual(mockedOutput); + }); + }); + describe("getProjectPerDayOfPeriod", () => { const mockedTimeSpentPerDayOnProject = [ { @@ -566,6 +616,7 @@ describe("ProjectsAnalyticsService", () => { name: "mooncode", start: "2026-06-17", end: "2026-06-21", + branches: ["main", "test"], groupBy: "days" as const, periodResolution: "day" as const, }; @@ -586,6 +637,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", groupBy: "days" as const, @@ -614,6 +666,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", periodResolution: "day" as const, @@ -641,6 +694,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { start: "2026-06-12", end: "2026-06-21", + branches: ["main", "test"], groupBy: "weeks" as const, periodResolution: "week" as const, userId: "1", @@ -723,6 +777,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-05-12", end: "2026-06-21", groupBy: "months" as const, @@ -805,6 +860,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", }; @@ -1013,6 +1069,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", groupBy: "days" as const, @@ -1037,6 +1094,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", groupBy: "days" as const, @@ -1120,6 +1178,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", periodResolution: "day" as const, @@ -1202,6 +1261,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-12", end: "2026-06-21", groupBy: "weeks" as const, @@ -1265,6 +1325,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-05-12", end: "2026-06-21", groupBy: "months" as const, @@ -1390,10 +1451,145 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { dateString: "2026-06-26", name: "mooncode", + branches: ["main", "test"], userId: "1", }; - it("should return an object containing the expected fields: formattedTotalTimeSpent and finalData", async () => { + it("should return an object containing the expected fields: formattedTotalTimeSpent and finalData when there the 'branches' parameter is specified", async () => { + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 8500, + }); + + mockedDrizzle.where.mockResolvedValue([ + { + timeSpent: 1200, + languageSlug: "docker", + projectPath: "/home/user/projects/mooncode/api/Dockerfile", + }, + { + timeSpent: 900, + languageSlug: "typescript", + projectPath: + "/home/user/projects/mooncode/api/src/auth/auth.service.ts", + }, + { + timeSpent: 800, + languageSlug: "typescript", + projectPath: + "/home/user/projects/mooncode/api/src/auth/auth.service.test.ts", + }, + { + timeSpent: 750, + languageSlug: "css", + projectPath: "/home/user/projects/mooncode/dashboard/src/index.css", + }, + { + timeSpent: 700, + languageSlug: "typescript", + projectPath: "/home/user/mooncode/api/src/app.module.ts", + }, + { + timeSpent: 650, + languageSlug: "typescript", + projectPath: + "/home/user/projects/mooncode/api/src/files/files.module.ts", + }, + { + timeSpent: 600, + languageSlug: "json", + projectPath: "/home/user/mooncode/vscode-extension/package.json", + }, + { + timeSpent: 550, + languageSlug: "yaml", + projectPath: + "/home/user/mooncode/.github/workflows/build-and-deploy.yaml", + }, + { + timeSpent: 500, + languageSlug: "typescript", + projectPath: "/home/user/mooncode/api/src/main.ts", + }, + { + timeSpent: 450, + languageSlug: "sql", + projectPath: "/home/user/mooncode/api/drizzle/0000_whole_glorian.sql", + }, + { + timeSpent: 400, + languageSlug: "json", + projectPath: "/home/user/mooncode/dashboard/package.json", + }, + ]); + + branchesService.findOne + .mockResolvedValueOnce({ id: "3", name: "main", timeSpent: 4500 }) + .mockResolvedValueOnce({ id: "4", name: "test", timeSpent: 4000 }); + + projectsService.findOne.mockResolvedValue({ + id: "5", + name: "mooncode", + path: "/home/user/mooncode", + timeSpent: 8500, + }); + + const mockedLanguagesStats = [ + { + formattedValue: "59 mins", + languageSlug: "typescript", + percentage: 41.76, + timeSpent: 3550, + }, + { + formattedValue: "20 mins", + languageSlug: "docker", + percentage: 14.12, + timeSpent: 1200, + }, + { + formattedValue: "16 mins", + languageSlug: "json", + percentage: 11.76, + timeSpent: 1000, + }, + { + formattedValue: "12 mins", + languageSlug: "css", + percentage: 8.82, + timeSpent: 750, + }, + { + formattedValue: "9 mins", + languageSlug: "yaml", + percentage: 6.47, + timeSpent: 550, + }, + { + formattedValue: "7 mins", + languageSlug: "sql", + percentage: 5.29, + timeSpent: 450, + }, + ]; + + const { finalData, formattedTotalTimeSpent } = + await projectsAnalyticsService.getProjectDailyStats(mockedEntry); + + expect(formattedTotalTimeSpent).toBeDefined(); + expect(formattedTotalTimeSpent).toEqual("2 hrs 21 mins"); + + expect(finalData).toBeDefined(); + expect(finalData).toEqual(mockedLanguagesStats); + }); + + it("should return an object containing the expected fields: formattedTotalTimeSpent and finalData when there the 'branches' parameter is not specified", async () => { + const mockedEntry = { + dateString: "2026-06-26", + name: "mooncode", + userId: "1", + }; + dailyDataService.findOne.mockResolvedValue({ id: "2", timeSpent: 8500, @@ -1655,6 +1851,7 @@ describe("ProjectsAnalyticsService", () => { periodResolution: "day" as const, userId: "1", name: "mooncode", + branches: ["main", "test"], }; vi.spyOn( @@ -1687,6 +1884,7 @@ describe("ProjectsAnalyticsService", () => { periodResolution: "day" as const, userId: "1", name: "mooncode", + branches: ["main", "test"], }; const mockedTimeSpentPerDayOnProject = [ @@ -1769,6 +1967,7 @@ describe("ProjectsAnalyticsService", () => { periodResolution: "day" as const, userId: "1", name: "mooncode", + branches: ["main", "test"], }; const mockedTimeSpentPerDayOnProject = [ @@ -1852,6 +2051,7 @@ describe("ProjectsAnalyticsService", () => { periodResolution: "week" as const, userId: "1", name: "mooncode", + branches: ["main", "test"], }; const mockedTimeSpentPerDayOnProject = [ @@ -1929,6 +2129,7 @@ describe("ProjectsAnalyticsService", () => { periodResolution: "month" as const, userId: "1", name: "mooncode", + branches: ["main", "test"], }; const mockedTimeSpentPerDayOnProject = [ @@ -2039,6 +2240,7 @@ describe("ProjectsAnalyticsService", () => { end: "2026-06-21", type: "normal" as const, amount: 25, + branches: ["main", "test"], }; mockedDrizzle.execute.mockResolvedValue([ @@ -2372,6 +2574,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", type: "paginated" as const, @@ -2721,6 +2924,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", type: "paginated" as const, @@ -2951,6 +3155,7 @@ describe("ProjectsAnalyticsService", () => { const mockedEntry = { userId: "1", name: "mooncode", + branches: ["main", "test"], start: "2026-06-17", end: "2026-06-21", type: "paginated" as const, diff --git a/apps/api/src/extension/extension.router.test.ts b/apps/api/src/extension/extension.router.test.ts index 4d8b38c0..edf9a7e3 100644 --- a/apps/api/src/extension/extension.router.test.ts +++ b/apps/api/src/extension/extension.router.test.ts @@ -133,6 +133,7 @@ describe("ExtensionRouter", () => { const mockedEntry = { dateString: "2026-06-22", userId: mockedPayload.sub, + type: "old" as const, }; const mockedOutput = { @@ -142,6 +143,7 @@ describe("ExtensionRouter", () => { fileName: "package.json", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "main", }, "/home/user/projects/mooncode/apps/api/main.ts": { languageSlug: "typescript", @@ -149,6 +151,7 @@ describe("ExtensionRouter", () => { fileName: "main.ts", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "test", }, "/home/user/projects/factory/Dockerfile": { languageSlug: "docker", @@ -156,6 +159,7 @@ describe("ExtensionRouter", () => { fileName: "Dockerfile", projectName: "factory", projectPath: "/home/user/projects/factory", + branchName: "main", }, }; @@ -256,6 +260,7 @@ describe("ExtensionRouter", () => { fileName: "package.json", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "main", }, "/home/user/projects/mooncode/apps/api/main.ts": { languageSlug: "typescript", @@ -263,6 +268,7 @@ describe("ExtensionRouter", () => { fileName: "main.ts", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "test", }, "/home/user/projects/factory/Dockerfile": { languageSlug: "docker", @@ -270,10 +276,12 @@ describe("ExtensionRouter", () => { fileName: "Dockerfile", projectName: "factory", projectPath: "/home/user/projects/factory", + branchName: "main", }, }, targetedDate: "2026-06-22", userId: mockedPayload.sub, + type: "old" as const, }; const mockedOutput = { @@ -283,6 +291,7 @@ describe("ExtensionRouter", () => { fileName: "package.json", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "main", }, "/home/user/projects/mooncode/apps/api/main.ts": { languageSlug: "typescript", @@ -290,6 +299,7 @@ describe("ExtensionRouter", () => { fileName: "main.ts", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "test", }, "/home/user/projects/factory/Dockerfile": { languageSlug: "docker", @@ -297,6 +307,7 @@ describe("ExtensionRouter", () => { fileName: "Dockerfile", projectName: "factory", projectPath: "/home/user/projects/factory", + branchName: "main", }, }; diff --git a/apps/api/src/extension/extension.service.test.ts b/apps/api/src/extension/extension.service.test.ts index 9f19e331..ce3ef0fa 100644 --- a/apps/api/src/extension/extension.service.test.ts +++ b/apps/api/src/extension/extension.service.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { BranchesService } from "@/branches/branches.service"; import { DailyDataService } from "@/daily-data/daily-data.service"; import { FilesService } from "@/files/files.service"; import { LanguagesService } from "@/languages/languages.service"; @@ -31,6 +32,12 @@ describe("ExtensionService", () => { create: Mock; }; + let branchesService: { + create: Mock; + findOne: Mock; + update: Mock; + }; + let filesService: { findAllOnDay: Mock; findOne: Mock; @@ -60,6 +67,12 @@ describe("ExtensionService", () => { create: vi.fn(), }; + branchesService = { + create: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + }; + filesService = { findAllOnDay: vi.fn(), findOne: vi.fn(), @@ -73,6 +86,7 @@ describe("ExtensionService", () => { { provide: DailyDataService, useValue: dailyDataService }, { provide: LanguagesService, useValue: languagesService }, { provide: ProjectsService, useValue: projectsService }, + { provide: BranchesService, useValue: branchesService }, { provide: FilesService, useValue: filesService }, ], }).compile(); @@ -143,58 +157,149 @@ describe("ExtensionService", () => { }); describe("getFilesForDay", () => { - const mockedEntry = { - userId: "1", - dateString: "2026-06-20", - }; - - it("should return data about each file on the provided day", async () => { - const mockedTimeSpent = 3800; - const mockedFilesData = { - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 600, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", + it.for([ + { + mockedEntry: { + userId: "1", + dateString: "2026-06-20", + type: "old" as const, }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 2000, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", + mockedOutput: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "test", + }, }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1200, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", + }, + { + mockedEntry: { + userId: "1", + dateString: "2026-06-20", + type: "new" as const, }, - }; - - dailyDataService.findOne.mockResolvedValue({ - id: "2", - timeSpent: mockedTimeSpent, - }); - - filesService.findAllOnDay.mockResolvedValue(mockedFilesData); + mockedOutput: { + "/home/user/projects/factory": { + test: { + "/home/user/projects/factory/Dockerfile": { + fileName: "Dockerfile", + languageSlug: "docker", + projectName: "factory", + timeSpent: 1200, + }, + }, + }, + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/main.ts": { + fileName: "main.ts", + languageSlug: "typescript", + projectName: "mooncode", + timeSpent: 2000, + }, + "/home/user/projects/mooncode/apps/api/package.json": { + fileName: "package.json", + languageSlug: "json", + projectName: "mooncode", + timeSpent: 600, + }, + }, + }, + }, + }, + ])( + "should return an object containing the data about each file on the provided day", + async ({ mockedEntry, mockedOutput }) => { + const mockedTimeSpent = 3800; + const mockedFilesOnDay = [ + { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + filePath: "/home/user/projects/mooncode/apps/api/package.json", + branchName: "main", + }, + { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + filePath: "/home/user/projects/mooncode/apps/api/main.ts", + branchName: "main", + }, + { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + filePath: "/home/user/projects/factory/Dockerfile", + branchName: "test", + }, + ]; - const filesData = await extensionService.getFilesForDay(mockedEntry); + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: mockedTimeSpent, + }); - expect(filesData).toBeDefined(); - expect(filesData).toEqual(mockedFilesData); - }); + filesService.findAllOnDay.mockResolvedValue(mockedFilesOnDay); - it("should return an empty state if there is no files data on the provided day", async () => { - dailyDataService.findOne.mockResolvedValue(null); + const filesData = await extensionService.getFilesForDay(mockedEntry); - const filesData = await extensionService.getFilesForDay(mockedEntry); + expect(filesData).toBeDefined(); + expect(filesData).toEqual(mockedOutput); + }, + ); - expect(filesData).toBeDefined(); - expect(filesData).toEqual({}); - }); + it.for([ + { + mockedEntry: { + userId: "1", + dateString: "2026-06-20", + type: "old" as const, + }, + }, + { + mockedEntry: { + userId: "1", + dateString: "2026-06-20", + type: "new" as const, + }, + }, + ])( + "should return an empty state if there is no files data on the provided day", + async ({ mockedEntry }) => { + dailyDataService.findOne.mockResolvedValue(null); + + const filesData = await extensionService.getFilesForDay(mockedEntry); + + expect(filesData).toBeDefined(); + expect(filesData).toEqual({}); + }, + ); }); describe("upsertLanguages", () => { @@ -761,35 +866,92 @@ describe("ExtensionService", () => { }); describe("upsertFiles", () => { - it("should return the updated files", async () => { - const mockedEntry = { - userId: "1", - filesData: { - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 600, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 2000, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1200, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, }, + targetedDate: "2026-06-21", + type: "old" as const, }, - targetedDate: "2026-06-21", - }; - + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, + }, + }, + ])("should return the updated files", async ({ mockedEntry }) => { dailyDataService.findOne.mockResolvedValue({ id: "2", timeSpent: 3000, @@ -823,45 +985,85 @@ describe("ExtensionService", () => { dailyDataId: "2", }); - filesService.findAllOnDay.mockResolvedValue({ - "/home/user/projects/mooncode/apps/api/package.json": { + branchesService.findOne + .mockResolvedValueOnce({ + id: "5", + name: "main", + timeSpent: 200, + }) + .mockResolvedValueOnce({ + id: "6", + name: "test", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + id: "7", + name: "main", + timeSpent: 1000, + }); + + branchesService.update + .mockResolvedValueOnce({ + projectId: "3", + name: "main", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + projectId: "3", + name: "test", + timeSpent: 2000, + }) + .mockResolvedValueOnce({ + projectId: "4", + name: "main", + timeSpent: 1200, + }); + + filesService.findAllOnDay.mockResolvedValue([ + { languageSlug: "json", timeSpent: 200, fileName: "package.json", + filePath: "/home/user/projects/mooncode/apps/api/package.json", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "main", }, - "/home/user/projects/mooncode/apps/api/main.ts": { + { languageSlug: "typescript", timeSpent: 1800, fileName: "main.ts", + filePath: "/home/user/projects/mooncode/apps/api/main.ts", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "test", }, - "/home/user/projects/factory/Dockerfile": { + { languageSlug: "docker", timeSpent: 1000, fileName: "Dockerfile", + filePath: "/home/user/projects/factory/Dockerfile", projectName: "factory", projectPath: "/home/user/projects/factory", + branchName: "main", }, - }); + ]); languagesService.findOne .mockResolvedValueOnce({ timeSpent: 200, languageSlug: "json", - languageId: "5", + languageId: "11", }) .mockResolvedValueOnce({ timeSpent: 1800, languageSlug: "typescript", - languageId: "6", + languageId: "12", }) .mockResolvedValue({ timeSpent: 1000, languageSlug: "docker", - languageId: "7", + languageId: "13", }); filesService.findOne @@ -904,447 +1106,837 @@ describe("ExtensionService", () => { expect(filesData).toEqual(mockedEntry.filesData); }); - it("should return an empty object if the data for the day doesn't exists (impossible state)", async () => { - const mockedEntry = { - userId: "1", - filesData: { - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 600, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 2000, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1200, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", + targetedDate: "2026-06-21", + type: "old" as const, + }, + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, + }, + }, + ])( + "should return an empty object if the data for the day doesn't exists (impossible state)", + async ({ mockedEntry }) => { + dailyDataService.findOne.mockResolvedValue(null); + + const filesData = await extensionService.upsertFiles(mockedEntry); + + expect(filesData).toBeDefined(); + expect(filesData).toEqual({}); + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, }, + targetedDate: "2026-06-21", + type: "old" as const, }, - targetedDate: "2026-06-21", - }; + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, + }, + }, + ])( + "should create the project for the day if it doesn't exist", + async ({ mockedEntry }) => { + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 3000, + }); - dailyDataService.findOne.mockResolvedValue(null); + projectsService.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + projectsService.create + .mockResolvedValueOnce({ + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2600, + dailyDataId: "3", + }) + .mockResolvedValueOnce({ + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1200, + dailyDataId: "4", + }); + + branchesService.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + branchesService.create + .mockResolvedValueOnce({ + id: "5", + name: "main", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + id: "6", + name: "test", + timeSpent: 2000, + }) + .mockResolvedValueOnce({ + id: "7", + name: "main", + timeSpent: 1200, + }); - const filesData = await extensionService.upsertFiles(mockedEntry); + filesService.findAllOnDay.mockResolvedValue([]); - expect(filesData).toBeDefined(); - expect(filesData).toEqual({}); - }); + languagesService.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue(null); - it("should create the project for the day if it doesn't exist", async () => { - const mockedEntry = { - userId: "1", - filesData: { - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", + filesService.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + filesService.create + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", timeSpent: 600, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", timeSpent: 2000, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", timeSpent: 1200, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", - }, - }, - targetedDate: "2026-06-21", - }; + }); - dailyDataService.findOne.mockResolvedValue({ - id: "2", - timeSpent: 3000, - }); + const filesData = await extensionService.upsertFiles(mockedEntry); - projectsService.findOne - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null); + expect(filesData).toBeDefined(); + expect(filesData).toEqual({}); - projectsService.create - .mockResolvedValueOnce({ + expect(projectsService.create).toHaveBeenCalled(); + expect(projectsService.create).toHaveBeenNthCalledWith(1, { + dailyDataId: "2", name: "mooncode", path: "/home/user/projects/mooncode", timeSpent: 2600, + }); + expect(projectsService.create).toHaveBeenNthCalledWith(2, { dailyDataId: "2", - }) - .mockResolvedValueOnce({ name: "factory", path: "/home/user/projects/factory", timeSpent: 1200, - dailyDataId: "2", }); - - filesService.findAllOnDay.mockResolvedValue({}); - - languagesService.findOne - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValue(null); - - filesService.findOne - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null); - - filesService.create - .mockResolvedValueOnce({ - name: "package.json", - path: "/home/user/projects/mooncode/apps/api/package.json", - timeSpent: 600, - }) - .mockResolvedValueOnce({ - name: "main.ts", - path: "/home/user/projects/mooncode/apps/api/main.ts", - timeSpent: 2000, - }) - .mockResolvedValueOnce({ - name: "Dockerfile", - path: "/home/user/projects/factory/Dockerfile", - timeSpent: 1200, + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + }, + targetedDate: "2026-06-21", + type: "old" as const, + }, + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, + }, + }, + ])( + "should update the project data for the day if it exists and the new time spent is greater than the existing one", + async ({ mockedEntry }) => { + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 3000, }); - const filesData = await extensionService.upsertFiles(mockedEntry); - - expect(filesData).toBeDefined(); - expect(filesData).toEqual({}); + projectsService.findOne + .mockResolvedValueOnce({ + id: "3", + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2000, + }) + .mockResolvedValueOnce({ + id: "4", + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1000, + }); + + projectsService.update + .mockResolvedValueOnce({ + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2600, + dailyDataId: "2", + }) + .mockResolvedValueOnce({ + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1200, + dailyDataId: "2", + }); + + branchesService.findOne + .mockResolvedValueOnce({ + id: "5", + name: "main", + timeSpent: 200, + }) + .mockResolvedValueOnce({ + id: "6", + name: "test", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + id: "7", + name: "main", + timeSpent: 1000, + }); - expect(projectsService.create).toHaveBeenCalled(); - expect(projectsService.create).toHaveBeenNthCalledWith(1, { - dailyDataId: "2", - name: "mooncode", - path: "/home/user/projects/mooncode", - timeSpent: 2600, - }); - expect(projectsService.create).toHaveBeenNthCalledWith(2, { - dailyDataId: "2", - name: "factory", - path: "/home/user/projects/factory", - timeSpent: 1200, - }); - }); + branchesService.update + .mockResolvedValueOnce({ + projectId: "3", + name: "main", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + projectId: "3", + name: "test", + timeSpent: 2000, + }) + .mockResolvedValueOnce({ + projectId: "4", + name: "main", + timeSpent: 1200, + }); - it("should update the project data for the day if it exists and the new time spent is greater than the existing one", async () => { - const mockedEntry = { - userId: "1", - filesData: { - "/home/user/projects/mooncode/apps/api/package.json": { + filesService.findAllOnDay.mockResolvedValue([ + { languageSlug: "json", - timeSpent: 600, + timeSpent: 200, fileName: "package.json", + filePath: "/home/user/projects/mooncode/apps/api/package.json", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "main", }, - "/home/user/projects/mooncode/apps/api/main.ts": { + { languageSlug: "typescript", - timeSpent: 2000, + timeSpent: 1800, fileName: "main.ts", + filePath: "/home/user/projects/mooncode/apps/api/main.ts", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "test", }, - "/home/user/projects/factory/Dockerfile": { + { languageSlug: "docker", - timeSpent: 1200, + timeSpent: 1000, fileName: "Dockerfile", + filePath: "/home/user/projects/factory/Dockerfile", projectName: "factory", projectPath: "/home/user/projects/factory", + branchName: "main", }, - }, - targetedDate: "2026-06-21", - }; + ]); - dailyDataService.findOne.mockResolvedValue({ - id: "2", - timeSpent: 3000, - }); + languagesService.findOne + .mockResolvedValueOnce({ + timeSpent: 200, + languageSlug: "json", + languageId: "5", + }) + .mockResolvedValueOnce({ + timeSpent: 1800, + languageSlug: "typescript", + languageId: "6", + }) + .mockResolvedValue({ + timeSpent: 1000, + languageSlug: "docker", + languageId: "7", + }); + + filesService.findOne + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 200, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1000, + }); - projectsService.findOne - .mockResolvedValueOnce({ - id: "3", - name: "mooncode", - path: "/home/user/projects/mooncode", - timeSpent: 2000, - }) - .mockResolvedValueOnce({ - id: "4", - name: "factory", - path: "/home/user/projects/factory", - timeSpent: 1000, - }); + filesService.update + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 2000, + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1200, + }); - projectsService.update - .mockResolvedValueOnce({ + const filesData = await extensionService.upsertFiles(mockedEntry); + + expect(filesData).toBeDefined(); + expect(filesData).toEqual(mockedEntry.filesData); + + expect(projectsService.update).toHaveBeenCalled(); + expect(projectsService.update).toHaveBeenNthCalledWith(1, { + dailyDataId: "2", name: "mooncode", path: "/home/user/projects/mooncode", timeSpent: 2600, + }); + expect(projectsService.update).toHaveBeenNthCalledWith(2, { dailyDataId: "2", - }) - .mockResolvedValueOnce({ name: "factory", path: "/home/user/projects/factory", timeSpent: 1200, - dailyDataId: "2", }); - - filesService.findAllOnDay.mockResolvedValue({ - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 200, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 1800, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1000, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 400, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 1000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 800, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + }, + targetedDate: "2026-06-21", + type: "old" as const, }, - }); - - languagesService.findOne - .mockResolvedValueOnce({ - timeSpent: 200, - languageSlug: "json", - languageId: "5", - }) - .mockResolvedValueOnce({ - timeSpent: 1800, - languageSlug: "typescript", - languageId: "6", - }) - .mockResolvedValue({ - timeSpent: 1000, - languageSlug: "docker", - languageId: "7", - }); - - filesService.findOne - .mockResolvedValueOnce({ - name: "package.json", - path: "/home/user/projects/mooncode/apps/api/package.json", - timeSpent: 200, - }) - .mockResolvedValueOnce({ - name: "main.ts", - path: "/home/user/projects/mooncode/apps/api/main.ts", - timeSpent: 1800, - }) - .mockResolvedValueOnce({ - name: "Dockerfile", - path: "/home/user/projects/factory/Dockerfile", - timeSpent: 1000, - }); - - filesService.update - .mockResolvedValueOnce({ - name: "package.json", - path: "/home/user/projects/mooncode/apps/api/package.json", - timeSpent: 600, - }) - .mockResolvedValueOnce({ - name: "main.ts", - path: "/home/user/projects/mooncode/apps/api/main.ts", - timeSpent: 2000, - }) - .mockResolvedValueOnce({ - name: "Dockerfile", - path: "/home/user/projects/factory/Dockerfile", - timeSpent: 1200, - }); - - const filesData = await extensionService.upsertFiles(mockedEntry); - - expect(filesData).toBeDefined(); - expect(filesData).toEqual(mockedEntry.filesData); - - expect(projectsService.update).toHaveBeenCalled(); - expect(projectsService.update).toHaveBeenNthCalledWith(1, { - dailyDataId: "2", - name: "mooncode", - path: "/home/user/projects/mooncode", - timeSpent: 2600, - }); - expect(projectsService.update).toHaveBeenNthCalledWith(2, { - dailyDataId: "2", - name: "factory", - path: "/home/user/projects/factory", - timeSpent: 1200, - }); - }); - - it("should NOT update the project data for the day if it exists and the new time spent is less than the existing one", async () => { - const mockedEntry = { - userId: "1", - filesData: { + mockedOutput: { "/home/user/projects/mooncode/apps/api/package.json": { languageSlug: "json", - timeSpent: 400, + timeSpent: 500, fileName: "package.json", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "main", }, "/home/user/projects/mooncode/apps/api/main.ts": { languageSlug: "typescript", - timeSpent: 1000, + timeSpent: 1800, fileName: "main.ts", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "test", }, "/home/user/projects/factory/Dockerfile": { languageSlug: "docker", - timeSpent: 800, + timeSpent: 1000, fileName: "Dockerfile", projectName: "factory", projectPath: "/home/user/projects/factory", + branchName: "main", }, }, - targetedDate: "2026-06-21", - }; - - dailyDataService.findOne.mockResolvedValue({ - id: "2", - timeSpent: 3300, - }); - - projectsService.findOne - .mockResolvedValueOnce({ - id: "3", - name: "mooncode", - path: "/home/user/projects/mooncode", - timeSpent: 2300, - }) - .mockResolvedValueOnce({ - id: "4", - name: "factory", - path: "/home/user/projects/factory", - timeSpent: 1000, - }); - - filesService.findAllOnDay.mockResolvedValue({ - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 500, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 1800, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 400, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 1000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 800, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1000, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", + mockedOutput: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 500, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 1800, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, }, - }); - - languagesService.findOne - .mockResolvedValueOnce({ - timeSpent: 500, - languageSlug: "json", - languageId: "5", - }) - .mockResolvedValueOnce({ - timeSpent: 1800, - languageSlug: "typescript", - languageId: "6", - }) - .mockResolvedValue({ - timeSpent: 1000, - languageSlug: "docker", - languageId: "7", + }, + ])( + "should NOT update the project data for the day if it exists and the new time spent is less than the existing one", + async ({ mockedEntry, mockedOutput }) => { + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 3300, }); - filesService.findOne - .mockResolvedValueOnce({ - name: "package.json", - path: "/home/user/projects/mooncode/apps/api/package.json", - timeSpent: 500, - }) - .mockResolvedValueOnce({ - name: "main.ts", - path: "/home/user/projects/mooncode/apps/api/main.ts", - timeSpent: 1800, - }) - .mockResolvedValueOnce({ - name: "Dockerfile", - path: "/home/user/projects/factory/Dockerfile", - timeSpent: 1000, - }); + projectsService.findOne + .mockResolvedValueOnce({ + id: "3", + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2300, + }) + .mockResolvedValueOnce({ + id: "4", + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1000, + }); + + branchesService.findOne + .mockResolvedValueOnce({ + id: "3", + name: "main", + timeSpent: 500, + }) + .mockResolvedValueOnce({ + id: "4", + name: "test", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + id: "5", + name: "main", + timeSpent: 1000, + }); - const filesData = await extensionService.upsertFiles(mockedEntry); + filesService.findAllOnDay.mockResolvedValue([ + { + languageSlug: "json", + timeSpent: 500, + fileName: "package.json", + filePath: "/home/user/projects/mooncode/apps/api/package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + { + languageSlug: "typescript", + timeSpent: 1800, + fileName: "main.ts", + filePath: "/home/user/projects/mooncode/apps/api/main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + filePath: "/home/user/projects/factory/Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + ]); - expect(filesData).toBeDefined(); - expect(filesData).toEqual({ - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 500, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 1800, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1000, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", + languagesService.findOne + .mockResolvedValueOnce({ + timeSpent: 500, + languageSlug: "json", + languageId: "5", + }) + .mockResolvedValueOnce({ + timeSpent: 1800, + languageSlug: "typescript", + languageId: "6", + }) + .mockResolvedValue({ + timeSpent: 1000, + languageSlug: "docker", + languageId: "7", + }); + + filesService.findOne + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 500, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1000, + }); + + const filesData = await extensionService.upsertFiles(mockedEntry); + + expect(filesData).toBeDefined(); + expect(filesData).toEqual(mockedOutput); + + expect(projectsService.update).not.toHaveBeenCalled(); + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 800, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + }, + targetedDate: "2026-06-21", + type: "old" as const, }, - }); - - expect(projectsService.update).not.toHaveBeenCalled(); - }); - - it("should ONLY update projects where the new time spent is greater than the existing one", async () => { - const mockedEntry = { - userId: "1", - filesData: { + mockedOutput: { "/home/user/projects/mooncode/apps/api/package.json": { languageSlug: "json", timeSpent: 600, fileName: "package.json", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "main", }, "/home/user/projects/mooncode/apps/api/main.ts": { languageSlug: "typescript", @@ -1352,640 +1944,2138 @@ describe("ExtensionService", () => { fileName: "main.ts", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "test", }, "/home/user/projects/factory/Dockerfile": { languageSlug: "docker", - timeSpent: 800, + timeSpent: 1000, fileName: "Dockerfile", projectName: "factory", projectPath: "/home/user/projects/factory", + branchName: "main", }, }, - targetedDate: "2026-06-21", - }; + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 800, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, + }, + mockedOutput: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + }, + }, + ])( + "should ONLY update projects where the new time spent is greater than the existing one", + async ({ mockedEntry, mockedOutput }) => { + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 3300, + }); - dailyDataService.findOne.mockResolvedValue({ - id: "2", - timeSpent: 3300, - }); + projectsService.findOne + .mockResolvedValueOnce({ + id: "3", + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2300, + }) + .mockResolvedValueOnce({ + id: "4", + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1000, + }); - projectsService.findOne - .mockResolvedValueOnce({ - id: "3", + projectsService.update.mockResolvedValueOnce({ name: "mooncode", path: "/home/user/projects/mooncode", - timeSpent: 2300, - }) - .mockResolvedValueOnce({ - id: "4", - name: "factory", - path: "/home/user/projects/factory", - timeSpent: 1000, + timeSpent: 2600, + dailyDataId: "2", }); - projectsService.update.mockResolvedValueOnce({ - name: "mooncode", - path: "/home/user/projects/mooncode", - timeSpent: 2600, - dailyDataId: "2", - }); - - filesService.findAllOnDay.mockResolvedValue({ - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 500, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 1800, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1000, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", - }, - }); - - languagesService.findOne - .mockResolvedValueOnce({ - timeSpent: 500, - languageSlug: "json", - languageId: "5", - }) - .mockResolvedValueOnce({ - timeSpent: 1800, - languageSlug: "typescript", - languageId: "6", - }) - .mockResolvedValue({ - timeSpent: 1000, - languageSlug: "docker", - languageId: "7", - }); - - filesService.findOne - .mockResolvedValueOnce({ - name: "package.json", - path: "/home/user/projects/mooncode/apps/api/package.json", - timeSpent: 500, - }) - .mockResolvedValueOnce({ - name: "main.ts", - path: "/home/user/projects/mooncode/apps/api/main.ts", - timeSpent: 1800, - }) - .mockResolvedValueOnce({ - name: "Dockerfile", - path: "/home/user/projects/factory/Dockerfile", - timeSpent: 1000, - }); - - filesService.update - .mockResolvedValueOnce({ - name: "package.json", - path: "/home/user/projects/mooncode/apps/api/package.json", - timeSpent: 600, - }) - .mockResolvedValueOnce({ - name: "main.ts", - path: "/home/user/projects/mooncode/apps/api/main.ts", - timeSpent: 2000, - }); - - const filesData = await extensionService.upsertFiles(mockedEntry); - - expect(filesData).toBeDefined(); - expect(filesData).toEqual({ - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 600, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 2000, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1000, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", - }, - }); + branchesService.findOne + .mockResolvedValueOnce({ + id: "5", + name: "main", + timeSpent: 500, + }) + .mockResolvedValueOnce({ + id: "6", + name: "test", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + id: "7", + name: "main", + timeSpent: 1000, + }); - expect(projectsService.update).toHaveBeenCalledOnce(); - expect(projectsService.update).toHaveBeenNthCalledWith(1, { - dailyDataId: "2", - name: "mooncode", - path: "/home/user/projects/mooncode", - timeSpent: 2600, - }); - }); + branchesService.update + .mockResolvedValueOnce({ + projectId: "3", + name: "main", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + projectId: "3", + name: "test", + timeSpent: 2000, + }); - it("should create the file for the day if it doesn't exist", async () => { - const mockedEntry = { - userId: "1", - filesData: { - "/home/user/projects/mooncode/apps/api/package.json": { + filesService.findAllOnDay.mockResolvedValue([ + { languageSlug: "json", - timeSpent: 600, + timeSpent: 500, fileName: "package.json", + filePath: "/home/user/projects/mooncode/apps/api/package.json", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "main", }, - "/home/user/projects/mooncode/apps/api/main.ts": { + { languageSlug: "typescript", - timeSpent: 2000, + timeSpent: 1800, fileName: "main.ts", + filePath: "/home/user/projects/mooncode/apps/api/main.ts", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "test", }, - "/home/user/projects/factory/Dockerfile": { + { languageSlug: "docker", - timeSpent: 1200, + timeSpent: 1000, fileName: "Dockerfile", + filePath: "/home/user/projects/factory/Dockerfile", projectName: "factory", projectPath: "/home/user/projects/factory", + branchName: "main", }, - }, - targetedDate: "2026-06-21", - }; + ]); - dailyDataService.findOne.mockResolvedValue({ - id: "2", - timeSpent: 1900, - }); + languagesService.findOne + .mockResolvedValueOnce({ + timeSpent: 500, + languageSlug: "json", + languageId: "5", + }) + .mockResolvedValueOnce({ + timeSpent: 1800, + languageSlug: "typescript", + languageId: "6", + }) + .mockResolvedValue({ + timeSpent: 1000, + languageSlug: "docker", + languageId: "7", + }); + + filesService.findOne + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 500, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1000, + }); - projectsService.findOne - .mockResolvedValueOnce({ - id: "3", - name: "mooncode", - path: "/home/user/projects/mooncode", - timeSpent: 800, - }) - .mockResolvedValueOnce({ - id: "4", - name: "factory", - path: "/home/user/projects/factory", - timeSpent: 1100, - }); + filesService.update + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 2000, + }); - projectsService.update - .mockResolvedValueOnce({ + const filesData = await extensionService.upsertFiles(mockedEntry); + + expect(filesData).toBeDefined(); + expect(filesData).toEqual(mockedOutput); + + expect(projectsService.update).toHaveBeenCalledOnce(); + expect(projectsService.update).toHaveBeenNthCalledWith(1, { + dailyDataId: "2", name: "mooncode", path: "/home/user/projects/mooncode", timeSpent: 2600, - dailyDataId: "2", - }) - .mockResolvedValueOnce({ - name: "factory", - path: "/home/user/projects/factory", - timeSpent: 1200, - dailyDataId: "2", }); - - filesService.findAllOnDay.mockResolvedValue({ - "/home/user/projects/mooncode/apps/api/tsconfig.json": { - languageSlug: "json", - timeSpent: 200, - fileName: "tsconfig.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/src/auth/auth.service.ts": { - languageSlug: "typescript", - timeSpent: 600, - fileName: "auth.service.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + }, + targetedDate: "2026-06-21", + type: "old" as const, }, - "/home/user/projects/mooncode/Dockerfile": { - languageSlug: "typescript", - timeSpent: 1100, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, }, - }); - - languagesService.findOne - .mockResolvedValueOnce({ - timeSpent: 200, - languageSlug: "json", - languageId: "5", - }) - .mockResolvedValueOnce({ - timeSpent: 350, - languageSlug: "typescript", - languageId: "6", - }) - .mockResolvedValue({ - timeSpent: 300, - languageSlug: "docker", - languageId: "7", + }, + ])( + "should create the branch for the project on the day if it doesn't exists", + async ({ mockedEntry }) => { + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 3000, }); - filesService.findOne - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null); + projectsService.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + projectsService.create + .mockResolvedValueOnce({ + id: "3", + name: "mooncode", + timeSpent: 2600, + }) + .mockResolvedValueOnce({ + id: "4", + name: "factory", + timeSpent: 1200, + }); - filesService.create - .mockResolvedValueOnce({ - name: "package.json", - path: "/home/user/projects/mooncode/apps/api/package.json", + branchesService.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + branchesService.create + .mockResolvedValueOnce({ + id: "5", + name: "main", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + id: "6", + name: "test", + timeSpent: 2000, + }) + .mockResolvedValueOnce({ + id: "7", + name: "main", + timeSpent: 1200, + }); + + filesService.findAllOnDay.mockResolvedValue([]); + + languagesService.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue(null); + + filesService.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + filesService.create + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 2000, + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1200, + }); + + const filesData = await extensionService.upsertFiles(mockedEntry); + + expect(filesData).toBeDefined(); + expect(filesData).toEqual({}); + + expect(branchesService.create).toHaveBeenCalled(); + expect(branchesService.create).toHaveBeenNthCalledWith(1, { + projectId: "3", + name: "main", timeSpent: 600, - }) - .mockResolvedValueOnce({ - name: "main.ts", - path: "/home/user/projects/mooncode/apps/api/main.ts", + }); + expect(branchesService.create).toHaveBeenNthCalledWith(2, { + projectId: "3", + name: "test", timeSpent: 2000, - }) - .mockResolvedValueOnce({ - name: "Dockerfile", - path: "/home/user/projects/factory/Dockerfile", + }); + expect(branchesService.create).toHaveBeenNthCalledWith(3, { + projectId: "4", + name: "main", timeSpent: 1200, }); - - const filesData = await extensionService.upsertFiles(mockedEntry); - expect(filesData).toBeDefined(); - expect(filesData).toEqual({ - "/home/user/projects/mooncode/apps/api/tsconfig.json": { - languageSlug: "json", - timeSpent: 200, - fileName: "tsconfig.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/src/auth/auth.service.ts": { - languageSlug: "typescript", - timeSpent: 600, - fileName: "auth.service.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + }, + targetedDate: "2026-06-21", + type: "old" as const, }, - "/home/user/projects/mooncode/Dockerfile": { - languageSlug: "typescript", - timeSpent: 1100, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, }, - ...mockedEntry.filesData, - }); + }, + ])( + "should update the branch data for the project on the day if it exists and the new time spent is greater than the existing one", + async ({ mockedEntry }) => { + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 3000, + }); - expect(filesService.create).toHaveBeenCalled(); - expect(filesService.create).toHaveBeenCalledTimes(3); - expect(filesService.create).toHaveBeenNthCalledWith(1, { - projectId: "3", - languageId: "5", - path: "/home/user/projects/mooncode/apps/api/package.json", - timeSpent: 600, - name: "package.json", - }); - expect(filesService.create).toHaveBeenNthCalledWith(2, { - projectId: "3", - languageId: "6", - path: "/home/user/projects/mooncode/apps/api/main.ts", - timeSpent: 2000, - name: "main.ts", - }); - expect(filesService.create).toHaveBeenNthCalledWith(3, { - projectId: "4", - languageId: "7", - path: "/home/user/projects/factory/Dockerfile", - timeSpent: 1200, - name: "Dockerfile", - }); - }); + projectsService.findOne + .mockResolvedValueOnce({ + id: "3", + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2000, + }) + .mockResolvedValueOnce({ + id: "4", + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1000, + }); + + projectsService.update + .mockResolvedValueOnce({ + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2600, + dailyDataId: "2", + }) + .mockResolvedValueOnce({ + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1200, + dailyDataId: "2", + }); + + branchesService.findOne + .mockResolvedValueOnce({ + id: "5", + name: "main", + timeSpent: 200, + }) + .mockResolvedValueOnce({ + id: "6", + name: "test", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + id: "7", + name: "main", + timeSpent: 1000, + }); - it("should update the file data for the day if it exists and the new time spent is greater than the existing one", async () => { - const mockedEntry = { - userId: "1", - filesData: { - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", + branchesService.update + .mockResolvedValueOnce({ + projectId: "3", + name: "main", timeSpent: 600, + }) + .mockResolvedValueOnce({ + projectId: "3", + name: "test", + timeSpent: 2000, + }) + .mockResolvedValueOnce({ + projectId: "4", + name: "main", + timeSpent: 1200, + }); + + filesService.findAllOnDay.mockResolvedValue([ + { + languageSlug: "json", + timeSpent: 200, fileName: "package.json", + filePath: "/home/user/projects/mooncode/apps/api/package.json", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "main", }, - "/home/user/projects/mooncode/apps/api/main.ts": { + { languageSlug: "typescript", - timeSpent: 2000, + timeSpent: 1800, fileName: "main.ts", + filePath: "/home/user/projects/mooncode/apps/api/main.ts", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "test", }, - "/home/user/projects/factory/Dockerfile": { + { languageSlug: "docker", - timeSpent: 1200, + timeSpent: 1000, fileName: "Dockerfile", + filePath: "/home/user/projects/factory/Dockerfile", projectName: "factory", projectPath: "/home/user/projects/factory", + branchName: "main", }, - }, - targetedDate: "2026-06-21", - }; + ]); - dailyDataService.findOne.mockResolvedValue({ - id: "2", - timeSpent: 3000, - }); + languagesService.findOne + .mockResolvedValueOnce({ + timeSpent: 200, + languageSlug: "json", + languageId: "5", + }) + .mockResolvedValueOnce({ + timeSpent: 1800, + languageSlug: "typescript", + languageId: "6", + }) + .mockResolvedValue({ + timeSpent: 1000, + languageSlug: "docker", + languageId: "7", + }); + + filesService.findOne + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 200, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1000, + }); - projectsService.findOne - .mockResolvedValueOnce({ - id: "3", - name: "mooncode", - path: "/home/user/projects/mooncode", + filesService.update + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 2000, + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1200, + }); + + const filesData = await extensionService.upsertFiles(mockedEntry); + + expect(filesData).toBeDefined(); + expect(filesData).toEqual(mockedEntry.filesData); + + expect(branchesService.update).toHaveBeenCalled(); + expect(branchesService.update).toHaveBeenNthCalledWith(1, { + projectId: "3", + name: "main", + timeSpent: 600, + }); + expect(branchesService.update).toHaveBeenNthCalledWith(2, { + projectId: "3", + name: "test", timeSpent: 2000, - }) - .mockResolvedValueOnce({ - id: "4", - name: "factory", - path: "/home/user/projects/factory", - timeSpent: 1000, + }); + expect(branchesService.update).toHaveBeenNthCalledWith(3, { + projectId: "4", + name: "main", + timeSpent: 1200, + }); + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 400, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 1000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 800, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + }, + targetedDate: "2026-06-21", + type: "old" as const, + }, + mockedOutput: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 500, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 1800, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + }, + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 400, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 1000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 800, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, + }, + mockedOutput: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 500, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 1800, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + }, + }, + ])( + "should NOT update the branch data for the project on the day if it exists and the new time spent is less than the existing one", + async ({ mockedEntry, mockedOutput }) => { + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 3300, }); - projectsService.update - .mockResolvedValueOnce({ + projectsService.findOne + .mockResolvedValueOnce({ + id: "3", + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2300, + }) + .mockResolvedValueOnce({ + id: "4", + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1000, + }); + + branchesService.findOne + .mockResolvedValueOnce({ + id: "3", + name: "main", + timeSpent: 500, + }) + .mockResolvedValueOnce({ + id: "4", + name: "test", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + id: "5", + name: "main", + timeSpent: 1000, + }); + + filesService.findAllOnDay.mockResolvedValue([ + { + languageSlug: "json", + timeSpent: 500, + fileName: "package.json", + filePath: "/home/user/projects/mooncode/apps/api/package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + { + languageSlug: "typescript", + timeSpent: 1800, + fileName: "main.ts", + filePath: "/home/user/projects/mooncode/apps/api/main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + filePath: "/home/user/projects/factory/Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + ]); + + languagesService.findOne + .mockResolvedValueOnce({ + timeSpent: 500, + languageSlug: "json", + languageId: "5", + }) + .mockResolvedValueOnce({ + timeSpent: 1800, + languageSlug: "typescript", + languageId: "6", + }) + .mockResolvedValue({ + timeSpent: 1000, + languageSlug: "docker", + languageId: "7", + }); + + filesService.findOne + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 500, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1000, + }); + + const filesData = await extensionService.upsertFiles(mockedEntry); + + expect(filesData).toBeDefined(); + expect(filesData).toEqual(mockedOutput); + + expect(branchesService.update).not.toHaveBeenCalled(); + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 800, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + }, + targetedDate: "2026-06-21", + type: "old" as const, + }, + mockedOutput: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + }, + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 800, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, + }, + mockedOutput: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + }, + }, + ])( + "should ONLY update branches where the new time spent is greater than the existing one", + async ({ mockedEntry, mockedOutput }) => { + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 3300, + }); + + projectsService.findOne + .mockResolvedValueOnce({ + id: "3", + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2300, + }) + .mockResolvedValueOnce({ + id: "4", + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1000, + }); + + projectsService.update.mockResolvedValueOnce({ name: "mooncode", path: "/home/user/projects/mooncode", timeSpent: 2600, dailyDataId: "2", - }) - .mockResolvedValueOnce({ - name: "factory", - path: "/home/user/projects/factory", - timeSpent: 1200, - dailyDataId: "2", }); - filesService.findAllOnDay.mockResolvedValue({ - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 200, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", + branchesService.findOne + .mockResolvedValueOnce({ + id: "5", + name: "main", + timeSpent: 500, + }) + .mockResolvedValueOnce({ + id: "6", + name: "test", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + id: "7", + name: "main", + timeSpent: 1000, + }); + + branchesService.update + .mockResolvedValueOnce({ + projectId: "3", + name: "main", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + projectId: "3", + name: "test", + timeSpent: 2000, + }); + + filesService.findAllOnDay.mockResolvedValue([ + { + languageSlug: "json", + timeSpent: 500, + fileName: "package.json", + filePath: "/home/user/projects/mooncode/apps/api/package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + { + languageSlug: "typescript", + timeSpent: 1800, + fileName: "main.ts", + filePath: "/home/user/projects/mooncode/apps/api/main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + filePath: "/home/user/projects/factory/Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + ]); + + languagesService.findOne + .mockResolvedValueOnce({ + timeSpent: 500, + languageSlug: "json", + languageId: "5", + }) + .mockResolvedValueOnce({ + timeSpent: 1800, + languageSlug: "typescript", + languageId: "6", + }) + .mockResolvedValue({ + timeSpent: 1000, + languageSlug: "docker", + languageId: "7", + }); + + filesService.findOne + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 500, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1000, + }); + + filesService.update + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 2000, + }); + + const filesData = await extensionService.upsertFiles(mockedEntry); + + expect(filesData).toBeDefined(); + expect(filesData).toEqual(mockedOutput); + + expect(branchesService.update).toHaveBeenCalledTimes(2); + expect(branchesService.update).toHaveBeenNthCalledWith(1, { + projectId: "3", + name: "main", + timeSpent: 600, + }); + expect(branchesService.update).toHaveBeenNthCalledWith(2, { + projectId: "3", + name: "test", + timeSpent: 2000, + }); + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + }, + targetedDate: "2026-06-21", + type: "old" as const, }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 1800, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", + mockedOutput: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/tsconfig.json": { + languageSlug: "json", + timeSpent: 200, + fileName: "tsconfig.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/mooncode/apps/api/src/auth/auth.service.ts": { + languageSlug: "typescript", + timeSpent: 600, + fileName: "auth.service.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1000, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, }, - }); - - languagesService.findOne - .mockResolvedValueOnce({ - timeSpent: 200, - languageSlug: "json", - languageId: "5", - }) - .mockResolvedValueOnce({ - timeSpent: 1800, - languageSlug: "typescript", - languageId: "6", - }) - .mockResolvedValue({ - timeSpent: 1000, - languageSlug: "docker", - languageId: "7", + mockedOutput: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/tsconfig.json": { + languageSlug: "json", + timeSpent: 200, + fileName: "tsconfig.json", + projectName: "mooncode", + }, + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/src/auth/auth.service.ts": + { + languageSlug: "typescript", + timeSpent: 600, + fileName: "auth.service.ts", + projectName: "mooncode", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + }, + }, + ])( + "should create the file for the day if it doesn't exist", + async ({ mockedEntry, mockedOutput }) => { + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 1900, }); - filesService.findOne - .mockResolvedValueOnce({ - name: "package.json", - path: "/home/user/projects/mooncode/apps/api/package.json", - timeSpent: 200, - }) - .mockResolvedValueOnce({ - name: "main.ts", - path: "/home/user/projects/mooncode/apps/api/main.ts", - timeSpent: 1800, - }) - .mockResolvedValueOnce({ + projectsService.findOne + .mockResolvedValueOnce({ + id: "3", + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 800, + }) + .mockResolvedValueOnce({ + id: "4", + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1100, + }); + + projectsService.update + .mockResolvedValueOnce({ + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2600, + dailyDataId: "2", + }) + .mockResolvedValueOnce({ + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1200, + dailyDataId: "2", + }); + + branchesService.findOne + .mockResolvedValueOnce({ + id: "5", + name: "main", + timeSpent: 200, + }) + .mockResolvedValueOnce({ + id: "6", + name: "test", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + id: "7", + name: "main", + timeSpent: 1100, + }); + + branchesService.update + .mockResolvedValueOnce({ + projectId: "3", + name: "main", + timeSpent: 800, + }) + .mockResolvedValueOnce({ + projectId: "3", + name: "test", + timeSpent: 2600, + }) + .mockResolvedValueOnce({ + projectId: "4", + name: "main", + timeSpent: 2300, + }); + + filesService.findAllOnDay.mockResolvedValue([ + { + languageSlug: "json", + timeSpent: 200, + fileName: "tsconfig.json", + filePath: "/home/user/projects/mooncode/apps/api/tsconfig.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + { + languageSlug: "typescript", + timeSpent: 600, + fileName: "auth.service.ts", + filePath: + "/home/user/projects/mooncode/apps/api/src/auth/auth.service.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + { + languageSlug: "docker", + timeSpent: 1100, + fileName: "Dockerfile", + filePath: "/home/user/projects/factory/Dockerfile", + projectPath: "/home/user/projects/factory", + projectName: "factory", + branchName: "main", + }, + ]); + + languagesService.findOne + .mockResolvedValueOnce({ + timeSpent: 200, + languageSlug: "json", + languageId: "11", + }) + .mockResolvedValueOnce({ + timeSpent: 350, + languageSlug: "typescript", + languageId: "12", + }) + .mockResolvedValue({ + timeSpent: 300, + languageSlug: "docker", + languageId: "13", + }); + + filesService.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1000, + }); + + filesService.create + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 2000, + }); + + filesService.update.mockResolvedValueOnce({ name: "Dockerfile", path: "/home/user/projects/factory/Dockerfile", - timeSpent: 1000, + timeSpent: 1200, }); - filesService.update - .mockResolvedValueOnce({ - name: "package.json", + const filesData = await extensionService.upsertFiles(mockedEntry); + expect(filesData).toBeDefined(); + expect(filesData).toEqual(mockedOutput); + + expect(filesService.create).toHaveBeenCalled(); + expect(filesService.create).toHaveBeenCalledTimes(2); + expect(filesService.create).toHaveBeenNthCalledWith(1, { + languageId: "11", + branchId: "5", path: "/home/user/projects/mooncode/apps/api/package.json", timeSpent: 600, - }) - .mockResolvedValueOnce({ - name: "main.ts", + name: "package.json", + }); + expect(filesService.create).toHaveBeenNthCalledWith(2, { + languageId: "12", + branchId: "6", path: "/home/user/projects/mooncode/apps/api/main.ts", timeSpent: 2000, - }) - .mockResolvedValueOnce({ - name: "Dockerfile", - path: "/home/user/projects/factory/Dockerfile", - timeSpent: 1200, + name: "main.ts", + }); + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + }, + targetedDate: "2026-06-21", + type: "old" as const, + }, + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1200, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, + }, + }, + ])( + "should update the file data for the day if it exists and the new time spent is greater than the existing one", + async ({ mockedEntry }) => { + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 3000, }); - const filesData = await extensionService.upsertFiles(mockedEntry); + projectsService.findOne + .mockResolvedValueOnce({ + id: "3", + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2000, + }) + .mockResolvedValueOnce({ + id: "4", + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1000, + }); + + projectsService.update + .mockResolvedValueOnce({ + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2600, + dailyDataId: "2", + }) + .mockResolvedValueOnce({ + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1200, + dailyDataId: "2", + }); + + branchesService.findOne + .mockResolvedValueOnce({ + id: "5", + name: "main", + timeSpent: 200, + }) + .mockResolvedValueOnce({ + id: "6", + name: "test", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + id: "7", + name: "main", + timeSpent: 1000, + }); - expect(filesData).toBeDefined(); - expect(filesData).toEqual(mockedEntry.filesData); + branchesService.update + .mockResolvedValueOnce({ + projectId: "3", + name: "main", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + projectId: "3", + name: "test", + timeSpent: 2000, + }) + .mockResolvedValueOnce({ + projectId: "4", + name: "main", + timeSpent: 1200, + }); - expect(filesService.update).toHaveBeenCalled(); - expect(filesService.update).toHaveBeenCalledTimes(3); - expect(filesService.update).toHaveBeenNthCalledWith(1, { - projectId: "3", - languageId: "5", - path: "/home/user/projects/mooncode/apps/api/package.json", - timeSpent: 600, - name: "package.json", - }); - expect(filesService.update).toHaveBeenNthCalledWith(2, { - projectId: "3", - languageId: "6", - path: "/home/user/projects/mooncode/apps/api/main.ts", - timeSpent: 2000, - name: "main.ts", - }); - expect(filesService.update).toHaveBeenNthCalledWith(3, { - projectId: "4", - languageId: "7", - path: "/home/user/projects/factory/Dockerfile", - timeSpent: 1200, - name: "Dockerfile", - }); - }); + filesService.findAllOnDay.mockResolvedValue([ + { + languageSlug: "json", + timeSpent: 200, + fileName: "package.json", + filePath: "/home/user/projects/mooncode/apps/api/package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + { + languageSlug: "typescript", + timeSpent: 1800, + fileName: "main.ts", + filePath: "/home/user/projects/mooncode/apps/api/main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + filePath: "/home/user/projects/factory/Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + ]); - it("should NOT update the file data for the day if it exists and the new time spent is less than the existing one", async () => { - const mockedEntry = { - userId: "1", - filesData: { + languagesService.findOne + .mockResolvedValueOnce({ + timeSpent: 200, + languageSlug: "json", + languageId: "8", + }) + .mockResolvedValueOnce({ + timeSpent: 1800, + languageSlug: "typescript", + languageId: "9", + }) + .mockResolvedValue({ + timeSpent: 1000, + languageSlug: "docker", + languageId: "10", + }); + + filesService.findOne + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 200, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1000, + }); + + filesService.update + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 2000, + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1200, + }); + + const filesData = await extensionService.upsertFiles(mockedEntry); + + expect(filesData).toBeDefined(); + expect(filesData).toEqual(mockedEntry.filesData); + + expect(filesService.update).toHaveBeenCalled(); + expect(filesService.update).toHaveBeenCalledTimes(3); + expect(filesService.update).toHaveBeenNthCalledWith(1, { + branchId: "5", + languageId: "8", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 600, + name: "package.json", + }); + expect(filesService.update).toHaveBeenNthCalledWith(2, { + branchId: "6", + languageId: "9", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 2000, + name: "main.ts", + }); + expect(filesService.update).toHaveBeenNthCalledWith(3, { + branchId: "7", + languageId: "10", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1200, + name: "Dockerfile", + }); + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 400, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 1000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 800, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + }, + targetedDate: "2026-06-21", + type: "old" as const, + }, + mockedOutput: { "/home/user/projects/mooncode/apps/api/package.json": { languageSlug: "json", - timeSpent: 400, + timeSpent: 500, fileName: "package.json", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "main", }, "/home/user/projects/mooncode/apps/api/main.ts": { languageSlug: "typescript", - timeSpent: 1000, + timeSpent: 1800, fileName: "main.ts", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "test", }, "/home/user/projects/factory/Dockerfile": { languageSlug: "docker", - timeSpent: 800, + timeSpent: 1000, fileName: "Dockerfile", projectName: "factory", projectPath: "/home/user/projects/factory", + branchName: "main", }, }, - targetedDate: "2026-06-21", - }; - - dailyDataService.findOne.mockResolvedValue({ - id: "2", - timeSpent: 3300, - }); - - projectsService.findOne - .mockResolvedValueOnce({ - id: "3", - name: "mooncode", - path: "/home/user/projects/mooncode", - timeSpent: 2300, - }) - .mockResolvedValueOnce({ - id: "4", - name: "factory", - path: "/home/user/projects/factory", - timeSpent: 1000, - }); - - filesService.findAllOnDay.mockResolvedValue({ - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 500, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 1800, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 400, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 1000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 800, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1000, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", + mockedOutput: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 500, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 1800, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, }, - }); - - languagesService.findOne - .mockResolvedValueOnce({ - timeSpent: 500, - languageSlug: "json", - languageId: "5", - }) - .mockResolvedValueOnce({ - timeSpent: 1800, - languageSlug: "typescript", - languageId: "6", - }) - .mockResolvedValue({ - timeSpent: 1000, - languageSlug: "docker", - languageId: "7", + }, + ])( + "should NOT update the file data for the day if it exists and the new time spent is less than the existing one", + async ({ mockedEntry, mockedOutput }) => { + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 3300, }); - filesService.findOne - .mockResolvedValueOnce({ - name: "package.json", - path: "/home/user/projects/mooncode/apps/api/package.json", - timeSpent: 500, - }) - .mockResolvedValueOnce({ - name: "main.ts", - path: "/home/user/projects/mooncode/apps/api/main.ts", - timeSpent: 1800, - }) - .mockResolvedValueOnce({ - name: "Dockerfile", - path: "/home/user/projects/factory/Dockerfile", - timeSpent: 1000, - }); + projectsService.findOne + .mockResolvedValueOnce({ + id: "3", + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2300, + }) + .mockResolvedValueOnce({ + id: "4", + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1000, + }); + + branchesService.findOne + .mockResolvedValueOnce({ + id: "5", + name: "main", + timeSpent: 500, + }) + .mockResolvedValueOnce({ + id: "6", + name: "test", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + id: "7", + name: "main", + timeSpent: 1000, + }); - const filesData = await extensionService.upsertFiles(mockedEntry); + filesService.findAllOnDay.mockResolvedValue([ + { + languageSlug: "json", + timeSpent: 500, + fileName: "package.json", + filePath: "/home/user/projects/mooncode/apps/api/package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + { + languageSlug: "typescript", + timeSpent: 1800, + fileName: "main.ts", + filePath: "/home/user/projects/mooncode/apps/api/main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + filePath: "/home/user/projects/factory/Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + ]); - expect(filesData).toBeDefined(); - expect(filesData).toEqual({ - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 500, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 1800, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1000, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", + languagesService.findOne + .mockResolvedValueOnce({ + timeSpent: 500, + languageSlug: "json", + languageId: "8", + }) + .mockResolvedValueOnce({ + timeSpent: 1800, + languageSlug: "typescript", + languageId: "9", + }) + .mockResolvedValue({ + timeSpent: 1000, + languageSlug: "docker", + languageId: "10", + }); + + filesService.findOne + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 500, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1000, + }); + + const filesData = await extensionService.upsertFiles(mockedEntry); + + expect(filesData).toBeDefined(); + expect(filesData).toEqual(mockedOutput); + + expect(filesService.update).not.toHaveBeenCalled(); + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 800, + fileName: "Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + }, + targetedDate: "2026-06-21", + type: "old" as const, }, - }); - - expect(filesService.update).not.toHaveBeenCalled(); - }); - - it("should ONLY update files where the new time spent is greater than the existing one", async () => { - const mockedEntry = { - userId: "1", - filesData: { + mockedOutput: { "/home/user/projects/mooncode/apps/api/package.json": { languageSlug: "json", timeSpent: 600, fileName: "package.json", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "main", }, "/home/user/projects/mooncode/apps/api/main.ts": { languageSlug: "typescript", @@ -1993,156 +4083,254 @@ describe("ExtensionService", () => { fileName: "main.ts", projectName: "mooncode", projectPath: "/home/user/projects/mooncode", + branchName: "test", }, "/home/user/projects/factory/Dockerfile": { languageSlug: "docker", - timeSpent: 800, + timeSpent: 1000, fileName: "Dockerfile", projectName: "factory", projectPath: "/home/user/projects/factory", + branchName: "main", }, }, - targetedDate: "2026-06-21", - }; + }, + { + mockedEntry: { + userId: "1", + filesData: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 800, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + } as Record< + string, + Record< + string, + Record< + string, + { + timeSpent: number; + languageSlug: string; + projectName: string; + fileName: string; + } + > + > + >, + targetedDate: "2026-06-21", + type: "new" as const, + }, + mockedOutput: { + "/home/user/projects/mooncode": { + main: { + "/home/user/projects/mooncode/apps/api/package.json": { + languageSlug: "json", + timeSpent: 600, + fileName: "package.json", + projectName: "mooncode", + }, + }, + test: { + "/home/user/projects/mooncode/apps/api/main.ts": { + languageSlug: "typescript", + timeSpent: 2000, + fileName: "main.ts", + projectName: "mooncode", + }, + }, + }, + "/home/user/projects/factory": { + main: { + "/home/user/projects/factory/Dockerfile": { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + projectName: "factory", + }, + }, + }, + }, + }, + ])( + "should ONLY update files where the new time spent is greater than the existing one", + async ({ mockedEntry, mockedOutput }) => { + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 3300, + }); - dailyDataService.findOne.mockResolvedValue({ - id: "2", - timeSpent: 3300, - }); + projectsService.findOne + .mockResolvedValueOnce({ + id: "3", + name: "mooncode", + path: "/home/user/projects/mooncode", + timeSpent: 2300, + }) + .mockResolvedValueOnce({ + id: "4", + name: "factory", + path: "/home/user/projects/factory", + timeSpent: 1000, + }); - projectsService.findOne - .mockResolvedValueOnce({ - id: "3", + projectsService.update.mockResolvedValueOnce({ name: "mooncode", path: "/home/user/projects/mooncode", - timeSpent: 2300, - }) - .mockResolvedValueOnce({ - id: "4", - name: "factory", - path: "/home/user/projects/factory", - timeSpent: 1000, + timeSpent: 2600, + dailyDataId: "2", }); - projectsService.update.mockResolvedValueOnce({ - name: "mooncode", - path: "/home/user/projects/mooncode", - timeSpent: 2600, - dailyDataId: "2", - }); + branchesService.findOne + .mockResolvedValueOnce({ + id: "5", + name: "main", + timeSpent: 500, + }) + .mockResolvedValueOnce({ + id: "6", + name: "test", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + id: "7", + name: "main", + timeSpent: 1000, + }); - filesService.findAllOnDay.mockResolvedValue({ - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 500, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 1800, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1000, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", - }, - }); + branchesService.update + .mockResolvedValueOnce({ + projectId: "3", + name: "main", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + projectId: "3", + name: "test", + timeSpent: 2000, + }); - languagesService.findOne - .mockResolvedValueOnce({ - timeSpent: 500, - languageSlug: "json", - languageId: "5", - }) - .mockResolvedValueOnce({ - timeSpent: 1800, - languageSlug: "typescript", - languageId: "6", - }) - .mockResolvedValue({ - timeSpent: 1000, - languageSlug: "docker", - languageId: "7", - }); + filesService.findAllOnDay.mockResolvedValue([ + { + languageSlug: "json", + timeSpent: 500, + fileName: "package.json", + filePath: "/home/user/projects/mooncode/apps/api/package.json", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "main", + }, + { + languageSlug: "typescript", + timeSpent: 1800, + fileName: "main.ts", + filePath: "/home/user/projects/mooncode/apps/api/main.ts", + projectName: "mooncode", + projectPath: "/home/user/projects/mooncode", + branchName: "test", + }, + { + languageSlug: "docker", + timeSpent: 1000, + fileName: "Dockerfile", + filePath: "/home/user/projects/factory/Dockerfile", + projectName: "factory", + projectPath: "/home/user/projects/factory", + branchName: "main", + }, + ]); - filesService.findOne - .mockResolvedValueOnce({ - name: "package.json", - path: "/home/user/projects/mooncode/apps/api/package.json", - timeSpent: 500, - }) - .mockResolvedValueOnce({ - name: "main.ts", - path: "/home/user/projects/mooncode/apps/api/main.ts", - timeSpent: 1800, - }) - .mockResolvedValueOnce({ - name: "Dockerfile", - path: "/home/user/projects/factory/Dockerfile", - timeSpent: 1000, - }); + languagesService.findOne + .mockResolvedValueOnce({ + timeSpent: 500, + languageSlug: "json", + languageId: "8", + }) + .mockResolvedValueOnce({ + timeSpent: 1800, + languageSlug: "typescript", + languageId: "9", + }) + .mockResolvedValue({ + timeSpent: 1000, + languageSlug: "docker", + languageId: "10", + }); + + filesService.findOne + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 500, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 1800, + }) + .mockResolvedValueOnce({ + name: "Dockerfile", + path: "/home/user/projects/factory/Dockerfile", + timeSpent: 1000, + }); - filesService.update - .mockResolvedValueOnce({ - name: "package.json", + filesService.update + .mockResolvedValueOnce({ + name: "package.json", + path: "/home/user/projects/mooncode/apps/api/package.json", + timeSpent: 600, + }) + .mockResolvedValueOnce({ + name: "main.ts", + path: "/home/user/projects/mooncode/apps/api/main.ts", + timeSpent: 2000, + }); + + const filesData = await extensionService.upsertFiles(mockedEntry); + + expect(filesData).toBeDefined(); + expect(filesData).toEqual(mockedOutput); + + expect(filesService.update).toHaveBeenCalledTimes(2); + expect(filesService.update).toHaveBeenNthCalledWith(1, { + branchId: "5", + languageId: "8", path: "/home/user/projects/mooncode/apps/api/package.json", timeSpent: 600, - }) - .mockResolvedValueOnce({ - name: "main.ts", + name: "package.json", + }); + expect(filesService.update).toHaveBeenNthCalledWith(2, { + branchId: "6", + languageId: "9", path: "/home/user/projects/mooncode/apps/api/main.ts", timeSpent: 2000, + name: "main.ts", }); - - const filesData = await extensionService.upsertFiles(mockedEntry); - - expect(filesData).toBeDefined(); - expect(filesData).toEqual({ - "/home/user/projects/mooncode/apps/api/package.json": { - languageSlug: "json", - timeSpent: 600, - fileName: "package.json", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/mooncode/apps/api/main.ts": { - languageSlug: "typescript", - timeSpent: 2000, - fileName: "main.ts", - projectName: "mooncode", - projectPath: "/home/user/projects/mooncode", - }, - "/home/user/projects/factory/Dockerfile": { - languageSlug: "docker", - timeSpent: 1000, - fileName: "Dockerfile", - projectName: "factory", - projectPath: "/home/user/projects/factory", - }, - }); - - expect(filesService.update).toHaveBeenCalledTimes(2); - expect(filesService.update).toHaveBeenNthCalledWith(1, { - projectId: "3", - languageId: "5", - path: "/home/user/projects/mooncode/apps/api/package.json", - timeSpent: 600, - name: "package.json", - }); - expect(filesService.update).toHaveBeenNthCalledWith(2, { - projectId: "3", - languageId: "6", - path: "/home/user/projects/mooncode/apps/api/main.ts", - timeSpent: 2000, - name: "main.ts", - }); - }); + }, + ); }); }); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index c7245c8c..6912a4e3 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -45,5 +45,6 @@ export default defineConfig({ }, }, ], + coverage: { reportOnFailure: true }, }, }); From 072670b735bae4e76d5b9becd8cea2e38f304106 Mon Sep 17 00:00:00 2001 From: Friedrich482 Date: Wed, 15 Jul 2026 23:46:35 +0200 Subject: [PATCH 11/13] test: projects analytics - tested both the presence and absence of the branches parameter for the projects analytics service methods using it --- .../projects-analytics.service.test.ts | 4433 +++++++++-------- 1 file changed, 2462 insertions(+), 1971 deletions(-) diff --git a/apps/api/src/analytics/services/projects-analytics.service.test.ts b/apps/api/src/analytics/services/projects-analytics.service.test.ts index 5453acd8..739bddb9 100644 --- a/apps/api/src/analytics/services/projects-analytics.service.test.ts +++ b/apps/api/src/analytics/services/projects-analytics.service.test.ts @@ -188,147 +188,285 @@ describe("ProjectsAnalyticsService", () => { }); describe("getLanguagesTimeOnPeriod", () => { - it("should return an object containing the time spent per language on the period for the project", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - }; - - mockedDrizzle.orderBy.mockResolvedValue([ - { - languageSlug: "typescript", - totalTime: 5000, - }, - { - languageSlug: "javascript", - totalTime: 2000, - }, - { - languageSlug: "css", - totalTime: 7000, - }, - { - languageSlug: "python", - totalTime: 1500, + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", }, - { - languageSlug: "rust", - totalTime: 15200, + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-17", + end: "2026-06-21", }, - ]); + }, + ])( + "should return an object containing the time spent per language on the period for the project", + async ({ mockedEntry }) => { + mockedDrizzle.orderBy.mockResolvedValue([ + { + languageSlug: "typescript", + totalTime: 5000, + }, + { + languageSlug: "javascript", + totalTime: 2000, + }, + { + languageSlug: "css", + totalTime: 7000, + }, + { + languageSlug: "python", + totalTime: 1500, + }, + { + languageSlug: "rust", + totalTime: 15200, + }, + ]); - const mockedOutput = { - typescript: 5000, - javascript: 2000, - css: 7000, - python: 1500, - rust: 15200, - }; + const mockedOutput = { + typescript: 5000, + javascript: 2000, + css: 7000, + python: 1500, + rust: 15200, + }; - const languagesTimesOnPeriod = - await projectsAnalyticsService.getLanguagesTimeOnPeriod(mockedEntry); + const languagesTimesOnPeriod = + await projectsAnalyticsService.getLanguagesTimeOnPeriod(mockedEntry); - expect(languagesTimesOnPeriod).toBeDefined(); - expect(languagesTimesOnPeriod).toEqual(mockedOutput); - }); + expect(languagesTimesOnPeriod).toBeDefined(); + expect(languagesTimesOnPeriod).toEqual(mockedOutput); + }, + ); }); describe("getLanguagesTimePerDayOfPeriod", () => { - it("should return an object containing the time spent per language by date", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - }; - - mockedDrizzle.where.mockResolvedValue([ - { languageSlug: "typescript", timeSpent: 375, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 702, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 484, date: "2026-06-17" }, - { languageSlug: "ignore", timeSpent: 7, date: "2026-06-17" }, - { languageSlug: "html", timeSpent: 8, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 18, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 27, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 67, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 332, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 124, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 197, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 130, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 579, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 249, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 366, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 87, date: "2026-06-17" }, - { languageSlug: "json", timeSpent: 12, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 13, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 1058, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 544, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 658, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 15, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 49, date: "2026-06-17" }, - { languageSlug: "json", timeSpent: 1388, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 783, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 27, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 118, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 218, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 4918, date: "2026-06-17" }, - { languageSlug: "typescript", timeSpent: 6847, date: "2026-06-19" }, - { languageSlug: "json", timeSpent: 175, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 18, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 3, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 543, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 49, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 183, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 2, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 101, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 215, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 7, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 1200, date: "2026-06-19" }, - { languageSlug: "gitignore", timeSpent: 1, date: "2026-06-19" }, - { languageSlug: "gitignore", timeSpent: 31, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 10, date: "2026-06-19" }, - { languageSlug: "json", timeSpent: 126, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 4, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 187, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 788, date: "2026-06-19" }, - { languageSlug: "typescript", timeSpent: 16, date: "2026-06-20" }, - { languageSlug: "typescript", timeSpent: 3307, date: "2026-06-20" }, - { languageSlug: "typescript", timeSpent: 744, date: "2026-06-20" }, - { languageSlug: "typescript", timeSpent: 479, date: "2026-06-20" }, - { languageSlug: "typescript", timeSpent: 11, date: "2026-06-20" }, - { languageSlug: "typescript", timeSpent: 7, date: "2026-06-20" }, - { languageSlug: "typescript", timeSpent: 10, date: "2026-06-20" }, - ]); - - const mockedOutput = { - "2026-06-17": { - html: 8, - ignore: 7, - json: 1400, - typescript: 12138, - }, - "2026-06-19": { - gitignore: 32, - json: 301, - typescript: 10157, + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", }, - "2026-06-20": { - typescript: 4574, + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-17", + end: "2026-06-21", }, - }; + }, + ])( + "should return an object containing the time spent per language by date", + async ({ mockedEntry }) => { + mockedDrizzle.where.mockResolvedValue([ + { + languageSlug: "typescript", + timeSpent: 375, + date: "2026-06-17", + }, + { + languageSlug: "typescript", + timeSpent: 702, + date: "2026-06-17", + }, + { + languageSlug: "typescript", + timeSpent: 484, + date: "2026-06-17", + }, + { languageSlug: "ignore", timeSpent: 7, date: "2026-06-17" }, + { languageSlug: "html", timeSpent: 8, date: "2026-06-17" }, + { languageSlug: "typescript", timeSpent: 18, date: "2026-06-17" }, + { languageSlug: "typescript", timeSpent: 27, date: "2026-06-17" }, + { languageSlug: "typescript", timeSpent: 67, date: "2026-06-17" }, + { + languageSlug: "typescript", + timeSpent: 332, + date: "2026-06-17", + }, + { + languageSlug: "typescript", + timeSpent: 124, + date: "2026-06-17", + }, + { + languageSlug: "typescript", + timeSpent: 197, + date: "2026-06-17", + }, + { + languageSlug: "typescript", + timeSpent: 130, + date: "2026-06-17", + }, + { + languageSlug: "typescript", + timeSpent: 579, + date: "2026-06-17", + }, + { + languageSlug: "typescript", + timeSpent: 249, + date: "2026-06-17", + }, + { + languageSlug: "typescript", + timeSpent: 366, + date: "2026-06-17", + }, + { languageSlug: "typescript", timeSpent: 87, date: "2026-06-17" }, + { languageSlug: "json", timeSpent: 12, date: "2026-06-17" }, + { languageSlug: "typescript", timeSpent: 13, date: "2026-06-17" }, + { + languageSlug: "typescript", + timeSpent: 1058, + date: "2026-06-17", + }, + { + languageSlug: "typescript", + timeSpent: 544, + date: "2026-06-17", + }, + { + languageSlug: "typescript", + timeSpent: 658, + date: "2026-06-17", + }, + { languageSlug: "typescript", timeSpent: 15, date: "2026-06-17" }, + { languageSlug: "typescript", timeSpent: 49, date: "2026-06-17" }, + { languageSlug: "json", timeSpent: 1388, date: "2026-06-17" }, + { + languageSlug: "typescript", + timeSpent: 783, + date: "2026-06-17", + }, + { languageSlug: "typescript", timeSpent: 27, date: "2026-06-17" }, + { + languageSlug: "typescript", + timeSpent: 118, + date: "2026-06-17", + }, + { + languageSlug: "typescript", + timeSpent: 218, + date: "2026-06-17", + }, + { + languageSlug: "typescript", + timeSpent: 4918, + date: "2026-06-17", + }, + { + languageSlug: "typescript", + timeSpent: 6847, + date: "2026-06-19", + }, + { languageSlug: "json", timeSpent: 175, date: "2026-06-19" }, + { languageSlug: "typescript", timeSpent: 18, date: "2026-06-19" }, + { languageSlug: "typescript", timeSpent: 3, date: "2026-06-19" }, + { + languageSlug: "typescript", + timeSpent: 543, + date: "2026-06-19", + }, + { languageSlug: "typescript", timeSpent: 49, date: "2026-06-19" }, + { + languageSlug: "typescript", + timeSpent: 183, + date: "2026-06-19", + }, + { languageSlug: "typescript", timeSpent: 2, date: "2026-06-19" }, + { + languageSlug: "typescript", + timeSpent: 101, + date: "2026-06-19", + }, + { + languageSlug: "typescript", + timeSpent: 215, + date: "2026-06-19", + }, + { languageSlug: "typescript", timeSpent: 7, date: "2026-06-19" }, + { + languageSlug: "typescript", + timeSpent: 1200, + date: "2026-06-19", + }, + { languageSlug: "gitignore", timeSpent: 1, date: "2026-06-19" }, + { languageSlug: "gitignore", timeSpent: 31, date: "2026-06-19" }, + { languageSlug: "typescript", timeSpent: 10, date: "2026-06-19" }, + { languageSlug: "json", timeSpent: 126, date: "2026-06-19" }, + { languageSlug: "typescript", timeSpent: 4, date: "2026-06-19" }, + { + languageSlug: "typescript", + timeSpent: 187, + date: "2026-06-19", + }, + { + languageSlug: "typescript", + timeSpent: 788, + date: "2026-06-19", + }, + { languageSlug: "typescript", timeSpent: 16, date: "2026-06-20" }, + { + languageSlug: "typescript", + timeSpent: 3307, + date: "2026-06-20", + }, + { + languageSlug: "typescript", + timeSpent: 744, + date: "2026-06-20", + }, + { + languageSlug: "typescript", + timeSpent: 479, + date: "2026-06-20", + }, + { languageSlug: "typescript", timeSpent: 11, date: "2026-06-20" }, + { languageSlug: "typescript", timeSpent: 7, date: "2026-06-20" }, + { languageSlug: "typescript", timeSpent: 10, date: "2026-06-20" }, + ]); + + const mockedOutput = { + "2026-06-17": { + html: 8, + ignore: 7, + json: 1400, + typescript: 12138, + }, + "2026-06-19": { + gitignore: 32, + json: 301, + typescript: 10157, + }, + "2026-06-20": { + typescript: 4574, + }, + }; - const languagesPerDayOfPeriod = - await projectsAnalyticsService.getLanguagesTimePerDayOfPeriod( - mockedEntry, - ); + const languagesPerDayOfPeriod = + await projectsAnalyticsService.getLanguagesTimePerDayOfPeriod( + mockedEntry, + ); - expect(languagesPerDayOfPeriod).toBeDefined(); - expect(languagesPerDayOfPeriod).toEqual(mockedOutput); - }); + expect(languagesPerDayOfPeriod).toBeDefined(); + expect(languagesPerDayOfPeriod).toEqual(mockedOutput); + }, + ); }); describe("checkProjectExists", () => { @@ -467,93 +605,132 @@ describe("ProjectsAnalyticsService", () => { }); describe("getProjectOnPeriod", () => { - it("should return an object containing the expected fields: name, path and totalTimeSpent for the project", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - }; - - mockedDrizzle.limit.mockResolvedValue([ - { + it.for([ + { + mockedEntry: { + userId: "1", name: "mooncode", - path: "/home/user/projects/mooncode", + start: "2026-06-17", + end: "2026-06-21", }, - ]); - - const mockedProjectAggregatedOnPeriod = { - name: "mooncode", - path: "/home/user/projects/mooncode", - totalTimeSpent: 24000, - }; + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-17", + end: "2026-06-21", + }, + }, + ])( + "should return an object containing the expected fields: name, path and totalTimeSpent for the project", + async ({ mockedEntry }) => { + mockedDrizzle.limit.mockResolvedValue([ + { + name: "mooncode", + path: "/home/user/projects/mooncode", + }, + ]); - mockedDrizzle.orderBy.mockResolvedValue([ - mockedProjectAggregatedOnPeriod, - ]); + const mockedProjectAggregatedOnPeriod = { + name: "mooncode", + path: "/home/user/projects/mooncode", + totalTimeSpent: 24000, + }; - const mockedOutput = mockedProjectAggregatedOnPeriod; + mockedDrizzle.orderBy.mockResolvedValue([ + mockedProjectAggregatedOnPeriod, + ]); - const projectAggregatedOnPeriod = - await projectsAnalyticsService.getProjectOnPeriod(mockedEntry); + const mockedOutput = mockedProjectAggregatedOnPeriod; - expect(projectAggregatedOnPeriod).toBeDefined(); - expect(projectAggregatedOnPeriod).toEqual(mockedOutput); - }); + const projectAggregatedOnPeriod = + await projectsAnalyticsService.getProjectOnPeriod(mockedEntry); - it("should return an empty state if the time spent on the project on that period is zero", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - }; + expect(projectAggregatedOnPeriod).toBeDefined(); + expect(projectAggregatedOnPeriod).toEqual(mockedOutput); + }, + ); - mockedDrizzle.limit.mockResolvedValue([ - { + it.for([ + { + mockedEntry: { + userId: "1", name: "mooncode", - path: "/home/user/projects/mooncode", + start: "2026-06-17", + end: "2026-06-21", }, - ]); - - mockedDrizzle.orderBy.mockResolvedValue([]); - - const mockedOutput = { - name: "mooncode", - path: "/home/user/projects/mooncode", - totalTimeSpent: 0, - }; - - const projectAggregatedOnPeriod = - await projectsAnalyticsService.getProjectOnPeriod(mockedEntry); + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-17", + end: "2026-06-21", + }, + }, + ])( + "should return an empty state if the time spent on the project on that period is zero", + async ({ mockedEntry }) => { + mockedDrizzle.limit.mockResolvedValue([ + { + name: "mooncode", + path: "/home/user/projects/mooncode", + }, + ]); - expect(projectAggregatedOnPeriod).toBeDefined(); - expect(projectAggregatedOnPeriod).toEqual(mockedOutput); - }); + mockedDrizzle.orderBy.mockResolvedValue([]); - it("should throw an error if the user doesn't have any project ", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - }; + const mockedOutput = { + name: "mooncode", + path: "/home/user/projects/mooncode", + totalTimeSpent: 0, + }; - mockedDrizzle.limit.mockResolvedValue([]); + const projectAggregatedOnPeriod = + await projectsAnalyticsService.getProjectOnPeriod(mockedEntry); - const error = await projectsAnalyticsService - .getProjectOnPeriod(mockedEntry) - .catch((e) => e); + expect(projectAggregatedOnPeriod).toBeDefined(); + expect(projectAggregatedOnPeriod).toEqual(mockedOutput); + }, + ); - expect(error).toBeInstanceOf(TRPCError); - expect(error).property("code").eql("NOT_FOUND"); - expect(error) - .property("message") - .match(/project/i); - }); + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + }, + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-17", + end: "2026-06-21", + }, + }, + ])( + "should throw an error if the user doesn't have any project ", + async ({ mockedEntry }) => { + mockedDrizzle.limit.mockResolvedValue([]); + + const error = await projectsAnalyticsService + .getProjectOnPeriod(mockedEntry) + .catch((e) => e); + + expect(error).toBeInstanceOf(TRPCError); + expect(error).property("code").eql("NOT_FOUND"); + expect(error) + .property("message") + .match(/project/i); + }, + ); }); describe("getProjectBranchesOnPeriod", () => { @@ -610,261 +787,345 @@ describe("ProjectsAnalyticsService", () => { }, ]; - it("should return an empty array if there is no data for the project of the period selected", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - start: "2026-06-17", - end: "2026-06-21", - branches: ["main", "test"], - groupBy: "days" as const, - periodResolution: "day" as const, - }; - - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue([]); - - const data = - await projectsAnalyticsService.getProjectPerDayOfPeriod(mockedEntry); + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + groupBy: "days" as const, + periodResolution: "day" as const, + }, + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + branches: ["main", "test"], + groupBy: "days" as const, + periodResolution: "day" as const, + }, + }, + ])( + "should return an empty array if there is no data for the project of the period selected", + async ({ mockedEntry }) => { + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue([]); + + const data = + await projectsAnalyticsService.getProjectPerDayOfPeriod(mockedEntry); + + expect(data).toBeDefined(); + expect(data).toEqual([]); + }, + ); - expect(data).toBeDefined(); - expect(data).toEqual([]); - }); - - it("should call the getProjectPerDayOfPeriodGroupedByDays utility function if the groupBy is 'days'", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - groupBy: "days" as const, - periodResolution: "day" as const, - }; + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + groupBy: "days" as const, + periodResolution: "day" as const, + }, + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-17", + end: "2026-06-21", + groupBy: "days" as const, + periodResolution: "day" as const, + }, + }, + ])( + "should call the getProjectPerDayOfPeriodGroupedByDays utility function if the groupBy is 'days'", + async ({ mockedEntry }) => { + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + + const getProjectPerDayOfPeriodGroupedByDaysSpy = vi.spyOn( + getProjectPerDayOfPeriodGroupedByDaysUtils, + "getProjectPerDayOfPeriodGroupedByDays", + ); - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + await projectsAnalyticsService.getProjectPerDayOfPeriod(mockedEntry); - const getProjectPerDayOfPeriodGroupedByDaysSpy = vi.spyOn( - getProjectPerDayOfPeriodGroupedByDaysUtils, - "getProjectPerDayOfPeriodGroupedByDays", - ); + expect(getProjectPerDayOfPeriodGroupedByDaysSpy).toHaveBeenCalled(); + expect(getProjectPerDayOfPeriodGroupedByDaysSpy).toHaveBeenCalledWith( + mockedTimeSpentPerDayOnProject, + ); + }, + ); - await projectsAnalyticsService.getProjectPerDayOfPeriod(mockedEntry); + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + periodResolution: "day" as const, + }, + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-17", + end: "2026-06-21", + periodResolution: "day" as const, + }, + }, + ])( + "should call the getProjectPerDayOfPeriodGroupedByDays utility function if the groupBy is undefined", + async ({ mockedEntry }) => { + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + + const getProjectPerDayOfPeriodGroupedByDaysSpy = vi.spyOn( + getProjectPerDayOfPeriodGroupedByDaysUtils, + "getProjectPerDayOfPeriodGroupedByDays", + ); - expect(getProjectPerDayOfPeriodGroupedByDaysSpy).toHaveBeenCalled(); - expect(getProjectPerDayOfPeriodGroupedByDaysSpy).toHaveBeenCalledWith( - mockedTimeSpentPerDayOnProject, - ); - }); + await projectsAnalyticsService.getProjectPerDayOfPeriod(mockedEntry); - it("should call the getProjectPerDayOfPeriodGroupedByDays utility function if the groupBy is undefined", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - periodResolution: "day" as const, - }; + expect(getProjectPerDayOfPeriodGroupedByDaysSpy).toHaveBeenCalled(); + expect(getProjectPerDayOfPeriodGroupedByDaysSpy).toHaveBeenCalledWith( + mockedTimeSpentPerDayOnProject, + ); + }, + ); - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + it.for([ + { + mockedEntry: { + start: "2026-06-12", + end: "2026-06-21", + groupBy: "weeks" as const, + periodResolution: "week" as const, + userId: "1", + name: "mooncode", + }, + }, + { + mockedEntry: { + start: "2026-06-12", + end: "2026-06-21", + branches: ["main", "test"], + groupBy: "weeks" as const, + periodResolution: "week" as const, + userId: "1", + name: "mooncode", + }, + }, + ])( + "should call the getDaysOfPeriodStatsGroupedByWeeks utility function if the groupBy is 'weeks'", + async ({ mockedEntry }) => { + const mockedTimeSpentPerDayOnProject = [ + { + id: "2", + timeSpent: 4500, + date: "2026-06-12", + }, + { + id: "3", + timeSpent: 1500, + date: "2026-06-13", + }, + { + id: "4", + timeSpent: 3800, + date: "2026-06-14", + }, + { + id: "5", + timeSpent: 14500, + date: "2026-06-15", + }, + { + id: "6", + timeSpent: 5900, + date: "2026-06-16", + }, + { + id: "7", + timeSpent: 4500, + date: "2026-06-17", + }, + { + id: "8", + timeSpent: 2500, + date: "2026-06-18", + }, + { + id: "9", + timeSpent: 12900, + date: "2026-06-19", + }, + { + id: "10", + timeSpent: 8200, + date: "2026-06-20", + }, + { + id: "11", + timeSpent: 6700, + date: "2026-06-21", + }, + ]; - const getProjectPerDayOfPeriodGroupedByDaysSpy = vi.spyOn( - getProjectPerDayOfPeriodGroupedByDaysUtils, - "getProjectPerDayOfPeriodGroupedByDays", - ); + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue(mockedTimeSpentPerDayOnProject); - await projectsAnalyticsService.getProjectPerDayOfPeriod(mockedEntry); + const getDaysOfPeriodStatsGroupedByWeeksSpy = vi.spyOn( + getProjectPerDayOfPeriodGroupedByWeeksUtils, + "getProjectPerDayOfPeriodGroupedByWeeks", + ); - expect(getProjectPerDayOfPeriodGroupedByDaysSpy).toHaveBeenCalled(); - expect(getProjectPerDayOfPeriodGroupedByDaysSpy).toHaveBeenCalledWith( - mockedTimeSpentPerDayOnProject, - ); - }); + await projectsAnalyticsService.getProjectPerDayOfPeriod(mockedEntry); - it("should call the getDaysOfPeriodStatsGroupedByWeeks utility function if the groupBy is 'weeks'", async () => { - const mockedEntry = { - start: "2026-06-12", - end: "2026-06-21", - branches: ["main", "test"], - groupBy: "weeks" as const, - periodResolution: "week" as const, - userId: "1", - name: "mooncode", - }; + expect(getDaysOfPeriodStatsGroupedByWeeksSpy).toHaveBeenCalled(); + expect(getDaysOfPeriodStatsGroupedByWeeksSpy).toHaveBeenCalledWith( + mockedTimeSpentPerDayOnProject, + mockedEntry.periodResolution, + ); + }, + ); - const mockedTimeSpentPerDayOnProject = [ - { - id: "2", - timeSpent: 4500, - date: "2026-06-12", - }, - { - id: "3", - timeSpent: 1500, - date: "2026-06-13", - }, - { - id: "4", - timeSpent: 3800, - date: "2026-06-14", - }, - { - id: "5", - timeSpent: 14500, - date: "2026-06-15", - }, - { - id: "6", - timeSpent: 5900, - date: "2026-06-16", - }, - { - id: "7", - timeSpent: 4500, - date: "2026-06-17", - }, - { - id: "8", - timeSpent: 2500, - date: "2026-06-18", - }, - { - id: "9", - timeSpent: 12900, - date: "2026-06-19", - }, - { - id: "10", - timeSpent: 8200, - date: "2026-06-20", + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-05-12", + end: "2026-06-21", + groupBy: "months" as const, + periodResolution: "month" as const, }, - { - id: "11", - timeSpent: 6700, - date: "2026-06-21", + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-05-12", + end: "2026-06-21", + groupBy: "months" as const, + periodResolution: "month" as const, }, - ]; + }, + ])( + "should call the getProjectPerDayOfPeriodGroupedByMonths utility function if the groupBy is 'months'", + async ({ mockedEntry }) => { + const mockedTimeSpentPerDayOnProject = [ + { + id: "2", + timeSpent: 4500, + date: "2026-05-12", + }, + { + id: "3", + timeSpent: 1500, + date: "2026-06-13", + }, + { + id: "4", + timeSpent: 3800, + date: "2026-06-14", + }, + { + id: "5", + timeSpent: 14500, + date: "2026-06-15", + }, + { + id: "6", + timeSpent: 5900, + date: "2026-06-16", + }, + { + id: "7", + timeSpent: 4500, + date: "2026-06-17", + }, + { + id: "8", + timeSpent: 2500, + date: "2026-06-18", + }, + { + id: "9", + timeSpent: 12900, + date: "2026-06-19", + }, + { + id: "10", + timeSpent: 8200, + date: "2026-06-20", + }, + { + id: "11", + timeSpent: 6700, + date: "2026-06-21", + }, + ]; - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue(mockedTimeSpentPerDayOnProject); - const getDaysOfPeriodStatsGroupedByWeeksSpy = vi.spyOn( - getProjectPerDayOfPeriodGroupedByWeeksUtils, - "getProjectPerDayOfPeriodGroupedByWeeks", - ); + const getProjectPerDayOfPeriodGroupedByMonthsSpy = vi.spyOn( + getProjectPerDayOfPeriodGroupedByMonthsUtils, + "getProjectPerDayOfPeriodGroupedByMonths", + ); - await projectsAnalyticsService.getProjectPerDayOfPeriod(mockedEntry); + await projectsAnalyticsService.getProjectPerDayOfPeriod(mockedEntry); - expect(getDaysOfPeriodStatsGroupedByWeeksSpy).toHaveBeenCalled(); - expect(getDaysOfPeriodStatsGroupedByWeeksSpy).toHaveBeenCalledWith( - mockedTimeSpentPerDayOnProject, - mockedEntry.periodResolution, - ); - }); + expect(getProjectPerDayOfPeriodGroupedByMonthsSpy).toHaveBeenCalled(); + expect(getProjectPerDayOfPeriodGroupedByMonthsSpy).toHaveBeenCalledWith( + mockedTimeSpentPerDayOnProject, + ); + }, + ); + }); - it("should call the getProjectPerDayOfPeriodGroupedByMonths utility function if the groupBy is 'months'", async () => { - const mockedEntry = { + describe.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + }, + }, + { + mockedEntry: { userId: "1", name: "mooncode", branches: ["main", "test"], - start: "2026-05-12", + start: "2026-06-17", end: "2026-06-21", - groupBy: "months" as const, - periodResolution: "month" as const, - }; - - const mockedTimeSpentPerDayOnProject = [ - { - id: "2", - timeSpent: 4500, - date: "2026-05-12", - }, - { - id: "3", - timeSpent: 1500, - date: "2026-06-13", - }, - { - id: "4", - timeSpent: 3800, - date: "2026-06-14", - }, - { - id: "5", - timeSpent: 14500, - date: "2026-06-15", - }, - { - id: "6", - timeSpent: 5900, - date: "2026-06-16", - }, - { - id: "7", - timeSpent: 4500, - date: "2026-06-17", - }, - { - id: "8", - timeSpent: 2500, - date: "2026-06-18", - }, - { - id: "9", - timeSpent: 12900, - date: "2026-06-19", - }, - { - id: "10", - timeSpent: 8200, - date: "2026-06-20", - }, - { - id: "11", - timeSpent: 6700, - date: "2026-06-21", - }, - ]; - - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue(mockedTimeSpentPerDayOnProject); - - const getProjectPerDayOfPeriodGroupedByMonthsSpy = vi.spyOn( - getProjectPerDayOfPeriodGroupedByMonthsUtils, - "getProjectPerDayOfPeriodGroupedByMonths", - ); - - await projectsAnalyticsService.getProjectPerDayOfPeriod(mockedEntry); - - expect(getProjectPerDayOfPeriodGroupedByMonthsSpy).toHaveBeenCalled(); - expect(getProjectPerDayOfPeriodGroupedByMonthsSpy).toHaveBeenCalledWith( - mockedTimeSpentPerDayOnProject, - ); - }); - }); - - describe("getProjectLanguagesTimeOnPeriod", () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - }; - + }, + }, + ])("getProjectLanguagesTimeOnPeriod", ({ mockedEntry }) => { const mockedTimeSpentPerDayOnProject = [ { date: "2026-06-17", @@ -1065,396 +1326,479 @@ describe("ProjectsAnalyticsService", () => { }); describe("getProjectLanguagesPerDayOfPeriod", () => { - it("should return an empty array if there is no data for the project of the period selected", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - groupBy: "days" as const, - periodResolution: "day" as const, - }; - - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue([]); - - const data = - await projectsAnalyticsService.getProjectLanguagesPerDayOfPeriod( - mockedEntry, - ); - - expect(data).toBeDefined(); - expect(data).toEqual([]); - }); - - it("should call the getProjectPerDayOfPeriodGroupedByDays utility function if the groupBy is 'days'", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - groupBy: "days" as const, - periodResolution: "day" as const, - }; - - const mockedTimeSpentPerDayOnProject = [ - { - date: "2026-06-17", - timeSpent: 4500, - }, - { - date: "2026-06-18", - timeSpent: 7500, + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + groupBy: "days" as const, + periodResolution: "day" as const, }, - { - date: "2026-06-19", - timeSpent: 12000, + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-17", + end: "2026-06-21", + groupBy: "days" as const, + periodResolution: "day" as const, }, - { - date: "2026-06-20", - timeSpent: 9800, - }, - { - date: "2026-06-21", - timeSpent: 15000, - }, - ]; + }, + ])( + "should return an empty array if there is no data for the project of the period selected", + async ({ mockedEntry }) => { + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue([]); + + const data = + await projectsAnalyticsService.getProjectLanguagesPerDayOfPeriod( + mockedEntry, + ); + + expect(data).toBeDefined(); + expect(data).toEqual([]); + }, + ); - const mockedLanguagesTimesPerDayOfPeriod = { - "2026-06-17": { - typescript: 2500, - rust: 2000, - }, - "2026-06-18": { - javascript: 4000, - typescript: 3500, - }, - "2026-06-19": { - javascript: 4000, - python: 5000, - html: 3000, - }, - "2026-06-20": { - go: 6000, - yaml: 3800, + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + groupBy: "days" as const, + periodResolution: "day" as const, }, - "2026-06-21": { - go: 6000, - docker: 9000, + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-17", + end: "2026-06-21", + groupBy: "days" as const, + periodResolution: "day" as const, }, - }; + }, + ])( + "should call the getProjectPerDayOfPeriodGroupedByDays utility function if the groupBy is 'days'", + async ({ mockedEntry }) => { + const mockedTimeSpentPerDayOnProject = [ + { + date: "2026-06-17", + timeSpent: 4500, + }, + { + date: "2026-06-18", + timeSpent: 7500, + }, + { + date: "2026-06-19", + timeSpent: 12000, + }, + { + date: "2026-06-20", + timeSpent: 9800, + }, + { + date: "2026-06-21", + timeSpent: 15000, + }, + ]; - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + const mockedLanguagesTimesPerDayOfPeriod = { + "2026-06-17": { + typescript: 2500, + rust: 2000, + }, + "2026-06-18": { + javascript: 4000, + typescript: 3500, + }, + "2026-06-19": { + javascript: 4000, + python: 5000, + html: 3000, + }, + "2026-06-20": { + go: 6000, + yaml: 3800, + }, + "2026-06-21": { + go: 6000, + docker: 9000, + }, + }; - vi.spyOn( - projectsAnalyticsService, - "getLanguagesTimePerDayOfPeriod", - ).mockResolvedValue(mockedLanguagesTimesPerDayOfPeriod); - - const getProjectLanguagesGroupedByDaysSpy = vi.spyOn( - getProjectLanguagesGroupedByDaysUtils, - "getProjectLanguagesGroupedByDays", - ); - - await projectsAnalyticsService.getProjectLanguagesPerDayOfPeriod( - mockedEntry, - ); - - expect(getProjectLanguagesGroupedByDaysSpy).toHaveBeenCalled(); - expect(getProjectLanguagesGroupedByDaysSpy).toHaveBeenCalledWith( - mockedTimeSpentPerDayOnProject, - mockedLanguagesTimesPerDayOfPeriod, - ); - }); + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue(mockedTimeSpentPerDayOnProject); - it("should call the getProjectPerDayOfPeriodGroupedByDays utility function if the groupBy is undefined", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - periodResolution: "day" as const, - }; + vi.spyOn( + projectsAnalyticsService, + "getLanguagesTimePerDayOfPeriod", + ).mockResolvedValue(mockedLanguagesTimesPerDayOfPeriod); - const mockedTimeSpentPerDayOnProject = [ - { - date: "2026-06-17", - timeSpent: 4500, - }, - { - date: "2026-06-18", - timeSpent: 7500, - }, - { - date: "2026-06-19", - timeSpent: 12000, - }, - { - date: "2026-06-20", - timeSpent: 9800, - }, - { - date: "2026-06-21", - timeSpent: 15000, - }, - ]; + const getProjectLanguagesGroupedByDaysSpy = vi.spyOn( + getProjectLanguagesGroupedByDaysUtils, + "getProjectLanguagesGroupedByDays", + ); - const mockedLanguagesTimesPerDayOfPeriod = { - "2026-06-17": { - typescript: 2500, - rust: 2000, - }, - "2026-06-18": { - javascript: 4000, - typescript: 3500, - }, - "2026-06-19": { - javascript: 4000, - python: 5000, - html: 3000, - }, - "2026-06-20": { - go: 6000, - yaml: 3800, + await projectsAnalyticsService.getProjectLanguagesPerDayOfPeriod( + mockedEntry, + ); + + expect(getProjectLanguagesGroupedByDaysSpy).toHaveBeenCalled(); + expect(getProjectLanguagesGroupedByDaysSpy).toHaveBeenCalledWith( + mockedTimeSpentPerDayOnProject, + mockedLanguagesTimesPerDayOfPeriod, + ); + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + periodResolution: "day" as const, }, - "2026-06-21": { - go: 6000, - docker: 9000, + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-17", + end: "2026-06-21", + periodResolution: "day" as const, }, - }; + }, + ])( + "should call the getProjectPerDayOfPeriodGroupedByDays utility function if the groupBy is undefined", + async ({ mockedEntry }) => { + const mockedTimeSpentPerDayOnProject = [ + { + date: "2026-06-17", + timeSpent: 4500, + }, + { + date: "2026-06-18", + timeSpent: 7500, + }, + { + date: "2026-06-19", + timeSpent: 12000, + }, + { + date: "2026-06-20", + timeSpent: 9800, + }, + { + date: "2026-06-21", + timeSpent: 15000, + }, + ]; - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + const mockedLanguagesTimesPerDayOfPeriod = { + "2026-06-17": { + typescript: 2500, + rust: 2000, + }, + "2026-06-18": { + javascript: 4000, + typescript: 3500, + }, + "2026-06-19": { + javascript: 4000, + python: 5000, + html: 3000, + }, + "2026-06-20": { + go: 6000, + yaml: 3800, + }, + "2026-06-21": { + go: 6000, + docker: 9000, + }, + }; - vi.spyOn( - projectsAnalyticsService, - "getLanguagesTimePerDayOfPeriod", - ).mockResolvedValue(mockedLanguagesTimesPerDayOfPeriod); - - const getProjectLanguagesGroupedByDaysSpy = vi.spyOn( - getProjectLanguagesGroupedByDaysUtils, - "getProjectLanguagesGroupedByDays", - ); - - await projectsAnalyticsService.getProjectLanguagesPerDayOfPeriod( - mockedEntry, - ); - - expect(getProjectLanguagesGroupedByDaysSpy).toHaveBeenCalled(); - expect(getProjectLanguagesGroupedByDaysSpy).toHaveBeenCalledWith( - mockedTimeSpentPerDayOnProject, - mockedLanguagesTimesPerDayOfPeriod, - ); - }); + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue(mockedTimeSpentPerDayOnProject); - it("should call the getProjectPerDayOfPeriodGroupedByWeeks utility function if the groupBy is 'weeks'", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-12", - end: "2026-06-21", - groupBy: "weeks" as const, - periodResolution: "week" as const, - }; + vi.spyOn( + projectsAnalyticsService, + "getLanguagesTimePerDayOfPeriod", + ).mockResolvedValue(mockedLanguagesTimesPerDayOfPeriod); - const mockedTimeSpentPerDayOnProject = [ - { date: "2026-06-12", timeSpent: 4500 }, - { date: "2026-06-13", timeSpent: 1500 }, - { date: "2026-06-14", timeSpent: 3800 }, - { date: "2026-06-15", timeSpent: 14500 }, - { date: "2026-06-16", timeSpent: 5900 }, - { date: "2026-06-17", timeSpent: 4500 }, - { date: "2026-06-18", timeSpent: 7500 }, - { date: "2026-06-19", timeSpent: 12000 }, - { date: "2026-06-20", timeSpent: 9800 }, - { date: "2026-06-21", timeSpent: 15000 }, - ]; + const getProjectLanguagesGroupedByDaysSpy = vi.spyOn( + getProjectLanguagesGroupedByDaysUtils, + "getProjectLanguagesGroupedByDays", + ); - const mockedLanguagesTimesPerDayOfPeriod = { - "2026-06-12": { rust: 2500, typescript: 2000 }, - "2026-06-13": { javascript: 1500 }, - "2026-06-14": { javascript: 1800, python: 2000 }, - "2026-06-15": { json: 4000, docker: 5000, yaml: 5500 }, - "2026-06-16": { yaml: 5900 }, - "2026-06-17": { typescript: 2500, rust: 2000 }, - "2026-06-18": { javascript: 4000, typescript: 3500 }, - "2026-06-19": { javascript: 4000, python: 5000, html: 3000 }, - "2026-06-20": { go: 6000, yaml: 3800 }, - "2026-06-21": { go: 6000, docker: 9000 }, - }; + await projectsAnalyticsService.getProjectLanguagesPerDayOfPeriod( + mockedEntry, + ); - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + expect(getProjectLanguagesGroupedByDaysSpy).toHaveBeenCalled(); + expect(getProjectLanguagesGroupedByDaysSpy).toHaveBeenCalledWith( + mockedTimeSpentPerDayOnProject, + mockedLanguagesTimesPerDayOfPeriod, + ); + }, + ); - vi.spyOn( - projectsAnalyticsService, - "getLanguagesTimePerDayOfPeriod", - ).mockResolvedValue(mockedLanguagesTimesPerDayOfPeriod); - - const getProjectLanguagesGroupedByWeeksSpy = vi.spyOn( - getProjectLanguagesGroupedByWeeksUtils, - "getProjectLanguagesGroupedByWeeks", - ); - - await projectsAnalyticsService.getProjectLanguagesPerDayOfPeriod( - mockedEntry, - ); - - expect(getProjectLanguagesGroupedByWeeksSpy).toHaveBeenCalled(); - expect(getProjectLanguagesGroupedByWeeksSpy).toHaveBeenCalledWith( - mockedTimeSpentPerDayOnProject, - mockedEntry.periodResolution, - mockedLanguagesTimesPerDayOfPeriod, - ); - }); + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-12", + end: "2026-06-21", + groupBy: "weeks" as const, + periodResolution: "week" as const, + }, + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-12", + end: "2026-06-21", + groupBy: "weeks" as const, + periodResolution: "week" as const, + }, + }, + ])( + "should call the getProjectPerDayOfPeriodGroupedByWeeks utility function if the groupBy is 'weeks'", + async ({ mockedEntry }) => { + const mockedTimeSpentPerDayOnProject = [ + { date: "2026-06-12", timeSpent: 4500 }, + { date: "2026-06-13", timeSpent: 1500 }, + { date: "2026-06-14", timeSpent: 3800 }, + { date: "2026-06-15", timeSpent: 14500 }, + { date: "2026-06-16", timeSpent: 5900 }, + { date: "2026-06-17", timeSpent: 4500 }, + { date: "2026-06-18", timeSpent: 7500 }, + { date: "2026-06-19", timeSpent: 12000 }, + { date: "2026-06-20", timeSpent: 9800 }, + { date: "2026-06-21", timeSpent: 15000 }, + ]; + + const mockedLanguagesTimesPerDayOfPeriod = { + "2026-06-12": { rust: 2500, typescript: 2000 }, + "2026-06-13": { javascript: 1500 }, + "2026-06-14": { javascript: 1800, python: 2000 }, + "2026-06-15": { json: 4000, docker: 5000, yaml: 5500 }, + "2026-06-16": { yaml: 5900 }, + "2026-06-17": { typescript: 2500, rust: 2000 }, + "2026-06-18": { javascript: 4000, typescript: 3500 }, + "2026-06-19": { javascript: 4000, python: 5000, html: 3000 }, + "2026-06-20": { go: 6000, yaml: 3800 }, + "2026-06-21": { go: 6000, docker: 9000 }, + }; + + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + + vi.spyOn( + projectsAnalyticsService, + "getLanguagesTimePerDayOfPeriod", + ).mockResolvedValue(mockedLanguagesTimesPerDayOfPeriod); + + const getProjectLanguagesGroupedByWeeksSpy = vi.spyOn( + getProjectLanguagesGroupedByWeeksUtils, + "getProjectLanguagesGroupedByWeeks", + ); - it("should call the getProjectPerDayOfPeriodGroupedByMonths utility function if the groupBy is 'months'", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-05-12", - end: "2026-06-21", - groupBy: "months" as const, - periodResolution: "month" as const, - }; + await projectsAnalyticsService.getProjectLanguagesPerDayOfPeriod( + mockedEntry, + ); - const mockedTimeSpentPerDayOnProject = [ - { date: "2026-05-12", timeSpent: 4500 }, - { date: "2026-05-13", timeSpent: 3200 }, - { date: "2026-05-14", timeSpent: 7800 }, - { date: "2026-05-15", timeSpent: 5100 }, - { date: "2026-05-16", timeSpent: 9300 }, - { date: "2026-05-17", timeSpent: 2700 }, - { date: "2026-05-18", timeSpent: 6400 }, - { date: "2026-05-19", timeSpent: 11200 }, - { date: "2026-05-20", timeSpent: 4800 }, - { date: "2026-05-21", timeSpent: 7600 }, - { date: "2026-05-22", timeSpent: 3300 }, - { date: "2026-05-23", timeSpent: 8900 }, - { date: "2026-05-24", timeSpent: 5500 }, - { date: "2026-05-25", timeSpent: 12100 }, - { date: "2026-05-26", timeSpent: 4200 }, - { date: "2026-05-27", timeSpent: 6800 }, - { date: "2026-05-28", timeSpent: 9700 }, - { date: "2026-05-29", timeSpent: 3600 }, - { date: "2026-05-30", timeSpent: 7100 }, - { date: "2026-05-31", timeSpent: 5400 }, - { date: "2026-06-01", timeSpent: 8300 }, - { date: "2026-06-02", timeSpent: 2900 }, - { date: "2026-06-03", timeSpent: 10500 }, - { date: "2026-06-04", timeSpent: 4600 }, - { date: "2026-06-05", timeSpent: 7300 }, - { date: "2026-06-06", timeSpent: 6100 }, - { date: "2026-06-07", timeSpent: 3800 }, - { date: "2026-06-08", timeSpent: 9200 }, - { date: "2026-06-09", timeSpent: 5700 }, - { date: "2026-06-10", timeSpent: 11800 }, - { date: "2026-06-11", timeSpent: 4100 }, - { date: "2026-06-12", timeSpent: 4500 }, - { date: "2026-06-13", timeSpent: 1500 }, - { date: "2026-06-14", timeSpent: 3800 }, - { date: "2026-06-15", timeSpent: 14500 }, - { date: "2026-06-16", timeSpent: 5900 }, - { date: "2026-06-17", timeSpent: 4500 }, - { date: "2026-06-18", timeSpent: 7500 }, - { date: "2026-06-19", timeSpent: 12000 }, - { date: "2026-06-20", timeSpent: 9800 }, - { date: "2026-06-21", timeSpent: 15000 }, - ]; + expect(getProjectLanguagesGroupedByWeeksSpy).toHaveBeenCalled(); + expect(getProjectLanguagesGroupedByWeeksSpy).toHaveBeenCalledWith( + mockedTimeSpentPerDayOnProject, + mockedEntry.periodResolution, + mockedLanguagesTimesPerDayOfPeriod, + ); + }, + ); - const mockedLanguagesTimesPerDayOfPeriod = { - "2026-05-12": { rust: 2500, typescript: 2000 }, - "2026-05-13": { python: 3200 }, - "2026-05-14": { typescript: 4000, javascript: 3800 }, - "2026-05-15": { go: 2600, yaml: 2500 }, - "2026-05-16": { rust: 5000, typescript: 4300 }, - "2026-05-17": { sql: 2700 }, - "2026-05-18": { python: 3200, json: 3200 }, - "2026-05-19": { typescript: 6000, html: 2600, css: 2600 }, - "2026-05-20": { go: 4800 }, - "2026-05-21": { javascript: 4000, python: 3600 }, - "2026-05-22": { yaml: 3300 }, - "2026-05-23": { rust: 4500, c: 4400 }, - "2026-05-24": { typescript: 3000, sql: 2500 }, - "2026-05-25": { json: 4000, docker: 4100, yaml: 4000 }, - "2026-05-26": { python: 4200 }, - "2026-05-27": { typescript: 3500, html: 1700, css: 1600 }, - "2026-05-28": { go: 5000, rust: 4700 }, - "2026-05-29": { sql: 3600 }, - "2026-05-30": { javascript: 3600, python: 3500 }, - "2026-05-31": { typescript: 5400 }, - "2026-06-01": { rust: 4200, c: 4100 }, - "2026-06-02": { yaml: 2900 }, - "2026-06-03": { typescript: 5500, javascript: 5000 }, - "2026-06-04": { docker: 4600 }, - "2026-06-05": { go: 3700, sql: 3600 }, - "2026-06-06": { python: 3100, json: 3000 }, - "2026-06-07": { typescript: 3800 }, - "2026-06-08": { rust: 4700, c: 4500 }, - "2026-06-09": { typescript: 3000, html: 1400, css: 1300 }, - "2026-06-10": { json: 4000, docker: 4000, yaml: 3800 }, - "2026-06-11": { go: 4100 }, - "2026-06-12": { rust: 2500, typescript: 2000 }, - "2026-06-13": { javascript: 1500 }, - "2026-06-14": { javascript: 1800, python: 2000 }, - "2026-06-15": { json: 4000, docker: 5000, yaml: 5500 }, - "2026-06-16": { yaml: 5900 }, - "2026-06-17": { typescript: 2500, rust: 2000 }, - "2026-06-18": { javascript: 4000, typescript: 3500 }, - "2026-06-19": { javascript: 4000, python: 5000, html: 3000 }, - "2026-06-20": { go: 6000, yaml: 3800 }, - "2026-06-21": { go: 6000, docker: 9000 }, - }; + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-05-12", + end: "2026-06-21", + groupBy: "months" as const, + periodResolution: "month" as const, + }, + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-05-12", + end: "2026-06-21", + groupBy: "months" as const, + periodResolution: "month" as const, + }, + }, + ])( + "should call the getProjectPerDayOfPeriodGroupedByMonths utility function if the groupBy is 'months'", + async ({ mockedEntry }) => { + const mockedTimeSpentPerDayOnProject = [ + { date: "2026-05-12", timeSpent: 4500 }, + { date: "2026-05-13", timeSpent: 3200 }, + { date: "2026-05-14", timeSpent: 7800 }, + { date: "2026-05-15", timeSpent: 5100 }, + { date: "2026-05-16", timeSpent: 9300 }, + { date: "2026-05-17", timeSpent: 2700 }, + { date: "2026-05-18", timeSpent: 6400 }, + { date: "2026-05-19", timeSpent: 11200 }, + { date: "2026-05-20", timeSpent: 4800 }, + { date: "2026-05-21", timeSpent: 7600 }, + { date: "2026-05-22", timeSpent: 3300 }, + { date: "2026-05-23", timeSpent: 8900 }, + { date: "2026-05-24", timeSpent: 5500 }, + { date: "2026-05-25", timeSpent: 12100 }, + { date: "2026-05-26", timeSpent: 4200 }, + { date: "2026-05-27", timeSpent: 6800 }, + { date: "2026-05-28", timeSpent: 9700 }, + { date: "2026-05-29", timeSpent: 3600 }, + { date: "2026-05-30", timeSpent: 7100 }, + { date: "2026-05-31", timeSpent: 5400 }, + { date: "2026-06-01", timeSpent: 8300 }, + { date: "2026-06-02", timeSpent: 2900 }, + { date: "2026-06-03", timeSpent: 10500 }, + { date: "2026-06-04", timeSpent: 4600 }, + { date: "2026-06-05", timeSpent: 7300 }, + { date: "2026-06-06", timeSpent: 6100 }, + { date: "2026-06-07", timeSpent: 3800 }, + { date: "2026-06-08", timeSpent: 9200 }, + { date: "2026-06-09", timeSpent: 5700 }, + { date: "2026-06-10", timeSpent: 11800 }, + { date: "2026-06-11", timeSpent: 4100 }, + { date: "2026-06-12", timeSpent: 4500 }, + { date: "2026-06-13", timeSpent: 1500 }, + { date: "2026-06-14", timeSpent: 3800 }, + { date: "2026-06-15", timeSpent: 14500 }, + { date: "2026-06-16", timeSpent: 5900 }, + { date: "2026-06-17", timeSpent: 4500 }, + { date: "2026-06-18", timeSpent: 7500 }, + { date: "2026-06-19", timeSpent: 12000 }, + { date: "2026-06-20", timeSpent: 9800 }, + { date: "2026-06-21", timeSpent: 15000 }, + ]; + + const mockedLanguagesTimesPerDayOfPeriod = { + "2026-05-12": { rust: 2500, typescript: 2000 }, + "2026-05-13": { python: 3200 }, + "2026-05-14": { typescript: 4000, javascript: 3800 }, + "2026-05-15": { go: 2600, yaml: 2500 }, + "2026-05-16": { rust: 5000, typescript: 4300 }, + "2026-05-17": { sql: 2700 }, + "2026-05-18": { python: 3200, json: 3200 }, + "2026-05-19": { typescript: 6000, html: 2600, css: 2600 }, + "2026-05-20": { go: 4800 }, + "2026-05-21": { javascript: 4000, python: 3600 }, + "2026-05-22": { yaml: 3300 }, + "2026-05-23": { rust: 4500, c: 4400 }, + "2026-05-24": { typescript: 3000, sql: 2500 }, + "2026-05-25": { json: 4000, docker: 4100, yaml: 4000 }, + "2026-05-26": { python: 4200 }, + "2026-05-27": { typescript: 3500, html: 1700, css: 1600 }, + "2026-05-28": { go: 5000, rust: 4700 }, + "2026-05-29": { sql: 3600 }, + "2026-05-30": { javascript: 3600, python: 3500 }, + "2026-05-31": { typescript: 5400 }, + "2026-06-01": { rust: 4200, c: 4100 }, + "2026-06-02": { yaml: 2900 }, + "2026-06-03": { typescript: 5500, javascript: 5000 }, + "2026-06-04": { docker: 4600 }, + "2026-06-05": { go: 3700, sql: 3600 }, + "2026-06-06": { python: 3100, json: 3000 }, + "2026-06-07": { typescript: 3800 }, + "2026-06-08": { rust: 4700, c: 4500 }, + "2026-06-09": { typescript: 3000, html: 1400, css: 1300 }, + "2026-06-10": { json: 4000, docker: 4000, yaml: 3800 }, + "2026-06-11": { go: 4100 }, + "2026-06-12": { rust: 2500, typescript: 2000 }, + "2026-06-13": { javascript: 1500 }, + "2026-06-14": { javascript: 1800, python: 2000 }, + "2026-06-15": { json: 4000, docker: 5000, yaml: 5500 }, + "2026-06-16": { yaml: 5900 }, + "2026-06-17": { typescript: 2500, rust: 2000 }, + "2026-06-18": { javascript: 4000, typescript: 3500 }, + "2026-06-19": { javascript: 4000, python: 5000, html: 3000 }, + "2026-06-20": { go: 6000, yaml: 3800 }, + "2026-06-21": { go: 6000, docker: 9000 }, + }; + + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + + vi.spyOn( + projectsAnalyticsService, + "getLanguagesTimePerDayOfPeriod", + ).mockResolvedValue(mockedLanguagesTimesPerDayOfPeriod); + + const getProjectLanguagesGroupedByMonthsSpy = vi.spyOn( + getProjectLanguagesGroupedByMonthsUtils, + "getProjectLanguagesGroupedByMonths", + ); - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + await projectsAnalyticsService.getProjectLanguagesPerDayOfPeriod( + mockedEntry, + ); - vi.spyOn( - projectsAnalyticsService, - "getLanguagesTimePerDayOfPeriod", - ).mockResolvedValue(mockedLanguagesTimesPerDayOfPeriod); - - const getProjectLanguagesGroupedByMonthsSpy = vi.spyOn( - getProjectLanguagesGroupedByMonthsUtils, - "getProjectLanguagesGroupedByMonths", - ); - - await projectsAnalyticsService.getProjectLanguagesPerDayOfPeriod( - mockedEntry, - ); - - expect(getProjectLanguagesGroupedByMonthsSpy).toHaveBeenCalled(); - expect(getProjectLanguagesGroupedByMonthsSpy).toHaveBeenCalledWith( - mockedTimeSpentPerDayOnProject, - mockedLanguagesTimesPerDayOfPeriod, - ); - }); + expect(getProjectLanguagesGroupedByMonthsSpy).toHaveBeenCalled(); + expect(getProjectLanguagesGroupedByMonthsSpy).toHaveBeenCalledWith( + mockedTimeSpentPerDayOnProject, + mockedLanguagesTimesPerDayOfPeriod, + ); + }, + ); }); - describe("getProjectDailyStats", () => { - const mockedEntry = { - dateString: "2026-06-26", - name: "mooncode", - branches: ["main", "test"], - userId: "1", - }; - + describe.for([ + { + mockedEntry: { + dateString: "2026-06-26", + name: "mooncode", + userId: "1", + }, + }, + { + mockedEntry: { + dateString: "2026-06-26", + name: "mooncode", + branches: ["main", "test"], + userId: "1", + }, + }, + ])("getProjectDailyStats", ({ mockedEntry }) => { it("should return an object containing the expected fields: formattedTotalTimeSpent and finalData when there the 'branches' parameter is specified", async () => { dailyDataService.findOne.mockResolvedValue({ id: "2", @@ -1842,930 +2186,683 @@ describe("ProjectsAnalyticsService", () => { }); describe("getPeriodGeneralStatsForProject", () => { - it("should return an empty state if there is no data on the selected period", async () => { - const mockedEntry = { - start: "2026-06-17", - end: "2026-06-21", - groupBy: "days" as const, - todaysDateString: "2026-06-23", - periodResolution: "day" as const, - userId: "1", - name: "mooncode", - branches: ["main", "test"], - }; + it.for([ + { + mockedEntry: { + start: "2026-06-17", + end: "2026-06-21", + groupBy: "days" as const, + todaysDateString: "2026-06-23", + periodResolution: "day" as const, + userId: "1", + name: "mooncode", + }, + }, + { + mockedEntry: { + start: "2026-06-17", + end: "2026-06-21", + groupBy: "days" as const, + todaysDateString: "2026-06-23", + periodResolution: "day" as const, + userId: "1", + name: "mooncode", + branches: ["main", "test"], + }, + }, + ])( + "should return an empty state if there is no data on the selected period", + async ({ mockedEntry }) => { + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue([]); + + const mockedOutput = { + avgTime: "0 secs", + mostActiveDate: "N/A", + mostUsedLanguageSlug: "N/A", + percentageToAvg: 0, + }; + + const data = + await projectsAnalyticsService.getPeriodGeneralStatsForProject( + mockedEntry, + ); + + expect(data).toBeDefined(); + expect(data).toEqual(mockedOutput); + }, + ); - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue([]); + it.for([ + { + mockedEntry: { + start: "2026-06-17", + end: "2026-06-21", + groupBy: "days" as const, + todaysDateString: "2026-06-23", + periodResolution: "day" as const, + userId: "1", + name: "mooncode", + }, + }, + { + mockedEntry: { + start: "2026-06-17", + end: "2026-06-21", + groupBy: "days" as const, + todaysDateString: "2026-06-23", + periodResolution: "day" as const, + userId: "1", + name: "mooncode", + branches: ["main", "test"], + }, + }, + ])( + "should call the getProjectGeneralStatsOnPeriodGroupedByDays utility function if the groupBy is 'days'", + async ({ mockedEntry }) => { + const mockedTimeSpentPerDayOnProject = [ + { + date: "2026-06-17", + timeSpent: 4500, + }, + { + date: "2026-06-18", + timeSpent: 7500, + }, + { + date: "2026-06-19", + timeSpent: 12000, + }, + { + date: "2026-06-20", + timeSpent: 9800, + }, + { + date: "2026-06-21", + timeSpent: 15000, + }, + ]; - const mockedOutput = { - avgTime: "0 secs", - mostActiveDate: "N/A", - mostUsedLanguageSlug: "N/A", - percentageToAvg: 0, - }; + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + + vi.spyOn( + projectsAnalyticsService, + "getProjectOnPeriod", + ).mockResolvedValue({ + name: "mooncode", + totalTimeSpent: 48800, + path: "/home/user/mooncode", + }); + + vi.spyOn( + projectsAnalyticsService, + "getLanguagesTimeOnPeriod", + ).mockResolvedValue({ + typescript: 12000, + rust: 14000, + python: 3000, + go: 13000, + yaml: 6800, + }); + + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 12500, + }); + + projectsService.findOne.mockResolvedValue({ + id: "3", + name: "mooncode", + path: "/home/user/mooncode", + timeSpent: 10000, + }); + + const getProjectGeneralStatsOnPeriodGroupedByDaysSpy = vi.spyOn( + getProjectGeneralStatsOnPeriodGroupedByDaysUtils, + "getProjectGeneralStatsOnPeriodGroupedByDays", + ); - const data = await projectsAnalyticsService.getPeriodGeneralStatsForProject( mockedEntry, ); - expect(data).toBeDefined(); - expect(data).toEqual(mockedOutput); - }); - - it("should call the getProjectGeneralStatsOnPeriodGroupedByDays utility function if the groupBy is 'days'", async () => { - const mockedEntry = { - start: "2026-06-17", - end: "2026-06-21", - groupBy: "days" as const, - todaysDateString: "2026-06-23", - periodResolution: "day" as const, - userId: "1", - name: "mooncode", - branches: ["main", "test"], - }; + expect( + getProjectGeneralStatsOnPeriodGroupedByDaysSpy, + ).toHaveBeenCalled(); + }, + ); - const mockedTimeSpentPerDayOnProject = [ - { - date: "2026-06-17", - timeSpent: 4500, + it.for([ + { + mockedEntry: { + start: "2026-06-17", + end: "2026-06-21", + todaysDateString: "2026-06-23", + periodResolution: "day" as const, + userId: "1", + name: "mooncode", }, - { - date: "2026-06-18", - timeSpent: 7500, - }, - { - date: "2026-06-19", - timeSpent: 12000, - }, - { - date: "2026-06-20", - timeSpent: 9800, - }, - { - date: "2026-06-21", - timeSpent: 15000, + }, + { + mockedEntry: { + start: "2026-06-17", + end: "2026-06-21", + todaysDateString: "2026-06-23", + periodResolution: "day" as const, + userId: "1", + name: "mooncode", + branches: ["main", "test"], }, - ]; - - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue(mockedTimeSpentPerDayOnProject); - - vi.spyOn( - projectsAnalyticsService, - "getProjectOnPeriod", - ).mockResolvedValue({ - name: "mooncode", - totalTimeSpent: 48800, - path: "/home/user/mooncode", - }); + }, + ])( + "should call the getProjectGeneralStatsOnPeriodGroupedByDays utility function if the groupBy is undefined", + async ({ mockedEntry }) => { + const mockedTimeSpentPerDayOnProject = [ + { + date: "2026-06-17", + timeSpent: 4500, + }, + { + date: "2026-06-18", + timeSpent: 7500, + }, + { + date: "2026-06-19", + timeSpent: 12000, + }, + { + date: "2026-06-20", + timeSpent: 9800, + }, + { + date: "2026-06-21", + timeSpent: 15000, + }, + ]; - vi.spyOn( - projectsAnalyticsService, - "getLanguagesTimeOnPeriod", - ).mockResolvedValue({ - typescript: 12000, - rust: 14000, - python: 3000, - go: 13000, - yaml: 6800, - }); + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue(mockedTimeSpentPerDayOnProject); - dailyDataService.findOne.mockResolvedValue({ - id: "2", - timeSpent: 12500, - }); - - projectsService.findOne.mockResolvedValue({ - id: "3", - name: "mooncode", - path: "/home/user/mooncode", - timeSpent: 10000, - }); + vi.spyOn( + projectsAnalyticsService, + "getProjectOnPeriod", + ).mockResolvedValue({ + name: "mooncode", + totalTimeSpent: 48800, + path: "/home/user/mooncode", + }); + + vi.spyOn( + projectsAnalyticsService, + "getLanguagesTimeOnPeriod", + ).mockResolvedValue({ + typescript: 12000, + rust: 14000, + python: 3000, + go: 13000, + yaml: 6800, + }); + + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 12500, + }); - const getProjectGeneralStatsOnPeriodGroupedByDaysSpy = vi.spyOn( - getProjectGeneralStatsOnPeriodGroupedByDaysUtils, - "getProjectGeneralStatsOnPeriodGroupedByDays", - ); + projectsService.findOne.mockResolvedValue({ + id: "3", + name: "mooncode", + path: "/home/user/mooncode", + timeSpent: 10000, + }); - await projectsAnalyticsService.getPeriodGeneralStatsForProject( - mockedEntry, - ); + const getProjectGeneralStatsOnPeriodGroupedByDaysSpy = vi.spyOn( + getProjectGeneralStatsOnPeriodGroupedByDaysUtils, + "getProjectGeneralStatsOnPeriodGroupedByDays", + ); - expect(getProjectGeneralStatsOnPeriodGroupedByDaysSpy).toHaveBeenCalled(); - }); + await projectsAnalyticsService.getPeriodGeneralStatsForProject( + mockedEntry, + ); - it("should call the getProjectGeneralStatsOnPeriodGroupedByDays utility function if the groupBy is undefined", async () => { - const mockedEntry = { - start: "2026-06-17", - end: "2026-06-21", - todaysDateString: "2026-06-23", - periodResolution: "day" as const, - userId: "1", - name: "mooncode", - branches: ["main", "test"], - }; + expect( + getProjectGeneralStatsOnPeriodGroupedByDaysSpy, + ).toHaveBeenCalled(); + }, + ); - const mockedTimeSpentPerDayOnProject = [ - { - date: "2026-06-17", - timeSpent: 4500, - }, - { - date: "2026-06-18", - timeSpent: 7500, - }, - { - date: "2026-06-19", - timeSpent: 12000, - }, - { - date: "2026-06-20", - timeSpent: 9800, + it.for([ + { + mockedEntry: { + start: "2026-06-12", + end: "2026-06-21", + todaysDateString: "2026-06-23", + groupBy: "weeks" as const, + periodResolution: "week" as const, + userId: "1", + name: "mooncode", }, - { - date: "2026-06-21", - timeSpent: 15000, + }, + { + mockedEntry: { + start: "2026-06-12", + end: "2026-06-21", + todaysDateString: "2026-06-23", + groupBy: "weeks" as const, + periodResolution: "week" as const, + userId: "1", + name: "mooncode", + branches: ["main", "test"], }, - ]; - - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue(mockedTimeSpentPerDayOnProject); - - vi.spyOn( - projectsAnalyticsService, - "getProjectOnPeriod", - ).mockResolvedValue({ - name: "mooncode", - totalTimeSpent: 48800, - path: "/home/user/mooncode", - }); - - vi.spyOn( - projectsAnalyticsService, - "getLanguagesTimeOnPeriod", - ).mockResolvedValue({ - typescript: 12000, - rust: 14000, - python: 3000, - go: 13000, - yaml: 6800, - }); - - dailyDataService.findOne.mockResolvedValue({ - id: "2", - timeSpent: 12500, - }); - - projectsService.findOne.mockResolvedValue({ - id: "3", - name: "mooncode", - path: "/home/user/mooncode", - timeSpent: 10000, - }); - - const getProjectGeneralStatsOnPeriodGroupedByDaysSpy = vi.spyOn( - getProjectGeneralStatsOnPeriodGroupedByDaysUtils, - "getProjectGeneralStatsOnPeriodGroupedByDays", - ); - - await projectsAnalyticsService.getPeriodGeneralStatsForProject( - mockedEntry, - ); - - expect(getProjectGeneralStatsOnPeriodGroupedByDaysSpy).toHaveBeenCalled(); - }); - - it("should call the getProjectGeneralStatsOnPeriodGroupedByWeeks utility function if the groupBy is 'weeks'", async () => { - const mockedEntry = { - start: "2026-06-12", - end: "2026-06-21", - todaysDateString: "2026-06-23", - groupBy: "weeks" as const, - periodResolution: "week" as const, - userId: "1", - name: "mooncode", - branches: ["main", "test"], - }; - - const mockedTimeSpentPerDayOnProject = [ - { date: "2026-06-12", timeSpent: 4500 }, - { date: "2026-06-13", timeSpent: 1500 }, - { date: "2026-06-14", timeSpent: 3800 }, - { date: "2026-06-15", timeSpent: 14500 }, - { date: "2026-06-16", timeSpent: 5900 }, - { date: "2026-06-17", timeSpent: 4500 }, - { date: "2026-06-18", timeSpent: 7500 }, - { date: "2026-06-19", timeSpent: 12000 }, - { date: "2026-06-20", timeSpent: 9800 }, - { date: "2026-06-21", timeSpent: 15000 }, - ]; - - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue(mockedTimeSpentPerDayOnProject); - - vi.spyOn( - projectsAnalyticsService, - "getProjectOnPeriod", - ).mockResolvedValue({ - name: "mooncode", - totalTimeSpent: 79000, - path: "/home/user/mooncode", - }); - - vi.spyOn( - projectsAnalyticsService, - "getLanguagesTimeOnPeriod", - ).mockResolvedValue({ - rust: 18000, - typescript: 16000, - go: 15800, - javascript: 13200, - docker: 7500, - yaml: 5500, - python: 3000, - }); - - dailyDataService.findOne.mockResolvedValue({ - id: "2", - timeSpent: 42500, - }); - - projectsService.findOne.mockResolvedValue({ - id: "3", - name: "mooncode", - path: "/home/user/mooncode", - timeSpent: 42500, - }); - - const getProjectGeneralStatsOnPeriodGroupedByWeeksSpy = vi.spyOn( - getProjectGeneralStatsOnPeriodGroupedByWeeksUtils, - "getProjectGeneralStatsOnPeriodGroupedByWeeks", - ); - - await projectsAnalyticsService.getPeriodGeneralStatsForProject( - mockedEntry, - ); - - expect( - getProjectGeneralStatsOnPeriodGroupedByWeeksSpy, - ).toHaveBeenCalled(); - }); - - it("should call the getProjectGeneralStatsOnPeriodGroupedByMonths utility function if the groupBy is 'months'", async () => { - const mockedEntry = { - start: "2026-05-12", - end: "2026-06-21", - todaysDateString: "2026-06-23", - groupBy: "months" as const, - periodResolution: "month" as const, - userId: "1", - name: "mooncode", - branches: ["main", "test"], - }; + }, + ])( + "should call the getProjectGeneralStatsOnPeriodGroupedByWeeks utility function if the groupBy is 'weeks'", + async ({ mockedEntry }) => { + const mockedTimeSpentPerDayOnProject = [ + { date: "2026-06-12", timeSpent: 4500 }, + { date: "2026-06-13", timeSpent: 1500 }, + { date: "2026-06-14", timeSpent: 3800 }, + { date: "2026-06-15", timeSpent: 14500 }, + { date: "2026-06-16", timeSpent: 5900 }, + { date: "2026-06-17", timeSpent: 4500 }, + { date: "2026-06-18", timeSpent: 7500 }, + { date: "2026-06-19", timeSpent: 12000 }, + { date: "2026-06-20", timeSpent: 9800 }, + { date: "2026-06-21", timeSpent: 15000 }, + ]; + + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + + vi.spyOn( + projectsAnalyticsService, + "getProjectOnPeriod", + ).mockResolvedValue({ + name: "mooncode", + totalTimeSpent: 79000, + path: "/home/user/mooncode", + }); + + vi.spyOn( + projectsAnalyticsService, + "getLanguagesTimeOnPeriod", + ).mockResolvedValue({ + rust: 18000, + typescript: 16000, + go: 15800, + javascript: 13200, + docker: 7500, + yaml: 5500, + python: 3000, + }); + + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 42500, + }); - const mockedTimeSpentPerDayOnProject = [ - { date: "2026-05-12", timeSpent: 4500 }, - { date: "2026-05-13", timeSpent: 3200 }, - { date: "2026-05-14", timeSpent: 7800 }, - { date: "2026-05-15", timeSpent: 5100 }, - { date: "2026-05-16", timeSpent: 9300 }, - { date: "2026-05-17", timeSpent: 2700 }, - { date: "2026-05-18", timeSpent: 6400 }, - { date: "2026-05-19", timeSpent: 11200 }, - { date: "2026-05-20", timeSpent: 4800 }, - { date: "2026-05-21", timeSpent: 7600 }, - { date: "2026-05-22", timeSpent: 3300 }, - { date: "2026-05-23", timeSpent: 8900 }, - { date: "2026-05-24", timeSpent: 5500 }, - { date: "2026-05-25", timeSpent: 12100 }, - { date: "2026-05-26", timeSpent: 4200 }, - { date: "2026-05-27", timeSpent: 6800 }, - { date: "2026-05-28", timeSpent: 9700 }, - { date: "2026-05-29", timeSpent: 3600 }, - { date: "2026-05-30", timeSpent: 7100 }, - { date: "2026-05-31", timeSpent: 5400 }, - { date: "2026-06-01", timeSpent: 8300 }, - { date: "2026-06-02", timeSpent: 2900 }, - { date: "2026-06-03", timeSpent: 10500 }, - { date: "2026-06-04", timeSpent: 4600 }, - { date: "2026-06-05", timeSpent: 7300 }, - { date: "2026-06-06", timeSpent: 6100 }, - { date: "2026-06-07", timeSpent: 3800 }, - { date: "2026-06-08", timeSpent: 9200 }, - { date: "2026-06-09", timeSpent: 5700 }, - { date: "2026-06-10", timeSpent: 11800 }, - { date: "2026-06-11", timeSpent: 4100 }, - { date: "2026-06-12", timeSpent: 4500 }, - { date: "2026-06-13", timeSpent: 1500 }, - { date: "2026-06-14", timeSpent: 3800 }, - { date: "2026-06-15", timeSpent: 14500 }, - { date: "2026-06-16", timeSpent: 5900 }, - { date: "2026-06-17", timeSpent: 4500 }, - { date: "2026-06-18", timeSpent: 7500 }, - { date: "2026-06-19", timeSpent: 12000 }, - { date: "2026-06-20", timeSpent: 9800 }, - { date: "2026-06-21", timeSpent: 15000 }, - ]; + projectsService.findOne.mockResolvedValue({ + id: "3", + name: "mooncode", + path: "/home/user/mooncode", + timeSpent: 42500, + }); - vi.spyOn( - projectsAnalyticsService, - "findProjectByNameOnRange", - ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + const getProjectGeneralStatsOnPeriodGroupedByWeeksSpy = vi.spyOn( + getProjectGeneralStatsOnPeriodGroupedByWeeksUtils, + "getProjectGeneralStatsOnPeriodGroupedByWeeks", + ); - vi.spyOn( - projectsAnalyticsService, - "getProjectOnPeriod", - ).mockResolvedValue({ - name: "mooncode", - totalTimeSpent: 282500, - path: "/home/user/mooncode", - }); + await projectsAnalyticsService.getPeriodGeneralStatsForProject( + mockedEntry, + ); - vi.spyOn( - projectsAnalyticsService, - "getLanguagesTimeOnPeriod", - ).mockResolvedValue({ - typescript: 62000, - rust: 55000, - go: 48500, - javascript: 42000, - python: 30000, - docker: 22000, - yaml: 13500, - json: 9500, - }); + expect( + getProjectGeneralStatsOnPeriodGroupedByWeeksSpy, + ).toHaveBeenCalled(); + }, + ); - dailyDataService.findOne.mockResolvedValue({ - id: "2", - timeSpent: 120500, - }); + it.for([ + { + mockedEntry: { + start: "2026-05-12", + end: "2026-06-21", + todaysDateString: "2026-06-23", + groupBy: "months" as const, + periodResolution: "month" as const, + userId: "1", + name: "mooncode", + }, + }, + { + mockedEntry: { + start: "2026-05-12", + end: "2026-06-21", + todaysDateString: "2026-06-23", + groupBy: "months" as const, + periodResolution: "month" as const, + userId: "1", + name: "mooncode", + branches: ["main", "test"], + }, + }, + ])( + "should call the getProjectGeneralStatsOnPeriodGroupedByMonths utility function if the groupBy is 'months'", + async ({ mockedEntry }) => { + const mockedTimeSpentPerDayOnProject = [ + { date: "2026-05-12", timeSpent: 4500 }, + { date: "2026-05-13", timeSpent: 3200 }, + { date: "2026-05-14", timeSpent: 7800 }, + { date: "2026-05-15", timeSpent: 5100 }, + { date: "2026-05-16", timeSpent: 9300 }, + { date: "2026-05-17", timeSpent: 2700 }, + { date: "2026-05-18", timeSpent: 6400 }, + { date: "2026-05-19", timeSpent: 11200 }, + { date: "2026-05-20", timeSpent: 4800 }, + { date: "2026-05-21", timeSpent: 7600 }, + { date: "2026-05-22", timeSpent: 3300 }, + { date: "2026-05-23", timeSpent: 8900 }, + { date: "2026-05-24", timeSpent: 5500 }, + { date: "2026-05-25", timeSpent: 12100 }, + { date: "2026-05-26", timeSpent: 4200 }, + { date: "2026-05-27", timeSpent: 6800 }, + { date: "2026-05-28", timeSpent: 9700 }, + { date: "2026-05-29", timeSpent: 3600 }, + { date: "2026-05-30", timeSpent: 7100 }, + { date: "2026-05-31", timeSpent: 5400 }, + { date: "2026-06-01", timeSpent: 8300 }, + { date: "2026-06-02", timeSpent: 2900 }, + { date: "2026-06-03", timeSpent: 10500 }, + { date: "2026-06-04", timeSpent: 4600 }, + { date: "2026-06-05", timeSpent: 7300 }, + { date: "2026-06-06", timeSpent: 6100 }, + { date: "2026-06-07", timeSpent: 3800 }, + { date: "2026-06-08", timeSpent: 9200 }, + { date: "2026-06-09", timeSpent: 5700 }, + { date: "2026-06-10", timeSpent: 11800 }, + { date: "2026-06-11", timeSpent: 4100 }, + { date: "2026-06-12", timeSpent: 4500 }, + { date: "2026-06-13", timeSpent: 1500 }, + { date: "2026-06-14", timeSpent: 3800 }, + { date: "2026-06-15", timeSpent: 14500 }, + { date: "2026-06-16", timeSpent: 5900 }, + { date: "2026-06-17", timeSpent: 4500 }, + { date: "2026-06-18", timeSpent: 7500 }, + { date: "2026-06-19", timeSpent: 12000 }, + { date: "2026-06-20", timeSpent: 9800 }, + { date: "2026-06-21", timeSpent: 15000 }, + ]; + + vi.spyOn( + projectsAnalyticsService, + "findProjectByNameOnRange", + ).mockResolvedValue(mockedTimeSpentPerDayOnProject); + + vi.spyOn( + projectsAnalyticsService, + "getProjectOnPeriod", + ).mockResolvedValue({ + name: "mooncode", + totalTimeSpent: 282500, + path: "/home/user/mooncode", + }); + + vi.spyOn( + projectsAnalyticsService, + "getLanguagesTimeOnPeriod", + ).mockResolvedValue({ + typescript: 62000, + rust: 55000, + go: 48500, + javascript: 42000, + python: 30000, + docker: 22000, + yaml: 13500, + json: 9500, + }); + + dailyDataService.findOne.mockResolvedValue({ + id: "2", + timeSpent: 120500, + }); - projectsService.findOne.mockResolvedValue({ - id: "3", - name: "mooncode", - path: "/home/user/mooncode", - timeSpent: 120500, - }); + projectsService.findOne.mockResolvedValue({ + id: "3", + name: "mooncode", + path: "/home/user/mooncode", + timeSpent: 120500, + }); - const getProjectGeneralStatsOnPeriodGroupedByMonthsSpy = vi.spyOn( - getProjectGeneralStatsOnPeriodGroupedByMonthsUtils, - "getProjectGeneralStatsOnPeriodGroupedByMonths", - ); + const getProjectGeneralStatsOnPeriodGroupedByMonthsSpy = vi.spyOn( + getProjectGeneralStatsOnPeriodGroupedByMonthsUtils, + "getProjectGeneralStatsOnPeriodGroupedByMonths", + ); - await projectsAnalyticsService.getPeriodGeneralStatsForProject( - mockedEntry, - ); + await projectsAnalyticsService.getPeriodGeneralStatsForProject( + mockedEntry, + ); - expect( - getProjectGeneralStatsOnPeriodGroupedByMonthsSpy, - ).toHaveBeenCalled(); - }); + expect( + getProjectGeneralStatsOnPeriodGroupedByMonthsSpy, + ).toHaveBeenCalled(); + }, + ); }); describe("getFilesOnPeriod", () => { - it("should return an object of all files with their path, name, time spent and language in the normal case", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - start: "2026-06-17", - end: "2026-06-21", - type: "normal" as const, - amount: 25, - branches: ["main", "test"], - }; - - mockedDrizzle.execute.mockResolvedValue([ - { - totalTimeSpent: 4537, - languageSlug: "typescript", - projectName: "mooncode", - name: "updateFilesDataAfterSync.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataAfterSync.ts", - }, - { - totalTimeSpent: 3694, - languageSlug: "typescript", - projectName: "mooncode", - name: "extension.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/extension.ts", - }, - { - totalTimeSpent: 3067, - languageSlug: "typescript", - projectName: "mooncode", - name: "useWebsocket.tsx", - path: "/home/user/mooncode/apps/dashboard/src/hooks/useWebsocket.tsx", - }, - { - totalTimeSpent: 2662, - languageSlug: "typescript", - projectName: "mooncode", - name: "serveDashboard.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serveDashboard.ts", - }, - { - totalTimeSpent: 2434, - languageSlug: "typescript", - projectName: "mooncode", - name: "getToken.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/auth/getToken.ts", - }, - { - totalTimeSpent: 2289, - languageSlug: "typescript", - projectName: "mooncode", - name: "periodicSyncData.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/periodicSyncData.ts", - }, - { - totalTimeSpent: 2182, - languageSlug: "typescript", - projectName: "mooncode", - name: "get-current-file-properties.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/get-current-file-properties.ts", - }, - { - totalTimeSpent: 1832, - languageSlug: "typescript", - projectName: "mooncode", - name: "useExtensionWebsocket.tsx", - path: "/home/user/mooncode/apps/dashboard/src/hooks/useExtensionWebsocket.tsx", - }, - { - totalTimeSpent: 1642, - languageSlug: "typescript", - projectName: "mooncode", - name: "client.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/trpc/client.ts", - }, - { - totalTimeSpent: 1453, - languageSlug: "typescript", - projectName: "mooncode", - name: "serveDashboardProd.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serve-dashboard/serveDashboardProd.ts", - }, - { - totalTimeSpent: 1172, - languageSlug: "typescript", - projectName: "mooncode", - name: "websocket.ts", - path: "/home/user/mooncode/apps/dashboard/src/utils/websocket.ts", - }, - { - totalTimeSpent: 1085, - languageSlug: "typescript", - projectName: "mooncode", - name: "constants.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/constants.ts", - }, - { - totalTimeSpent: 1049, - languageSlug: "typescript", - projectName: "mooncode", - name: "get-language-slug.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/languages/get-language-slug.ts", - }, - { - totalTimeSpent: 1014, - languageSlug: "typescript", - projectName: "mooncode", - name: "serveDashboardDev.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serve-dashboard/serveDashboardDev.ts", - }, - { - totalTimeSpent: 866, - languageSlug: "typescript", - projectName: "mooncode", - name: "calculate-time.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/time/calculate-time.ts", - }, - { - totalTimeSpent: 854, - languageSlug: "typescript", - projectName: "mooncode", - name: "registerAuthUriHandler.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/auth/registerAuthUriHandler.ts", - }, - { - totalTimeSpent: 821, - languageSlug: "typescript", - projectName: "mooncode", - name: "setStatusBarAfterLogin.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/status-bar/setStatusBarAfterLogin.ts", - }, - { - totalTimeSpent: 675, - languageSlug: "typescript", - projectName: "mooncode", - name: "login.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/auth/login.ts", - }, - { - totalTimeSpent: 648, - languageSlug: "go", - projectName: "mooncode", - name: "go.tmpl", - path: "/home/user/mooncode/packages/ui/src/go.tmpl", - }, - { - totalTimeSpent: 579, - languageSlug: "json", - projectName: "mooncode", - name: "package.json", - path: "/home/user/mooncode/apps/vscode-extension/package.json", - }, - { - totalTimeSpent: 525, - languageSlug: "typescript", - projectName: "mooncode", - name: "openDashboard.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/openDashboard.ts", - }, - { - totalTimeSpent: 515, - languageSlug: "json", - projectName: "mooncode", - name: "colors.json", - path: "/home/user/mooncode/packages/ui/src/colors.json", - }, - { - totalTimeSpent: 482, - languageSlug: "typescript", - projectName: "mooncode", - name: "trpc.service.ts", - path: "/home/user/mooncode/apps/api/src/trpc/trpc.service.ts", - }, - { - totalTimeSpent: 452, - languageSlug: "typescript", - projectName: "mooncode", - name: "logger.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/logger/logger.ts", - }, - { - totalTimeSpent: 429, - languageSlug: "go", - projectName: "mooncode", - name: "go.mod", - path: "/home/user/mooncode/packages/ui/src/go.mod", + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + type: "normal" as const, + amount: 25, }, - ]); - - const mockedOutput = { - "/home/user/mooncode/apps/api/src/trpc/trpc.service.ts": { - languageSlug: "typescript", - name: "trpc.service.ts", - totalTimeSpent: 482, + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + type: "normal" as const, + amount: 25, + branches: ["main", "test"], }, - "/home/user/mooncode/apps/dashboard/src/hooks/useExtensionWebsocket.tsx": + }, + ])( + "should return an object of all files with their path, name, time spent and language in the normal case", + async ({ mockedEntry }) => { + mockedDrizzle.execute.mockResolvedValue([ { + totalTimeSpent: 4537, languageSlug: "typescript", - name: "useExtensionWebsocket.tsx", - totalTimeSpent: 1832, + projectName: "mooncode", + name: "updateFilesDataAfterSync.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataAfterSync.ts", }, - "/home/user/mooncode/apps/dashboard/src/hooks/useWebsocket.tsx": { - languageSlug: "typescript", - name: "useWebsocket.tsx", - totalTimeSpent: 3067, - }, - "/home/user/mooncode/apps/dashboard/src/utils/websocket.ts": { - languageSlug: "typescript", - name: "websocket.ts", - totalTimeSpent: 1172, - }, - "/home/user/mooncode/apps/vscode-extension/package.json": { - languageSlug: "json", - name: "package.json", - totalTimeSpent: 579, - }, - "/home/user/mooncode/apps/vscode-extension/src/constants.ts": { - languageSlug: "typescript", - name: "constants.ts", - totalTimeSpent: 1085, - }, - "/home/user/mooncode/apps/vscode-extension/src/extension.ts": { - languageSlug: "typescript", - name: "extension.ts", - totalTimeSpent: 3694, - }, - "/home/user/mooncode/apps/vscode-extension/src/utils/auth/getToken.ts": { + totalTimeSpent: 3694, languageSlug: "typescript", - name: "getToken.ts", + projectName: "mooncode", + name: "extension.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/extension.ts", + }, + { + totalTimeSpent: 3067, + languageSlug: "typescript", + projectName: "mooncode", + name: "useWebsocket.tsx", + path: "/home/user/mooncode/apps/dashboard/src/hooks/useWebsocket.tsx", + }, + { + totalTimeSpent: 2662, + languageSlug: "typescript", + projectName: "mooncode", + name: "serveDashboard.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serveDashboard.ts", + }, + { totalTimeSpent: 2434, + languageSlug: "typescript", + projectName: "mooncode", + name: "getToken.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/auth/getToken.ts", }, - "/home/user/mooncode/apps/vscode-extension/src/utils/auth/login.ts": { - languageSlug: "typescript", - name: "login.ts", - totalTimeSpent: 675, - }, - "/home/user/mooncode/apps/vscode-extension/src/utils/auth/registerAuthUriHandler.ts": { + totalTimeSpent: 2289, languageSlug: "typescript", - name: "registerAuthUriHandler.ts", - totalTimeSpent: 854, + projectName: "mooncode", + name: "periodicSyncData.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/periodicSyncData.ts", }, - "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/openDashboard.ts": { + totalTimeSpent: 2182, languageSlug: "typescript", - name: "openDashboard.ts", - totalTimeSpent: 525, + projectName: "mooncode", + name: "get-current-file-properties.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/get-current-file-properties.ts", }, - "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serve-dashboard/serveDashboardDev.ts": { + totalTimeSpent: 1832, languageSlug: "typescript", - name: "serveDashboardDev.ts", - totalTimeSpent: 1014, + projectName: "mooncode", + name: "useExtensionWebsocket.tsx", + path: "/home/user/mooncode/apps/dashboard/src/hooks/useExtensionWebsocket.tsx", }, - "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serve-dashboard/serveDashboardProd.ts": { + totalTimeSpent: 1642, languageSlug: "typescript", - name: "serveDashboardProd.ts", - totalTimeSpent: 1453, + projectName: "mooncode", + name: "client.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/trpc/client.ts", }, - "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serveDashboard.ts": { + totalTimeSpent: 1453, languageSlug: "typescript", - name: "serveDashboard.ts", - totalTimeSpent: 2662, + projectName: "mooncode", + name: "serveDashboardProd.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serve-dashboard/serveDashboardProd.ts", }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/get-current-file-properties.ts": { + totalTimeSpent: 1172, languageSlug: "typescript", - name: "get-current-file-properties.ts", - totalTimeSpent: 2182, + projectName: "mooncode", + name: "websocket.ts", + path: "/home/user/mooncode/apps/dashboard/src/utils/websocket.ts", }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataAfterSync.ts": { + totalTimeSpent: 1085, languageSlug: "typescript", - name: "updateFilesDataAfterSync.ts", - totalTimeSpent: 4537, + projectName: "mooncode", + name: "constants.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/constants.ts", }, - "/home/user/mooncode/apps/vscode-extension/src/utils/languages/get-language-slug.ts": { + totalTimeSpent: 1049, languageSlug: "typescript", + projectName: "mooncode", name: "get-language-slug.ts", - totalTimeSpent: 1049, + path: "/home/user/mooncode/apps/vscode-extension/src/utils/languages/get-language-slug.ts", + }, + { + totalTimeSpent: 1014, + languageSlug: "typescript", + projectName: "mooncode", + name: "serveDashboardDev.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serve-dashboard/serveDashboardDev.ts", }, - "/home/user/mooncode/apps/vscode-extension/src/utils/logger/logger.ts": { + totalTimeSpent: 866, languageSlug: "typescript", - name: "logger.ts", - totalTimeSpent: 452, + projectName: "mooncode", + name: "calculate-time.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/time/calculate-time.ts", + }, + { + totalTimeSpent: 854, + languageSlug: "typescript", + projectName: "mooncode", + name: "registerAuthUriHandler.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/auth/registerAuthUriHandler.ts", + }, + { + totalTimeSpent: 821, + languageSlug: "typescript", + projectName: "mooncode", + name: "setStatusBarAfterLogin.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/status-bar/setStatusBarAfterLogin.ts", + }, + { + totalTimeSpent: 675, + languageSlug: "typescript", + projectName: "mooncode", + name: "login.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/auth/login.ts", + }, + { + totalTimeSpent: 648, + languageSlug: "go", + projectName: "mooncode", + name: "go.tmpl", + path: "/home/user/mooncode/packages/ui/src/go.tmpl", + }, + { + totalTimeSpent: 579, + languageSlug: "json", + projectName: "mooncode", + name: "package.json", + path: "/home/user/mooncode/apps/vscode-extension/package.json", + }, + { + totalTimeSpent: 525, + languageSlug: "typescript", + projectName: "mooncode", + name: "openDashboard.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/openDashboard.ts", + }, + { + totalTimeSpent: 515, + languageSlug: "json", + projectName: "mooncode", + name: "colors.json", + path: "/home/user/mooncode/packages/ui/src/colors.json", }, - "/home/user/mooncode/apps/vscode-extension/src/utils/periodicSyncData.ts": { + totalTimeSpent: 482, languageSlug: "typescript", - name: "periodicSyncData.ts", - totalTimeSpent: 2289, + projectName: "mooncode", + name: "trpc.service.ts", + path: "/home/user/mooncode/apps/api/src/trpc/trpc.service.ts", }, - "/home/user/mooncode/apps/vscode-extension/src/utils/status-bar/setStatusBarAfterLogin.ts": { + totalTimeSpent: 452, languageSlug: "typescript", - name: "setStatusBarAfterLogin.ts", - totalTimeSpent: 821, + projectName: "mooncode", + name: "logger.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/logger/logger.ts", }, - "/home/user/mooncode/apps/vscode-extension/src/utils/time/calculate-time.ts": { - languageSlug: "typescript", - name: "calculate-time.ts", - totalTimeSpent: 866, + totalTimeSpent: 429, + languageSlug: "go", + projectName: "mooncode", + name: "go.mod", + path: "/home/user/mooncode/packages/ui/src/go.mod", }, - "/home/user/mooncode/apps/vscode-extension/src/utils/trpc/client.ts": { - languageSlug: "typescript", - name: "client.ts", - totalTimeSpent: 1642, - }, - "/home/user/mooncode/packages/ui/src/colors.json": { - languageSlug: "json", - name: "colors.json", - totalTimeSpent: 515, - }, - "/home/user/mooncode/packages/ui/src/go.mod": { - languageSlug: "go", - name: "go.mod", - totalTimeSpent: 429, - }, - "/home/user/mooncode/packages/ui/src/go.tmpl": { - languageSlug: "go", - name: "go.tmpl", - totalTimeSpent: 648, - }, - }; - - const projectFilesOnPeriod = - await projectsAnalyticsService.getFilesOnPeriod(mockedEntry); - - expect(projectFilesOnPeriod).toBeDefined(); - expect(projectFilesOnPeriod).toEqual(mockedOutput); - }); - - it("should return an object of all files with their path, name, time spent and language as well as a boolean indicating if there is another page of data (hasNext)", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - type: "paginated" as const, - page: 1, - }; - - mockedDrizzle.execute.mockResolvedValue([ - { - totalTimeSpent: 4537, - languageSlug: "typescript", - projectName: "mooncode", - name: "updateFilesDataAfterSync.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataAfterSync.ts", - }, - { - totalTimeSpent: 3694, - languageSlug: "typescript", - projectName: "mooncode", - name: "extension.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/extension.ts", - }, - { - totalTimeSpent: 3067, - languageSlug: "typescript", - projectName: "mooncode", - name: "useWebsocket.tsx", - path: "/home/user/mooncode/apps/dashboard/src/hooks/useWebsocket.tsx", - }, - { - totalTimeSpent: 2662, - languageSlug: "typescript", - projectName: "mooncode", - name: "serveDashboard.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serveDashboard.ts", - }, - { - totalTimeSpent: 2434, - languageSlug: "typescript", - projectName: "mooncode", - name: "getToken.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/auth/getToken.ts", - }, - { - totalTimeSpent: 2289, - languageSlug: "typescript", - projectName: "mooncode", - name: "periodicSyncData.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/periodicSyncData.ts", - }, - { - totalTimeSpent: 2182, - languageSlug: "typescript", - projectName: "mooncode", - name: "get-current-file-properties.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/get-current-file-properties.ts", - }, - { - totalTimeSpent: 1832, - languageSlug: "typescript", - projectName: "mooncode", - name: "useExtensionWebsocket.tsx", - path: "/home/user/mooncode/apps/dashboard/src/hooks/useExtensionWebsocket.tsx", - }, - { - totalTimeSpent: 1642, - languageSlug: "typescript", - projectName: "mooncode", - name: "client.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/trpc/client.ts", - }, - { - totalTimeSpent: 1453, - languageSlug: "typescript", - projectName: "mooncode", - name: "serveDashboardProd.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serve-dashboard/serveDashboardProd.ts", - }, - { - totalTimeSpent: 1172, - languageSlug: "typescript", - projectName: "mooncode", - name: "websocket.ts", - path: "/home/user/mooncode/apps/dashboard/src/utils/websocket.ts", - }, - { - totalTimeSpent: 1085, - languageSlug: "typescript", - projectName: "mooncode", - name: "constants.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/constants.ts", - }, - { - totalTimeSpent: 1049, - languageSlug: "typescript", - projectName: "mooncode", - name: "get-language-slug.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/languages/get-language-slug.ts", - }, - { - totalTimeSpent: 1014, - languageSlug: "typescript", - projectName: "mooncode", - name: "serveDashboardDev.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serve-dashboard/serveDashboardDev.ts", - }, - { - totalTimeSpent: 866, - languageSlug: "typescript", - projectName: "mooncode", - name: "calculate-time.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/time/calculate-time.ts", - }, - { - totalTimeSpent: 854, - languageSlug: "typescript", - projectName: "mooncode", - name: "registerAuthUriHandler.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/auth/registerAuthUriHandler.ts", - }, - { - totalTimeSpent: 821, - languageSlug: "typescript", - projectName: "mooncode", - name: "setStatusBarAfterLogin.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/status-bar/setStatusBarAfterLogin.ts", - }, - { - totalTimeSpent: 675, - languageSlug: "typescript", - projectName: "mooncode", - name: "login.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/auth/login.ts", - }, - { - totalTimeSpent: 648, - languageSlug: "go", - projectName: "mooncode", - name: "go.tmpl", - path: "/home/user/mooncode/packages/ui/src/go.tmpl", - }, - { - totalTimeSpent: 579, - languageSlug: "json", - projectName: "mooncode", - name: "package.json", - path: "/home/user/mooncode/apps/vscode-extension/package.json", - }, - { - totalTimeSpent: 525, - languageSlug: "typescript", - projectName: "mooncode", - name: "openDashboard.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/openDashboard.ts", - }, - { - totalTimeSpent: 515, - languageSlug: "json", - projectName: "mooncode", - name: "colors.json", - path: "/home/user/mooncode/packages/ui/src/colors.json", - }, - { - totalTimeSpent: 482, - languageSlug: "typescript", - projectName: "mooncode", - name: "trpc.service.ts", - path: "/home/user/mooncode/apps/api/src/trpc/trpc.service.ts", - }, - { - totalTimeSpent: 452, - languageSlug: "typescript", - projectName: "mooncode", - name: "logger.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/logger/logger.ts", - }, - { - totalTimeSpent: 429, - languageSlug: "go", - projectName: "mooncode", - name: "go.mod", - path: "/home/user/mooncode/packages/ui/src/go.mod", - }, - ]); - - mockedDrizzle.from - .mockReturnValueOnce(mockedDrizzle) - .mockReturnValueOnce(mockedDrizzle) - .mockResolvedValueOnce([{ count: 32 }]); + ]); - const mockedOutput = { - projectFilesOnPeriod: { + const mockedOutput = { "/home/user/mooncode/apps/api/src/trpc/trpc.service.ts": { languageSlug: "typescript", name: "trpc.service.ts", @@ -2906,381 +3003,775 @@ describe("ProjectsAnalyticsService", () => { name: "go.tmpl", totalTimeSpent: 648, }, - }, - hasNext: true, - }; - - const { projectFilesOnPeriod, hasNext } = - await projectsAnalyticsService.getFilesOnPeriod(mockedEntry); + }; - expect(projectFilesOnPeriod).toBeDefined(); - expect(projectFilesOnPeriod).toEqual(mockedOutput.projectFilesOnPeriod); - - expect(hasNext).toBeDefined(); - expect(hasNext).toEqual(mockedOutput.hasNext); - }); + const projectFilesOnPeriod = + await projectsAnalyticsService.getFilesOnPeriod(mockedEntry); - it("should only keep the matching files when the search attribute is passed in parameter", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - type: "paginated" as const, - page: 1, - search: "file", - }; + expect(projectFilesOnPeriod).toBeDefined(); + expect(projectFilesOnPeriod).toEqual(mockedOutput); + }, + ); - mockedDrizzle.execute.mockResolvedValue([ - { - totalTimeSpent: 4537, - languageSlug: "typescript", - projectName: "mooncode", - name: "updateFilesDataAfterSync.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataAfterSync.ts", - }, - { - totalTimeSpent: 2182, - languageSlug: "typescript", - projectName: "mooncode", - name: "get-current-file-properties.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/get-current-file-properties.ts", - }, - { - totalTimeSpent: 292, - languageSlug: "typescript", - projectName: "mooncode", - name: "update-files-data-frozen-states.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-files-data-frozen-states.ts", - }, - { - totalTimeSpent: 199, - languageSlug: "typescript", - projectName: "mooncode", - name: "updateFilesDataElapsedTime.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataElapsedTime.ts", - }, - { - totalTimeSpent: 170, - languageSlug: "typescript", - projectName: "mooncode", - name: "update-current-file-obj.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-current-file-obj.ts", - }, - { - totalTimeSpent: 166, - languageSlug: "typescript", - projectName: "mooncode", - name: "update-files-data-after-sync.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-files-data-after-sync.ts", - }, - { - totalTimeSpent: 67, - languageSlug: "typescript", - projectName: "mooncode", - name: "updateFilesDataFrozenStates.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataFrozenStates.ts", - }, - { - totalTimeSpent: 51, - languageSlug: "typescript", - projectName: "mooncode", - name: "updateCurrentFileObj.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateCurrentFileObj.ts", - }, - { - totalTimeSpent: 46, - languageSlug: "typescript", - projectName: "mooncode", - name: "initializeFiles.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/initializeFiles.ts", - }, - { - totalTimeSpent: 40, - languageSlug: "typescript", - projectName: "mooncode", - name: "update-files-data-elapsed-time.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-files-data-elapsed-time.ts", - }, - { - totalTimeSpent: 38, - languageSlug: "typescript", - projectName: "mooncode", - name: "deleteFilesDataContent.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/deleteFilesDataContent.ts", - }, - { - totalTimeSpent: 27, - languageSlug: "typescript", - projectName: "mooncode", - name: "getCurrentFileProperties.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/getCurrentFileProperties.ts", - }, - { - totalTimeSpent: 23, - languageSlug: "typescript", - projectName: "mooncode", - name: "FilesList.tsx", - path: "/home/user/mooncode/apps/dashboard/src/components/project-page/files-list/FilesList.tsx", - }, - { - totalTimeSpent: 21, - languageSlug: "typescript", - projectName: "mooncode", - name: "initialize-files.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/initialize-files.ts", + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + type: "paginated" as const, + page: 1, }, - { - totalTimeSpent: 21, - languageSlug: "typescript", - projectName: "mooncode", - name: "delete-files-data-content.ts", - path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/delete-files-data-content.ts", + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-17", + end: "2026-06-21", + type: "paginated" as const, + page: 1, }, - ]); + }, + ])( + "should return an object of all files with their path, name, time spent and language as well as a boolean indicating if there is another page of data (hasNext)", + async ({ mockedEntry }) => { + mockedDrizzle.execute.mockResolvedValue([ + { + totalTimeSpent: 4537, + languageSlug: "typescript", + projectName: "mooncode", + name: "updateFilesDataAfterSync.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataAfterSync.ts", + }, + { + totalTimeSpent: 3694, + languageSlug: "typescript", + projectName: "mooncode", + name: "extension.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/extension.ts", + }, + { + totalTimeSpent: 3067, + languageSlug: "typescript", + projectName: "mooncode", + name: "useWebsocket.tsx", + path: "/home/user/mooncode/apps/dashboard/src/hooks/useWebsocket.tsx", + }, + { + totalTimeSpent: 2662, + languageSlug: "typescript", + projectName: "mooncode", + name: "serveDashboard.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serveDashboard.ts", + }, + { + totalTimeSpent: 2434, + languageSlug: "typescript", + projectName: "mooncode", + name: "getToken.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/auth/getToken.ts", + }, + { + totalTimeSpent: 2289, + languageSlug: "typescript", + projectName: "mooncode", + name: "periodicSyncData.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/periodicSyncData.ts", + }, + { + totalTimeSpent: 2182, + languageSlug: "typescript", + projectName: "mooncode", + name: "get-current-file-properties.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/get-current-file-properties.ts", + }, + { + totalTimeSpent: 1832, + languageSlug: "typescript", + projectName: "mooncode", + name: "useExtensionWebsocket.tsx", + path: "/home/user/mooncode/apps/dashboard/src/hooks/useExtensionWebsocket.tsx", + }, + { + totalTimeSpent: 1642, + languageSlug: "typescript", + projectName: "mooncode", + name: "client.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/trpc/client.ts", + }, + { + totalTimeSpent: 1453, + languageSlug: "typescript", + projectName: "mooncode", + name: "serveDashboardProd.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serve-dashboard/serveDashboardProd.ts", + }, + { + totalTimeSpent: 1172, + languageSlug: "typescript", + projectName: "mooncode", + name: "websocket.ts", + path: "/home/user/mooncode/apps/dashboard/src/utils/websocket.ts", + }, + { + totalTimeSpent: 1085, + languageSlug: "typescript", + projectName: "mooncode", + name: "constants.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/constants.ts", + }, + { + totalTimeSpent: 1049, + languageSlug: "typescript", + projectName: "mooncode", + name: "get-language-slug.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/languages/get-language-slug.ts", + }, + { + totalTimeSpent: 1014, + languageSlug: "typescript", + projectName: "mooncode", + name: "serveDashboardDev.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serve-dashboard/serveDashboardDev.ts", + }, + { + totalTimeSpent: 866, + languageSlug: "typescript", + projectName: "mooncode", + name: "calculate-time.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/time/calculate-time.ts", + }, + { + totalTimeSpent: 854, + languageSlug: "typescript", + projectName: "mooncode", + name: "registerAuthUriHandler.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/auth/registerAuthUriHandler.ts", + }, + { + totalTimeSpent: 821, + languageSlug: "typescript", + projectName: "mooncode", + name: "setStatusBarAfterLogin.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/status-bar/setStatusBarAfterLogin.ts", + }, + { + totalTimeSpent: 675, + languageSlug: "typescript", + projectName: "mooncode", + name: "login.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/auth/login.ts", + }, + { + totalTimeSpent: 648, + languageSlug: "go", + projectName: "mooncode", + name: "go.tmpl", + path: "/home/user/mooncode/packages/ui/src/go.tmpl", + }, + { + totalTimeSpent: 579, + languageSlug: "json", + projectName: "mooncode", + name: "package.json", + path: "/home/user/mooncode/apps/vscode-extension/package.json", + }, + { + totalTimeSpent: 525, + languageSlug: "typescript", + projectName: "mooncode", + name: "openDashboard.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/openDashboard.ts", + }, + { + totalTimeSpent: 515, + languageSlug: "json", + projectName: "mooncode", + name: "colors.json", + path: "/home/user/mooncode/packages/ui/src/colors.json", + }, + { + totalTimeSpent: 482, + languageSlug: "typescript", + projectName: "mooncode", + name: "trpc.service.ts", + path: "/home/user/mooncode/apps/api/src/trpc/trpc.service.ts", + }, + { + totalTimeSpent: 452, + languageSlug: "typescript", + projectName: "mooncode", + name: "logger.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/logger/logger.ts", + }, + { + totalTimeSpent: 429, + languageSlug: "go", + projectName: "mooncode", + name: "go.mod", + path: "/home/user/mooncode/packages/ui/src/go.mod", + }, + ]); - mockedDrizzle.from - .mockReturnValueOnce(mockedDrizzle) - .mockReturnValueOnce(mockedDrizzle) - .mockResolvedValueOnce([{ count: 15 }]); + mockedDrizzle.from + .mockReturnValueOnce(mockedDrizzle) + .mockReturnValueOnce(mockedDrizzle) + .mockResolvedValueOnce([{ count: 32 }]); - const mockedOutput = { - projectFilesOnPeriod: { - "/home/user/mooncode/apps/dashboard/src/components/project-page/files-list/FilesList.tsx": - { - languageSlug: "typescript", - name: "FilesList.tsx", - totalTimeSpent: 23, - }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/delete-files-data-content.ts": - { - languageSlug: "typescript", - name: "delete-files-data-content.ts", - totalTimeSpent: 21, - }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/deleteFilesDataContent.ts": - { - languageSlug: "typescript", - name: "deleteFilesDataContent.ts", - totalTimeSpent: 38, - }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/get-current-file-properties.ts": - { - languageSlug: "typescript", - name: "get-current-file-properties.ts", - totalTimeSpent: 2182, - }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/getCurrentFileProperties.ts": - { - languageSlug: "typescript", - name: "getCurrentFileProperties.ts", - totalTimeSpent: 27, - }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/initialize-files.ts": - { + const mockedOutput = { + projectFilesOnPeriod: { + "/home/user/mooncode/apps/api/src/trpc/trpc.service.ts": { languageSlug: "typescript", - name: "initialize-files.ts", - totalTimeSpent: 21, + name: "trpc.service.ts", + totalTimeSpent: 482, }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/initializeFiles.ts": - { - languageSlug: "typescript", - name: "initializeFiles.ts", - totalTimeSpent: 46, - }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-current-file-obj.ts": - { + "/home/user/mooncode/apps/dashboard/src/hooks/useExtensionWebsocket.tsx": + { + languageSlug: "typescript", + name: "useExtensionWebsocket.tsx", + totalTimeSpent: 1832, + }, + "/home/user/mooncode/apps/dashboard/src/hooks/useWebsocket.tsx": { languageSlug: "typescript", - name: "update-current-file-obj.ts", - totalTimeSpent: 170, + name: "useWebsocket.tsx", + totalTimeSpent: 3067, }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-files-data-after-sync.ts": - { + "/home/user/mooncode/apps/dashboard/src/utils/websocket.ts": { languageSlug: "typescript", - name: "update-files-data-after-sync.ts", - totalTimeSpent: 166, + name: "websocket.ts", + totalTimeSpent: 1172, }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-files-data-elapsed-time.ts": - { - languageSlug: "typescript", - name: "update-files-data-elapsed-time.ts", - totalTimeSpent: 40, + "/home/user/mooncode/apps/vscode-extension/package.json": { + languageSlug: "json", + name: "package.json", + totalTimeSpent: 579, }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-files-data-frozen-states.ts": - { + "/home/user/mooncode/apps/vscode-extension/src/constants.ts": { languageSlug: "typescript", - name: "update-files-data-frozen-states.ts", - totalTimeSpent: 292, + name: "constants.ts", + totalTimeSpent: 1085, }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateCurrentFileObj.ts": - { + "/home/user/mooncode/apps/vscode-extension/src/extension.ts": { languageSlug: "typescript", - name: "updateCurrentFileObj.ts", - totalTimeSpent: 51, + name: "extension.ts", + totalTimeSpent: 3694, }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataAfterSync.ts": - { - languageSlug: "typescript", - name: "updateFilesDataAfterSync.ts", - totalTimeSpent: 4537, + "/home/user/mooncode/apps/vscode-extension/src/utils/auth/getToken.ts": + { + languageSlug: "typescript", + name: "getToken.ts", + totalTimeSpent: 2434, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/auth/login.ts": + { + languageSlug: "typescript", + name: "login.ts", + totalTimeSpent: 675, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/auth/registerAuthUriHandler.ts": + { + languageSlug: "typescript", + name: "registerAuthUriHandler.ts", + totalTimeSpent: 854, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/openDashboard.ts": + { + languageSlug: "typescript", + name: "openDashboard.ts", + totalTimeSpent: 525, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serve-dashboard/serveDashboardDev.ts": + { + languageSlug: "typescript", + name: "serveDashboardDev.ts", + totalTimeSpent: 1014, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serve-dashboard/serveDashboardProd.ts": + { + languageSlug: "typescript", + name: "serveDashboardProd.ts", + totalTimeSpent: 1453, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/dashboard/serveDashboard.ts": + { + languageSlug: "typescript", + name: "serveDashboard.ts", + totalTimeSpent: 2662, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/get-current-file-properties.ts": + { + languageSlug: "typescript", + name: "get-current-file-properties.ts", + totalTimeSpent: 2182, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataAfterSync.ts": + { + languageSlug: "typescript", + name: "updateFilesDataAfterSync.ts", + totalTimeSpent: 4537, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/languages/get-language-slug.ts": + { + languageSlug: "typescript", + name: "get-language-slug.ts", + totalTimeSpent: 1049, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/logger/logger.ts": + { + languageSlug: "typescript", + name: "logger.ts", + totalTimeSpent: 452, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/periodicSyncData.ts": + { + languageSlug: "typescript", + name: "periodicSyncData.ts", + totalTimeSpent: 2289, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/status-bar/setStatusBarAfterLogin.ts": + { + languageSlug: "typescript", + name: "setStatusBarAfterLogin.ts", + totalTimeSpent: 821, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/time/calculate-time.ts": + { + languageSlug: "typescript", + name: "calculate-time.ts", + totalTimeSpent: 866, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/trpc/client.ts": + { + languageSlug: "typescript", + name: "client.ts", + totalTimeSpent: 1642, + }, + "/home/user/mooncode/packages/ui/src/colors.json": { + languageSlug: "json", + name: "colors.json", + totalTimeSpent: 515, }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataElapsedTime.ts": - { - languageSlug: "typescript", - name: "updateFilesDataElapsedTime.ts", - totalTimeSpent: 199, + "/home/user/mooncode/packages/ui/src/go.mod": { + languageSlug: "go", + name: "go.mod", + totalTimeSpent: 429, }, - "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataFrozenStates.ts": - { - languageSlug: "typescript", - name: "updateFilesDataFrozenStates.ts", - totalTimeSpent: 67, + "/home/user/mooncode/packages/ui/src/go.tmpl": { + languageSlug: "go", + name: "go.tmpl", + totalTimeSpent: 648, }, - }, - hasNext: false, - }; - - const { projectFilesOnPeriod, hasNext } = - await projectsAnalyticsService.getFilesOnPeriod(mockedEntry); + }, + hasNext: true, + }; - expect(projectFilesOnPeriod).toBeDefined(); - expect(projectFilesOnPeriod).toEqual(mockedOutput.projectFilesOnPeriod); + const { projectFilesOnPeriod, hasNext } = + await projectsAnalyticsService.getFilesOnPeriod(mockedEntry); - expect(hasNext).toBeDefined(); - expect(hasNext).toEqual(mockedOutput.hasNext); - }); + expect(projectFilesOnPeriod).toBeDefined(); + expect(projectFilesOnPeriod).toEqual(mockedOutput.projectFilesOnPeriod); - it("should only keep the matching files when there is a languages array in parameter", async () => { - const mockedEntry = { - userId: "1", - name: "mooncode", - branches: ["main", "test"], - start: "2026-06-17", - end: "2026-06-21", - type: "paginated" as const, - page: 1, - languages: ["go", "json"], - }; + expect(hasNext).toBeDefined(); + expect(hasNext).toEqual(mockedOutput.hasNext); + }, + ); - mockedDrizzle.execute.mockResolvedValue([ - { - totalTimeSpent: 648, - languageSlug: "go", - projectName: "mooncode", - name: "go.tmpl", - path: "/home/user/mooncode/packages/ui/src/go.tmpl", - }, - { - totalTimeSpent: 579, - languageSlug: "json", - projectName: "mooncode", - name: "package.json", - path: "/home/user/mooncode/apps/vscode-extension/package.json", - }, - { - totalTimeSpent: 515, - languageSlug: "json", - projectName: "mooncode", - name: "colors.json", - path: "/home/user/mooncode/packages/ui/src/colors.json", - }, - { - totalTimeSpent: 429, - languageSlug: "go", - projectName: "mooncode", - name: "go.mod", - path: "/home/user/mooncode/packages/ui/src/go.mod", - }, - { - totalTimeSpent: 196, - languageSlug: "go", - projectName: "mooncode", - name: "go.work", - path: "/home/user/mooncode/packages/ui/src/go.work", - }, - { - totalTimeSpent: 51, - languageSlug: "go", - projectName: "mooncode", - name: "go.sum", - path: "/home/user/mooncode/packages/ui/src/go.sum", - }, - { - totalTimeSpent: 38, - languageSlug: "go", - projectName: "mooncode", - name: "go.asm", - path: "/home/user/mooncode/packages/ui/src/go.asm", + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + type: "paginated" as const, + page: 1, + search: "file", }, - { - totalTimeSpent: 18, - languageSlug: "json", - projectName: "mooncode", - name: "package.json", - path: "/home/user/mooncode/package.json", + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-17", + end: "2026-06-21", + type: "paginated" as const, + page: 1, + search: "file", }, - ]); + }, + ])( + "should only keep the matching files when the search attribute is passed in parameter", + async ({ mockedEntry }) => { + mockedDrizzle.execute.mockResolvedValue([ + { + totalTimeSpent: 4537, + languageSlug: "typescript", + projectName: "mooncode", + name: "updateFilesDataAfterSync.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataAfterSync.ts", + }, + { + totalTimeSpent: 2182, + languageSlug: "typescript", + projectName: "mooncode", + name: "get-current-file-properties.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/get-current-file-properties.ts", + }, + { + totalTimeSpent: 292, + languageSlug: "typescript", + projectName: "mooncode", + name: "update-files-data-frozen-states.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-files-data-frozen-states.ts", + }, + { + totalTimeSpent: 199, + languageSlug: "typescript", + projectName: "mooncode", + name: "updateFilesDataElapsedTime.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataElapsedTime.ts", + }, + { + totalTimeSpent: 170, + languageSlug: "typescript", + projectName: "mooncode", + name: "update-current-file-obj.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-current-file-obj.ts", + }, + { + totalTimeSpent: 166, + languageSlug: "typescript", + projectName: "mooncode", + name: "update-files-data-after-sync.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-files-data-after-sync.ts", + }, + { + totalTimeSpent: 67, + languageSlug: "typescript", + projectName: "mooncode", + name: "updateFilesDataFrozenStates.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataFrozenStates.ts", + }, + { + totalTimeSpent: 51, + languageSlug: "typescript", + projectName: "mooncode", + name: "updateCurrentFileObj.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateCurrentFileObj.ts", + }, + { + totalTimeSpent: 46, + languageSlug: "typescript", + projectName: "mooncode", + name: "initializeFiles.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/initializeFiles.ts", + }, + { + totalTimeSpent: 40, + languageSlug: "typescript", + projectName: "mooncode", + name: "update-files-data-elapsed-time.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-files-data-elapsed-time.ts", + }, + { + totalTimeSpent: 38, + languageSlug: "typescript", + projectName: "mooncode", + name: "deleteFilesDataContent.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/deleteFilesDataContent.ts", + }, + { + totalTimeSpent: 27, + languageSlug: "typescript", + projectName: "mooncode", + name: "getCurrentFileProperties.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/getCurrentFileProperties.ts", + }, + { + totalTimeSpent: 23, + languageSlug: "typescript", + projectName: "mooncode", + name: "FilesList.tsx", + path: "/home/user/mooncode/apps/dashboard/src/components/project-page/files-list/FilesList.tsx", + }, + { + totalTimeSpent: 21, + languageSlug: "typescript", + projectName: "mooncode", + name: "initialize-files.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/initialize-files.ts", + }, + { + totalTimeSpent: 21, + languageSlug: "typescript", + projectName: "mooncode", + name: "delete-files-data-content.ts", + path: "/home/user/mooncode/apps/vscode-extension/src/utils/files/delete-files-data-content.ts", + }, + ]); + + mockedDrizzle.from + .mockReturnValueOnce(mockedDrizzle) + .mockReturnValueOnce(mockedDrizzle) + .mockResolvedValueOnce([{ count: 15 }]); + + const mockedOutput = { + projectFilesOnPeriod: { + "/home/user/mooncode/apps/dashboard/src/components/project-page/files-list/FilesList.tsx": + { + languageSlug: "typescript", + name: "FilesList.tsx", + totalTimeSpent: 23, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/delete-files-data-content.ts": + { + languageSlug: "typescript", + name: "delete-files-data-content.ts", + totalTimeSpent: 21, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/deleteFilesDataContent.ts": + { + languageSlug: "typescript", + name: "deleteFilesDataContent.ts", + totalTimeSpent: 38, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/get-current-file-properties.ts": + { + languageSlug: "typescript", + name: "get-current-file-properties.ts", + totalTimeSpent: 2182, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/getCurrentFileProperties.ts": + { + languageSlug: "typescript", + name: "getCurrentFileProperties.ts", + totalTimeSpent: 27, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/initialize-files.ts": + { + languageSlug: "typescript", + name: "initialize-files.ts", + totalTimeSpent: 21, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/initializeFiles.ts": + { + languageSlug: "typescript", + name: "initializeFiles.ts", + totalTimeSpent: 46, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-current-file-obj.ts": + { + languageSlug: "typescript", + name: "update-current-file-obj.ts", + totalTimeSpent: 170, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-files-data-after-sync.ts": + { + languageSlug: "typescript", + name: "update-files-data-after-sync.ts", + totalTimeSpent: 166, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-files-data-elapsed-time.ts": + { + languageSlug: "typescript", + name: "update-files-data-elapsed-time.ts", + totalTimeSpent: 40, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/update-files-data-frozen-states.ts": + { + languageSlug: "typescript", + name: "update-files-data-frozen-states.ts", + totalTimeSpent: 292, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateCurrentFileObj.ts": + { + languageSlug: "typescript", + name: "updateCurrentFileObj.ts", + totalTimeSpent: 51, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataAfterSync.ts": + { + languageSlug: "typescript", + name: "updateFilesDataAfterSync.ts", + totalTimeSpent: 4537, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataElapsedTime.ts": + { + languageSlug: "typescript", + name: "updateFilesDataElapsedTime.ts", + totalTimeSpent: 199, + }, + "/home/user/mooncode/apps/vscode-extension/src/utils/files/updateFilesDataFrozenStates.ts": + { + languageSlug: "typescript", + name: "updateFilesDataFrozenStates.ts", + totalTimeSpent: 67, + }, + }, + hasNext: false, + }; - mockedDrizzle.from - .mockReturnValueOnce(mockedDrizzle) - .mockReturnValueOnce(mockedDrizzle) - .mockResolvedValueOnce([{ count: 8 }]); + const { projectFilesOnPeriod, hasNext } = + await projectsAnalyticsService.getFilesOnPeriod(mockedEntry); - const mockedOutput = { - projectFilesOnPeriod: { - "/home/user/mooncode/apps/vscode-extension/package.json": { - languageSlug: "json", - name: "package.json", - totalTimeSpent: 579, + expect(projectFilesOnPeriod).toBeDefined(); + expect(projectFilesOnPeriod).toEqual(mockedOutput.projectFilesOnPeriod); + + expect(hasNext).toBeDefined(); + expect(hasNext).toEqual(mockedOutput.hasNext); + }, + ); + + it.for([ + { + mockedEntry: { + userId: "1", + name: "mooncode", + start: "2026-06-17", + end: "2026-06-21", + type: "paginated" as const, + page: 1, + languages: ["go", "json"], + }, + }, + { + mockedEntry: { + userId: "1", + name: "mooncode", + branches: ["main", "test"], + start: "2026-06-17", + end: "2026-06-21", + type: "paginated" as const, + page: 1, + languages: ["go", "json"], + }, + }, + ])( + "should only keep the matching files when there is a languages array in parameter", + async ({ mockedEntry }) => { + mockedDrizzle.execute.mockResolvedValue([ + { + totalTimeSpent: 648, + languageSlug: "go", + projectName: "mooncode", + name: "go.tmpl", + path: "/home/user/mooncode/packages/ui/src/go.tmpl", }, - "/home/user/mooncode/package.json": { + { + totalTimeSpent: 579, languageSlug: "json", + projectName: "mooncode", name: "package.json", - totalTimeSpent: 18, + path: "/home/user/mooncode/apps/vscode-extension/package.json", }, - "/home/user/mooncode/packages/ui/src/colors.json": { + { + totalTimeSpent: 515, languageSlug: "json", + projectName: "mooncode", name: "colors.json", - totalTimeSpent: 515, + path: "/home/user/mooncode/packages/ui/src/colors.json", }, - "/home/user/mooncode/packages/ui/src/go.asm": { + { + totalTimeSpent: 429, languageSlug: "go", - name: "go.asm", - totalTimeSpent: 38, + projectName: "mooncode", + name: "go.mod", + path: "/home/user/mooncode/packages/ui/src/go.mod", }, - "/home/user/mooncode/packages/ui/src/go.mod": { + { + totalTimeSpent: 196, languageSlug: "go", - name: "go.mod", - totalTimeSpent: 429, + projectName: "mooncode", + name: "go.work", + path: "/home/user/mooncode/packages/ui/src/go.work", }, - "/home/user/mooncode/packages/ui/src/go.sum": { + { + totalTimeSpent: 51, languageSlug: "go", + projectName: "mooncode", name: "go.sum", - totalTimeSpent: 51, + path: "/home/user/mooncode/packages/ui/src/go.sum", }, - "/home/user/mooncode/packages/ui/src/go.tmpl": { + { + totalTimeSpent: 38, languageSlug: "go", - name: "go.tmpl", - totalTimeSpent: 648, + projectName: "mooncode", + name: "go.asm", + path: "/home/user/mooncode/packages/ui/src/go.asm", }, - "/home/user/mooncode/packages/ui/src/go.work": { - languageSlug: "go", - name: "go.work", - totalTimeSpent: 196, + { + totalTimeSpent: 18, + languageSlug: "json", + projectName: "mooncode", + name: "package.json", + path: "/home/user/mooncode/package.json", }, - }, - hasNext: false, - }; + ]); + + mockedDrizzle.from + .mockReturnValueOnce(mockedDrizzle) + .mockReturnValueOnce(mockedDrizzle) + .mockResolvedValueOnce([{ count: 8 }]); + + const mockedOutput = { + projectFilesOnPeriod: { + "/home/user/mooncode/apps/vscode-extension/package.json": { + languageSlug: "json", + name: "package.json", + totalTimeSpent: 579, + }, + "/home/user/mooncode/package.json": { + languageSlug: "json", + name: "package.json", + totalTimeSpent: 18, + }, + "/home/user/mooncode/packages/ui/src/colors.json": { + languageSlug: "json", + name: "colors.json", + totalTimeSpent: 515, + }, + "/home/user/mooncode/packages/ui/src/go.asm": { + languageSlug: "go", + name: "go.asm", + totalTimeSpent: 38, + }, + "/home/user/mooncode/packages/ui/src/go.mod": { + languageSlug: "go", + name: "go.mod", + totalTimeSpent: 429, + }, + "/home/user/mooncode/packages/ui/src/go.sum": { + languageSlug: "go", + name: "go.sum", + totalTimeSpent: 51, + }, + "/home/user/mooncode/packages/ui/src/go.tmpl": { + languageSlug: "go", + name: "go.tmpl", + totalTimeSpent: 648, + }, + "/home/user/mooncode/packages/ui/src/go.work": { + languageSlug: "go", + name: "go.work", + totalTimeSpent: 196, + }, + }, + hasNext: false, + }; - const { projectFilesOnPeriod, hasNext } = - await projectsAnalyticsService.getFilesOnPeriod(mockedEntry); + const { projectFilesOnPeriod, hasNext } = + await projectsAnalyticsService.getFilesOnPeriod(mockedEntry); - expect(projectFilesOnPeriod).toBeDefined(); - expect(projectFilesOnPeriod).toEqual(mockedOutput.projectFilesOnPeriod); + expect(projectFilesOnPeriod).toBeDefined(); + expect(projectFilesOnPeriod).toEqual(mockedOutput.projectFilesOnPeriod); - expect(hasNext).toBeDefined(); - expect(hasNext).toEqual(mockedOutput.hasNext); - }); + expect(hasNext).toBeDefined(); + expect(hasNext).toEqual(mockedOutput.hasNext); + }, + ); }); }); From 9e82380ab669a6815f03ff70b1cb38503105af24 Mon Sep 17 00:00:00 2001 From: Friedrich482 Date: Thu, 16 Jul 2026 00:06:41 +0200 Subject: [PATCH 12/13] feat: web app - added the git branches tracking feature to the features section on the landing page --- apps/web/src/app/constants.ts | 6 ++++++ apps/web/src/constants.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/constants.ts b/apps/web/src/app/constants.ts index 8b40a68d..de5aa450 100644 --- a/apps/web/src/app/constants.ts +++ b/apps/web/src/app/constants.ts @@ -2,6 +2,7 @@ import { Activity, ChartColumnIcon, Code, + FolderGit2, LayoutDashboard, Timer, WifiOff, @@ -24,6 +25,11 @@ export const FEATURES: Feature[] = [ title: "Support for most languages and file extensions", text: "A wide variety of programming languages are supported and automatically detected by the VSCode extension, from the common ones to the more obscure.", }, + { + Icon: FolderGit2, + title: "Project Git branch stats", + text: "Coding stats filterable per Git branch(es) on each version-controlled project.", + }, { Icon: LayoutDashboard, title: "Local Dashboard", diff --git a/apps/web/src/constants.ts b/apps/web/src/constants.ts index b7714766..4acc36a0 100644 --- a/apps/web/src/constants.ts +++ b/apps/web/src/constants.ts @@ -1,11 +1,11 @@ import { type IconType, SiGithub, SiX } from "@icons-pack/react-simple-icons"; export const NAVBAR_LINKS: { href: string; text: string }[] = [ - { href: "architecture", text: "Architecture" }, { href: "features", text: "Features", }, + { href: "architecture", text: "Architecture" }, { href: "installation", text: "Installation", From 20d1cae3fa43831e694eb95d995126052d028b0b Mon Sep 17 00:00:00 2001 From: Friedrich482 Date: Thu, 16 Jul 2026 00:07:33 +0200 Subject: [PATCH 13/13] chore: extension version - bumped the extension version to `v0.0.70` --- apps/vscode-extension/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 6152ce75..6b4a332a 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "mooncode", "displayName": "MoonCode", "description": "MoonCode is an extension that tracks your coding time (like WakaTime) and gives you a detailed summary about all your coding statistics. With MoonCode, developers get the full history of their coding activity.", - "version": "0.0.69", + "version": "0.0.70", "icon": "./public/moon.png", "publisher": "Friedrich482", "author": {