Commit bb67855

Anton Medvedev <anton@medv.io>
2023-10-06 10:15:40
Add time-of-commit badges
1 parent 4536819
src/all-badges/time-of-commit/evening-commits.png
Binary file
src/all-badges/time-of-commit/midnight-commits.png
Binary file
src/all-badges/time-of-commit/morning-commits.png
Binary file
src/all-badges/time-of-commit/time-of-commit.ts
@@ -0,0 +1,183 @@
+import {BadgePresenter, Present} from '../../badges.js'
+import {Commit, Data, User} from '../../collect/collect.js'
+
+export default new class implements BadgePresenter {
+  url = new URL(import.meta.url)
+  badges = [
+    'midnight-commits',
+    'morning-commits',
+    'evening-commits',
+  ] as const
+  present: Present = (data, grant) => {
+    const midnightCommits: Commit[] = []
+    const morningCommits: Commit[] = []
+    const eveningCommits: Commit[] = []
+
+    const timezoneOffset = guessTimezone(data.user)
+
+    for (const repo of data.repos) {
+      for (const commit of repo.commits) {
+        const date = new Date(commit.committedDate)
+
+        date.setHours(date.getUTCHours() + timezoneOffset)
+
+        if (date.getUTCHours() == 0 && date.getUTCMinutes() < 5) {
+          midnightCommits.push(commit)
+        }
+        if (date.getUTCHours() >= 4 && date.getUTCHours() < 6) {
+          morningCommits.push(commit)
+        }
+        if (date.getUTCHours() >= 21) {
+          eveningCommits.push(commit)
+        }
+      }
+    }
+
+    if (midnightCommits.length > 0) {
+      grant('midnight-commits', 'I commit at midnight.')
+        .evidenceCommits(...midnightCommits.sort(latest).slice(0, 6))
+    }
+    if (morningCommits.length > 0) {
+      grant('morning-commits', 'I commit in the morning.')
+        .evidenceCommits(...morningCommits.sort(latest).slice(0, 6))
+    }
+    if (eveningCommits.length > 0) {
+      grant('evening-commits', 'I commit in the evening.')
+        .evidenceCommits(...eveningCommits.sort(latest).slice(0, 6))
+    }
+  }
+}
+
+function latest(a: Commit, b: Commit) {
+  return new Date(b.committedDate).getTime() - new Date(a.committedDate).getTime()
+}
+
+function guessTimezone(user: User) {
+  const location = user.location ? user.location.toLowerCase() : ''
+  const regexMapping = [
+    {pattern: /\bamsterdam\b|\bnetherlands\b/, offset: 2},
+    {pattern: /\bantwerp\b|\bghent\b/, offset: 2},
+    {pattern: /\bargentina\b|\bbuenos aires\b/, offset: -3},
+    {pattern: /\bathens\b|\bgreece\b/, offset: 3},
+    {pattern: /\bbangkok\b|\bthailand\b/, offset: 7},
+    {pattern: /\bbarcelona\b|\bvalencia\b|\bseville\b/, offset: 2},
+    {pattern: /\bbeijing\b|\bchina\b/, offset: 8},
+    {pattern: /\bbelgrade\b|\bserbia\b/, offset: 2},
+    {pattern: /\bberlin\b|\bgermany\b/, offset: 2},
+    {pattern: /\bbilbao\b|\bzaragoza\b/, offset: 2},
+    {pattern: /\bbordeaux\b|\bmarseille\b|\blyon\b/, offset: 2},
+    {pattern: /\bbratislava\b|\bslovakia\b/, offset: 2},
+    {pattern: /\bbrazil\b|\bsao paulo\b|\brio de janeiro\b/, offset: -3},
+    {pattern: /\bbrussels\b|\bbelgium\b/, offset: 2},
+    {pattern: /\bbucharest\b|\bromania\b/, offset: 3},
+    {pattern: /\bbudapest\b|\bhungary\b/, offset: 2},
+    {pattern: /\bcairo\b|\begypt\b/, offset: 2},
+    {pattern: /\bcasablanca\b|\bmorocco\b/, offset: 1},
+    {pattern: /\bchicago\b|\billinois\b|\bil\b/, offset: -5},
+    {pattern: /\bchile\b|\bsantiago\b/, offset: -3},
+    {pattern: /\bchisinau\b|\bmoldova\b/, offset: 3},
+    {pattern: /\bcolombia\b|\bbogota\b/, offset: -5},
+    {pattern: /\bcopenhagen\b|\bdenmark\b/, offset: 2},
+    {pattern: /\bcosta rica\b|\bsan jose\b/, offset: -6},
+    {pattern: /\bcrete\b|\bthessaloniki\b/, offset: 3},
+    {pattern: /\bdhaka\b|\bbangladesh\b/, offset: 6},
+    {pattern: /\bdubai\b|\buae\b|\babu dhabi\b/, offset: 4},
+    {pattern: /\bdublin\b|\bireland\b/, offset: 1},
+    {pattern: /\becuador\b|\bquito\b/, offset: -5},
+    {pattern: /\bfaro\b|\bevora\b/, offset: 1},
+    {pattern: /\bfiji\b/, offset: 12},
+    {pattern: /\bflorence\b|\bpalermo\b/, offset: 2},
+    {pattern: /\bflorida\b|\bmiami\b/, offset: -4},
+    {pattern: /\bgdansk\b|\bwroclaw\b/, offset: 2},
+    {pattern: /\bgeneva\b|\bbern\b/, offset: 2},
+    {pattern: /\bgenoa\b|\bturin\b/, offset: 2},
+    {pattern: /\bglasgow\b|\bliverpool\b|\bmanchester\b/, offset: 0},
+    {pattern: /\bgothenburg\b|\bmalmo\b/, offset: 2},
+    {pattern: /\bhamburg\b|\bmunich\b|\bfrankfurt\b/, offset: 2},
+    {pattern: /\bhawaii\b/, offset: -10},
+    {pattern: /\bhelsinki\b|\bfinland\b/, offset: 3},
+    {pattern: /\biraq\b|\bbaghdad\b/, offset: 3},
+    {pattern: /\bisrael\b|\btel aviv\b/, offset: 3},
+    {pattern: /\bjakarta\b|\bindonesia\b/, offset: 7},
+    {pattern: /\bjohannesburg\b|\bsouth africa\b/, offset: 2},
+    {pattern: /\bjordan\b|\bamman\b/, offset: 3},
+    {pattern: /\bkathmandu\b|\bnepal\b/, offset: 5.75},
+    {pattern: /\bkiev\b|\bukraine\b/, offset: 3},
+    {pattern: /\bkuala lumpur\b|\bmalaysia\b/, offset: 8},
+    {pattern: /\blagos\b|\bnigeria\b/, offset: 1},
+    {pattern: /\bled\b|\bslovenia\b/, offset: 2},
+    {pattern: /\bledz\b|\bkrakow\b/, offset: 2},
+    {pattern: /\blisbon\b|\bportugal\b/, offset: 1},
+    {pattern: /\bljubljana\b|\bslovenia\b/, offset: 2},
+    {pattern: /\blondon\b|\buk\b/, offset: 0},
+    {pattern: /\blos angeles\b|\bcalifornia\b|\bca\b/, offset: -7},
+    {pattern: /\bluxembourg\b/, offset: 2},
+    {pattern: /\blyon\b|\bnantes\b/, offset: 2},
+    {pattern: /\bmadrid\b|\bspain\b/, offset: 2},
+    {pattern: /\bmalta\b|\bvalletta\b/, offset: 2},
+    {pattern: /\bmelbourne\b/, offset: 11},
+    {pattern: /\bmexico\b|\bmexico city\b/, offset: -5},
+    {pattern: /\bminsk\b|\bbelarus\b/, offset: 3},
+    {pattern: /\bmonaco\b/, offset: 2},
+    {pattern: /\bmontreal\b|\bquebec\b/, offset: -4},
+    {pattern: /\bmoscow\b|\brussia\b/, offset: 3},
+    {pattern: /\bmumbai\b|\bindia\b/, offset: 5.5},
+    {pattern: /\bnairobi\b|\bkenya\b/, offset: 3},
+    {pattern: /\bnaples\b|\bmilan\b|\bvenice\b/, offset: 2},
+    {pattern: /\bnew delhi\b/, offset: 5.5},
+    {pattern: /\bnew york\b|\bny\b/, offset: -4},
+    {pattern: /\bnew zealand\b|\bauckland\b/, offset: 13},
+    {pattern: /\bnice\b|\btoulouse\b/, offset: 2},
+    {pattern: /\bnicosia\b|\bcyprus\b/, offset: 3},
+    {pattern: /\bodessa\b|\blviv\b/, offset: 3},
+    {pattern: /\boslo\b|\bnorway\b/, offset: 2},
+    {pattern: /\bpakistan\b|\blahore\b|\bkarachi\b/, offset: 5},
+    {pattern: /\bpanama\b|\bpanama city\b/, offset: -5},
+    {pattern: /\bpapua new guinea\b|\bport moresby\b/, offset: 10},
+    {pattern: /\bparis\b|\bfrance\b/, offset: 2},
+    {pattern: /\bperu\b|\blima\b/, offset: -5},
+    {pattern: /\bpodgorica\b|\bmontenegro\b/, offset: 2},
+    {pattern: /\bporto\b|\bbraga\b/, offset: 1},
+    {pattern: /\bprague\b|\bczech republic\b|\bczechia\b/, offset: 2},
+    {pattern: /\breykjavik\b|\biceland\b/, offset: 0},
+    {pattern: /\briga\b|\blatvia\b/, offset: 3},
+    {pattern: /\brome\b|\bitaly\b/, offset: 2},
+    {pattern: /\brotterdam\b|\butrecht\b/, offset: 2},
+    {pattern: /\bsalzburg\b|\bgraz\b/, offset: 2},
+    {pattern: /\bsarajevo\b|\bbosnia and herzegovina\b/, offset: 2},
+    {pattern: /\bsaudi arabia\b|\briyadh\b/, offset: 3},
+    {pattern: /\bseoul\b|\bsouth korea\b/, offset: 9},
+    {pattern: /\bshanghai\b/, offset: 8},
+    {pattern: /\bsheffield\b|\bleeds\b/, offset: 0},
+    {pattern: /\bsingapore\b/, offset: 8},
+    {pattern: /\bskopje\b|\bnorth macedonia\b/, offset: 2},
+    {pattern: /\bsofia\b|\bbulgaria\b/, offset: 3},
+    {pattern: /\bstockholm\b|\bsweden\b/, offset: 2},
+    {pattern: /\bstuttgart\b|\bdusseldorf\b/, offset: 2},
+    {pattern: /\bsydney\b|\baustralia\b/, offset: 11},
+    {pattern: /\btallinn\b|\bestonia\b/, offset: 3},
+    {pattern: /\btehran\b|\biran\b/, offset: 3.5},
+    {pattern: /\btexas\b|\bhouston\b|\bdallas\b/, offset: -5},
+    {pattern: /\btirana\b|\balbania\b/, offset: 2},
+    {pattern: /\btokyo\b|\bjapan\b/, offset: 9},
+    {pattern: /\btoronto\b|\bontario\b/, offset: -4},
+    {pattern: /\btunisia\b|\btunis\b/, offset: 1},
+    {pattern: /\bvalencia\b|\bmalaga\b/, offset: 2},
+    {pattern: /\bvancouver\b|\bbritish columbia\b/, offset: -7},
+    {pattern: /\bvenezuela\b|\bcaracas\b/, offset: -4},
+    {pattern: /\bvienna\b|\baustria\b/, offset: 2},
+    {pattern: /\bvilnius\b|\blithuania\b/, offset: 3},
+    {pattern: /\bwarsaw\b|\bpoland\b/, offset: 2},
+    {pattern: /\bzagreb\b|\bcroatia\b/, offset: 2},
+    {pattern: /\bzurich\b|\bswitzerland\b/, offset: 2},
+  ]
+
+  for (const mapping of regexMapping) {
+    if (mapping.pattern.test(location)) {
+      return mapping.offset
+    }
+  }
+
+  return 0
+}
+
src/all-badges/index.ts
@@ -6,4 +6,5 @@ export const allBadges = [
   await import('./mass-delete-commit/mass-delete-commit.js'),
   await import('./revert-revert-commit/revert-revert-commit.js'),
   await import('./yeti/yeti.js'),
+  await import('./time-of-commit/time-of-commit.js'),
 ] as const
