見出し画像

note運営を“感覚”から“見える化”へ|GitHub Pagesでダッシュボードを作る|データ分析|#286

前にワイは、noteのPVとかスキ数をGitHub Actionsで自動収集する方法を記事にした。

で、今回はその次や。

せっかくCSVが毎日たまっていくようになっても、
見にくかったら、たぶん人はそんなに見返さん。

これ、割とある笑

なので今回は、前に集めたnoteのデータを
ブラウザで見やすく可視化する方法をまとめる。

やることは、そこまで重くない。

  • HTMLファイルを1個置く

  • 設定をほぼ1行だけ変える

  • GitHub Pagesで開く

これで、PV推移とかランキングとか資産記事っぽいやつまで、
ブラウザで見られるようになる。


作成したダッシュボード:PC向け

ダッシュボード画面-1
ダッシュボード画面-2
ダッシュボード画面-3

ほな、いくで!




この記事でできること

このダッシュボードを入れると、こんな感じのものが見られるようになる。

  • 累計PV・スキ・記事数・フォロワー数

  • ビューとスキの日次推移

  • Viewランキング / スキランキング

  • スキ率のベスト / ワースト

  • PV × スキ率の4象限マップ

  • 資産記事の分析

  • 直近7日間のトレンド動態

要するに、

「なんとなく最近伸びてる気がする」

「これが伸びてる」「これは埋もれてる」「これは地味に残ってる」

に変えるやつや。

ただ数字を並べるだけやなくて、
運営ログを“見たくなる画面”にするのが今回の目的やな。


先に大事なことだけ言っとく

このHTMLは、GitHub上にあるCSVをブラウザから直接読みにいく作りになってる。

せやから、この記事のやり方をそのまま使うなら、基本は公開リポジトリ前提で考えてな。

つまり、

  • 自分だけで裏側のデータを貯めたい

  • でも見せる用の画面も作りたい

この2つを両立したいなら、

収集はPrivate
公開用の整形データやダッシュボードはPublic

みたいに分けるのがわりと相性ええ。

前回の有料記事でも、
「集計はPrivateで回して、見せるCSVだけPublicに出す」
みたいな運用はありやで、って話をしたけど、今回はまさにその“見る側”や。


これ、誰向けなん?

こんな人向けや。

  • note運営を感覚じゃなく、ログで見たい人

  • 前回の収集記事を入れたあと、次に何をすればええか探してる人

  • せっかくCSVを貯めてるのに、正直ちゃんと見返せてない人

  • 自分の運営データを、ちょっとカッコよく見たい人

  • 「分析っていうより、まずは見やすくしたい」人

要するに、
集めたデータを“ちゃんと使える状態”にしたい人向けやな。


はじめる前に確認しておくこと

必要なのは主にこの2つ。

① GitHubアカウント

これは無料でOK。

② CSVが入ったリポジトリ

このHTMLは、GitHub上のCSVを読みにいって動く。
せやから、元になるデータが必要や。

最低でも、このへんがある前提で考えてな。

  • daily_summary.csv

  • articles.csv

  • followers.csv

  • trend_analysis.csv

  • asset_articles.csv

ここ、地味に大事や。

前回の有料記事をそのまま入れた人は、
収集用のCSVはできてても、分析用のCSVがまだ出てないことがある。

その場合は、analyze.py を回すステップも追加してな。

つまり今回は、

  • 前回の記事 → 集める土台

  • 今回の記事 → 見る画面

って役割分担やと思ってもらえればOKや。

⚠️ GitHub Pagesは、GitHub Free では公開(Public)リポジトリで利用する前提になります。

GitHub Pro 以上では非公開(Private)リポジトリでもPagesを使えます。

ただし、今回のダッシュボードはブラウザからCSVを直接読みにいく実装なので、このままの構成で使う場合は、CSVが外から読める状態になっている必要があります。

「収集はPrivate、公開用の整形データだけPublicに出す」みたいに分ける運用が相性ええです。

全体の構成はこんな感じ

まず先に、完成形の構成を見せるで。

フォルダ構成画像

イメージとしてはこんな感じや。

your-repo/
├── data/
│   ├── articles.csv
│   ├── asset_articles.csv
│   ├── daily_summary.csv
│   ├── followers.csv
│   ├── period_ranking.csv
│   └── trend_analysis.csv
└── docs/
    └── index.html

ポイントはシンプルで、

  • data/ にCSVがある

  • docs/ にHTMLがある

  • GitHub Pagesで docs/ を公開する

これだけや。


Step 1 | HTMLファイルを入手する

この記事の末尾に index.html のコード全文を掲載している。コピーして、ファイル名を index.html として保存してほしい。

コードの量が多くて戸惑うかもしれないが、触る箇所はStep 3で説明する2か所だけ。

今はまるごとコピーするだけでOK。


Step 2|テキストエディタで開く

次に、そのHTMLファイルをテキストエディタで開く。

「テキストエディタって何?」って人も心配いらん。

  • Windows → メモ帳

  • Mac → テキストエディット

で十分や。

開くと、英語とか記号がずらっと並んでて「うわ、無理そう」って一瞬なるかもしれん。
でも安心してええ。

触るのは次のStep 3の1か所だけや。


Step 3 | CONFIGを書き換える

Ctrl+F(Macは Command+F)でファイル内検索を開き、CONFIG を探してみて。

すると、こんな箇所が見つかるはずや。

const CONFIG = {
  repo: 'YOUR_GITHUB_USERNAME/YOUR_REPO_NAME',
  dataPath: 'data',
  excludeTitles: [],
};

基本的に変えるのは repo: の1行だけ

変更前:

repo: 'YOUR_GITHUB_USERNAME/YOUR_REPO_NAME',

変更後(例):

repo: 'yamada-taro/note-stats-public',

GitHubのリポジトリページを開いたときに上部に表示される「ユーザー名/リポジトリ名」をそのまま入れればOK。

