From 7a953392964f883c3b4c92cab165557f091090d6 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Mon, 2 Jan 2023 10:18:47 +0900
Subject: [PATCH] enhance(client): user activity page

---
 locales/ja-JP.yml                             |   1 +
 packages/frontend/src/components/MkChart.vue  |  69 +++---
 .../frontend/src/components/MkHeatmap.vue     |  37 +--
 .../src/components/MkInstanceStats.vue        |  38 +--
 .../src/components/MkRetentionHeatmap.vue     |  37 +--
 packages/frontend/src/pages/admin/metrics.vue |  32 +--
 .../src/pages/admin/overview.active-users.vue |  37 +--
 .../src/pages/admin/overview.ap-requests.vue  |  37 +--
 .../frontend/src/pages/admin/overview.pie.vue |  38 +--
 .../src/pages/admin/overview.queue.chart.vue  |  36 +--
 .../src/pages/admin/queue.chart.chart.vue     |  36 +--
 .../src/pages/user/activity.heatmap.vue       | 217 ++++++++++++++++++
 .../frontend/src/pages/user/activity.pv.vue   | 201 ++++++++++++++++
 packages/frontend/src/pages/user/activity.vue |  29 +++
 .../src/pages/user/index.activity.vue         |   8 +-
 packages/frontend/src/pages/user/index.vue    |   6 +
 packages/frontend/src/scripts/init-chart.ts   |  44 ++++
 17 files changed, 564 insertions(+), 339 deletions(-)
 create mode 100644 packages/frontend/src/pages/user/activity.heatmap.vue
 create mode 100644 packages/frontend/src/pages/user/activity.pv.vue
 create mode 100644 packages/frontend/src/pages/user/activity.vue
 create mode 100644 packages/frontend/src/scripts/init-chart.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index a07fb5ff91..eb30eed53a 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -916,6 +916,7 @@ caption: "キャプション"
 loggedInAsBot: "Botアカウントでログイン中"
 tools: "ツール"
 cannotLoad: "読み込めません"
+numberOfProfileView: "プロフィール表示回数"
 
 _sensitiveMediaDetection:
   description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。"
diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue
index d99a5478e9..9ca7deaf80 100644
--- a/packages/frontend/src/components/MkChart.vue
+++ b/packages/frontend/src/components/MkChart.vue
@@ -14,26 +14,9 @@
   As this is part of Chart.js's API it makes sense to disable the check here.
 */
 import { onMounted, ref, watch, PropType, onUnmounted } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
+import { Chart } from 'chart.js';
 import 'chartjs-adapter-date-fns';
 import { enUS } from 'date-fns/locale';
-import zoomPlugin from 'chartjs-plugin-zoom';
 import gradient from 'chartjs-plugin-gradient';
 import * as os from '@/os';
 import { defaultStore } from '@/store';
@@ -41,6 +24,9 @@ import { useChartTooltip } from '@/scripts/use-chart-tooltip';
 import { chartVLine } from '@/scripts/chart-vline';
 import { alpha } from '@/scripts/color';
 import date from '@/filters/date';
+import { initChart } from '@/scripts/init-chart';
+
+initChart();
 
 const props = defineProps({
 	src: {
@@ -82,25 +68,6 @@ const props = defineProps({
 	},
 });
 
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-	zoomPlugin,
-	gradient,
-);
-
 const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
 const negate = arr => arr.map(x => -x);
 
@@ -742,6 +709,33 @@ const fetchPerUserNotesChart = async (): Promise<typeof chartData> => {
 	};
 };
 