src/collect/collect.ts
@@ -5,6 +5,7 @@ import {CommitsQuery} from './commits.js'
 import fs from 'node:fs'
 import {fileURLToPath} from 'url'
 import {IssuesQuery} from './issues.js'
+import {UserQuery} from './user.js'
 
 export type Data = {
   user: User
@@ -12,7 +13,7 @@ export type Data = {
   pulls: Pull[]
   issues: Issues[]
 }
-export type User = Endpoints['GET /users/{username}']['response']['data']
+export type User = UserQuery['user']
 export type Repo = Endpoints['GET /users/{username}/repos']['response']['data'][0] & {
   commits: Commit[]
 }
@@ -21,7 +22,7 @@ export type Pull = PullsQuery['user']['pullRequests']['nodes'][0]
 export type Issues = IssuesQuery['user']['issues']['nodes'][0]
 
 export async function collect(username: string, octokit: Octokit): Promise<Data> {
-  const user = (await octokit.request('GET /users/{username}', {username})).data
+  const {user} = await octokit.graphql<UserQuery>(loadGraphql('./user.graphql'), {login: username})
 
   const data: Data = {
     user: user,
@@ -54,7 +55,7 @@ export async function collect(username: string, octokit: Octokit): Promise<Data>
       const commits = octokit.graphql.paginate.iterator<CommitsQuery>(loadGraphql('./commits.graphql'), {
         owner: repo.owner.login,
         name: repo.name,
-        author: user.node_id,
+        author: user.id,
       })
 
       for await (const resp of commits) {
src/collect/commits.graphql
@@ -13,6 +13,7 @@ query CommitsQuery(
             totalCount
             nodes {
               sha: oid
+              committedDate
               message
               messageBody
               additions
src/collect/commits.ts
@@ -6,6 +6,7 @@ export type CommitsQuery = {
           totalCount: number
           nodes: Array<{
             sha: string
+            committedDate: string
             message: string
             messageBody: string
             additions: number
src/collect/user.graphql
@@ -0,0 +1,52 @@
+query UserQuery(
+  $login: String!,
+) {
+  user(login: $login) {
+    id
+    login
+    name
+    avatarUrl
+    bio
+    company
+    location
+    email
+    twitterUsername
+    websiteUrl
+    status {
+      createdAt
+      emoji
+      message
+    }
+    createdAt
+    followers {
+      totalCount
+    }
+    following {
+      totalCount
+    }
+    anyPinnableItems
+    pinnedItems(first: 6) {
+      totalCount
+      nodes {
+        ... on Gist {
+          name
+        }
+        ... on Repository {
+          name
+        }
+      }
+    }
+    sponsoring {
+      totalCount
+    }
+    sponsors {
+      totalCount
+    }
+  }
+  rateLimit {
+    limit
+    cost
+    remaining
+    resetAt
+  }
+}
src/collect/user.ts
@@ -0,0 +1,45 @@
+export type UserQuery = {
+  user: {
+    id: string
+    login: string
+    name: string
+    avatarUrl: string
+    bio: string | null
+    company: string | null
+    location: string | null
+    email: string
+    twitterUsername: string | null
+    websiteUrl: string | null
+    status: {
+      createdAt: string
+      emoji: string
+      message: string
+    } | null
+    createdAt: string
+    followers: {
+      totalCount: number
+    }
+    following: {
+      totalCount: number
+    }
+    anyPinnableItems: boolean
+    pinnedItems: {
+      totalCount: number
+      nodes: Array<{
+        name: string
+      }>
+    }
+    sponsoring: {
+      totalCount: number
+    }
+    sponsors: {
+      totalCount: number
+    }
+  }
+  rateLimit: {
+    limit: number
+    cost: number
+    remaining: number
+    resetAt: string
+  }
+}