From 687d0fcf9e8dec19d540f987978f4f628c9210d6 Mon Sep 17 00:00:00 2001 From: amonsour Date: Thu, 9 Jan 2025 18:27:13 +0000 Subject: [PATCH 1/6] Updated db.ts for more ease of use - Added intervals to get either full, weekly, monthly, quarterly granularity - cleaned up code --- apps/db-cli/src/app/scripts/db.ts | 602 ++++++++++++++++++------------ 1 file changed, 365 insertions(+), 237 deletions(-) diff --git a/apps/db-cli/src/app/scripts/db.ts b/apps/db-cli/src/app/scripts/db.ts index 0da51401..94488a3e 100644 --- a/apps/db-cli/src/app/scripts/db.ts +++ b/apps/db-cli/src/app/scripts/db.ts @@ -20,9 +20,9 @@ import { singleDatesFromDateRange, } from '@dua-upd/external-data'; import { + GranularityPeriod, TimingUtility, arrayToDictionary, - arrayToDictionaryFlat, arrayToDictionaryMultiref, dayjs, logJson, @@ -35,10 +35,11 @@ import { BlobStorageService } from '@dua-upd/blob-storage'; import { RunScriptCommand } from '../run-script.command'; import { startTimer } from './utils/misc'; import { outputExcel, outputJson } from './utils/output'; -import { spawn } from 'child_process'; import { preprocessCommentWords } from '@dua-upd/feedback'; import { FeedbackService } from '@dua-upd/api/feedback'; +type Interval = 'full' | 'weekly' | 'monthly' | 'quarterly' | 'yearly'; + export const recalculateViews = async ( db: DbService, updateService: DbUpdateService, @@ -1188,6 +1189,113 @@ export async function syncCalldriversRefs(db: DbService) { await db.collections.callDrivers.syncTaskReferences(db.collections.tasks); console.timeEnd('syncCalldriversRefs'); } +function getDateRanges( + startDate: string, + endDate: string, + interval: Interval, +): { start: string; end: string }[] { + const start = dayjs(startDate, 'MM/DD/YYYY'); + const end = dayjs(endDate, 'MM/DD/YYYY'); + + const dateRanges = []; + let current = start; + + const addRange = (rangeStart, rangeEnd) => { + dateRanges.push({ + start: rangeStart.format('YYYY-MM-DD'), + end: rangeEnd.format('YYYY-MM-DD'), + }); + }; + + switch (interval) { + case 'full': + { + addRange(start, end); + break; + } + case 'weekly': + // Weekly intervals (Monday-Sunday) + { + const firstWeekEnd = current.endOf('week').isAfter(end) + ? end + : current.endOf('week').add(1, 'day'); + + addRange(current, firstWeekEnd); + + current = firstWeekEnd.add(1, 'day').startOf('isoWeek'); + + while (current.isBefore(end) || current.isSame(end)) { + const weekStart = current; + const weekEnd = current.endOf('isoWeek').isAfter(end) + ? end + : current.endOf('isoWeek'); + + addRange(weekStart, weekEnd); + + current = current.add(1, 'week').startOf('isoWeek'); + } + } + break; + + case 'monthly': { + const firstMonthEnd = current.endOf('month').isAfter(end) + ? end + : current.endOf('month'); + + addRange(current, firstMonthEnd); + + current = current.add(1, 'month').startOf('month'); + + while (current.isBefore(end) && current.endOf('month').isBefore(end)) { + const monthStart = current.startOf('month'); + const monthEnd = current.endOf('month'); + + addRange(monthStart, monthEnd); + + current = current.add(1, 'month').startOf('month'); + } + + if (current.isBefore(end) || current.isSame(end)) { + const lastMonthEnd = end; + addRange(current, lastMonthEnd); + } + break; + } + + case 'quarterly': + while (current.isBefore(end) || current.isSame(end)) { + const quarterStart = current.startOf('quarter'); + const quarterEnd = current.endOf('quarter').isAfter(end) + ? end + : current.endOf('quarter'); + + addRange(quarterStart, quarterEnd); + + current = current.add(1, 'quarter').startOf('quarter'); + } + break; + + case 'yearly': + while (current.isBefore(end) || current.isSame(end)) { + const yearStart = current.startOf('year'); + const yearEnd = current.endOf('year').isAfter(end) + ? end + : current.endOf('year'); + + addRange(yearStart, yearEnd); + + current = current.add(1, 'year').startOf('year'); + } + break; + + default: + throw new Error( + 'Invalid interval. Use "full", "weekly", "monthly", "quarterly", or "yearly".', + ); + } + + return dateRanges; +} export async function thirty30Report() { const db = (this).inject(DbService); @@ -1209,23 +1317,34 @@ export async function thirty30Report() { // ------------------- Input Values ---------------------- - const dateRangeBefore = { - start: '2024-08-26', + const interval: Interval = 'monthly'; + + const dateRangeBefore: GranularityPeriod = { + start: '2024-06-24', end: '2024-09-24', - // start: '2024-10-20', - // end: '2024-10-26', }; - const dateRangeAfter = { + const dateRangeAfter: GranularityPeriod = { start: '2024-09-25', - end: '2024-10-24', + end: '2024-12-25', }; const project = '6597b740c6cda2812bbb141f'; // -------------------------------------------------------- - const dateRanges = [dateRangeBefore, dateRangeAfter]; + const dateRangeBeforeInterval = getDateRanges( + dateRangeBefore.start, + dateRangeBefore.end, + interval + ); + const dateRangeAfterInterval = getDateRanges( + dateRangeAfter.start, + dateRangeAfter.end, + interval + ); + + const dateRangesInterval = [dateRangeBeforeInterval, dateRangeAfterInterval]; const projectId = new Types.ObjectId(project); const tasks = await db.collections.tasks @@ -1234,41 +1353,32 @@ export async function thirty30Report() { .exec(); async function collectMetricsForTasks(dateRange) { - const metricsByTask: any = {}; + const metricsByTask = []; const metricsByCallDrivers: any = {}; const metricsByFeedbackEN: any = {}; const metricsByFeedbackFR: any = {}; - for (const task of tasks) { - const calls = ( - await db.collections.callDrivers.getCallsByTpcId( - `${dateRange.start}/${dateRange.end}`, - task.tpc_ids, - ) - ).reduce((a, b) => a + b.calls, 0); + // Ensure dateRange is an array + const dateRangesArray = Array.isArray(dateRange) ? dateRange : [dateRange]; + // Calculate full range (earliest start to latest end) + const fullRangeStart = dateRangesArray[0].start; + const fullRangeEnd = dateRangesArray[dateRangesArray.length - 1].end; + + // Collect data for the full range + for (const task of tasks) { const callsByTask = await db.collections.callDrivers.getCallsByTopic( - `${dateRange.start}/${dateRange.end}`, - { - tasks: task._id, - }, + `${fullRangeStart}/${fullRangeEnd}`, + { tasks: task._id }, ); const mostRelevantCommentsAndWords = await feedback.getMostRelevantCommentsAndWords({ - dateRange: parseDateRangeString( - `${dateRange.start}/${dateRange.end}`, - ), + dateRange: parseDateRangeString(`${fullRangeStart}/${fullRangeEnd}`), type: 'task', id: task._id.toString(), }); - metricsByTask[task._id.toString()] = { - _id: task._id.toString(), - title: task.title, - calls, - }; - metricsByCallDrivers[task._id.toString()] = { ...callsByTask, }; @@ -1282,82 +1392,78 @@ export async function thirty30Report() { }; } - const metrics = await db.collections.pageMetrics - .aggregate<{ - _id: Types.ObjectId; - url: string; - visits: number; - no_clicks: number; - yes_clicks: number; - }>() - .match({ - projects: projectId, - date: { - $gte: new Date(dateRange.start), - $lte: new Date(dateRange.end), - }, - }) - .project({ - date: 1, - visits: 1, - dyf_no: 1, - dyf_yes: 1, - tasks: 1, - url: 1, - }) - .unwind('tasks') - .group({ - _id: '$tasks', - visits: { - $sum: '$visits', - }, - no_clicks: { - $sum: '$dyf_no', - }, - yes_clicks: { - $sum: '$dyf_yes', - }, - }) - .exec(); - - for (const taskMetrics of metrics) { - const _id = taskMetrics._id.toString(); - const taskMetricz = metricsByTask[_id]; - - delete taskMetrics._id; - - if (taskMetricz) { - metricsByTask[_id] = { - _id, - start: dateRange.start, - end: dateRange.end, - ...taskMetricz, - ...taskMetrics, - }; + // Process metrics by interval date range + for (const { start, end } of dateRangesArray) { + for (const task of tasks) { + // Aggregate call metrics for the interval + const calls = ( + await db.collections.callDrivers.getCallsByTpcId( + `${start}/${end}`, + task.tpc_ids, + ) + ).reduce((total, entry) => total + entry.calls, 0); + + // Collect page metrics + const pageMetrics = await db.collections.pageMetrics + .aggregate<{ + _id: Types.ObjectId; + visits: number; + no_clicks: number; + yes_clicks: number; + }>() + .match({ + projects: projectId, + date: { + $gte: new Date(start), + $lte: new Date(end), + }, + }) + .project({ + visits: 1, + no_clicks: '$dyf_no', + yes_clicks: '$dyf_yes', + tasks: 1, + }) + .unwind('tasks') + .group({ + _id: '$tasks', + visits: { $sum: '$visits' }, + no_clicks: { $sum: '$no_clicks' }, + yes_clicks: { $sum: '$yes_clicks' }, + }) + .exec(); - const calls_per_100_visits = - metricsByTask[_id].visits > 0 - ? round( - (metricsByTask[_id].calls / metricsByTask[_id].visits) * 100, - 3, - ) || 0 - : 0; + const taskMetrics = pageMetrics.find( + (metric) => metric._id.toString() === task._id.toString(), + ); + + const visits = taskMetrics?.visits || 0; + const no_clicks = taskMetrics?.no_clicks || 0; + const yes_clicks = taskMetrics?.yes_clicks || 0; + const calls_per_100_visits = + visits > 0 ? round((calls / visits) * 100, 3) : 0; const no_clicks_per_1000_visits = - metricsByTask[_id].no_clicks > 0 - ? round( - (metricsByTask[_id].no_clicks / metricsByTask[_id].visits) * - 1000, - 3, - ) || 0 - : 0; - - metricsByTask[_id].calls_per_100_visits = calls_per_100_visits; - metricsByTask[_id].no_clicks_per_1000_visits = - no_clicks_per_1000_visits; + visits > 0 ? round((no_clicks / visits) * 1000, 3) : 0; + + // Push metrics for this interval + metricsByTask.push({ + task_id: task._id.toString(), + task_title: task.title, + start, + end, + calls, + visits, + no_clicks, + yes_clicks, + calls_per_100_visits, + no_clicks_per_1000_visits, + }); } } + metricsByTask.sort((a, b) => a.task_title.localeCompare(b.task_title)); + return { metricsByTask, metricsByCallDrivers, @@ -1367,57 +1473,103 @@ export async function thirty30Report() { } async function collectMetricsForProjects(dateRange) { - const projectMetrics = await db.collections.pageMetrics - .aggregate<{ - visits: number; - no_clicks: number; - yes_clicks: number; - }>() - .match({ - projects: projectId, - date: { - $gte: new Date(dateRange.start), - $lte: new Date(dateRange.end), - }, - }) - .group({ - _id: null, - visits: { $sum: '$visits' }, - no_clicks: { $sum: '$dyf_no' }, - yes_clicks: { $sum: '$dyf_yes' }, - }) - .exec(); - - const totalVisits = projectMetrics[0]?.visits || 0; - const totalNoClicks = projectMetrics[0]?.no_clicks || 0; - const totalYesClicks = projectMetrics[0]?.yes_clicks || 0; - - const totalCalls = ( - await db.collections.callDrivers.getCallsByTpcId( - `${dateRange.start}/${dateRange.end}`, - tasks.map((task) => task.tpc_ids).flat(), - ) - ).reduce((a, b) => a + b.calls, 0); - - const calls_per_100_visits = - totalVisits > 0 ? round((totalCalls / totalVisits) * 100, 3) : 0; - - const no_clicks_per_1000_visits = - totalVisits > 0 ? round((totalNoClicks / totalVisits) * 1000, 3) : 0; - - return [ - { + const projectMetrics = []; + const callsByWeek = []; + + // Ensure dateRange is an array + const dateRangesArray = Array.isArray(dateRange) ? dateRange : [dateRange]; + + for (const { start, end } of dateRangesArray) { + // Aggregate project metrics + const intervalMetrics = await db.collections.pageMetrics + .aggregate<{ + visits: number; + no_clicks: number; + yes_clicks: number; + }>() + .match({ + projects: projectId, + date: { + $gte: new Date(start), + $lte: new Date(end), + }, + }) + .project({ + visits: 1, + no_clicks: '$dyf_no', + yes_clicks: '$dyf_yes', + }) + .group({ + _id: null, + visits: { $sum: '$visits' }, + no_clicks: { $sum: '$no_clicks' }, + yes_clicks: { $sum: '$yes_clicks' }, + }) + .exec(); + + // Ensure we have data before processing + if (intervalMetrics.length > 0) { + projectMetrics.push({ + visits: intervalMetrics[0].visits || 0, + no_clicks: intervalMetrics[0].no_clicks || 0, + yes_clicks: intervalMetrics[0].yes_clicks || 0, + start, + end, + }); + } else { + projectMetrics.push({ + visits: 0, + no_clicks: 0, + yes_clicks: 0, + start, + end, + }); + } + + // Get total calls for the date range + const totalCalls = ( + await db.collections.callDrivers.getCallsByTpcId( + `${start}/${end}`, + tasks.map((task) => task.tpc_ids).flat(), // Flatten task IDs array + ) + ).reduce((a, b) => a + b.calls, 0); + + callsByWeek.push({ + calls: totalCalls, + start, + end, + }); + } + + // Combine project metrics and call data + const intervalMetrics = projectMetrics.map((metric) => { + const callData = callsByWeek.find( + (call) => call.start === metric.start && call.end === metric.end, + ); + const totalCalls = callData ? callData.calls : 0; + + const calls_per_100_visits = + metric.visits > 0 ? round((totalCalls / metric.visits) * 100, 3) : 0; + + const no_clicks_per_1000_visits = + metric.visits > 0 + ? round((metric.no_clicks / metric.visits) * 1000, 3) + : 0; + + return { project_id: projectId.toString(), - start: dateRange.start, - end: dateRange.end, - total_visits: totalVisits, + start: metric.start, + end: metric.end, + total_visits: metric.visits, total_calls: totalCalls, - total_no_clicks: totalNoClicks, - total_yes_clicks: totalYesClicks, + total_no_clicks: metric.no_clicks, + total_yes_clicks: metric.yes_clicks, calls_per_100_visits, no_clicks_per_1000_visits, - }, - ]; + }; + }); + + return intervalMetrics; } function getValidSheetName(title: string, suffix: string): string { @@ -1430,106 +1582,82 @@ export async function thirty30Report() { return `${shortenedTitle}${suffix}`; } - const { - metricsByTask: tasksBeforeArray, - metricsByCallDrivers: callDriverBeforeArray, - metricsByFeedbackEN: feedbackENBeforeData, - metricsByFeedbackFR: feedbackFRBeforeData, - } = await collectMetricsForTasks(dateRanges[0]); - const { - metricsByTask: tasksAfterArray, - metricsByCallDrivers: callDriverAfterArray, - metricsByFeedbackEN: feedbackENAfterData, - metricsByFeedbackFR: feedbackFRAfterData, - } = await collectMetricsForTasks(dateRanges[1]); - - const projectsBeforeArray = await collectMetricsForProjects(dateRanges[0]); - const projectsAfterArray = await collectMetricsForProjects(dateRanges[1]); - - const overviewData = [ - { - sheetName: 'tasks-before', - data: Object.values(tasksBeforeArray) as Record[], - }, - { - sheetName: 'tasks-after', - data: Object.values(tasksAfterArray) as Record[], - }, + function createSheetData(dataObject, suffix, tasksDict) { + const sheetData = []; + let index = 0; + + for (const [taskId, data] of Object.entries(dataObject)) { + const taskTitle = `${index}-${tasksDict[taskId]?.task_title}` || `Task-${index}-${taskId}`; + sheetData.push({ + sheetName: getValidSheetName(taskTitle, suffix), + data: Object.values(data) as Record[], + }); + index++; + } + + return sheetData; + } + + // Collect metrics for tasks and projects + const [ { - sheetName: 'projects-before', - data: projectsBeforeArray as Record[], + metricsByTask: tasksBeforeArray, + metricsByCallDrivers: callDriverBeforeArray, + metricsByFeedbackEN: feedbackENBeforeData, + metricsByFeedbackFR: feedbackFRBeforeData, }, { - sheetName: 'projects-after', - data: projectsAfterArray as Record[], + metricsByTask: tasksAfterArray, + metricsByCallDrivers: callDriverAfterArray, + metricsByFeedbackEN: feedbackENAfterData, + metricsByFeedbackFR: feedbackFRAfterData, }, + ] = await Promise.all([ + collectMetricsForTasks(dateRangesInterval[0]), + collectMetricsForTasks(dateRangesInterval[1]), + ]); + + const [ + projectsBeforeArray, + projectsAfterArray, + ] = await Promise.all([ + collectMetricsForProjects(dateRangesInterval[0]), + collectMetricsForProjects(dateRangesInterval[1]), + ]); + + // Prepare overview data + const overviewData = [ + { sheetName: 'tasks-before', data: Object.values(tasksBeforeArray) }, + { sheetName: 'tasks-after', data: Object.values(tasksAfterArray) }, + { sheetName: 'projects-before', data: projectsBeforeArray }, + { sheetName: 'projects-after', data: projectsAfterArray }, + ]; + + // Generate task dictionaries on task_id data + const beforeTasksDict = arrayToDictionary(tasksBeforeArray, 'task_id', true); + const afterTasksDict = arrayToDictionary(tasksAfterArray, 'task_id', true); + + const callDriversData = [ + ...createSheetData(callDriverBeforeArray, '-before', beforeTasksDict), + ...createSheetData(callDriverAfterArray, '-after', afterTasksDict), + ]; + + // Generate feedback data + const feedbackData = [ + ...createSheetData(feedbackENBeforeData, '-en-before', beforeTasksDict), + ...createSheetData(feedbackFRBeforeData, '-fr-before', beforeTasksDict), + ...createSheetData(feedbackENAfterData, '-en-after', afterTasksDict), + ...createSheetData(feedbackFRAfterData, '-fr-after', afterTasksDict), ]; - const callDriversData = []; - const feedbackData = []; - - //CALL DRIVERS SHEET CONF STARTS --------- - - for (const [taskId, data] of Object.entries(callDriverBeforeArray)) { - const taskTitle = tasksBeforeArray[taskId]?.title || `Task-${taskId}`; - callDriversData.push({ - sheetName: getValidSheetName(taskTitle, '-before'), - data: Object.values(data) as Record[], - }); - } - - for (const [taskId, data] of Object.entries(callDriverAfterArray)) { - const taskTitle = tasksAfterArray[taskId]?.title || `Task-${taskId}`; - callDriversData.push({ - sheetName: getValidSheetName(taskTitle, '-after'), - data: Object.values(data) as Record[], - }); - } - - //CALL DRIVERS SHEET CONF ENDS -------- - - //FEEDBACK SHEET CONF STARTS --------- - for (const [taskId, data] of Object.entries(feedbackENBeforeData)) { - const taskTitle = tasksBeforeArray[taskId]?.title || `Task-${taskId}`; - feedbackData.push({ - sheetName: getValidSheetName(taskTitle, '-en-before'), - data: Object.values(data) as Record[], - }); - } - - for (const [taskId, data] of Object.entries(feedbackFRBeforeData)) { - const taskTitle = tasksAfterArray[taskId]?.title || `Task-${taskId}`; - feedbackData.push({ - sheetName: getValidSheetName(taskTitle, '-fr-before'), - data: Object.values(data) as Record[], - }); - } - - for (const [taskId, data] of Object.entries(feedbackENAfterData)) { - const taskTitle = tasksBeforeArray[taskId]?.title || `Task-${taskId}`; - feedbackData.push({ - sheetName: getValidSheetName(taskTitle, '-en-after'), - data: Object.values(data) as Record[], - }); - } - - for (const [taskId, data] of Object.entries(feedbackFRAfterData)) { - const taskTitle = tasksAfterArray[taskId]?.title || `Task-${taskId}`; - feedbackData.push({ - sheetName: getValidSheetName(taskTitle, '-fr-after'), - data: Object.values(data) as Record[], - }); - } - - //FEEDBACK SHEET CONF ENDS --------- const date = new Date().toISOString().slice(0, 10); - await outputExcel(`${outDir}/overview_report_${date}.xlsx`, overviewData); + await outputExcel(`${outDir}/overview_report_${interval}_${date}.xlsx`, overviewData); await outputExcel( - `${outDir}/call_drivers_report_${date}.xlsx`, + `${outDir}/call_drivers_report_${interval}_${date}.xlsx`, callDriversData, ); - await outputExcel(`${outDir}/feedback_report_${date}.xlsx`, feedbackData); + await outputExcel(`${outDir}/feedback_report_${interval}_${date}.xlsx`, feedbackData); console.log(`Excel report generated successfully in ${outDir}`); -} \ No newline at end of file +} From 8b391bbfeee1d098ac6359cf56bae5dc8e7d5470 Mon Sep 17 00:00:00 2001 From: amonsour Date: Thu, 9 Jan 2025 18:39:13 +0000 Subject: [PATCH 2/6] Update db.ts --- apps/db-cli/src/app/scripts/db.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/db-cli/src/app/scripts/db.ts b/apps/db-cli/src/app/scripts/db.ts index 94488a3e..a141efd2 100644 --- a/apps/db-cli/src/app/scripts/db.ts +++ b/apps/db-cli/src/app/scripts/db.ts @@ -37,6 +37,8 @@ import { startTimer } from './utils/misc'; import { outputExcel, outputJson } from './utils/output'; import { preprocessCommentWords } from '@dua-upd/feedback'; import { FeedbackService } from '@dua-upd/api/feedback'; +import isoWeek from 'dayjs/plugin/isoWeek'; +dayjs.extend(isoWeek); type Interval = 'full' | 'weekly' | 'monthly' | 'quarterly' | 'yearly'; From 7611308964a24871d12d52394c5fa638fff4a196 Mon Sep 17 00:00:00 2001 From: amonsour Date: Thu, 30 Jan 2025 15:54:54 +0000 Subject: [PATCH 3/6] [Feature addition] Added Version history and the ability to get the alt language from the selected page --- apps/api/src/pages/pages.module.ts | 4 +- apps/api/src/pages/pages.service.ts | 53 ++ libs/types-common/src/lib/data.types.ts | 4 + libs/types-common/src/lib/schema.types.ts | 1 + libs/upd/components/src/index.ts | 3 +- .../page-version/page-version.component.html | 115 +++ .../page-version/page-version.component.scss | 115 +++ .../page-version.component.spec.ts | 21 + .../page-version/page-version.component.ts | 895 ++++++++++++++++++ .../src/lib/radio/radio.component.html | 16 +- .../src/lib/radio/radio.component.ts | 43 +- .../src/lib/upd-components.module.ts | 5 +- libs/upd/i18n/src/lib/translations/en-CA.json | 6 +- libs/upd/i18n/src/lib/translations/fr-CA.json | 6 +- .../+state/pages-details.facade.ts | 10 +- .../+state/pages-details.reducer.ts | 2 + .../pages-details-versions.component.css | 0 .../pages-details-versions.component.html | 9 + .../pages-details-versions.component.spec.ts | 24 + .../pages-details-versions.component.ts | 30 + .../pages-details/pages-details.component.css | 6 +- .../pages-details.component.html | 6 +- .../pages-details/pages-details.component.ts | 121 ++- .../pages/src/lib/pages-routing.module.ts | 2 + libs/upd/views/pages/src/lib/pages.module.ts | 2 + package-lock.json | 162 +++- package.json | 9 +- 27 files changed, 1603 insertions(+), 67 deletions(-) create mode 100644 libs/upd/components/src/lib/page-version/page-version.component.html create mode 100644 libs/upd/components/src/lib/page-version/page-version.component.scss create mode 100644 libs/upd/components/src/lib/page-version/page-version.component.spec.ts create mode 100644 libs/upd/components/src/lib/page-version/page-version.component.ts create mode 100644 libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.css create mode 100644 libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.html create mode 100644 libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.spec.ts create mode 100644 libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.ts diff --git a/apps/api/src/pages/pages.module.ts b/apps/api/src/pages/pages.module.ts index f20eb6ca..d2134502 100644 --- a/apps/api/src/pages/pages.module.ts +++ b/apps/api/src/pages/pages.module.ts @@ -6,6 +6,7 @@ import { DbModule, DbService } from '@dua-upd/db'; import { hours } from '@dua-upd/utils-common'; import { FeedbackModule } from '@dua-upd/api/feedback'; import { FlowModule } from '@dua-upd/api/flow'; +import { BlobStorageModule, BlobStorageService } from '@dua-upd/blob-storage'; @Module({ imports: [ @@ -13,8 +14,9 @@ import { FlowModule } from '@dua-upd/api/flow'; DbModule, FeedbackModule, FlowModule.register(), + BlobStorageModule, ], controllers: [PagesController], - providers: [PagesService, DbService], + providers: [PagesService, DbService, BlobStorageService], }) export class PagesModule {} diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 716d2c0c..b90d44ec 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -8,6 +8,7 @@ import type { MetricsConfig, PageDocument, PageMetricsModel, + UrlDocument, } from '@dua-upd/db'; import { DbService, @@ -27,17 +28,21 @@ import type { IProject, PageStatus, Direction, + UrlHash, } from '@dua-upd/types-common'; import { arrayToDictionary, dateRangeSplit, parseDateRangeString, percentChange, + wait, } from '@dua-upd/utils-common'; import type { InternalSearchTerm } from '@dua-upd/types-common'; import { FeedbackService } from '@dua-upd/api/feedback'; import { compressString, decompressString } from '@dua-upd/node-utils'; import { FlowService } from '@dua-upd/api/flow'; +import { BlobStorageService } from '@dua-upd/blob-storage'; +import { format } from 'prettier'; @Injectable() export class PagesService { @@ -54,6 +59,7 @@ export class PagesService { @Inject(CACHE_MANAGER) private cacheManager: Cache, private feedbackService: FeedbackService, private flowService: FlowService, + @Inject(BlobStorageService.name) private blob: BlobStorageService, ) {} async listPages({ projection, populate }): Promise { @@ -267,8 +273,39 @@ export class PagesService { const readability = await this.readabilityModel .find({ page: new Types.ObjectId(params.id) }) .sort({ date: -1 }) + .lean() .exec(); + const urls = await this.db.collections.urls + .find({ page: new Types.ObjectId(params.id) }) + .sort({ date: -1 }) + .lean() + .exec(); + + const hash = urls.map((url) => url.hashes).flat(); + + const promises: Promise[] = []; + + for (const h of hash) { + promises.push( + this.blob.blobModels.urls + .blob(h.hash) + .downloadToString() + .then(async (blob) => ({ + hash: h.hash, + date: h.date, + blob: await format(blob, { + parser: 'html', + }), + })), + ); + await wait(30); + } + + const hashes = (await Promise.all(promises)).sort( + (a, b) => b.date.getTime() - a.date.getTime(), + ); + const mostRelevantCommentsAndWords = await this.feedbackService.getMostRelevantCommentsAndWords({ dateRange: parseDateRangeString(params.dateRange), @@ -295,6 +332,20 @@ export class PagesService { ? percentChange(numComments, numPreviousComments) : null; + const alternateUrl = await this.pageModel.findById( + new Types.ObjectId(params.id), + { + altLangHref: 1, + }, + ); + + const altLangPage = await this.pageModel.findOne( + { url: alternateUrl.altLangHref }, + { _id: 1 }, + ); + + const alternatePageId = altLangPage._id; + const results = { ...page, is404: page.is_404, @@ -336,6 +387,8 @@ export class PagesService { mostRelevantCommentsAndWords, numComments, numCommentsPercentChange, + hashes, + alternatePageId, } as PageDetailsData; await this.cacheManager.set(cacheKey, results); diff --git a/libs/types-common/src/lib/data.types.ts b/libs/types-common/src/lib/data.types.ts index 5f19653d..9ec5c623 100644 --- a/libs/types-common/src/lib/data.types.ts +++ b/libs/types-common/src/lib/data.types.ts @@ -16,6 +16,7 @@ import type { IReadability, IAnnotations, IReports, + UrlHash, } from './schema.types'; import type { MostRelevantCommentsAndWordsByLang } from './feedback.types'; @@ -132,6 +133,9 @@ export interface PageDetailsData extends EntityDetailsData { mostRelevantCommentsAndWords: MostRelevantCommentsAndWordsByLang; numComments: number; numCommentsPercentChange: number | null; + + hashes: UrlHash[]; + alternatePageId: string; } export interface OverviewAggregatedData { diff --git a/libs/types-common/src/lib/schema.types.ts b/libs/types-common/src/lib/schema.types.ts index ab04dcd2..b40d351e 100644 --- a/libs/types-common/src/lib/schema.types.ts +++ b/libs/types-common/src/lib/schema.types.ts @@ -377,6 +377,7 @@ export interface IUxTest { export interface UrlHash { hash: string; date: Date; + blob?: string; } export interface IUrl { diff --git a/libs/upd/components/src/index.ts b/libs/upd/components/src/index.ts index a1086998..bcc968e8 100644 --- a/libs/upd/components/src/index.ts +++ b/libs/upd/components/src/index.ts @@ -34,4 +34,5 @@ export * from './lib/did-you-know/did-you-know.component'; export * from './lib/range-slider/range-slider.component'; export * from './lib/heatmap/heatmap.component'; export * from './lib/page-flow/page-flow.component'; -export * from './lib/arrow-connect/arrow-connect.component'; \ No newline at end of file +export * from './lib/arrow-connect/arrow-connect.component'; +export * from './lib/page-version/page-version.component'; \ No newline at end of file diff --git a/libs/upd/components/src/lib/page-version/page-version.component.html b/libs/upd/components/src/lib/page-version/page-version.component.html new file mode 100644 index 00000000..e43298a0 --- /dev/null +++ b/libs/upd/components/src/lib/page-version/page-version.component.html @@ -0,0 +1,115 @@ +@if (hashes().length < 1) { +

Does not have a comparison

+} @else { +
+
+ +
+ + +
+ + +
+ + +
+
+
+ @if (viewMode().value !== 'source') { + @if (elements().length > 0) { +
+

+ Total: {{ elements().length }} updates | + {{ currentIndex() === 0 ? '-' : currentIndex() }}/{{ + elements().length + }} +

+ +
+ keyboard_arrow_up +
+
+ keyboard_arrow_down +
+
+ } @else if (elements().length === 0) { +

No updates!

+ } + } +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ {{ item.text }} +
+ + + +
+
+
+
+
+ + +
+ +
+
+
+} diff --git a/libs/upd/components/src/lib/page-version/page-version.component.scss b/libs/upd/components/src/lib/page-version/page-version.component.scss new file mode 100644 index 00000000..ee3e9493 --- /dev/null +++ b/libs/upd/components/src/lib/page-version/page-version.component.scss @@ -0,0 +1,115 @@ +@import 'ngx-diff/styles/default-theme'; +@import 'diff2html/bundles/css/diff2html.min.css'; +@import 'https://use.fontawesome.com/releases/v5.15.4/css/all.css'; +/* * { + outline: 1px solid red; /* Highlight all elements */ + +/* @import 'https://www.canada.ca/etc/designs/canada/wet-boew/css/theme.min.css'; */ + +.d2h-file-side-diff { + overflow-x: scroll; + overflow-y: scroll; + max-height: 50vh; +} + +.d2h-file-diff { + overflow-x: scroll; + overflow-y: scroll; + max-height: 50vh; +} + +.d2h-code-wrapper { + position: relative; +} + +.d2h-file-name-wrapper { + display: none; +} + +.live-container { + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 1rem; + overflow-y: scroll; + height: 50vh; + position: relative; +} + + + +.slide-content { + display: flex; + justify-content: flex-end; /* Align content to the right */ + align-items: center; /* Vertically center the text and buttons */ +} + +.scrollable { + max-height: 200px; + overflow-y: auto; + width: 100%; + overflow-x: hidden; +} + +.change-text { + margin: 0; + text-align: right; /* Align the text to the right */ + padding: 10px; +} + + +.custom-prev, +.custom-next { + background: #fff; + color: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(0, 0, 0, 0.1); + cursor: pointer; + border-radius: 5px; + font-size: 14px; +} + +.custom-prev { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.custom-next { + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.custom-prev:hover, +.custom-next:hover { + background: rgba(255, 255, 255, 0.5); +} + +.loading-spinner { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + font-size: 1.2rem; + color: #888; +} + +.group-legend { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: nowrap; /* Prevent wrapping */ + overflow-x: auto; /* Add horizontal scrolling if content overflows */ + justify-content: flex-end; /* Align items to the right */ +} + +.legend-item { + display: flex; + align-items: center; + gap: 8px; +} + +.legend-box { + width: 16px; + height: 16px; + border-radius: 4px; /* Adjust for rounded corners */ + flex-shrink: 0; /* Prevent shrinking */ +} \ No newline at end of file diff --git a/libs/upd/components/src/lib/page-version/page-version.component.spec.ts b/libs/upd/components/src/lib/page-version/page-version.component.spec.ts new file mode 100644 index 00000000..cafb6301 --- /dev/null +++ b/libs/upd/components/src/lib/page-version/page-version.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PageVersionComponent } from './page-version.component'; + +describe('PageVersionComponent', () => { + let component: PageVersionComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PageVersionComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PageVersionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/upd/components/src/lib/page-version/page-version.component.ts b/libs/upd/components/src/lib/page-version/page-version.component.ts new file mode 100644 index 00000000..6483f27b --- /dev/null +++ b/libs/upd/components/src/lib/page-version/page-version.component.ts @@ -0,0 +1,895 @@ +import { + Component, + computed, + effect, + ElementRef, + inject, + input, + Renderer2, + signal, + Signal, + viewChild, + ViewEncapsulation, +} from '@angular/core'; +import { DropdownOption } from '../dropdown/dropdown.component'; +import dayjs from 'dayjs'; +import { + Diff2HtmlUIConfig, + Diff2HtmlUI, +} from 'diff2html/lib/ui/js/diff2html-ui'; +import { createPatch } from 'diff'; +import { valid } from 'node-html-parser'; +import { load } from 'cheerio/lib/slim'; +import { Diff } from '@ali-tas/htmldiff-js'; +import { RadioOption } from '../radio/radio.component'; +import { I18nFacade } from '@dua-upd/upd/state'; +import { FR_CA } from '@dua-upd/upd/i18n'; +interface DiffOptions { + repeatingWordsAccuracy?: number; + ignoreWhiteSpaceDifferences?: boolean; + orphanMatchThreshold?: number; + matchGranularity?: number; + combineWords?: boolean; +} + +interface MainConfig { + outputFormat: 'side-by-side' | 'line-by-line'; + viewMode: { value: string; label: string; description: string }; +} + +interface PageConfig { + before: HashSelection | null; + after: HashSelection | null; +} + +interface HashSelection { + hash: string; + date: Date; + blob: string; +} + +@Component({ + selector: 'upd-page-version', + templateUrl: './page-version.component.html', + styleUrls: ['./page-version.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class PageVersionComponent { + i18n= inject(I18nFacade); + hashes = input([]); + url = input(''); + shadowDOM = signal(null); + + sourceContainer = viewChild>('sourceContainer'); + liveContainer = viewChild>('liveContainer'); + beforeContainer = viewChild>('beforeContainer'); + afterContainer = viewChild>('afterContainer'); + + outputFormat = signal<'side-by-side' | 'line-by-line'>('side-by-side'); + viewMode = signal>({ + label: 'Live View', + value: 'live', + description: 'View the live page', + }); + + before = signal(null); + after = signal(null); + + currentLang = this.i18n.currentLang; + dateParams = computed(() => { + return this.currentLang() == FR_CA ? 'DD MMM YYYY' : 'MMM DD, YYYY'; + }); + + dropdownOptions: Signal[]> = computed(() => { + const current = this.hashes()[0]?.hash; + + return this.hashes().map(({ hash, date }) => ({ + label: `${dayjs(date).format(this.dateParams())}${hash === current ? ' (Current)' : ''}`, + value: hash, + })); + }); + sourceFormatOptions: DropdownOption[] = [ + { label: 'Side by side', value: 'side-by-side' }, + { label: 'Unified', value: 'line-by-line' }, + ]; + + liveFormatOptions: DropdownOption[] = [ + { label: 'Side by side', value: 'side-by-side' }, + { label: 'Unified', value: 'line-by-line' }, + ]; + + viewModeOptions: RadioOption[] = [ + { label: 'Live View', value: 'live', description: '' }, + { + label: 'Page Source', + value: 'source', + description: '', + }, + ]; + + versionConfig = computed(() => ({ + before: this.before(), + after: this.after(), + })); + + config = computed(() => ({ + outputFormat: this.outputFormat(), + viewMode: this.viewMode(), + })); + + elements = signal([]); + currentSlide = signal(0); + scrollElement = viewChild>('scrollElement'); + currentIndex = signal(0); + lastExpandedDetails = signal(null); + + legendItems = signal<{ text: string; colour: string; style: string; lineStyle?: string }[]>([ + { text: 'Previous version', colour: '#F3A59D', style: 'highlight' }, + { text: 'Updated version', colour: '#83d5a8', style: 'highlight' }, + { text: 'Invisible content', colour: '#6F9FFF', style: 'line' }, + { text: 'Modal content', colour: '#666', style: 'line', lineStyle: 'dashed' }, + ]); + + constructor(private renderer: Renderer2) { + effect(() => { + console.log(`Initial selection for 'left':`, this.getInitialSelection('left')()); + console.log(`Dropdown options:`, this.dropdownOptions()); + }); + + effect(() => { + const liveContainer = this.liveContainer()?.nativeElement; + if (!liveContainer) return; + + const shadowDOM = this.shadowDOM()?.innerHTML; + if (!shadowDOM) return; + + const diffViewer = liveContainer.querySelector( + 'diff-viewer', + ) as HTMLElement; + + if (!diffViewer || !diffViewer.shadowRoot) return; + + this.renderer.listen( + diffViewer.shadowRoot, + 'click', + this.handleDocumentClick.bind(this), + ); + }); + + // effect(() => { + // const liveContainer = this.liveContainer()?.nativeElement; + // if (!liveContainer) return; + + // const shadowDOM = this.shadowDOM()?.innerHTML; + // if (!shadowDOM) return; + + // const diffViewer = liveContainer.querySelector( + // 'diff-viewer', + // ) as HTMLElement; + + // if (!diffViewer || !diffViewer.shadowRoot) return; + + // this.renderer.listen(diffViewer.shadowRoot, 'click', (event: Event) => { + // const target = event.target as HTMLElement; + + // // Check if the clicked element is an anchor tag with an href starting with # + // if ( + // target.tagName === 'A' && + // target.getAttribute('href')?.startsWith('#') + // ) { + // event.preventDefault(); // Prevent default anchor behavior + + // const sectionId = target.getAttribute('href')?.substring(1); // Extract ID (removes the #) + // const targetSection = diffViewer.shadowRoot?.getElementById( + // sectionId ?? '', + // ); + + // if (targetSection) { + // // Scroll smoothly to the target section + // targetSection.scrollIntoView({ + // behavior: 'smooth', + // block: 'start', + // }); + // } + // } + // }); + // }); + + effect( + () => { + const storedConfig = this.getStoredConfig(); + if (storedConfig) { + this.restoreConfig(storedConfig); + } else { + this.useDefaultSelection(); + } + }, + { allowSignalWrites: true }, + ); + + effect( + () => { + const storedConfig = JSON.parse( + sessionStorage.getItem(`main-version-config`) || 'null', + ); + + if (storedConfig) { + this.restoreMainConfig(storedConfig); + } + }, + { allowSignalWrites: true }, + ); + + effect( + () => { + const container = this.sourceContainer(); + if (!container) return; + + this.createHtmlDiffContent(container); + + this.storeConfig(); + }, + { allowSignalWrites: true }, + ); + + effect( + async () => { + const container = this.liveContainer(); + if (!container) return; + + try { + const { liveDiffs, leftBlobContent, rightBlobContent } = + await this.createLiveDiffContent(); + this.renderLiveDifferences( + liveDiffs, + leftBlobContent, + rightBlobContent, + ); + this.storeConfig(); + } catch (error) { + console.error('Error in live diff effect:', error); + } + }, + { allowSignalWrites: true }, + ); + } + + private handleDocumentClick(event: MouseEvent): void { + const liveContainer = this.liveContainer()?.nativeElement; + if (!liveContainer) return; + + const diffViewer = liveContainer.querySelector('diff-viewer') as HTMLElement; + if (!diffViewer || !diffViewer.shadowRoot) return; + + let target = event.target as HTMLElement; + while (target && target.tagName !== 'A') { + target = target.parentElement as HTMLElement; + } + + if (target?.tagName === 'A' && target.getAttribute('href')?.startsWith('#')) { + event.preventDefault(); + const sectionId = target.getAttribute('href')?.substring(1); // Extract ID (removes the #) + const targetSection = diffViewer.shadowRoot?.getElementById(sectionId ?? ''); + + if (targetSection) { + targetSection.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } + } else { + const changeElements = Array.from( + diffViewer.shadowRoot.querySelectorAll('[data-id]') + ); + + if (!changeElements.length) return; + + const clickedElement = changeElements.find((el) => + el.contains(event.target as Node) + ); + + if (!clickedElement) return; + const index = Number(clickedElement.getAttribute('data-id')); + this.scrollToElement(index); + } + } + + // private findNearestElement( + // event: MouseEvent, + // elements: HTMLElement[], + // container: HTMLElement, + // ): HTMLElement | null { + // const { left: containerLeft, top: containerTop } = + // container.getBoundingClientRect(); + // const clickX = event.clientX - containerLeft; + // const clickY = event.clientY - containerTop; + + // return elements.reduce( + // (nearest, element) => { + // const rect = element.getBoundingClientRect(); + // const distance = Math.hypot( + // rect.left + rect.width / 2 - containerLeft - clickX, + // rect.top + rect.height / 2 - containerTop - clickY, + // ); + + // return distance < nearest.distance ? { element, distance } : nearest; + // }, + // { element: null as HTMLElement | null, distance: Infinity }, + // ).element; + // } + + // private initializeScrollables(): void { + // const beforeElement = this.beforeContainer()?.nativeElement; + // const afterElement = this.afterContainer()?.nativeElement; + + // if (!beforeElement || !afterElement) return; + + // // // Balance content heights + // // this.balanceContentHeights(); + + // // Add scroll event listeners for synchronization + // beforeElement.addEventListener('scroll', () => this.handleScroll('before')); + // afterElement.addEventListener('scroll', () => this.handleScroll('after')); + // } + + // private handleScroll(source: 'before' | 'after'): void { + // const beforeElement = this.beforeContainer()?.nativeElement; + // const afterElement = this.afterContainer()?.nativeElement; + + // const sourceElement = source === 'before' ? beforeElement : afterElement; + // const targetElement = source === 'before' ? afterElement : beforeElement; + + // if (!sourceElement || !targetElement) return; + + // console.log({ + // sourceScrollTop: sourceElement.scrollTop, + // sourceScrollHeight: sourceElement.scrollHeight, + // sourceClientHeight: sourceElement.clientHeight, + // targetScrollTop: targetElement.scrollTop, + // targetScrollHeight: targetElement.scrollHeight, + // targetClientHeight: targetElement.clientHeight, + // }); + + // // Calculate the proportion of scrolling in the source element + // const sourceScrollHeight = + // sourceElement.scrollHeight - sourceElement.clientHeight; + // const targetScrollHeight = + // targetElement.scrollHeight - targetElement.clientHeight; + + // if (sourceScrollHeight <= 0 || targetScrollHeight <= 0) { + // return; // Avoid division by zero + // } + + // // Calculate the visible scroll ratio in the source element + // const sourceScrollRatio = sourceElement.scrollTop / sourceScrollHeight; + + // // Calculate the target scroll offset based on the ratio + // const targetScrollTop = sourceScrollRatio * targetScrollHeight; + + // // Directly set the scrollTop for the target element + // targetElement.scrollTop = targetScrollTop; + + // // Debugging: Log the synchronization details + // console.log({ + // sourceScrollTop: sourceElement.scrollTop, + // sourceScrollHeight, + // targetScrollTop, + // targetScrollHeight, + // }); + // } + + // private balanceContentHeights(): void { + // const beforeElement = this.beforeContainer()?.nativeElement; + // const afterElement = this.afterContainer()?.nativeElement; + + // if (!beforeElement || !afterElement) return; + + // const beforeHeight = beforeElement.scrollHeight; + // const afterHeight = afterElement.scrollHeight; + + // if (beforeHeight > afterHeight) { + // this.addPadding(afterElement, beforeHeight - afterHeight); + // } else if (afterHeight > beforeHeight) { + // this.addPadding(beforeElement, afterHeight - beforeHeight); + // } + // } + + // private addPadding(element: HTMLElement, padding: number): void { + // const paddingDiv = document.createElement('div'); + // paddingDiv.style.height = `${padding}px`; + // element.appendChild(paddingDiv); + // } + + private getStoredConfig(): PageConfig | null { + const currentUrl = this.url(); + return currentUrl + ? JSON.parse( + sessionStorage.getItem(`${currentUrl}-version-config`) || 'null', + ) + : null; + } + + private storeConfig(): void { + const currentUrl = this.url(); + sessionStorage.setItem( + `${currentUrl}-version-config`, + JSON.stringify(this.versionConfig()), + ); + } + + private createHtmlDiffContent(container: ElementRef) { + const leftBlob = this.before()?.blob || ''; + const rightBlob = this.after()?.blob || ''; + + const patch = createPatch('', leftBlob, rightBlob, '', ''); + const diffOptions: Diff2HtmlUIConfig = { + outputFormat: this.outputFormat(), + drawFileList: false, + fileContentToggle: false, + matching: 'words', + }; + + const diff2 = new Diff2HtmlUI(container.nativeElement, patch, diffOptions); + diff2.draw(); + } + + private async renderLiveDifferences( + differences: string, + left: string, + right: string, + ): Promise { + const liveContainer = this.liveContainer(); + if (!liveContainer) return; + + let element = liveContainer.nativeElement.querySelector('diff-viewer'); + if (!element) { + element = document.createElement('diff-viewer'); + liveContainer.nativeElement.appendChild(element); + } + + const shadowDOM = + element.shadowRoot || element.attachShadow({ mode: 'open' }); + + // const fontAwesomeCss = this.httpClient.getCache( + // 'https://use.fontawesome.com/releases/v5.15.4/css/all.css', + // ); + // const wetBoewCss = this.httpCache.getCached( + // 'https://www.canada.ca/etc/designs/canada/wet-boew/css/theme.min.css', + // ); + + const parser = new DOMParser(); + const sanitizedUnifiedContent = parser.parseFromString( + differences, + 'text/html', + ).body.innerHTML; + + // ${fontAwesomeCss() || ''} + // ${wetBoewCss() || ''} + shadowDOM.innerHTML = ` + + + + +
+
${sanitizedUnifiedContent}
+
+ `; + + await this.adjustDOM(shadowDOM); + } + + private async adjustDOM(shadowDOM: ShadowRoot) { + const $ = load(shadowDOM.innerHTML); + + const seen = new Set(); + + $('del>del, ins>ins').each((index, element) => { + const $element = $(element); + const parent = $element.parent(); + if (parent.text().trim() === $element.text().trim()) { + parent.replaceWith($element); + } + }); + + $('del>ins, ins>del').each((index, element) => { + const $element = $(element); + const parentText = $element.parent(); + const childText = $element.text(); + if (parentText.text().trim() === childText.trim()) { + parentText.replaceWith($element); + } + }); + + shadowDOM.innerHTML = $.html(); + + const uniqueElements = $('ins, del') + .toArray() + .map((element) => { + const $element = $(element); + const parent = $element.parent(); + + const outerHTML = parent?.html()?.replace(/\n/g, '').trim() || ''; + const contentOnly = parent?.text().trim() || ''; + const normalizedContent = $element + .text() + .trim() + .replace(/\s| /g, ''); + + return { element: $element, normalizedContent, outerHTML, contentOnly }; + }); + // .filter(({ normalizedContent, contentOnly }) => { + // if (!normalizedContent || !contentOnly || seen.has(contentOnly)) { + // return false; + // } + // seen.add(contentOnly); + // return true; + // }); + + uniqueElements.forEach(({ element }, index) => { + element.attr('data-id', `${index + 1}`); // Start from 1 instead of 0 + }); + + shadowDOM.innerHTML = $.html(); + + this.elements.set(uniqueElements.map(({ outerHTML }) => outerHTML)); + this.currentIndex.set(0); + this.shadowDOM.set(shadowDOM); + } + + private async extractContent(html: string): Promise { + const $ = load(html); + const baseUrl = 'https://www.canada.ca'; + + /** + * Fetches content from a URL and returns it as text. + */ + const fetchUrl = async (url: string): Promise => { + try { + const response = await fetch(url); + return await response.text(); + } catch (error) { + console.error(`Error fetching URL: ${url}`, error); + return ''; + } + }; + + const processAjaxReplacements = async () => { + const processElements = async () => { + const ajaxElements = $( + '[data-ajax-replace^="/"], [data-ajax-after^="/"], [data-ajax-append^="/"], [data-ajax-before^="/"], [data-ajax-prepend^="/"]', + ).toArray(); + if (!ajaxElements.length) return; + + for (const element of ajaxElements) { + const $el = $(element); + const attributes = $el.attr(); + + for (const [attr, ajaxUrl] of Object.entries(attributes || {})) { + if (!attr.startsWith('data-ajax-') || !ajaxUrl.startsWith('/')) + continue; + + const [url, anchor] = ajaxUrl.split('#'); + const fullUrl = `${baseUrl}${url}`; + const $ajaxContent = load(await fetchUrl(fullUrl)); + + const content = $ajaxContent(`#${anchor}`) + .map((_, el) => $(el).html()) + .get() + .join(''); + + const styledContent = ` + <${attr} URL> +
${anchor ? content : $ajaxContent.html() || ''}
+ `; + + $el.replaceWith(styledContent); + } + } + }; + + let previousCount; + let currentCount = 0; + + do { + previousCount = currentCount; + await processElements(); + currentCount = $( + '[data-ajax-replace^="/"], [data-ajax-after^="/"], [data-ajax-append^="/"], [data-ajax-before^="/"], [data-ajax-prepend^="/"]', + ).length; + } while (currentCount && currentCount !== previousCount); + }; + + const processModalDialogs = () => { + $('.modal-dialog.modal-content').each((index, element) => { + const $el = $(element); + const currentContent = $el.html(); + const id = $el.attr('id'); + + const styledContent = ` +
${currentContent || ''}
+ `; + + $el.html(styledContent).removeClass('mfp-hide'); + }); + }; + + /** + * Updates relative URLs for `` and `` elements to be absolute and opens links in a new tab. + */ + const updateRelativeURLs = () => { + $('a, img').each((index, element) => { + const $el = $(element); + const href = $el.attr('href'); + const src = $el.attr('src'); + + if (href) { + if (href.startsWith('/')) { + $el.attr('href', `${baseUrl}${href}`).attr('target', '_blank'); + } else if (/^(http|https):\/\//.test(href)) { + $el.attr('target', '_blank'); + } + } + + if (src && src.startsWith('/')) { + $el.attr('src', `${baseUrl}${src}`); + } + }); + }; + + // const updateFootnotes = () => { + // $('a[href^="#"]').each((index, element) => { + // const $el = $(element); + // const href = $el.attr('href'); + + // if (href) { + // $el.attr('href', `${href}`); + // } + // }); + // }; + + /** + * Removes unnecessary elements like the chat bottom bar. + */ + const cleanupUnnecessaryElements = () => { + $('section#chat-bottom-bar').remove(); + }; + + const displayInvisibleElements = () => { + // .hidden and .nojs-show also? + $('.wb-inv').each((index, element) => { + const $el = $(element); + $el.css({ + border: '2px solid #6F9FFF', + }); + $el.removeClass('wb-inv'); + }); + }; + + const addToc = () => { + const $tocSection = $('.section.mwsinpagetoc'); + if (!$tocSection.length) + return console.log('No `.section.mwsinpagetoc` element found'); + + const tocLinks = $tocSection + .find('a') + .map((_, link) => { + const $link = $(link); + const href = $link.attr('href'); + return href?.startsWith('#') + ? { id: href.slice(1), text: $link.text().trim() } + : null; + }) + .get(); + + if (!tocLinks.length) return console.log('No links found in the TOC'); + + $('h2, h3, h4, h5, h6').each((_, heading) => { + const $heading = $(heading); + const matchedLink = tocLinks.find( + (link) => link.text === $heading.text().trim(), + ); + if (matchedLink) $heading.attr('id', matchedLink.id); + }); + }; + + // Execute the processing steps + await processAjaxReplacements(); + processModalDialogs(); + updateRelativeURLs(); + cleanupUnnecessaryElements(); + displayInvisibleElements(); + addToc(); + + return $('main').html() || ''; + } + + private async createLiveDiffContent(): Promise<{ + liveDiffs: string; + leftBlobContent: string; + rightBlobContent: string; + }> { + const leftBlob = this.before()?.blob || ''; + const rightBlob = this.after()?.blob || ''; + + const leftBlobContent = await this.extractContent(leftBlob); + const rightBlobContent = await this.extractContent(rightBlob); + + const isValid = valid(leftBlobContent) && valid(rightBlobContent); + + const options: DiffOptions = { + repeatingWordsAccuracy: 0, + ignoreWhiteSpaceDifferences: false, + orphanMatchThreshold: 0, + matchGranularity: 4, + combineWords: true, + }; + + let liveDiffs = isValid + ? Diff.execute(leftBlobContent, rightBlobContent, options) + : ''; + + liveDiffs = liveDiffs.replace( + /<(ins|del)[^>]*>(\s| | | |â|€|¯| | )+<\/(ins|del)>/gis, + ' ', + ); + + return { liveDiffs, leftBlobContent, rightBlobContent }; + } + + next() { + const elementsArray = this.elements(); + const currentIndex = this.currentIndex(); + const newIndex = + currentIndex === elementsArray.length ? 1 : currentIndex + 1; + this.scrollToElement(newIndex); + } + + prev() { + const elementsArray = this.elements(); + const currentIndex = this.currentIndex(); + const newIndex = + currentIndex === 1 || currentIndex === 0 + ? elementsArray.length + : currentIndex - 1; + this.scrollToElement(newIndex); + } + private scrollToElement(index: number): void { + const shadowDOM = this.shadowDOM(); + if (!shadowDOM) return; + + const $ = load(shadowDOM.innerHTML); + + const targetElement = $(`[data-id="${index}"]`); + if (!targetElement.length) return; + + $('.highlight').removeClass('highlight'); + + $('details[open]').each((index, element) => { + const $element = $(element); + if (!$element.is(targetElement.closest('details'))) { + $element.removeAttr('open'); + } + }); + + let parentDetails = targetElement.closest('details'); + while (parentDetails.length) { + if (!parentDetails.attr('open')) { + parentDetails.attr('open', ''); + } + parentDetails = parentDetails.parent().closest('details'); + } + + targetElement.addClass('highlight'); + + shadowDOM.innerHTML = $.html(); + + const domTargetElement = shadowDOM.querySelector(`[data-id="${index}"]`); + if (domTargetElement) { + domTargetElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + this.currentIndex.set(index); + } + + updateSelection(hash: string, side: 'left' | 'right'): void { + const version = this.hashes().find((h) => h.hash === hash) || null; + if (!version) return; + + side === 'left' ? this.before.set(version) : this.after.set(version); + } + + private restoreConfig(config: PageConfig): void { + if (config.before) this.before.set(config.before); + if (config.after) this.after.set(config.after); + } + + private restoreMainConfig(config: MainConfig): void { + if (config.outputFormat) this.outputFormat.set(config.outputFormat); + if (config.viewMode) this.viewMode.set(config.viewMode); + } + + private useDefaultSelection(): void { + const [first, second] = this.hashes(); + if (first && second) { + this.before.set(second); + this.after.set(first); + } + } + + changeOutputFormat(format: string) { + this.outputFormat.set(format as 'side-by-side' | 'line-by-line'); + sessionStorage.setItem( + `main-version-config`, + JSON.stringify(this.config()), + ); + } + + changeViewMode(mode: { value: string; label: string; description: string }) { + this.viewMode.set(mode); + sessionStorage.setItem( + `main-version-config`, + JSON.stringify(this.config()), + ); + } + + getInitialSelection = (side: 'left' | 'right') => + computed(() => { + const currentHash = side === 'left' ? this.before()?.hash : this.after()?.hash; + const availableOptions = this.dropdownOptions(); // Explicit dependency on dropdownOptions + + // Ensure we track the available options and current hash explicitly + const isCurrentHashAvailable = availableOptions.some(option => option.value === currentHash); + if (isCurrentHashAvailable) { + return currentHash; + } + + return availableOptions.length > 0 ? availableOptions[0].value : ''; + }); + + getInitialSelectionView = () => + computed(() => { + const currentView = this.viewMode()?.value; + const viewModeOptions = this.viewModeOptions; + + if (viewModeOptions.some((option) => option.value === currentView)) { + return currentView; + } + + return viewModeOptions.length > 0 ? viewModeOptions[0].value : ''; + }); +} diff --git a/libs/upd/components/src/lib/radio/radio.component.html b/libs/upd/components/src/lib/radio/radio.component.html index f8c4b1d1..61a4574b 100644 --- a/libs/upd/components/src/lib/radio/radio.component.html +++ b/libs/upd/components/src/lib/radio/radio.component.html @@ -1,4 +1,5 @@ -
+
+
- + @if (displayTooltip) { + + } @else { + + }
+
\ No newline at end of file diff --git a/libs/upd/components/src/lib/radio/radio.component.ts b/libs/upd/components/src/lib/radio/radio.component.ts index 26cf7266..8a18c259 100644 --- a/libs/upd/components/src/lib/radio/radio.component.ts +++ b/libs/upd/components/src/lib/radio/radio.component.ts @@ -1,6 +1,19 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { + Component, + EventEmitter, + input, + Input, + OnInit, + Output, +} from '@angular/core'; import { Required } from '@dua-upd/utils-common'; +export interface RadioOption { + value: string; + label: string; + description: string; +} + @Component({ selector: 'upd-radio', templateUrl: './radio.component.html', @@ -8,17 +21,41 @@ import { Required } from '@dua-upd/utils-common'; }) export class RadioComponent< T extends { value: string; label: string; description: string }, -> { +> implements OnInit +{ @Input() items: T[] = []; @Input() selectAllText = ''; @Input() @Required id!: string; @Input() disabled = false; + @Input() displayTooltip = true; + @Input() autoDisplayFirst = false; - @Input() selectedItem?: T; + @Input() selectedItem?: RadioOption; @Output() selectedItemsChange = new EventEmitter(); + @Input() set initialSelection( + option: RadioOption | RadioOption['value'] | undefined, + ) { + if (!option) { + return; + } + + if (typeof option === 'object' && 'label' in option && 'value' in option) { + this.selectedItem = option; + return; + } + + this.selectedItem = + this.items.find((o) => o.value === option); + } onSelectionChange(item: T) { this.selectedItem = item; this.selectedItemsChange.emit(item); } + + ngOnInit() { + if (this.autoDisplayFirst && this.items.length > 0) { + this.selectedItem = this.items[0]; + } + } } diff --git a/libs/upd/components/src/lib/upd-components.module.ts b/libs/upd/components/src/lib/upd-components.module.ts index 9193afe1..2e76523b 100644 --- a/libs/upd/components/src/lib/upd-components.module.ts +++ b/libs/upd/components/src/lib/upd-components.module.ts @@ -64,6 +64,7 @@ import { RangeSliderComponent } from './range-slider/range-slider.component'; import { HeatmapComponent } from './heatmap/heatmap.component'; import { PageFlowComponent } from './page-flow/page-flow.component'; import { ArrowConnectComponent } from './arrow-connect/arrow-connect.component'; +import { PageVersionComponent } from './page-version/page-version.component'; @NgModule({ imports: [ @@ -96,7 +97,7 @@ import { ArrowConnectComponent } from './arrow-connect/arrow-connect.component'; NgApexchartsModule, ProgressBarModule, RangeSliderComponent, - ], +], declarations: [ CardComponent, DataTableComponent, @@ -132,6 +133,7 @@ import { ArrowConnectComponent } from './arrow-connect/arrow-connect.component'; HeatmapComponent, PageFlowComponent, ArrowConnectComponent, + PageVersionComponent, ], exports: [ NgbPopoverModule, @@ -180,6 +182,7 @@ import { ArrowConnectComponent } from './arrow-connect/arrow-connect.component'; HeatmapComponent, PageFlowComponent, ArrowConnectComponent, + PageVersionComponent, ], }) export class UpdComponentsModule {} diff --git a/libs/upd/i18n/src/lib/translations/en-CA.json b/libs/upd/i18n/src/lib/translations/en-CA.json index 87017116..0da407d3 100644 --- a/libs/upd/i18n/src/lib/translations/en-CA.json +++ b/libs/upd/i18n/src/lib/translations/en-CA.json @@ -22,6 +22,7 @@ "tab-details": "Details", "tab-flow": "Flow", "tab-readability": "Readability", + "tab-version-history": "Version history", "tab-gctasks": "GC Task Success Survey", "dr-lastweek": "Last week", "dr-lastmonth": "Last month", @@ -1149,6 +1150,7 @@ "Pages | Page feedback": "UPD | Pages | Page feedback", "Pages | Flow": "UPD | Pages | Flow", "Pages | Readability": "UPD | Pages | Readability", + "Pages | Version history": "UPD | Pages | Version history", "Projects | Home": "UPD | UX projects | Home", "Projects | Summary": "UPD | UX projects | Summary", "Projects | Web traffic": "UPD | UX projects | Web traffic", @@ -1404,5 +1406,7 @@ "flow-tooltip-flow-prev": "In the selected flow to the focus fage, {{ visits }} visits included {{ page }} page at some point before reaching the focus page, by following the selected pages in the flow.", "flow-tooltip-flow-prev-beforefocus": "In the selected flow to the focus page, {{ visits }} visits included {{ page }} page before reaching the focus page.", "flow-tooltip-flow-next": "In the selected flow from the focus page, {{ visits }} visits included {{ page }} page at some point after leaving the focus page, by following the selected pages in the flow.", - "flow-tooltip-flow-next-afterfocus": "In the selected flow from the focus page, {{ visits }} visits included {{ page }} page after leaving the focus page." + "flow-tooltip-flow-next-afterfocus": "In the selected flow from the focus page, {{ visits }} visits included {{ page }} page after leaving the focus page.", + "-----VERSION HISTORY TAB---": "-----------------------------------------------------", + "version-history": "Version history" } \ No newline at end of file diff --git a/libs/upd/i18n/src/lib/translations/fr-CA.json b/libs/upd/i18n/src/lib/translations/fr-CA.json index b4285afb..dbbb9a92 100644 --- a/libs/upd/i18n/src/lib/translations/fr-CA.json +++ b/libs/upd/i18n/src/lib/translations/fr-CA.json @@ -22,6 +22,7 @@ "tab-details": "Détails", "tab-flow": "Flux", "tab-readability": "Lisibilité", + "tab-version-history": "Historique des versions", "tab-gctasks": "Sondage sur la réussite des tâches du GC", "dr-lastweek": "La semaine dernière", "dr-lastmonth": "Le mois dernier", @@ -1453,6 +1454,7 @@ "Pages | Page feedback": "TBPC | Pages | Rétroaction sur la page", "Pages | Flow": "TBPC | Pages | Flux", "Pages | Readability": "TBPC | Pages | Lisibilité", + "Pages | Version history": "TBPC | Pages | Historique des versions", "Projects | Home": "TBPC | Projets UX | Accueil", "Projects | Summary": "TBPC | Projets UX | Résumé", "Projects | Web traffic": "TBPC | Projets UX | Trafic Web", @@ -1714,5 +1716,7 @@ "flow-tooltip-flow-prev": "Dans le flux sélectionné vers la page focus, {{ visits }} visites ont inclus la page {{ page }} à un moment donné avant d'atteindre la page focus, en suivant les pages sélectionnées dans le flux.", "flow-tooltip-flow-prev-beforefocus": "Dans le flux sélectionné vers la page focus, {{ visits }} visites ont inclus la page {{ page }} avant d'atteindre la page focus.", "flow-tooltip-flow-next": "Dans le flux sélectionné depuis la page focus, {{ visits }} visites ont inclus la page {{ page }} à un moment donné après avoir quitté la page focus, en suivant les pages sélectionnées dans le flux.", - "flow-tooltip-flow-next-afterfocus": "Dans le flux sélectionné depuis la page focus, {{ visits }} visites ont inclus la page {{ page }} après avoir quitté la page focus." + "flow-tooltip-flow-next-afterfocus": "Dans le flux sélectionné depuis la page focus, {{ visits }} visites ont inclus la page {{ page }} après avoir quitté la page focus.", + "-----VERSION HISTORY TAB---": "-----------------------------------------------------", + "version-history": "Historique des versions" } diff --git a/libs/upd/views/pages/src/lib/pages-details/+state/pages-details.facade.ts b/libs/upd/views/pages/src/lib/pages-details/+state/pages-details.facade.ts index 4744df64..eb0a19e4 100644 --- a/libs/upd/views/pages/src/lib/pages-details/+state/pages-details.facade.ts +++ b/libs/upd/views/pages/src/lib/pages-details/+state/pages-details.facade.ts @@ -6,7 +6,7 @@ import utc from 'dayjs/plugin/utc'; import 'dayjs/locale/en-ca'; import 'dayjs/locale/fr-ca'; import { FR_CA, type LocaleId } from '@dua-upd/upd/i18n'; -import { I18nFacade, selectDatePeriodSelection } from '@dua-upd/upd/state'; +import { I18nFacade, selectDatePeriodSelection, selectUrl } from '@dua-upd/upd/state'; import { percentChange, UnwrapObservable } from '@dua-upd/utils-common'; import type { PickByType } from '@dua-upd/utils-common'; import type { @@ -48,6 +48,10 @@ export class PagesDetailsFacade { currentLang$ = this.i18n.currentLang$; + currentRoute$ = this.store + .select(selectUrl) + .pipe(map((url) => url.replace(/\?.+$/, ''))); + dateRangeSelected$ = this.store.select(selectDatePeriodSelection); rawDateRange$ = combineLatest([this.pagesDetailsData$]).pipe( @@ -121,6 +125,10 @@ export class PagesDetailsFacade { pageTitle$ = this.pagesDetailsData$.pipe(map((data) => data?.title)); pageUrl$ = this.pagesDetailsData$.pipe(map((data) => data?.url)); + hashes$ = this.pagesDetailsData$.pipe(map((data) => data?.hashes)); + + altPageId$ = this.pagesDetailsData$.pipe(map((data) => data?.alternatePageId || 0)); + pageStatus$ = this.pagesDetailsData$.pipe( map((data) => { if (data?.isRedirect) { diff --git a/libs/upd/views/pages/src/lib/pages-details/+state/pages-details.reducer.ts b/libs/upd/views/pages/src/lib/pages-details/+state/pages-details.reducer.ts index b1dedf3a..5a670c67 100644 --- a/libs/upd/views/pages/src/lib/pages-details/+state/pages-details.reducer.ts +++ b/libs/upd/views/pages/src/lib/pages-details/+state/pages-details.reducer.ts @@ -34,6 +34,8 @@ export const pagesDetailsInitialState: PagesDetailsState = { }, numComments: 0, numCommentsPercentChange: null, + hashes: [], + alternatePageId: '', }, loading: false, loaded: false, diff --git a/libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.css b/libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.css new file mode 100644 index 00000000..e69de29b diff --git a/libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.html b/libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.html new file mode 100644 index 00000000..7ceee2f6 --- /dev/null +++ b/libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.html @@ -0,0 +1,9 @@ +
+
+
+ + + +
+
+
diff --git a/libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.spec.ts b/libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.spec.ts new file mode 100644 index 00000000..ef0e0309 --- /dev/null +++ b/libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PagesDetailsWebtrafficComponent } from './pages-details-webtraffic.component'; + +describe('PageDetailsWebtrafficComponent', () => { + let component: PagesDetailsWebtrafficComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [PagesDetailsWebtrafficComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PagesDetailsWebtrafficComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.ts b/libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.ts new file mode 100644 index 00000000..013e66a7 --- /dev/null +++ b/libs/upd/views/pages/src/lib/pages-details/pages-details-versions/pages-details-versions.component.ts @@ -0,0 +1,30 @@ +import { Component, computed, inject, signal, Signal } from '@angular/core'; +import { I18nFacade } from '@dua-upd/upd/state'; +import type { GetTableProps } from '@dua-upd/utils-common'; +import { PagesDetailsFacade } from '../+state/pages-details.facade'; +import { toSignal } from '@angular/core/rxjs-interop'; +import dayjs from 'dayjs'; +import { DropdownOption } from '@dua-upd/upd-components'; + +interface UrlHash { + hash: string; + date: Date; + blob: string; +} + +@Component({ + selector: 'upd-page-details-versions', + templateUrl: './pages-details-versions.component.html', + styleUrls: ['./pages-details-versions.component.css'], +}) +export class PagesDetailsVersionsComponent { + private i18n = inject(I18nFacade); + private pageDetailsService = inject(PagesDetailsFacade); + + currentLang = this.i18n.currentLang(); + + data$ = this.pageDetailsService.pagesDetailsData$; + error$ = this.pageDetailsService.error$; + hashes = toSignal(this.pageDetailsService.hashes$) as () => UrlHash[]; + url = toSignal(this.pageDetailsService.pageUrl$) as () => string; +} diff --git a/libs/upd/views/pages/src/lib/pages-details/pages-details.component.css b/libs/upd/views/pages/src/lib/pages-details/pages-details.component.css index 0f000c91..b22ab1bd 100644 --- a/libs/upd/views/pages/src/lib/pages-details/pages-details.component.css +++ b/libs/upd/views/pages/src/lib/pages-details/pages-details.component.css @@ -1,3 +1,7 @@ #view_url:hover, #copy_url:hover { color: #2e5ea7; -} \ No newline at end of file +} +.clickable-link { + cursor: pointer; + text-decoration: underline; + } \ No newline at end of file diff --git a/libs/upd/views/pages/src/lib/pages-details/pages-details.component.html b/libs/upd/views/pages/src/lib/pages-details/pages-details.component.html index b7d00092..9272e203 100644 --- a/libs/upd/views/pages/src/lib/pages-details/pages-details.component.html +++ b/libs/upd/views/pages/src/lib/pages-details/pages-details.component.html @@ -32,6 +32,7 @@

{{ url$ | async }}

+
View equivalent alternate language page