+const fetchPerUserPvChart = async (): Promise<typeof chartData> => {
+	const raw = await os.apiGet('charts/user/pv', { userId: props.args.user.id, limit: props.limit, span: props.span });
+	return {
+		series: [{
+			name: 'Unique PV (user)',
+			type: 'area',
+			data: format(raw.upv.user),
+			color: colors.purple,
+		}, {
+			name: 'PV (user)',
+			type: 'area',
+			data: format(raw.pv.user),
+			color: colors.green,
+		}, {
+			name: 'Unique PV (visitor)',
+			type: 'area',
+			data: format(raw.upv.visitor),
+			color: colors.yellow,
+		}, {
+			name: 'PV (visitor)',
+			type: 'area',
+			data: format(raw.pv.visitor),
+			color: colors.blue,
+		}],
+	};
+};
+
 const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => {
 	const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span });
 	return {
@@ -814,6 +808,7 @@ const fetchAndRender = async () => {
 			case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true);
 
 			case 'per-user-notes': return fetchPerUserNotesChart();
+			case 'per-user-pv': return fetchPerUserPvChart();
 			case 'per-user-following': return fetchPerUserFollowingChart();
 			case 'per-user-followers': return fetchPerUserFollowersChart();
 			case 'per-user-drive': return fetchPerUserDriveChart();
diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue
index 078d0721da..5350928bfe 100644
--- a/packages/frontend/src/components/MkHeatmap.vue
+++ b/packages/frontend/src/components/MkHeatmap.vue
@@ -9,23 +9,7 @@
 
 <script lang="ts" setup>
 import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
+import { Chart } from 'chart.js';
 import { enUS } from 'date-fns/locale';
 import tinycolor from 'tinycolor2';
 import * as os from '@/os';
@@ -35,24 +19,9 @@ import { useChartTooltip } from '@/scripts/use-chart-tooltip';
 import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
 import { chartVLine } from '@/scripts/chart-vline';
 import { alpha } from '@/scripts/color';
+import { initChart } from '@/scripts/init-chart';
 
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-	MatrixController, MatrixElement,
-);
+initChart();
 
 const props = defineProps<{
 	src: string;
diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue
index 382aaf16ef..e576caf78a 100644
--- a/packages/frontend/src/components/MkInstanceStats.vue
+++ b/packages/frontend/src/components/MkInstanceStats.vue
@@ -77,24 +77,7 @@
 
 <script lang="ts" setup>
 import { onMounted } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-	DoughnutController,
-} from 'chart.js';
+import { Chart } from 'chart.js';
 import MkSelect from '@/components/form/select.vue';
 import MkChart from '@/components/MkChart.vue';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip';
@@ -103,24 +86,9 @@ import { i18n } from '@/i18n';
 import MkHeatmap from '@/components/MkHeatmap.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue';
+import { initChart } from '@/scripts/init-chart';
 
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	DoughnutController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-);
+initChart();
 
 const chartLimit = 500;
 let chartSpan = $ref<'hour' | 'day'>('hour');
diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue
index 547fe70a8c..b42c4f29a5 100644
--- a/packages/frontend/src/components/MkRetentionHeatmap.vue
+++ b/packages/frontend/src/components/MkRetentionHeatmap.vue
@@ -9,23 +9,7 @@
 
 <script lang="ts" setup>
 import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
+import { Chart } from 'chart.js';
 import { enUS } from 'date-fns/locale';
 import tinycolor from 'tinycolor2';
 import * as os from '@/os';
@@ -35,24 +19,9 @@ import { useChartTooltip } from '@/scripts/use-chart-tooltip';
 import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
 import { chartVLine } from '@/scripts/chart-vline';
 import { alpha } from '@/scripts/color';
+import { initChart } from '@/scripts/init-chart';
 
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-	MatrixController, MatrixElement,
-);
+initChart();
 
 const rootEl = $ref<HTMLDivElement>(null);
 const chartEl = $ref<HTMLCanvasElement>(null);
diff --git a/packages/frontend/src/pages/admin/metrics.vue b/packages/frontend/src/pages/admin/metrics.vue
index 6c4803fe0b..f32b52d30a 100644
--- a/packages/frontend/src/pages/admin/metrics.vue
+++ b/packages/frontend/src/pages/admin/metrics.vue
@@ -52,21 +52,7 @@
 
 <script lang="ts">
 import { defineComponent, markRaw } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-} from 'chart.js';
+import { Chart } from 'chart.js';
 import MkwFederation from '../../widgets/federation.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkSelect from '@/components/form/select.vue';
@@ -79,21 +65,9 @@ import number from '@/filters/number';
 import * as os from '@/os';
 import { stream } from '@/stream';
 import { alpha } from '@/scripts/color';
