SYSTEM NOTICE

Auto translation by AI. Be sure, accuracy, nuances and authorial intent may not be fully reflected.
見出し画像

From 'Intuition' to 'Visualization' in note Management | Creating a Dashboard with GitHub Pages | Data Analysis | #286

Previously, I wrote an article about how to automatically collect note PVs and like counts using GitHub Actions.

So, this is the next step.

Even if you manage to accumulate CSV data every day,
if it's hard to read, people probably won't look back at it much.

This happens quite often, haha.

So this time, I will summarize
how to visualize the note data collected previously in a browser in an easy-to-read format.

The tasks involved aren't that heavy.

  • Place one HTML file

  • Change the settings by almost just one line

  • Open it with GitHub Pages

With this, you will be able to view things like PV trends, rankings, and even your asset articles in your browser.


Created dashboard: For PC

Dashboard Screen-1
Dashboard Screen-2
Dashboard Screen-3

Alright, let's go!




What you can do with this article

Once you install this dashboard, you will be able to see things like this.

  • Cumulative PVs, likes, number of articles, and follower count

  • Daily trends of views and likes

  • View ranking / Like ranking

  • Best / Worst Like Rates

  • PV x Like Rate Quadrant Map

  • Analysis of Evergreen Articles

  • Trend Dynamics for the Last 7 Days

In short,

“I feel like things have been growing lately”
into

“This is growing,” “This is buried,” “This is quietly remaining”

is what this turns it into.

It's not just about lining up numbers; the goal this time is to
make the management log a 'screen you want to look at' as well.


Let me say the important thing first.

This HTML is built to read the CSV on GitHub directly from the browser.

So, if you use the method in this article as is, basically assume it's for a public repository.

In other words,

  • I want to store backend data just for myself

  • but I also want to create a screen for showing it

If you want to achieve both of these,

Collection is Private
Formatted data for display and the dashboard are Public

separating them like this works quite well.

In my previous paid article, I mentioned that 'running collection in Private and only outputting the CSV for display to Public' is a viable operation, and this time is exactly about that 'viewing side'.


Who is this for?

This is for people like this.

  • People who want to see their note management through logs rather than intuition

  • People who are looking for what to do next after collecting articles in the previous step

  • People who are saving CSVs but honestly aren't really looking back at them properly

  • People who want to see their management data in a slightly cooler way

  • People who want to make things easier to see first, rather than doing 'analysis'

In short, this is for
people who want to get their collected data into a 'properly usable state'.


Things to check before starting

You mainly need these two things.

1. GitHub account

This is free, so it's fine.

2. Repository containing CSVs

This HTML works by reading CSVs on GitHub. So, you need the source data.

At the very least, assume that you have these around.

  • daily_summary.csv

  • articles.csv

  • followers.csv

  • trend_analysis.csv

  • asset_articles.csv

This part is quietly important.

For those who just used the previous paid article as is,
you might have the collection CSV ready, but the analysis CSV hasn't been generated yet.

In that case, make sure to add the step to run analyze.py.

In other words, for this time,

  • Previous article → Foundation for collection

  • This article → Screen for viewing

You can think of it as this division of roles.

⚠️ GitHub Pages is intended for use with public repositories on GitHub Free.

With GitHub Pro or higher, you can use Pages with private repositories as well.

However, since this dashboard is implemented to read CSVs directly from the browser, if you use this configuration as is, the CSV must be in a state where it can be read from the outside.

An operational approach like "Collection in Private, only publishing the formatted data for public viewing" works well.

The overall structure looks like this

First, I will show you the completed structure.

Folder structure image

The image is something like this.

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

The points are simple:

  • CSV is in data/

  • HTML is in docs/

  • Publish docs/ via GitHub Pages

That's all.


Step 1 | Get the HTML file

The full code for index.html is included at the end of this article. Please copy it and save it as a file named index.html.

You might be overwhelmed by the amount of code, but there are only two places you need to touch, which I will explain in Step 3.

For now, just copying the whole thing is fine.


Step 2 | Open with a text editor

Next, open that HTML file in a text editor.

If you're wondering, "What's a text editor?", don't worry.

  • Windows -> Notepad

  • Mac -> TextEdit

will be enough.

When you open it, you might see a long list of English and symbols and think, "Whoa, this looks impossible" for a second.
But you can rest easy.

You only need to touch one place in the next Step 3.


Step 3 | Rewrite the CONFIG

Open the find function in the file with Ctrl+F (Command+F on Mac) and try searching for CONFIG.

You should find a section like this.

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

Basically, you only need to change the one line for repo:.

