Oregami
Repositories/oxedyne/daimond

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})();