GitHub Repo Age

Displays repository creation date/time/age.

// ==UserScript==
// @name         GitHub Repo Age
// @description  Displays repository creation date/time/age.
// @icon         https://github.githubassets.com/favicons/favicon-dark.svg
// @version      1.4
// @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
// ==/UserScript==

;(() => {
    const CACHE_KEY_PREFIX = "github_repo_created_"
  
    const proxyServices = [
      {
        name: "Direct GitHub API",
        url: "https://api.github.com/repos/",
        parseResponse: (response) => {
          return JSON.parse(response)
        },
      },
      {
        name: "CodeTabs Proxy",
        url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/repos/",
        parseResponse: (response) => {
          return JSON.parse(response)
        },
      },
      {
        name: "CORS.lol Proxy",
        url: "https://api.cors.lol/?url=https://api.github.com/repos/",
        parseResponse: (response) => {
          return JSON.parse(response)
        },
      },
      {
        name: "AllOrigins Proxy",
        url: "https://api.allorigins.win/get?url=https://api.github.com/repos/",
        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/repos/",
        parseResponse: (response) => {
          const parsed = JSON.parse(response)
          return JSON.parse(parsed.html)
        },
      },
    ]
  
    const selectors = {
      desktop: [".BorderGrid-cell .hide-sm.hide-md .f4.my-3", ".BorderGrid-cell"],
      mobile: [
        ".d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5 .f4.mb-3.color-fg-muted",
        ".d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5 .d-flex.gap-2.mt-n3.mb-3.flex-wrap",
        ".d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5",
      ],
    }
  
    let currentRepoPath = ""
  
    function formatDate(isoDateStr) {
      const createdDate = new Date(isoDateStr)
      const now = new Date()
      const diffTime = Math.abs(now - createdDate)
  
      const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
      const diffHours = Math.floor((diffTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
      const diffMinutes = Math.floor((diffTime % (1000 * 60 * 60)) / (1000 * 60))
  
      const diffMonths = Math.floor(diffDays / 30.44)
      const diffYears = Math.floor(diffMonths / 12)
      const remainingMonths = diffMonths % 12
      const remainingDays = Math.floor(diffDays % 30.44)
  
      const datePart = createdDate.toLocaleDateString("en-GB", {
        day: "2-digit",
        month: "short",
        year: "numeric",
      })
  
      const timePart = createdDate.toLocaleTimeString("en-GB", {
        hour: "2-digit",
        minute: "2-digit",
        hour12: false,
      })
  
      let ageText = ""
  
      if (diffYears > 0) {
        ageText = `${diffYears} year${diffYears !== 1 ? "s" : ""}`
        if (remainingMonths > 0) {
          ageText += ` ${remainingMonths} month${remainingMonths !== 1 ? "s" : ""}`
        }
      } else if (diffMonths > 0) {
        ageText = `${diffMonths} month${diffMonths !== 1 ? "s" : ""}`
        if (remainingDays > 0) {
          ageText += ` ${remainingDays} day${remainingDays !== 1 ? "s" : ""}`
        }
      } else if (diffDays > 0) {
        ageText = `${diffDays} day${diffDays !== 1 ? "s" : ""}`
        if (diffHours > 0) {
          ageText += ` ${diffHours} hour${diffHours !== 1 ? "s" : ""}`
        }
      } else if (diffHours > 0) {
        ageText = `${diffHours} hour${diffHours !== 1 ? "s" : ""}`
        if (diffMinutes > 0) {
          ageText += ` ${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""}`
        }
      } else {
        ageText = `${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""}`
      }
  
      return `${datePart} - ${timePart} (${ageText} ago)`
    }
  
    const cache = {
      getKey: (user, repo) => `${CACHE_KEY_PREFIX}${user}_${repo}`,
  
      get: function (user, repo) {
        try {
          const key = this.getKey(user, repo)
          const cachedValue = localStorage.getItem(key)
          if (!cachedValue) return null
          return JSON.parse(cachedValue)
        } catch (err) {
          return null
        }
      },
  
      set: function (user, repo, value) {
        try {
          const key = this.getKey(user, repo)
          localStorage.setItem(key, JSON.stringify(value))
        } catch (err) {
          // Storage error - continue without caching
        }
      },
    }
  
    async function fetchFromApi(proxyService, user, repo) {
      const apiUrl = `${proxyService.url}${user}/${repo}`
  
      return new Promise((resolve) => {
        if (typeof GM_xmlhttpRequest === "undefined") {
          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 data = proxyService.parseResponse(response.responseText)
                const createdAt = data.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 getRepoCreationDate(user, repo) {
      const cachedDate = cache.get(user, repo)
      if (cachedDate) {
        return cachedDate
      }
  
      for (let i = 0; i < proxyServices.length; i++) {
        const proxyService = proxyServices[i]
        const result = await fetchFromApi(proxyService, user, repo)
  
        if (result.success) {
          cache.set(user, repo, result.data)
          return result.data
        }
      }
  
      return null
    }
  
    async function insertCreatedDate() {
      const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/)
      if (!match) return false
  
      const [_, user, repo] = match
      const repoPath = `${user}/${repo}`
  
      currentRepoPath = repoPath
  
      const createdAt = await getRepoCreationDate(user, repo)
      if (!createdAt) return false
  
      const formattedDate = formatDate(createdAt)
      let insertedCount = 0
  
      document.querySelectorAll(".repo-created-date").forEach((el) => el.remove())
  
      for (const [view, selectorsList] of Object.entries(selectors)) {
        for (const selector of selectorsList) {
          const element = document.querySelector(selector)
          if (element && !element.querySelector(`.repo-created-${view}`)) {
            insertDateElement(element, formattedDate, view)
            insertedCount++
            break
          }
        }
      }
  
      return insertedCount > 0
    }
  
    function insertDateElement(targetElement, formattedDate, view) {
      const p = document.createElement("p")
      p.className = `f6 color-fg-muted repo-created-date repo-created-${view}`
      p.style.marginTop = "4px"
      p.style.marginBottom = "8px"
      p.innerHTML = `<strong>Created</strong> ${formattedDate}`
  
      if (view === "mobile") {
        const flexWrap = targetElement.querySelector(".flex-wrap")
        if (flexWrap) {
          flexWrap.parentNode.insertBefore(p, flexWrap.nextSibling)
          return
        }
  
        const dFlex = targetElement.querySelector(".d-flex")
        if (dFlex) {
          dFlex.parentNode.insertBefore(p, dFlex.nextSibling)
          return
        }
      }
  
      targetElement.insertBefore(p, targetElement.firstChild)
    }
  
    function checkAndInsertWithRetry(retryCount = 0, maxRetries = 5) {
      insertCreatedDate().then((inserted) => {
        if (!inserted && retryCount < maxRetries) {
          const delay = Math.pow(2, retryCount) * 500
          setTimeout(() => checkAndInsertWithRetry(retryCount + 1, maxRetries), delay)
        }
      })
    }
  
    function checkForRepoChange() {
      const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/)
      if (!match) return
  
      const [_, user, repo] = match
      const repoPath = `${user}/${repo}`
  
      if (repoPath !== currentRepoPath) {
        checkAndInsertWithRetry()
      }
    }
  
    if (document.readyState === "loading") {
      document.addEventListener("DOMContentLoaded", () => checkAndInsertWithRetry())
    } else {
      checkAndInsertWithRetry()
    }
  
    const originalPushState = history.pushState
    history.pushState = function () {
      originalPushState.apply(this, arguments)
      setTimeout(checkForRepoChange, 100)
    }
  
    const originalReplaceState = history.replaceState
    history.replaceState = function () {
      originalReplaceState.apply(this, arguments)
      setTimeout(checkForRepoChange, 100)
    }
  
    window.addEventListener("popstate", () => {
      setTimeout(checkForRepoChange, 100)
    })
  
    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (
          mutation.type === "childList" &&
          (mutation.target.id === "js-repo-pjax-container" || mutation.target.id === "repository-container-header")
        ) {
          setTimeout(checkForRepoChange, 100)
          break
        }
      }
    })
  
    observer.observe(document.body, { childList: true, subtree: true })
  })()