Activity Graph

linux.do activity graph

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Activity Graph
// @namespace    http://tampermonkey.net/
// @version      2025-05-16
// @description  linux.do activity graph
// @author       You
// @match        https://linux.do/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=linux.do
// @license      MIT
// @grant        none
// ==/UserScript==

(function () {
  'use strict';
  // Constants
  const STEP = 5 // Step size for color levels
  const COLORS = [
    '#ebedf0',  // 0
    '#9be9a8',  // 1
    '#40c463',  // 2
    '#30a14e',  // 3
    '#216e39',  // 4
    '#1b5e2e',  // 5
    '#154c25',  // 6
    '#0f3a1c',  // 7
    '#0a2813',  // 8
    '#05160a',  // 9
    '#030d06',  // 10
    '#020a04',  // 11
    '#010703',  // 12
    '#010502',  // 13
    '#000401',  // 14
    '#000301',  // 15
    '#000201',  // 16
    '#000100',  // 17
    '#000000'   // 18+
  ]

  // Inject styles
  const styles = `
.activity-graph {
  display: grid;
  grid-template-columns: repeat(53, 1fr);
  gap: 3px;
  padding: 20px;
  background: white;
  border-radius: 6px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
  margin-bottom: 30px;
}

.activity-day {
  width: 10px;
  height: 10px;
  border-radius: 2px;
  transition: transform 0.2s ease;
}

.activity-day:hover {
  transform: scale(1.5);
  z-index: 1;
}

.tooltip {
  position: fixed;
  display: none;
  background: rgba(0, 0, 0, 0.8);
  color: white;
  padding: 8px 12px;
  border-radius: 6px;
  font-size: 12px;
  pointer-events: none;
  z-index: 1000;
  white-space: nowrap;
  transform: translate(10px, -50%);
}

.tooltip-content {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.tooltip-date {
  font-weight: bold;
}

.tooltip-counts {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin-top: 4px;
}

.tooltip-count-item {
  color: #ccc;
}

.tooltip-total {
  margin-top: 4px;
  padding-top: 4px;
  border-top: 1px solid rgba(255, 255, 255, 0.2);
  font-weight: bold;
  color: #9be9a8;
}
`

  // Create and inject style element
  const styleElement = document.createElement('style')
  styleElement.textContent = styles
  document.head.appendChild(styleElement)

  // Function to process the data and create activity map
  function processActivityData(data) {
    const activityMap = new Map()

    data.user_actions.forEach(action => {
      const date = new Date(action.created_at).toISOString().split('T')[0]
      if (!activityMap.has(date)) {
        activityMap.set(date, {
          count: 0,
          posts: new Map()
        })
      }
      const dayData = activityMap.get(date)
      dayData.count++

      // Group by action type
      if (!dayData.posts.has(action.action_type)) {
        dayData.posts.set(action.action_type, 0)
      }
      dayData.posts.set(action.action_type, dayData.posts.get(action.action_type) + 1)
    })

    return activityMap
  }

  // Function to create empty activity map
  function createEmptyActivityMap() {
    const activityMap = new Map()
    const today = new Date()
    for (let i = 0; i < 365; i++) {
      const date = new Date(today)
      date.setDate(date.getDate() - i)
      const dateStr = date.toISOString().split('T')[0]
      activityMap.set(dateStr, {
        count: 0,
        posts: new Map()
      })
    }
    return activityMap
  }

  // Function to update graph with new data
  function updateGraph(activityMap) {
    const days = document.querySelectorAll('.activity-day')

    days.forEach(day => {
      const date = day.getAttribute('data-date')
      const dayData = activityMap.get(date) || { count: 0, posts: new Map() }
      const count = dayData.count

      // Calculate color index based on count and step
      let colorIndex = 0
      if (count > 0) {
        colorIndex = Math.min(Math.ceil(count / STEP), COLORS.length - 1)
      }
      day.style.backgroundColor = COLORS[colorIndex]

      // Update tooltip content
      const formattedDate = new Date(date).toLocaleDateString('en-US', {
        weekday: 'long',
        year: 'numeric',
        month: 'long',
        day: 'numeric'
      })

      let tooltipContent = `<div class="tooltip-date">${formattedDate}</div>`

      if (dayData.posts.size > 0) {
        tooltipContent += '<div class="tooltip-counts">'
        // Show 赞 (type 1) first
        const likeCount = dayData.posts.get(1) || 0
        if (likeCount > 0) {
          tooltipContent += `<div class="tooltip-count-item">赞: ${likeCount}</div>`
        }
        // Show 话题 (type 4) second
        const topicCount = dayData.posts.get(4) || 0
        if (topicCount > 0) {
          tooltipContent += `<div class="tooltip-count-item">话题: ${topicCount}</div>`
        }
        // Show 帖子 (type 5) third
        const postCount = dayData.posts.get(5) || 0
        if (postCount > 0) {
          tooltipContent += `<div class="tooltip-count-item">帖子: ${postCount}</div>`
        }
        tooltipContent += `<div class="tooltip-total">Total: ${dayData.count}</div>`
        tooltipContent += '</div>'
      }

      day.setAttribute('data-tooltip', tooltipContent)
    })
  }

  // Function to create the activity graph
  function createActivityGraph(activityMap) {
    const container = document.createElement('div')
    container.className = 'activity-graph'

    // Create tooltip element
    const tooltip = document.createElement('div')
    tooltip.className = 'tooltip'
    document.body.appendChild(tooltip)

    // Get the last 365 days
    const today = new Date()
    const days = []
    for (let i = 0; i < 365; i++) {
      const date = new Date(today)
      date.setDate(date.getDate() - i)
      days.unshift(date.toISOString().split('T')[0])
    }

    // Create the graph
    days.forEach(date => {
      const day = document.createElement('div')
      day.className = 'activity-day'
      day.setAttribute('data-date', date)
      const dayData = activityMap.get(date) || { count: 0, posts: new Map() }
      const count = dayData.count

      // Calculate color index based on count and step
      let colorIndex = 0
      if (count > 0) {
        colorIndex = Math.min(Math.ceil(count / STEP), COLORS.length - 1)
      }
      day.style.backgroundColor = COLORS[colorIndex]

      // Add hover events for tooltip
      day.addEventListener('mousemove', (e) => {
        tooltip.innerHTML = day.getAttribute('data-tooltip')
        tooltip.style.display = 'block'

        // Position tooltip at cursor
        tooltip.style.left = `${e.pageX}px`
        tooltip.style.top = `${e.pageY}px`
      })

      day.addEventListener('mouseleave', () => {
        tooltip.style.display = 'none'
      })

      container.appendChild(day)
    })

    return container
  }

  // Function to fetch data with pagination
  async function fetchAllData() {
    const allData = { user_actions: [] }
    const limit = 30
    const urlParams = new URLSearchParams(window.location.search)
    const fetchCount = parseInt(urlParams.get('count')) || 10 // Get count from URL or fallback to 10

    try {
      // Get username from URL
      const username = window.location.pathname.split('/')[2]

      for (let i = 0; i < fetchCount; i++) {
        const offset = i * limit
        const response = await fetch(`https://linux.do/user_actions.json?offset=${offset}&username=${username}&filter=1,4,5`)
        const data = await response.json()
        allData.user_actions = [...allData.user_actions, ...data.user_actions]

        // Update graph with new data
        const activityMap = processActivityData(allData)
        updateGraph(activityMap)
      }
      return allData
    } catch (error) {
      console.error('Error fetching data:', error)
      return allData
    }
  }

  // Function to check if URL matches pattern
  function isUserSummaryPage() {
    return window.location.pathname.match(/^\/u\/[^/]+\/summary$/)
  }

  // Function to remove existing graph
  function removeExistingGraph() {
    const existingGraph = document.querySelector('.activity-graph')
    if (existingGraph) {
      existingGraph.remove()
    }
  }

  // Function to get target container
  function getTargetContainer() {
    return document.querySelector('#user-content') || document.querySelector('.user-content')
  }

  // Fetch data and create graph
  async function init() {
    if (!isUserSummaryPage()) {
      return
    }

    try {
      removeExistingGraph()

      // Wait for target container to be available
      let container = getTargetContainer()
      if (!container) {
        // Wait for DOM to be ready
        await new Promise(resolve => {
          const observer = new MutationObserver((mutations, obs) => {
            container = getTargetContainer()
            if (container) {
              obs.disconnect()
              resolve()
            }
          })
          observer.observe(document.body, {
            childList: true,
            subtree: true
          })
        })
      }

      // Create and show empty graph immediately
      const emptyActivityMap = createEmptyActivityMap()
      const graph = createActivityGraph(emptyActivityMap)
      container.prepend(graph)

      // Fetch and update data
      await fetchAllData()
    } catch (error) {
      console.error('Error creating graph:', error)
    }
  }

  // Initialize the graph
  init()

  // Monitor URL changes
  let lastUrl = location.href
  new MutationObserver(() => {
    const currentUrl = location.href
    if (currentUrl !== lastUrl) {
      lastUrl = currentUrl
      init()
    }
  }).observe(document, { subtree: true, childList: true })
})();