DEV Community

김이더
김이더

Posted on

I Built My Own Year-End Review for AI Coding — Memradar Code Report

Live app at memradar.vercel.app. Code on GitHub.
More posts at radarlog.kr.


Every December Cursor pops up with something like "you coded X hours this year, across these languages." GitHub drops Year in Review. Discord puts yearly stats on your profile.

The numbers are all mine, but flipping through them, I catch myself smiling — "oh, that's how I was." That's what a year-end review does. It's not information delivery — it's a ritual that pulls out one number at a time.

I wanted that for myself. Claude Code and Codex drop JSONL logs into my home directory every day (~/.claude/projects/, ~/.codex/sessions/), and I had never looked inside. My conversations are on my disk, and I couldn't read them.

Memradar is a local tool that turns those JSONLs into a retrospective. One line (npx memradar), a browser opens, and along with the dashboard there's a full-screen slide retrospective — Code Report — built in.

This post is about the details I got obsessive about while building it. The idea is simple, but making people actually feel something while looking at their own data took a lot of small calls.

It had to be Code Report, not Wrapped

When I first built the full-screen retrospective feature, I called it "Wrapped" internally. The folder is still src/components/wrapped/. Spotify Wrapped was the reference, so the name stuck.

But shipping a product literally named "Wrapped" has problems. You're borrowing someone else's brand. The word also pins the feature to "year-end summary" as a category.

While renaming, I wrote this principle into docs/UI-UX-PRINCIPLES.md:

9. The dashboard and the Code Report are two moods
   of the same product
- The dashboard is for exploration; the Code Report is
  for emotional retrospective and sharing.
- The Code Report uses its own palette, its own typography,
  and full-screen narrative.
Enter fullscreen mode Exit fullscreen mode

Same data, but the dashboard should feel like an "analysis tool" — calm — while the Code Report should lean into emotion, like a "retrospective experience." I separated the palettes and typography so the two screens don't bleed into each other.

So the name became Code Report. A report on the code (my AI coding logs). Not tied to a fixed calendar moment — it's a screen that's a retrospective whenever you open it. Wrapped happens once a year; Code Report opens whenever I need it.

I kept the folder name (wrapped/) as-is. Renaming internal variables carries too much refactor risk for no real benefit — only the product-facing name got locked to Code Report. That split itself is a small lesson: the inside and the outside of the same feature can have different names.

Why the dashboard alone wasn't enough

The dashboard came first, naturally. Heatmap, hourly chart, word cloud, session browser. Every metric on one screen.

I loaded my own logs. The numbers were all there. And I felt nothing.

That's the dashboard ceiling. Lots of information, zero emotion. You nod and close the tab. The reason Cursor's year-end review makes people laugh with the same kind of data is that each screen shows one number. The space around that number is empty on purpose.

I locked in a composition principle for Code Report — "one scene, one message." From docs/UI-UX-PRINCIPLES.md:

- Here, "one scene, one message" matters more
  than the dashboard's rules.
- Full-screen, heavy whitespace, big type,
  a dedicated dark story palette.
- End with a shareable image —
  the end of the emotional arc is also the call to action.
Enter fullscreen mode Exit fullscreen mode

There are eight slides. Intro with the first session date. Total prompts. Favorite model. Coding hours. Top tools ranking. Personality type. Usage. Shareable card at the end.

Each slide holds one number or one message. "I talked to Claude 3,200 times" as a small figure in a dashboard corner is one thing. "3,200" counting up on an empty slide is completely another. Same data.

That was the first obsession. How do you make a number be felt, not just read?

Why I scrapped 4 personality types for 8

The climax of Code Report is the personality slide. I started with four: Architect, Speed Runner, Explorer, Night Sage.

After a couple of runs on different logs, everyone said some version of "...I don't think I'm any of those." MBTI works because its four axes are computed independently — you're I, and N, and F, and P — all at once — which is how INFP appears. One axis (pick one of four buckets) is always going to be coarse.

So I rewrote it as three axes.

style × scope × rhythm
 ↓       ↓        ↓
care    depth    pace
  8 combinations
Enter fullscreen mode Exit fullscreen mode