+import { initChart } from '@/scripts/init-chart';
 
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-);
+initChart();
 
 export default defineComponent({
 	components: {
diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue
index ea8c74f3a2..190635c754 100644
--- a/packages/frontend/src/pages/admin/overview.active-users.vue
+++ b/packages/frontend/src/pages/admin/overview.active-users.vue
@@ -9,23 +9,7 @@
 
 <script lang="ts" setup>
 import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
+import { Chart } from 'chart.js';
 import { enUS } from 'date-fns/locale';
 import tinycolor from 'tinycolor2';
 import * as os from '@/os';
@@ -35,24 +19,9 @@ import { useChartTooltip } from '@/scripts/use-chart-tooltip';
 import gradient from 'chartjs-plugin-gradient';
 import { chartVLine } from '@/scripts/chart-vline';
 import { alpha } from '@/scripts/color';
+import { initChart } from '@/scripts/init-chart';
 
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-	gradient,
-);
+initChart();
 
 const chartEl = $ref<HTMLCanvasElement>(null);
 const now = new Date();
diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue
index d15507564d..fa6a6f30d3 100644
--- a/packages/frontend/src/pages/admin/overview.ap-requests.vue
+++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue
@@ -16,23 +16,7 @@
 
 <script lang="ts" setup>
 import { onMounted, onUnmounted, ref } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
+import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import { enUS } from 'date-fns/locale';
 import tinycolor from 'tinycolor2';
@@ -45,24 +29,9 @@ import { useChartTooltip } from '@/scripts/use-chart-tooltip';
 import { chartVLine } from '@/scripts/chart-vline';
 import { defaultStore } from '@/store';
 import { alpha } from '@/scripts/color';
+import { initChart } from '@/scripts/init-chart';
 
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-	gradient,
-);
+initChart();
 
 const chartLimit = 50;
 const chartEl = $ref<HTMLCanvasElement>();
diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue
index 94509cf006..33ab6fe851 100644
--- a/packages/frontend/src/pages/admin/overview.pie.vue
+++ b/packages/frontend/src/pages/admin/overview.pie.vue
@@ -4,45 +4,13 @@
 
 <script lang="ts" setup>
 import { onMounted, onUnmounted, ref } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-	DoughnutController,
-} from 'chart.js';
+import { Chart } from 'chart.js';
 import number from '@/filters/number';
 import { defaultStore } from '@/store';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import { initChart } from '@/scripts/init-chart';
 
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	DoughnutController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-);
+initChart();
 
 const props = defineProps<{
 	data: { name: string; value: number; color: string; onClick?: () => void }[];
diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue
index 2552e0a6c3..bb51ffd68f 100644
--- a/packages/frontend/src/pages/admin/overview.queue.chart.vue
+++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue
@@ -4,46 +4,16 @@
 
 <script lang="ts" setup>
 import { watch, onMounted, onUnmounted, ref } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
+import { Chart } from 'chart.js';
 import number from '@/filters/number';
 import * as os from '@/os';
 import { defaultStore } from '@/store';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip';
 import { chartVLine } from '@/scripts/chart-vline';
 import { alpha } from '@/scripts/color';
+import { initChart } from '@/scripts/init-chart';
 
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-);
+initChart();
 
 const props = defineProps<{
 	type: string;
diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue
index b91689589d..f95cd1c872 100644
--- a/packages/frontend/src/pages/admin/queue.chart.chart.vue
+++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue
@@ -4,46 +4,16 @@
 
 <script lang="ts" setup>
 import { watch, onMounted, onUnmounted, ref } from 'vue';
-import {
-	Chart,
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-} from 'chart.js';
+import { Chart } from 'chart.js';
 import number from '@/filters/number';
 import * as os from '@/os';
 import { defaultStore } from '@/store';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip';
 import { chartVLine } from '@/scripts/chart-vline';
 import { alpha } from '@/scripts/color';
+import { initChart } from '@/scripts/init-chart';
 
-Chart.register(
-	ArcElement,
-	LineElement,
-	BarElement,
-	PointElement,
-	BarController,
-	LineController,
-	CategoryScale,
-	LinearScale,
-	TimeScale,
-	Legend,
-	Title,
-	Tooltip,
-	SubTitle,
-	Filler,
-);
+initChart();
 
 const props = defineProps<{
 	type: string;
diff --git a/packages/frontend/src/pages/user/activity.heatmap.vue b/packages/frontend/src/pages/user/activity.heatmap.vue
new file mode 100644
index 0000000000..86e3a0f4f9
--- /dev/null
+++ b/packages/frontend/src/pages/user/activity.heatmap.vue
@@ -0,0 +1,217 @@
+<template>
+<div ref="rootEl">
+	<MkLoading v-if="fetching"/>
+	<div v-else>
+		<canvas ref="chartEl"></canvas>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
+import { Chart } from 'chart.js';
+import { enUS } from 'date-fns/locale';
+import tinycolor from 'tinycolor2';
+import * as misskey from 'misskey-js';
+import * as os from '@/os';
+import 'chartjs-adapter-date-fns';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import { chartVLine } from '@/scripts/chart-vline';
+import { alpha } from '@/scripts/color';
+import { initChart } from '@/scripts/init-chart';
+
+initChart();
+
+const props = defineProps<{
+	src: string;
+	user: misskey.entities.User;
+}>();
+
+const rootEl = $ref<HTMLDivElement>(null);
+const chartEl = $ref<HTMLCanvasElement>(null);
+const now = new Date();
+let chartInstance: Chart = null;
+let fetching = $ref(true);
+
+const { handler: externalTooltipHandler } = useChartTooltip({
+	position: 'middle',
+});
+
+async function renderChart() {
+	if (chartInstance) {
+		chartInstance.destroy();
+	}
+
+	const wide = rootEl.offsetWidth > 700;
+	const narrow = rootEl.offsetWidth < 400;
+
+	const weeks = wide ? 50 : narrow ? 10 : 25;
+	const chartLimit = 7 * weeks;
+
+	const getDate = (ago: number) => {
+		const y = now.getFullYear();
+		const m = now.getMonth();
+		const d = now.getDate();
+
+		return new Date(y, m, d - ago);
+	};
+
+	const format = (arr) => {
+		return arr.map((v, i) => {
+			const dt = getDate(i);
+			const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`;
+			return {
+				x: iso,
+				y: dt.getDay(),
+				d: iso,
+				v,
+			};
+		});
+	};
+
+	let values;
+
+	if (props.src === 'notes') {
+		const raw = await os.api('charts/user/notes', { userId: props.user.id, limit: chartLimit, span: 'day' });
+		values = raw.inc;
+	}
+
+	fetching = false;
+
+	await nextTick();
+
+	const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+	// フォントカラー
+	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+	const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300';
+
+	// 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする
+	const max = values.slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3;
+
+	const min = Math.max(0, Math.min(...values) - 1);
+
+	const marginEachCell = 4;
+
+	chartInstance = new Chart(chartEl, {
+		type: 'matrix',
+		data: {
+			datasets: [{
+				label: '',
+				data: format(values),
+				pointRadius: 0,
+				borderWidth: 0,
+				borderJoinStyle: 'round',
+				borderRadius: 3,
+				backgroundColor(c) {
+					const value = c.dataset.data[c.dataIndex].v;
+					let a = (value - min) / max;
+					if (value !== 0) { // 0でない限りは完全に不可視にはしない
+						a = Math.max(a, 0.05);
+					}
+					return alpha(color, a);
+				},
+				fill: true,
+				width(c) {
+					const a = c.chart.chartArea ?? {};
+					return (a.right - a.left) / weeks - marginEachCell;
+				},
+				height(c) {
+					const a = c.chart.chartArea ?? {};
+					return (a.bottom - a.top) / 7 - marginEachCell;
+				},
+			}],
+		},
+		options: {
+			aspectRatio: wide ? 6 : narrow ? 1.8 : 3.2,
+			layout: {
+				padding: {
+					left: 8,
+					right: 0,
+					top: 0,
+					bottom: 0,
+				},
+			},
+			scales: {
+				x: {
+					type: 'time',
+					offset: true,
+					position: 'bottom',
+					time: {
+						unit: 'week',
+						round: 'week',
+						isoWeekday: 0,
+						displayFormats: {
+							week: 'MMM dd',
+						},
+					},
+					grid: {
+						display: false,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						display: true,
+						maxRotation: 0,
+						autoSkipPadding: 8,
+					},
+				},
+				y: {
+					offset: true,
+					reverse: true,
+					position: 'right',
+					grid: {
+						display: false,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						maxRotation: 0,
+						autoSkip: true,
+						padding: 1,
+						font: {
+							size: 9,
+						},
+						callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value],
+					},
+				},
+			},
+			animation: false,
+			plugins: {
+				legend: {
+					display: false,
+				},
+				tooltip: {
+					enabled: false,
+					callbacks: {
+						title(context) {
+							const v = context[0].dataset.data[context[0].dataIndex];
+							return v.d;
+						},
+						label(context) {
+							const v = context.dataset.data[context.dataIndex];
+							return [v.v];
+						},
+					},
+					//mode: 'index',
+					animation: {
+						duration: 0,
+					},
+					external: externalTooltipHandler,
+				},
+			},
+		},
+	});
+}
+
+watch(() => props.src, () => {
+	fetching = true;
+	renderChart();
+});
+
+onMounted(async () => {
+	renderChart();
+});
+</script>
diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue
new file mode 100644
index 0000000000..d709bc01b9
--- /dev/null
+++ b/packages/frontend/src/pages/user/activity.pv.vue
@@ -0,0 +1,201 @@
+<template>
+<div>
+	<MkLoading v-if="fetching"/>
+	<div v-show="!fetching" :class="$style.root" class="_panel">
+		<canvas ref="chartEl"></canvas>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import { Chart } from 'chart.js';
+import { enUS } from 'date-fns/locale';
+import tinycolor from 'tinycolor2';
+import * as misskey from 'misskey-js';
+import * as os from '@/os';
+import 'chartjs-adapter-date-fns';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import gradient from 'chartjs-plugin-gradient';
+import { chartVLine } from '@/scripts/chart-vline';
+import { alpha } from '@/scripts/color';
+import { initChart } from '@/scripts/init-chart';
+
+initChart();
+
+const props = defineProps<{
+	user: misskey.entities.User;
+}>();
+
+const chartEl = $ref<HTMLCanvasElement>(null);
+const now = new Date();
+let chartInstance: Chart = null;
+const chartLimit = 30;
+let fetching = $ref(true);
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+async function renderChart() {
+	if (chartInstance) {
+		chartInstance.destroy();
+	}
+
+	const getDate = (ago: number) => {
+		const y = now.getFullYear();
+		const m = now.getMonth();
+		const d = now.getDate();
+
+		return new Date(y, m, d - ago);
+	};
+
+	const format = (arr) => {
+		return arr.map((v, i) => ({
+			x: getDate(i).getTime(),
+			y: v,
+		}));
+	};
+
+	const raw = await os.api('charts/user/pv', { userId: props.user.id, limit: chartLimit, span: 'day' });
+
+	const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+	const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+
+	// フォントカラー
+	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+	const colorUser = '#3498db';
+	const colorVisitor = '#2ecc71';
+
+	chartInstance = new Chart(chartEl, {
+		type: 'bar',
+		data: {
+			datasets: [{
+				parsing: false,
+				label: 'UPV (user)',
+				data: format(raw.upv.user).slice().reverse(),
+				pointRadius: 0,
+				borderWidth: 0,
+				borderJoinStyle: 'round',
+				borderRadius: 4,
+				backgroundColor: colorUser,
+				barPercentage: 0.7,
+				categoryPercentage: 1,
+				fill: true,
+			}, {
+				parsing: false,
+				label: 'UPV (visitor)',
+				data: format(raw.upv.visitor).slice().reverse(),
+				pointRadius: 0,
+				borderWidth: 0,
+				borderJoinStyle: 'round',
+				borderRadius: 4,
+				backgroundColor: colorVisitor,
+				barPercentage: 0.7,
+				categoryPercentage: 1,
+				fill: true,
+			}],
+		},
+		options: {
+			aspectRatio: 2.5,
+			layout: {
+				padding: {
+					left: 0,
+					right: 8,
+					top: 0,
+					bottom: 0,
+				},
+			},
+			scales: {
+				x: {
+					type: 'time',
+					offset: true,
+					stacked: true,
+					time: {
+						stepSize: 1,
+						unit: 'day',
+					},
+					grid: {
+						display: false,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						display: true,
+						maxRotation: 0,
+						autoSkipPadding: 8,
+					},
+					adapters: {
+						date: {
+							locale: enUS,
+						},
+					},
+				},
+				y: {
+					position: 'left',
+					stacked: true,
+					suggestedMax: 10,
+					grid: {
+						display: true,
+						color: gridColor,
+						borderColor: 'rgb(0, 0, 0, 0)',
+					},
+					ticks: {
+						display: true,
+						//mirror: true,
+					},
+				},
+			},
+			interaction: {
+				intersect: false,
+				mode: 'index',
+			},
+			animation: false,
+			plugins: {
+				title: {
+					display: true,
+					text: 'Unique PV',
+					padding: {
+						left: 0,
+						right: 0,
+						top: 0,
+						bottom: 12,
+					},
+				},
+				legend: {
+					display: true,
+					position: 'bottom',
+					padding: {
+						left: 0,
+						right: 0,
+						top: 8,
+						bottom: 0,
+					},
+				},
+				tooltip: {
+					enabled: false,
+					mode: 'index',
+					animation: {
+						duration: 0,
+					},
+					external: externalTooltipHandler,
+				},
+				gradient,
+			},
+		},
+		plugins: [chartVLine(vLineColor)],
+	});
+
+	fetching = false;
+}
+
+onMounted(async () => {
+	renderChart();
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	padding: 20px;
+}
+</style>
diff --git a/packages/frontend/src/pages/user/activity.vue b/packages/frontend/src/pages/user/activity.vue
new file mode 100644
index 0000000000..f9dce3a9e8
--- /dev/null
+++ b/packages/frontend/src/pages/user/activity.vue
@@ -0,0 +1,29 @@
+<template>
+<MkSpacer :content-max="700">
+	<MkFolder class="item">
+		<template #header>Heatmap</template>
+		<XHeatmap :user="user" :src="'notes'"/>
+	</MkFolder>
+	<MkFolder class="item">
+		<template #header>PV</template>
+		<XPv :user="user"/>
+	</MkFolder>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as misskey from 'misskey-js';
+import XHeatmap from './activity.heatmap.vue';
+import XPv from './activity.pv.vue';
+import MkFolder from '@/components/MkFolder.vue';
+
+const props = defineProps<{
+	user: misskey.entities.User;
+}>();
+
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue
index 523072d2e6..0cc1524663 100644
--- a/packages/frontend/src/pages/user/index.activity.vue
+++ b/packages/frontend/src/pages/user/index.activity.vue
@@ -33,10 +33,16 @@ let chartSrc = $ref('per-user-notes');
 function showMenu(ev: MouseEvent) {
 	os.popupMenu([{
 		text: i18n.ts.notes,
-		active: true,
+		active: chartSrc === 'per-user-notes',
 		action: () => {
 			chartSrc = 'per-user-notes';
 		},
+	}, {
+		text: i18n.ts.numberOfProfileView,
+		active: chartSrc === 'per-user-pv',
+		action: () => {
+			chartSrc = 'per-user-pv';
+		},
 	}, /*, {
 		text: i18n.ts.following,
 		action: () => {
diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue
index f40cd0b8d6..b60cef3729 100644
--- a/packages/frontend/src/pages/user/index.vue
+++ b/packages/frontend/src/pages/user/index.vue
@@ -5,6 +5,7 @@
 		<Transition name="fade" mode="out-in">
 			<div v-if="user">
 				<XHome v-if="tab === 'home'" :user="user"/>
+				<XActivity v-else-if="tab === 'activity'" :user="user"/>
 				<XReactions v-else-if="tab === 'reactions'" :user="user"/>
 				<XClips v-else-if="tab === 'clips'" :user="user"/>
 				<XPages v-else-if="tab === 'pages'" :user="user"/>
@@ -32,6 +33,7 @@ import { i18n } from '@/i18n';
 import { $i } from '@/account';
 
 const XHome = defineAsyncComponent(() => import('./home.vue'));
+const XActivity = defineAsyncComponent(() => import('./activity.vue'));
 const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
 const XClips = defineAsyncComponent(() => import('./clips.vue'));
 const XPages = defineAsyncComponent(() => import('./pages.vue'));
@@ -70,6 +72,10 @@ const headerTabs = $computed(() => user ? [{
 	key: 'home',
 	title: i18n.ts.overview,
 	icon: 'ti ti-home',
+}, {
+	key: 'activity',
+	title: i18n.ts.activity,
+	icon: 'ti ti-chart-line',
 }, ...($i && ($i.id === user.id)) || user.publicReactions ? [{
 	key: 'reactions',
 	title: i18n.ts.reaction,
diff --git a/packages/frontend/src/scripts/init-chart.ts b/packages/frontend/src/scripts/init-chart.ts
new file mode 100644
index 0000000000..32f887f2e7
--- /dev/null
+++ b/packages/frontend/src/scripts/init-chart.ts
@@ -0,0 +1,44 @@
+import {
+	Chart,
+	ArcElement,
+	LineElement,
+	BarElement,
+	PointElement,
+	BarController,
+	LineController,
+	DoughnutController,
+	CategoryScale,
+	LinearScale,
+	TimeScale,
+	Legend,
+	Title,
+	Tooltip,
+	SubTitle,
+	Filler,
+} from 'chart.js';
+import gradient from 'chartjs-plugin-gradient';
+import zoomPlugin from 'chartjs-plugin-zoom';
+import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
+
+export function initChart() {
+	Chart.register(
+		ArcElement,
+		LineElement,
+		BarElement,
+		PointElement,
+		BarController,
+		LineController,
+		DoughnutController,
+		CategoryScale,
+		LinearScale,
+		TimeScale,
+		Legend,
+		Title,
+		Tooltip,
+		SubTitle,
+		Filler,
+		MatrixController, MatrixElement,
+		zoomPlugin,
+		gradient,
+	);
+}
-- 
GitLab