GitHub Join Date

Displays user's join date/time/age.

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         GitHub Join Date
// @description  Displays user's join date/time/age.
// @icon         https://github.githubassets.com/favicons/favicon-dark.svg
// @version      1.5
// @author       afkarxyz
// @namespace    https://github.com/afkarxyz/userscripts/
// @supportURL   https://github.com/afkarxyz/userscripts/issues
// @license      MIT
// @match        https://github.com/*
// @grant        GM_xmlhttpRequest
// @connect      api.codetabs.com
// @connect      api.cors.lol
// @connect      api.allorigins.win
// @connect      everyorigin.jwvbremen.nl
// @connect      api.github.com
// @require      https://cdn.jsdelivr.net/npm/[email protected]/cdn.min.js
// @run-at       document-idle
// ==/UserScript==

;(() => {
  const ELEMENT_ID = "userscript-join-date-display"
  const CACHE_KEY = "githubUserJoinDatesCache_v1"

  let isProcessing = false
  let observerDebounceTimeout = null

  const proxyServices = [
    {
      name: "Direct GitHub API",
      url: "https://api.github.com/users/",
      parseResponse: (response) => {
        return JSON.parse(response)
      },
    },
    {
      name: "CodeTabs Proxy",
      url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/users/",
      parseResponse: (response) => {
        return JSON.parse(response)
      },
    },
    {
      name: "CORS.lol Proxy",
      url: "https://api.cors.lol/?url=https://api.github.com/users/",
      parseResponse: (response) => {
        return JSON.parse(response)
      },
    },
    {
      name: "AllOrigins Proxy",
      url: "https://api.allorigins.win/get?url=https://api.github.com/users/",
      parseResponse: (response) => {
        const parsed = JSON.parse(response)
        return JSON.parse(parsed.contents)
      },
    },
    {
      name: "EveryOrigin Proxy",
      url: "https://everyorigin.jwvbremen.nl/api/get?url=https://api.github.com/users/",
      parseResponse: (response) => {
        const parsed = JSON.parse(response)
        return JSON.parse(parsed.html)
      },
    },
  ]

  function readCache() {
    try {
      const cachedData = localStorage.getItem(CACHE_KEY)
      return cachedData ? JSON.parse(cachedData) : {}
    } catch (e) {
      return {}
    }
  }

  function writeCache(cacheData) {
    try {
      localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData))
    } catch (e) {

    }
  }

  function formatDate(isoDateStr) {
    const joinDate = new Date(isoDateStr)
    const now = new Date()
    
    const datePart = dateFns.format(joinDate, "dd MMM yyyy")
    const timePart = dateFns.format(joinDate, "HH:mm")
    
    let ageText = ""
    
    if (dateFns.differenceInYears(now, joinDate) > 0) {
      const years = dateFns.differenceInYears(now, joinDate)
      ageText = `${years} year${years !== 1 ? "s" : ""}`
    } else if (dateFns.differenceInMonths(now, joinDate) > 0) {
      const months = dateFns.differenceInMonths(now, joinDate)
      ageText = `${months} month${months !== 1 ? "s" : ""}`
    } else if (dateFns.differenceInDays(now, joinDate) > 0) {
      const days = dateFns.differenceInDays(now, joinDate)
      ageText = `${days} day${days !== 1 ? "s" : ""}`
    } else if (dateFns.differenceInHours(now, joinDate) > 0) {
      const hours = dateFns.differenceInHours(now, joinDate)
      ageText = `${hours} hour${hours !== 1 ? "s" : ""}`
    } else {
      const minutes = dateFns.differenceInMinutes(now, joinDate)
      ageText = `${minutes} minute${minutes !== 1 ? "s" : ""}`
    }

    return `${datePart} - ${timePart} (${ageText} ago)`
  }

  async function fetchFromApi(proxyService, username) {
    const apiUrl = `${proxyService.url}${username}`

    return new Promise((resolve) => {
      if (typeof GM_xmlhttpRequest === "undefined") {
        console.error("GM_xmlhttpRequest is not defined. Make sure your userscript manager supports it.")
        resolve({ success: false, error: "GM_xmlhttpRequest is not defined" })
        return
      }
      GM_xmlhttpRequest({
        method: "GET",
        url: apiUrl,
        headers: {
          Accept: "application/vnd.github.v3+json",
        },
        onload: (response) => {
          if (response.responseText.includes("limit") && response.responseText.includes("API")) {
            resolve({
              success: false,
              error: "Rate limit exceeded",
              isRateLimit: true,
            })
            return
          }

          if (response.status >= 200 && response.status < 300) {
            try {
              const userData = proxyService.parseResponse(response.responseText)
              const createdAt = userData.created_at
              if (createdAt) {
                resolve({ success: true, data: createdAt })
              } else {
                resolve({ success: false, error: "Missing creation date" })
              }
            } catch (e) {
              resolve({ success: false, error: "JSON parse error" })
            }
          } else {
            resolve({
              success: false,
              error: `Status ${response.status}`,
            })
          }
        },
        onerror: () => {
          resolve({ success: false, error: "Network error" })
        },
        ontimeout: () => {
          resolve({ success: false, error: "Timeout" })
        },
      })
    })
  }

  async function getGitHubJoinDate(username) {
    for (let i = 0; i < proxyServices.length; i++) {
      const proxyService = proxyServices[i]
      const result = await fetchFromApi(proxyService, username)

      if (result.success) {
        return result.data
      }
    }
    return null
  }

  function removeExistingElement() {
    const existingElement = document.getElementById(ELEMENT_ID)
    if (existingElement) {
      existingElement.remove()
    }
  }
  
  function formatJoinDateElement(createdAtISO) {
    return `<strong>Joined</strong> <span style="font-weight: normal;">${formatDate(createdAtISO)}</span>`
  }
  
  function updateJoinDateDisplay() {
    const joinElement = document.getElementById(ELEMENT_ID)
    if (!joinElement || !joinElement.dataset.joinDate) return
    
    joinElement.innerHTML = formatJoinDateElement(joinElement.dataset.joinDate)
  }

  async function addOrUpdateJoinDateElement() {
    if (document.getElementById(ELEMENT_ID) && !isProcessing) {
      updateJoinDateDisplay()
      return
    }
    if (isProcessing) {
      return
    }

    const pathParts = window.location.pathname.split("/").filter((part) => part)
    if (
      pathParts.length < 1 ||
      pathParts.length > 2 ||
      (pathParts.length === 2 && !["sponsors", "followers", "following"].includes(pathParts[1]))
    ) {
      removeExistingElement()
      return
    }

    const usernameElement =
      document.querySelector(".p-nickname.vcard-username") || document.querySelector("h1.h2.lh-condensed")

    if (!usernameElement) {
      removeExistingElement()
      return
    }

    const username = pathParts[0].toLowerCase()

    isProcessing = true
    let joinElement = document.getElementById(ELEMENT_ID)
    let createdAtISO = null
    let fromCache = false

    try {
      const cache = readCache()
      if (cache[username]) {
        createdAtISO = cache[username]
        fromCache = true
      }

      if (!joinElement) {
        joinElement = document.createElement("div")
        joinElement.id = ELEMENT_ID
        joinElement.innerHTML = fromCache ? "..." : "Loading..."
        joinElement.style.color = "var(--color-fg-muted)"
        joinElement.style.fontSize = "14px"
        joinElement.style.fontWeight = "normal"

        if (usernameElement.classList.contains("h2")) {
          joinElement.style.marginTop = "0px"

          const colorFgMuted = usernameElement.nextElementSibling?.classList.contains("color-fg-muted")
            ? usernameElement.nextElementSibling
            : null

          if (colorFgMuted) {
            const innerDiv = colorFgMuted.querySelector("div") || colorFgMuted
            innerDiv.appendChild(joinElement)
          } else {
            usernameElement.insertAdjacentElement("afterend", joinElement)
          }
        } else {
          joinElement.style.marginTop = "8px"
          usernameElement.insertAdjacentElement("afterend", joinElement)
        }
      }

      if (!fromCache) {
        createdAtISO = await getGitHubJoinDate(username)
        joinElement = document.getElementById(ELEMENT_ID)
        if (!joinElement) {
          return
        }

        if (createdAtISO) {
          const currentCache = readCache()
          currentCache[username] = createdAtISO
          writeCache(currentCache)
        } else {
          removeExistingElement()
          return
        }
      }

      if (createdAtISO && joinElement) {
        joinElement.dataset.joinDate = createdAtISO
        joinElement.innerHTML = formatJoinDateElement(createdAtISO)
      } else if (!createdAtISO && joinElement) {
        removeExistingElement()
      }
    } catch (error) {
      removeExistingElement()
    } finally {
      isProcessing = false
    }
  }

  function handlePotentialPageChange() {
    clearTimeout(observerDebounceTimeout)
    observerDebounceTimeout = setTimeout(() => {
      addOrUpdateJoinDateElement()
    }, 600)
  }

  addOrUpdateJoinDateElement()

  const observer = new MutationObserver((mutationsList) => {
    let potentiallyRelevantChange = false
    for (const mutation of mutationsList) {
      if (mutation.type === "childList" && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) {
        const targetNode = mutation.target
        if (targetNode && targetNode.matches?.("main, main *, .Layout-sidebar, .Layout-sidebar *, body")) {
          let onlySelfChange = false
          if (
            (mutation.addedNodes.length === 1 &&
              mutation.addedNodes[0].id === ELEMENT_ID &&
              mutation.removedNodes.length === 0) ||
            (mutation.removedNodes.length === 1 &&
              mutation.removedNodes[0].id === ELEMENT_ID &&
              mutation.addedNodes.length === 0)
          ) {
            onlySelfChange = true
          }
          if (!onlySelfChange) {
            potentiallyRelevantChange = true
            break
          }
        }
      }
    }
    if (potentiallyRelevantChange) {
      handlePotentialPageChange()
    }
  })
  observer.observe(document.body, { childList: true, subtree: true })
  
  setInterval(updateJoinDateDisplay, 60000)
})()