前後のシングルクォート(')と末尾のカンマ(,)は消さないこと。

その他の設定(必要な場合のみ)

dataPath と excludeTitles は通常そのままで問題ないが、必要に応じて変更できるようにしておいた。

特定の記事をランキングから除外したい場合は、こんな感じで追記する。

excludeTitles: ['テスト投稿', '下書き確認用'],

設定が正しくできているか確認する方法

設定を書き換えずにブラウザで開くと、こんな案内画面が表示される。

⚙️ 設定が必要です

この画面が出なくなればOKってことや。


Step 3.5|前回の記事を入れてる人へ

前回の有料記事をベースにしてる人は、ここだけ追加で見てな。

今回のHTMLは、

  • trend_analysis.csv

  • asset_articles.csv

も読みにいく。

せやから、前回の収集だけで止まってると、
画面の一部が表示されないことがある。

その場合は、GitHub Actionsに analyze.py を回す処理を追加する。

たとえばこんな感じ。

- name: Run Analyze Script
  run: python scripts/analyze.py --articles data/articles.csv --out data/

これを足すと、

収集 → 集計 → 可視化

までつながる。

ここまでくると、
前回の記事と今回の記事がちゃんとセットで効いてくるで。


Step 4|ファイルを保存する

書き換えが終わったら保存。

  • Windows → Ctrl + S

  • Mac → Command + S

以上。
ここはほんまに普通に保存でええ。


Step 5|GitHubにアップロードする

保存した index.html を、GitHubの docs/ フォルダにアップロードする。

流れはこんな感じ。

  1. GitHubでリポジトリを開く

  2. docs フォルダを開く

  3. Add file → Upload files

  4. index.html をアップロード

  5. Commit changes

もし docs フォルダがまだ無いなら、
docs/index.html って名前で新規作成すれば、フォルダごと作れる。


Step 6|GitHub Pagesを有効にする

次に、GitHub Pagesをオンにする。

やることはこんな感じ。

  1. リポジトリの Settings を開く

  2. 左側の Pages を開く

  3. Source を Deploy from a branch にする

  4. ブランチを main、フォルダを /docs にする

  5. Save

これでしばらくすると、URLが発行される。

だいたいこういう形や。

https://あなたのユーザー名.github.io/リポジトリ名/

このURLをブラウザで開いて、ダッシュボードが出たら完了や。
おつかれさまです🎉


うまくいかんときの確認ポイント

① 「⚙️ 設定が必要です」と出る

だいたい repo: の設定ミスや。

  • 書き換え忘れてないか

  • ユーザー名/リポジトリ名 の形になってるか

  • ' を消してないか

このへんを見直してな。


② 数字が — のまま

この場合は、だいたい次のどれかや。

  • repo名のスペルミス

  • リポジトリが公開状態じゃない

  • data/ フォルダにCSVが無い

  • trend_analysis.csv と asset_articles.csv がまだ生成されてない

特に最後の2つは、わりと見落としやすい。


③ URLを開いたら404になる

これは少し待てば直ることもある。

GitHub Pagesって、設定直後は反映に少し時間がかかることがあるから、
5〜10分くらい置いてからもう一回見ると出る場合もある。


まとめ

今回やったことを、雑にまとめるとこうや。

  1. index.html の repo: を自分用に変える

  2. docs/ に置く

  3. GitHub Pagesを有効にする

  4. 必要なら analyze.py も回す

これで、前回集めたnoteのログを
ブラウザでかなり見やすく扱えるようになる。

前回の記事が
「データを貯める仕組み」
だとしたら、

今回の記事は「そのデータをちゃんと眺める画面」や。

ここまでつながると、note運営がちょっと変わる。

「なんとなく伸びた気がする」が、
「これが伸びてる」「これは埋もれてる」「これは資産っぽい」
に変わるからや。

こういうのって派手ではない。
でも、一回見えるようになると、じわじわ効いてくる。

割とマジで。


おわりに

このツールは、個人開発の「作ってみた」作品や。

せやから、手厚いサポート窓口とか、
何でも即レスで直しますみたいな感じではない。
そこは先に正直に言っとく。

ただ、今の時代ここはそんなに絶望せんでもええ。

動かんときは、

  • エラー文

  • この記事

  • コード

このへんをそのままChatGPTとかClaudeとかGeminiに投げると、
下手したら人に聞くより早いことも普通にある笑

もしこの記事が
「ちょっと参考になった」
「これ、やってみたい」
と思ったら、スキとかコメントもらえるとうれしい。

まずはCSVを自動で貯めるところからやりたい人は、前回の有料記事から入るとスムーズやで。

で、ここまで実装できたら、あとはHTMLを自分好みにいじるのもおもろい。

こんな感じで、見た目を遊ぶ方向にも広げられる。


もしこの記事が「おもろかった」「ちょっと参考になった」と思ったら、スキ・フォロー・コメントで反応もらえるとうれしい。

ワイ、スキとコメント、かなり好物です笑

ほな、また。


補足

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>note / stats</title>
<script src="/https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<link href="/https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=JetBrains+Mono:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
:root {
  --bg:        #06080f;
  --bg2:       #0b0e1a;
  --surface:   #0f1221;
  --surface2:  #141729;
  --border:    rgba(255,255,255,0.07);
  --border2:   rgba(255,255,255,0.12);

  --cyan:      #00e5ff;
  --cyan-dim:  rgba(0,229,255,0.12);
  --cyan-glow: rgba(0,229,255,0.25);
  --pink:      #ff4d8d;
  --pink-dim:  rgba(255,77,141,0.12);
  --gold:      #ffc947;
  --gold-dim:  rgba(255,201,71,0.12);
  --violet:    #9d6fff;
  --violet-dim:rgba(157,111,255,0.12);
  --green:     #00e5a0;
  --red:       #ff4d6a;

  --text:      #f0f4ff;
  --text2:     #b0bcd8;
  --text3:     #6b7599;

  --mono: 'JetBrains Mono', monospace;
  --sans: 'Syne', sans-serif;

  --r: 6px;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--mono);
  min-height: 100vh;
  overflow-x: hidden;
}

/* ─── NOISE OVERLAY ─── */
body::before {
  content: '';
  position: fixed; inset: 0;
  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
  pointer-events: none; z-index: 999;
}

/* ─── SCROLLBAR ─── */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }

/* ─── HEADER ─── */
header {
  position: sticky; top: 0; z-index: 100;
  background: rgba(6,8,15,0.92);
  backdrop-filter: blur(20px);
  border-bottom: 1px solid var(--border);
  padding: 0 32px;
  height: 56px;
  display: flex; align-items: center; justify-content: space-between;
}

.logo {
  font-family: var(--sans);
  font-size: 15px;
  font-weight: 800;
  letter-spacing: 0.05em;
  display: flex; align-items: center; gap: 10px;
}
.logo-dot {
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--cyan);
  box-shadow: 0 0 12px var(--cyan);
  animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
  0%,100% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.5; transform: scale(0.8); }
}
.logo-text { color: var(--text); }
.logo-slash { color: var(--text3); margin: 0 2px; }
.logo-sub { color: var(--cyan); }

.header-right {
  display: flex; align-items: center; gap: 24px;
}
#last-updated {
  font-size: 12px;
  color: var(--text2);
  letter-spacing: 0.1em;
}
.header-badge {
  font-size: 9px;
  padding: 3px 8px;
  border: 1px solid var(--border2);
  border-radius: 2px;
  color: var(--text2);
  letter-spacing: 0.15em;
  text-transform: uppercase;
}

/* ─── LAYOUT ─── */
.dashboard {
  padding: 0;
  max-width: 1600px;
  margin: 0 auto;
}

/* ─── STATUS BAND (3KPI) ─── */
.status-band {
  border-bottom: 1px solid var(--border);
  padding: 28px 32px;
  display: grid;
  grid-template-columns: 1fr 1px 1fr 1px 1fr 2px 1fr;
  align-items: center;
  gap: 0;
  animation: fadeUp 0.6s ease both;
}
.band-divider {
  width: 1px;
  height: 40px;
  background: var(--border2);
  justify-self: center;
}
.band-divider.thick {
  width: 2px;
  background: var(--border2);
  height: 60px;
}

