From 988479ae380b1363565c5ad15405155f370a94b5 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Tue, 24 Feb 2026 14:12:42 -0500 Subject: [PATCH 1/4] feat(agents): add new agents for Angular review, code research, duplicate detection, file classification, issue validation, SCSS/HTML style review, team routing, and test quality --- .claude/agents/README.md | 40 +- ...reviewer.md => dotcms-angular-reviewer.md} | 12 +- ...esearcher.md => dotcms-code-researcher.md} | 2 +- ...tector.md => dotcms-duplicate-detector.md} | 2 +- ...lassifier.md => dotcms-file-classifier.md} | 30 +- ...validator.md => dotcms-issue-validator.md} | 2 +- .../agents/dotcms-scss-html-style-reviewer.md | 170 +++++++ .../{team-router.md => dotcms-team-router.md} | 6 +- ...st-reviewer.md => dotcms-test-reviewer.md} | 10 +- ...iewer.md => dotcms-typescript-reviewer.md} | 12 +- .claude/skills/README.md | 6 +- .claude/skills/review/README.md | 14 +- .claude/skills/review/SKILL.md | 47 +- .cursor/rules/frontend-context.mdc | 6 +- core-web/.claude/settings.json | 13 + core-web/.cursor/rules/nx-rules.mdc | 34 -- core-web/.mcp.json | 9 - core-web/AGENTS.md | 17 +- core-web/CLAUDE.md | 24 +- .../dot-analytics/data-access/src/index.ts | 2 +- .../store/dot-analytics-dashboard.store.ts | 134 +++--- .../store/features/with-engagement.feature.ts | 432 ++++++++++++++++-- .../data-access/src/lib/types/common.types.ts | 2 +- .../src/lib/types/cubequery.types.ts | 16 +- .../src/lib/types/engagement.types.ts | 2 - .../src/lib/types/entities.types.ts | 47 ++ .../utils/data/analytics-data.utils.spec.ts | 39 +- .../lib/utils/data/analytics-data.utils.ts | 41 +- .../lib/utils/data/engagement-data.utils.ts | 318 +++++++++++++ .../src/lib/utils/mock-engagement-data.ts | 68 --- .../dot-analytics-dashboard.component.html | 28 +- .../dot-analytics-dashboard.component.scss | 2 +- .../dot-analytics-dashboard.component.ts | 7 +- ...s-content-conversions-table.component.html | 74 +-- ...s-content-conversions-table.component.scss | 36 +- ...ontent-conversions-table.component.spec.ts | 4 +- ...ics-content-conversions-table.component.ts | 4 + ...-conversions-overview-table.component.html | 67 +-- ...-conversions-overview-table.component.scss | 54 ++- ...nversions-overview-table.component.spec.ts | 21 +- ...cs-conversions-overview-table.component.ts | 4 + ...nalytics-conversions-report.component.html | 6 +- ...-analytics-conversions-report.component.ts | 27 +- ...analytics-engagement-report.component.html | 118 ++--- ...lytics-engagement-report.component.spec.ts | 120 ++++- ...t-analytics-engagement-report.component.ts | 53 ++- ...t-analytics-platforms-table.component.html | 268 +++++------ ...t-analytics-platforms-table.component.scss | 34 +- ...nalytics-platforms-table.component.spec.ts | 4 +- ...dot-analytics-platforms-table.component.ts | 16 +- ...t-analytics-pageview-report.component.html | 11 +- ...dot-analytics-pageview-report.component.ts | 2 +- ...t-analytics-top-pages-table.component.html | 9 +- ...t-analytics-top-pages-table.component.scss | 13 +- ...nalytics-top-pages-table.component.spec.ts | 4 +- ...dot-analytics-top-pages-table.component.ts | 2 + .../dot-analytics-chart.component.html | 16 +- .../dot-analytics-chart.component.scss | 38 +- .../dot-analytics-chart.component.spec.ts | 8 +- .../dot-analytics-chart.component.ts | 4 +- ...ot-analytics-empty-state.component.spec.ts | 59 +++ .../dot-analytics-empty-state.component.ts | 30 ++ .../dot-analytics-metric.component.html | 51 +-- .../dot-analytics-metric.component.scss | 29 +- .../dot-analytics-metric.component.spec.ts | 48 +- .../dot-analytics-metric.component.ts | 17 +- .../dot-analytics-sparkline.component.ts | 22 + ...dot-analytics-state-message.component.scss | 5 +- ...-analytics-state-message.component.spec.ts | 2 +- .../dot-analytics-state-message.component.ts | 2 +- docs/frontend/ANGULAR_STANDARDS.md | 4 +- docs/frontend/README.md | 4 +- docs/frontend/STYLING_STANDARDS.md | 115 +++-- .../WEB-INF/messages/Language.properties | 3 + 74 files changed, 2140 insertions(+), 862 deletions(-) rename .claude/agents/{angular-reviewer.md => dotcms-angular-reviewer.md} (96%) rename .claude/agents/{code-researcher.md => dotcms-code-researcher.md} (99%) rename .claude/agents/{duplicate-detector.md => dotcms-duplicate-detector.md} (98%) rename .claude/agents/{file-classifier.md => dotcms-file-classifier.md} (89%) rename .claude/agents/{issue-validator.md => dotcms-issue-validator.md} (98%) create mode 100644 .claude/agents/dotcms-scss-html-style-reviewer.md rename .claude/agents/{team-router.md => dotcms-team-router.md} (86%) rename .claude/agents/{test-reviewer.md => dotcms-test-reviewer.md} (97%) rename .claude/agents/{typescript-reviewer.md => dotcms-typescript-reviewer.md} (95%) create mode 100644 core-web/.claude/settings.json delete mode 100644 core-web/.cursor/rules/nx-rules.mdc delete mode 100644 core-web/.mcp.json create mode 100644 core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/engagement-data.utils.ts delete mode 100644 core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/mock-engagement-data.ts create mode 100644 core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.spec.ts create mode 100644 core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.ts diff --git a/.claude/agents/README.md b/.claude/agents/README.md index 1d17db99106d..0d4c6153fab0 100644 --- a/.claude/agents/README.md +++ b/.claude/agents/README.md @@ -9,7 +9,7 @@ This directory contains **reusable specialized agents** for code review. These a ## Available Agents ### 🟠 File Classifier -**File**: `file-classifier.md` +**File**: `dotcms-file-classifier.md` **Model**: Sonnet **Focus**: PR file triage and classification @@ -23,7 +23,7 @@ This directory contains **reusable specialized agents** for code review. These a **Used as**: First step in the review pipeline, before launching specialized reviewers. ### 🔷 TypeScript Type Reviewer -**File**: `typescript-reviewer.md` +**File**: `dotcms-typescript-reviewer.md` **Model**: Sonnet **Focus**: TypeScript type system, generics, null safety @@ -35,10 +35,10 @@ This directory contains **reusable specialized agents** for code review. These a - Function signatures and return types **Confidence threshold**: ≥ 75 -**Excludes**: `.spec.ts` files (handled by test-reviewer) +**Excludes**: `.spec.ts` files (handled by dotcms-test-reviewer) ### 🟣 Angular Pattern Reviewer -**File**: `angular-reviewer.md` +**File**: `dotcms-angular-reviewer.md` **Model**: Sonnet **Focus**: Angular framework patterns, modern syntax, architecture @@ -52,10 +52,10 @@ This directory contains **reusable specialized agents** for code review. These a - SCSS standards (variables, BEM) **Confidence threshold**: ≥ 75 -**Excludes**: `.spec.ts` files (handled by test-reviewer) +**Excludes**: `.spec.ts` files (handled by dotcms-test-reviewer) ### 🟢 Test Quality Reviewer -**File**: `test-reviewer.md` +**File**: `dotcms-test-reviewer.md` **Model**: Sonnet **Focus**: Test patterns, Spectator usage, coverage @@ -79,7 +79,7 @@ The review skill follows a two-phase pipeline: ```typescript // Phase 1: File classification (single agent) const fileMap = await Task( - subagent_type="file-classifier", + subagent_type="dotcms-file-classifier", prompt="Classify PR #34553 files by domain", description="Classify PR files" ); @@ -88,17 +88,17 @@ const fileMap = await Task( if (fileMap.decision === "REVIEW") { const [typeResults, angularResults, testResults] = await Promise.all([ Task( - subagent_type="typescript-reviewer", + subagent_type="dotcms-typescript-reviewer", prompt="Review TypeScript types for PR #34553. Files: ", description="TypeScript review" ), Task( - subagent_type="angular-reviewer", + subagent_type="dotcms-angular-reviewer", prompt="Review Angular patterns for PR #34553. Files: ", description="Angular review" ), Task( - subagent_type="test-reviewer", + subagent_type="dotcms-test-reviewer", prompt="Review test quality for PR #34553. Files: ", description="Test review" ) @@ -142,7 +142,7 @@ All agents use the same confidence scoring system: Each agent has **pre-approved permissions** via `allowed-tools` in their frontmatter: ```yaml -# Example: angular-reviewer.md +# Example: dotcms-angular-reviewer.md allowed-tools: - Bash(gh pr diff:*) - Bash(gh pr view:*) @@ -174,7 +174,7 @@ While the main `review` skill orchestrates these agents automatically, you can a ### TypeScript-Only Review ```bash Task( - subagent_type="typescript-reviewer", + subagent_type="dotcms-typescript-reviewer", prompt="Review TypeScript types for PR #34553", description="TypeScript type review" ) @@ -183,7 +183,7 @@ Task( ### Angular-Only Review ```bash Task( - subagent_type="angular-reviewer", + subagent_type="dotcms-angular-reviewer", prompt="Review Angular patterns for PR #34553", description="Angular pattern review" ) @@ -192,7 +192,7 @@ Task( ### Test-Only Review ```bash Task( - subagent_type="test-reviewer", + subagent_type="dotcms-test-reviewer", prompt="Review test quality for PR #34553", description="Test quality review" ) @@ -250,7 +250,7 @@ The main review skill consolidates agent outputs: To add a new specialized agent: -1. Create `agents/your-agent-reviewer.md` with: +1. Create `agents/dotcms-your-agent-reviewer.md` with: - Clear mission and scope - Non-overlapping domain - Issue confidence scoring @@ -273,9 +273,9 @@ Files changed: - 4 .spec.ts files Agents launched: -✅ typescript-reviewer → Reviews .component.ts for types -✅ angular-reviewer → Reviews .component.ts + .html for patterns -✅ test-reviewer → Reviews .spec.ts for test quality +✅ dotcms-typescript-reviewer → Reviews .component.ts for types +✅ dotcms-angular-reviewer → Reviews .component.ts + .html for patterns +✅ dotcms-test-reviewer → Reviews .spec.ts for test quality Result: Comprehensive review with 3 specialized sections ``` @@ -286,7 +286,7 @@ Files changed: - 2 .util.ts files (no Angular, no tests) Agents launched: -✅ typescript-reviewer → Reviews type safety only +✅ dotcms-typescript-reviewer → Reviews type safety only Result: Focused type safety review, no Angular/test sections ``` @@ -297,7 +297,7 @@ Files changed: - 5 .spec.ts files (test updates only) Agents launched: -✅ test-reviewer → Reviews test quality only +✅ dotcms-test-reviewer → Reviews test quality only Result: Focused test quality review, no type/pattern sections ``` diff --git a/.claude/agents/angular-reviewer.md b/.claude/agents/dotcms-angular-reviewer.md similarity index 96% rename from .claude/agents/angular-reviewer.md rename to .claude/agents/dotcms-angular-reviewer.md index 3f397791ae43..299f6455902c 100644 --- a/.claude/agents/angular-reviewer.md +++ b/.claude/agents/dotcms-angular-reviewer.md @@ -1,5 +1,5 @@ --- -name: angular-reviewer +name: dotcms-angular-reviewer description: Angular patterns specialist. Use proactively after writing or modifying Angular components, services, or templates to ensure modern syntax and best practices. Focuses on Angular framework patterns without checking TypeScript types or tests. model: sonnet color: purple @@ -68,7 +68,7 @@ Analyze these Angular files from the PR diff: - `.html` template files - `.scss` style files -**Exclude**: `.spec.ts` files (handled by test-reviewer) +**Exclude**: `.spec.ts` files (handled by dotcms-test-reviewer) ## Core Review Areas @@ -457,8 +457,8 @@ ngOnInit() { ## What NOT to Flag **Pre-existing legacy code** - Only flag new code using legacy patterns -**Type safety issues** - `any`, generics, null checks (typescript-reviewer handles this) -**Test patterns** - Spectator usage, test structure (test-reviewer handles this) +**Type safety issues** - `any`, generics, null checks (dotcms-typescript-reviewer handles this) +**Test patterns** - Spectator usage, test structure (dotcms-test-reviewer handles this) **Non-Angular files** - Pure TypeScript utilities, models **Intentional legacy usage** - Code updating existing legacy components (not new code) @@ -474,7 +474,7 @@ ngOnInit() { ## Integration with Main Review You are invoked by the main `review` skill when Angular files are changed. You work alongside: -- `typescript-reviewer` - Handles type safety and TypeScript quality -- `test-reviewer` - Handles test patterns and coverage +- `dotcms-typescript-reviewer` - Handles type safety and TypeScript quality +- `dotcms-test-reviewer` - Handles test patterns and coverage Your output is merged into the final review under "Angular Patterns" section. diff --git a/.claude/agents/code-researcher.md b/.claude/agents/dotcms-code-researcher.md similarity index 99% rename from .claude/agents/code-researcher.md rename to .claude/agents/dotcms-code-researcher.md index 8272c804c270..cc0fdd4cc482 100644 --- a/.claude/agents/code-researcher.md +++ b/.claude/agents/dotcms-code-researcher.md @@ -1,5 +1,5 @@ --- -name: code-researcher +name: dotcms-code-researcher description: Researches the dotCMS codebase to produce a technical briefing for a GitHub issue. Identifies entry points, call chains, likely bug locations, relevant files, test gaps, and suspicious git commits. Output is used by both human triagers and downstream AI code agents. model: sonnet color: blue diff --git a/.claude/agents/duplicate-detector.md b/.claude/agents/dotcms-duplicate-detector.md similarity index 98% rename from .claude/agents/duplicate-detector.md rename to .claude/agents/dotcms-duplicate-detector.md index 8a00e1119eca..eca615421ff5 100644 --- a/.claude/agents/duplicate-detector.md +++ b/.claude/agents/dotcms-duplicate-detector.md @@ -1,5 +1,5 @@ --- -name: duplicate-detector +name: dotcms-duplicate-detector description: Detects duplicate or related GitHub issues by searching existing issues, PRs, and git history. Returns DUPLICATE, RELATED, or NO MATCH with references. model: haiku color: orange diff --git a/.claude/agents/file-classifier.md b/.claude/agents/dotcms-file-classifier.md similarity index 89% rename from .claude/agents/file-classifier.md rename to .claude/agents/dotcms-file-classifier.md index e04e241a6fe4..7e2fed06c14b 100644 --- a/.claude/agents/file-classifier.md +++ b/.claude/agents/dotcms-file-classifier.md @@ -1,5 +1,5 @@ --- -name: file-classifier +name: dotcms-file-classifier description: PR file classifier. Fetches a PR diff, classifies changed files by domain (Angular, TypeScript, tests, styles), and returns a structured mapping of which reviewers should analyze which files. Use as the first step before launching review agents. model: sonnet color: orange @@ -42,11 +42,11 @@ Assign each file to **one or more** reviewer buckets based on its path and exten | Bucket | File Patterns | Reviewer Agent | |--------|--------------|----------------| -| **angular** | `*.component.ts`, `*.component.html`, `*.component.scss`, `*.directive.ts`, `*.pipe.ts`, `*.service.ts` (in Angular libs/apps) | `angular-reviewer` | -| **typescript** | `*.ts` (excluding `*.spec.ts`, excluding Angular-specific files above) | `typescript-reviewer` | -| **test** | `*.spec.ts` | `test-reviewer` | -| **style** | `*.scss`, `*.css` (standalone, not `.component.scss`) | `angular-reviewer` (SCSS section) | -| **template** | `*.html` (standalone, not `.component.html`) | `angular-reviewer` (template section) | +| **angular** | `*.component.ts`, `*.component.html`, `*.component.scss`, `*.directive.ts`, `*.pipe.ts`, `*.service.ts` (in Angular libs/apps) | `dotcms-angular-reviewer` | +| **typescript** | `*.ts` (excluding `*.spec.ts`, excluding Angular-specific files above) | `dotcms-typescript-reviewer` | +| **test** | `*.spec.ts` | `dotcms-test-reviewer` | +| **style** | `*.scss`, `*.css` (standalone, not `.component.scss`) | `dotcms-angular-reviewer` (SCSS section) | +| **template** | `*.html` (standalone, not `.component.html`) | `dotcms-angular-reviewer` (template section) | | **out-of-scope** | `*.java`, `*.xml`, `*.json`, `*.md`, `*.yml`, `*.yaml`, `*.sh`, `Dockerfile`, `*.properties`, `*.vtl`, images, etc. | None (skip) | #### Classification Rules @@ -98,7 +98,7 @@ Even when skipping, still return the full classification so the orchestrator can ## File Map -### angular-reviewer +### dotcms-angular-reviewer Files for Angular pattern review: | File | Lines Changed | Type | @@ -107,7 +107,7 @@ Files for Angular pattern review: | `path/to/file.component.html` | +15 -5 | template | | `path/to/file.service.ts` | +20 -0 | service | -### typescript-reviewer +### dotcms-typescript-reviewer Files for TypeScript type safety review: | File | Lines Changed | Type | @@ -116,7 +116,7 @@ Files for TypeScript type safety review: | `path/to/utils.ts` | +50 -20 | utility | | `path/to/model.ts` | +10 -0 | model | -### test-reviewer +### dotcms-test-reviewer Files for test quality review: | File | Lines Changed | Type | @@ -133,9 +133,9 @@ Files excluded from frontend review: | `package.json` | +2 -1 | Config file | ## Summary -- **angular-reviewer**: files () -- **typescript-reviewer**: files () -- **test-reviewer**: files () +- **dotcms-angular-reviewer**: files () +- **dotcms-typescript-reviewer**: files () +- **dotcms-test-reviewer**: files () - **out-of-scope**: files ``` @@ -166,15 +166,15 @@ Still classify all files. The orchestrator decides what to do: - **Decision**: REVIEW - **Reason**: All changes are test files -### test-reviewer +### dotcms-test-reviewer | File | Lines Changed | Type | |------|--------------|------| | ... | ... | unit test | -### angular-reviewer +### dotcms-angular-reviewer _No files to review._ -### typescript-reviewer +### dotcms-typescript-reviewer _No files to review._ ``` diff --git a/.claude/agents/issue-validator.md b/.claude/agents/dotcms-issue-validator.md similarity index 98% rename from .claude/agents/issue-validator.md rename to .claude/agents/dotcms-issue-validator.md index 9700cd26ff2b..31a9d6aecc42 100644 --- a/.claude/agents/issue-validator.md +++ b/.claude/agents/dotcms-issue-validator.md @@ -1,5 +1,5 @@ --- -name: issue-validator +name: dotcms-issue-validator description: Validates GitHub issue completeness for triage. Checks if an issue has enough information to act on. Returns a structured report with a completeness score, status (SUFFICIENT or NEEDS_INFO), and a list of what is missing. model: haiku color: yellow diff --git a/.claude/agents/dotcms-scss-html-style-reviewer.md b/.claude/agents/dotcms-scss-html-style-reviewer.md new file mode 100644 index 000000000000..5b24988f794f --- /dev/null +++ b/.claude/agents/dotcms-scss-html-style-reviewer.md @@ -0,0 +1,170 @@ +--- +name: dotcms-scss-html-style-reviewer +description: "Use this agent when recently modified SCSS/CSS and HTML files need to be reviewed for styling standards compliance. This agent should be triggered after the dotcms-file-classifier agent has identified changed files, particularly when those files include Angular component styles (.scss), global stylesheets (.css), or HTML templates (.html). It verifies adherence to the project's styling standards defined in docs/frontend/STYLING_STANDARDS.md, checks for unused custom classes, and ensures BEM conventions and dotCMS UI standards are properly followed.\\n\\n\\nContext: The user has just written or modified Angular component files including SCSS and HTML, and the dotcms-file-classifier agent has already identified the changed files.\\nuser: \"I just updated the content-type card component with new styles\"\\nassistant: \"Let me use the dotcms-file-classifier agent first to identify the changed files, and then I'll launch the dotcms-scss-html-style-reviewer agent to verify the styling changes.\"\\n\\nAfter the dotcms-file-classifier agent returns the list of changed SCSS/HTML files, use the Task tool to launch the dotcms-scss-html-style-reviewer agent with those files to ensure styling standards compliance.\\n\\nassistant: \"Now let me use the Task tool to launch the dotcms-scss-html-style-reviewer agent with the identified SCSS and HTML files.\"\\n\\n\\n\\nContext: A developer has created a new Angular component with custom styles and wants to verify compliance before committing.\\nuser: \"Can you review the styles in my new sidebar component?\"\\nassistant: \"I'll use the Task tool to launch the dotcms-scss-html-style-reviewer agent to analyze the SCSS and HTML files in your sidebar component for styling standards compliance.\"\\n\\nSince the user is explicitly requesting a style review, use the dotcms-scss-html-style-reviewer agent immediately via the Task tool.\\n\\n\\n\\n\\nContext: The dotcms-file-classifier agent has returned a list of changed files that includes .scss and .html files among others.\\nuser: \"Review all the changes I just made\"\\nassistant: \"The dotcms-file-classifier has identified SCSS and HTML changes. Let me launch the dotcms-scss-html-style-reviewer agent to check the styling standards compliance.\"\\n\\nWhen dotcms-file-classifier output contains .scss, .css, or .html files, proactively use the Task tool to launch the dotcms-scss-html-style-reviewer agent.\\n\\n" +tools: Bash, Glob, Grep, Read, WebFetch, WebSearch, ListMcpResourcesTool, ReadMcpResourceTool +model: sonnet +color: orange +memory: project +--- + +You are an expert Angular/SCSS frontend code reviewer specializing in dotCMS styling standards, BEM methodology, and modern CSS architecture. You have deep expertise in SCSS best practices, PrimeNG component theming, CSS custom properties, and Angular component encapsulation patterns. Your mission is to perform thorough, actionable code reviews on SCSS/CSS and HTML template files to ensure they strictly comply with the project's styling standards documented in `docs/frontend/STYLING_STANDARDS.md`. + +## Core Responsibilities + +1. **Read the Styling Standards First**: Always start by reading `docs/frontend/STYLING_STANDARDS.md` using the Read tool to get the latest and authoritative styling rules before performing any review. + +2. **Receive Files from dotcms-file-classifier**: You work with the output of the dotcms-file-classifier agent. Focus ONLY on changed/new `.scss`, `.css`, and `.html` files provided to you. Do not review the entire codebase unless explicitly instructed. + +3. **Perform Comprehensive Style Review**: Analyze each file against the standards, identifying all violations, warnings, and recommendations. + +## Review Checklist + +### BEM Methodology Compliance +- Verify all custom CSS classes follow BEM naming convention (`block__element--modifier`) +- Check that block names are meaningful and describe the component purpose +- Ensure modifiers are used correctly and not overused +- Flag any class names that don't follow BEM (camelCase classes, arbitrary names, etc.) + +### Unused Custom Classes Detection +- Cross-reference every custom CSS class defined in `.scss`/`.css` files against their usage in corresponding `.html` templates +- Flag classes defined in SCSS that are never applied in any HTML template within the same component +- Flag classes applied in HTML that have no corresponding SCSS definition (if custom classes are expected to be styled) +- Check for dead code: rules that exist but are never triggered + +### CSS Custom Properties (Variables) +- Verify that colors, spacing, typography, and other design tokens use CSS custom properties (`var(--variable-name)`) instead of hardcoded values +- Flag any hardcoded hex colors, pixel values for spacing that should use variables, or font families not using variables +- Ensure custom properties follow the project's naming conventions from the standards doc + +### SCSS-Specific Standards +- Check nesting depth (flag if exceeding the limit defined in standards, typically 3-4 levels) +- Verify `@use` and `@forward` are used instead of deprecated `@import` +- Check for unnecessary `!important` declarations +- Validate mixin usage and ensure mixins from the design system are preferred over custom implementations +- Flag `&` selector misuse or overly complex selectors + +### Angular Component Encapsulation +- Verify `::ng-deep` is avoided or used only with proper justification and `:host` scoping +- Check that `:host` is used appropriately for host element styling +- Ensure component styles don't inadvertently bleed into child components + +### PrimeNG Integration +- Verify PrimeNG component overrides use the correct theming approach (CSS custom properties over deep selectors) +- Check that PrimeNG class overrides follow the project's established patterns + +### HTML Template Style Application +- Check that `class` bindings use the correct Angular syntax (`[class.modifier]="condition"` or `[ngClass]`) +- Flag inline `style` attributes (should be replaced with dynamic class bindings in most cases) +- Verify conditional class application follows Angular best practices +- Check for overly complex class strings that should be refactored + +### General Best Practices +- No hardcoded colors, sizes, or spacing outside of CSS variables +- Responsive design using the project's established breakpoint mixins/variables +- Accessibility: check for focus styles, color contrast considerations +- No vendor prefixes that are no longer needed +- No commented-out code blocks + +## Output Format + +Provide your review in this structured format: + +### 📋 Review Summary +- **Files Reviewed**: List all files analyzed +- **Overall Status**: ✅ PASS | ⚠️ WARNINGS | ❌ FAIL +- **Critical Issues**: Count of blocking issues +- **Warnings**: Count of non-blocking issues +- **Suggestions**: Count of optional improvements + +### ❌ Critical Issues (Must Fix) +For each critical issue: +``` +File: path/to/file.scss (line X) +Issue: [Clear description of the violation] +Standard Violated: [Reference to specific rule in STYLING_STANDARDS.md] +Current Code: + .myCustomClass { color: #FF0000; } ← Hardcoded color +Required Fix: + .my-custom-class { color: var(--color-danger); } ← Use CSS variable + BEM +``` + +### ⚠️ Warnings (Should Fix) +Same format as critical issues but for non-blocking violations. + +### 💡 Suggestions (Consider Improving) +Optional improvements for code quality, maintainability, or consistency. + +### 🔍 Unused Classes Report +List all classes found in SCSS that are not used in HTML templates: +``` +Defined but Unused in SCSS: +- .my-component__unused-element (my-component.component.scss:45) + +Applied in HTML but Missing SCSS Definition: +- .my-component__undefined-style (my-component.component.html:23) +``` + +### ✅ What Was Done Well +Acknowledge positive patterns and good practices observed. + +## Workflow + +1. Read `docs/frontend/STYLING_STANDARDS.md` with the Read tool +2. If file paths are provided, read each file with the Read tool +3. For each SCSS/CSS file, build a map of all defined classes and selectors +4. For each HTML file in the same component, build a map of all applied classes +5. Cross-reference to find unused or undefined custom classes +6. Apply all checklist items systematically +7. Generate the structured report + +## Important Rules + +- **Be specific**: Always include file name, line number, current code, and required fix +- **Reference standards**: Always cite which rule in `STYLING_STANDARDS.md` is being violated +- **Focus on changed files only**: Do not review files not provided by the dotcms-file-classifier agent +- **Distinguish custom from framework classes**: PrimeNG, Angular Material, and utility classes from the design system are NOT subject to BEM/unused checks — only custom project-specific classes +- **Context awareness**: If a class is defined in a shared/global stylesheet and used across multiple components, do not flag it as unused just because it's absent in one component's HTML +- **No false positives**: If you're uncertain whether a class is used (e.g., dynamically constructed class names), flag it as a warning rather than a critical issue, and explain why + +**Update your agent memory** as you discover recurring styling patterns, common violations, component-specific conventions, and architectural decisions about how styles are organized in this codebase. This builds up institutional knowledge across conversations. + +Examples of what to record: +- Common BEM patterns used across components +- CSS custom property naming conventions specific to this project +- Components that legitimately use `::ng-deep` and why +- Shared style files and their intended usage patterns +- Recurring mistakes found in reviews (to prioritize in future reviews) + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `/Users/arcadioquintero/Work/core/.claude/agent-memory/scss-html-style-reviewer/`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files + +What to save: +- Stable patterns and conventions confirmed across multiple interactions +- Key architectural decisions, important file paths, and project structure +- User preferences for workflow, tools, and communication style +- Solutions to recurring problems and debugging insights + +What NOT to save: +- Session-specific context (current task details, in-progress work, temporary state) +- Information that might be incomplete — verify against project docs before writing +- Anything that duplicates or contradicts existing CLAUDE.md instructions +- Speculative or unverified conclusions from reading a single file + +Explicit user requests: +- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions +- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time. diff --git a/.claude/agents/team-router.md b/.claude/agents/dotcms-team-router.md similarity index 86% rename from .claude/agents/team-router.md rename to .claude/agents/dotcms-team-router.md index da3d95e48e88..5ac13248ce64 100644 --- a/.claude/agents/team-router.md +++ b/.claude/agents/dotcms-team-router.md @@ -1,6 +1,6 @@ --- -name: team-router -description: Determines team ownership for a GitHub issue by running git blame on files provided by the code-researcher and matching the commit author against the triage config. +name: dotcms-team-router +description: Determines team ownership for a GitHub issue by running git blame on files provided by the dotcms-code-researcher and matching the commit author against the triage config. model: haiku color: green allowed-tools: @@ -10,7 +10,7 @@ allowed-tools: maxTurns: 5 --- -You are a **Team Router**. You receive a list of files already identified by the code-researcher. Your only job is to git blame them and determine team ownership. +You are a **Team Router**. You receive a list of files already identified by the dotcms-code-researcher. Your only job is to git blame them and determine team ownership. Do NOT search for files — they are provided in the input. diff --git a/.claude/agents/test-reviewer.md b/.claude/agents/dotcms-test-reviewer.md similarity index 97% rename from .claude/agents/test-reviewer.md rename to .claude/agents/dotcms-test-reviewer.md index 17aa5bec01e9..784700be90ed 100644 --- a/.claude/agents/test-reviewer.md +++ b/.claude/agents/dotcms-test-reviewer.md @@ -1,5 +1,5 @@ --- -name: test-reviewer +name: dotcms-test-reviewer description: Test quality specialist. Use proactively after writing or modifying test files to ensure proper Spectator patterns, coverage, and test quality. Focuses exclusively on .spec.ts files and testing patterns without reviewing production code. model: sonnet color: green @@ -522,8 +522,8 @@ it('should create component with default form values', () => { ## What NOT to Flag **Pre-existing test issues** - Only flag issues in changed test lines -**Component logic issues** - Component structure, Angular patterns (angular-reviewer handles this) -**Type safety in tests** - Type issues in test files (typescript-reviewer can help, but lower priority) +**Component logic issues** - Component structure, Angular patterns (dotcms-angular-reviewer handles this) +**Type safety in tests** - Type issues in test files (dotcms-typescript-reviewer can help, but lower priority) **E2E test patterns** - This reviewer focuses on unit tests with Jest/Spectator **Test files for legacy components** - Legacy components may have legacy test patterns (grandfathered) @@ -539,7 +539,7 @@ it('should create component with default form values', () => { ## Integration with Main Review You are invoked by the main `review` skill when test files are changed. You work alongside: -- `typescript-reviewer` - Handles type safety in production code -- `angular-reviewer` - Handles Angular patterns in production code +- `dotcms-typescript-reviewer` - Handles type safety in production code +- `dotcms-angular-reviewer` - Handles Angular patterns in production code Your output is merged into the final review under "Test Quality" section. diff --git a/.claude/agents/typescript-reviewer.md b/.claude/agents/dotcms-typescript-reviewer.md similarity index 95% rename from .claude/agents/typescript-reviewer.md rename to .claude/agents/dotcms-typescript-reviewer.md index 82f35d82cec9..2d4261c48fa7 100644 --- a/.claude/agents/typescript-reviewer.md +++ b/.claude/agents/dotcms-typescript-reviewer.md @@ -1,5 +1,5 @@ --- -name: typescript-reviewer +name: dotcms-typescript-reviewer description: TypeScript type safety specialist. Use proactively after writing or modifying TypeScript code to catch type issues early before code review. Focuses on type safety, generics, null handling without checking Angular patterns or tests. model: sonnet color: blue @@ -56,7 +56,7 @@ Read(core-web/libs/dotcms-models/src/lib/my.model.ts) ## Review Scope Analyze these TypeScript files from the PR diff: -- `.ts` files (but NOT `.spec.ts` - tests are handled by test-reviewer) +- `.ts` files (but NOT `.spec.ts` - tests are handled by dotcms-test-reviewer) - `.tsx` files if React components exist - Focus on type definitions, interfaces, generics, type guards @@ -328,8 +328,8 @@ getData(): Observable { ## What NOT to Flag **Pre-existing issues** - Only flag issues in changed lines -**Angular patterns** - Component structure, lifecycle, etc. (angular-reviewer handles this) -**Test files** - `*.spec.ts` files (test-reviewer handles this) +**Angular patterns** - Component structure, lifecycle, etc. (dotcms-angular-reviewer handles this) +**Test files** - `*.spec.ts` files (dotcms-test-reviewer handles this) **Legitimate any usage** - With `// eslint-disable-next-line @typescript-eslint/no-explicit-any` and comment **Third-party types** - Issues in node_modules or external libraries **Configuration files** - Type issues in `.json`, `.js` config files (not TypeScript source) @@ -346,7 +346,7 @@ getData(): Observable { ## Integration with Main Review You are invoked by the main `review` skill when TypeScript files are changed. You work alongside: -- `angular-reviewer` - Handles component patterns, Angular syntax -- `test-reviewer` - Handles test quality and Spectator patterns +- `dotcms-angular-reviewer` - Handles component patterns, Angular syntax +- `dotcms-test-reviewer` - Handles test quality and Spectator patterns Your output is merged into the final review under "TypeScript Type Safety" section. diff --git a/.claude/skills/README.md b/.claude/skills/README.md index a29d85ec2622..d7cceef6d1ce 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -8,9 +8,9 @@ This directory contains **repository-specific skills** for Claude Code that enha .claude/ ├── agents/ # Reusable agents (independent workers) │ ├── README.md # Agent documentation -│ ├── typescript-reviewer.md # TypeScript type safety specialist -│ ├── angular-reviewer.md # Angular patterns specialist -│ └── test-reviewer.md # Test quality specialist +│ ├── dotcms-typescript-reviewer.md # TypeScript type safety specialist +│ ├── dotcms-angular-reviewer.md # Angular patterns specialist +│ └── dotcms-test-reviewer.md # Test quality specialist │ └── skills/ └── review/ # Autonomous PR Review System diff --git a/.claude/skills/review/README.md b/.claude/skills/review/README.md index 9258e09e142b..93aec167a8ee 100644 --- a/.claude/skills/review/README.md +++ b/.claude/skills/review/README.md @@ -36,9 +36,9 @@ This skill **automates all of that** with a single command. ### 🤖 Specialized Review Agents (NEW!) For frontend code, launches **3 parallel expert agents** using registered agent types: -- **TypeScript Reviewer** (`typescript-reviewer`): Type safety, generics, null handling -- **Angular Reviewer** (`angular-reviewer`): Modern syntax, component patterns, architecture -- **Test Reviewer** (`test-reviewer`): Spectator patterns, coverage, test quality +- **TypeScript Reviewer** (`dotcms-typescript-reviewer`): Type safety, generics, null handling +- **Angular Reviewer** (`dotcms-angular-reviewer`): Modern syntax, component patterns, architecture +- **Test Reviewer** (`dotcms-test-reviewer`): Spectator patterns, coverage, test quality Each agent is a domain expert with: - Non-overlapping focus areas (no duplicate findings) @@ -72,9 +72,9 @@ Three specialized agents work in parallel to review: .claude/ ├── agents/ # ⭐ Specialized review agents (reusable) │ ├── README.md # Agent documentation -│ ├── typescript-reviewer.md # TypeScript type safety specialist -│ ├── angular-reviewer.md # Angular patterns specialist -│ └── test-reviewer.md # Test quality specialist +│ ├── dotcms-typescript-reviewer.md # TypeScript type safety specialist +│ ├── dotcms-angular-reviewer.md # Angular patterns specialist +│ └── dotcms-test-reviewer.md # Test quality specialist └── skills/review/ ├── SKILL.md # Main skill logic (orchestrates agents) └── README.md # This file @@ -97,7 +97,7 @@ $ /review 34553 Files changed: 3 TypeScript components, 2 HTML templates, 3 spec files Review Lens: Frontend-Only -Agents Launched: typescript-reviewer, angular-reviewer, test-reviewer +Agents Launched: dotcms-typescript-reviewer, dotcms-angular-reviewer, dotcms-test-reviewer Output: Comprehensive frontend review with findings from all 3 agents ``` diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index f011f349701e..44437bf9ea5c 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -27,17 +27,17 @@ This skill performs an **autonomous, multi-stage frontend review** with intellig ### Stage 1-3: File Classification with Dedicated Agent -Launch the **File Classifier** agent (subagent type: `file-classifier`) to handle PR data collection, file classification, and review decision: +Launch the **File Classifier** agent (subagent type: `dotcms-file-classifier`) to handle PR data collection, file classification, and review decision: ``` Task( - subagent_type="file-classifier", + subagent_type="dotcms-file-classifier", prompt="Classify PR # files by domain (Angular, TypeScript, tests, styles) and determine if frontend-focused review is needed.", description="Classify PR files" ) ``` -The `file-classifier` agent will: +The `dotcms-file-classifier` agent will: 1. **Fetch** PR metadata and diff (`gh pr view`, `gh pr diff`) 2. **Classify** every changed file into reviewer buckets (angular, typescript, test, out-of-scope) 3. **Calculate** frontend vs non-frontend ratio @@ -49,31 +49,38 @@ The `file-classifier` agent will: ### Stage 4: Domain-Specific Review with Specialized Agents -**Using the file map from the file-classifier agent**, launch **parallel specialized agents** only for buckets that have files: +**Using the file map from the dotcms-file-classifier agent**, launch **parallel specialized agents** only for buckets that have files: -1. **TypeScript Type Reviewer** (subagent type: `typescript-reviewer`) +1. **TypeScript Type Reviewer** (subagent type: `dotcms-typescript-reviewer`) - Receives the `typescript-reviewer` file list from the file map - Focus: Type safety, generics, null handling, type quality - Confidence threshold: ≥ 75 - **Skip if**: No files in the typescript bucket -2. **Angular Pattern Reviewer** (subagent type: `angular-reviewer`) +2. **Angular Pattern Reviewer** (subagent type: `dotcms-angular-reviewer`) - Receives the `angular-reviewer` file list from the file map - Focus: Modern syntax, component architecture, lifecycle, subscriptions - Confidence threshold: ≥ 75 - **Skip if**: No files in the angular bucket -3. **Test Quality Reviewer** (subagent type: `test-reviewer`) +3. **Test Quality Reviewer** (subagent type: `dotcms-test-reviewer`) - Receives the `test-reviewer` file list from the file map - Focus: Spectator patterns, coverage, test quality - Confidence threshold: ≥ 75 - **Skip if**: No files in the test bucket +4. **SCSS/HTML Style Reviewer** (subagent type: `dotcms-scss-html-style-reviewer`) + - Receives the `styles` file list from the file map (`.scss`, `.css`, `.html` files) + - Focus: BEM compliance, CSS custom properties, unused classes, SCSS standards, Angular encapsulation, PrimeNG theming + - Confidence threshold: ≥ 75 + - **Skip if**: No `.scss`, `.css`, or `.html` files in the styles bucket + **Launch agents in parallel** using the Task tool (only for non-empty buckets): ``` -Task(subagent_type="typescript-reviewer", prompt="Review TypeScript type safety for PR #. Files: ", description="TypeScript review") -Task(subagent_type="angular-reviewer", prompt="Review Angular patterns for PR #. Files: ", description="Angular review") -Task(subagent_type="test-reviewer", prompt="Review test quality for PR #. Files: ", description="Test review") +Task(subagent_type="dotcms-typescript-reviewer", prompt="Review TypeScript type safety for PR #. Files: ", description="TypeScript review") +Task(subagent_type="dotcms-angular-reviewer", prompt="Review Angular patterns for PR #. Files: ", description="Angular review") +Task(subagent_type="dotcms-test-reviewer", prompt="Review test quality for PR #. Files: ", description="Test review") +Task(subagent_type="dotcms-scss-html-style-reviewer", prompt="Review SCSS/HTML styling standards for PR #. Files: ", description="Style review") ``` **For Backend/Config/Docs changes**: This skill focuses on frontend code review only. Backend reviews are handled separately. @@ -131,7 +138,7 @@ Task(subagent_type="test-reviewer", prompt="Review test quality for PR # [Only if frontend files changed - consolidate from specialized agents] ### TypeScript Type Safety -[From typescript-reviewer agent] +[From dotcms-typescript-reviewer agent] #### Critical Issues 🔴 (95-100) [Type safety violations, raw generics, unsafe casts] @@ -143,7 +150,7 @@ Task(subagent_type="test-reviewer", prompt="Review test quality for PR # [Type improvements, better generics] ### Angular Patterns -[From angular-reviewer agent] +[From dotcms-angular-reviewer agent] #### Critical Issues 🔴 (95-100) [Legacy syntax, missing standalone, memory leaks] @@ -155,7 +162,7 @@ Task(subagent_type="test-reviewer", prompt="Review test quality for PR # [Pattern improvements, optimizations] ### Test Quality -[From test-reviewer agent] +[From dotcms-test-reviewer agent] #### Critical Issues 🔴 (95-100) [Wrong Spectator usage, missing detectChanges] @@ -166,6 +173,18 @@ Task(subagent_type="test-reviewer", prompt="Review test quality for PR # #### Quality Issues 🔵 (75-84) [Test organization, clarity] +### Styling Standards +[From dotcms-scss-html-style-reviewer agent — only if .scss/.css/.html files changed] + +#### Critical Issues 🔴 (95-100) +[BEM violations, hardcoded colors/spacing, ::ng-deep misuse] + +#### Important Issues 🟡 (85-94) +[Unused classes, missing CSS variables, nesting depth exceeded] + +#### Quality Issues 🔵 (75-84) +[Selector improvements, mixin usage, PrimeNG theming patterns] + --- ## Approval Recommendation @@ -227,6 +246,6 @@ Use this as your **single entry point** for all PR reviews. ## Skill Metadata - **Author**: Generated from usage insights analysis -- **Last Updated**: 2026-02-10 +- **Last Updated**: 2026-02-24 - **Replaces**: dotcms-code-reviewer-frontend - **Dependencies**: `gh` CLI, access to repository diff --git a/.cursor/rules/frontend-context.mdc b/.cursor/rules/frontend-context.mdc index 1d8824d1a656..764fe0eac36f 100644 --- a/.cursor/rules/frontend-context.mdc +++ b/.cursor/rules/frontend-context.mdc @@ -9,7 +9,7 @@ alwaysApply: false **Nx monorepo** in `core-web/`: TypeScript, Angular apps/libs, and SDK for **Angular** and **React**. Full standards live in **`docs/frontend/`**. Index: **`@docs/frontend/README.md`** — load it for the full doc list and when to use each; then load the specific doc you need. ## Stack -- **Angular**: standalone, signals, `inject()`, `input()`/`output()`, `@if`/`@for`, OnPush, PrimeNG/PrimeFlex (Angular 20+). +- **Angular**: standalone, signals, `inject()`, `input()`/`output()`, `@if`/`@for`, OnPush, PrimeNG + Tailwind CSS (Angular 20+). PrimeFlex is deprecated/removed. - **SDK**: `sdk-angular`, `sdk-react`, `sdk-client`, `sdk-types`, etc. - **Apps**: `dotcms-ui`, `content-drive-ui`, `edit-ema-ui`, portlets, libs. @@ -31,7 +31,7 @@ cd core-web && yarn nx affected -t test --exclude='tag:skip:test' | Writing or refactoring components, templates, or Angular patterns | `@docs/frontend/ANGULAR_STANDARDS.md` | | Defining component structure, inputs/outputs, file layout | `@docs/frontend/COMPONENT_ARCHITECTURE.md` | | Adding or changing feature state (stores, async) | `@docs/frontend/STATE_MANAGEMENT.md` | -| Writing or updating styles, BEM, PrimeFlex | `@docs/frontend/STYLING_STANDARDS.md` | +| Writing or updating styles, Tailwind, BEM | `@docs/frontend/STYLING_STANDARDS.md` | | Writing or fixing tests (Spectator, Jest, data-testid) | `@docs/frontend/TESTING_FRONTEND.md` | | Using TypeScript (strict, inference, `unknown`, `as const`, `#` private) | `@docs/frontend/TYPESCRIPT_STANDARDS.md` | @@ -42,7 +42,7 @@ cd core-web && yarn nx affected -t test --exclude='tag:skip:test' - **File structure**: One component = one `.ts` + one `.html` + one `.scss` (or `.css`); `templateUrl`/`styleUrls` relative to the component file. - **Images**: Use `NgOptimizedImage` for static/asset URLs; not for inline base64. - **State**: Prefer NgRx Signal Store; avoid manual signal soup in components. See `@docs/frontend/STATE_MANAGEMENT.md`. -- **Styling**: Prefer PrimeFlex utility classes and PrimeNG components first; avoid custom SCSS when a utility exists. Use BEM only when custom styles are needed. See `@docs/frontend/STYLING_STANDARDS.md`. +- **Styling**: Prefer Tailwind utility classes and PrimeNG components first; PrimeFlex is deprecated/removed. Avoid custom SCSS when a Tailwind class exists. Use BEM only when custom styles are truly needed. See `@docs/frontend/STYLING_STANDARDS.md`. - **Testing**: Spectator + Jest/Vitest; `data-testid` on elements tests query; `byTestId()`, `spectator.setInput()`, `spectator.detectChanges()`, `spectator.click()`, `mockProvider()`; never set component inputs directly. Use `@dotcms/utils-testing` createFake functions (e.g. `createFakeContentlet`, `createFakeLanguage`) instead of manual mocks. See `@docs/frontend/TESTING_FRONTEND.md`. - **TypeScript**: Strict types, no `any` (use `unknown`), `as const` instead of enums, `#` for private. See `@docs/frontend/TYPESCRIPT_STANDARDS.md`. - **Services**: Single responsibility; `providedIn: 'root'`; use `inject()` instead of constructor injection. diff --git a/core-web/.claude/settings.json b/core-web/.claude/settings.json new file mode 100644 index 000000000000..a2fdc2bd4e9e --- /dev/null +++ b/core-web/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "nx-claude-plugins": { + "source": { + "source": "github", + "repo": "nrwl/nx-ai-agents-config" + } + } + }, + "enabledPlugins": { + "nx@nx-claude-plugins": true + } +} diff --git a/core-web/.cursor/rules/nx-rules.mdc b/core-web/.cursor/rules/nx-rules.mdc deleted file mode 100644 index 30ac77eb9f89..000000000000 --- a/core-web/.cursor/rules/nx-rules.mdc +++ /dev/null @@ -1,34 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- - -// This file is automatically generated by Nx Console - -You are in an nx workspace using Nx 20.5.1 and yarn as the package manager. - -You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user: - -# General Guidelines -- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture -- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration -- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors -- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool - -# Generation Guidelines -If the user wants to generate something, use the following flow: - -- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable -- get the available generators using the 'nx_generators' tool -- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them -- get generator details using the 'nx_generator_schema' tool -- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure -- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic -- open the generator UI using the 'nx_open_generate_ui' tool -- wait for the user to finish the generator -- read the generator log file using the 'nx_read_generator_log' tool -- use the information provided in the log file to answer the user's question or continue with what they were doing - - - diff --git a/core-web/.mcp.json b/core-web/.mcp.json deleted file mode 100644 index 7206148e4d3d..000000000000 --- a/core-web/.mcp.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "mcpServers": { - "nx-mcp": { - "type": "stdio", - "command": "npx", - "args": ["nx-mcp"] - } - } -} diff --git a/core-web/AGENTS.md b/core-web/AGENTS.md index ec27f8330efa..1bd62dcf741a 100644 --- a/core-web/AGENTS.md +++ b/core-web/AGENTS.md @@ -3,12 +3,21 @@ # General Guidelines for working with Nx +- For navigating/exploring the workspace, invoke the `nx-workspace` skill first - it has patterns for querying projects, targets, and dependencies - When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly +- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI - You have access to the Nx MCP server and its tools, use them to help the user -- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable. -- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies -- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration -- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors - For Nx plugin best practices, check `node_modules/@nx//PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable. +- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure + +## Scaffolding & Generators + +- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools + +## When to use nx_docs + +- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases +- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know +- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax diff --git a/core-web/CLAUDE.md b/core-web/CLAUDE.md index 42024325bed5..4d102d21b249 100644 --- a/core-web/CLAUDE.md +++ b/core-web/CLAUDE.md @@ -77,7 +77,7 @@ spectator.setInput('prop', value); // ALWAYS use setInput - **Prefix**: All components use `dot-` prefix - **Standalone**: All new components must be standalone - **State**: Use NgRx signals (`@ngrx/signals`) for state management -- **Styling**: Tailwind CSS + PrimeFlex utilities +- **Styling**: Tailwind CSS + PrimeNG theme (PrimeFlex deprecated/removed — use Tailwind utilities instead) - **Testing**: Jest + Spectator, use `data-testid` for selectors - **Dialogs**: All dialogs must have `closable: true` and `closeOnEscape: true` to allow closing via X button and ESC key @@ -158,13 +158,23 @@ See **[../CLAUDE.md](../CLAUDE.md)** for Java, Maven, REST API, and Git workflow -# General Guidelines for working with Nx +## General Guidelines for working with Nx -- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `yarn nx` (i.e. `yarn nx run`, `yarn nx run-many`, `yarn nx affected`) instead of using the underlying tooling directly +- For navigating/exploring the workspace, invoke the `nx-workspace` skill first - it has patterns for querying projects, targets, and dependencies +- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly +- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI - You have access to the Nx MCP server and its tools, use them to help the user -- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable. -- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies -- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration -- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors +- For Nx plugin best practices, check `node_modules/@nx//PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable. +- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure + +## Scaffolding & Generators + +- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools + +## When to use nx_docs + +- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases +- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know +- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/index.ts b/core-web/libs/portlets/dot-analytics/data-access/src/index.ts index 404fd48390a4..3c1d15b88f5b 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/index.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/index.ts @@ -6,8 +6,8 @@ export * from './lib/services/dot-analytics.service'; // Utils export * from './lib/utils/data/analytics-data.utils'; +export * from './lib/utils/data/engagement-data.utils'; export * from './lib/utils/filters.utils'; -export * from './lib/utils/mock-engagement-data'; // Constants export * from './lib/constants'; diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/dot-analytics-dashboard.store.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/dot-analytics-dashboard.store.ts index 6a6383fb6ee4..aa3c442c8faa 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/dot-analytics-dashboard.store.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/dot-analytics-dashboard.store.ts @@ -1,5 +1,6 @@ import { patchState, signalStore, withHooks, withMethods } from '@ngrx/signals'; +import { Location } from '@angular/common'; import { effect, inject } from '@angular/core'; import { ActivatedRoute, Params, Router } from '@angular/router'; @@ -35,71 +36,76 @@ export const DotAnalyticsDashboardStore = signalStore( withConversions(), withEngagement(), // Coordinator methods that work across features - withMethods((store, route = inject(ActivatedRoute), router = inject(Router)) => ({ - /** - * Sets current tab and syncs URL. - */ - setCurrentTabAndNavigate(tab: DashboardTab): void { - store.setCurrentTab(tab); - - // Update URL with tab query param - // TODO: Find a better way to update the URL with the tab query param. - router.navigate([], { - relativeTo: route, - queryParams: { tab: tab }, - queryParamsHandling: 'merge', - replaceUrl: true - }); - }, - - /** - * Refreshes all currently loaded data based on the current tab. - */ - refreshAllData(): void { - const currentTab = store.currentTab(); - - switch (currentTab) { - case DASHBOARD_TABS.pageview: - store.loadAllPageviewData(); - break; - case DASHBOARD_TABS.engagement: - store.loadEngagementData(); - break; - case DASHBOARD_TABS.conversions: - store.loadConversionsData(); - break; - } - }, - - /** - * Updates time range and syncs URL with query params. - */ - updateTimeRange(timeRange: TimeRangeInput): void { - store.setTimeRange(timeRange); - - // Build query params from time range - const queryParams: Params = {}; - - if (Array.isArray(timeRange)) { - // Custom date range - queryParams['time_range'] = TIME_RANGE_OPTIONS.custom; - queryParams['from'] = timeRange[0]; - queryParams['to'] = timeRange[1]; - } else { - // Predefined range - queryParams['time_range'] = timeRange; - } + withMethods( + ( + store, + route = inject(ActivatedRoute), + router = inject(Router), + location = inject(Location) + ) => ({ + /** + * Sets current tab and syncs URL without triggering Angular router navigation. + */ + setCurrentTabAndNavigate(tab: DashboardTab): void { + store.setCurrentTab(tab); + + const urlTree = router.createUrlTree([], { + relativeTo: route, + queryParams: { tab }, + queryParamsHandling: 'merge' + }); + location.replaceState(router.serializeUrl(urlTree)); + }, + + /** + * Refreshes all currently loaded data based on the current tab. + */ + refreshAllData(): void { + const currentTab = store.currentTab(); - // Update URL - // TODO: Find a better way to update the URL with the time range query params. - router.navigate([], { - relativeTo: route, - queryParams: queryParams, - queryParamsHandling: 'merge', - replaceUrl: true - }); - } - })), + switch (currentTab) { + case DASHBOARD_TABS.pageview: + store.loadAllPageviewData(); + break; + case DASHBOARD_TABS.engagement: + store.loadEngagementData(); + break; + case DASHBOARD_TABS.conversions: + store.loadConversionsData(); + break; + } + }, + + /** + * Updates time range and syncs URL with query params. + */ + updateTimeRange(timeRange: TimeRangeInput): void { + store.setTimeRange(timeRange); + + // Build query params from time range + const queryParams: Params = {}; + + if (Array.isArray(timeRange)) { + // Custom date range + queryParams['time_range'] = TIME_RANGE_OPTIONS.custom; + queryParams['from'] = timeRange[0]; + queryParams['to'] = timeRange[1]; + } else { + // Predefined range + queryParams['time_range'] = timeRange; + } + + // Update URL + // TODO: Find a better way to update the URL with the time range query params. + router.navigate([], { + relativeTo: route, + queryParams: queryParams, + queryParamsHandling: 'merge', + replaceUrl: true + }); + } + }) + ), withHooks({ onInit(store) { const route = inject(ActivatedRoute); diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-engagement.feature.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-engagement.feature.ts index 3c798053004b..a1b484e62e9f 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-engagement.feature.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-engagement.feature.ts @@ -1,63 +1,425 @@ +import { tapResponse } from '@ngrx/operators'; import { patchState, signalStoreFeature, type, withMethods, withState } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { pipe } from 'rxjs'; +import { forkJoin, of, pipe } from 'rxjs'; -import { delay, tap } from 'rxjs/operators'; +import { HttpErrorResponse } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; + +import { DotMessageService } from '@dotcms/data-access'; import { ComponentStatus } from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; import { FiltersState } from './with-filters.feature'; -import { RequestState } from '../../types'; -import { EngagementData } from '../../types/engagement.types'; -import { createInitialRequestState } from '../../utils/data/analytics-data.utils'; -import { MOCK_ENGAGEMENT_DATA } from '../../utils/mock-engagement-data'; +import { DotAnalyticsService } from '../../services/dot-analytics.service'; +import { createCubeQuery } from '../../utils/cube/cube-query-builder.util'; +import { + createInitialRequestState, + getPreviousPeriod, + toTimeRangeCubeJS +} from '../../utils/data/analytics-data.utils'; +import { + toEngagementBreakdownChartData, + toEngagementKPIs, + toEngagementPlatforms, + toEngagementSparklineData +} from '../../utils/data/engagement-data.utils'; + +import type { + ChartData, + DimensionField, + EngagementDailyEntity, + EngagementKPIs, + EngagementPlatforms, + RequestState, + SessionsByBrowserDailyEntity, + SessionsByDeviceDailyEntity, + SessionsByLanguageDailyEntity, + SparklineDataPoint, + TimeRangeInput +} from '../../types'; + +const ENGAGEMENT_DAILY_MEASURES = [ + 'totalSessions', + 'engagedSessions', + 'engagedConversionSessions', + 'engagementRate', + 'avgInteractionsPerEngagedSession', + 'avgSessionTimeSeconds', + 'avgEngagedSessionTimeSeconds' +]; + +const ENGAGEMENT_TREND_MEASURES = ['totalSessions', 'engagedSessions', 'engagementRate']; + +const SESSIONS_BY_MEASURES = ['engagedSessions', 'totalSessions', 'avgEngagedSessionTimeSeconds']; +const SESSIONS_BY_DEVICE_DIMENSIONS: DimensionField[] = ['deviceCategory']; +const SESSIONS_BY_BROWSER_DIMENSIONS: DimensionField[] = ['browserFamily']; +const SESSIONS_BY_LANGUAGE_DIMENSIONS: DimensionField[] = ['languageId']; /** * State interface for the Engagement feature. + * Multiple slices for independent loading per block. */ export interface EngagementState { - engagementData: RequestState; + engagementKpis: RequestState; + engagementSparkline: RequestState; + engagementBreakdown: RequestState; + engagementPlatforms: RequestState; } -/** - * Initial state for the Engagement feature. - */ const initialEngagementState: EngagementState = { - engagementData: createInitialRequestState() + engagementKpis: createInitialRequestState(), + engagementSparkline: createInitialRequestState(), + engagementBreakdown: createInitialRequestState(), + engagementPlatforms: createInitialRequestState() }; /** * Signal Store Feature for managing engagement analytics data. + * Each slice (KPIs, trend chart, breakdown, platforms) loads independently and has its own loading/error state. */ export function withEngagement() { return signalStoreFeature( { state: type() }, withState(initialEngagementState), - withMethods((store) => ({ - loadEngagementData: rxMethod( - pipe( - tap(() => - patchState(store, { - engagementData: { - status: ComponentStatus.LOADING, - data: null, - error: null - } - }) + withMethods( + ( + store, + globalStore = inject(GlobalStore), + analyticsService = inject(DotAnalyticsService), + dotMessageService = inject(DotMessageService) + ) => { + const getErrorMessage = (key: string, fallback: string) => + dotMessageService.get(key) || fallback; + + return { + /** + * Loads KPIs: current + previous period totals for trend calculation. + */ + _loadEngagementKpis: rxMethod<{ + timeRange: TimeRangeInput; + currentSiteId: string; + }>( + pipe( + tap(() => + patchState(store, { + engagementKpis: { + status: ComponentStatus.LOADING, + data: null, + error: null + } + }) + ), + switchMap(({ timeRange, currentSiteId }) => { + const dateRange = toTimeRangeCubeJS(timeRange); + const previousRange = getPreviousPeriod(timeRange); + + const currentQuery = createCubeQuery() + .fromCube('EngagementDaily') + .siteId(currentSiteId) + .measures(ENGAGEMENT_DAILY_MEASURES) + .timeRange('day', dateRange) + .build(); + + const previousQuery = createCubeQuery() + .fromCube('EngagementDaily') + .siteId(currentSiteId) + .measures(ENGAGEMENT_DAILY_MEASURES) + .timeRange('day', previousRange) + .build(); + + return forkJoin({ + current: analyticsService + .cubeQuery(currentQuery) + .pipe( + map((rows) => rows[0] ?? null), + catchError(() => of(null)) + ), + previous: analyticsService + .cubeQuery(previousQuery) + .pipe( + map((rows) => rows[0] ?? null), + catchError(() => of(null)) + ) + }).pipe( + tapResponse( + ({ current, previous }) => { + patchState(store, { + engagementKpis: { + status: ComponentStatus.LOADED, + data: toEngagementKPIs(current, previous), + error: null + } + }); + }, + (error: HttpErrorResponse) => { + patchState(store, { + engagementKpis: { + status: ComponentStatus.ERROR, + data: null, + error: + error?.message || + getErrorMessage( + 'analytics.error.loading.engagement', + 'Failed to load engagement KPIs' + ) + } + }); + } + ) + ); + }) + ) + ), + + /** + * Loads breakdown (engaged vs bounced doughnut) from current period totals. + */ + _loadEngagementBreakdown: rxMethod<{ + timeRange: TimeRangeInput; + currentSiteId: string; + }>( + pipe( + tap(() => + patchState(store, { + engagementBreakdown: { + status: ComponentStatus.LOADING, + data: null, + error: null + } + }) + ), + switchMap(({ timeRange, currentSiteId }) => { + const dateRange = toTimeRangeCubeJS(timeRange); + + const query = createCubeQuery() + .fromCube('EngagementDaily') + .siteId(currentSiteId) + .measures(['totalSessions', 'engagedSessions']) + .timeRange('day', dateRange) + .build(); + + return analyticsService + .cubeQuery(query) + .pipe( + tapResponse( + (rows) => { + const row = rows?.[0]; + const total = row + ? Number( + row['EngagementDaily.totalSessions'] ?? 0 + ) + : 0; + const engaged = row + ? Number( + row['EngagementDaily.engagedSessions'] ?? + 0 + ) + : 0; + patchState(store, { + engagementBreakdown: { + status: ComponentStatus.LOADED, + data: toEngagementBreakdownChartData( + total, + engaged + ), + error: null + } + }); + }, + (error: HttpErrorResponse) => { + patchState(store, { + engagementBreakdown: { + status: ComponentStatus.ERROR, + data: null, + error: + error?.message || + getErrorMessage( + 'analytics.error.loading.engagement', + 'Failed to load breakdown data' + ) + } + }); + } + ) + ); + }) + ) + ), + + /** + * Loads trend chart data (by day). + */ + /** + * Loads sparkline data (engagement rate per day). + */ + _loadEngagementSparkline: rxMethod<{ + timeRange: TimeRangeInput; + currentSiteId: string; + }>( + pipe( + tap(() => + patchState(store, { + engagementSparkline: { + status: ComponentStatus.LOADING, + data: null, + error: null + } + }) + ), + switchMap(({ timeRange, currentSiteId }) => { + const dateRange = toTimeRangeCubeJS(timeRange); + const query = createCubeQuery() + .fromCube('EngagementDaily') + .siteId(currentSiteId) + .measures(ENGAGEMENT_TREND_MEASURES) + .timeRange('day', dateRange, 'day') + .build(); + + return analyticsService + .cubeQuery(query) + .pipe( + tapResponse( + (rows) => { + + patchState(store, { + engagementSparkline: { + status: ComponentStatus.LOADED, + data: toEngagementSparklineData(rows ?? []), + error: null + } + }); + }, + (error: HttpErrorResponse) => { + patchState(store, { + engagementSparkline: { + status: ComponentStatus.ERROR, + data: null, + error: + error?.message || + getErrorMessage( + 'analytics.error.loading.engagement', + 'Failed to load sparkline data' + ) + } + }); + } + ) + ); + }) + ) + ), + + /** + * Loads platforms (device, browser, language) in parallel. + */ + _loadEngagementPlatforms: rxMethod<{ + timeRange: TimeRangeInput; + currentSiteId: string; + }>( + pipe( + tap(() => + patchState(store, { + engagementPlatforms: { + status: ComponentStatus.LOADING, + data: null, + error: null + } + }) + ), + switchMap(({ timeRange, currentSiteId }) => { + const dateRange = toTimeRangeCubeJS(timeRange); + + const deviceQuery = createCubeQuery() + .fromCube('SessionsByDeviceDaily') + .siteId(currentSiteId) + .measures(SESSIONS_BY_MEASURES) + .dimensions(SESSIONS_BY_DEVICE_DIMENSIONS) + .timeRange('day', dateRange) + .build(); + + const browserQuery = createCubeQuery() + .fromCube('SessionsByBrowserDaily') + .siteId(currentSiteId) + .measures(SESSIONS_BY_MEASURES) + .dimensions(SESSIONS_BY_BROWSER_DIMENSIONS) + .timeRange('day', dateRange) + .build(); + + const languageQuery = createCubeQuery() + .fromCube('SessionsByLanguageDaily') + .siteId(currentSiteId) + .measures(SESSIONS_BY_MEASURES) + .dimensions(SESSIONS_BY_LANGUAGE_DIMENSIONS) + .timeRange('day', dateRange) + .build(); + + return forkJoin({ + device: analyticsService.cubeQuery( + deviceQuery + ), + browser: + analyticsService.cubeQuery( + browserQuery + ), + language: + analyticsService.cubeQuery( + languageQuery + ) + }).pipe( + map(({ device, browser, language }) => + toEngagementPlatforms(device, browser, language) + ), + tapResponse( + (platforms) => + patchState(store, { + engagementPlatforms: { + status: ComponentStatus.LOADED, + data: platforms, + error: null + } + }), + (error: HttpErrorResponse) => + patchState(store, { + engagementPlatforms: { + status: ComponentStatus.ERROR, + data: null, + error: + error?.message || + getErrorMessage( + 'analytics.error.loading.engagement', + 'Failed to load platforms data' + ) + } + }) + ) + ); + }) + ) ), - delay(500), // Simulate network delay - tap(() => { - patchState(store, { - engagementData: { - status: ComponentStatus.LOADED, - data: MOCK_ENGAGEMENT_DATA, - error: null - } - }); - }) - ) - ) - })) + + /** + * Loads all engagement data. Dispatches independent requests per block. + */ + loadEngagementData(): void { + const currentSiteId = globalStore.currentSiteId(); + const timeRange = store.timeRange(); + + if (!currentSiteId) { + return; + } + + const payload = { timeRange, currentSiteId }; + this._loadEngagementKpis(payload); + this._loadEngagementSparkline(payload); + this._loadEngagementBreakdown(payload); + this._loadEngagementPlatforms(payload); + } + }; + } + ) ); } diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/common.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/common.types.ts index 6f224d914765..3ec8e99df376 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/common.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/common.types.ts @@ -57,7 +57,7 @@ export interface RequestState { */ export interface MetricData { name: string; - value: number | string; + value: number | string | null; subtitle: string; icon: string; status: ComponentStatus; diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/cubequery.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/cubequery.types.ts index 88d0f8be9007..7f3790ed7868 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/cubequery.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/cubequery.types.ts @@ -8,8 +8,17 @@ * - 'EventSummary': Conversion and event analytics * - 'ContentAttribution': Content attribution for conversions * - 'Conversion': Conversions overview data + * - 'EngagementDaily' and SessionsBy*: Engagement dashboard cubes */ -export type CubePrefix = 'request' | 'EventSummary' | 'ContentAttribution' | 'Conversion'; +export type CubePrefix = + | 'request' + | 'EventSummary' + | 'ContentAttribution' + | 'Conversion' + | 'EngagementDaily' + | 'SessionsByDeviceDaily' + | 'SessionsByBrowserDaily' + | 'SessionsByLanguageDaily'; /** * Sort direction options for ordering queries. @@ -72,7 +81,10 @@ const DimensionField = { CONVERSION_NAME: 'conversionName', TOTAL_CONVERSION: 'totalConversion', CONV_RATE: 'convRate', - TOP_ATTRIBUTED_CONTENT: 'topAttributedContent' + TOP_ATTRIBUTED_CONTENT: 'topAttributedContent', + DEVICE_CATEGORY: 'deviceCategory', + BROWSER_FAMILY: 'browserFamily', + LANGUAGE_ID: 'languageId' } as const; export type DimensionField = (typeof DimensionField)[keyof typeof DimensionField]; diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/engagement.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/engagement.types.ts index 6cf28d15d18b..03e5b783df9a 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/engagement.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/engagement.types.ts @@ -12,8 +12,6 @@ export interface EngagementKPI { label: string; /** Optional subtitle text */ subtitle?: string; - /** Optional sparkline data points for trend visualization */ - sparklineData?: SparklineDataPoint[]; } export interface EngagementKPIs { diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts index 45484e15042b..2320c14dbfba 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts @@ -195,3 +195,50 @@ export type AnalyticsKeys = (typeof AnalyticsKeys)[keyof typeof AnalyticsKeys]; * Default count limit for analytics queries. */ export const DEFAULT_COUNT_LIMIT = 50; + +/** + * EngagementDaily cube entity (totals or by-day row). + * Keys match Cube response format: cubeName.measureOrDimension. + */ +export interface EngagementDailyEntity { + 'EngagementDaily.totalSessions'?: string; + 'EngagementDaily.engagedSessions'?: string; + 'EngagementDaily.engagedConversionSessions'?: string; + 'EngagementDaily.engagementRate'?: string; + 'EngagementDaily.conversionRate'?: string; + 'EngagementDaily.avgInteractionsPerEngagedSession'?: string; + 'EngagementDaily.avgSessionTimeSeconds'?: string; + 'EngagementDaily.avgEngagedSessionTimeSeconds'?: string; + 'EngagementDaily.day'?: string; + 'EngagementDaily.day.day'?: string; +} + +/** + * SessionsByDeviceDaily cube entity (one row per device category). + */ +export interface SessionsByDeviceDailyEntity { + 'SessionsByDeviceDaily.deviceCategory'?: string; + 'SessionsByDeviceDaily.engagedSessions'?: string; + 'SessionsByDeviceDaily.totalSessions'?: string; + 'SessionsByDeviceDaily.avgEngagedSessionTimeSeconds'?: string; +} + +/** + * SessionsByBrowserDaily cube entity (one row per browser family). + */ +export interface SessionsByBrowserDailyEntity { + 'SessionsByBrowserDaily.browserFamily'?: string; + 'SessionsByBrowserDaily.engagedSessions'?: string; + 'SessionsByBrowserDaily.totalSessions'?: string; + 'SessionsByBrowserDaily.avgEngagedSessionTimeSeconds'?: string; +} + +/** + * SessionsByLanguageDaily cube entity (one row per language). + */ +export interface SessionsByLanguageDailyEntity { + 'SessionsByLanguageDaily.languageId'?: string; + 'SessionsByLanguageDaily.engagedSessions'?: string; + 'SessionsByLanguageDaily.totalSessions'?: string; + 'SessionsByLanguageDaily.avgEngagedSessionTimeSeconds'?: string; +} diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts index 60de4579ae3d..050548208382 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts @@ -14,6 +14,7 @@ import { extractTopPageValue, fillMissingDates, getDateRange, + getPreviousPeriod, transformDeviceBrowsersData, transformPageViewTimeLineData, transformTopPagesTableData @@ -71,16 +72,16 @@ describe('Analytics Data Utils', () => { expect(result).toBe(1250); }); - it('should return 0 when data is null', () => { + it('should return null when data is null', () => { const result = extractPageViews(null); - expect(result).toBe(0); + expect(result).toBeNull(); }); - it('should return 0 when totalRequest is missing', () => { + it('should return null when totalRequest is missing', () => { const mockData: Partial = {}; const result = extractPageViews(mockData as TotalPageViewsEntity); - expect(result).toBe(0); + expect(result).toBeNull(); }); it('should handle string numbers correctly', () => { @@ -103,9 +104,9 @@ describe('Analytics Data Utils', () => { expect(result).toBe(342); }); - it('should return 0 when data is null', () => { + it('should return null when data is null', () => { const result = extractSessions(null); - expect(result).toBe(0); + expect(result).toBeNull(); }); it('should return NaN when totalUsers is missing', () => { @@ -128,9 +129,9 @@ describe('Analytics Data Utils', () => { expect(result).toBe(890); }); - it('should return 0 when data is null', () => { + it('should return null when data is null', () => { const result = extractTopPageValue(null); - expect(result).toBe(0); + expect(result).toBeNull(); }); it('should return NaN when totalRequest is missing', () => { @@ -955,6 +956,28 @@ describe('Analytics Data Utils', () => { }); }); + describe('getPreviousPeriod', () => { + it('should return previous period of same length for custom date range', () => { + const result = getPreviousPeriod(['2026-02-01', '2026-02-06']); + expect(result).toEqual(['2026-01-26', '2026-01-31']); + }); + + it('should return previous period for single-day custom range', () => { + const result = getPreviousPeriod(['2026-02-01', '2026-02-01']); + expect(result).toEqual(['2026-01-31', '2026-01-31']); + }); + + it('should return previous period for predefined last7days', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); + const result = getPreviousPeriod('last7days'); + jest.useRealTimers(); + // last7days: Jan 9 - Jan 15 (7 days). Previous: Jan 2 - Jan 8 + expect(result[0]).toBe('2024-01-02'); + expect(result[1]).toBe('2024-01-08'); + }); + }); + describe('fillMissingDates', () => { describe('with PageViewTimeLineEntity', () => { it('should return empty array when data is null', () => { diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts index 7f652ec86d13..2a04088c0bf5 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts @@ -1,6 +1,7 @@ import { addDays, addHours, + differenceInDays, endOfDay, format, isSameDay, @@ -141,20 +142,32 @@ export function toTimeRangeCubeJS(timeRange: TimeRangeInput): TimeRangeCubeJS { /** * Extracts page views count from TotalPageViewsEntity */ -export const extractPageViews = (data: TotalPageViewsEntity | null): number => - data ? Number(data['EventSummary.totalEvents'] ?? 0) : 0; +export const extractPageViews = (data: TotalPageViewsEntity | null): number | null => { + if (!data) return null; + const value = Number(data['EventSummary.totalEvents'] ?? 0); + + return value === 0 ? null : value; +}; /** * Extracts unique sessions from UniqueVisitorsEntity */ -export const extractSessions = (data: UniqueVisitorsEntity | null): number => - data ? Number(data['EventSummary.uniqueVisitors']) : 0; +export const extractSessions = (data: UniqueVisitorsEntity | null): number | null => { + if (!data) return null; + const value = Number(data['EventSummary.uniqueVisitors']); + + return value === 0 ? null : value; +}; /** * Extracts top page performance value from TopPagePerformanceEntity */ -export const extractTopPageValue = (data: TopPagePerformanceEntity | null): number => - data ? Number(data['EventSummary.totalEvents']) : 0; +export const extractTopPageValue = (data: TopPagePerformanceEntity | null): number | null => { + if (!data) return null; + const value = Number(data['EventSummary.totalEvents']); + + return value === 0 ? null : value; +}; /** * Extracts page title from TopPagePerformanceEntity @@ -709,3 +722,19 @@ export const getDateRange = (timeRange: TimeRangeInput): [Date, Date] => { return [startOfDay(today), endOfDay(today)]; } }; + +/** + * Get the previous period of the same length as the given time range, ending the day before the current range starts. + * Used for engagement trend comparison (current vs previous period). + * + * @param timeRange - The current time range (predefined or custom [from, to]) + * @returns The previous period as [from, to] date strings (yyyy-MM-dd) for Cube queries + */ +export const getPreviousPeriod = (timeRange: TimeRangeInput): [string, string] => { + const [startDate, endDate] = getDateRange(timeRange); + const days = differenceInDays(endDate, startDate) + 1; + const previousEnd = subDays(startDate, 1); + const previousStart = subDays(previousEnd, days - 1); + + return [format(previousStart, 'yyyy-MM-dd'), format(previousEnd, 'yyyy-MM-dd')]; +}; diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/engagement-data.utils.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/engagement-data.utils.ts new file mode 100644 index 000000000000..9dac5d82643d --- /dev/null +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/engagement-data.utils.ts @@ -0,0 +1,318 @@ +import { AnalyticsChartColors, BAR_CHART_STYLE } from '../../constants'; + +import type { + ChartData, + EngagementDailyEntity, + EngagementKPIs, + EngagementPlatformMetrics, + EngagementPlatforms, + SessionsByBrowserDailyEntity, + SessionsByDeviceDailyEntity, + SessionsByLanguageDailyEntity, + SparklineDataPoint +} from '../../types'; + +const EMPTY_KPIS: EngagementKPIs = { + engagementRate: { + value: 0, + trend: 0, + label: 'Engagement Rate', + subtitle: '0 Engaged Sessions' + }, + avgInteractions: { value: 0, trend: 0, label: 'Avg Interactions (Engaged)' }, + avgSessionTime: { value: '0m 0s', trend: 0, label: 'Average Session Time' }, + conversionRate: { value: '0%', trend: 0, label: 'Conversion Rate' } +}; + +function parseNum(s: string | undefined): number { + if (s === undefined || s === null || s === '') return 0; + const n = Number(s); + return Number.isFinite(n) ? n : 0; +} + +/** + * Format seconds to "Xm Ys" for display. + */ +export function formatSecondsToTime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return '0m 0s'; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}m ${s}s`; +} + +/** + * Compute trend percentage: ((current - previous) / previous) * 100. + * - When previous is 0 or missing and current > 0: returns 100 (+100%, "subió desde cero"). + * - When both 0: returns 0. + * - Otherwise: normal percentage change. + */ +export function computeTrendPercent(current: number, previous: number): number { + const prev = previous ?? 0; + if (prev === 0) { + return current > 0 ? 100 : 0; + } + return Math.round(((current - prev) / prev) * 1000) / 10; +} + +/** + * Map EngagementDaily totals (current + previous) to EngagementKPIs. + * Handles empty/insufficient data with defaults. + */ +export function toEngagementKPIs( + currentRow: EngagementDailyEntity | null, + previousRow: EngagementDailyEntity | null +): EngagementKPIs | null { + const current = currentRow ?? {}; + const previous = previousRow ?? {}; + + const totalSessionsCur = parseNum(current['EngagementDaily.totalSessions']); + + if (totalSessionsCur === 0) { + return null; + } + + const engagedSessionsCur = parseNum(current['EngagementDaily.engagedSessions']); + const engagementRateCur = parseNum(current['EngagementDaily.engagementRate']); + const conversionRateCur = parseNum(current['EngagementDaily.conversionRate']); + const avgInteractionsCur = parseNum( + current['EngagementDaily.avgInteractionsPerEngagedSession'] + ); + const avgSessionTimeCur = parseNum(current['EngagementDaily.avgSessionTimeSeconds']); + + const engagementRatePrev = parseNum(previous['EngagementDaily.engagementRate']); + const conversionRatePrev = parseNum(previous['EngagementDaily.conversionRate']); + const avgInteractionsPrev = parseNum( + previous['EngagementDaily.avgInteractionsPerEngagedSession'] + ); + const avgSessionTimePrev = parseNum(previous['EngagementDaily.avgSessionTimeSeconds']); + + const engagementRateTrend = computeTrendPercent(engagementRateCur, engagementRatePrev); + const conversionRateTrend = computeTrendPercent(conversionRateCur, conversionRatePrev); + const avgInteractionsTrend = computeTrendPercent(avgInteractionsCur, avgInteractionsPrev); + const avgSessionTimeTrend = computeTrendPercent(avgSessionTimeCur, avgSessionTimePrev); + + const engagementRateValue = Math.round(engagementRateCur * 10000) / 100; + const conversionRateValue = `${Math.round(conversionRateCur * 1000) / 10}%`; + + return { + engagementRate: { + value: engagementRateValue, + trend: engagementRateTrend, + label: 'Engagement Rate', + subtitle: `${engagedSessionsCur.toLocaleString()} Engaged Sessions` + }, + avgInteractions: { + value: avgInteractionsCur, + trend: avgInteractionsTrend, + label: 'Avg Interactions (Engaged)' + }, + avgSessionTime: { + value: formatSecondsToTime(avgSessionTimeCur), + trend: avgSessionTimeTrend, + label: 'Average Session Time' + }, + conversionRate: { + value: conversionRateValue, + trend: conversionRateTrend, + label: 'Conversion Rate' + } + }; +} + +/** + * Map EngagementDaily trend-by-day rows to SparklineDataPoint[]. + * Shows engagement rate (%) per day for the sparkline chart. + * When only 1 point exists, prepends a synthetic point at the same value + * so Chart.js draws a flat line across the full width. + */ +export function toEngagementSparklineData(rows: EngagementDailyEntity[]): SparklineDataPoint[] { + if (!rows?.length) return []; + + const points = rows.map((row) => { + const day = row['EngagementDaily.day.day'] ?? row['EngagementDaily.day'] ?? ''; + const rate = parseNum(row['EngagementDaily.engagementRate']); + return { + date: typeof day === 'string' ? day.slice(0, 10) : '', + value: Math.round(rate * 100) + }; + }); + + if (points.length === 1) { + const only = points[0]; + const prevDate = getPreviousDay(only.date); + points.unshift({ date: prevDate, value: 0 }); + } + + return points; +} + +function getPreviousDay(dateStr: string): string { + const date = new Date(dateStr + 'T00:00:00'); + date.setDate(date.getDate() - 1); + return date.toISOString().slice(0, 10); +} + +/** + * Map EngagementDaily by-day rows to ChartData for the trend chart. + * Handles empty array with default empty ChartData. + */ +export function toEngagementTrendChartData(rows: EngagementDailyEntity[]): ChartData { + if (!rows?.length) { + return { labels: [], datasets: [] }; + } + + const labels = rows.map((row) => { + const day = row['EngagementDaily.day.day'] ?? row['EngagementDaily.day']; + if (typeof day === 'string') return day.slice(0, 10); + return ''; + }); + const data = rows.map((row) => parseNum(row['EngagementDaily.engagedSessions'])); + + return { + labels, + datasets: [ + { + label: 'Trend', + data, + backgroundColor: AnalyticsChartColors.primary.line, + ...BAR_CHART_STYLE + } + ] + }; +} + +/** + * Map totalSessions and engagedSessions to doughnut ChartData (Engaged vs Bounced). + * Returns empty ChartData when totalSessions is 0 so the chart shows empty state. + */ +export function toEngagementBreakdownChartData( + totalSessions: number, + engagedSessions: number +): ChartData { + if (totalSessions === 0) { + return { labels: [], datasets: [] }; + } + const bounced = Math.max(0, totalSessions - engagedSessions); + const engagedPct = Math.round((engagedSessions / totalSessions) * 100); + const bouncedPct = 100 - engagedPct; + + return { + labels: [`Engaged Sessions (${engagedPct}%)`, `Bounced Sessions (${bouncedPct}%)`], + datasets: [ + { + label: 'Engagement Breakdown', + data: [engagedSessions, bounced], + backgroundColor: [AnalyticsChartColors.primary.line, '#000000'] + } + ] + }; +} + +function toPlatformMetrics( + name: string, + views: number, + totalViews: number, + avgTimeSeconds: number +): EngagementPlatformMetrics { + const percentage = totalViews > 0 ? Math.round((views / totalViews) * 100) : 0; + return { + name, + views, + percentage, + time: formatSecondsToTime(avgTimeSeconds) + }; +} + +/** + * Map SessionsByDeviceDaily rows to device platform metrics. + */ +export function toEngagementPlatformsFromDevice( + rows: SessionsByDeviceDailyEntity[] | null +): EngagementPlatformMetrics[] { + if (!rows?.length) return []; + const total = rows.reduce( + (sum, r) => sum + parseNum(r['SessionsByDeviceDaily.engagedSessions']), + 0 + ); + return rows.map((row) => { + const views = parseNum(row['SessionsByDeviceDaily.engagedSessions']); + const avgSec = parseNum(row['SessionsByDeviceDaily.avgEngagedSessionTimeSeconds']); + const name = row['SessionsByDeviceDaily.deviceCategory'] ?? 'Other'; + return toPlatformMetrics(name, views, total, avgSec); + }); +} + +/** + * Map SessionsByBrowserDaily rows to browser platform metrics. + */ +export function toEngagementPlatformsFromBrowser( + rows: SessionsByBrowserDailyEntity[] | null +): EngagementPlatformMetrics[] { + if (!rows?.length) return []; + const total = rows.reduce( + (sum, r) => sum + parseNum(r['SessionsByBrowserDaily.engagedSessions']), + 0 + ); + return rows.map((row) => { + const views = parseNum(row['SessionsByBrowserDaily.engagedSessions']); + const avgSec = parseNum(row['SessionsByBrowserDaily.avgEngagedSessionTimeSeconds']); + const name = row['SessionsByBrowserDaily.browserFamily'] ?? 'Other'; + return toPlatformMetrics(name, views, total, avgSec); + }); +} + +/** + * Map SessionsByLanguageDaily rows to language platform metrics. + * Uses languageId as name if no display-name API is available. + */ +export function toEngagementPlatformsFromLanguage( + rows: SessionsByLanguageDailyEntity[] | null +): EngagementPlatformMetrics[] { + if (!rows?.length) return []; + const total = rows.reduce( + (sum, r) => sum + parseNum(r['SessionsByLanguageDaily.engagedSessions']), + 0 + ); + return rows.map((row) => { + const views = parseNum(row['SessionsByLanguageDaily.engagedSessions']); + const avgSec = parseNum(row['SessionsByLanguageDaily.avgEngagedSessionTimeSeconds']); + const name = row['SessionsByLanguageDaily.languageId'] ?? 'Unknown'; + return toPlatformMetrics(name, views, total, avgSec); + }); +} + +/** + * Build full EngagementPlatforms from device, browser, and language arrays. + */ +export function toEngagementPlatforms( + deviceRows: SessionsByDeviceDailyEntity[] | null, + browserRows: SessionsByBrowserDailyEntity[] | null, + languageRows: SessionsByLanguageDailyEntity[] | null +): EngagementPlatforms { + return { + device: toEngagementPlatformsFromDevice(deviceRows), + browser: toEngagementPlatformsFromBrowser(browserRows), + language: toEngagementPlatformsFromLanguage(languageRows) + }; +} + +/** + * Default empty KPIs when request fails or has no data. + */ +export function getEmptyEngagementKPIs(): EngagementKPIs { + return { ...EMPTY_KPIS }; +} + +/** + * Default empty ChartData for trend or breakdown when no data. + */ +export function getEmptyEngagementChartData(): ChartData { + return { labels: [], datasets: [] }; +} + +/** + * Default empty EngagementPlatforms when no data. + */ +export function getEmptyEngagementPlatforms(): EngagementPlatforms { + return { device: [], browser: [], language: [] }; +} diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/mock-engagement-data.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/mock-engagement-data.ts deleted file mode 100644 index cb611bfc7656..000000000000 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/mock-engagement-data.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { AnalyticsChartColors, BAR_CHART_STYLE } from '../constants/dot-analytics.constants'; -import { ChartData } from '../types/entities.types'; - -export const MOCK_ENGAGEMENT_DATA = { - kpis: { - engagementRate: { - value: 45, - trend: 8, - subtitle: '29,203 Engaged Sessions', - label: 'Engagement Rate', - sparklineData: [ - { date: 'Oct 1', value: 32 }, - { date: 'Oct 2', value: 35 }, - { date: 'Oct 3', value: 38 }, - { date: 'Oct 4', value: 36 }, - { date: 'Oct 5', value: 40 }, - { date: 'Oct 6', value: 42 }, - { date: 'Oct 7', value: 39 }, - { date: 'Oct 8', value: 43 }, - { date: 'Oct 9', value: 41 }, - { date: 'Oct 10', value: 44 }, - { date: 'Oct 11', value: 42 }, - { date: 'Oct 12', value: 45 } - ] - }, - avgInteractions: { value: 6.4, trend: 18, label: 'Avg Interactions (Engaged)' }, - avgSessionTime: { value: '2m 34s', trend: 12, label: 'Average Session Time' }, - conversionRate: { value: '3.2%', trend: -0.3, label: 'Conversion Rate' } - }, - trend: { - labels: ['Oct1', 'Oct2', 'Oct3', 'Oct4', 'Oct5', 'Nov1', 'Nov2', 'Nov3'], - datasets: [ - { - label: 'Trend', - data: [40, 35, 45, 30, 50, 45, 48, 48], - backgroundColor: AnalyticsChartColors.primary.line, - ...BAR_CHART_STYLE - } - ] - } as ChartData, - breakdown: { - labels: ['Engaged Sessions (65%)', 'Bounced Sessions (35%)'], - datasets: [ - { - label: 'Engagement Breakdown', - data: [65, 35], - backgroundColor: [AnalyticsChartColors.primary.line, '#000000'] - } - ] - } as ChartData, - platforms: { - device: [ - { name: 'Desktop', views: 77053, percentage: 72, time: '2m 45s' }, - { name: 'Mobile', views: 16071, percentage: 20, time: '1m 47s' }, - { name: 'Tablet', views: 2531, percentage: 8, time: '2m 00s' } - ], - browser: [ - { name: 'Chrome', views: 60000, percentage: 65, time: '2m 50s' }, - { name: 'Safari', views: 20000, percentage: 25, time: '2m 30s' }, - { name: 'Firefox', views: 10000, percentage: 10, time: '2m 40s' } - ], - language: [ - { name: 'English', views: 80000, percentage: 80, time: '2m 55s' }, - { name: 'Spanish', views: 10000, percentage: 10, time: '2m 20s' }, - { name: 'French', views: 5000, percentage: 5, time: '2m 10s' } - ] - } -}; diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.html index 334ae44a1283..1d1340590c9e 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.html @@ -1,19 +1,21 @@
@if ($showMessage()) { - -
- - - {{ 'analytics.feature.state' | dm }} - {{ 'development' | dm }} - -
-
+
+ +
+ + + {{ 'analytics.feature.state' | dm }} + {{ 'development' | dm }} + +
+
+
}
diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.scss b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.scss index f1e54c492e3e..4ab2de4ea25c 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.scss +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.scss @@ -2,5 +2,5 @@ .header-container { background: $color-palette-gray-100; - border-bottom: 1.5px solid $color-palette-gray-300; + border-bottom: $field-border-size solid $color-palette-gray-300; } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.ts index 27eca6bec160..a12010ca7312 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.ts @@ -25,6 +25,7 @@ import { isValidTab, TimeRangeInput } from '@dotcms/portlets/dot-analytics/data-access'; +import { GlobalStore } from '@dotcms/store'; import { DotMessagePipe } from '@dotcms/ui'; import DotAnalyticsConversionsReportComponent from './reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component'; @@ -56,6 +57,7 @@ const HIDE_ANALYTICS_MESSAGE_BANNER_KEY = 'analytics-dashboard-hide-message-bann * and feature-flag-gated visibility of the Engagement tab. */ export default class DotAnalyticsDashboardComponent { + readonly #globalStore = inject(GlobalStore); /** Analytics dashboard store providing data and actions */ readonly store = inject(DotAnalyticsDashboardStore); readonly #activatedRoute = inject(ActivatedRoute); @@ -89,7 +91,10 @@ export default class DotAnalyticsDashboardComponent { const params = this.#activatedRoute.snapshot.queryParamMap; if (enabled && !params.has('tab')) { - this.store.setCurrentTabAndNavigate(DASHBOARD_TABS.engagement); + this.#globalStore.addNewBreadcrumb({ + label: DASHBOARD_TABS.engagement, + url: '/analytics/dashboard?tab=engagement' + }); } }); } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.html index 0ce65a833bce..fa95d5428a35 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.html @@ -5,64 +5,36 @@ @let eventTypeOptions = $eventTypeOptions(); @if (isLoading) { - -
- @for (i of [1, 2, 3]; track i) { -
-
-
-
-
-
+
+
+
+
+
+
+
+
+ @for (i of [1, 2, 3, 4, 5]; track i) { +
+
+
+
+
+
} +
+ +
} @else if (isError) { - -
+
} @else if (isEmpty) { - -
- +
+
} @else { @@ -148,8 +120,8 @@ [styleClass]="'event-type-badge ' + row.eventType" /> -
-
{{ row.title }}
+
+
{{ row.title }}
{{ row.identifier }}
diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.scss b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.scss index 281f68f103b9..dd3345102f2f 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.scss +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.scss @@ -1,6 +1,40 @@ @use "variables" as *; -::ng-deep { +$table-min-height: 20rem; + +.table-state { + display: flex; + align-items: center; + justify-content: center; + min-height: $table-min-height; +} + +.skeleton-table { + display: flex; + flex-direction: column; + + &__header { + display: flex; + gap: $spacing-3; + padding: $spacing-3 $spacing-4; + border-bottom: 1px solid var(--surface-200); + } + + &__row { + display: flex; + gap: $spacing-3; + padding: $spacing-3 $spacing-4; + border-bottom: 1px solid var(--surface-100); + } + + &__pagination { + display: flex; + justify-content: center; + padding: $spacing-3 $spacing-4; + } +} + +:host ::ng-deep { .p-tag.event-type-badge { border-radius: $border-radius-sm; font-size: $font-size-xs; diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.spec.ts index b46949d75ff4..91778567d466 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.spec.ts @@ -47,7 +47,7 @@ describe('DotAnalyticsContentConversionsTableComponent', () => { it('should display loading skeleton when isLoading is true', () => { expect(spectator.query(byTestId('content-conversions-loading'))).toBeTruthy(); - expect(spectator.queryAll('.skeleton-cell').length).toBeGreaterThan(0); + expect(spectator.queryAll('.skeleton-table__row').length).toBe(5); }); it('should not display table when loading', () => { @@ -87,7 +87,7 @@ describe('DotAnalyticsContentConversionsTableComponent', () => { it('should display empty message when isEmpty is true', () => { expect(spectator.query(byTestId('content-conversions-empty'))).toBeTruthy(); - expect(spectator.query('dot-analytics-state-message')).toBeTruthy(); + expect(spectator.query('dot-analytics-empty-state')).toBeTruthy(); }); it('should not display table when empty', () => { diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.ts index 88f9a420d940..9d09f9dbb280 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, computed, input, linkedSignal } fro import { FormsModule } from '@angular/forms'; import { MultiSelectModule } from 'primeng/multiselect'; +import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; import { TagModule } from 'primeng/tag'; @@ -10,6 +11,7 @@ import { ComponentStatus } from '@dotcms/dotcms-models'; import { ContentConversionRow } from '@dotcms/portlets/dot-analytics/data-access'; import { DotMessagePipe } from '@dotcms/ui'; +import { DotAnalyticsEmptyStateComponent } from '../../../shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component'; import { DotAnalyticsStateMessageComponent } from '../../../shared/components/dot-analytics-state-message/dot-analytics-state-message.component'; /** @@ -23,8 +25,10 @@ import { DotAnalyticsStateMessageComponent } from '../../../shared/components/do CommonModule, FormsModule, MultiSelectModule, + SkeletonModule, TableModule, TagModule, + DotAnalyticsEmptyStateComponent, DotAnalyticsStateMessageComponent, DotMessagePipe ], diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.html index 47950a106f3c..ba92b8c769bf 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.html @@ -4,63 +4,34 @@ @let data = $data(); @if (isLoading) { - -
- @for (i of [1, 2, 3]; track i) { -
-
-
-
-
-
+
+
+
+
+
+
+
+ @for (i of [1, 2, 3, 4, 5]; track i) { +
+
+
+
+
} +
+ +
} @else if (isError) { - -
+
} @else if (isEmpty) { - -
- +
+
} @else { diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.scss b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.scss index 1d490fc5c444..f7b50000870e 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.scss +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.scss @@ -1,17 +1,41 @@ @use "variables" as *; -.table-title { - font-size: $font-size-lmd; - font-weight: $font-weight-bold; - color: $black; - line-height: $line-height-relative; - margin: 0; +$table-min-height: 20rem; + +.table-state { display: flex; align-items: center; + justify-content: center; + min-height: $table-min-height; +} + +.skeleton-table { + display: flex; + flex-direction: column; + + &__header { + display: flex; + gap: $spacing-3; + padding: $spacing-3 $spacing-4; + border-bottom: 1px solid var(--surface-200); + } + + &__row { + display: flex; + gap: $spacing-3; + padding: $spacing-3 $spacing-4; + border-bottom: 1px solid var(--surface-100); + } + + &__pagination { + display: flex; + justify-content: center; + padding: $spacing-3 $spacing-4; + } } // // Custom conversion name badges -::ng-deep { +:host ::ng-deep { .p-tag.conversion-name-badge { background-color: $color-palette-blue-tint; border: 1px solid $color-palette-blue-op-20; @@ -60,16 +84,16 @@ text-align: right; } -// .more-indicator { -// font-size: $font-size-xs; -// color: $color-palette-gray-500; -// background: $color-palette-gray-100; -// border-radius: $border-radius-sm; -// padding: 0 $spacing-1; -// } +.more-indicator { + font-size: $font-size-xs; + color: $color-palette-gray-500; + background: $color-palette-gray-100; + border-radius: $border-radius-sm; + padding: 0 $spacing-1; +} // // Event type badge styling -::ng-deep { +:host ::ng-deep { .p-tag.event-type-badge { border-radius: $border-radius-sm; font-size: $font-size-xs; diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.spec.ts index 74b49dd6fc0a..cbd78bac858e 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.spec.ts @@ -66,7 +66,7 @@ describe('DotAnalyticsConversionsOverviewTableComponent', () => { it('should display loading skeleton when isLoading is true', () => { expect(spectator.query(byTestId('conversions-overview-loading'))).toBeTruthy(); - expect(spectator.queryAll('.skeleton-cell').length).toBeGreaterThan(0); + expect(spectator.queryAll('.skeleton-table__row').length).toBe(5); }); it('should not display table when loading', () => { @@ -106,7 +106,7 @@ describe('DotAnalyticsConversionsOverviewTableComponent', () => { it('should display empty message when isEmpty is true', () => { expect(spectator.query(byTestId('conversions-overview-empty'))).toBeTruthy(); - expect(spectator.query('dot-analytics-state-message')).toBeTruthy(); + expect(spectator.query('dot-analytics-empty-state')).toBeTruthy(); }); it('should not display table when empty', () => { @@ -144,16 +144,15 @@ describe('DotAnalyticsConversionsOverviewTableComponent', () => { ); }); - it('should display top attributed content items', () => { + it('should display the first attributed content item', () => { const firstRow = spectator.queryAll('p-table tbody tr')[0]; - const lastCell = firstRow.querySelectorAll('td')[3]; // Top Attributed Content column - const contentItems = lastCell.querySelectorAll('.attributed-content-item'); - - // Template shows one .attributed-content-item per row (first item only); "+N" for the rest - expect(contentItems.length).toBe(1); - const firstItem = mockData[0]['Conversion.topAttributedContent'][0]; - expect(lastCell.textContent).toContain(firstItem.title); - expect(lastCell.querySelector('.more-indicator')?.textContent?.trim()).toBe('+1'); + const lastCell = firstRow.querySelectorAll('td')[3]; + const contentItem = lastCell.querySelector('.attributed-content-item'); + + expect(contentItem).toExist(); + expect(contentItem.textContent).toContain( + mockData[0]['Conversion.topAttributedContent'][0].title + ); }); it('should display header columns', () => { diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.ts index 8913cd551cc4..77e8656e105e 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; import { TagModule } from 'primeng/tag'; @@ -8,6 +9,7 @@ import { ComponentStatus } from '@dotcms/dotcms-models'; import { ConversionsOverviewEntity } from '@dotcms/portlets/dot-analytics/data-access'; import { DotMessagePipe } from '@dotcms/ui'; +import { DotAnalyticsEmptyStateComponent } from '../../../shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component'; import { DotAnalyticsStateMessageComponent } from '../../../shared/components/dot-analytics-state-message/dot-analytics-state-message.component'; /** @@ -19,8 +21,10 @@ import { DotAnalyticsStateMessageComponent } from '../../../shared/components/do selector: 'dot-analytics-conversions-overview-table', imports: [ CommonModule, + SkeletonModule, TableModule, TagModule, + DotAnalyticsEmptyStateComponent, DotAnalyticsStateMessageComponent, DotMessagePipe ], diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.html index d6a690a90c81..1573ac1725ad 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.html @@ -4,9 +4,9 @@
diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.ts index 56a27c0eb62d..d44b33b4f8b5 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.ts @@ -108,23 +108,28 @@ export default class DotAnalyticsConversionsReportComponent implements OnInit { const totalConversions = this.store.totalConversions(); const convertingVisitors = this.store.convertingVisitors(); - const totalConversionsValue = totalConversions.data + const totalConversionsRaw = totalConversions.data ? parseInt(totalConversions.data['EventSummary.totalEvents'], 10) - : 0; + : null; + const totalConversionsValue = totalConversionsRaw === 0 ? null : totalConversionsRaw; const uniqueVisitors = convertingVisitors.data ? parseInt(convertingVisitors.data['EventSummary.uniqueVisitors'], 10) - : 0; + : null; const uniqueConvertingVisitors = convertingVisitors.data ? parseInt(convertingVisitors.data['EventSummary.uniqueConvertingVisitors'], 10) - : 0; + : null; - // Site Conversion Rate = (uniqueConvertingVisitors / uniqueVisitors) * 100 - const conversionRate = - uniqueVisitors > 0 - ? Math.round((uniqueConvertingVisitors / uniqueVisitors) * 10000) / 100 - : 0; + const hasVisitorData = uniqueVisitors != null && uniqueVisitors > 0; + + const conversionRate = hasVisitorData + ? `${Math.round(((uniqueConvertingVisitors ?? 0) / uniqueVisitors) * 10000) / 100}%` + : null; + + const convertingVisitorsValue = hasVisitorData + ? `${uniqueConvertingVisitors ?? 0}/${uniqueVisitors}` + : null; return [ { @@ -137,7 +142,7 @@ export default class DotAnalyticsConversionsReportComponent implements OnInit { }, { name: 'analytics.metrics.converting-visitors', - value: `${uniqueConvertingVisitors}/${uniqueVisitors}`, + value: convertingVisitorsValue, subtitle: 'analytics.metrics.converting-visitors.subtitle', icon: 'pi-users', status: convertingVisitors.status, @@ -145,7 +150,7 @@ export default class DotAnalyticsConversionsReportComponent implements OnInit { }, { name: 'analytics.metrics.site-conversion-rate', - value: `${conversionRate}%`, + value: conversionRate, subtitle: 'analytics.metrics.site-conversion-rate.subtitle', icon: 'pi-chart-line', status: convertingVisitors.status, diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.html index 5144244130d7..cae4925606bc 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.html @@ -1,62 +1,68 @@ -@let kpis = $kpis(); -@let status = $status(); +