From 0b08c94f46862e79fc095a4bb370dbc130528b28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:14:12 +0000 Subject: [PATCH 01/12] Initial plan From 24af9473eff5f5cff884061791ac00f66e8ca46c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:25:23 +0000 Subject: [PATCH 02/12] Add backend statistics endpoints for domain sense count and progress proportion Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com> --- .../Controllers/StatisticsControllerTests.cs | 32 +++++++ Backend.Tests/Mocks/StatisticsServiceMock.cs | 8 ++ Backend/Controllers/StatisticsController.cs | 34 ++++++++ Backend/Interfaces/IStatisticsService.cs | 2 + Backend/Services/StatisticsService.cs | 85 +++++++++++++++++++ 5 files changed, 161 insertions(+) diff --git a/Backend.Tests/Controllers/StatisticsControllerTests.cs b/Backend.Tests/Controllers/StatisticsControllerTests.cs index 03f4fbcef3..fba6522a4a 100644 --- a/Backend.Tests/Controllers/StatisticsControllerTests.cs +++ b/Backend.Tests/Controllers/StatisticsControllerTests.cs @@ -132,5 +132,37 @@ public async Task TestGetSemanticDomainUserCounts() var result = await _statsController.GetSemanticDomainUserCounts(_projId); Assert.That(result, Is.InstanceOf()); } + + [Test] + public async Task TestGetDomainSenseCountNoPermission() + { + _statsController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + + var result = await _statsController.GetDomainSenseCount(_projId, "1"); + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task TestGetDomainSenseCount() + { + var result = await _statsController.GetDomainSenseCount(_projId, "1"); + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task TestGetDomainProgressProportionNoPermission() + { + _statsController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + + var result = await _statsController.GetDomainProgressProportion(_projId, "1", "en"); + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task TestGetDomainProgressProportion() + { + var result = await _statsController.GetDomainProgressProportion(_projId, "1", "en"); + Assert.That(result, Is.InstanceOf()); + } } } diff --git a/Backend.Tests/Mocks/StatisticsServiceMock.cs b/Backend.Tests/Mocks/StatisticsServiceMock.cs index 4e55c3abd6..a8861b0dbd 100644 --- a/Backend.Tests/Mocks/StatisticsServiceMock.cs +++ b/Backend.Tests/Mocks/StatisticsServiceMock.cs @@ -28,5 +28,13 @@ public Task> GetSemanticDomainUserCounts(string pr { return Task.FromResult(new List()); } + public Task GetDomainSenseCount(string projectId, string domainId) + { + return Task.FromResult(0); + } + public Task GetDomainProgressProportion(string projectId, string domainId, string lang) + { + return Task.FromResult(0.0); + } } } diff --git a/Backend/Controllers/StatisticsController.cs b/Backend/Controllers/StatisticsController.cs index 092535f824..b54cdf9d6d 100644 --- a/Backend/Controllers/StatisticsController.cs +++ b/Backend/Controllers/StatisticsController.cs @@ -118,5 +118,39 @@ public async Task GetSemanticDomainUserCounts(string projectId) return Ok(await _statService.GetSemanticDomainUserCounts(projectId)); } + + /// Get the count of senses in a specific semantic domain + /// An integer count + [HttpGet("GetDomainSenseCount", Name = "GetDomainSenseCount")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(int))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetDomainSenseCount(string projectId, string domainId) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting domain sense count"); + + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.Statistics, projectId)) + { + return Forbid(); + } + + return Ok(await _statService.GetDomainSenseCount(projectId, domainId)); + } + + /// Get the proportion of descendant domains that have at least one entry + /// A double value between 0 and 1 + [HttpGet("GetDomainProgressProportion", Name = "GetDomainProgressProportion")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(double))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetDomainProgressProportion(string projectId, string domainId, string lang) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting domain progress proportion"); + + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.Statistics, projectId)) + { + return Forbid(); + } + + return Ok(await _statService.GetDomainProgressProportion(projectId, domainId, lang)); + } } } diff --git a/Backend/Interfaces/IStatisticsService.cs b/Backend/Interfaces/IStatisticsService.cs index 09229d5289..f478375959 100644 --- a/Backend/Interfaces/IStatisticsService.cs +++ b/Backend/Interfaces/IStatisticsService.cs @@ -12,6 +12,8 @@ public interface IStatisticsService Task GetProgressEstimationLineChartRoot(string projectId, List schedule); Task GetLineChartRootData(string projectId); Task> GetSemanticDomainUserCounts(string projectId); + Task GetDomainSenseCount(string projectId, string domainId); + Task GetDomainProgressProportion(string projectId, string domainId, string lang); } } diff --git a/Backend/Services/StatisticsService.cs b/Backend/Services/StatisticsService.cs index d09c6e22db..cdda3282de 100644 --- a/Backend/Services/StatisticsService.cs +++ b/Backend/Services/StatisticsService.cs @@ -355,5 +355,90 @@ public async Task> GetSemanticDomainUserCounts(str // return descending order by senseCount return resUserMap.Values.ToList().OrderByDescending(t => t.WordCount).ToList(); } + + /// + /// Get the count of senses in a specific semantic domain + /// + /// The project id + /// The semantic domain id + /// The count of senses with the specified domain + public async Task GetDomainSenseCount(string projectId, string domainId) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting domain sense count"); + + var wordList = await _wordRepo.GetFrontier(projectId); + var count = 0; + + foreach (var word in wordList) + { + foreach (var sense in word.Senses) + { + if (sense.SemanticDomains.Any(sd => sd.Id == domainId)) + { + count++; + } + } + } + + return count; + } + + /// + /// Get the proportion of descendant domains that have at least one entry + /// + /// The project id + /// The semantic domain id + /// The language code + /// A proportion value between 0 and 1 + public async Task GetDomainProgressProportion(string projectId, string domainId, string lang) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting domain progress proportion"); + + var domainTreeNodeList = await _domainRepo.GetAllSemanticDomainTreeNodes(lang); + if (domainTreeNodeList is null || domainTreeNodeList.Count == 0) + { + return 0.0; + } + + // Get all descendant domain IDs + var descendantIds = new List(); + foreach (var node in domainTreeNodeList) + { + if (node.Id.StartsWith(domainId, StringComparison.Ordinal) && node.Id != domainId) + { + // Check if it's a direct or indirect child (not just a string prefix match) + var relativePart = node.Id.Substring(domainId.Length); + if (relativePart.StartsWith('.')) + { + descendantIds.Add(node.Id); + } + } + } + + if (descendantIds.Count == 0) + { + return 0.0; + } + + // Get word list and count which descendants have at least one entry + var wordList = await _wordRepo.GetFrontier(projectId); + var domainsWithEntries = new HashSet(); + + foreach (var word in wordList) + { + foreach (var sense in word.Senses) + { + foreach (var sd in sense.SemanticDomains) + { + if (descendantIds.Contains(sd.Id)) + { + domainsWithEntries.Add(sd.Id); + } + } + } + } + + return (double)domainsWithEntries.Count / descendantIds.Count; + } } } From d3151d853572661b90d7f3c8819d09efe310fbef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:40:20 +0000 Subject: [PATCH 03/12] Add frontend components for visual progress indicators on domain tiles Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com> --- public/locales/en/translation.json | 3 +- src/api/api/statistics-api.ts | 291 ++++++++++++++++++ src/backend/index.ts | 23 ++ .../TreeView/TreeDepiction/CurrentRow.tsx | 47 ++- .../TreeDepiction/DomainTileButton.tsx | 62 +++- .../TreeDepiction/tests/CurrentRow.test.tsx | 10 + .../tests/DomainTileButton.test.tsx | 10 + .../TreeDepiction/tests/index.test.tsx | 11 + 8 files changed, 448 insertions(+), 9 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 909612a9db..0fdb068430 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -16,7 +16,8 @@ "treeView": { "findDomain": "Find a domain", "domainNotFound": "Domain not found", - "returnToTop": "Return to the top of the domain tree." + "returnToTop": "Return to the top of the domain tree.", + "senseCountTooltip": "Number of words gathered in this domain" }, "addWords": { "selectEntry": "Select an entry", diff --git a/src/api/api/statistics-api.ts b/src/api/api/statistics-api.ts index 96205a7008..456b7909e8 100644 --- a/src/api/api/statistics-api.ts +++ b/src/api/api/statistics-api.ts @@ -52,6 +52,116 @@ export const StatisticsApiAxiosParamCreator = function ( configuration?: Configuration ) { return { + /** + * + * @param {string} projectId + * @param {string} [domainId] + * @param {string} [lang] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDomainProgressProportion: async ( + projectId: string, + domainId?: string, + lang?: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("getDomainProgressProportion", "projectId", projectId); + const localVarPath = + `/v1/projects/{projectId}/statistics/GetDomainProgressProportion`.replace( + `{${"projectId"}}`, + encodeURIComponent(String(projectId)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (domainId !== undefined) { + localVarQueryParameter["domainId"] = domainId; + } + + if (lang !== undefined) { + localVarQueryParameter["lang"] = lang; + } + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} projectId + * @param {string} [domainId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDomainSenseCount: async ( + projectId: string, + domainId?: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("getDomainSenseCount", "projectId", projectId); + const localVarPath = + `/v1/projects/{projectId}/statistics/GetDomainSenseCount`.replace( + `{${"projectId"}}`, + encodeURIComponent(String(projectId)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (domainId !== undefined) { + localVarQueryParameter["domainId"] = domainId; + } + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} projectId @@ -303,6 +413,63 @@ export const StatisticsApiFp = function (configuration?: Configuration) { const localVarAxiosParamCreator = StatisticsApiAxiosParamCreator(configuration); return { + /** + * + * @param {string} projectId + * @param {string} [domainId] + * @param {string} [lang] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getDomainProgressProportion( + projectId: string, + domainId?: string, + lang?: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getDomainProgressProportion( + projectId, + domainId, + lang, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {string} projectId + * @param {string} [domainId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getDomainSenseCount( + projectId: string, + domainId?: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getDomainSenseCount( + projectId, + domainId, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, /** * * @param {string} projectId @@ -449,6 +616,40 @@ export const StatisticsApiFactory = function ( ) { const localVarFp = StatisticsApiFp(configuration); return { + /** + * + * @param {string} projectId + * @param {string} [domainId] + * @param {string} [lang] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDomainProgressProportion( + projectId: string, + domainId?: string, + lang?: string, + options?: any + ): AxiosPromise { + return localVarFp + .getDomainProgressProportion(projectId, domainId, lang, options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} projectId + * @param {string} [domainId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDomainSenseCount( + projectId: string, + domainId?: string, + options?: any + ): AxiosPromise { + return localVarFp + .getDomainSenseCount(projectId, domainId, options) + .then((request) => request(axios, basePath)); + }, /** * * @param {string} projectId @@ -524,6 +725,55 @@ export const StatisticsApiFactory = function ( }; }; +/** + * Request parameters for getDomainProgressProportion operation in StatisticsApi. + * @export + * @interface StatisticsApiGetDomainProgressProportionRequest + */ +export interface StatisticsApiGetDomainProgressProportionRequest { + /** + * + * @type {string} + * @memberof StatisticsApiGetDomainProgressProportion + */ + readonly projectId: string; + + /** + * + * @type {string} + * @memberof StatisticsApiGetDomainProgressProportion + */ + readonly domainId?: string; + + /** + * + * @type {string} + * @memberof StatisticsApiGetDomainProgressProportion + */ + readonly lang?: string; +} + +/** + * Request parameters for getDomainSenseCount operation in StatisticsApi. + * @export + * @interface StatisticsApiGetDomainSenseCountRequest + */ +export interface StatisticsApiGetDomainSenseCountRequest { + /** + * + * @type {string} + * @memberof StatisticsApiGetDomainSenseCount + */ + readonly projectId: string; + + /** + * + * @type {string} + * @memberof StatisticsApiGetDomainSenseCount + */ + readonly domainId?: string; +} + /** * Request parameters for getLineChartRootData operation in StatisticsApi. * @export @@ -608,6 +858,47 @@ export interface StatisticsApiGetWordsPerDayPerUserCountsRequest { * @extends {BaseAPI} */ export class StatisticsApi extends BaseAPI { + /** + * + * @param {StatisticsApiGetDomainProgressProportionRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof StatisticsApi + */ + public getDomainProgressProportion( + requestParameters: StatisticsApiGetDomainProgressProportionRequest, + options?: any + ) { + return StatisticsApiFp(this.configuration) + .getDomainProgressProportion( + requestParameters.projectId, + requestParameters.domainId, + requestParameters.lang, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {StatisticsApiGetDomainSenseCountRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof StatisticsApi + */ + public getDomainSenseCount( + requestParameters: StatisticsApiGetDomainSenseCountRequest, + options?: any + ) { + return StatisticsApiFp(this.configuration) + .getDomainSenseCount( + requestParameters.projectId, + requestParameters.domainId, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + /** * * @param {StatisticsApiGetLineChartRootDataRequest} requestParameters Request parameters. diff --git a/src/backend/index.ts b/src/backend/index.ts index 69ea33a8ae..c63aee60bd 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -689,6 +689,29 @@ export async function getProgressEstimationLineChartRoot( return response.data ?? undefined; } +export async function getDomainSenseCount( + projectId: string, + domainId: string +): Promise { + const response = await statisticsApi.getDomainSenseCount( + { projectId: projectId, domainId: domainId }, + defaultOptions() + ); + return response.data; +} + +export async function getDomainProgressProportion( + projectId: string, + domainId: string, + lang: string +): Promise { + const response = await statisticsApi.getDomainProgressProportion( + { projectId: projectId, domainId: domainId, lang: lang }, + defaultOptions() + ); + return response.data; +} + /* UserController.cs */ export async function verifyCaptchaToken(token: string): Promise { diff --git a/src/components/TreeView/TreeDepiction/CurrentRow.tsx b/src/components/TreeView/TreeDepiction/CurrentRow.tsx index 90a68ca9a9..e55882caf2 100644 --- a/src/components/TreeView/TreeDepiction/CurrentRow.tsx +++ b/src/components/TreeView/TreeDepiction/CurrentRow.tsx @@ -1,6 +1,16 @@ -import { Box, Button, Grid2, ImageList, ImageListItem } from "@mui/material"; -import { ReactElement } from "react"; +import { + Badge, + Box, + Button, + Grid2, + ImageList, + ImageListItem, + Tooltip, +} from "@mui/material"; +import { ReactElement, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { getDomainSenseCount } from "backend"; import DomainTileButton, { DomainText, } from "components/TreeView/TreeDepiction/DomainTileButton"; @@ -9,6 +19,8 @@ import { TreeRowProps, } from "components/TreeView/TreeDepiction/TreeDepictionTypes"; import { parent as parentSvg } from "resources/tree"; +import { useAppSelector } from "rootRedux/hooks"; +import { type StoreState } from "rootRedux/types"; const currentDomainButtonId = "current-domain"; @@ -22,6 +34,17 @@ export default function CurrentRow(props: TreeRowProps): ReactElement { function CurrentTile(props: TreeRowProps): ReactElement { const { animate, currentDomain } = props; + const { t } = useTranslation(); + const projectId = useAppSelector( + (state: StoreState) => state.currentProjectState.project.id + ); + const [senseCount, setSenseCount] = useState(undefined); + + useEffect(() => { + if (projectId && currentDomain.id) { + getDomainSenseCount(projectId, currentDomain.id).then(setSenseCount); + } + }, [projectId, currentDomain.id]); return ( ); } diff --git a/src/components/TreeView/TreeDepiction/DomainTileButton.tsx b/src/components/TreeView/TreeDepiction/DomainTileButton.tsx index 06021f60ee..e6a0614ec7 100644 --- a/src/components/TreeView/TreeDepiction/DomainTileButton.tsx +++ b/src/components/TreeView/TreeDepiction/DomainTileButton.tsx @@ -4,12 +4,15 @@ import { KeyboardArrowDown, KeyboardArrowUp, } from "@mui/icons-material"; -import { Button, Stack, Typography } from "@mui/material"; -import { ReactElement } from "react"; +import { Box, Button, Stack, Typography, useTheme } from "@mui/material"; +import { ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { SemanticDomain } from "api/models"; +import { getDomainProgressProportion } from "backend"; import { Direction } from "components/TreeView/TreeDepiction/TreeDepictionTypes"; +import { useAppSelector } from "rootRedux/hooks"; +import { type StoreState } from "rootRedux/types"; import { rootId } from "types/semanticDomain"; interface DomainTextProps { @@ -82,17 +85,66 @@ interface DomainTileButtonProps extends DomainTileProps { export default function DomainTileButton( props: DomainTileButtonProps ): ReactElement { - const { onClick, ...domainTileProps } = props; + const { onClick, direction, ...domainTileProps } = props; + const theme = useTheme(); + const projectId = useAppSelector( + (state: StoreState) => state.currentProjectState.project.id + ); + const lang = useAppSelector( + (state: StoreState) => state.treeViewState.currentDomain.lang + ); + const [progressProportion, setProgressProportion] = useState< + number | undefined + >(undefined); + + const shouldShowProgress = + direction === Direction.Down || + direction === Direction.Prev || + direction === Direction.Next; + + useEffect(() => { + if (shouldShowProgress && projectId && props.domain.id && lang) { + getDomainProgressProportion(projectId, props.domain.id, lang).then( + setProgressProportion + ); + } + }, [shouldShowProgress, projectId, props.domain.id, lang]); + return ( ); } diff --git a/src/components/TreeView/TreeDepiction/tests/CurrentRow.test.tsx b/src/components/TreeView/TreeDepiction/tests/CurrentRow.test.tsx index 82e5862fb8..88bdd12d36 100644 --- a/src/components/TreeView/TreeDepiction/tests/CurrentRow.test.tsx +++ b/src/components/TreeView/TreeDepiction/tests/CurrentRow.test.tsx @@ -8,6 +8,16 @@ import testDomainMap, { mapIds, } from "components/TreeView/tests/SemanticDomainMock"; +// Mock the backend API +jest.mock("backend", () => ({ + getDomainSenseCount: jest.fn(() => Promise.resolve(0)), +})); + +// Mock the Redux hooks +jest.mock("rootRedux/hooks", () => ({ + useAppSelector: jest.fn(() => "test-project-id"), +})); + const mockAnimate = jest.fn(); beforeEach(() => { diff --git a/src/components/TreeView/TreeDepiction/tests/DomainTileButton.test.tsx b/src/components/TreeView/TreeDepiction/tests/DomainTileButton.test.tsx index d0b1f14782..629647359c 100644 --- a/src/components/TreeView/TreeDepiction/tests/DomainTileButton.test.tsx +++ b/src/components/TreeView/TreeDepiction/tests/DomainTileButton.test.tsx @@ -4,6 +4,16 @@ import DomainTileButton from "components/TreeView/TreeDepiction/DomainTileButton import { Direction } from "components/TreeView/TreeDepiction/TreeDepictionTypes"; import domMap, { mapIds } from "components/TreeView/tests/SemanticDomainMock"; +// Mock the backend API +jest.mock("backend", () => ({ + getDomainProgressProportion: jest.fn(() => Promise.resolve(0.5)), +})); + +// Mock the Redux hooks +jest.mock("rootRedux/hooks", () => ({ + useAppSelector: jest.fn(() => "test-project-id"), +})); + const MOCK_ANIMATE = jest.fn(); describe("DomainTileButton", () => { diff --git a/src/components/TreeView/TreeDepiction/tests/index.test.tsx b/src/components/TreeView/TreeDepiction/tests/index.test.tsx index d5bfedd014..e8c82d8f72 100644 --- a/src/components/TreeView/TreeDepiction/tests/index.test.tsx +++ b/src/components/TreeView/TreeDepiction/tests/index.test.tsx @@ -5,6 +5,17 @@ import testDomainMap, { mapIds, } from "components/TreeView/tests/SemanticDomainMock"; +// Mock the backend API +jest.mock("backend", () => ({ + getDomainSenseCount: jest.fn(() => Promise.resolve(0)), + getDomainProgressProportion: jest.fn(() => Promise.resolve(0.5)), +})); + +// Mock the Redux hooks +jest.mock("rootRedux/hooks", () => ({ + useAppSelector: jest.fn(() => "test-project-id"), +})); + describe("TreeDepiction", () => { for (const small of [false, true]) { describe(small ? "renders narrow" : "renders wide", () => { From 61510e2abb5c7f544dff9628c16c9ddda2c6abe3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:42:22 +0000 Subject: [PATCH 04/12] Add translations for sense count tooltip Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com> --- public/locales/ar/translation.json | 3 ++- public/locales/es/translation.json | 3 ++- public/locales/fr/translation.json | 3 ++- public/locales/pt/translation.json | 3 ++- public/locales/zh/translation.json | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/public/locales/ar/translation.json b/public/locales/ar/translation.json index 6d10246ad0..ada7beec76 100644 --- a/public/locales/ar/translation.json +++ b/public/locales/ar/translation.json @@ -16,7 +16,8 @@ "treeView": { "findDomain": "البحث عن نطاق", "domainNotFound": "لم يتم العثور على المجال", - "returnToTop": "الرجوع إلى الجزء العلوي من شجرة النطاق." + "returnToTop": "الرجوع إلى الجزء العلوي من شجرة النطاق.", + "senseCountTooltip": "عدد الكلمات المجمعة في هذا المجال" }, "addWords": { "selectEntry": "حدد إدخالاً", diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index e7caf39cc9..9e7106536d 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -16,7 +16,8 @@ "treeView": { "findDomain": "Buscar un dominio", "domainNotFound": "Dominio no encontrado", - "returnToTop": "Volver a la parte superior del árbol de dominios." + "returnToTop": "Volver a la parte superior del árbol de dominios.", + "senseCountTooltip": "Número de palabras recopiladas en este dominio" }, "addWords": { "selectEntry": "Seleccionar una entrada", diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 963fcbee31..3d8cead817 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -15,7 +15,8 @@ }, "treeView": { "findDomain": "Chercher un domaine", - "domainNotFound": "Champ sémantique introuvable" + "domainNotFound": "Champ sémantique introuvable", + "senseCountTooltip": "Nombre de mots recueillis dans ce domaine" }, "addWords": { "selectEntry": "Choisissez une entrée", diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json index bb94797839..3c4f6e0320 100644 --- a/public/locales/pt/translation.json +++ b/public/locales/pt/translation.json @@ -15,7 +15,8 @@ }, "treeView": { "findDomain": "Encontrar um domínio", - "domainNotFound": "Domínio não encontrado" + "domainNotFound": "Domínio não encontrado", + "senseCountTooltip": "Número de palavras coletadas neste domínio" }, "addWords": { "selectEntry": "Selecione uma entrada", diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 080d13d649..ee72cd220b 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -16,7 +16,8 @@ "treeView": { "findDomain": "查找语义域", "domainNotFound": "未找到语义域", - "returnToTop": "返回语义域树顶部" + "returnToTop": "返回语义域树顶部", + "senseCountTooltip": "该语义域中收集的词数" }, "addWords": { "selectEntry": "选择一个词条", From 2e9f8b29934ae06707981ed0c86acc60eb81c895 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:48:35 +0000 Subject: [PATCH 05/12] Add error handling and fix potential bugs from code review Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com> --- .gitignore | 1 + Backend/Services/StatisticsService.cs | 4 +++- src/components/TreeView/TreeDepiction/CurrentRow.tsx | 7 ++++++- .../TreeView/TreeDepiction/DomainTileButton.tsx | 11 +++++++---- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index a6d362e66d..0d083314e0 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,4 @@ dev_*.yaml # JetBrains Rider .idea + diff --git a/Backend/Services/StatisticsService.cs b/Backend/Services/StatisticsService.cs index cdda3282de..603ad247f2 100644 --- a/Backend/Services/StatisticsService.cs +++ b/Backend/Services/StatisticsService.cs @@ -404,7 +404,9 @@ public async Task GetDomainProgressProportion(string projectId, string d var descendantIds = new List(); foreach (var node in domainTreeNodeList) { - if (node.Id.StartsWith(domainId, StringComparison.Ordinal) && node.Id != domainId) + if (node.Id.StartsWith(domainId, StringComparison.Ordinal) && + node.Id.Length > domainId.Length && + node.Id != domainId) { // Check if it's a direct or indirect child (not just a string prefix match) var relativePart = node.Id.Substring(domainId.Length); diff --git a/src/components/TreeView/TreeDepiction/CurrentRow.tsx b/src/components/TreeView/TreeDepiction/CurrentRow.tsx index e55882caf2..ebec84d956 100644 --- a/src/components/TreeView/TreeDepiction/CurrentRow.tsx +++ b/src/components/TreeView/TreeDepiction/CurrentRow.tsx @@ -42,7 +42,12 @@ function CurrentTile(props: TreeRowProps): ReactElement { useEffect(() => { if (projectId && currentDomain.id) { - getDomainSenseCount(projectId, currentDomain.id).then(setSenseCount); + getDomainSenseCount(projectId, currentDomain.id) + .then(setSenseCount) + .catch(() => { + // Silently fail - the badge simply won't be displayed + setSenseCount(undefined); + }); } }, [projectId, currentDomain.id]); diff --git a/src/components/TreeView/TreeDepiction/DomainTileButton.tsx b/src/components/TreeView/TreeDepiction/DomainTileButton.tsx index e6a0614ec7..f2d1df1b75 100644 --- a/src/components/TreeView/TreeDepiction/DomainTileButton.tsx +++ b/src/components/TreeView/TreeDepiction/DomainTileButton.tsx @@ -104,9 +104,12 @@ export default function DomainTileButton( useEffect(() => { if (shouldShowProgress && projectId && props.domain.id && lang) { - getDomainProgressProportion(projectId, props.domain.id, lang).then( - setProgressProportion - ); + getDomainProgressProportion(projectId, props.domain.id, lang) + .then(setProgressProportion) + .catch(() => { + // Silently fail - the progress bar simply won't be displayed + setProgressProportion(undefined); + }); } }, [shouldShowProgress, projectId, props.domain.id, lang]); @@ -115,7 +118,7 @@ export default function DomainTileButton( id={props.domain.id} fullWidth onClick={() => onClick(props.domain)} - sx={{ height: "100%", position: "relative", overflow: "visible" }} + sx={{ height: "100%", position: "relative", overflow: "hidden" }} tabIndex={-1} variant="outlined" > From 04fc77c52ed0a549b7990ca656060aff98467f01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:40:28 +0000 Subject: [PATCH 06/12] Revert non-English translations (handled by Crowdin) Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com> --- public/locales/ar/translation.json | 3 +-- public/locales/es/translation.json | 3 +-- public/locales/fr/translation.json | 3 +-- public/locales/pt/translation.json | 3 +-- public/locales/zh/translation.json | 3 +-- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/public/locales/ar/translation.json b/public/locales/ar/translation.json index ada7beec76..6d10246ad0 100644 --- a/public/locales/ar/translation.json +++ b/public/locales/ar/translation.json @@ -16,8 +16,7 @@ "treeView": { "findDomain": "البحث عن نطاق", "domainNotFound": "لم يتم العثور على المجال", - "returnToTop": "الرجوع إلى الجزء العلوي من شجرة النطاق.", - "senseCountTooltip": "عدد الكلمات المجمعة في هذا المجال" + "returnToTop": "الرجوع إلى الجزء العلوي من شجرة النطاق." }, "addWords": { "selectEntry": "حدد إدخالاً", diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index 9e7106536d..e7caf39cc9 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -16,8 +16,7 @@ "treeView": { "findDomain": "Buscar un dominio", "domainNotFound": "Dominio no encontrado", - "returnToTop": "Volver a la parte superior del árbol de dominios.", - "senseCountTooltip": "Número de palabras recopiladas en este dominio" + "returnToTop": "Volver a la parte superior del árbol de dominios." }, "addWords": { "selectEntry": "Seleccionar una entrada", diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 3d8cead817..963fcbee31 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -15,8 +15,7 @@ }, "treeView": { "findDomain": "Chercher un domaine", - "domainNotFound": "Champ sémantique introuvable", - "senseCountTooltip": "Nombre de mots recueillis dans ce domaine" + "domainNotFound": "Champ sémantique introuvable" }, "addWords": { "selectEntry": "Choisissez une entrée", diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json index 3c4f6e0320..bb94797839 100644 --- a/public/locales/pt/translation.json +++ b/public/locales/pt/translation.json @@ -15,8 +15,7 @@ }, "treeView": { "findDomain": "Encontrar um domínio", - "domainNotFound": "Domínio não encontrado", - "senseCountTooltip": "Número de palavras coletadas neste domínio" + "domainNotFound": "Domínio não encontrado" }, "addWords": { "selectEntry": "Selecione uma entrada", diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index ee72cd220b..080d13d649 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -16,8 +16,7 @@ "treeView": { "findDomain": "查找语义域", "domainNotFound": "未找到语义域", - "returnToTop": "返回语义域树顶部", - "senseCountTooltip": "该语义域中收集的词数" + "returnToTop": "返回语义域树顶部" }, "addWords": { "selectEntry": "选择一个词条", From 4a0dfab3628d94e688c4af6da7c5a3482bf26aeb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:48:55 +0000 Subject: [PATCH 07/12] Refactor to use efficient DB query and remove useAppSelector Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com> --- Backend.Tests/Mocks/WordRepositoryMock.cs | 20 +++++++++ Backend/Interfaces/IWordRepository.cs | 1 + Backend/Repositories/WordRepository.cs | 41 +++++++++++++++++++ Backend/Services/StatisticsService.cs | 33 ++++----------- src/backend/index.ts | 10 ++--- .../TreeView/TreeDepiction/CurrentRow.tsx | 11 ++--- .../TreeDepiction/DomainTileButton.tsx | 14 ++----- 7 files changed, 78 insertions(+), 52 deletions(-) diff --git a/Backend.Tests/Mocks/WordRepositoryMock.cs b/Backend.Tests/Mocks/WordRepositoryMock.cs index 677481670f..80642fe832 100644 --- a/Backend.Tests/Mocks/WordRepositoryMock.cs +++ b/Backend.Tests/Mocks/WordRepositoryMock.cs @@ -131,5 +131,25 @@ public Task Add(Word word) _words.Add(word.Clone()); return Task.FromResult(word); } + + public Task CountSensesWithDomain(string projectId, string domainId, int? maxCount = null) + { + var count = 0; + foreach (var word in _frontier.Where(w => w.ProjectId == projectId)) + { + foreach (var sense in word.Senses) + { + if (sense.SemanticDomains.Any(sd => sd.Id == domainId)) + { + count++; + if (maxCount.HasValue && count >= maxCount.Value) + { + return Task.FromResult(maxCount.Value); + } + } + } + } + return Task.FromResult(count); + } } } diff --git a/Backend/Interfaces/IWordRepository.cs b/Backend/Interfaces/IWordRepository.cs index 4dd65b628e..6dc3f048d6 100644 --- a/Backend/Interfaces/IWordRepository.cs +++ b/Backend/Interfaces/IWordRepository.cs @@ -23,5 +23,6 @@ public interface IWordRepository Task> AddFrontier(List words); Task DeleteFrontier(string projectId, string wordId); Task DeleteFrontier(string projectId, List wordIds); + Task CountSensesWithDomain(string projectId, string domainId, int? maxCount = null); } } diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index 5a9147a456..d5091c5f92 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading.Tasks; using BackendFramework.Helper; using BackendFramework.Interfaces; @@ -268,5 +269,45 @@ public async Task DeleteFrontier(string projectId, List wordIds) var deleted = await _frontier.DeleteManyAsync(GetProjectWordsFilter(projectId, wordIds)); return deleted.DeletedCount; } + + /// + /// Counts the number of senses in Frontier words that have the specified semantic domain. + /// + /// The project id + /// The semantic domain id + /// Optional maximum count to return (for optimization) + /// The count of senses with the specified domain, capped at maxCount if provided + public async Task CountSensesWithDomain(string projectId, string domainId, int? maxCount = null) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "counting senses with domain"); + + var filterDef = new FilterDefinitionBuilder(); + var filter = filterDef.And( + filterDef.Eq(w => w.ProjectId, projectId), + filterDef.ElemMatch(w => w.Senses, s => s.SemanticDomains.Any(sd => sd.Id == domainId)) + ); + + // Get words that have at least one sense with the specified domain + var words = await _frontier.Find(filter).ToListAsync(); + + // Count the total number of senses with this domain across all words + var count = 0; + foreach (var word in words) + { + foreach (var sense in word.Senses) + { + if (sense.SemanticDomains.Any(sd => sd.Id == domainId)) + { + count++; + if (maxCount.HasValue && count >= maxCount.Value) + { + return maxCount.Value; + } + } + } + } + + return count; + } } } diff --git a/Backend/Services/StatisticsService.cs b/Backend/Services/StatisticsService.cs index 603ad247f2..a102d3fc7a 100644 --- a/Backend/Services/StatisticsService.cs +++ b/Backend/Services/StatisticsService.cs @@ -366,21 +366,7 @@ public async Task GetDomainSenseCount(string projectId, string domainId) { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting domain sense count"); - var wordList = await _wordRepo.GetFrontier(projectId); - var count = 0; - - foreach (var word in wordList) - { - foreach (var sense in word.Senses) - { - if (sense.SemanticDomains.Any(sd => sd.Id == domainId)) - { - count++; - } - } - } - - return count; + return await _wordRepo.CountSensesWithDomain(projectId, domainId); } /// @@ -422,21 +408,16 @@ public async Task GetDomainProgressProportion(string projectId, string d return 0.0; } - // Get word list and count which descendants have at least one entry - var wordList = await _wordRepo.GetFrontier(projectId); + // Count which descendants have at least one entry using the efficient repo method var domainsWithEntries = new HashSet(); - foreach (var word in wordList) + foreach (var descendantId in descendantIds) { - foreach (var sense in word.Senses) + // Use maxCount=1 to check if at least one entry exists + var count = await _wordRepo.CountSensesWithDomain(projectId, descendantId, maxCount: 1); + if (count > 0) { - foreach (var sd in sense.SemanticDomains) - { - if (descendantIds.Contains(sd.Id)) - { - domainsWithEntries.Add(sd.Id); - } - } + domainsWithEntries.Add(descendantId); } } diff --git a/src/backend/index.ts b/src/backend/index.ts index c63aee60bd..fd0b61c42c 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -689,24 +689,20 @@ export async function getProgressEstimationLineChartRoot( return response.data ?? undefined; } -export async function getDomainSenseCount( - projectId: string, - domainId: string -): Promise { +export async function getDomainSenseCount(domainId: string): Promise { const response = await statisticsApi.getDomainSenseCount( - { projectId: projectId, domainId: domainId }, + { projectId: LocalStorage.getProjectId(), domainId: domainId }, defaultOptions() ); return response.data; } export async function getDomainProgressProportion( - projectId: string, domainId: string, lang: string ): Promise { const response = await statisticsApi.getDomainProgressProportion( - { projectId: projectId, domainId: domainId, lang: lang }, + { projectId: LocalStorage.getProjectId(), domainId: domainId, lang: lang }, defaultOptions() ); return response.data; diff --git a/src/components/TreeView/TreeDepiction/CurrentRow.tsx b/src/components/TreeView/TreeDepiction/CurrentRow.tsx index ebec84d956..d60c7e97c5 100644 --- a/src/components/TreeView/TreeDepiction/CurrentRow.tsx +++ b/src/components/TreeView/TreeDepiction/CurrentRow.tsx @@ -19,8 +19,6 @@ import { TreeRowProps, } from "components/TreeView/TreeDepiction/TreeDepictionTypes"; import { parent as parentSvg } from "resources/tree"; -import { useAppSelector } from "rootRedux/hooks"; -import { type StoreState } from "rootRedux/types"; const currentDomainButtonId = "current-domain"; @@ -35,21 +33,18 @@ export default function CurrentRow(props: TreeRowProps): ReactElement { function CurrentTile(props: TreeRowProps): ReactElement { const { animate, currentDomain } = props; const { t } = useTranslation(); - const projectId = useAppSelector( - (state: StoreState) => state.currentProjectState.project.id - ); const [senseCount, setSenseCount] = useState(undefined); useEffect(() => { - if (projectId && currentDomain.id) { - getDomainSenseCount(projectId, currentDomain.id) + if (currentDomain.id) { + getDomainSenseCount(currentDomain.id) .then(setSenseCount) .catch(() => { // Silently fail - the badge simply won't be displayed setSenseCount(undefined); }); } - }, [projectId, currentDomain.id]); + }, [currentDomain.id]); return (