.kpi3-item { padding: 0 28px; }
.kpi3-label {
  font-size: 15px;
  color: var(--text2);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  margin-bottom: 10px;
}
.kpi3-row {
  display: flex; align-items: baseline; gap: 10px;
}
.kpi3-val {
  font-family: var(--sans);
  font-size: 34px;
  font-weight: 700;
  color: var(--text);
  line-height: 1;
}
.kpi3-arrow {
  font-size: 20px;
  line-height: 1;
  font-family: var(--mono);
}
.kpi3-arrow.up   { color: var(--green); }
.kpi3-arrow.down { color: var(--red); }
.kpi3-arrow.flat { color: var(--text3); }
.kpi3-sub {
  font-size: 12px;
  color: var(--text2);
  margin-top: 5px;
}

.status-memo-wrap { padding: 0 28px; }
.status-memo-label {
  font-size: 18px;
  color: var(--text2);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  margin-bottom: 10px;
}
#status-memo-text {
  font-family: var(--sans);
  font-size: 15px;
  font-weight: 600;
  color: var(--cyan);
  line-height: 1.5;
  min-height: 40px;
}

/* ─── KPI STRIP ─── */
.kpi-strip {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  border-bottom: 1px solid var(--border);
  animation: fadeUp 0.6s 0.1s ease both;
}
.kpi {
  padding: 24px 32px;
  border-right: 1px solid var(--border);
  position: relative;
  overflow: hidden;
  transition: background 0.2s;
}
.kpi:last-child { border-right: none; }
.kpi::after {
  content: '';
  position: absolute;
  bottom: 0; left: 32px; right: 32px;
  height: 2px;
  border-radius: 1px;
  opacity: 0;
  transition: opacity 0.3s;
}
.kpi:nth-child(1)::after { background: var(--cyan); }
.kpi:nth-child(2)::after { background: var(--pink); }
.kpi:nth-child(3)::after { background: var(--gold); }
.kpi:nth-child(4)::after { background: var(--violet); }
.kpi:hover::after { opacity: 1; }
.kpi:hover { background: var(--surface); }

.kpi-label {
  font-size: 18px;
  color: var(--text2);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  margin-bottom: 10px;
  display: flex; align-items: center; gap: 6px;
}
.kpi-label::before {
  content: '';
  width: 5px; height: 5px; border-radius: 50%;
}
.kpi:nth-child(1) .kpi-label::before { background: var(--cyan); box-shadow: 0 0 6px var(--cyan); }
.kpi:nth-child(2) .kpi-label::before { background: var(--pink); box-shadow: 0 0 6px var(--pink); }
.kpi:nth-child(3) .kpi-label::before { background: var(--gold); box-shadow: 0 0 6px var(--gold); }
.kpi:nth-child(4) .kpi-label::before { background: var(--violet); box-shadow: 0 0 6px var(--violet); }

.kpi-value {
  font-family: var(--sans);
  font-size: 48px;
  font-weight: 800;
  line-height: 1;
  letter-spacing: -0.02em;
}
.kpi-value.v { color: var(--cyan); }
.kpi-value.l { color: var(--pink); }
.kpi-value.a { color: var(--gold); }
.kpi-value.f { color: var(--violet); }

.kpi-diff {
  margin-top: 8px;
  font-size: 12px;
  color: var(--text3);
  display: flex; align-items: center; gap: 4px;
}
.kpi-diff.up   { color: var(--green); }
.kpi-diff.down { color: var(--red); }

/* ─── GRID ─── */
.grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
}

.panel {
  padding: 28px 32px;
  border-bottom: 1px solid var(--border);
  border-right: 1px solid var(--border);
  position: relative;
  animation: fadeUp 0.5s ease both;
}
.panel:nth-child(2n) { border-right: none; }
.panel.full {
  grid-column: 1 / -1;
  border-right: none;
}
.panel.third {
  grid-column: span 1;
}

.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 22px;
}
.panel-title {
  font-size: 18px;
  color: var(--text2);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  display: flex; align-items: center; gap: 8px;
}
.panel-title-dot {
  width: 4px; height: 4px; border-radius: 50%;
  background: var(--cyan);
  flex-shrink: 0;
}
.panel-badge {
  font-size: 10px;
  padding: 2px 8px;
  border: 1px solid var(--border2);
  border-radius: 2px;
  color: var(--text3);
  letter-spacing: 0.1em;
}

.chart-wrap { position: relative; height: 280px; }
.chart-wrap.tall { height: 360px; }

/* ─── RANKING ─── */
.ranking-list {
  display: flex; flex-direction: column; gap: 4px;
  max-height: 380px; overflow-y: auto;
  padding-right: 4px;
}

.rank-item {
  display: grid;
  grid-template-columns: 28px 1fr 110px;
  align-items: center;
  gap: 10px;
  padding: 9px 12px;
  border-radius: var(--r);
  border: 1px solid transparent;
  background: var(--surface);
  transition: all 0.15s ease;
  cursor: default;
}
.rank-item:hover {
  background: var(--surface2);
  border-color: var(--border2);
  transform: translateX(2px);
}