The combinations yield names like "Deep-Sea Diver," "Lightning Fixer," "Chaos Creator." Because each axis is computed separately, the result feels more personal.

Half a day burned on this. The four types weren't wrong — the feeling of reading the result was weak. That's what matters most in Code Report. Before "correct," the viewer has to chuckle and think "yeah, that's me."

I kept tuning the personality logic against real data. Did my logs produce a result that felt right for me? Did a friend's logs produce something that fit them? Move one threshold and everything shifts. I calibrated that dozens of times.

That 2.5-second delay on the last slide

Commit title: Fix last slide dashboard prompt timing.

The problem: on the share-card slide, tapping the screen opens a "Go to dashboard?" modal. But people flip through Code Report with a rhythm — tap, tap, tap — and that same rhythm triggers the modal the moment they land on the last slide. They never even see the share card.

Most people would shrug at this. But once it happened to me, I couldn't unsee it.

useEffect(() => {
  setDashboardPromptReady(false)
  if (slideIndex !== lastSlideIndex) return

  const timer = window.setTimeout(() => {
    setDashboardPromptReady(true)
  }, 2500)

  return () => window.clearTimeout(timer)
}, [lastSlideIndex, slideIndex])
Enter fullscreen mode Exit fullscreen mode

When you enter the last slide, dashboardPromptReady stays false for 2.5 seconds. Tap all you want — the modal won't open. I even changed the cursor to default during that window. The mouse cursor itself signals "not yet."

Writing it, I asked myself: is this really needed? The user won't even notice.

And that's exactly the point. Same idea as locking input for a few frames right after a cutscene ends in games. You prevent the player's "skip button rhythm" from triggering something they didn't mean to. When it bites, it feels awful; when it's handled, nobody notices. Nobody noticing is the win.

Why 20 themes?

Memradar ships with 20 themes. Four backgrounds (Dark, AMOLED, Light, Warm) × five accents (Indigo, Violet, Teal, Rose, Amber).

Why so many? Same reason as everything else — it's about feel.

Flipping through Code Report is personal. It's me looking at my own coding log. I don't want to use someone else's dark theme — I want to pick my own mood. Unlike Cursor's year-end review where the palette is fixed, if I'm the one building it, I should be able to choose.

The four backgrounds are calculated, too. Dark as the default. AMOLED for a true black on OLED screens. Light for a bright café. Warm for evening. The right pick changes with the situation.

Fonts are pinned to Noto Sans KR + Noto Serif KR. The UI shows a lot of Hangul, so the Korean has to look right first. I didn't pick an English-first font. I code mostly in Korean, my prompts are mostly Korean, and the Code Report that reads those has to look good in Korean.

localStorage preserves the choice across visits. Obvious, but forgetting it is the kind of thing that screams amateur.

The decisions packed into one heatmap

A GitHub-style daily activity heatmap. Here are the calls inside that single widget.

First, responsive cell size. Cells auto-grow and shrink with container width. A ResizeObserver watches the container; on change, cell size is recomputed and the grid rerenders. I started with fixed sizes, then watched the heatmap get clipped on a smaller laptop and rebuilt it.

Second, click-to-pin a date. Clicking a cell shows that day's session summary in a side panel. Hover-only worked on desktop but died on mobile, and "pinning" a day on desktop lets you actually dig in.

Third, streak counter. "How many days in a row." I debated where to place it, and landed on the heatmap's side panel. Not trying to gamify like Duolingo — just a passive "huh, 15 straight days this month" when your eyes already land on the heatmap.

Fourth, day-of-week pattern. Which weekday I code the most, tucked small on the side. I thought about cutting it. Then I noticed I work weekends way more than I thought. That's the Code Report flavor — surfacing a pattern I didn't see.

Four decisions inside one widget. Same story for the word cloud, the hourly chart, the token chart.

How many times I rewrote the DropZone wording

The most obsessive stretch was the landing page DropZone. The commit log has four wording fixes for one component:

- Replace copy-paste wording with Ctrl+C/V shortcut
- Use Ctrl+C/V/Enter wording consistently in DropZone
- Allow .claude/.codex root folder drop and add install guide link
- Fix DropZone wording: shorten Ctrl+C/V label
Enter fullscreen mode Exit fullscreen mode

