Greasy Fork is available in English.

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.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
// ==/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 datePart = dateFns.format(createdDate, "dd MMM yyyy")
    
    const timePart = dateFns.format(createdDate, "HH:mm")
    
    const diffYears = dateFns.differenceInYears(now, createdDate)
    const tempDate = dateFns.addYears(createdDate, diffYears)
    
    const diffMonths = dateFns.differenceInMonths(now, tempDate)
    const tempDate2 = dateFns.addMonths(tempDate, diffMonths)
    
    const diffDays = dateFns.differenceInDays(now, tempDate2)
    const tempDate3 = dateFns.addDays(tempDate2, diffDays)
    
    const diffHours = dateFns.differenceInHours(now, tempDate3)
    const tempDate4 = dateFns.addHours(tempDate3, diffHours)
    
    const diffMinutes = dateFns.differenceInMinutes(now, tempDate4)
    
    let ageText = ""
    
    if (diffYears > 0) {
      ageText = `${diffYears} year${diffYears !== 1 ? "s" : ""}`
      if (diffMonths > 0) {
        ageText += ` ${diffMonths} month${diffMonths !== 1 ? "s" : ""}`
      }
    } else if (dateFns.differenceInMonths(now, createdDate) > 0) {
      const totalMonths = dateFns.differenceInMonths(now, createdDate)
      ageText = `${totalMonths} month${totalMonths !== 1 ? "s" : ""}`
      if (diffDays > 0) {
        ageText += ` ${diffDays} day${diffDays !== 1 ? "s" : ""}`
      }
    } else if (dateFns.differenceInDays(now, createdDate) > 0) {
      const totalDays = dateFns.differenceInDays(now, createdDate)
      ageText = `${totalDays} day${totalDays !== 1 ? "s" : ""}`
      if (diffHours > 0 && totalDays < 7) {
        ageText += ` ${diffHours} hour${diffHours !== 1 ? "s" : ""}`
      }
    } else if (dateFns.differenceInHours(now, createdDate) > 0) {
      const totalHours = dateFns.differenceInHours(now, createdDate)
      ageText = `${totalHours} hour${totalHours !== 1 ? "s" : ""}`
      if (diffMinutes > 0) {
        ageText += ` ${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""}`
      }
    } else {
      const totalMinutes = dateFns.differenceInMinutes(now, createdDate)
      ageText = `${totalMinutes} minute${totalMinutes !== 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 cachedValue
      } catch (err) {
        return null
      }
    },

    set: function (user, repo, value) {
      try {
        const key = this.getKey(user, repo)
        localStorage.setItem(key, value)
      } catch (err) {

      }
    },
  }

  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, createdAt)
          insertedCount++
          break
        }
      }
    }

    return insertedCount > 0
  }

  function insertDateElement(targetElement, formattedDate, view, isoDateStr) {
    const p = document.createElement("p")
    p.className = `f6 color-fg-muted repo-created-date repo-created-${view}`
    p.dataset.createdAt = isoDateStr
    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 updateAges() {
    document.querySelectorAll(".repo-created-date").forEach((el) => {
      const createdAt = el.dataset.createdAt
      if (createdAt) {
        const formattedDate = formatDate(createdAt)
        const strongElement = el.querySelector("strong")
        if (strongElement) {
          el.innerHTML = `<strong>Created</strong> ${formattedDate}`
        } else {
          el.innerHTML = formattedDate
        }
      }
    })
  }

  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 })
  
  setInterval(updateAges, 60000)
})()