.rank-num {
  font-size: 11px;
  color: var(--text3);
  text-align: center;
  font-weight: 600;
}
.rank-num.gold   { color: var(--gold); }
.rank-num.silver { color: #a8b0c8; }
.rank-num.bronze { color: #c87850; }

.rank-title {
  font-size: 16px;
  color: var(--text2);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: color 0.15s;
}
.rank-item:hover .rank-title { color: var(--text); }

.rank-right {
  display: flex; align-items: center; gap: 8px;
}
.rank-bar {
  flex: 1; height: 2px;
  background: var(--border2);
  border-radius: 1px;
  overflow: hidden;
}
.rank-bar-fill {
  height: 100%;
  border-radius: 1px;
  transition: width 1s cubic-bezier(0.16,1,0.3,1);
}
.rank-val {
  font-size: 12px;
  min-width: 44px;
  text-align: right;
  font-weight: 600;
}
.view-bar  { background: var(--cyan); }
.view-val  { color: var(--cyan); }
.like-bar  { background: var(--pink); }
.like-val  { color: var(--pink); }
.worst-bar { background: var(--red); }
.worst-val { color: var(--red); }

/* ─── TABLE ─── */
.table-wrap {
  overflow-x: auto;
  border: 1px solid var(--border);
  border-radius: var(--r);
}
.data-table {
  width: 100%;
  border-collapse: separate;
  border-spacing: 0;
  font-size: 12px;
}

.data-table th {
  padding: 10px 14px;
  background: var(--surface);
  color: var(--text2);
  text-align: left;
  font-size: 12px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  border-bottom: 1px solid var(--border2);
  white-space: nowrap;
  position: sticky; top: 0; z-index: 5;
}

.data-table td {
  padding: 10px 14px;
  border-bottom: 1px solid var(--border);
  color: var(--text2);
  font-size: 14px;
  transition: background 0.15s;
}

.status-badge {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 3px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.05em;
  white-space: nowrap;
}
.badge-hot  { background: rgba(255,77,106,0.15); color: #ff4d6a; border: 1px solid rgba(255,77,106,0.3); }
.badge-grow { background: rgba(0,229,160,0.12); color: var(--green); border: 1px solid rgba(0,229,160,0.25); }
.badge-idle { background: rgba(61,68,102,0.3); color: var(--text3); border: 1px solid var(--border); }

.bar-cell-wrap {
  display: flex; align-items: center; gap: 8px;
}
.inline-bar {
  width: 80px; height: 5px;
  background: var(--border2);
  border-radius: 3px;
  overflow: hidden;
  flex-shrink: 0;
}
.inline-bar-fill {
  height: 100%;
  background: var(--cyan);
  border-radius: 3px;
  opacity: 1;
}
.num-right { text-align: right; font-weight: 600; color: var(--text); }
.num-accent { color: var(--cyan); }
.num-muted  { color: var(--text3); }

/* ─── TWO-COL WORST ─── */
.worst-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 32px;
}
.worst-section-title {
  font-size: 15px;
  color: var(--text3);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  margin-bottom: 12px;
  display: flex; align-items: center; gap: 6px;
}
.worst-section-title::before {
  content: '';
  width: 4px; height: 4px; border-radius: 50%;
  background: var(--red);
}
.best-section-title {
  font-size: 15px;
  color: var(--text3);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  margin-bottom: 12px;
  display: flex; align-items: center; gap: 6px;
}
.best-section-title::before {
  content: '';
  width: 4px; height: 4px; border-radius: 50%;
  background: var(--green);
}
.best-bar { background: var(--green); }
.best-val { color: var(--green); }

/* ─── LOADING ─── */
.loading {
  display: flex; align-items: center; justify-content: center;
  height: 160px;
  font-size: 10px;
  color: var(--text3);
  letter-spacing: 0.1em;
}
.loading::after {
  content: '...';
  animation: dots 1.2s steps(4, end) infinite;
}
@keyframes dots {
  0%,20% { content: ''; }
  40% { content: '.'; }
  60% { content: '..'; }
  80%,100% { content: '...'; }
}

/* ─── ANIMATIONS ─── */
@keyframes fadeUp {
  from { opacity: 0; transform: translateY(16px); }
  to   { opacity: 1; transform: translateY(0); }
}
.panel:nth-child(1)  { animation-delay: 0.05s; }
.panel:nth-child(2)  { animation-delay: 0.10s; }
.panel:nth-child(3)  { animation-delay: 0.15s; }
.panel:nth-child(4)  { animation-delay: 0.20s; }
.panel:nth-child(5)  { animation-delay: 0.25s; }
.panel:nth-child(6)  { animation-delay: 0.30s; }
.panel:nth-child(7)  { animation-delay: 0.35s; }
.panel:nth-child(8)  { animation-delay: 0.40s; }

/* ─── RESPONSIVE ─── */
@media (max-width: 900px) {
  .kpi-strip { grid-template-columns: repeat(2,1fr); }
  .grid { grid-template-columns: 1fr; }
  .panel { border-right: none !important; }
  .status-band { grid-template-columns: 1fr; gap: 16px; }
  .band-divider { display: none; }
  .worst-grid { grid-template-columns: 1fr; }
  header, .kpi, .panel, .kpi3-item, .status-memo-wrap { padding-left: 16px; padding-right: 16px; }
}
</style>
</head>
<body>

<!-- ═══ HEADER ═══ -->
<header>
  <div class="logo">
    <div class="logo-dot"></div>
    <span class="logo-text">note</span>
    <span class="logo-slash">/</span>
    <span class="logo-sub">stats</span>
  </div>
  <div class="header-right">
    <div id="last-updated">—</div>
    <div class="header-badge">live</div>
  </div>
</header>

<div class="dashboard">

  <!-- ═══ STATUS BAND (3KPI + memo) ═══ -->
  <div class="status-band">
    <div class="kpi3-item">
      <div class="kpi3-label">Reach Power|リーチ力</div>
      <div class="kpi3-row">
        <span class="kpi3-val" id="k3-reach">—</span>
        <span class="kpi3-arrow" id="k3-reach-arrow"></span>
      </div>
      <div class="kpi3-sub">PV ÷ 記事数</div>
    </div>
    <div class="band-divider"></div>

    <div class="kpi3-item">
      <div class="kpi3-label">Action Power|アクション力</div>
      <div class="kpi3-row">
        <span class="kpi3-val" id="k3-action">—</span>
        <span class="kpi3-arrow" id="k3-action-arrow"></span>
      </div>
      <div class="kpi3-sub">スキ ÷ 記事数</div>
    </div>
    <div class="band-divider"></div>

    <div class="kpi3-item">
      <div class="kpi3-label">η スキ率</div>
      <div class="kpi3-row">
        <span class="kpi3-val" id="k3-eta">—</span>
        <span class="kpi3-arrow" id="k3-eta-arrow"></span>
      </div>
      <div class="kpi3-sub">スキ ÷ PV × 100</div>
    </div>
    <div class="band-divider thick"></div>

    <div class="status-memo-wrap">
      <div class="status-memo-label">📋 Status Memo|自動診断</div>
      <div id="status-memo-text">データ読み込み中...</div>
    </div>
  </div>

  <!-- ═══ KPI STRIP ═══ -->
  <div class="kpi-strip">
    <div class="kpi">
      <div class="kpi-label">Total Views</div>
      <div class="kpi-value v" id="kpi-views">—</div>
      <div class="kpi-diff" id="kpi-views-diff">前日比</div>
    </div>
    <div class="kpi">
      <div class="kpi-label">Total Likes</div>
      <div class="kpi-value l" id="kpi-likes">—</div>
      <div class="kpi-diff" id="kpi-likes-diff">前日比</div>
    </div>
    <div class="kpi">
      <div class="kpi-label">Articles</div>
      <div class="kpi-value a" id="kpi-articles">—</div>
      <div class="kpi-diff">公開記事数</div>
    </div>
    <div class="kpi">
      <div class="kpi-label">Followers</div>
      <div class="kpi-value f" id="kpi-followers">—</div>
      <div class="kpi-diff">フォロワー数</div>
    </div>
  </div>

  <!-- ═══ PANELS ═══ -->
  <div class="grid">

    <!-- 日次推移 -->
    <div class="panel">
      <div class="panel-header">
        <div class="panel-title">
          <span class="panel-title-dot" style="background:var(--cyan)"></span>
          ビュー / スキ 日次推移
        </div>
        <span class="panel-badge">60D</span>
      </div>
      <div class="chart-wrap"><canvas id="chart-daily"></canvas></div>
    </div>

    <!-- ビューランキング -->
    <div class="panel">
      <div class="panel-header">
        <div class="panel-title">
          <span class="panel-title-dot" style="background:var(--cyan)"></span>
          View ランキング TOP 20
        </div>
        <span class="panel-badge" style="color:var(--cyan);border-color:var(--cyan-dim)">PV</span>
      </div>
      <div class="ranking-list" id="ranking-views"><div class="loading">loading</div></div>
    </div>

    <!-- フォロワー推移 -->
    <div class="panel">
      <div class="panel-header">
        <div class="panel-title">
          <span class="panel-title-dot" style="background:var(--violet)"></span>
          フォロワー 日次推移
        </div>
        <span class="panel-badge">ALL</span>
      </div>
      <div class="chart-wrap"><canvas id="chart-followers"></canvas></div>
    </div>

    <!-- スキランキング -->
    <div class="panel">
      <div class="panel-header">
        <div class="panel-title">
          <span class="panel-title-dot" style="background:var(--pink)"></span>
          スキ ランキング TOP 20
        </div>
        <span class="panel-badge" style="color:var(--pink);border-color:var(--pink-dim)">LIKE</span>
      </div>
      <div class="ranking-list" id="ranking-likes"><div class="loading">loading</div></div>
    </div>

    <!-- ワースト10 -->
    <div class="panel full">
      <div class="panel-header">
        <div class="panel-title">
          <span class="panel-title-dot" style="background:var(--red)"></span>
          改善候補 ワースト分析
        </div>
        <span class="panel-badge" style="color:var(--red);border-color:rgba(255,77,106,0.2)">WORST 10</span>
      </div>
      <!-- ワースト -->
      <div class="worst-grid" style="margin-bottom:28px">
        <div>
          <div class="worst-section-title">スキ率 ワースト(読まれてるのに刺さってない)</div>
          <div class="ranking-list" id="ranking-worst-eta"><div class="loading">loading</div></div>
        </div>
        <div>
          <div class="worst-section-title">View数 ワースト(埋もれている記事)</div>
          <div class="ranking-list" id="ranking-worst-views"><div class="loading">loading</div></div>
        </div>
      </div>

      <!-- 区切り -->
      <div style="border-top:1px solid var(--border);margin-bottom:28px;position:relative">
        <span style="position:absolute;top:-9px;left:50%;transform:translateX(-50%);background:var(--bg2);padding:0 16px;font-size:9px;color:var(--text3);letter-spacing:0.25em">VS</span>
      </div>

      <!-- ベスト -->
      <div class="worst-grid">
        <div>
          <div class="best-section-title">スキ率 ベスト(読まれて、かつ刺さった)</div>
          <div class="ranking-list" id="ranking-best-eta"><div class="loading">loading</div></div>
        </div>
        <div>
          <div class="best-section-title">View数 ベスト(最も読まれた記事)</div>
          <div class="ranking-list" id="ranking-best-views"><div class="loading">loading</div></div>
        </div>
      </div>
    </div>

    <!-- 4象限 -->
    <div class="panel full">
      <div class="panel-header">
        <div class="panel-title">
          <span class="panel-title-dot" style="background:var(--violet)"></span>
          4象限マップ|PV × スキ率
        </div>
        <span class="panel-badge">SCATTER</span>
      </div>
      <div class="chart-wrap tall"><canvas id="chart-quad"></canvas></div>
    </div>

    <!-- 資産記事 -->
    <div class="panel full">
      <div class="panel-header">
        <div class="panel-title">
          <span class="panel-title-dot" style="background:var(--gold)"></span>
          資産記事分析|バースト後の定着確認
        </div>
        <span class="panel-badge" style="color:var(--gold);border-color:var(--gold-dim)">ASSET</span>
      </div>
      <div class="table-wrap">
        <table class="data-table">
          <thead>
            <tr>
              <th style="width:44px">#</th>
              <th>タイトル</th>
              <th>期間 PV 増加</th>
              <th style="width:80px">稼働日数</th>
              <th>推移</th>
            </tr>
          </thead>
          <tbody id="asset-body">
            <tr><td colspan="5" class="loading">スキャン中</td></tr>
          </tbody>
        </table>
      </div>
    </div>

    <!-- トレンド詳細 -->
    <div class="panel full">
      <div class="panel-header">
        <div class="panel-title">
          <span class="panel-title-dot" style="background:var(--pink)"></span>
          トレンド動態|7日間詳細
        </div>
        <span class="panel-badge">← SCROLL →</span>
      </div>
      <div class="table-wrap">
        <table class="data-table">
          <thead>
            <tr>
              <th class="sticky-1" style="width:80px">状態</th>
              <th class="sticky-2">タイトル</th>
              <th>累計PV</th>
              <th>スコア</th>
              <th>-1日</th><th>-2日</th><th>-3日</th><th>-4日</th><th>-5日</th><th>-6日</th><th>-7日</th>
            </tr>
          </thead>
          <tbody id="trend-body">
            <tr><td colspan="11" class="loading">読み込み中</td></tr>
          </tbody>
        </table>
      </div>
    </div>

  </div><!-- /grid -->
</div><!-- /dashboard -->

<script>
// ╔══════════════════════════════════════════════════════════════╗
// ║  ⚙️  CONFIG — ここだけ書き換えてください                    ║
// ╚══════════════════════════════════════════════════════════════╝

const CONFIG = {

  // ① あなたの GitHub ユーザー名 と リポジトリ名
  //    形式: 'ユーザー名/リポジトリ名'
  //    例  : 'yamada-taro/note-stats-public'
  repo: 'YOUR_GITHUB_USERNAME/YOUR_REPO_NAME',

  // ② dataフォルダのパス(リポジトリ直下に data/ がある場合はそのまま)
  //    変更不要なことがほとんどです
  dataPath: 'data',

  // ③ ランキングから除外したい記事タイトル
  //    不要なら空配列 [] のままでOK
  //    例: ['下書き記事', 'テスト投稿']
  excludeTitles: [],

};

// ─── 以下は変更不要 ───────────────────────────────────────────
const EXCLUDED_TITLES = CONFIG.excludeTitles;
const BASE = `https://raw.githubusercontent.com/${CONFIG.repo}/main/${CONFIG.dataPath}/`;

async function fetchCSV(name) {
  const res = await fetch(BASE + name + '.csv?t=' + Date.now());
  if (!res.ok) throw new Error(name + ' not found');
  const text = await res.text();
  const lines = text.trim().split('\n');
  const headers = lines[0].split(',').map(h => h.trim());
  return lines.slice(1).map(line => {
    const vals = line.split(',');
    const obj = {};
    headers.forEach((h, i) => obj[h] = (vals[i] || '').trim());
    return obj;
  });
}

/* ─── Chart.js defaults ─── */
const GRID_COLOR = 'rgba(255,255,255,0.05)';
Chart.defaults.color = '#3d4466';
Chart.defaults.font.family = "'JetBrains Mono', monospace";
Chart.defaults.font.size = 11;

const baseOpts = () => ({
  responsive: true,
  maintainAspectRatio: false,
  animation: { duration: 1000, easing: 'easeOutQuart' },
  plugins: {
    legend: {
      display: true,
      labels: { boxWidth: 8, padding: 16, color: 'rgba(255,255,255,0.85)', usePointStyle: true } // 凡例のカラー指定
    },
    tooltip: {
      backgroundColor: '#0f1221',
      borderColor: 'rgba(255,255,255,0.1)',
      borderWidth: 1,
      titleColor: '#e8ecf8',
      bodyColor: '#8891b0',
      padding: 12,
      cornerRadius: 6
    }
  },
  scales: {
    x: {
      grid: { color: GRID_COLOR },
      ticks: {
        maxTicksLimit: 10,
        color: 'rgba(255,255,255,0.85)'
      }
    },
    y: {
      grid: { color: GRID_COLOR },
      ticks: {
        color: 'rgba(255,255,255,0.85)'
      }
    }
  }
});

/* ─── KPI diff ─── */
function setDiff(id, val) {
  if (isNaN(val)) return;
  const el = document.getElementById(id);
  const sign = val >= 0 ? '▲' : '▼';
  el.textContent = sign + ' ' + Math.abs(val).toFixed(1) + '% 前日比';
  el.className = 'kpi-diff ' + (val >= 0 ? 'up' : 'down');
}

/* ─── 日次推移 ─── */
function buildDaily(rows) {
  const r = rows.slice(-60);
  const labels = r.map(d => d['日付'] ? d['日付'].slice(5) : '');
  const views = r.map(d => Number(d['ビュー合計']));
  const likes = r.map(d => Number(d['スキ合計']));
  new Chart(document.getElementById('chart-daily'), {
    type: 'bar',
    data: {
      labels,
      datasets: [
        {
          type: 'bar', label: 'スキ合計', data: likes,
          backgroundColor: 'rgba(255,77,141,0.35)',
          borderColor: 'rgba(255,77,141,0.8)',
          borderWidth: 1, yAxisID: 'y1', borderRadius: 2
        },
        {
          type: 'line', label: 'ビュー合計', data: views,
          borderColor: '#00e5ff',
          backgroundColor: 'rgba(0,229,255,0.04)',
          fill: true, tension: 0.35,
          pointRadius: 0, pointHoverRadius: 4,
          borderWidth: 2, yAxisID: 'y'
        }
      ]
    },
    options: {
      ...baseOpts(),
      scales: {
        x: {
          grid: { color: GRID_COLOR },
          ticks: {
            maxTicksLimit: 10,
            color: 'rgba(255,255,255,0.85)' // X軸の文字色
          }
        },
        y: {
          position: 'left',
          grid: { color: GRID_COLOR },
          ticks: {
            color: 'rgba(255,255,255,0.85)' // Y軸の文字色
          }
        },
        y1: {
          position: 'right',
          grid: { display: false },
          ticks: {
            color: 'rgba(255,255,255,0.85)' // Y1軸の文字色
          }
        }
      }
    }
  });
}

/* ─── ランキング ─── */
function buildRanking(containerId, rows, valueKey, barClass, valClass) {
  if (!rows.length) return;
  const latest = rows.reduce((a, b) => a.date > b.date ? a : b).date;
  const sorted = rows.filter(r => r.date === latest && !EXCLUDED_TITLES.includes(r.title))
    .sort((a, b) => Number(b[valueKey]) - Number(a[valueKey])).slice(0, 20);
  const max = Number(sorted[0][valueKey] || 1);
  const medal = i => i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : '';
  document.getElementById(containerId).innerHTML = sorted.map((r, i) => `
    <div class="rank-item">
      <span class="rank-num ${medal(i)}">${String(i + 1).padStart(2, '0')}</span>
      <span class="rank-title" title="${r.title}">${r.title}</span>
      <div class="rank-right">
        <div class="rank-bar">
          <div class="rank-bar-fill ${barClass}" style="width:${(Number(r[valueKey]) / max * 100).toFixed(1)}%"></div>
        </div>
        <span class="rank-val ${valClass}">${Number(r[valueKey]).toLocaleString()}</span>
      </div>
    </div>`).join('');
}

/* ─── フォロワー ─── */
function buildFollowers(summary, followers) {
  const fRows = followers.filter(r => r['フォロワー数']);
  let labels, data;
  if (fRows.length > 1) {
    labels = fRows.map(r => r['日付'] ? r['日付'].slice(5) : '');
    data = fRows.map(r => Number(r['フォロワー数']));
  } else {
    const s = summary.filter(r => r['フォロワー数']);
    labels = s.map(r => r['日付'] ? r['日付'].slice(5) : '');
    data = s.map(r => Number(r['フォロワー数']));
  }
  new Chart(document.getElementById('chart-followers'), {
    type: 'line',
    data: {
      labels,
      datasets: [{
        label: 'フォロワー数', data,
        borderColor: '#9d6fff',
        backgroundColor: 'rgba(157,111,255,0.06)',
        fill: true, tension: 0.35,
        pointRadius: 0, pointHoverRadius: 4,
        borderWidth: 2
      }]
    },
    options: baseOpts()
  });
}

/* ─── 4象限 ─── */
function buildQuad(rows) {
  const data = rows.map(r => ({
    x: parseFloat(r['ビュー/記事']) || 0,
    y: parseFloat(r['スキ率(%)']) || 0,
    label: r['日付']
  }));
  new Chart(document.getElementById('chart-quad'), {
    type: 'scatter',
    data: {
      datasets: [{
        label: '日次動態',
        data,
        backgroundColor: 'rgba(157,111,255,0.5)',
        pointRadius: 5,
        pointHoverRadius: 7
      }]
    },
    options: {
      ...baseOpts(),
      scales: {
        x: {
          grid: { color: GRID_COLOR },
          ticks: {
            maxTicksLimit: 10,
            color: 'rgba(255,255,255,0.85)'
          },
          title: {
            display: true,
            text: 'Views / Article',
            color: 'rgba(255,255,255,0.85)'
          }
        },
        y: {
          grid: { color: GRID_COLOR },
          ticks: {
            color: 'rgba(255,255,255,0.85)'
          },
          title: {
            display: true,
            text: 'Like Rate (%)',
            color: 'rgba(255,255,255,0.85)'
          }
        }
      }
    }
  });
}

/* ─── 資産記事 ─── */
function buildAssetTable(rows) {
  const tbody = document.getElementById('asset-body');
  if (!rows.length) { tbody.innerHTML = '<tr><td colspan="5" style="color:var(--text3);padding:20px">No asset data</td></tr>'; return; }
  const maxGain = Math.max(...rows.map(r => Number(r['期間増加PV'] || 0)), 1);
  tbody.innerHTML = rows.slice(0, 15).map((r, i) => {
    const gain = Number(r['期間増加PV'] || 0);
    const barW = (gain / maxGain * 100).toFixed(1);
    const trend = r['推移PV'] || '---';
    return `
      <tr>
        <td class="num-muted" style="text-align:center">${i + 1}</td>
        <td style="max-width:320px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text2)">${r.title}</td>
        <td>
          <div class="bar-cell-wrap">
            <div class="inline-bar"><div class="inline-bar-fill" style="width:${barW}%"></div></div>
            <span class="num-accent" style="font-weight:700;font-size:16px">${gain.toLocaleString()}</span>
          </div>
        </td>
        <td class="num-right" style="color:var(--gold);font-size:16px;font-weight:700;text-align:center">${r['稼働日数']}</td>
        <td style="font-size:15px;color:var(--text3);white-space:nowrap">${trend}</td>
      </tr>`;
  }).join('');
}

/* ─── トレンド詳細 ─── */
function buildTrendDetailed(rows) {
  const tbody = document.getElementById('trend-body');
  if (!rows.length) return;

  tbody.innerHTML = rows.slice(0, 20).map(r => {
    const state = r['状態'] || '';
    let badgeClass = 'badge-idle';
    if (state.includes('🔥')) badgeClass = 'badge-hot';
    else if (state.includes('🟢')) badgeClass = 'badge-grow';

    const dailyCells = [1, 2, 3, 4, 5, 6, 7].map(i => {
      const v = Number(r[`-${i}日PV`] || 0);
      return `<td class="${v > 0 ? 'num-right' : 'num-muted'}" style="text-align:right">${v || '—'}</td>`;
    }).join('');

    return `
      <tr>
        <td class="sticky-1"><span class="status-badge ${badgeClass}">${state}</span></td>
        <td class="sticky-2" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text2)">${r['title']}</td>
        <td class="num-right">${Number(r['累計PV']).toLocaleString()}</td>
        <td class="num-accent" style="text-align:center;font-weight:700">${r['トレンドスコア']}</td>
        ${dailyCells}
      </tr>`;
  }).join('');
}

/* ─── 3KPI + 状態メモ ─── */
function build3KPI(summary) {
  if (summary.length < 2) return;
  const today = summary[summary.length - 1];
  const yesterday = summary[summary.length - 2];
  const ac = Number(today['記事数']) || 1;
  const acY = Number(yesterday['記事数']) || 1;

  const reach  = Number(today['ビュー合計']) / ac;
  const reachY = Number(yesterday['ビュー合計']) / acY;
  const action  = Number(today['スキ合計']) / ac;
  const actionY = Number(yesterday['スキ合計']) / acY;
  const eta  = Number(today['スキ合計']) / (Number(today['ビュー合計']) || 1) * 100;
  const etaY = Number(yesterday['スキ合計']) / (Number(yesterday['ビュー合計']) || 1) * 100;

  function arrow(now, prev) {
    const d = now - prev;
    if (d > prev * 0.01) return { s: '↑', c: 'up' };
    if (d < -prev * 0.01) return { s: '↓', c: 'down' };
    return { s: '→', c: 'flat' };
  }

  const aR = arrow(reach, reachY);
  const aA = arrow(action, actionY);
  const aE = arrow(eta, etaY);

  document.getElementById('k3-reach').textContent = reach.toFixed(1);
  document.getElementById('k3-action').textContent = action.toFixed(2);
  document.getElementById('k3-eta').textContent = eta.toFixed(2) + '%';

  [['reach', aR], ['action', aA], ['eta', aE]].forEach(([id, a]) => {
    const el = document.getElementById(`k3-${id}-arrow`);
    el.textContent = a.s;
    el.className = `kpi3-arrow ${a.c}`;
  });

  const key = `${aR.c}_${aA.c}_${aE.c}`;
  const memos = {
    'up_up_up':      '🔥 全指標上昇。コンテンツ・タイトル・スキ率すべて好調',
    'up_up_flat':    '📈 リーチもアクションも増加。スキ率は維持で安定成長',
    'up_up_down':    '⚠️ 露出とスキは増えたがスキ率は低下。量で稼いでいる状態',
    'up_flat_up':    '✅ リーチ増・スキ率改善。質の高い流入が増えている',
    'up_flat_flat':  '📊 リーチだけ増加。スキ率は変わらず。タイトル効果の可能性',
    'up_flat_down':  '⚠️ 露出は増えたが刺さってない。タイトル勝ちの可能性',
    'up_down_up':    '🤔 PV増・スキ減・スキ率上昇。データ確認推奨',
    'up_down_flat':  '📉 リーチ増だがスキ横ばい。内容が刺さっていない',
    'up_down_down':  '❌ 露出は増えたが全く刺さってない。内容またはターゲットの見直しを',
    'flat_up_up':    '✨ PV横ばいでスキとスキ率が上昇。コアなファンに届いている',
    'flat_up_flat':  '👍 アクション力が上がっている。スキしやすい記事が増えた可能性',
    'flat_up_down':  '🔄 スキは増えたがスキ率は低下。PVも増えた可能性',
    'flat_flat_up':  '💡 全体横ばいだがスキ率改善。静かに質が上がっている',
    'flat_flat_flat':'😐 全指標横ばい。変化なし。新しいアクションを検討',
    'flat_flat_down':'😶 スキ率だけ低下。最近の記事の質を確認',
    'flat_down_up':  '🤔 スキ減・スキ率上昇は矛盾。データ確認推奨',
    'flat_down_flat':'📉 アクション力低下。スキしにくい記事が続いている',
    'flat_down_down':'⚠️ アクション・スキ率ともに低下。内容の見直しタイミング',
    'down_up_up':    '🌱 PV減でもスキとスキ率が上昇。少数精鋭に届いている',
    'down_up_flat':  '🔄 PV減だがスキは増加。ファン向け記事が増えた可能性',
    'down_up_down':  '📉 PV減による相対効果の可能性。実態確認を',
    'down_flat_up':  '💤 PV減・スキ率改善。深いファンには刺さっている',
    'down_flat_flat':'📉 リーチのみ低下。スキ率は維持。コンテンツ質は悪くない',
    'down_flat_down':'⚠️ リーチ低下・スキ率も低下。発信ペースや内容を見直し',
    'down_down_up':  '💤 PV減による相対効果の可能性',
    'down_down_flat':'😔 リーチ・アクション低下。停滞期の可能性',
    'down_down_down':'🚨 全指標低下。内容・頻度・タイトルを総見直し',
  };
  document.getElementById('status-memo-text').textContent = memos[key] || '状態を解析中...';
}

/* ─── ワースト10 ─── */
function buildWorstRanking(rows) {
  if (!rows.length) return;
  const latest = rows.reduce((a, b) => a.date > b.date ? a : b).date;
  const all = rows.filter(r =>
    r.date === latest &&
    Number(r['read_count']) > 0 &&
    !EXCLUDED_TITLES.includes(r.title)
  );

  // スキ率ワースト(50PV以上)
  const etaWorst = all
    .filter(r => Number(r['read_count']) >= 50)
    .map(r => ({ ...r, eta: Number(r['like_count']) / Number(r['read_count']) * 100 }))
    .sort((a, b) => a.eta - b.eta)
    .slice(0, 10);
  const etaMax = Math.max(...etaWorst.map(r => r.eta), 1);

  document.getElementById('ranking-worst-eta').innerHTML = etaWorst.map((r, i) => `
    <div class="rank-item">
      <span class="rank-num">${String(i + 1).padStart(2, '0')}</span>
      <span class="rank-title" title="${r.title}">${r.title}</span>
      <div class="rank-right">
        <div class="rank-bar"><div class="rank-bar-fill worst-bar" style="width:${(r.eta / etaMax * 100).toFixed(1)}%"></div></div>
        <span class="rank-val worst-val">${r.eta.toFixed(1)}%</span>
      </div>
    </div>`).join('');

  // Viewワースト
  const viewWorst = [...all].sort((a, b) => Number(a['read_count']) - Number(b['read_count'])).slice(0, 10);
  const viewMax = Math.max(...viewWorst.map(r => Number(r['read_count'])), 1);

  document.getElementById('ranking-worst-views').innerHTML = viewWorst.map((r, i) => `
    <div class="rank-item">
      <span class="rank-num">${String(i + 1).padStart(2, '0')}</span>
      <span class="rank-title" title="${r.title}">${r.title}</span>
      <div class="rank-right">
        <div class="rank-bar"><div class="rank-bar-fill worst-bar" style="width:${(Number(r['read_count']) / viewMax * 100).toFixed(1)}%"></div></div>
        <span class="rank-val worst-val">${Number(r['read_count']).toLocaleString()}</span>
      </div>
    </div>`).join('');
}


/* ─── ベスト10 ─── */
function buildBestRanking(rows) {
  if (!rows.length) return;
  const latest = rows.reduce((a, b) => a.date > b.date ? a : b).date;
  const all = rows.filter(r =>
    r.date === latest &&
    Number(r['read_count']) > 0 &&
    !EXCLUDED_TITLES.includes(r.title)
  );

  // スキ率ベスト(50PV以上)
  const etaBest = all
    .filter(r => Number(r['read_count']) >= 50)
    .map(r => ({ ...r, eta: Number(r['like_count']) / Number(r['read_count']) * 100 }))
    .sort((a, b) => b.eta - a.eta)
    .slice(0, 10);
  const etaMax = Math.max(...etaBest.map(r => r.eta), 1);

  const medal = i => i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : '';
  document.getElementById('ranking-best-eta').innerHTML = etaBest.map((r, i) => `
    <div class="rank-item">
      <span class="rank-num ${medal(i)}">${String(i + 1).padStart(2, '0')}</span>
      <span class="rank-title" title="${r.title}">${r.title}</span>
      <div class="rank-right">
        <div class="rank-bar"><div class="rank-bar-fill best-bar" style="width:${(r.eta / etaMax * 100).toFixed(1)}%"></div></div>
        <span class="rank-val best-val">${r.eta.toFixed(1)}%</span>
      </div>
    </div>`).join('');

  // Viewベスト
  const viewBest = [...all].sort((a, b) => Number(b['read_count']) - Number(a['read_count'])).slice(0, 10);
  const viewMax = Math.max(...viewBest.map(r => Number(r['read_count'])), 1);

  document.getElementById('ranking-best-views').innerHTML = viewBest.map((r, i) => `
    <div class="rank-item">
      <span class="rank-num ${medal(i)}">${String(i + 1).padStart(2, '0')}</span>
      <span class="rank-title" title="${r.title}">${r.title}</span>
      <div class="rank-right">
        <div class="rank-bar"><div class="rank-bar-fill best-bar" style="width:${(Number(r['read_count']) / viewMax * 100).toFixed(1)}%"></div></div>
        <span class="rank-val best-val">${Number(r['read_count']).toLocaleString()}</span>
      </div>
    </div>`).join('');
}
/* ─── CONFIG チェック ─── */
function validateConfig() {
  if (CONFIG.repo === 'YOUR_GITHUB_USERNAME/YOUR_REPO_NAME' || !CONFIG.repo.includes('/')) {
    document.getElementById('last-updated').textContent = '⚠️ CONFIG未設定';
    document.querySelector('.dashboard').innerHTML = `
      <div style="display:flex;align-items:center;justify-content:center;min-height:60vh;padding:32px">
        <div style="max-width:560px;border:1px solid rgba(255,201,71,0.3);border-radius:8px;padding:40px;background:rgba(255,201,71,0.05)">
          <div style="font-size:22px;font-weight:700;color:#ffc947;margin-bottom:16px">⚙️ 設定が必要です</div>
          <div style="color:#b0bcd8;line-height:1.9;font-size:14px">
            このファイルを使うには、スクリプト冒頭の <code style="color:#00e5ff;background:rgba(0,229,255,0.1);padding:2px 6px;border-radius:3px">CONFIG</code> を編集してください。<br><br>
            <strong style="color:#f0f4ff">① HTMLファイルをテキストエディタで開く</strong><br>
            (メモ帳 / VSCode など何でもOK)<br><br>
            <strong style="color:#f0f4ff">② 以下の1行を書き換える</strong><br>
            <div style="background:#0f1221;border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:14px;margin:12px 0;font-family:monospace;font-size:13px">
              <span style="color:#6b7599">// 変更前</span><br>
              <span style="color:#ff4d6a">repo: 'YOUR_GITHUB_USERNAME/YOUR_REPO_NAME'</span><br><br>
              <span style="color:#6b7599">// 変更後(例)</span><br>
              <span style="color:#00e5a0">repo: 'yamada-taro/note-stats-public'</span>
            </div>
            <strong style="color:#f0f4ff">③ ファイルを保存してブラウザで再度開く</strong>
          </div>
        </div>
      </div>`;
    return false;
  }
  return true;
}

/* ─── INIT ─── */
async function init() {
  if (!validateConfig()) return;
  try {
    const [summary, articles, followers, trend, assets] = await Promise.all([
      fetchCSV('daily_summary'),
      fetchCSV('articles'),
      fetchCSV('followers'),
      fetchCSV('trend_analysis'),
      fetchCSV('asset_articles')
    ]);

    const latest = summary[summary.length - 1];
    document.getElementById('kpi-views').textContent    = Number(latest['ビュー合計']).toLocaleString();
    document.getElementById('kpi-likes').textContent    = Number(latest['スキ合計']).toLocaleString();
    document.getElementById('kpi-articles').textContent = Number(latest['記事数']).toLocaleString();
    document.getElementById('kpi-followers').textContent = latest['フォロワー数'] || '—';
    document.getElementById('last-updated').textContent = `updated ${latest['日付']} ${latest['更新時刻'] || ''}`;

    setDiff('kpi-views-diff', parseFloat(latest['ビュー前日比(%)']));
    setDiff('kpi-likes-diff', parseFloat(latest['スキ前日比(%)']));

    build3KPI(summary);
    buildDaily(summary);
    buildRanking('ranking-views', articles, 'read_count', 'view-bar', 'view-val');
    buildFollowers(summary, followers);
    buildRanking('ranking-likes', articles, 'like_count', 'like-bar', 'like-val');
    buildWorstRanking(articles);
    buildBestRanking(articles);
    buildQuad(summary);
    buildAssetTable(assets);
    buildTrendDetailed(trend);

  } catch (e) {
    console.error(e);
    document.getElementById('last-updated').textContent = 'error: ' + e.message;
  }
}

init();
</script>
</body>
</html>

#note運営 #GitHubPages #GitHubActions #データ分析 #ダッシュボード #可視化 #HTML #CSV #運営分析 #アクセス解析 #note #仕事 #ビジネス #自分 #日記 #学び #勉強 #ライフハック #情報整理 #業務改善


いいなと思ったら応援しよう!

KITAcore|キタコレ@ログプレイヤー よろしければ応援お願いします! いただいたチップはクリエイターとしての活動費に使わせていただきます!