One at a time. First, "copy and paste" was the original wording — but it's long in both languages. Two shortcuts side by side make the action click in half a second.

Second was consistency. "Ctrl+C/V" in some places, "copy/paste" left in others. Mixed vocabulary annoys people.

Third, accept the .claude folder itself. Originally you had to drop the inner .claude/projects/. But users naturally drag .claude from their home. So the drop handler digs one level deeper when it sees .claude:

if (droppedFolder.name === '.claude') {
  const projectsDir = findChild(droppedFolder, 'projects')
  if (projectsDir) return scanDir(projectsDir)
}
Enter fullscreen mode Exit fullscreen mode

Fourth, wording again. The "Ctrl+C/V" label had a redundant prefix stuck in front, so I shortened it.

Four passes on one component's copy. Over the top? Maybe. But the DropZone is the first screen a user sees. If they can't figure out what to do in five seconds, the rest doesn't matter.

Bilingual support turned out to be a big call

i18n (ko/en), theme presets, and hash routing. Three things landed in one commit.

I added bilingual support partly because I publish this blog in both Korean and English. But there's a bigger reason. The copy in Code Report is emotional. "You're a Night Owl." "You started 3,200 conversations this month." If those only exist in English, the joy gets cut in half for Korean readers.

// src/i18n.tsx
const translations = {
  ko: {
    'personality.nightSage': '새벽의 현자',
    'personality.speedRunner': '번개 해결사',
    // ...
  },
  en: {
    'personality.nightSage': 'Night Sage',
    'personality.speedRunner': 'Lightning Fixer',
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Translating Night Sage as "새벽의 현자" (sage of the dawn, not just night) and Lightning Fixer as "번개 해결사" wasn't literal — I picked each Korean name so it lands at the same temperature the English version does. Writing the eight personality names in both languages took hours.

Hash routing shipped alongside for a concrete reason: sharing Code Report links means state has to live in the URL. #wrapped/5 puts the slide index right there. Refreshing keeps you on the same slide.

The moment I threw single-HTML away

The biggest technical call was flipping the CLI from single-HTML to a local server.

Originally the CLI parsed every JSONL, serialized the result to JSON, inlined it into a <script> tag, and produced a single giant HTML file. No server, works offline, one file to open. Clean.

Then my .claude/projects/ grew up. The HTML passed 18MB. The browser froze for 4-5 seconds just running JSON.parse. Code Report was already dead before it started.

So I flipped it. A local server on port 3939 (http.createServer) plus /api/sessions streaming. The browser pulls a 0.6MB app bundle first. Sessions arrive in batches of ten from the server. Session bodies load only on click.

It's the same shift as whole-world loading vs level streaming in UE5. You used to load the entire map into RAM. Modern open worlds stream only chunks near the player. Disk holds everything; RAM holds only what's needed. Exactly that move.

I kept single-HTML around behind a --static flag. For a handful of sessions, single-HTML is still the simplest thing that works. Pick based on the situation.

The name Memradar — and the one line

The first name was Promptale. Prompt plus tale. Emotional but doesn't tell you what the tool does.

Memradar made the intent concrete. Mem (memory) plus Radar. A radar sweeping over my memory. It also ties into my blog (radarlog.kr), so the brand carries across. And inside lives Code Report as its own screen. The product name and the feature name each do their own job.

To try it locally, it's one line:

npx memradar
Enter fullscreen mode Exit fullscreen mode

It auto-scans ~/.claude/projects/ and ~/.codex/sessions/, opens the browser into the dashboard. From there, opening Code Report kicks off the full-screen retrospective. Everything stays local. Nothing gets sent anywhere.

There's a web version too. At memradar.vercel.app you can drag the .claude folder straight in.

The first time I looked at my own logs through this, what hit me was how much more I'd been talking to Claude than I realized. And how focused I was late at night. Seeing it as numbers, it finally clicked: "so this is how I've been living."

That's the point of Code Report. Take the conversations I left behind — and let me look back on them.

"My logs were on my disk the whole time. I just wasn't looking."

Top comments (0)