mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
618 lines
23 KiB
TypeScript
618 lines
23 KiB
TypeScript
/**
|
||
* REWARDS: the one points system, as hanzo-inc/cloud apps/rewards answers it.
|
||
* The shapes below are the cloud's Go types (rules.go, board.go, me.go,
|
||
* redeem.go, share.go), their json tags the field names.
|
||
*
|
||
* GET /v1/rewards/rules what earns, what each earns, caps, levels, badges (public)
|
||
* GET /v1/rewards the board (?board=&period=all|month&limit=); a bearer adds `you`
|
||
* GET /v1/rewards/me the reader's standing, badges, history, posts
|
||
* GET /v1/rewards/optin whether the reader's row carries their name
|
||
* PUT /v1/rewards/optin list the reader's row under a handle, or take the name off
|
||
* POST /v1/rewards/redeem {dollars}: eligible points into service credit, capped
|
||
* GET /v1/rewards/redemptions the reader's redemptions, newest first
|
||
* POST /v1/rewards/posts {url}: a public post carrying the reader's link, held pending
|
||
* GET /v1/rewards/posts the reader's posts and what the pending ones are worth
|
||
* GET /v1/rewards/badges/<id> a badge's metadata (ERC-721 JSON)
|
||
* GET /v1/rewards/badges/<id>/art a badge's art (SVG)
|
||
* POST /v1/rewards/claim {badge, wallet}: a badge claimed on the rules' chain, once minting is on
|
||
* GET /v1/rewards/cyber the standing on the invite-only track; 404 for everyone not invited
|
||
*
|
||
* Points are reputation: non-transferable, held by one account. Only the kinds
|
||
* the rules mark `credit` redeem, for non-transferable Hanzo AI service credit
|
||
* with no cash value. Every page that says what earns what prints these rules:
|
||
* the build's snapshot (lib/data/rewards-rules.json, scripts/sync-rewards.ts)
|
||
* and, once the page is open, the endpoint's own answer.
|
||
*
|
||
* A GitHub account is linked through the person's own GitHub connection
|
||
* (/v1/provider/github/user): GitHub sends the browser back to
|
||
* https://hanzo.ai/leaderboard with a grant, and the page completes it.
|
||
*/
|
||
import { api } from '@hanzo/app/lib/api'
|
||
import { scope } from '@hanzo/app/lib/session'
|
||
import snapshot from '@/lib/data/rewards-rules.json' with { type: 'json' }
|
||
|
||
export interface Source {
|
||
id: string
|
||
name: string
|
||
}
|
||
|
||
/** One way to earn. A kind that is not `live` is part of the program and awards nothing yet. */
|
||
export interface Kind {
|
||
id: string
|
||
source: string
|
||
points: number
|
||
title: string
|
||
rule: string
|
||
/** Its points redeem for service credit. */
|
||
credit: boolean
|
||
/** Only Hanzo awards it, after review. */
|
||
manual: boolean
|
||
/** Never on the public board. */
|
||
private: boolean
|
||
live: boolean
|
||
}
|
||
|
||
/** A token milestone: awarded once, the first time recorded usage reaches `tokens`. */
|
||
export interface Tier {
|
||
tokens: number
|
||
points: number
|
||
badge: string
|
||
}
|
||
|
||
/** The most one holder earns from a source in a window: `day` (UTC) or `30d` (rolling). */
|
||
export interface Cap {
|
||
source: string
|
||
limit: number
|
||
window: string
|
||
}
|
||
|
||
/** A level, reached at `points` all time; a `substantive` one also needs reviewed work that is not a referral. */
|
||
export interface Level {
|
||
name: string
|
||
points: number
|
||
substantive: boolean
|
||
}
|
||
|
||
/** A collectible, unlocked by `count` awards of `kinds` (of `ref`, when named), or by lifetime `points`. */
|
||
export interface Badge {
|
||
id: string
|
||
name: string
|
||
group: string
|
||
rarity: string
|
||
rule: string
|
||
kinds: string[]
|
||
ref: string
|
||
count: number
|
||
points: number
|
||
live: boolean
|
||
}
|
||
|
||
export interface Mission {
|
||
id: string
|
||
name: string
|
||
goal: string
|
||
badge: string
|
||
}
|
||
|
||
/** One view of the board: the sources it ranks by, every public one when none. */
|
||
export interface Tab {
|
||
id: string
|
||
name: string
|
||
sources: string[]
|
||
}
|
||
|
||
/** A place a post can be pasted from; one that is not `verified` waits for a maintainer's review. */
|
||
export interface Network {
|
||
id: string
|
||
name: string
|
||
verified: boolean
|
||
}
|
||
|
||
export interface Redeem {
|
||
enabled: boolean
|
||
pointsPerDollar: number
|
||
memberMonthCents: number
|
||
programMonthCents: number
|
||
currency: string
|
||
}
|
||
|
||
/** Where a badge is claimed: an empty contract is no chain yet, and `minting` is false. */
|
||
export interface Chain {
|
||
id: number
|
||
name: string
|
||
coin: string
|
||
rpc: string
|
||
contract: string
|
||
minting: boolean
|
||
}
|
||
|
||
export interface Rules {
|
||
version: string
|
||
sources: Source[]
|
||
kinds: Kind[]
|
||
tiers: Tier[]
|
||
caps: Cap[]
|
||
levels: Level[]
|
||
badges: Badge[]
|
||
missions: Mission[]
|
||
tabs: Tab[]
|
||
github: { orgs: string[]; labels: Record<string, string[]>; staff: boolean }
|
||
activity: { retainDays: number }
|
||
share: { days: number; perWindow: number; networks: Network[] }
|
||
redeem: Redeem
|
||
payouts: boolean
|
||
chain: Chain
|
||
/** What a member who posts about Hanzo for points says. */
|
||
disclosure: string
|
||
/** What never earns, in plain words. */
|
||
excluded: string[]
|
||
/** When this program took over from the beta board, RFC 3339; empty before. */
|
||
cutover: string
|
||
}
|
||
|
||
/** One ranked row. `name` is null for a person who did not list their name. */
|
||
export interface Row {
|
||
rank: number
|
||
name: string | null
|
||
you: boolean
|
||
points: number
|
||
level: string
|
||
since: string
|
||
}
|
||
|
||
export interface Board {
|
||
rows: Row[]
|
||
you: Row | null
|
||
of: number
|
||
source: string
|
||
board: string
|
||
period: string
|
||
/** Which measured sources the last settle read. */
|
||
settled: { usage: boolean; referrals: boolean }
|
||
}
|
||
|
||
export interface Earned {
|
||
id: string
|
||
name: string
|
||
earned: boolean
|
||
at: string
|
||
}
|
||
|
||
/** One award in the reader's history. */
|
||
export interface Entry {
|
||
kind: string
|
||
source: string
|
||
points: number
|
||
ref: string
|
||
note: string
|
||
at: string
|
||
}
|
||
|
||
/** What a member may still redeem this month; `open` is whether the program's budget has room for a dollar. */
|
||
export interface Allowance {
|
||
monthCents: number
|
||
usedCents: number
|
||
leftCents: number
|
||
open: boolean
|
||
}
|
||
|
||
/** A post pasted for points: pending until it has stayed up its week, then confirmed or removed. */
|
||
export interface Post {
|
||
id: string
|
||
network: string
|
||
url: string
|
||
status: 'pending' | 'confirmed' | 'removed' | 'review' | string
|
||
points: number
|
||
at: string
|
||
countsAt: string
|
||
daysLeft: number
|
||
note: string
|
||
}
|
||
|
||
/** A standing on the track only some members are invited to; its names are the cloud's. */
|
||
export interface Track {
|
||
points: number
|
||
level: { name: string; points: number } | null
|
||
next: { name: string; points: number } | null
|
||
toNext: number
|
||
badges: Earned[]
|
||
}
|
||
|
||
export interface Me {
|
||
rank: number
|
||
of: number
|
||
points: number
|
||
since: string
|
||
level: { name: string; points: number }
|
||
next: { name: string; points: number } | null
|
||
/** Points to pass the row above; 0 at the top. */
|
||
toNext: number
|
||
sources: Record<string, number>
|
||
badges: Earned[]
|
||
redeemable: number
|
||
allowance: Allowance
|
||
legacy: number
|
||
private: number
|
||
staff: boolean
|
||
usage: { tokens: number; next: Tier | null } | null
|
||
github: { login: string; linked: boolean }
|
||
history: Entry[]
|
||
posts: Post[]
|
||
/** What the posts still waiting out their week are worth; never in `points`. */
|
||
pending: number
|
||
}
|
||
|
||
export interface Listing {
|
||
listed: boolean
|
||
handle: string
|
||
canSet: boolean
|
||
}
|
||
|
||
/** One redemption: `reserved` while the credit is granted, then `granted`, or `failed` with the points returned. */
|
||
export interface Redemption {
|
||
id: string
|
||
points: number
|
||
cents: number
|
||
status: 'reserved' | 'granted' | 'failed' | string
|
||
at: string
|
||
redeemable: number
|
||
allowance: Allowance | null
|
||
}
|
||
|
||
/** The page GitHub returns to; the cloud names it among the brand's return pages. */
|
||
export const RETURN = 'https://hanzo.ai/leaderboard'
|
||
|
||
/** All time is the record the levels count; this month is the competition. */
|
||
export type Period = 'all' | 'month'
|
||
|
||
const list = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
|
||
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
|
||
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||
const yes = (v: unknown): boolean => v === true
|
||
type Raw = Record<string, unknown>
|
||
const obj = (v: unknown): Raw => (v && typeof v === 'object' ? (v as Raw) : {})
|
||
|
||
/** A rules table, or null for anything that is not one. Absent fields read as their zero, never as a guess. */
|
||
export function asRules(raw: unknown): Rules | null {
|
||
const r = obj(raw)
|
||
if (!Array.isArray(r.kinds) || !r.kinds.length || !Array.isArray(r.sources) || !Array.isArray(r.levels) || !Array.isArray(r.tabs)) return null
|
||
const gh = obj(r.github)
|
||
const share = obj(r.share)
|
||
const d = obj(r.redeem)
|
||
const c = obj(r.chain)
|
||
return {
|
||
version: str(r.version),
|
||
sources: list<Raw>(r.sources).map((s) => ({ id: str(s.id), name: str(s.name) })),
|
||
kinds: list<Raw>(r.kinds).map((k) => ({
|
||
id: str(k.id),
|
||
source: str(k.source),
|
||
points: num(k.points),
|
||
title: str(k.title),
|
||
rule: str(k.rule),
|
||
credit: yes(k.credit),
|
||
manual: yes(k.manual),
|
||
private: yes(k.private),
|
||
live: yes(k.live),
|
||
})),
|
||
tiers: list<Raw>(r.tiers).map((t) => ({ tokens: num(t.tokens), points: num(t.points), badge: str(t.badge) })),
|
||
caps: list<Raw>(r.caps).map((x) => ({ source: str(x.source), limit: num(x.limit), window: str(x.window) })),
|
||
levels: list<Raw>(r.levels).map((l) => ({ name: str(l.name), points: num(l.points), substantive: yes(l.substantive) })),
|
||
badges: list<Raw>(r.badges).map((b) => ({
|
||
id: str(b.id),
|
||
name: str(b.name),
|
||
group: str(b.group),
|
||
rarity: str(b.rarity),
|
||
rule: str(b.rule),
|
||
kinds: list<string>(b.kinds),
|
||
ref: str(b.ref),
|
||
count: num(b.count),
|
||
points: num(b.points),
|
||
live: yes(b.live),
|
||
})),
|
||
missions: list<Raw>(r.missions).map((m) => ({ id: str(m.id), name: str(m.name), goal: str(m.goal), badge: str(m.badge) })),
|
||
tabs: list<Raw>(r.tabs).map((t) => ({ id: str(t.id), name: str(t.name), sources: list<string>(t.sources) })),
|
||
github: { orgs: list<string>(gh.orgs), labels: obj(gh.labels) as Record<string, string[]>, staff: yes(gh.staff) },
|
||
activity: { retainDays: num(obj(r.activity).retainDays) },
|
||
share: { days: num(share.days), perWindow: num(share.perWindow), networks: list<Raw>(share.networks).map((n) => ({ id: str(n.id), name: str(n.name), verified: yes(n.verified) })) },
|
||
redeem: {
|
||
enabled: yes(d.enabled),
|
||
pointsPerDollar: num(d.pointsPerDollar),
|
||
memberMonthCents: num(d.memberMonthCents),
|
||
programMonthCents: num(d.programMonthCents),
|
||
currency: str(d.currency) || 'usd',
|
||
},
|
||
payouts: yes(r.payouts),
|
||
chain: { id: num(c.id), name: str(c.name), coin: str(c.coin), rpc: str(c.rpc), contract: str(c.contract), minting: yes(c.minting) },
|
||
disclosure: str(r.disclosure),
|
||
excluded: list<string>(r.excluded),
|
||
cutover: str(r.cutover),
|
||
}
|
||
}
|
||
|
||
/** The build's snapshot of the rules: what a page prints before the endpoint answers. */
|
||
export const SNAPSHOT: Rules = asRules(snapshot) as Rules
|
||
|
||
/** The rules as the cloud states them now; null when it does not answer one. */
|
||
export async function rules(): Promise<Rules | null> {
|
||
try {
|
||
const res = await fetch(`${api()}/v1/rewards/rules`)
|
||
return res.ok ? asRules(await res.json()) : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** The sentence a cloud refusal carries: RFC 9457's `detail`, or a fallback. */
|
||
async function why(res: Response, fallback: string): Promise<string> {
|
||
const b = obj(await res.json().catch(() => ({})))
|
||
return str(b.detail) || str(b.message) || str(b.error) || `${fallback} (${res.status})`
|
||
}
|
||
|
||
const row = (raw: unknown): Row | null => {
|
||
const r = obj(raw)
|
||
if (typeof r.rank !== 'number') return null
|
||
return { rank: r.rank, name: str(r.name) || null, you: yes(r.you), points: num(r.points), level: str(r.level), since: str(r.since) }
|
||
}
|
||
|
||
/** The board for a tab of the rules and a period; null when the cloud does not answer one. */
|
||
export async function board(tab = '', period: Period = 'all', limit = 25): Promise<Board | null> {
|
||
const q = new URLSearchParams({ limit: String(limit), period })
|
||
if (tab) q.set('board', tab)
|
||
try {
|
||
const res = await fetch(`${api()}/v1/rewards?${q}`, { headers: scope() })
|
||
if (!res.ok) return null
|
||
const b = obj(await res.json())
|
||
const s = obj(b.settled)
|
||
return {
|
||
rows: list<unknown>(b.rows).map(row).filter((r): r is Row => r !== null),
|
||
you: row(b.you),
|
||
of: num(b.of),
|
||
source: str(b.source),
|
||
board: str(b.board),
|
||
period: str(b.period) || period,
|
||
settled: { usage: yes(s.usage), referrals: yes(s.referrals) },
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
const earned = (raw: unknown): Earned[] => list<Raw>(raw).map((b) => ({ id: str(b.id), name: str(b.name), earned: yes(b.earned), at: str(b.at) }))
|
||
const step = (raw: unknown): { name: string; points: number } | null => {
|
||
const l = obj(raw)
|
||
return typeof l.name === 'string' && l.name ? { name: l.name, points: num(l.points) } : null
|
||
}
|
||
const allowance = (raw: unknown): Allowance => {
|
||
const a = obj(raw)
|
||
return { monthCents: num(a.monthCents), usedCents: num(a.usedCents), leftCents: num(a.leftCents), open: yes(a.open) }
|
||
}
|
||
|
||
/** A post as the cloud answers it. */
|
||
export const asPost = (raw: unknown): Post => {
|
||
const p = obj(raw)
|
||
return { id: str(p.id), network: str(p.network), url: str(p.url), status: str(p.status), points: num(p.points), at: str(p.at), countsAt: str(p.countsAt), daysLeft: num(p.daysLeft), note: str(p.note) }
|
||
}
|
||
|
||
/** The reader's standing; null signed out or when the cloud does not answer. */
|
||
export async function me(): Promise<Me | null> {
|
||
const headers = scope()
|
||
if (!headers.Authorization) return null
|
||
try {
|
||
const res = await fetch(`${api()}/v1/rewards/me`, { headers })
|
||
if (!res.ok) return null
|
||
const m = obj(await res.json())
|
||
const usage = m.usage && typeof obj(m.usage).tokens === 'number' ? obj(m.usage) : null
|
||
const next = usage ? obj(usage.next) : null
|
||
const gh = obj(m.github)
|
||
return {
|
||
rank: num(m.rank),
|
||
of: num(m.of),
|
||
points: num(m.points),
|
||
since: str(m.since),
|
||
level: step(m.level) ?? { name: '', points: 0 },
|
||
next: step(m.next),
|
||
toNext: num(m.toNext),
|
||
sources: obj(m.sources) as Record<string, number>,
|
||
badges: earned(m.badges),
|
||
redeemable: num(m.redeemable),
|
||
allowance: allowance(m.allowance),
|
||
legacy: num(m.legacy),
|
||
private: num(m.private),
|
||
staff: yes(m.staff),
|
||
usage: usage ? { tokens: num(usage.tokens), next: next && typeof next.tokens === 'number' ? { tokens: num(next.tokens), points: num(next.points), badge: str(next.badge) } : null } : null,
|
||
github: { login: str(gh.login), linked: yes(gh.linked) },
|
||
history: list<Raw>(m.history).map((e) => ({ kind: str(e.kind), source: str(e.source), points: num(e.points), ref: str(e.ref), note: str(e.note), at: str(e.at) })),
|
||
posts: list<unknown>(m.posts).map(asPost),
|
||
pending: num(m.pending),
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
const json = { 'Content-Type': 'application/json' }
|
||
|
||
/** Whether the reader's row carries their name. */
|
||
export async function listing(): Promise<Listing | null> {
|
||
const headers = scope()
|
||
if (!headers.Authorization) return null
|
||
try {
|
||
const res = await fetch(`${api()}/v1/rewards/optin`, { headers })
|
||
if (!res.ok) return null
|
||
const u = obj(obj(await res.json()).user)
|
||
return { listed: yes(u.listed), handle: str(u.handle), canSet: yes(u.canSet) }
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** List the reader's row under a handle (their username when none is given), or take the name off. */
|
||
export async function setListing(listed: boolean, handle = ''): Promise<Listing | null> {
|
||
const headers = scope()
|
||
if (!headers.Authorization) return null
|
||
try {
|
||
const res = await fetch(`${api()}/v1/rewards/optin`, { method: 'PUT', headers: { ...json, ...headers }, body: JSON.stringify({ listed, handle }) })
|
||
if (!res.ok) return null
|
||
const b = obj(await res.json())
|
||
return { listed: yes(b.listed), handle: str(b.handle), canSet: true }
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
const redemption = (raw: unknown): Redemption => {
|
||
const r = obj(raw)
|
||
return { id: str(r.id), points: num(r.points), cents: num(r.cents), status: str(r.status), at: str(r.at), redeemable: num(r.redeemable), allowance: r.allowance ? allowance(r.allowance) : null }
|
||
}
|
||
|
||
/** Redeem whole dollars of service credit. Throws with the cloud's reason when it refuses. */
|
||
export async function redeem(dollars: number): Promise<Redemption> {
|
||
const res = await fetch(`${api()}/v1/rewards/redeem`, { method: 'POST', headers: { ...json, ...scope() }, body: JSON.stringify({ dollars }) })
|
||
if (!res.ok) throw new Error(await why(res, 'The points could not be redeemed'))
|
||
return redemption(await res.json())
|
||
}
|
||
|
||
/** The reader's redemptions, newest first; null signed out or unanswered. */
|
||
export async function redemptions(): Promise<Redemption[] | null> {
|
||
const headers = scope()
|
||
if (!headers.Authorization) return null
|
||
try {
|
||
const res = await fetch(`${api()}/v1/rewards/redemptions`, { headers })
|
||
return res.ok ? list<unknown>(obj(await res.json()).redemptions).map(redemption) : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** The reader's posts and what the pending ones are worth; null signed out or unanswered. */
|
||
export async function posts(): Promise<{ posts: Post[]; pending: number } | null> {
|
||
const headers = scope()
|
||
if (!headers.Authorization) return null
|
||
try {
|
||
const res = await fetch(`${api()}/v1/rewards/posts`, { headers })
|
||
if (!res.ok) return null
|
||
const b = obj(await res.json())
|
||
return { posts: list<unknown>(b.posts).map(asPost), pending: num(b.pending) }
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** Paste a post for points. Throws with the cloud's reason when it refuses one. */
|
||
export async function post(url: string): Promise<Post> {
|
||
const res = await fetch(`${api()}/v1/rewards/posts`, { method: 'POST', headers: { ...json, ...scope() }, body: JSON.stringify({ url }) })
|
||
if (!res.ok) throw new Error(await why(res, 'The post could not be added'))
|
||
return asPost(await res.json())
|
||
}
|
||
|
||
/** Claim an unlocked badge on the rules' chain, to a wallet verified on the account. Only offered while `chain.minting` is true. */
|
||
export async function claim(badge: string, wallet: string): Promise<void> {
|
||
const res = await fetch(`${api()}/v1/rewards/claim`, { method: 'POST', headers: { ...json, ...scope() }, body: JSON.stringify({ badge, wallet }) })
|
||
if (!res.ok) throw new Error(await why(res, 'The badge could not be claimed'))
|
||
}
|
||
|
||
/** The reader's standing on the invite-only track; null for everyone the cloud does not let in (it answers 404). */
|
||
export async function track(): Promise<Track | null> {
|
||
const headers = scope()
|
||
if (!headers.Authorization) return null
|
||
try {
|
||
const res = await fetch(`${api()}/v1/rewards/cyber`, { headers })
|
||
if (!res.ok) return null
|
||
const v = obj(await res.json())
|
||
return { points: num(v.points), level: step(v.level), next: step(v.next), toNext: num(v.toNext), badges: earned(v.badges) }
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** A badge's art, as the cloud draws it. */
|
||
export const art = (id: string): string => `${api()}/v1/rewards/badges/${encodeURIComponent(id)}/art`
|
||
|
||
/** The reader's GitHub connection: whether this deployment can connect one, and which account is linked. */
|
||
export interface Connection {
|
||
configured: boolean
|
||
connected: boolean
|
||
login: string
|
||
}
|
||
|
||
const connection = (raw: unknown): Connection => {
|
||
const c = obj(raw)
|
||
return { configured: yes(c.configured), connected: yes(c.connected), login: str(c.login) }
|
||
}
|
||
|
||
/** The reader's GitHub connection; null signed out or unanswered. */
|
||
export async function github(): Promise<Connection | null> {
|
||
const headers = scope()
|
||
if (!headers.Authorization) return null
|
||
try {
|
||
const res = await fetch(`${api()}/v1/provider/github/user`, { headers })
|
||
return res.ok ? connection(await res.json()) : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** Where to send the reader to authorize the link; throws with the cloud's reason when it refuses. */
|
||
export async function connect(): Promise<string> {
|
||
const res = await fetch(`${api()}/v1/provider/github/user/connect`, { method: 'POST', headers: { ...json, ...scope() }, body: JSON.stringify({ return: RETURN }) })
|
||
if (!res.ok) throw new Error(await why(res, 'GitHub could not be linked'))
|
||
const to = str(obj(await res.json()).authorizeUrl)
|
||
if (!to) throw new Error('GitHub could not be linked')
|
||
return to
|
||
}
|
||
|
||
/** Finish the link GitHub returned with; throws with the cloud's reason when it refuses. */
|
||
export async function complete(grant: string): Promise<Connection> {
|
||
const res = await fetch(`${api()}/v1/provider/github/user/complete`, { method: 'POST', headers: { ...json, ...scope() }, body: JSON.stringify({ grant }) })
|
||
if (!res.ok) throw new Error(await why(res, 'GitHub could not be linked'))
|
||
return connection(await res.json())
|
||
}
|
||
|
||
/** A token count as the milestones name it: 100K, 1M, 3.6M, 1T. */
|
||
export function tokens(n: number): string {
|
||
const units: [number, string][] = [
|
||
[1e12, 'T'],
|
||
[1e9, 'B'],
|
||
[1e6, 'M'],
|
||
[1e3, 'K'],
|
||
]
|
||
for (const [size, unit] of units) if (n >= size) return `${Number((n / size).toFixed(n % size === 0 ? 0 : 1))}${unit}`
|
||
return String(n)
|
||
}
|
||
|
||
/** A kind of the rules, by id. */
|
||
export const kind = (r: Rules, id: string): Kind | undefined => r.kinds.find((k) => k.id === id)
|
||
|
||
/** A source's name as the rules give it, or its id. */
|
||
export const sourceName = (r: Rules, id: string): string => r.sources.find((s) => s.id === id)?.name ?? id
|
||
|
||
/** What one kind earns, as a tile says it: +20, or a milestone's range. */
|
||
export function worth(k: Kind, r: Rules): string {
|
||
if (k.id === 'tier' && r.tiers.length) {
|
||
const pts = r.tiers.map((t) => t.points)
|
||
return `+${Math.min(...pts)}–${Math.max(...pts)}`
|
||
}
|
||
return `+${k.points}`
|
||
}
|
||
|
||
/** The ledger's own bookkeeping, priced per unit: kept points, reversals and redemptions, never a way to earn. */
|
||
const BOOKS = ['legacy', 'reversal', 'redeem', 'unredeem']
|
||
|
||
/** The kinds of one source a page lists as ways to earn. */
|
||
export const kindsOf = (r: Rules, source: string): Kind[] => r.kinds.filter((k) => k.source === source && !BOOKS.includes(k.id))
|
||
|
||
/** The sources a page lists as ways to earn: each that holds such a kind, in the rules' order. */
|
||
export const earning = (r: Rules): Source[] => r.sources.filter((s) => kindsOf(r, s.id).length > 0)
|
||
|
||
/** The kinds whose points redeem. */
|
||
export const credited = (r: Rules): Kind[] => r.kinds.filter((k) => k.credit)
|
||
|
||
/** The line a post that earns points carries: the quoted words of the rules' disclosure, or all of it. */
|
||
export const disclosed = (r: Rules): string => r.disclosure.match(/"([^"]+)"/)?.[1] ?? r.disclosure
|
||
|
||
/** The bounty kind a set of GitHub labels names, if any: the rules' bounty-* kinds, by their labels. */
|
||
export function tier(r: Rules, labels: string[]): Kind | undefined {
|
||
const lower = labels.map((l) => l.toLowerCase())
|
||
return r.kinds.find((k) => k.id.startsWith('bounty-') && (r.github.labels[k.id] ?? []).some((l) => lower.includes(l.toLowerCase())))
|
||
}
|