oxedyne/daimond/www/js/money.js
8.4 KiB, 1 run
created by r2519314175:1403, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* ============================================================ |
| 2 | Daimond — whose money, and how much of it (DaimondMoney) |
| 3 | ============================================================ |
| 4 | |
| 5 | The rail used to carry one row, labelled "Credits", showing the |
| 6 | balance held with Daimond. For somebody running on their own |
| 7 | provider key that row said **"Credits $0.00"** — while their key |
| 8 | was funding every turn perfectly well. The one number on screen |
| 9 | about money told them they were broke, and it was not their money |
| 10 | it was talking about. |
| 11 | |
| 12 | There are two economies here and they never mix: the balance |
| 13 | minted with Daimond, and whatever the user holds with a provider |
| 14 | of their own. So there are up to two rows, and each is NAMED BY |
| 15 | WHOSE MONEY IT IS. |
| 16 | |
| 17 | ── The four rules ───────────────────────────────────────── |
| 18 | |
| 19 | **1. Never a bare "Credits".** Every label names an owner: |
| 20 | "Daimond credits", "Your OpenAI key". A label that does not say |
| 21 | whose money it is cannot be read correctly by somebody who has |
| 22 | two kinds. |
| 23 | |
| 24 | **2. The strongest true statement, and otherwise nothing.** |
| 25 | In order: an exact balance the provider reported; an estimate |
| 26 | (`≈`) from a figure the user typed less what has been spent since; |
| 27 | what has been spent so far, when no balance is knowable at all. |
| 28 | If none of those is true, THE ROW IS NOT DRAWN. A dash is not an |
| 29 | answer -- it occupies the place where the answer goes and says |
| 30 | nothing, which reads as zero to anybody scanning. |
| 31 | |
| 32 | **3. Warn on runway, not on a threshold.** $2 left is fine at a |
| 33 | penny an hour and gone in ten minutes during a fan-out. An |
| 34 | absolute figure cannot know which, and a warning that fires on |
| 35 | round numbers gets ignored. Time is the honest unit. |
| 36 | |
| 37 | **4. When it is at risk, show the CONSEQUENCE rather than the |
| 38 | figure.** "About 20 minutes left at this rate" is what the reader |
| 39 | needs; the number they can still see by opening Credits. A figure |
| 40 | with a red edge round it makes the reader do the division. |
| 41 | |
| 42 | Pure, and exported for Node, because these are wording rules and |
| 43 | wording rules should be tested without a browser. |
| 44 | ============================================================ */ |
| 45 | (function () { |
| 46 | 'use strict'; |
| 47 | |
| 48 | /// Below this many minutes of runway a row stops reporting a figure and |
| 49 | /// starts reporting the consequence. Twenty minutes is about the length of |
| 50 | /// one working stretch: long enough to finish a thought, short enough that |
| 51 | /// being told now is useful. |
| 52 | var RISK_MINUTES = 20; |
| 53 | |
| 54 | /// A rate below this is treated as no rate at all. A near-zero burn divides |
| 55 | /// into any balance to give a runway of years, which is not information. |
| 56 | var MIN_RATE_USD_MIN = 0.0001; |
| 57 | |
| 58 | /// How long the money lasts at the current burn, in minutes, or null when |
| 59 | /// that cannot be said. |
| 60 | /// An EMPTY pot has no runway, and this is not a rounding detail. Zero |
| 61 | /// divided by any rate is zero minutes, which reads as "at risk with one |
| 62 | /// minute left" -- a prediction about the future, made about money that has |
| 63 | /// already run out. Caught by rendering it: the rail said "Daimond credits, |
| 64 | /// ~1 min left at this rate" beside a funded key with $42.50 on it. |
| 65 | function runwayMinutes(usd, rateUsdPerMin) { |
| 66 | if (typeof usd !== 'number' || !isFinite(usd) || usd <= 0) return null; |
| 67 | if (typeof rateUsdPerMin !== 'number' || !isFinite(rateUsdPerMin)) return null; |
| 68 | if (rateUsdPerMin < MIN_RATE_USD_MIN) return null; |
| 69 | return usd / rateUsdPerMin; |
| 70 | } |
| 71 | |
| 72 | /// One pot of money, reduced to what can honestly be said about it. |
| 73 | /// |
| 74 | /// # Arguments |
| 75 | /// * `pot` - `{ label, exactUsd, estimateUsd, spentUsd }`. Each figure is a |
| 76 | /// number or null; they are tried in that order, which is rule 2. |
| 77 | /// * `rate` - Current burn in USD per minute, or null. |
| 78 | /// |
| 79 | /// # Returns |
| 80 | /// A row, or `null` when there is nothing true to say -- which is the whole |
| 81 | /// point of returning null rather than a row with a dash in it. |
| 82 | function rowFor(pot, rate) { |
| 83 | if (!pot || !pot.label) return null; |
| 84 | var usd = null, kind = ''; |
| 85 | if (typeof pot.exactUsd === 'number' && isFinite(pot.exactUsd)) { |
| 86 | usd = pot.exactUsd; kind = 'exact'; |
| 87 | } else if (typeof pot.estimateUsd === 'number' && isFinite(pot.estimateUsd)) { |
| 88 | usd = pot.estimateUsd; kind = 'estimate'; |
| 89 | } else if (typeof pot.spentUsd === 'number' && isFinite(pot.spentUsd) && pot.spentUsd > 0) { |
| 90 | // No balance is knowable -- most providers will not say -- so the |
| 91 | // strongest true statement left is what has gone through it. |
| 92 | return { key: pot.key || '', label: pot.label, kind: 'spent', |
| 93 | usd: pot.spentUsd, tone: 'ok', atRisk: false, minutes: null }; |
| 94 | } else { |
| 95 | return null; |
| 96 | } |
| 97 | |
| 98 | var mins = runwayMinutes(usd, rate); |
| 99 | var atRisk = (mins !== null && mins <= RISK_MINUTES); |
| 100 | return { |
| 101 | key: pot.key || '', |
| 102 | label: pot.label, |
| 103 | kind: kind, |
| 104 | usd: usd, |
| 105 | minutes: mins, |
| 106 | atRisk: atRisk, |
| 107 | // Amber rather than red: nothing is broken yet, and a red row for |
| 108 | // money that is merely running low is the boy who cried wolf. |
| 109 | tone: atRisk ? 'warn' : 'ok', |
| 110 | }; |
| 111 | } |
| 112 | |
| 113 | /// Both pots, in the order the reader should meet them. |
| 114 | /// |
| 115 | /// # Arguments |
| 116 | /// * `st` - `{ authed, creditsUsd, providers, rateUsdPerMin }` where |
| 117 | /// `providers` is `DaimondModels.providers()`. |
| 118 | function rows(st) { |
| 119 | st = st || {}; |
| 120 | var out = []; |
| 121 | var rate = (typeof st.rateUsdPerMin === 'number') ? st.rateUsdPerMin : null; |
| 122 | var list = st.providers || []; |
| 123 | |
| 124 | // The Daimond balance, only for an account that has one. An app with no |
| 125 | // account has no such pot, and a row saying so would be an advert in the |
| 126 | // place a fact belongs. |
| 127 | if (st.authed) { |
| 128 | var r = rowFor({ |
| 129 | key: 'credits', |
| 130 | label: st.creditsLabel || 'Daimond credits', |
| 131 | exactUsd: (typeof st.creditsUsd === 'number') ? st.creditsUsd : null, |
| 132 | spentUsd: (typeof st.creditsSpentUsd === 'number') ? st.creditsSpentUsd : null, |
| 133 | }, rate); |
| 134 | if (r) { |
| 135 | // Carried through untouched so the caller can print the account's own |
| 136 | // currency. This module does arithmetic and never formatting: a |
| 137 | // balance in minor units is the only lossless form of it. |
| 138 | r.minor = st.creditsMinor; |
| 139 | r.currency = st.creditsCurrency; |
| 140 | out.push(r); |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // The user's own keys. One provider is named; several are not, because |
| 145 | // four rows of provider names is a list, not a status. |
| 146 | var own = list.filter(function (p) { return p && !p.paid && p.hasKey; }); |
| 147 | if (own.length === 1) { |
| 148 | var p = own[0]; |
| 149 | var c = p.credit || null; |
| 150 | out.push(rowFor({ |
| 151 | key: 'own', |
| 152 | label: st.ownOneLabel ? st.ownOneLabel(p.name) : ('Your ' + p.name + ' key'), |
| 153 | exactUsd: (c && c.mode === 'auto') ? c.usd : null, |
| 154 | estimateUsd: (c && c.mode === 'manual') ? c.usd : null, |
| 155 | spentUsd: (typeof p.spentUsd === 'number') ? p.spentUsd : null, |
| 156 | }, rate)); |
| 157 | } else if (own.length > 1) { |
| 158 | // Summed only where every one of them can be summed. A total that |
| 159 | // silently omits the two providers that would not answer is a wrong |
| 160 | // number, and a wrong number is worse than no row. |
| 161 | var known = own.filter(function (x) { return x.credit && typeof x.credit.usd === 'number'; }); |
| 162 | var spent = own.reduce(function (a, x) { |
| 163 | return a + (typeof x.spentUsd === 'number' ? x.spentUsd : 0); |
| 164 | }, 0); |
| 165 | var all = (known.length === own.length); |
| 166 | var total = known.reduce(function (a, x) { return a + x.credit.usd; }, 0); |
| 167 | var anyEstimate = known.some(function (x) { return x.credit.mode === 'manual'; }); |
| 168 | out.push(rowFor({ |
| 169 | key: 'own', |
| 170 | label: st.ownManyLabel || 'Your own keys', |
| 171 | exactUsd: (all && !anyEstimate) ? total : null, |
| 172 | estimateUsd: (all && anyEstimate) ? total : null, |
| 173 | spentUsd: spent, |
| 174 | }, rate)); |
| 175 | } |
| 176 | |
| 177 | out = out.filter(Boolean); |
| 178 | |
| 179 | // An empty pot is a warning only when it is the ONLY pot. Zero Daimond |
| 180 | // credits beside a funded key of the user's own is a fact about an |
| 181 | // account they are not using, and colouring it amber would put a caution |
| 182 | // on the rail of somebody whose work is fully funded -- which is the same |
| 183 | // mistake, in a different colour, as the row this module replaced. |
| 184 | if (out.length === 1 && out[0].kind !== 'spent' && out[0].usd <= 0) { |
| 185 | out[0].tone = 'warn'; |
| 186 | out[0].empty = true; |
| 187 | } |
| 188 | return out; |
| 189 | } |
| 190 | |
| 191 | var api = { |
| 192 | RISK_MINUTES: RISK_MINUTES, |
| 193 | runwayMinutes: runwayMinutes, |
| 194 | rowFor: rowFor, |
| 195 | rows: rows, |
| 196 | }; |
| 197 | if (typeof window !== 'undefined') window.DaimondMoney = api; |
| 198 | if (typeof module !== 'undefined' && module.exports) module.exports = api; |
| 199 | })(); |