From c6a96fe58f314fed7a6befe1a0641c116cc3046d Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sat, 20 Dec 2025 20:08:49 -0500 Subject: [PATCH 01/10] in progress --- znai-reactjs/src/App.jsx | 6 +- .../src/screens/doc-stats/DocStatsScreen.tsx | 18 ++ .../src/screens/doc-stats/DocStatsView.css | 176 ++++++++++++++++ .../screens/doc-stats/DocStatsView.demo.tsx | 189 ++++++++++++++++++ .../src/screens/doc-stats/DocStatsView.tsx | 176 ++++++++++++++++ 5 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx create mode 100644 znai-reactjs/src/screens/doc-stats/DocStatsView.css create mode 100644 znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx create mode 100644 znai-reactjs/src/screens/doc-stats/DocStatsView.tsx diff --git a/znai-reactjs/src/App.jsx b/znai-reactjs/src/App.jsx index 11abcd563..0bbe24cc2 100644 --- a/znai-reactjs/src/App.jsx +++ b/znai-reactjs/src/App.jsx @@ -19,7 +19,7 @@ import "./App.css"; import "./layout/DocumentationLayout.css"; import "./doc-elements/search/Search.css"; -import React, { Component, useEffect } from "react"; +import React, { useEffect } from "react"; import { ComponentViewer, DropDowns, Registries } from "react-component-viewer"; import { tabsDemo } from "./doc-elements/tabs/Tabs.demo"; @@ -106,6 +106,7 @@ import { jsonPresentationDemo } from "./doc-elements/json/PresentationJson.demo" import { footnoteDemo } from "./doc-elements/footnote/Footnote.demo"; import { asciinemaDemo } from "./doc-elements/asciinema/Asciinema.demo"; import { previewConsoleOutputDemo } from "./screens/preview-change-path/PreviewConsoleOutput.demo"; +import { docStatsViewDemo } from "./screens/doc-stats/DocStatsView.demo"; import { readMoreDemo } from "./doc-elements/read-more/ReadMore.demo.js"; import { snippetsResultOutputDemo } from "./doc-elements/code-snippets/SnippetResultOutput.demo.jsx"; import { createLocalSearchIndex } from "./doc-elements/search/flexSearch.ts"; @@ -241,7 +242,8 @@ registries .registerAsTabs("Landing", landingDemo) .registerAsTabs("Not Authorized", notAuthorizedDemo) .registerAsTabs("Search Popup", searchPopupDemo) - .registerAsTabs("Preview Console Output", previewConsoleOutputDemo); + .registerAsTabs("Preview Console Output", previewConsoleOutputDemo) + .registerAsTabs("Doc Stats", docStatsViewDemo); window.znaiSearchIdx = createLocalSearchIndex(); populateLocalSearchIndexWithData(window.znaiSearchIdx, window.znaiSearchData); diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx new file mode 100644 index 000000000..875c46ab9 --- /dev/null +++ b/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx @@ -0,0 +1,18 @@ +/* + * Copyright 2025 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { DocStatsView } from "./DocStatsView"; +export type { DocStatsViewProps, PageStats } from "./DocStatsView"; diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsView.css b/znai-reactjs/src/screens/doc-stats/DocStatsView.css new file mode 100644 index 000000000..ca2c96cf8 --- /dev/null +++ b/znai-reactjs/src/screens/doc-stats/DocStatsView.css @@ -0,0 +1,176 @@ +/* + * Copyright 2025 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.znai-doc-stats-view { + font-family: var(--znai-regular-font-family); + max-width: var(--znai-single-column-render-width); + margin: 0 auto; + padding: 24px; + color: var(--znai-regular-text-color); + background: var(--znai-background-color); +} + +.znai-doc-stats-header { + margin-bottom: 32px; + padding-bottom: 24px; + border-bottom: 1px solid var(--znai-toc-panel-border-color); +} + +.znai-doc-stats-title { + margin: 0 0 20px 0; + font-size: 24px; + font-weight: 600; + color: var(--znai-section-title-color); +} + +.znai-doc-stats-overall { + display: flex; + gap: 32px; + flex-wrap: wrap; +} + +.znai-doc-stats-overall-stat { + display: flex; + flex-direction: column; + padding: 16px 24px; + background: var(--znai-toc-panel-background-color); + border-radius: 8px; + min-width: 140px; +} + +.znai-doc-stats-overall-value { + font-size: 28px; + font-weight: 600; + color: var(--znai-brand-primary-color); + line-height: 1.2; +} + +.znai-doc-stats-overall-label { + font-size: var(--znai-small-meta-text-size); + color: var(--znai-meta-color); + margin-top: 4px; +} + +.znai-doc-stats-chapters { + display: flex; + flex-direction: column; + gap: 24px; +} + +.znai-doc-stats-chapter { + border: 1px solid var(--znai-toc-panel-border-color); + border-radius: 8px; + overflow: hidden; +} + +.znai-doc-stats-chapter-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--znai-toc-panel-background-color); + border-bottom: 1px solid var(--znai-toc-panel-border-color); +} + +.znai-doc-stats-chapter-title { + font-size: var(--znai-smaller-text-size); + font-weight: 600; + color: var(--znai-toc-chapter-title-color); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.znai-doc-stats-chapter-summary { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--znai-small-meta-text-size); + color: var(--znai-meta-color); +} + +.znai-doc-stats-summary-separator { + color: var(--znai-toc-panel-border-color); +} + +.znai-doc-stats-pages { + display: flex; + flex-direction: column; +} + +.znai-doc-stats-page-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid var(--znai-toc-panel-border-color); + transition: background-color 0.15s ease; +} + +.znai-doc-stats-page-item:last-child { + border-bottom: none; +} + +.znai-doc-stats-page-item:hover { + background: var(--znai-toc-panel-background-color); +} + +.znai-doc-stats-page-link { + color: var(--znai-link-color); + text-decoration: none; + font-size: var(--znai-smaller-text-size); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.znai-doc-stats-page-link:hover { + text-decoration: var(--znai-link-decoration); +} + +.znai-doc-stats-counters { + display: flex; + gap: 16px; + flex-shrink: 0; + margin-left: 16px; +} + +.znai-doc-stats-counter { + display: flex; + align-items: baseline; + gap: 4px; +} + +.znai-doc-stats-counter-value { + font-size: var(--znai-smaller-text-size); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.znai-doc-stats-total .znai-doc-stats-counter-value { + color: var(--znai-brand-primary-color); +} + +.znai-doc-stats-unique .znai-doc-stats-counter-value { + color: var(--znai-color-green); +} + +.znai-doc-stats-counter-label { + font-size: var(--znai-small-text-size); + color: var(--znai-meta-color); + text-transform: uppercase; + letter-spacing: 0.02em; +} diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx new file mode 100644 index 000000000..e8522fbb6 --- /dev/null +++ b/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx @@ -0,0 +1,189 @@ +/* + * Copyright 2025 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from "react"; +import { Registry } from "react-component-viewer"; +import { DocStatsView, PageStats } from "./DocStatsView"; +import { TocItem } from "../../structure/TocItem"; + +const sampleToc: TocItem[] = [ + { + chapterTitle: "", + dirName: "", + fileName: "", + items: [ + { + chapterTitle: "", + pageTitle: "Index", + dirName: "", + fileName: "index", + }, + { + chapterTitle: "", + pageTitle: "Getting Started", + dirName: "", + fileName: "getting-started", + }, + ], + }, + { + chapterTitle: "Introduction", + dirName: "introduction", + fileName: "", + items: [ + { + chapterTitle: "Introduction", + pageTitle: "What Is This", + dirName: "introduction", + fileName: "what-is-this", + }, + { + chapterTitle: "Introduction", + pageTitle: "Installation Guide", + dirName: "introduction", + fileName: "installation", + }, + { + chapterTitle: "Introduction", + pageTitle: "Quick Start Tutorial", + dirName: "introduction", + fileName: "quick-start", + }, + ], + }, + { + chapterTitle: "Core Concepts", + dirName: "core-concepts", + fileName: "", + items: [ + { + chapterTitle: "Core Concepts", + pageTitle: "Architecture Overview", + dirName: "core-concepts", + fileName: "architecture", + }, + { + chapterTitle: "Core Concepts", + pageTitle: "Configuration", + dirName: "core-concepts", + fileName: "configuration", + }, + { + chapterTitle: "Core Concepts", + pageTitle: "Plugins System", + dirName: "core-concepts", + fileName: "plugins", + }, + { + chapterTitle: "Core Concepts", + pageTitle: "Theming", + dirName: "core-concepts", + fileName: "theming", + }, + ], + }, + { + chapterTitle: "API Reference", + dirName: "api", + fileName: "", + items: [ + { + chapterTitle: "API Reference", + pageTitle: "REST Endpoints", + dirName: "api", + fileName: "rest-endpoints", + }, + { + chapterTitle: "API Reference", + pageTitle: "Authentication", + dirName: "api", + fileName: "authentication", + }, + { + chapterTitle: "API Reference", + pageTitle: "Error Handling", + dirName: "api", + fileName: "error-handling", + }, + ], + }, + { + chapterTitle: "Advanced Topics", + dirName: "advanced", + fileName: "", + items: [ + { + chapterTitle: "Advanced Topics", + pageTitle: "Performance Optimization", + dirName: "advanced", + fileName: "performance", + }, + { + chapterTitle: "Advanced Topics", + pageTitle: "Custom Extensions", + dirName: "advanced", + fileName: "extensions", + }, + ], + }, +]; + +const samplePageStats: Record = { + "getting-started": { totalViews: 15420, uniqueViews: 8934 }, + "introduction/what-is-this": { totalViews: 12350, uniqueViews: 7210 }, + "introduction/installation": { totalViews: 9876, uniqueViews: 5432 }, + "introduction/quick-start": { totalViews: 8543, uniqueViews: 4321 }, + "core-concepts/architecture": { totalViews: 5678, uniqueViews: 3456 }, + "core-concepts/configuration": { totalViews: 4321, uniqueViews: 2345 }, + "core-concepts/plugins": { totalViews: 3210, uniqueViews: 1987 }, + "core-concepts/theming": { totalViews: 2100, uniqueViews: 1234 }, + "api/rest-endpoints": { totalViews: 7890, uniqueViews: 4567 }, + "api/authentication": { totalViews: 6543, uniqueViews: 3890 }, + "api/error-handling": { totalViews: 2345, uniqueViews: 1456 }, + "advanced/performance": { totalViews: 1890, uniqueViews: 1023 }, + "advanced/extensions": { totalViews: 987, uniqueViews: 654 }, +}; + +const sparsePageStats: Record = { + "getting-started": { totalViews: 1520, uniqueViews: 893 }, + "introduction/what-is-this": { totalViews: 2350, uniqueViews: 1210 }, + "core-concepts/architecture": { totalViews: 567, uniqueViews: 345 }, + "api/rest-endpoints": { totalViews: 789, uniqueViews: 456 }, +}; + +const highVolumeStats: Record = { + "getting-started": { totalViews: 1542000, uniqueViews: 893400 }, + "introduction/what-is-this": { totalViews: 1235000, uniqueViews: 721000 }, + "introduction/installation": { totalViews: 987600, uniqueViews: 543200 }, + "introduction/quick-start": { totalViews: 854300, uniqueViews: 432100 }, + "core-concepts/architecture": { totalViews: 567800, uniqueViews: 345600 }, + "core-concepts/configuration": { totalViews: 432100, uniqueViews: 234500 }, + "api/rest-endpoints": { totalViews: 789000, uniqueViews: 456700 }, + "api/authentication": { totalViews: 654300, uniqueViews: 389000 }, +}; + +export function docStatsViewDemo(registry: Registry) { + registry + .add("with full stats", () => ) + .add("with sparse stats", () => ) + .add("with high volume stats", () => ) + .add("dark theme", () => ( +
+ +
+ )) + .add("empty stats", () => ); +} diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx new file mode 100644 index 000000000..0f800f69f --- /dev/null +++ b/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx @@ -0,0 +1,176 @@ +/* + * Copyright 2025 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from "react"; +import { TocItem } from "../../structure/TocItem"; +import { documentationNavigation } from "../../structure/DocumentationNavigation"; +import { isTocItemIndex } from "../../structure/toc/TableOfContents"; + +import "./DocStatsView.css"; + +export interface PageStats { + totalViews: number; + uniqueViews: number; +} + +export interface DocStatsViewProps { + toc: TocItem[]; + pageStats: Record; +} + +interface PageItemProps { + item: TocItem; + stats?: PageStats; + onPageClick: (dirName: string, fileName: string) => void; +} + +function buildPageId(dirName: string, fileName: string): string { + return dirName ? `${dirName}/${fileName}` : fileName; +} + +function formatNumber(num: number): string { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + "M"; + } + if (num >= 1000) { + return (num / 1000).toFixed(1) + "K"; + } + return num.toLocaleString(); +} + +const PageItem: React.FC = ({ item, stats, onPageClick }) => { + const href = documentationNavigation.buildUrl(item); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + onPageClick(item.dirName, item.fileName); + }; + + return ( +
+ + {item.pageTitle} + + {stats && ( +
+ + {formatNumber(stats.totalViews)} + views + + + {formatNumber(stats.uniqueViews)} + unique + +
+ )} +
+ ); +}; + +interface ChapterSectionProps { + chapter: TocItem; + pageStats: Record; + onPageClick: (dirName: string, fileName: string) => void; +} + +const ChapterSection: React.FC = ({ chapter, pageStats, onPageClick }) => { + const items = (chapter.items || []).filter((item) => !isTocItemIndex(item)); + + if (items.length === 0) { + return null; + } + + const chapterStats = items.reduce( + (acc, item) => { + const pageId = buildPageId(item.dirName, item.fileName); + const stats = pageStats[pageId]; + if (stats) { + acc.totalViews += stats.totalViews; + acc.uniqueViews += stats.uniqueViews; + } + return acc; + }, + { totalViews: 0, uniqueViews: 0 } + ); + + return ( +
+
+ {chapter.chapterTitle} + {chapterStats.totalViews > 0 && ( +
+ {formatNumber(chapterStats.totalViews)} views + | + {formatNumber(chapterStats.uniqueViews)} unique +
+ )} +
+
+ {items.map((item) => { + const pageId = buildPageId(item.dirName, item.fileName); + return ; + })} +
+
+ ); +}; + +export const DocStatsView: React.FC = ({ toc, pageStats }) => { + const navigateToPage = (dirName: string, fileName: string) => { + documentationNavigation.navigateToPage({ dirName, fileName }); + }; + + const totalStats = Object.values(pageStats).reduce( + (acc, stats) => { + acc.totalViews += stats.totalViews; + acc.uniqueViews += stats.uniqueViews; + return acc; + }, + { totalViews: 0, uniqueViews: 0 } + ); + + return ( +
+
+

Guide Analytics

+
+
+ {formatNumber(totalStats.totalViews)} + Total Views +
+
+ {formatNumber(totalStats.uniqueViews)} + Unique Visitors +
+
+ {Object.keys(pageStats).length} + Pages Tracked +
+
+
+
+ {toc.map((chapter, idx) => ( + + ))} +
+
+ ); +}; From 0d0fddb885f92d5cd48702da25f1d27f6347ace2 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 21 Dec 2025 12:45:45 -0500 Subject: [PATCH 02/10] in progress --- .../src/screens/doc-stats/DocStatsScreen.tsx | 124 +++++++++- .../src/screens/doc-stats/DocStatsView.css | 37 ++- .../screens/doc-stats/DocStatsView.demo.tsx | 214 +++++++----------- .../src/screens/doc-stats/DocStatsView.tsx | 63 +++++- znai-reactjs/src/structure/docMeta.ts | 1 + 5 files changed, 303 insertions(+), 136 deletions(-) diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx index 875c46ab9..98e70b9f4 100644 --- a/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx +++ b/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx @@ -14,5 +14,127 @@ * limitations under the License. */ +import { useEffect, useState } from "react"; +import { TocItem } from "../../structure/TocItem"; +import { getDocMeta } from "../../structure/docMeta"; +import { DocStatsView, PageStats, TimePeriod } from "./DocStatsView"; + +/* + * REST API Schema: + * + * GET {docMeta.docStatsUrl} + * + * Response: + * { + * "week": { + * "getting-started": { "totalViews": 85, "uniqueViews": 42 }, + * "introduction/installation": { "totalViews": 54, "uniqueViews": 28 }, + * ... + * }, + * "month": { ... }, + * "year": { ... }, + * "total": { ... } + * } + * + * Error Response (4xx/5xx): + * { + * "error": "Error message" + * } + */ + +const AVAILABLE_PERIODS: TimePeriod[] = ["week", "month", "year", "total"]; + +export type DocStatsResponse = Record>; + +export interface DocStatsScreenProps { + toc: TocItem[]; +} + +async function fetchDocStats(apiUrl: string): Promise { + const response = await fetch(apiUrl); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: "Failed to fetch stats" })); + throw new Error(error.error || `HTTP ${response.status}`); + } + + return response.json(); +} + +export function DocStatsScreen({ toc }: DocStatsScreenProps) { + const [selectedPeriod, setSelectedPeriod] = useState("total"); + const [statsByPeriod, setStatsByPeriod] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const docStatsUrl = getDocMeta().docStatsUrl; + if (!docStatsUrl) { + setIsLoading(false); + return; + } + + let cancelled = false; + + async function loadStats() { + setIsLoading(true); + setError(null); + + try { + const data = await fetchDocStats(docStatsUrl!); + + if (cancelled) return; + + setStatsByPeriod(data); + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err.message : "Failed to load stats"); + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + loadStats(); + + return () => { + cancelled = true; + }; + }, []); + + if (error) { + return ( +
+

Failed to load analytics: {error}

+
+ ); + } + + if (isLoading) { + return ( +
+

Loading analytics...

+
+ ); + } + + if (!statsByPeriod) { + return null; + } + + const pageStats = statsByPeriod[selectedPeriod] || {}; + + return ( + + ); +} + export { DocStatsView } from "./DocStatsView"; -export type { DocStatsViewProps, PageStats } from "./DocStatsView"; +export type { DocStatsViewProps, PageStats, TimePeriod } from "./DocStatsView"; diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsView.css b/znai-reactjs/src/screens/doc-stats/DocStatsView.css index ca2c96cf8..b8d4bf454 100644 --- a/znai-reactjs/src/screens/doc-stats/DocStatsView.css +++ b/znai-reactjs/src/screens/doc-stats/DocStatsView.css @@ -29,13 +29,48 @@ border-bottom: 1px solid var(--znai-toc-panel-border-color); } +.znai-doc-stats-header-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + .znai-doc-stats-title { - margin: 0 0 20px 0; + margin: 0; font-size: 24px; font-weight: 600; color: var(--znai-section-title-color); } +.znai-doc-stats-period-switcher { + display: flex; + border-bottom: 1px solid var(--znai-snippets-outer-border-color); +} + +.znai-doc-stats-period-button { + padding: 8px 16px; + border: none; + background: none; + font-family: var(--znai-regular-font-family); + font-size: var(--znai-smaller-text-size); + color: var(--znai-regular-text-color); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.15s ease; +} + +.znai-doc-stats-period-button:hover { + color: var(--znai-brand-primary-color); +} + +.znai-doc-stats-period-button.active { + font-weight: 600; + color: var(--znai-brand-primary-color); + border-bottom-color: var(--znai-brand-primary-color); +} + .znai-doc-stats-overall { display: flex; gap: 32px; diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx index e8522fbb6..771bed1f5 100644 --- a/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx +++ b/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx @@ -15,8 +15,8 @@ */ import React from "react"; -import { Registry } from "react-component-viewer"; -import { DocStatsView, PageStats } from "./DocStatsView"; +import { Registry, simulateState } from "react-component-viewer"; +import { DocStatsView, PageStats, TimePeriod } from "./DocStatsView"; import { TocItem } from "../../structure/TocItem"; const sampleToc: TocItem[] = [ @@ -25,18 +25,8 @@ const sampleToc: TocItem[] = [ dirName: "", fileName: "", items: [ - { - chapterTitle: "", - pageTitle: "Index", - dirName: "", - fileName: "index", - }, - { - chapterTitle: "", - pageTitle: "Getting Started", - dirName: "", - fileName: "getting-started", - }, + { chapterTitle: "", pageTitle: "Index", dirName: "", fileName: "index" }, + { chapterTitle: "", pageTitle: "Getting Started", dirName: "", fileName: "getting-started" }, ], }, { @@ -44,24 +34,9 @@ const sampleToc: TocItem[] = [ dirName: "introduction", fileName: "", items: [ - { - chapterTitle: "Introduction", - pageTitle: "What Is This", - dirName: "introduction", - fileName: "what-is-this", - }, - { - chapterTitle: "Introduction", - pageTitle: "Installation Guide", - dirName: "introduction", - fileName: "installation", - }, - { - chapterTitle: "Introduction", - pageTitle: "Quick Start Tutorial", - dirName: "introduction", - fileName: "quick-start", - }, + { chapterTitle: "Introduction", pageTitle: "What Is This", dirName: "introduction", fileName: "what-is-this" }, + { chapterTitle: "Introduction", pageTitle: "Installation Guide", dirName: "introduction", fileName: "installation" }, + { chapterTitle: "Introduction", pageTitle: "Quick Start Tutorial", dirName: "introduction", fileName: "quick-start" }, ], }, { @@ -69,30 +44,10 @@ const sampleToc: TocItem[] = [ dirName: "core-concepts", fileName: "", items: [ - { - chapterTitle: "Core Concepts", - pageTitle: "Architecture Overview", - dirName: "core-concepts", - fileName: "architecture", - }, - { - chapterTitle: "Core Concepts", - pageTitle: "Configuration", - dirName: "core-concepts", - fileName: "configuration", - }, - { - chapterTitle: "Core Concepts", - pageTitle: "Plugins System", - dirName: "core-concepts", - fileName: "plugins", - }, - { - chapterTitle: "Core Concepts", - pageTitle: "Theming", - dirName: "core-concepts", - fileName: "theming", - }, + { chapterTitle: "Core Concepts", pageTitle: "Architecture Overview", dirName: "core-concepts", fileName: "architecture" }, + { chapterTitle: "Core Concepts", pageTitle: "Configuration", dirName: "core-concepts", fileName: "configuration" }, + { chapterTitle: "Core Concepts", pageTitle: "Plugins System", dirName: "core-concepts", fileName: "plugins" }, + { chapterTitle: "Core Concepts", pageTitle: "Theming", dirName: "core-concepts", fileName: "theming" }, ], }, { @@ -100,24 +55,9 @@ const sampleToc: TocItem[] = [ dirName: "api", fileName: "", items: [ - { - chapterTitle: "API Reference", - pageTitle: "REST Endpoints", - dirName: "api", - fileName: "rest-endpoints", - }, - { - chapterTitle: "API Reference", - pageTitle: "Authentication", - dirName: "api", - fileName: "authentication", - }, - { - chapterTitle: "API Reference", - pageTitle: "Error Handling", - dirName: "api", - fileName: "error-handling", - }, + { chapterTitle: "API Reference", pageTitle: "REST Endpoints", dirName: "api", fileName: "rest-endpoints" }, + { chapterTitle: "API Reference", pageTitle: "Authentication", dirName: "api", fileName: "authentication" }, + { chapterTitle: "API Reference", pageTitle: "Error Handling", dirName: "api", fileName: "error-handling" }, ], }, { @@ -125,65 +65,85 @@ const sampleToc: TocItem[] = [ dirName: "advanced", fileName: "", items: [ - { - chapterTitle: "Advanced Topics", - pageTitle: "Performance Optimization", - dirName: "advanced", - fileName: "performance", - }, - { - chapterTitle: "Advanced Topics", - pageTitle: "Custom Extensions", - dirName: "advanced", - fileName: "extensions", - }, + { chapterTitle: "Advanced Topics", pageTitle: "Performance Optimization", dirName: "advanced", fileName: "performance" }, + { chapterTitle: "Advanced Topics", pageTitle: "Custom Extensions", dirName: "advanced", fileName: "extensions" }, ], }, ]; -const samplePageStats: Record = { - "getting-started": { totalViews: 15420, uniqueViews: 8934 }, - "introduction/what-is-this": { totalViews: 12350, uniqueViews: 7210 }, - "introduction/installation": { totalViews: 9876, uniqueViews: 5432 }, - "introduction/quick-start": { totalViews: 8543, uniqueViews: 4321 }, - "core-concepts/architecture": { totalViews: 5678, uniqueViews: 3456 }, - "core-concepts/configuration": { totalViews: 4321, uniqueViews: 2345 }, - "core-concepts/plugins": { totalViews: 3210, uniqueViews: 1987 }, - "core-concepts/theming": { totalViews: 2100, uniqueViews: 1234 }, - "api/rest-endpoints": { totalViews: 7890, uniqueViews: 4567 }, - "api/authentication": { totalViews: 6543, uniqueViews: 3890 }, - "api/error-handling": { totalViews: 2345, uniqueViews: 1456 }, - "advanced/performance": { totalViews: 1890, uniqueViews: 1023 }, - "advanced/extensions": { totalViews: 987, uniqueViews: 654 }, -}; - -const sparsePageStats: Record = { - "getting-started": { totalViews: 1520, uniqueViews: 893 }, - "introduction/what-is-this": { totalViews: 2350, uniqueViews: 1210 }, - "core-concepts/architecture": { totalViews: 567, uniqueViews: 345 }, - "api/rest-endpoints": { totalViews: 789, uniqueViews: 456 }, +const statsByPeriod: Record> = { + week: { + "getting-started": { totalViews: 85, uniqueViews: 42 }, + "introduction/what-is-this": { totalViews: 68, uniqueViews: 35 }, + "introduction/installation": { totalViews: 54, uniqueViews: 28 }, + "introduction/quick-start": { totalViews: 47, uniqueViews: 24 }, + "core-concepts/architecture": { totalViews: 31, uniqueViews: 18 }, + "core-concepts/configuration": { totalViews: 24, uniqueViews: 12 }, + "core-concepts/plugins": { totalViews: 18, uniqueViews: 9 }, + "core-concepts/theming": { totalViews: 12, uniqueViews: 6 }, + "api/rest-endpoints": { totalViews: 43, uniqueViews: 22 }, + "api/authentication": { totalViews: 36, uniqueViews: 19 }, + "api/error-handling": { totalViews: 14, uniqueViews: 7 }, + "advanced/performance": { totalViews: 11, uniqueViews: 5 }, + "advanced/extensions": { totalViews: 6, uniqueViews: 3 }, + }, + month: { + "getting-started": { totalViews: 1542, uniqueViews: 893 }, + "introduction/what-is-this": { totalViews: 1235, uniqueViews: 721 }, + "introduction/installation": { totalViews: 987, uniqueViews: 543 }, + "introduction/quick-start": { totalViews: 854, uniqueViews: 432 }, + "core-concepts/architecture": { totalViews: 567, uniqueViews: 345 }, + "core-concepts/configuration": { totalViews: 432, uniqueViews: 234 }, + "core-concepts/plugins": { totalViews: 321, uniqueViews: 198 }, + "core-concepts/theming": { totalViews: 210, uniqueViews: 123 }, + "api/rest-endpoints": { totalViews: 789, uniqueViews: 456 }, + "api/authentication": { totalViews: 654, uniqueViews: 389 }, + "api/error-handling": { totalViews: 234, uniqueViews: 145 }, + "advanced/performance": { totalViews: 189, uniqueViews: 102 }, + "advanced/extensions": { totalViews: 98, uniqueViews: 65 }, + }, + year: { + "getting-started": { totalViews: 12000, uniqueViews: 7000 }, + "introduction/what-is-this": { totalViews: 9500, uniqueViews: 5500 }, + "introduction/installation": { totalViews: 7500, uniqueViews: 4200 }, + "introduction/quick-start": { totalViews: 6500, uniqueViews: 3300 }, + "core-concepts/architecture": { totalViews: 4200, uniqueViews: 2600 }, + "core-concepts/configuration": { totalViews: 3200, uniqueViews: 1800 }, + "core-concepts/plugins": { totalViews: 2400, uniqueViews: 1500 }, + "core-concepts/theming": { totalViews: 1600, uniqueViews: 950 }, + "api/rest-endpoints": { totalViews: 5900, uniqueViews: 3400 }, + "api/authentication": { totalViews: 4900, uniqueViews: 2900 }, + "api/error-handling": { totalViews: 1750, uniqueViews: 1100 }, + "advanced/performance": { totalViews: 1400, uniqueViews: 780 }, + "advanced/extensions": { totalViews: 740, uniqueViews: 490 }, + }, + total: { + "getting-started": { totalViews: 15420, uniqueViews: 8934 }, + "introduction/what-is-this": { totalViews: 12350, uniqueViews: 7210 }, + "introduction/installation": { totalViews: 9876, uniqueViews: 5432 }, + "introduction/quick-start": { totalViews: 8543, uniqueViews: 4321 }, + "core-concepts/architecture": { totalViews: 5678, uniqueViews: 3456 }, + "core-concepts/configuration": { totalViews: 4321, uniqueViews: 2345 }, + "core-concepts/plugins": { totalViews: 3210, uniqueViews: 1987 }, + "core-concepts/theming": { totalViews: 2100, uniqueViews: 1234 }, + "api/rest-endpoints": { totalViews: 7890, uniqueViews: 4567 }, + "api/authentication": { totalViews: 6543, uniqueViews: 3890 }, + "api/error-handling": { totalViews: 2345, uniqueViews: 1456 }, + "advanced/performance": { totalViews: 1890, uniqueViews: 1023 }, + "advanced/extensions": { totalViews: 987, uniqueViews: 654 }, + }, }; -const highVolumeStats: Record = { - "getting-started": { totalViews: 1542000, uniqueViews: 893400 }, - "introduction/what-is-this": { totalViews: 1235000, uniqueViews: 721000 }, - "introduction/installation": { totalViews: 987600, uniqueViews: 543200 }, - "introduction/quick-start": { totalViews: 854300, uniqueViews: 432100 }, - "core-concepts/architecture": { totalViews: 567800, uniqueViews: 345600 }, - "core-concepts/configuration": { totalViews: 432100, uniqueViews: 234500 }, - "api/rest-endpoints": { totalViews: 789000, uniqueViews: 456700 }, - "api/authentication": { totalViews: 654300, uniqueViews: 389000 }, -}; +const [getSelectedPeriod, setSelectedPeriod] = simulateState("total"); export function docStatsViewDemo(registry: Registry) { - registry - .add("with full stats", () => ) - .add("with sparse stats", () => ) - .add("with high volume stats", () => ) - .add("dark theme", () => ( -
- -
- )) - .add("empty stats", () => ); + registry.add("default", () => ( + + )); } diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx index 0f800f69f..db11e3e42 100644 --- a/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx +++ b/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx @@ -21,6 +21,8 @@ import { isTocItemIndex } from "../../structure/toc/TableOfContents"; import "./DocStatsView.css"; +export type TimePeriod = "week" | "month" | "year" | "total"; + export interface PageStats { totalViews: number; uniqueViews: number; @@ -29,6 +31,9 @@ export interface PageStats { export interface DocStatsViewProps { toc: TocItem[]; pageStats: Record; + selectedPeriod: TimePeriod; + availablePeriods: TimePeriod[]; + onPeriodChange: (period: TimePeriod) => void; } interface PageItemProps { @@ -37,6 +42,13 @@ interface PageItemProps { onPageClick: (dirName: string, fileName: string) => void; } +const TIME_PERIODS: { key: TimePeriod; label: string }[] = [ + { key: "week", label: "Week" }, + { key: "month", label: "Month" }, + { key: "year", label: "Year" }, + { key: "total", label: "Total" }, +]; + function buildPageId(dirName: string, fileName: string): string { return dirName ? `${dirName}/${fileName}` : fileName; } @@ -51,7 +63,7 @@ function formatNumber(num: number): string { return num.toLocaleString(); } -const PageItem: React.FC = ({ item, stats, onPageClick }) => { +function PageItem({ item, stats, onPageClick }: PageItemProps) { const href = documentationNavigation.buildUrl(item); const handleClick = (e: React.MouseEvent) => { @@ -78,7 +90,7 @@ const PageItem: React.FC = ({ item, stats, onPageClick }) => { )} ); -}; +} interface ChapterSectionProps { chapter: TocItem; @@ -86,7 +98,7 @@ interface ChapterSectionProps { onPageClick: (dirName: string, fileName: string) => void; } -const ChapterSection: React.FC = ({ chapter, pageStats, onPageClick }) => { +function ChapterSection({ chapter, pageStats, onPageClick }: ChapterSectionProps) { const items = (chapter.items || []).filter((item) => !isTocItemIndex(item)); if (items.length === 0) { @@ -126,9 +138,37 @@ const ChapterSection: React.FC = ({ chapter, pageStats, onP ); -}; +} -export const DocStatsView: React.FC = ({ toc, pageStats }) => { +interface TimePeriodSwitcherProps { + selectedPeriod: TimePeriod; + availablePeriods: TimePeriod[]; + onPeriodChange: (period: TimePeriod) => void; +} + +function TimePeriodSwitcher({ selectedPeriod, availablePeriods, onPeriodChange }: TimePeriodSwitcherProps) { + return ( +
+ {TIME_PERIODS.filter((p) => availablePeriods.includes(p.key)).map((period) => ( + + ))} +
+ ); +} + +export function DocStatsView({ + toc, + pageStats, + selectedPeriod, + availablePeriods, + onPeriodChange, +}: DocStatsViewProps) { const navigateToPage = (dirName: string, fileName: string) => { documentationNavigation.navigateToPage({ dirName, fileName }); }; @@ -145,7 +185,16 @@ export const DocStatsView: React.FC = ({ toc, pageStats }) => return (
-

Guide Analytics

+
+

Guide Analytics

+ {availablePeriods.length > 1 && ( + + )} +
{formatNumber(totalStats.totalViews)} @@ -173,4 +222,4 @@ export const DocStatsView: React.FC = ({ toc, pageStats }) =>
); -}; +} diff --git a/znai-reactjs/src/structure/docMeta.ts b/znai-reactjs/src/structure/docMeta.ts index 931a72b08..e5be33af4 100644 --- a/znai-reactjs/src/structure/docMeta.ts +++ b/znai-reactjs/src/structure/docMeta.ts @@ -29,6 +29,7 @@ export interface DocMeta { resolveSlackQuestionUrl?: string; trackActivityUrl?: string; trackActivityIncludeContentType?: boolean; + docStatsUrl?: string; useTopHeader?: boolean; hidePresentationTrigger?: boolean; support?: DocMetaSupport; From 35e7020b875026d29dc88ea845d4136916f0b1ee Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 21 Dec 2025 13:55:46 -0500 Subject: [PATCH 03/10] stats page in progress --- .../src/screens/doc-stats/DocStatsView.css | 84 +++++--- .../screens/doc-stats/DocStatsView.demo.tsx | 56 ++++- .../src/screens/doc-stats/DocStatsView.tsx | 201 +++++++++++------- 3 files changed, 233 insertions(+), 108 deletions(-) diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsView.css b/znai-reactjs/src/screens/doc-stats/DocStatsView.css index b8d4bf454..ecdd5edc0 100644 --- a/znai-reactjs/src/screens/doc-stats/DocStatsView.css +++ b/znai-reactjs/src/screens/doc-stats/DocStatsView.css @@ -16,24 +16,21 @@ .znai-doc-stats-view { font-family: var(--znai-regular-font-family); - max-width: var(--znai-single-column-render-width); - margin: 0 auto; - padding: 24px; + height: 100vh; + display: flex; + flex-direction: column; color: var(--znai-regular-text-color); background: var(--znai-background-color); } .znai-doc-stats-header { - margin-bottom: 32px; - padding-bottom: 24px; - border-bottom: 1px solid var(--znai-toc-panel-border-color); -} - -.znai-doc-stats-header-top { + flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; - margin-bottom: 20px; + padding: 20px 24px; + border-bottom: 1px solid var(--znai-toc-panel-border-color); + background: var(--znai-background-color); } .znai-doc-stats-title { @@ -45,49 +42,62 @@ .znai-doc-stats-period-switcher { display: flex; - border-bottom: 1px solid var(--znai-snippets-outer-border-color); + gap: 4px; } .znai-doc-stats-period-button { - padding: 8px 16px; - border: none; - background: none; + padding: 6px 14px; + border: 1px solid var(--znai-toc-panel-border-color); + background: var(--znai-background-color); font-family: var(--znai-regular-font-family); font-size: var(--znai-smaller-text-size); color: var(--znai-regular-text-color); cursor: pointer; - border-bottom: 2px solid transparent; - margin-bottom: -1px; - transition: color 0.15s ease; + border-radius: 4px; + transition: all 0.15s ease; } .znai-doc-stats-period-button:hover { + border-color: var(--znai-brand-primary-color); color: var(--znai-brand-primary-color); } .znai-doc-stats-period-button.active { - font-weight: 600; - color: var(--znai-brand-primary-color); - border-bottom-color: var(--znai-brand-primary-color); + background: var(--znai-brand-primary-color); + border-color: var(--znai-brand-primary-color); + color: #fff; + font-weight: 500; +} + +.znai-doc-stats-content { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +.znai-doc-stats-content:focus { + outline: none; } .znai-doc-stats-overall { display: flex; - gap: 32px; + gap: 24px; flex-wrap: wrap; + max-width: var(--znai-single-column-render-width); + margin: 0 auto 24px; } .znai-doc-stats-overall-stat { display: flex; flex-direction: column; - padding: 16px 24px; + padding: 12px 20px; background: var(--znai-toc-panel-background-color); - border-radius: 8px; - min-width: 140px; + border-radius: 6px; + min-width: 120px; } .znai-doc-stats-overall-value { - font-size: 28px; + font-size: 24px; font-weight: 600; color: var(--znai-brand-primary-color); line-height: 1.2; @@ -103,6 +113,8 @@ display: flex; flex-direction: column; gap: 24px; + max-width: var(--znai-single-column-render-width); + margin: 0 auto; } .znai-doc-stats-chapter { @@ -209,3 +221,25 @@ text-transform: uppercase; letter-spacing: 0.02em; } + +.znai-doc-stats-orphaned { + border-color: var(--znai-color-yellow); +} + +.znai-doc-stats-orphaned .znai-doc-stats-chapter-header { + background: color-mix(in srgb, var(--znai-color-yellow) 10%, var(--znai-background-color)); + border-bottom-color: var(--znai-color-yellow); +} + +.znai-doc-stats-orphaned .znai-doc-stats-chapter-title { + color: var(--znai-color-yellow); +} + +.znai-doc-stats-orphaned-page-id { + font-size: var(--znai-smaller-text-size); + color: var(--znai-regular-text-color); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx index 771bed1f5..4be714f96 100644 --- a/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx +++ b/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx @@ -19,7 +19,7 @@ import { Registry, simulateState } from "react-component-viewer"; import { DocStatsView, PageStats, TimePeriod } from "./DocStatsView"; import { TocItem } from "../../structure/TocItem"; -const sampleToc: TocItem[] = [ +const demoToc: TocItem[] = [ { chapterTitle: "", dirName: "", @@ -35,8 +35,18 @@ const sampleToc: TocItem[] = [ fileName: "", items: [ { chapterTitle: "Introduction", pageTitle: "What Is This", dirName: "introduction", fileName: "what-is-this" }, - { chapterTitle: "Introduction", pageTitle: "Installation Guide", dirName: "introduction", fileName: "installation" }, - { chapterTitle: "Introduction", pageTitle: "Quick Start Tutorial", dirName: "introduction", fileName: "quick-start" }, + { + chapterTitle: "Introduction", + pageTitle: "Installation Guide", + dirName: "introduction", + fileName: "installation", + }, + { + chapterTitle: "Introduction", + pageTitle: "Quick Start Tutorial", + dirName: "introduction", + fileName: "quick-start", + }, ], }, { @@ -44,8 +54,18 @@ const sampleToc: TocItem[] = [ dirName: "core-concepts", fileName: "", items: [ - { chapterTitle: "Core Concepts", pageTitle: "Architecture Overview", dirName: "core-concepts", fileName: "architecture" }, - { chapterTitle: "Core Concepts", pageTitle: "Configuration", dirName: "core-concepts", fileName: "configuration" }, + { + chapterTitle: "Core Concepts", + pageTitle: "Architecture Overview", + dirName: "core-concepts", + fileName: "architecture", + }, + { + chapterTitle: "Core Concepts", + pageTitle: "Configuration", + dirName: "core-concepts", + fileName: "configuration", + }, { chapterTitle: "Core Concepts", pageTitle: "Plugins System", dirName: "core-concepts", fileName: "plugins" }, { chapterTitle: "Core Concepts", pageTitle: "Theming", dirName: "core-concepts", fileName: "theming" }, ], @@ -65,7 +85,12 @@ const sampleToc: TocItem[] = [ dirName: "advanced", fileName: "", items: [ - { chapterTitle: "Advanced Topics", pageTitle: "Performance Optimization", dirName: "advanced", fileName: "performance" }, + { + chapterTitle: "Advanced Topics", + pageTitle: "Performance Optimization", + dirName: "advanced", + fileName: "performance", + }, { chapterTitle: "Advanced Topics", pageTitle: "Custom Extensions", dirName: "advanced", fileName: "extensions" }, ], }, @@ -77,15 +102,17 @@ const statsByPeriod: Record> = { "introduction/what-is-this": { totalViews: 68, uniqueViews: 35 }, "introduction/installation": { totalViews: 54, uniqueViews: 28 }, "introduction/quick-start": { totalViews: 47, uniqueViews: 24 }, - "core-concepts/architecture": { totalViews: 31, uniqueViews: 18 }, - "core-concepts/configuration": { totalViews: 24, uniqueViews: 12 }, - "core-concepts/plugins": { totalViews: 18, uniqueViews: 9 }, - "core-concepts/theming": { totalViews: 12, uniqueViews: 6 }, + "core-concepts/architecture": { totalViews: 0, uniqueViews: 0 }, + "core-concepts/configuration": { totalViews: 0, uniqueViews: 0 }, + "core-concepts/plugins": { totalViews: 0, uniqueViews: 0 }, + "core-concepts/theming": { totalViews: 0, uniqueViews: 0 }, "api/rest-endpoints": { totalViews: 43, uniqueViews: 22 }, "api/authentication": { totalViews: 36, uniqueViews: 19 }, "api/error-handling": { totalViews: 14, uniqueViews: 7 }, "advanced/performance": { totalViews: 11, uniqueViews: 5 }, "advanced/extensions": { totalViews: 6, uniqueViews: 3 }, + "legacy/old-api": { totalViews: 23, uniqueViews: 12 }, + "removed/deprecated-feature": { totalViews: 8, uniqueViews: 4 }, }, month: { "getting-started": { totalViews: 1542, uniqueViews: 893 }, @@ -101,6 +128,8 @@ const statsByPeriod: Record> = { "api/error-handling": { totalViews: 234, uniqueViews: 145 }, "advanced/performance": { totalViews: 189, uniqueViews: 102 }, "advanced/extensions": { totalViews: 98, uniqueViews: 65 }, + "legacy/old-api": { totalViews: 156, uniqueViews: 89 }, + "removed/deprecated-feature": { totalViews: 67, uniqueViews: 34 }, }, year: { "getting-started": { totalViews: 12000, uniqueViews: 7000 }, @@ -116,6 +145,8 @@ const statsByPeriod: Record> = { "api/error-handling": { totalViews: 1750, uniqueViews: 1100 }, "advanced/performance": { totalViews: 1400, uniqueViews: 780 }, "advanced/extensions": { totalViews: 740, uniqueViews: 490 }, + "legacy/old-api": { totalViews: 1230, uniqueViews: 678 }, + "removed/deprecated-feature": { totalViews: 543, uniqueViews: 276 }, }, total: { "getting-started": { totalViews: 15420, uniqueViews: 8934 }, @@ -131,6 +162,8 @@ const statsByPeriod: Record> = { "api/error-handling": { totalViews: 2345, uniqueViews: 1456 }, "advanced/performance": { totalViews: 1890, uniqueViews: 1023 }, "advanced/extensions": { totalViews: 987, uniqueViews: 654 }, + "legacy/old-api": { totalViews: 1567, uniqueViews: 834 }, + "removed/deprecated-feature": { totalViews: 712, uniqueViews: 389 }, }, }; @@ -139,7 +172,8 @@ const [getSelectedPeriod, setSelectedPeriod] = simulateState("total" export function docStatsViewDemo(registry: Registry) { registry.add("default", () => ( ; selectedPeriod: TimePeriod; @@ -53,6 +54,18 @@ function buildPageId(dirName: string, fileName: string): string { return dirName ? `${dirName}/${fileName}` : fileName; } +function collectTocPageIds(toc: TocItem[]): Set { + const pageIds = new Set(); + for (const chapter of toc) { + for (const item of chapter.items || []) { + if (!isTocItemIndex(item)) { + pageIds.add(buildPageId(item.dirName, item.fileName)); + } + } + } + return pageIds; +} + function formatNumber(num: number): string { if (num >= 1000000) { return (num / 1000000).toFixed(1) + "M"; @@ -63,6 +76,52 @@ function formatNumber(num: number): string { return num.toLocaleString(); } +const ZERO_STATS: PageStats = { totalViews: 0, uniqueViews: 0 }; + +function sumStats(statsArray: PageStats[]): PageStats { + return statsArray.reduce( + (acc, stats) => ({ + totalViews: acc.totalViews + stats.totalViews, + uniqueViews: acc.uniqueViews + stats.uniqueViews, + }), + ZERO_STATS + ); +} + +function StatsCounters({ stats }: { stats: PageStats }) { + return ( +
+ + {formatNumber(stats.totalViews)} + views + + + {formatNumber(stats.uniqueViews)} + unique + +
+ ); +} + +function ChapterSummary({ stats }: { stats: PageStats }) { + return ( +
+ {formatNumber(stats.totalViews)} views + | + {formatNumber(stats.uniqueViews)} unique +
+ ); +} + +function OverallStatCard({ value, label }: { value: number; label: string }) { + return ( +
+ {formatNumber(value)} + {label} +
+ ); +} + function PageItem({ item, stats, onPageClick }: PageItemProps) { const href = documentationNavigation.buildUrl(item); @@ -76,18 +135,7 @@ function PageItem({ item, stats, onPageClick }: PageItemProps) { {item.pageTitle} - {stats && ( -
- - {formatNumber(stats.totalViews)} - views - - - {formatNumber(stats.uniqueViews)} - unique - -
- )} +
); } @@ -105,30 +153,16 @@ function ChapterSection({ chapter, pageStats, onPageClick }: ChapterSectionProps return null; } - const chapterStats = items.reduce( - (acc, item) => { - const pageId = buildPageId(item.dirName, item.fileName); - const stats = pageStats[pageId]; - if (stats) { - acc.totalViews += stats.totalViews; - acc.uniqueViews += stats.uniqueViews; - } - return acc; - }, - { totalViews: 0, uniqueViews: 0 } - ); + const itemStats = items + .map((item) => pageStats[buildPageId(item.dirName, item.fileName)]) + .filter((stats): stats is PageStats => !!stats); + const chapterStats = sumStats(itemStats); return (
{chapter.chapterTitle} - {chapterStats.totalViews > 0 && ( -
- {formatNumber(chapterStats.totalViews)} views - | - {formatNumber(chapterStats.uniqueViews)} unique -
- )} +
{items.map((item) => { @@ -140,6 +174,35 @@ function ChapterSection({ chapter, pageStats, onPageClick }: ChapterSectionProps ); } +interface OrphanedPagesSectionProps { + orphanedPages: [string, PageStats][]; +} + +function OrphanedPagesSection({ orphanedPages }: OrphanedPagesSectionProps) { + if (orphanedPages.length === 0) { + return null; + } + + const sectionStats = sumStats(orphanedPages.map(([, stats]) => stats)); + + return ( +
+
+ Orphaned Pages + +
+
+ {orphanedPages.map(([pageId, stats]) => ( +
+ {pageId} + +
+ ))} +
+
+ ); +} + interface TimePeriodSwitcherProps { selectedPeriod: TimePeriod; availablePeriods: TimePeriod[]; @@ -163,62 +226,56 @@ function TimePeriodSwitcher({ selectedPeriod, availablePeriods, onPeriodChange } } export function DocStatsView({ + guideName, toc, pageStats, selectedPeriod, availablePeriods, onPeriodChange, }: DocStatsViewProps) { + const contentRef = useRef(null); + + useEffect(() => { + contentRef.current?.focus(); + }, []); + const navigateToPage = (dirName: string, fileName: string) => { documentationNavigation.navigateToPage({ dirName, fileName }); }; - const totalStats = Object.values(pageStats).reduce( - (acc, stats) => { - acc.totalViews += stats.totalViews; - acc.uniqueViews += stats.uniqueViews; - return acc; - }, - { totalViews: 0, uniqueViews: 0 } - ); + const totalStats = sumStats(Object.values(pageStats)); + + const tocPageIds = collectTocPageIds(toc); + const orphanedPages = Object.entries(pageStats).filter(([pageId]) => !tocPageIds.has(pageId)); return (
-
-

Guide Analytics

- {availablePeriods.length > 1 && ( - - )} -
+

{guideName} analytics

+ {availablePeriods.length > 1 && ( + + )} +
+
-
- {formatNumber(totalStats.totalViews)} - Total Views -
-
- {formatNumber(totalStats.uniqueViews)} - Unique Visitors -
-
- {Object.keys(pageStats).length} - Pages Tracked -
+ + +
+
+ {toc.map((chapter, idx) => ( + + ))} +
-
-
- {toc.map((chapter, idx) => ( - - ))}
); From 1649d8813c99e33261f1b470fdf2d0a7b2e23ff2 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 21 Dec 2025 14:11:01 -0500 Subject: [PATCH 04/10] in progress --- .../src/screens/doc-stats/DocStatsScreen.tsx | 76 ++++--------------- 1 file changed, 14 insertions(+), 62 deletions(-) diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx index 98e70b9f4..a234d8c74 100644 --- a/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx +++ b/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx @@ -14,34 +14,11 @@ * limitations under the License. */ -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { TocItem } from "../../structure/TocItem"; import { getDocMeta } from "../../structure/docMeta"; import { DocStatsView, PageStats, TimePeriod } from "./DocStatsView"; -/* - * REST API Schema: - * - * GET {docMeta.docStatsUrl} - * - * Response: - * { - * "week": { - * "getting-started": { "totalViews": 85, "uniqueViews": 42 }, - * "introduction/installation": { "totalViews": 54, "uniqueViews": 28 }, - * ... - * }, - * "month": { ... }, - * "year": { ... }, - * "total": { ... } - * } - * - * Error Response (4xx/5xx): - * { - * "error": "Error message" - * } - */ - const AVAILABLE_PERIODS: TimePeriod[] = ["week", "month", "year", "total"]; export type DocStatsResponse = Record>; @@ -50,12 +27,12 @@ export interface DocStatsScreenProps { toc: TocItem[]; } -async function fetchDocStats(apiUrl: string): Promise { - const response = await fetch(apiUrl); +async function fetchDocStats(url: string, signal: AbortSignal): Promise { + const response = await fetch(url, { signal }); if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Failed to fetch stats" })); - throw new Error(error.error || `HTTP ${response.status}`); + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${response.status}`); } return response.json(); @@ -64,43 +41,25 @@ async function fetchDocStats(apiUrl: string): Promise { export function DocStatsScreen({ toc }: DocStatsScreenProps) { const [selectedPeriod, setSelectedPeriod] = useState("total"); const [statsByPeriod, setStatsByPeriod] = useState(null); - const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const docStatsUrl = getDocMeta().docStatsUrl; if (!docStatsUrl) { - setIsLoading(false); return; } - let cancelled = false; - - async function loadStats() { - setIsLoading(true); - setError(null); - - try { - const data = await fetchDocStats(docStatsUrl!); + const abortController = new AbortController(); - if (cancelled) return; - - setStatsByPeriod(data); - } catch (err) { - if (cancelled) return; - setError(err instanceof Error ? err.message : "Failed to load stats"); - } finally { - if (!cancelled) { - setIsLoading(false); + fetchDocStats(docStatsUrl, abortController.signal) + .then(setStatsByPeriod) + .catch((err) => { + if (!abortController.signal.aborted) { + setError(err instanceof Error ? err.message : "Failed to load stats"); } - } - } + }); - loadStats(); - - return () => { - cancelled = true; - }; + return () => abortController.abort(); }, []); if (error) { @@ -111,14 +70,6 @@ export function DocStatsScreen({ toc }: DocStatsScreenProps) { ); } - if (isLoading) { - return ( -
-

Loading analytics...

-
- ); - } - if (!statsByPeriod) { return null; } @@ -127,6 +78,7 @@ export function DocStatsScreen({ toc }: DocStatsScreenProps) { return ( Date: Sun, 21 Dec 2025 16:26:39 -0500 Subject: [PATCH 05/10] in progress --- .../text-selection/SlackActiveQuestions.tsx | 9 +++------ .../text-selection/TextSelectionMenu.tsx | 11 ++--------- .../tracking/HttpDocumentationTracking.ts | 13 +++---------- .../src/screens/doc-stats/DocStatsScreen.tsx | 3 ++- znai-reactjs/src/structure/docMeta.ts | 3 +-- .../testingisdocumenting/znai/website/WebSite.java | 3 +-- 6 files changed, 12 insertions(+), 30 deletions(-) diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx index d9c54a521..2d0027f04 100644 --- a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx @@ -21,6 +21,7 @@ import { Notification } from "../../components/Notification"; import { HighlightedText } from "./HighlightedText"; import { TocItem } from "../../structure/TocItem"; import { errorNotifications } from "../../components/DismissableErrorIndicators"; +import { fetchWithCredentials } from "../../utils/fetchWithCredentials"; import { ResolveQuestionButton } from "./ResolveQuestionButton"; import { removeTrailingSlashFromQueryParam } from "./queryParamUtils"; @@ -64,10 +65,7 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode const url = `${baseUrl}?pageId=${encodeURIComponent(pageId)}&questionId=${questionId}`; - const response = await fetch(url, { - method: "GET", - credentials: "include", - }); + const response = await fetchWithCredentials(url); if (response.ok) { const data = await response.json(); @@ -102,9 +100,8 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode async function resolveQuestionPost(question: Question) { try { - const response = await fetch(getDocMeta().resolveSlackQuestionUrl! + "/" + question.slackMessageTs, { + const response = await fetchWithCredentials(getDocMeta().resolveSlackQuestionUrl! + "/" + question.slackMessageTs, { method: "POST", - credentials: "include", }); if (response.ok) { diff --git a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx index 6fc1f69b2..37996c2ad 100644 --- a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx @@ -20,6 +20,7 @@ import { findPrefixSuffixAndMatch } from "./textSelectionBuilder"; import { buildHighlightUrl } from "./highlightUrl"; import { getDocMeta } from "../../structure/docMeta"; +import { fetchWithCredentials } from "../../utils/fetchWithCredentials"; import { buildContext } from "./markdownContextBuilder"; import { Notification } from "../../components/Notification"; @@ -256,16 +257,8 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle }; try { - const headers = getDocMeta().sendToSlackIncludeContentType - ? { - "Content-Type": "application/json", - } - : undefined; - - const response = await fetch(getDocMeta().sendToSlackUrl!, { + const response = await fetchWithCredentials(getDocMeta().sendToSlackUrl!, { method: "POST", - credentials: "include", - headers, body: JSON.stringify(body), }); diff --git a/znai-reactjs/src/doc-elements/tracking/HttpDocumentationTracking.ts b/znai-reactjs/src/doc-elements/tracking/HttpDocumentationTracking.ts index 3cf10587d..491e4f8cb 100644 --- a/znai-reactjs/src/doc-elements/tracking/HttpDocumentationTracking.ts +++ b/znai-reactjs/src/doc-elements/tracking/HttpDocumentationTracking.ts @@ -15,7 +15,8 @@ */ import { DocumentationTrackingListener } from "./DocumentationTracking"; -import { getDocId, getDocMeta } from "../../structure/docMeta"; +import { getDocId } from "../../structure/docMeta"; +import { fetchWithCredentials } from "../../utils/fetchWithCredentials"; export interface TrackingEvent { docId: string; @@ -78,17 +79,9 @@ export class HttpDocumentationTracking implements DocumentationTrackingListener }; try { - const headers = getDocMeta().trackActivityIncludeContentType - ? { - "Content-Type": "application/json", - } - : undefined; - - const response = await fetch(this.trackingUrl, { + const response = await fetchWithCredentials(this.trackingUrl, { method: "POST", - headers: headers, body: JSON.stringify(event), - credentials: "include", }); if (!response.ok) { diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx index a234d8c74..5101b9fa2 100644 --- a/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx +++ b/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx @@ -17,6 +17,7 @@ import React, { useEffect, useState } from "react"; import { TocItem } from "../../structure/TocItem"; import { getDocMeta } from "../../structure/docMeta"; +import { fetchWithCredentials } from "../../utils/fetchWithCredentials"; import { DocStatsView, PageStats, TimePeriod } from "./DocStatsView"; const AVAILABLE_PERIODS: TimePeriod[] = ["week", "month", "year", "total"]; @@ -28,7 +29,7 @@ export interface DocStatsScreenProps { } async function fetchDocStats(url: string, signal: AbortSignal): Promise { - const response = await fetch(url, { signal }); + const response = await fetchWithCredentials(url, { signal }); if (!response.ok) { const body = await response.json().catch(() => ({})); diff --git a/znai-reactjs/src/structure/docMeta.ts b/znai-reactjs/src/structure/docMeta.ts index e5be33af4..40f6a0c8e 100644 --- a/znai-reactjs/src/structure/docMeta.ts +++ b/znai-reactjs/src/structure/docMeta.ts @@ -25,11 +25,10 @@ export interface DocMeta { slackChannel?: string; sendToSlackUrl?: string; slackActiveQuestionsUrl?: string; - sendToSlackIncludeContentType?: boolean; resolveSlackQuestionUrl?: string; trackActivityUrl?: string; - trackActivityIncludeContentType?: boolean; docStatsUrl?: string; + fetchIncludeContentType?: boolean; useTopHeader?: boolean; hidePresentationTrigger?: boolean; support?: DocMetaSupport; diff --git a/znai-website-gen/src/main/java/org/testingisdocumenting/znai/website/WebSite.java b/znai-website-gen/src/main/java/org/testingisdocumenting/znai/website/WebSite.java index aa25c092b..5f1fe0471 100644 --- a/znai-website-gen/src/main/java/org/testingisdocumenting/znai/website/WebSite.java +++ b/znai-website-gen/src/main/java/org/testingisdocumenting/znai/website/WebSite.java @@ -1056,9 +1056,8 @@ private void attachSlackMetaFromEnvVars(DocMeta docMeta) { addMetaFromEnvVar(docMeta, "slackChannel", "ZNAI_SLACK_CHANNEL"); addMetaFromEnvVar(docMeta, "slackActiveQuestionsUrl", "ZNAI_SLACK_ACTIVE_QUESTIONS_URL"); addMetaFromEnvVar(docMeta, "resolveSlackQuestionUrl", "ZNAI_RESOLVE_SLACK_QUESTION_URL"); - addMetaFromEnvVar(docMeta, "sendToSlackIncludeContentType", "ZNAI_SEND_TO_SLACK_INCLUDE_CONTENT_TYPE"); addMetaFromEnvVar(docMeta, "trackActivityUrl", "ZNAI_TRACK_ACTIVITY_URL"); - addMetaFromEnvVar(docMeta, "trackActivityIncludeContentType", "ZNAI_TRACK_ACTIVITY_INCLUDE_CONTENT_TYPE"); + addMetaFromEnvVar(docMeta, "fetchIncludeContentType", "ZNAI_FETCH_INCLUDE_CONTENT_TYPE"); } private void addMetaFromEnvVar(DocMeta docMeta, String key, String envVarName) { From 9720500861d74739814fa262c34afd8e883625eb Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 21 Dec 2025 17:13:32 -0500 Subject: [PATCH 06/10] in progress --- znai-reactjs/src/doc-elements/Documentation.jsx | 4 ++-- .../text-selection/SlackActiveQuestions.tsx | 13 ++++++++----- .../text-selection/TextSelectionMenu.tsx | 4 ++-- .../src/structure/DocumentationNavigation.jsx | 9 +++++++-- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/znai-reactjs/src/doc-elements/Documentation.jsx b/znai-reactjs/src/doc-elements/Documentation.jsx index 28805813b..bea0748af 100644 --- a/znai-reactjs/src/doc-elements/Documentation.jsx +++ b/znai-reactjs/src/doc-elements/Documentation.jsx @@ -22,7 +22,7 @@ import { themeRegistry } from "../theme/ThemeRegistry"; import SearchPopup from "./search/SearchPopup"; import { getSearchPromise } from "./search/searchPromise"; -import { documentationNavigation } from "../structure/DocumentationNavigation"; +import { documentationNavigation, currentPageId } from "../structure/DocumentationNavigation"; import { documentationTracking } from "./tracking/DocumentationTracking"; import { tableOfContents } from "../structure/toc/TableOfContents"; import { getAllPagesPromise } from "./allPages"; @@ -444,7 +444,7 @@ export class Documentation extends React.Component { document.title = page.tocItem.pageTitle ? docMeta.title + ": " + page.tocItem.pageTitle : docMeta.title; this.setState({ presentationRegistry }, () => { - documentationTracking.onPageOpen(currentPageLocation); + documentationTracking.onPageOpen(currentPageId()); }); } diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx index 2d0027f04..124f8a8e1 100644 --- a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx @@ -15,7 +15,7 @@ */ import React, { useEffect, useState } from "react"; -import { currentPageId, pageIdFromTocItem } from "../../structure/DocumentationNavigation"; +import { currentPageIdWithDocId, pageIdFromTocItem } from "../../structure/DocumentationNavigation"; import { getDocMeta } from "../../structure/docMeta"; import { Notification } from "../../components/Notification"; import { HighlightedText } from "./HighlightedText"; @@ -57,7 +57,7 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode async function fetchActiveQuestions() { try { - const pageId = currentPageId(); + const pageId = currentPageIdWithDocId(); const baseUrl = getDocMeta().slackActiveQuestionsUrl; if (!baseUrl) { return; @@ -100,9 +100,12 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode async function resolveQuestionPost(question: Question) { try { - const response = await fetchWithCredentials(getDocMeta().resolveSlackQuestionUrl! + "/" + question.slackMessageTs, { - method: "POST", - }); + const response = await fetchWithCredentials( + getDocMeta().resolveSlackQuestionUrl! + "/" + question.slackMessageTs, + { + method: "POST", + } + ); if (response.ok) { setNotification({ type: "success", message: "Resolved slack question" }); diff --git a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx index 37996c2ad..6cdd104b3 100644 --- a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx @@ -24,7 +24,7 @@ import { fetchWithCredentials } from "../../utils/fetchWithCredentials"; import { buildContext } from "./markdownContextBuilder"; import { Notification } from "../../components/Notification"; -import { currentPageId } from "../../structure/DocumentationNavigation"; +import { currentPageIdWithDocId } from "../../structure/DocumentationNavigation"; import "./TextSelectionMenu.css"; export interface TextMenuListener { @@ -249,7 +249,7 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle selectedText: panelData!.prefixSuffixMatch.selection, selectedPrefix: panelData!.prefixSuffixMatch.prefix, selectedSuffix: panelData!.prefixSuffixMatch.suffix, - pageId: currentPageId(), + pageId: currentPageIdWithDocId(), pageOrigin: document.location.origin, slackChannel: getDocMeta().slackChannel, question: question, diff --git a/znai-reactjs/src/structure/DocumentationNavigation.jsx b/znai-reactjs/src/structure/DocumentationNavigation.jsx index 46b23ff61..5559a32b8 100644 --- a/znai-reactjs/src/structure/DocumentationNavigation.jsx +++ b/znai-reactjs/src/structure/DocumentationNavigation.jsx @@ -132,17 +132,22 @@ function joinPageIdParts(docId, dirName, fileName) { return [docId, dirName, fileName].filter((part) => !!part).join("/"); } -function currentPageId() { +function currentPageIdWithDocId() { const pageLocation = documentationNavigation.currentPageLocation(); return isTocItemIndex(pageLocation) ? getDocId() : joinPageIdParts(getDocId(), pageLocation.dirName, pageLocation.fileName); } +function currentPageId() { + const pageLocation = documentationNavigation.currentPageLocation(); + return joinPageIdParts(pageLocation.dirName, pageLocation.fileName); +} + function pageIdFromTocItem(tocItem) { return joinPageIdParts(getDocId(), tocItem.dirName, tocItem.fileName); } const documentationNavigation = new DocumentationNavigation(); -export { documentationNavigation, currentPageId, pageIdFromTocItem }; +export { documentationNavigation, currentPageIdWithDocId, currentPageId, pageIdFromTocItem }; From f8bd1438cd62ba420dd61e6b4f4e9943605f26a4 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 21 Dec 2025 18:46:06 -0500 Subject: [PATCH 07/10] in progress --- .../test/groovy/scenarios/httpTracking.groovy | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 znai-tests/src/test/groovy/scenarios/httpTracking.groovy diff --git a/znai-tests/src/test/groovy/scenarios/httpTracking.groovy b/znai-tests/src/test/groovy/scenarios/httpTracking.groovy new file mode 100644 index 000000000..46639fbfd --- /dev/null +++ b/znai-tests/src/test/groovy/scenarios/httpTracking.groovy @@ -0,0 +1,93 @@ +/* + * Copyright 2025 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package scenarios + +import org.testingisdocumenting.webtau.cli.CliBackgroundCommand +import org.testingisdocumenting.webtau.server.WebTauServer + +import static clicommands.CliCommands.znai +import static org.testingisdocumenting.webtau.WebTauGroovyDsl.* +import static pages.Pages.* + +def port = 3460 +def trackingServerPort = 3461 +def capturedEvents = Collections.synchronizedList([]) + +WebTauServer trackingServer +CliBackgroundCommand znaiPreview + +scenario("start tracking fake server") { + def router = server.router() + .post("/track") { request -> + def content = request.getContentAsMap() + capturedEvents.add(content) + return server.response([status: "ok"]) + } + + trackingServer = server.fake("tracking-server", trackingServerPort, router) +} + +scenario("run znai preview with tracking enabled") { + znaiPreview = znai.runInBackground( + "--source=${cfg.fullPath("sampledoc")} --port=$port --preview", + cli.env(ZNAI_TRACK_ACTIVITY_URL: "http://localhost:$trackingServerPort/track")) + znaiPreview.output.waitTo(contain("server started"), 20_000) +} + +scenario("validate initial page load sends tracking event") { + previewServer.openPreviewWithUrl(port, "chapter-one/target") + docContent.title.waitToBe visible + + actual(liveValue{ -> capturedEvents.size() }).waitToBe(greaterThanOrEqual(1)) + + def pageOpenEvent = capturedEvents.find { it.eventType == "pageOpen" } + pageOpenEvent.pageId.should == "chapter-one/target" +} + +scenario("validate TOC navigation sends tracking events") { + standardView.tocItems.get("Links").click() + docContent.title.waitTo == "Links" + + def tocSelectEvents = capturedEvents.findAll { it.eventType == "tocItemSelect" } + tocSelectEvents.size().shouldBe >= 1 +} + +scenario("validate another TOC navigation") { + standardView.tocItems.get("Target").click() + docContent.title.waitTo == "Target" + + def tocSelectEvents = capturedEvents.findAll { it.eventType == "tocItemSelect" } + tocSelectEvents.size().waitTo >= 2 +} + +scenario("validate all captured events") { + def pageOpenEvents = capturedEvents.findAll { it.eventType == "pageOpen" } + pageOpenEvents.size().shouldBe >= 2 + + def pageIds = pageOpenEvents.collect { it.pageId } + pageIds.should contain("chapter-one/links") + pageIds.should contain("chapter-one/target") +} + +scenario("stop servers") { + if (znaiPreview) { + znaiPreview.stop() + } + if (trackingServer) { + trackingServer.stop() + } +} From 06315e237395e48d8ad1c9b714ee2a074870d2e7 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Thu, 25 Dec 2025 12:01:56 -0500 Subject: [PATCH 08/10] in progress --- .../znai/core/DocMeta.java | 5 ++ .../znai-enterprise-sample-server.py | 83 ++++++++++++++++++- znai-reactjs/src/index.jsx | 2 + .../src/screens/doc-stats/DocStatsScreen.tsx | 27 +++--- .../znai/website/WebSite.java | 31 ++++++- 5 files changed, 130 insertions(+), 18 deletions(-) diff --git a/znai-core/src/main/java/org/testingisdocumenting/znai/core/DocMeta.java b/znai-core/src/main/java/org/testingisdocumenting/znai/core/DocMeta.java index 7e6fb6a12..a21c7eb7c 100644 --- a/znai-core/src/main/java/org/testingisdocumenting/znai/core/DocMeta.java +++ b/znai-core/src/main/java/org/testingisdocumenting/znai/core/DocMeta.java @@ -26,6 +26,7 @@ public class DocMeta { public static final String META_FILE_NAME = "meta.json"; + public static final String TRACK_ACTIVITY_URL_KEY = "trackActivityUrl"; private String id; @@ -114,6 +115,10 @@ public List getAllowedGroups() { return allowedGroups; } + public boolean hasTrackActivityUrl() { + return docMetaMap.containsKey(TRACK_ACTIVITY_URL_KEY); + } + public Map toMap() { Map result = new HashMap<>(); result.put("id", id); diff --git a/znai-enterprise-sample-server/znai-enterprise-sample-server.py b/znai-enterprise-sample-server/znai-enterprise-sample-server.py index 96ea07486..99426bb5c 100644 --- a/znai-enterprise-sample-server/znai-enterprise-sample-server.py +++ b/znai-enterprise-sample-server/znai-enterprise-sample-server.py @@ -2,7 +2,8 @@ import json import csv import uuid -from datetime import datetime +from datetime import datetime, timedelta +from collections import defaultdict from flask import Flask, request, jsonify from flask_cors import CORS from slack_sdk import WebClient @@ -248,6 +249,85 @@ def persist_tracking_event(event): writer.writerow(event) +def load_tracking_events(): + if not os.path.exists(TRACKING_CSV_FILE): + return [] + + events = [] + with open(TRACKING_CSV_FILE, 'r', newline='', encoding='utf-8') as csvfile: + reader = csv.DictReader(csvfile) + for row in reader: + events.append(row) + + return events + +def calculate_page_stats(events, start_time=None): + page_views = defaultdict(int) + page_unique = defaultdict(set) + + for event in events: + if event.get('eventType') != 'pageOpen': + continue + + timestamp_str = event.get('timestamp', '') + if start_time and timestamp_str: + try: + event_time = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) + if event_time < start_time: + continue + except ValueError: + pass + + page_id = event.get('pageId', '') + if not page_id: + continue + + page_views[page_id] += 1 + + data_str = event.get('data', '{}') + try: + data = json.loads(data_str) if data_str else {} + except json.JSONDecodeError: + data = {} + + visitor_id = data.get('visitorId', timestamp_str) + page_unique[page_id].add(visitor_id) + + result = {} + all_pages = set(page_views.keys()) | set(page_unique.keys()) + for page_id in all_pages: + result[page_id] = { + 'totalViews': page_views[page_id], + 'uniqueViews': len(page_unique[page_id]) + } + + return result + +@app.route('/doc-stats', methods=['GET']) +def get_doc_stats(): + try: + events = load_tracking_events() + + now = datetime.utcnow() + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + year_ago = now - timedelta(days=365) + + stats = { + 'week': calculate_page_stats(events, week_ago), + 'month': calculate_page_stats(events, month_ago), + 'year': calculate_page_stats(events, year_ago), + 'total': calculate_page_stats(events) + } + + return jsonify(stats), 200 + + except Exception as e: + print(f"Error getting doc stats: {type(e).__name__}: {str(e)}") + import traceback + traceback.print_exc() + return jsonify({"error": str(e)}), 500 + def format_slack_message(username, question, context, page_url): message_parts = [f"@{username} <{page_url}|asked>: {question}"] @@ -256,7 +336,6 @@ def format_slack_message(username, question, context, page_url): return "\n\n".join(message_parts) -# Erase tracking CSV file on server start if os.path.exists(TRACKING_CSV_FILE): os.remove(TRACKING_CSV_FILE) print(f"Erased existing tracking file: {TRACKING_CSV_FILE}") diff --git a/znai-reactjs/src/index.jsx b/znai-reactjs/src/index.jsx index 729eea976..e8ec55f11 100644 --- a/znai-reactjs/src/index.jsx +++ b/znai-reactjs/src/index.jsx @@ -28,6 +28,7 @@ import {Documentation} from "./doc-elements/Documentation"; import {DocumentationPreparationScreen} from './screens/documentation-preparation/DocumentationPreparationScreen' import {PreviewChangeScreen} from './screens/preview-change-path/PreviewChangeScreen' import {NotAuthorizedScreen} from './screens/not-authorized/NotAuthorizedScreen' +import {DocStatsScreen} from './screens/doc-stats/DocStatsScreen' import {Landing} from './screens/landing/Landing' import {themeRegistry} from './theme/ThemeRegistry' import {documentationNavigation} from './structure/DocumentationNavigation' @@ -42,6 +43,7 @@ window.ReactDOM = ReactDOM window.Documentation = Documentation window.DocumentationPreparationScreen = DocumentationPreparationScreen window.NotAuthorizedScreen = NotAuthorizedScreen +window.DocStatsScreen = DocStatsScreen window.Landing = Landing window.PreviewChangeScreen = PreviewChangeScreen window.themeRegistry = themeRegistry diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx index 5101b9fa2..35fbd8223 100644 --- a/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx +++ b/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx @@ -16,7 +16,7 @@ import React, { useEffect, useState } from "react"; import { TocItem } from "../../structure/TocItem"; -import { getDocMeta } from "../../structure/docMeta"; +import { DocMeta, getDocMeta, mergeDocMeta } from "../../structure/docMeta"; import { fetchWithCredentials } from "../../utils/fetchWithCredentials"; import { DocStatsView, PageStats, TimePeriod } from "./DocStatsView"; @@ -26,10 +26,11 @@ export type DocStatsResponse = Record>; export interface DocStatsScreenProps { toc: TocItem[]; + docMeta: DocMeta; } -async function fetchDocStats(url: string, signal: AbortSignal): Promise { - const response = await fetchWithCredentials(url, { signal }); +async function fetchDocStats(url: string): Promise { + const response = await fetchWithCredentials(url, {}); if (!response.ok) { const body = await response.json().catch(() => ({})); @@ -39,28 +40,27 @@ async function fetchDocStats(url: string, signal: AbortSignal): Promise("total"); const [statsByPeriod, setStatsByPeriod] = useState(null); const [error, setError] = useState(null); + // rest of the code in this file and nested components expect global docMeta presence + useEffect(() => { + mergeDocMeta(docMeta); + }, [docMeta]); + useEffect(() => { const docStatsUrl = getDocMeta().docStatsUrl; if (!docStatsUrl) { return; } - const abortController = new AbortController(); - - fetchDocStats(docStatsUrl, abortController.signal) + fetchDocStats(docStatsUrl) .then(setStatsByPeriod) .catch((err) => { - if (!abortController.signal.aborted) { - setError(err instanceof Error ? err.message : "Failed to load stats"); - } + setError(err instanceof Error ? err.message : "Failed to load stats"); }); - - return () => abortController.abort(); }, []); if (error) { @@ -88,6 +88,3 @@ export function DocStatsScreen({ toc }: DocStatsScreenProps) { /> ); } - -export { DocStatsView } from "./DocStatsView"; -export type { DocStatsViewProps, PageStats, TimePeriod } from "./DocStatsView"; diff --git a/znai-website-gen/src/main/java/org/testingisdocumenting/znai/website/WebSite.java b/znai-website-gen/src/main/java/org/testingisdocumenting/znai/website/WebSite.java index 5f1fe0471..62e222f0b 100644 --- a/znai-website-gen/src/main/java/org/testingisdocumenting/znai/website/WebSite.java +++ b/znai-website-gen/src/main/java/org/testingisdocumenting/znai/website/WebSite.java @@ -26,6 +26,7 @@ import org.testingisdocumenting.znai.preprocessor.RegexpBasedPreprocessor; import org.testingisdocumenting.znai.resources.*; import org.testingisdocumenting.znai.html.*; +import org.testingisdocumenting.znai.html.reactjs.HtmlReactJsPage; import org.testingisdocumenting.znai.html.reactjs.ReactJsBundle; import org.testingisdocumenting.znai.markdown.PageMarkdownSection; import org.testingisdocumenting.znai.parser.MarkupParser; @@ -205,6 +206,7 @@ private void registerPreprocessor() { public void deploy() { reportPhase("deploying documentation"); generatePages(); + generateDocStatsPage(); generateChapterIndexRedirectPages(); generatePageRedirects(); generateSearchIndex(); @@ -690,6 +692,32 @@ public void buildJsonOfAllPages() { deployer.deploy("all-pages.json", json); } + private void generateDocStatsPage() { + if (!docMeta.hasTrackActivityUrl()) { + return; + } + + reportPhase("generating doc stats page"); + + Map props = new HashMap<>(); + props.put("docMeta", docMeta.toMap()); + props.put("toc", toc.toListOfMaps()); + + HtmlReactJsPage reactJsPage = new HtmlReactJsPage(ReactJsBundle.INSTANCE); + HtmlPage htmlPage = reactJsPage.create( + docMeta.getTitle() + ": Stats", + "DocStatsScreen", + props, + () -> "", + ""); + + extraJavaScriptsInFront.forEach(htmlPage::addJavaScriptInFront); + extraJavaScriptsInBack.forEach(htmlPage::addJavaScript); + + String html = htmlPage.render(docMeta.getId()); + deployer.deploy("_stats/index.html", html); + } + private HtmlPageAndPageProps generatePage(TocItem tocItem, Page page) { try { HtmlPageAndPageProps htmlAndProps = createHtmlPageAndProps(tocItem, page); @@ -1056,7 +1084,8 @@ private void attachSlackMetaFromEnvVars(DocMeta docMeta) { addMetaFromEnvVar(docMeta, "slackChannel", "ZNAI_SLACK_CHANNEL"); addMetaFromEnvVar(docMeta, "slackActiveQuestionsUrl", "ZNAI_SLACK_ACTIVE_QUESTIONS_URL"); addMetaFromEnvVar(docMeta, "resolveSlackQuestionUrl", "ZNAI_RESOLVE_SLACK_QUESTION_URL"); - addMetaFromEnvVar(docMeta, "trackActivityUrl", "ZNAI_TRACK_ACTIVITY_URL"); + addMetaFromEnvVar(docMeta, DocMeta.TRACK_ACTIVITY_URL_KEY, "ZNAI_TRACK_ACTIVITY_URL"); + addMetaFromEnvVar(docMeta, "docStatsUrl", "ZNAI_DOC_STATS_URL"); addMetaFromEnvVar(docMeta, "fetchIncludeContentType", "ZNAI_FETCH_INCLUDE_CONTENT_TYPE"); } From d430616bab823a731f56a350bd66d1d037f30dc7 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Fri, 26 Dec 2025 15:31:16 -0500 Subject: [PATCH 09/10] in progress --- .../1.84/add-2025-12-26-stats-page.md | 1 + .../znai-enterprise-sample-server.py | 22 +++++++++++----- .../src/screens/doc-stats/DocStatsView.tsx | 26 ++++--------------- 3 files changed, 22 insertions(+), 27 deletions(-) create mode 100644 znai-docs/znai/release-notes/1.84/add-2025-12-26-stats-page.md diff --git a/znai-docs/znai/release-notes/1.84/add-2025-12-26-stats-page.md b/znai-docs/znai/release-notes/1.84/add-2025-12-26-stats-page.md new file mode 100644 index 000000000..36297ef08 --- /dev/null +++ b/znai-docs/znai/release-notes/1.84/add-2025-12-26-stats-page.md @@ -0,0 +1 @@ +* Add: `/_stats` page to display tracked visitors for internally hosted documentations \ No newline at end of file diff --git a/znai-enterprise-sample-server/znai-enterprise-sample-server.py b/znai-enterprise-sample-server/znai-enterprise-sample-server.py index 99426bb5c..7a1037a39 100644 --- a/znai-enterprise-sample-server/znai-enterprise-sample-server.py +++ b/znai-enterprise-sample-server/znai-enterprise-sample-server.py @@ -303,6 +303,16 @@ def calculate_page_stats(events, start_time=None): return result +def apply_stats_multiplier(page_stats, multiplier): + result = {} + for page_id, stats in page_stats.items(): + total_views = stats['totalViews'] * multiplier + result[page_id] = { + 'totalViews': int(total_views), + 'uniqueViews': int(total_views * 0.2) + } + return result + @app.route('/doc-stats', methods=['GET']) def get_doc_stats(): try: @@ -310,14 +320,14 @@ def get_doc_stats(): now = datetime.utcnow() week_ago = now - timedelta(days=7) - month_ago = now - timedelta(days=30) - year_ago = now - timedelta(days=365) + + week_stats = calculate_page_stats(events, week_ago) stats = { - 'week': calculate_page_stats(events, week_ago), - 'month': calculate_page_stats(events, month_ago), - 'year': calculate_page_stats(events, year_ago), - 'total': calculate_page_stats(events) + 'week': apply_stats_multiplier(week_stats, 1), + 'month': apply_stats_multiplier(week_stats, 2), + 'year': apply_stats_multiplier(week_stats, 8), + 'total': apply_stats_multiplier(week_stats, 8) } return jsonify(stats), 200 diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx index a942fe1a4..e6b4defe4 100644 --- a/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx +++ b/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx @@ -40,7 +40,6 @@ export interface DocStatsViewProps { interface PageItemProps { item: TocItem; stats?: PageStats; - onPageClick: (dirName: string, fileName: string) => void; } const TIME_PERIODS: { key: TimePeriod; label: string }[] = [ @@ -122,17 +121,12 @@ function OverallStatCard({ value, label }: { value: number; label: string }) { ); } -function PageItem({ item, stats, onPageClick }: PageItemProps) { +function PageItem({ item, stats }: PageItemProps) { const href = documentationNavigation.buildUrl(item); - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - onPageClick(item.dirName, item.fileName); - }; - return (
- + {item.pageTitle} @@ -143,10 +137,9 @@ function PageItem({ item, stats, onPageClick }: PageItemProps) { interface ChapterSectionProps { chapter: TocItem; pageStats: Record; - onPageClick: (dirName: string, fileName: string) => void; } -function ChapterSection({ chapter, pageStats, onPageClick }: ChapterSectionProps) { +function ChapterSection({ chapter, pageStats }: ChapterSectionProps) { const items = (chapter.items || []).filter((item) => !isTocItemIndex(item)); if (items.length === 0) { @@ -167,7 +160,7 @@ function ChapterSection({ chapter, pageStats, onPageClick }: ChapterSectionProps
{items.map((item) => { const pageId = buildPageId(item.dirName, item.fileName); - return ; + return ; })}
@@ -239,10 +232,6 @@ export function DocStatsView({ contentRef.current?.focus(); }, []); - const navigateToPage = (dirName: string, fileName: string) => { - documentationNavigation.navigateToPage({ dirName, fileName }); - }; - const totalStats = sumStats(Object.values(pageStats)); const tocPageIds = collectTocPageIds(toc); @@ -267,12 +256,7 @@ export function DocStatsView({
{toc.map((chapter, idx) => ( - + ))}
From e843ef16a7a57b6c465b688ac57a09aee95056bc Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Fri, 26 Dec 2025 16:38:08 -0500 Subject: [PATCH 10/10] add missing file --- .../src/utils/fetchWithCredentials.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 znai-reactjs/src/utils/fetchWithCredentials.ts diff --git a/znai-reactjs/src/utils/fetchWithCredentials.ts b/znai-reactjs/src/utils/fetchWithCredentials.ts new file mode 100644 index 000000000..8f0b5b80e --- /dev/null +++ b/znai-reactjs/src/utils/fetchWithCredentials.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2025 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getDocMeta } from "../structure/docMeta"; + +export function fetchWithCredentials(url: string, options?: RequestInit): Promise { + const includeContentType = getDocMeta().fetchIncludeContentType; + + const headers = + includeContentType && options?.body ? { "Content-Type": "application/json", ...options?.headers } : options?.headers; + + return fetch(url, { + ...options, + credentials: "include", + headers, + }); +}