Before change:

repo: 'YOUR_GITHUB_USERNAME/YOUR_REPO_NAME',

After change (example):

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

Just enter the "username/repository name" that appears at the top when you open your GitHub repository page.

Do not delete the single quotes (') before and after, or the comma (,) at the end.

Other settings (only if necessary)

dataPath and excludeTitles are usually fine as they are, but I've made them editable if needed.

If you want to exclude specific articles from the ranking, add them like this.

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

How to check if the settings are correct

If you open it in a browser without rewriting the settings, this guide screen will be displayed.

⚙️ Settings required

It means you're good to go once this screen stops appearing.


Step 3.5 | For those who have included the previous article

If you are basing this on the previous paid article, please take a look at this additional part.

The HTML this time also reads:

  • trend_analysis.csv

  • asset_articles.csv

as well.

Therefore, if you stop at just the previous collection,
some parts of the screen may not be displayed correctly.

In that case, add the process to run analyze.py to GitHub Actions.

For example, like this.

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

Once you add this, it connects:

Collection → Aggregation → Visualization

all the way through.

Once you reach this point, the previous article and this article will work together properly.


Step 4 | Save the file

Save after you finish rewriting.

  • Windows → Ctrl + S

  • Mac → Command + S

That's it.
You can just save this normally.


Step 5 | Upload to GitHub

Upload the saved index.html to the GitHub docs/ folder.

The process is like this.

  1. Open the repository on GitHub

  2. Open the docs folder

  3. Add file → Upload files

  4. Upload index.html

  5. Commit changes

If the docs folder doesn't exist yet,
you can create it along with the file by naming it docs/index.html.


Step 6 | Enable GitHub Pages

Next, turn on GitHub Pages.

Here is what you need to do.

  1. Open the repository's Settings

  2. Open Pages on the left side

  3. Set Source to Deploy from a branch

  4. Set the branch to main and the folder to /docs

  5. Save

After a short while, a URL will be issued.

It usually looks something like this.

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

Open this URL in your browser, and if the dashboard appears, you're done.
Great work! 🎉


Troubleshooting points when things don't go well

1. It says "⚙️ Configuration required"

It's usually a configuration error in repo:.

  • Did you forget to rewrite it?

  • Is it in the format username/repository-name?

  • Did you accidentally delete the '?

Check these points.


2. The numbers remain as —

In this case, it's usually one of the following.

  • Typo in the repo name

  • The repository is not set to public

  • There is no CSV in the data/ folder

  • trend_analysis.csv and asset_articles.csv have not been generated yet

The last two, in particular, are easy to overlook.


3. It shows a 404 error when opening the URL

Sometimes this fixes itself if you wait a little while.

GitHub Pages can sometimes take a while to reflect changes after configuration,
so it might appear if you wait about 5 to 10 minutes and check again.


Summary

Here is a rough summary of what we did this time.

  1. Change the repo: in index.html for your own use

  2. Place it in docs/

  3. Enable GitHub Pages

  4. Run analyze.py if necessary

With this, the note logs collected last time will
become much easier to view and handle in your browser.

If the previous article was
a 'system for storing data',
then

this article is a 'screen to properly view that data'.

Once you connect these, your note management will change a little.

Because 'I feel like it grew somehow' changes to
'This is growing', 'This is buried', and 'This looks like an asset'.

Things like this aren't flashy.
But once you can see them, they start to have a gradual effect.

Seriously.


Conclusion

This tool is a 'made it' project from personal development.

So, it's not like there's a dedicated support desk or
a promise to fix everything with an immediate response.
I'll be honest about that upfront.

However, in this day and age, you don't have to despair about that.

When it doesn't work,

  • the error message

  • this article

  • the code

If you just throw these into ChatGPT, Claude, or Gemini,
it's often faster than asking a person, believe it or not lol

If you found this article
a little helpful
or thought, "I want to try this,"
I would be happy if you could leave a like or a comment.

If you want to start by automatically saving CSV files, it will be smoother to begin with my previous paid article.

And once you've implemented this much, it's also fun to tweak the HTML to your liking.

Like this, you can also expand into playing around with the appearance.


If you found this article interesting or a little helpful, I would be happy to receive your reaction through likes, follows, or comments.

I really love likes and comments, haha.

See you later.


Supplementary Note

<!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>

#noteManagement #GitHubPages #GitHubActions #DataAnalysis #Dashboard #Visualization #HTML #CSV #OperationalAnalysis #AccessAnalysis #note #Work #Business #Self #Diary #Learning #Study #LifeHack #InformationOrganization #BusinessImprovement


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

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