@kitkat-vn/kitcode 0.2.0-staging.8 → 0.3.0-staging.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ // The milestone record in state.json, `milestonesReached`, read (docs/reward-rules.md §6.1): what each milestone's entry
2
+ // says, checked as KitCode writes it. Pure, with no imports: reward.mjs reads it to decide the milestones, and
3
+ // milestones-reached.mjs to write the record and to report it in the claim bundle.
4
+ //
5
+ // {"recordingSince": "2026-09-25T09:15:00.004Z",
6
+ // "milestones": {"30": {"reachedBeforeRecording": true},
7
+ // "50": {"reachedAt": "2026-10-20T11:03:27.918Z", "focusSeconds": 3600, "equals": 15}},
8
+ // "freshSince": {"at": "2026-10-20T10:00:00.000Z", "focusSeconds": 2400, "equals": 11}}
9
+ //
10
+ // Since 2026-10-20 (rewards/2-no-stack) an entry the CLI writes also holds the totals at the moment the milestone was
11
+ // reached, its focus seconds and its =, and `freshSince` the totals at the moment this KitCode first wrote a record that
12
+ // holds a milestone reached without them: by an earlier KitCode, or before the record began. The milestone after such a
13
+ // one counts from `freshSince` (the plan's decision 2). An entry an earlier KitCode wrote, `reachedAt` alone, stays as it
14
+ // is, and so does every field this version does not know.
15
+ //
16
+ // Since 2026-10-22 (rewards/3-repeat-vouchers, phase 2) the record counts in cycles (the plan of 2026-10-21, decision 1: after a
17
+ // claim, all progress resets). Cycle 1 is the record as it was, its entries in `milestones`; each later cycle is one entry of
18
+ // `cycles`, added when the tracker takes a receipt the claim backend signed for the voucher of the cycle before
19
+ // (milestones-reached.mjs, beginNextCycle), and never changed after but for its own milestones, added once each. Since 2026-10-22's
20
+ // phase 3 a cycle is numbered as the claim backend counts, the one after its receipt's, so the numbers can skip: a KitCode installed
21
+ // afresh in cycle 1 that takes the receipt of cycle 2 begins cycle 3.
22
+ //
23
+ // "cycles": [{"cycle": 2, "startedAt": "2026-10-22T08:00:00.000Z", "focusSeconds": 57900, "equals": 1810,
24
+ // "receipt": {"claimId": "…", "issuedAt": "2026-10-22T07:58:00.000Z"},
25
+ // "milestones": {"30": {"reachedAt": "…", "focusSeconds": 68722, "equals": 2110}}}]
26
+ //
27
+ // A cycle's start is the totals since install at the moment it began, which its first milestone counts from; the totals keep
28
+ // counting across cycles. Nothing of cycle 1 changes when cycle 2 begins: the record is additive only (hard rule 4).
29
+
30
+ // A time as toISOString writes it, and a real one: 2026-02-30 is not.
31
+ const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
32
+
33
+ function isObject(value) {
34
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
35
+ }
36
+
37
+ export function isInstant(value) {
38
+ if (typeof value !== 'string' || !INSTANT.test(value)) {
39
+ return false;
40
+ }
41
+
42
+ const time = Date.parse(value);
43
+ return Number.isFinite(time) && new Date(time).toISOString() === value;
44
+ }
45
+
46
+ function isCount(value) {
47
+ return Number.isSafeInteger(value) && value >= 0;
48
+ }
49
+
50
+ const CLAIM_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
51
+
52
+ // A record as KitCode writes it: when it began, and its entries.
53
+ export function isRecord(record) {
54
+ return isObject(record) && isInstant(record.recordingSince) && isObject(record.milestones);
55
+ }
56
+
57
+ // The totals an entry, or freshSince, holds: both, whole and not below 0, or neither (null). Anything else is undefined:
58
+ // not what KitCode writes.
59
+ function totalsOf(value) {
60
+ const hasSeconds = Object.hasOwn(value, 'focusSeconds');
61
+ const hasEquals = Object.hasOwn(value, 'equals');
62
+
63
+ if (!hasSeconds && !hasEquals) {
64
+ return null;
65
+ }
66
+
67
+ return hasSeconds && hasEquals && isCount(value.focusSeconds) && isCount(value.equals)
68
+ ? {seconds: value.focusSeconds, equals: value.equals}
69
+ : undefined;
70
+ }
71
+
72
+ // One milestone's entry: {kind: 'before-recording'} or {kind: 'reached', reachedAt, end}, `end` the totals it was reached
73
+ // at, or null when the entry holds none; undefined when it is not an entry KitCode writes.
74
+ function entryOf(entry) {
75
+ if (!isObject(entry)) {
76
+ return undefined;
77
+ }
78
+
79
+ if (entry.reachedBeforeRecording === true && entry.reachedAt === undefined) {
80
+ return totalsOf(entry) === null ? {kind: 'before-recording', reachedAt: null, end: null} : undefined;
81
+ }
82
+
83
+ if (isInstant(entry.reachedAt) && entry.reachedBeforeRecording === undefined) {
84
+ const end = totalsOf(entry);
85
+ return end === undefined ? undefined : {kind: 'reached', reachedAt: entry.reachedAt, end};
86
+ }
87
+
88
+ return undefined;
89
+ }
90
+
91
+ // A later cycle's entry, read for `percents`, `after` the number of the cycle before it: {cycle, startedAt, start, receipt,
92
+ // entries}, or a problem's words, a string, when it is not what KitCode writes. Each of its milestones was reached with its
93
+ // totals: a later cycle counts from a start it knows. Its number is past the one before it, by one when the tracker took the
94
+ // receipt of the cycle it was in, and by more since 2026-10-22's phase 3, when it took one of a cycle ahead, as the claim
95
+ // backend counts them (milestones-reached.mjs, beginNextCycle).
96
+ function cycleOf(value, after, percents) {
97
+ if (!isObject(value) || !Number.isSafeInteger(value.cycle) || value.cycle <= after || !isInstant(value.startedAt) || !isCount(value.focusSeconds) || !isCount(value.equals)
98
+ || !isObject(value.receipt) || typeof value.receipt.claimId !== 'string' || !CLAIM_ID.test(value.receipt.claimId) || !isInstant(value.receipt.issuedAt)
99
+ || !isObject(value.milestones)) {
100
+ return `holds a cycle after cycle ${after} that KitCode does not write`;
101
+ }
102
+
103
+ const cycle = value.cycle;
104
+ const entries = new Map();
105
+
106
+ for (const percent of percents) {
107
+ if (!Object.hasOwn(value.milestones, String(percent))) {
108
+ continue;
109
+ }
110
+
111
+ const entry = entryOf(value.milestones[String(percent)]);
112
+
113
+ if (entry?.kind !== 'reached' || entry.end === null) {
114
+ return `holds an entry for ${percent} % in cycle ${cycle} that KitCode does not write`;
115
+ }
116
+
117
+ entries.set(percent, entry);
118
+ }
119
+
120
+ return {
121
+ cycle,
122
+ startedAt: value.startedAt,
123
+ start: {seconds: value.focusSeconds, equals: value.equals},
124
+ receipt: {claimId: value.receipt.claimId, issuedAt: value.receipt.issuedAt},
125
+ entries,
126
+ };
127
+ }
128
+
129
+ // The record, read for `percents`, the milestones in order (the retired 10 and 20 % are never among them, whatever their
130
+ // entries hold):
131
+ //
132
+ // {state: 'none'}: no record yet, a file from before it, or one 0.1.39 rewrote;
133
+ // {state: 'damaged', problem}: not what KitCode writes, which is left as it is, never repaired or written over;
134
+ // {state: 'kept', recordingSince, freshSince, entries, cycles, current}: `entries` maps each percent recorded in cycle 1 to
135
+ // its entry, and `freshSince` is cycle 1's {at, seconds, equals} or null; since 2026-10-22 `cycles` lists the later cycles,
136
+ // oldest first, each {cycle, startedAt, start, receipt, entries}, and `current` is the cycle the milestones count in now:
137
+ // {cycle: 1, startedAt: null, start: {seconds: 0, equals: 0}, receipt: null, entries, freshSince} with no later one, else
138
+ // the last, its freshSince null.
139
+ //
140
+ // A milestone with its totals, after one whose totals are not known, counts from freshSince: without it, the record is
141
+ // not one KitCode writes.
142
+ export function readMilestoneRecord(record, percents) {
143
+ if (record === undefined) {
144
+ return {state: 'none'};
145
+ }
146
+
147
+ if (!isRecord(record)) {
148
+ return {state: 'damaged', problem: 'is not the record KitCode writes'};
149
+ }
150
+
151
+ let freshSince = null;
152
+
153
+ if (record.freshSince !== undefined) {
154
+ const totals = isObject(record.freshSince) && isInstant(record.freshSince.at) ? totalsOf(record.freshSince) : undefined;
155
+
156
+ if (!totals) {
157
+ return {state: 'damaged', problem: 'holds a start of fresh counting (freshSince) that KitCode does not write'};
158
+ }
159
+
160
+ freshSince = {at: record.freshSince.at, ...totals};
161
+ }
162
+
163
+ const entries = new Map();
164
+
165
+ for (const percent of percents) {
166
+ if (!Object.hasOwn(record.milestones, String(percent))) {
167
+ continue;
168
+ }
169
+
170
+ const entry = entryOf(record.milestones[String(percent)]);
171
+
172
+ if (entry === undefined) {
173
+ return {state: 'damaged', problem: `holds an entry for ${percent} % that KitCode does not write`};
174
+ }
175
+
176
+ entries.set(percent, entry);
177
+ }
178
+
179
+ // Each milestone's start, in order: install for the first; for another, the totals the one before it was reached at,
180
+ // when its entry holds them, and otherwise freshSince.
181
+ let previousEnd = {seconds: 0, equals: 0};
182
+
183
+ for (const percent of percents) {
184
+ const entry = entries.get(percent);
185
+
186
+ if (entry?.end && previousEnd === null && freshSince === null) {
187
+ return {state: 'damaged', problem: `holds the totals ${percent} % was reached at, with no start for them to count from`};
188
+ }
189
+
190
+ previousEnd = entry?.end ?? null;
191
+ }
192
+
193
+ // Since 2026-10-22: the later cycles, oldest first, each past the one before it, the first past cycle 1.
194
+ const cycles = [];
195
+
196
+ if (record.cycles !== undefined) {
197
+ if (!Array.isArray(record.cycles)) {
198
+ return {state: 'damaged', problem: 'holds cycles that KitCode does not write'};
199
+ }
200
+
201
+ for (const value of record.cycles) {
202
+ const cycle = cycleOf(value, cycles.at(-1)?.cycle ?? 1, percents);
203
+
204
+ if (typeof cycle === 'string') {
205
+ return {state: 'damaged', problem: cycle};
206
+ }
207
+
208
+ cycles.push(cycle);
209
+ }
210
+ }
211
+
212
+ const current = cycles.at(-1) ?? {cycle: 1, startedAt: null, start: {seconds: 0, equals: 0}, receipt: null, entries, freshSince};
213
+ return {state: 'kept', recordingSince: record.recordingSince, freshSince, entries, cycles, current: {freshSince: null, ...current}};
214
+ }
@@ -1,6 +1,7 @@
1
1
  import {equalsLedgerFrom} from './equals-ledger.mjs';
2
2
  import {focusTimeFrom} from './focus-clock.mjs';
3
- import {buildRewardSummary, REWARD_MILESTONES} from './reward.mjs';
3
+ import {readMilestoneRecord} from './milestone-record.mjs';
4
+ import {decideMilestones, MILESTONE_PERCENTS, REWARD_TIERS} from './reward.mjs';
4
5
 
5
6
  // When each milestone first became ready (docs/reward-rules.md §6.1), for the claim bundle. Kept in
6
7
  // state.json, where the tracker writes the progress it belongs to, as `milestonesReached`:
@@ -17,40 +18,38 @@ import {buildRewardSummary, REWARD_MILESTONES} from './reward.mjs';
17
18
  // 0.1.39 drops it when it rewrites the file (install-id.mjs says how). The next write of this version then
18
19
  // begins a new record, and a milestone reached by then is recorded as reached before recording began:
19
20
  // the time it was reached is lost, and none is made up.
20
-
21
- export const MILESTONE_PERCENTS = REWARD_MILESTONES.map((milestone) => milestone.percent);
22
-
23
- // A time as toISOString writes it, and a real one: 2026-02-30 is not.
24
- const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
25
-
26
- function isObject(value) {
27
- return value !== null && typeof value === 'object' && !Array.isArray(value);
28
- }
29
-
30
- function isInstant(value) {
31
- if (typeof value !== 'string' || !INSTANT.test(value)) {
32
- return false;
33
- }
34
-
35
- const time = Date.parse(value);
36
- return Number.isFinite(time) && new Date(time).toISOString() === value;
37
- }
38
-
39
- // A record as KitCode writes it: when it began, and its entries.
40
- function isRecord(record) {
41
- return isObject(record) && isInstant(record.recordingSince) && isObject(record.milestones);
21
+ //
22
+ // Since 2026-10-08 the milestones are 30, 50 and 100 % (integration-spec.mjs): the entries an earlier version wrote for 10
23
+ // and 20 % stay in the record, never changed or removed, as every entry does, and nothing reads them now.
24
+ //
25
+ // Since 2026-10-20 (rewards/2-no-stack) the record decides the milestones: each counts from the moment the one before it
26
+ // was reached (reward.mjs decideMilestones), so an entry also holds the totals at that moment, written with it, once:
27
+ //
28
+ // "50": {"reachedAt": "2026-10-20T11:03:27.918Z", "focusSeconds": 4200, "equals": 21}
29
+ //
30
+ // and, the first time this KitCode writes a record that holds a milestone reached without them, by an earlier KitCode or
31
+ // before the record began, `freshSince` records the totals then: the milestone after it counts from there (the plan's
32
+ // decision 2). An earlier entry is never given totals it did not hold, and none is made up (milestone-record.mjs).
33
+ //
34
+ // Since 2026-10-22 (rewards/3-repeat-vouchers, phase 2) the record counts in cycles (milestone-record.mjs): a milestone newly
35
+ // reached is recorded in the cycle it is in, and a new cycle begins only by beginNextCycle, on a receipt the claim backend
36
+ // signed for the voucher of the cycle before, which the tracker has checked (api.mjs). Nothing else begins one: no read, no
37
+ // failure and no prompt resets anyone's progress.
38
+
39
+ export {MILESTONE_PERCENTS};
40
+
41
+ function totalsIn(stored, focusSeconds) {
42
+ return {
43
+ seconds: Math.floor(Math.max(0, Number(focusSeconds ?? focusTimeFrom(stored).activeSeconds) || 0)),
44
+ equals: Math.max(0, Number(equalsLedgerFrom(stored).total_equals) || 0),
45
+ };
42
46
  }
43
47
 
44
- // The milestones reached by `state` now: every milestone up to the highest one that is ready or
45
- // redeemed. A milestone's targets are never below a lower one's, so a higher milestone ready means every
46
- // lower one was ready then too, even one that reads locked now: a redeemed tier keeps its status when
47
- // its = go down (docs/reward-rules.md §6, E8).
48
- function reachedNow(state) {
49
- const ledger = equalsLedgerFrom(state);
50
- const reward = buildRewardSummary({earnedSeconds: focusTimeFrom(state).activeSeconds, totalEquals: ledger.total_equals, ledger});
51
- const highest = Math.max(0, ...reward.milestones.filter((milestone) => milestone.status !== 'locked').map((milestone) => milestone.percent));
52
-
53
- return MILESTONE_PERCENTS.filter((percent) => percent <= highest);
48
+ function redeemedIn(stored) {
49
+ const tiers = new Set(REWARD_TIERS.map((tier) => tier.percent));
50
+ return new Set(equalsLedgerFrom(stored).earned_tiers
51
+ .filter((entry) => entry !== null && typeof entry === 'object' && tiers.has(Number(entry.percent)) && typeof entry.redeemed_at === 'string')
52
+ .map((entry) => Number(entry.percent)));
54
53
  }
55
54
 
56
55
  // Records, in `stored`, each milestone first found reached: `stored` is the file's own object, inside
@@ -59,72 +58,172 @@ function reachedNow(state) {
59
58
  // before recording began, with no time, since none is known. From then on, a milestone newly reached is
60
59
  // recorded at `now`. A record, or an entry, that is not what KitCode writes is left exactly as it is:
61
60
  // never repaired, never written over. `now` is the tracker's clock, which a test can move.
62
- export function noteMilestonesReached(stored, now = new Date(Date.now())) {
61
+ //
62
+ // Since 2026-10-20 a milestone newly reached is recorded with the totals at `now`, its focus seconds, floored, and its =;
63
+ // `focusSeconds` is the tracker's own clock, when it folds a scan between two saves, ahead of the file's by up to one
64
+ // save. One write reaches one milestone at most: the next counts from that write's totals, so nothing it credited counts
65
+ // toward the next. A milestone reached with no totals known, and freshSince not yet written, has it written now.
66
+ export function noteMilestonesReached(stored, now = new Date(Date.now()), {focusSeconds} = {}) {
63
67
  const record = stored.milestonesReached;
68
+ const read = readMilestoneRecord(record, MILESTONE_PERCENTS);
64
69
 
65
- if (record !== undefined && !isRecord(record)) {
70
+ if (read.state === 'damaged') {
66
71
  return;
67
72
  }
68
73
 
69
- const reached = reachedNow(stored);
74
+ const totals = totalsIn(stored, focusSeconds);
75
+ const decided = decideMilestones({totals, record, redeemed: redeemedIn(stored)});
70
76
  const at = now.toISOString();
77
+ const atTotals = {at, focusSeconds: totals.seconds, equals: totals.equals};
71
78
 
72
- if (record === undefined) {
79
+ if (read.state === 'none') {
80
+ const before = decided.entries.filter((entry) => entry.reached);
73
81
  stored.milestonesReached = {
74
82
  recordingSince: at,
75
- milestones: Object.fromEntries(reached.map((percent) => [String(percent), {reachedBeforeRecording: true}])),
83
+ milestones: Object.fromEntries(before.map((entry) => [String(entry.percent), {reachedBeforeRecording: true}])),
84
+ ...(before.length > 0 ? {freshSince: atTotals} : {}),
76
85
  };
77
86
  return;
78
87
  }
79
88
 
80
- for (const percent of reached) {
81
- if (!Object.hasOwn(record.milestones, String(percent))) {
82
- record.milestones[String(percent)] = {reachedAt: at};
89
+ // The milestones of the cycle counted in: cycle 1's are the record's own; a later cycle's, its entry's, the last of `cycles`
90
+ // (milestone-record.mjs), whose numbers can skip since 2026-10-22's phase 3.
91
+ const cycle = read.current.cycle > 1 ? record.cycles[record.cycles.length - 1] : null;
92
+ const milestones = cycle === null ? record.milestones : cycle.milestones;
93
+
94
+ for (const entry of decided.entries) {
95
+ if (!entry.reached || entry.recorded || Object.hasOwn(milestones, String(entry.percent))) {
96
+ continue;
83
97
  }
98
+
99
+ // Found reached now, at the frontier, with the totals of this write; or below one that is, its totals not known, which a
100
+ // later cycle never has: it counts from a start it knows.
101
+ milestones[String(entry.percent)] = entry.end === null
102
+ ? {reachedAt: at}
103
+ : {reachedAt: at, focusSeconds: entry.end.seconds, equals: entry.end.equals};
84
104
  }
85
- }
86
105
 
87
- // A milestone record that is not what KitCode writes. The claim bundle does not guess at it.
88
- export class MilestoneRecordError extends Error {
89
- constructor(problem) {
90
- super(`The record of when each milestone was reached, in state.json, ${problem}. KitCode has left it as it is.`);
91
- this.name = 'MilestoneRecordError';
106
+ const unknown = MILESTONE_PERCENTS.some((percent) => {
107
+ const entry = record.milestones[String(percent)];
108
+ return entry !== undefined && (entry.reachedBeforeRecording === true || entry.focusSeconds === undefined);
109
+ });
110
+
111
+ if (unknown && record.freshSince === undefined) {
112
+ record.freshSince = atTotals;
92
113
  }
93
114
  }
94
115
 
95
- function entryFor(percent, record) {
96
- const key = String(percent);
116
+ // Begins the next cycle, in `stored`, the file's own object inside updateState, on `receipt`, a receipt the tracker read and
117
+ // found signed with the claim backend's key and for this install (cycle-receipt.mjs, api.mjs): {installId, cycle, claimId,
118
+ // issuedAt}. The plan of 2026-10-21's decision 1: after a claim, all progress resets. The receipt of the cycle the record is in
119
+ // begins the next, once: the record holds which receipt began each cycle, so the same receipt again, or one of a cycle past,
120
+ // changes nothing. Since 2026-10-22's phase 3 (the coordinator's ruling: the server is the authority on cycles) one of a cycle
121
+ // ahead, as a KitCode installed afresh after its person's earlier vouchers is given, begins the cycle after the receipt's, as the
122
+ // claim backend counts them; until then it changed nothing, and such an install stayed in its cycle for good. Either way a
123
+ // receipt only ever resets progress, and never adds any. The new cycle starts at the totals at `now`, `focusSeconds` the
124
+ // tracker's own clock as a save writes it (noteMilestonesReached); the totals themselves go on counting, and nothing earlier is
125
+ // changed or removed: the record is additive only (hard rule 4). First, as any write that makes progress does, it records what
126
+ // is reached by now in the cycle it closes, beginning the record if none was.
127
+ //
128
+ // Returns {outcome: 'started', cycle, startedAt, start}; {outcome: 'used', cycle, startedAt}, the cycle this receipt began
129
+ // already; {outcome: 'cycle', current}, the receipt of a cycle before the record's, `current`; or {outcome: 'damaged', problem},
130
+ // a record not what KitCode writes, left as it is.
131
+ export function beginNextCycle(stored, receipt, now = new Date(Date.now()), {focusSeconds} = {}) {
132
+ noteMilestonesReached(stored, now, {focusSeconds});
133
+ const read = readMilestoneRecord(stored.milestonesReached, MILESTONE_PERCENTS);
134
+
135
+ if (read.state === 'damaged') {
136
+ return {outcome: 'damaged', problem: read.problem};
137
+ }
138
+
139
+ const used = read.cycles.find((cycle) => cycle.receipt.claimId === receipt.claimId);
97
140
 
98
- if (!Object.hasOwn(record.milestones, key)) {
99
- return {percent, status: 'not-reached', reachedAt: null};
141
+ if (used !== undefined) {
142
+ return {outcome: 'used', cycle: used.cycle, startedAt: used.startedAt};
100
143
  }
101
144
 
102
- const entry = record.milestones[key];
145
+ if (receipt.cycle < read.current.cycle) {
146
+ return {outcome: 'cycle', current: read.current.cycle};
147
+ }
103
148
 
104
- if (isObject(entry) && entry.reachedBeforeRecording === true && entry.reachedAt === undefined) {
105
- return {percent, status: 'reached-before-recording', reachedAt: null};
149
+ const totals = totalsIn(stored, focusSeconds);
150
+ const startedAt = now.toISOString();
151
+ const record = stored.milestonesReached;
152
+ const next = {
153
+ cycle: receipt.cycle + 1,
154
+ startedAt,
155
+ focusSeconds: totals.seconds,
156
+ equals: totals.equals,
157
+ receipt: {claimId: receipt.claimId, issuedAt: receipt.issuedAt},
158
+ milestones: {},
159
+ };
160
+
161
+ if (Array.isArray(record.cycles)) {
162
+ record.cycles.push(next);
163
+ } else {
164
+ record.cycles = [next];
106
165
  }
107
166
 
108
- if (isObject(entry) && isInstant(entry.reachedAt) && entry.reachedBeforeRecording === undefined) {
109
- return {percent, status: 'reached', reachedAt: entry.reachedAt};
167
+ return {outcome: 'started', cycle: next.cycle, startedAt, start: {focusSeconds: totals.seconds, equals: totals.equals}};
168
+ }
169
+
170
+ // A milestone record that is not what KitCode writes. The claim bundle does not guess at it.
171
+ export class MilestoneRecordError extends Error {
172
+ constructor(problem) {
173
+ super(`The record of when each milestone was reached, in state.json, ${problem}. KitCode has left it as it is.`);
174
+ this.name = 'MilestoneRecordError';
110
175
  }
176
+ }
111
177
 
112
- throw new MilestoneRecordError(`holds an entry for ${percent} % that KitCode does not write`);
178
+ function totalsField(totals) {
179
+ return {focusSeconds: totals.seconds, equals: totals.equals};
113
180
  }
114
181
 
115
182
  // The record as the claim bundle reports it: when it began, or null when it has not, and each
116
- // milestone's status and time, from 10 to 100 %. Reads only; a damaged record throws a
183
+ // milestone's status and time, from 30 to 100 %. Reads only; a damaged record throws a
117
184
  // MilestoneRecordError.
185
+ //
186
+ // Since 2026-10-20 each milestone also carries `start` and `end`, the totals it counted from and those it was reached at,
187
+ // {focusSeconds, equals}, when its entry holds them; null otherwise: not reached, or reached with no totals known.
188
+ //
189
+ // Since 2026-10-22 the record reported is that of the cycle counted in, `cycle`, and `cycleStart` says where it began:
190
+ // {at: null, focusSeconds: 0, equals: 0} for cycle 1, install; the moment a later one began, and the totals then, which its
191
+ // 30 % counts from.
192
+ const FIRST_CYCLE_START = Object.freeze({at: null, focusSeconds: 0, equals: 0});
193
+
118
194
  export function milestonesReachedFrom(state) {
119
- const record = state.milestonesReached;
195
+ const read = readMilestoneRecord(state.milestonesReached, MILESTONE_PERCENTS);
120
196
 
121
- if (record === undefined) {
122
- return {recordingSince: null, milestones: MILESTONE_PERCENTS.map((percent) => ({percent, status: 'not-reached', reachedAt: null}))};
197
+ if (read.state === 'none') {
198
+ return {recordingSince: null, cycle: 1, cycleStart: {...FIRST_CYCLE_START}, milestones: MILESTONE_PERCENTS.map((percent) => ({percent, status: 'not-reached', reachedAt: null, start: null, end: null}))};
123
199
  }
124
200
 
125
- if (!isRecord(record)) {
126
- throw new MilestoneRecordError('is not the record KitCode writes');
201
+ if (read.state === 'damaged') {
202
+ throw new MilestoneRecordError(read.problem);
127
203
  }
128
204
 
129
- return {recordingSince: record.recordingSince, milestones: MILESTONE_PERCENTS.map((percent) => entryFor(percent, record))};
205
+ const {current} = read;
206
+ let previousEnd = {...current.start};
207
+
208
+ const milestones = MILESTONE_PERCENTS.map((percent) => {
209
+ const entry = current.entries.get(percent);
210
+ const start = previousEnd ?? current.freshSince;
211
+ previousEnd = entry?.end ?? null;
212
+
213
+ if (entry === undefined) {
214
+ return {percent, status: 'not-reached', reachedAt: null, start: null, end: null};
215
+ }
216
+
217
+ const segment = entry.end === null ? {start: null, end: null} : {start: totalsField(start), end: totalsField(entry.end)};
218
+ return entry.kind === 'before-recording'
219
+ ? {percent, status: 'reached-before-recording', reachedAt: null, ...segment}
220
+ : {percent, status: 'reached', reachedAt: entry.reachedAt, ...segment};
221
+ });
222
+
223
+ return {
224
+ recordingSince: read.recordingSince,
225
+ cycle: current.cycle,
226
+ cycleStart: current.cycle > 1 ? {at: current.startedAt, focusSeconds: current.start.seconds, equals: current.start.equals} : {...FIRST_CYCLE_START},
227
+ milestones,
228
+ };
130
229
  }
@@ -1,4 +1,4 @@
1
- import {milestoneProgressPercent} from './break-progress.mjs';
1
+ import {earnedToward, milestoneProgressPercent} from './break-progress.mjs';
2
2
 
3
3
  export function formatBreakDuration(seconds) {
4
4
  const totalSeconds = Math.max(0, Math.floor(Number(seconds) || 0));
@@ -48,8 +48,9 @@ export function nextBreakReminder(reward) {
48
48
  return null;
49
49
  }
50
50
 
51
- const earnedSeconds = Math.max(0, Number(reward.earnedSeconds) || 0);
52
- const totalEquals = Math.max(0, Number(reward.totalEquals) || 0);
51
+ // What counts toward it: since 2026-10-20 what was earned since it began, the moment the milestone before it was
52
+ // reached (break-progress.mjs earnedToward); from a tracker before then, the totals.
53
+ const {seconds: earnedSeconds, equals: totalEquals} = earnedToward(reward, milestone);
53
54
  const requiredSeconds = Math.max(0, Number(milestone.requiredSeconds) || 0);
54
55
  const requiredEquals = Math.max(0, Number(milestone.requiredEquals) || 0);
55
56
  // Floored, so it reads 100 only once the milestone is unlocked (docs/reward-rules.md §10).
@@ -10,6 +10,7 @@ import {DASHBOARD_URL} from './integration-spec.mjs';
10
10
  import {onboardingPreferences, saveOnboardingPreferences, STORE_DIR} from './store.mjs';
11
11
  import {resolveSetupPlatform} from './onboarding-platform.mjs';
12
12
  import {renderOnboardingWindow} from './onboarding-window.mjs';
13
+ import {WELCOME_ERRORS, WELCOME_TESTER_ERRORS, WELCOME_WORDS} from './welcome-words.mjs';
13
14
  import {keepWindowOnItsPage} from './window-guards.mjs';
14
15
 
15
16
  const sourceDirectory = path.dirname(fileURLToPath(import.meta.url));
@@ -132,7 +133,7 @@ function setupWindowOptions(platform) {
132
133
  transparent: true,
133
134
  hasShadow: false,
134
135
  backgroundColor: '#00000000',
135
- title: 'Welcome to KitCode',
136
+ title: WELCOME_WORDS.windowTitle,
136
137
  webPreferences: {preload: preloadPath, contextIsolation: true, nodeIntegration: false, sandbox: true},
137
138
  };
138
139
  }
@@ -154,7 +155,7 @@ app.whenReady().then(() => {
154
155
  keepWindowOnItsPage(window);
155
156
  // Project records carry absolute paths: only Welcome's own page gets them.
156
157
  ipcMain.handle('kitcode:onboarding-initial-state', (event) => {
157
- if (event.sender !== window.webContents) return {ok: false, error: 'Invalid setup request.', projects: [], suggestedProjects: []};
158
+ if (event.sender !== window.webContents) return {ok: false, error: WELCOME_ERRORS.invalidRequest, projects: [], suggestedProjects: []};
158
159
  return {
159
160
  ...onboardingPreferences(),
160
161
  projects: listProjectRecords(),
@@ -170,21 +171,21 @@ app.whenReady().then(() => {
170
171
  return {canceled: result.canceled, paths, projects: describeProjects(paths)};
171
172
  });
172
173
  ipcMain.handle('kitcode:onboarding-submit', async (event, input) => {
173
- if (event.sender !== window.webContents) return {ok: false, error: 'Invalid setup request.'};
174
+ if (event.sender !== window.webContents) return {ok: false, error: WELCOME_ERRORS.invalidRequest};
174
175
  // A tester build's invited email, kept outside state.json, before anything else is saved; one that is not an address is
175
176
  // asked for again, and nothing is saved.
176
177
  if (diagnosticsEnabled() && typeof input?.invitedEmail === 'string' && !setInvitedEmail(input.invitedEmail).ok) {
177
- return {ok: false, error: 'That email address does not look right: check it, or leave it empty.'};
178
+ return {ok: false, error: WELCOME_TESTER_ERRORS.invalidEmail};
178
179
  }
179
180
  const folders = validFolders(input?.folders);
180
- if (!folders.length && !listProjectRecords().length) return {ok: false, error: 'Add at least one readable project folder.'};
181
- try { registerNewProjects(folders); } catch { return {ok: false, error: 'KitCode could not register one of those folders.'}; }
181
+ if (!folders.length && !listProjectRecords().length) return {ok: false, error: WELCOME_ERRORS.noProjects};
182
+ try { registerNewProjects(folders); } catch { return {ok: false, error: WELCOME_ERRORS.registerFailed}; }
182
183
  const selection = {autoTrack: input?.autoTrack === true, companionView: 'mini'};
183
184
  if (selection.autoTrack && !(await runCli(['track', '--host', host, '--port', port]))) {
184
185
  saveOnboardingPreferences({...selection, completed: false});
185
186
  return {
186
187
  ok: false,
187
- error: 'Projects were saved, but the tracker could not start. Free the local port and retry.',
188
+ error: WELCOME_ERRORS.trackerFailed,
188
189
  projects: listProjectRecords(),
189
190
  };
190
191
  }
@@ -197,14 +198,14 @@ app.whenReady().then(() => {
197
198
  return {ok: true, projects};
198
199
  });
199
200
  ipcMain.handle('kitcode:onboarding-remove-project', async (event, projectId) => {
200
- if (event.sender !== window.webContents) return {ok: false, error: 'Invalid setup request.'};
201
- if (!projectId) return {ok: false, error: 'Project not found.'};
201
+ if (event.sender !== window.webContents) return {ok: false, error: WELCOME_ERRORS.invalidRequest};
202
+ if (!projectId) return {ok: false, error: WELCOME_ERRORS.projectNotFound};
202
203
  try {
203
204
  const removed = removeProject(projectId);
204
- if (!removed) return {ok: false, error: 'Project not found.'};
205
+ if (!removed) return {ok: false, error: WELCOME_ERRORS.projectNotFound};
205
206
  return {ok: true, projects: listProjectRecords()};
206
207
  } catch {
207
- return {ok: false, error: 'KitCode could not remove that project.'};
208
+ return {ok: false, error: WELCOME_ERRORS.removeFailed};
208
209
  }
209
210
  });
210
211
  ipcMain.handle('kitcode:onboarding-close', (event) => {
@@ -1,3 +1,5 @@
1
+ import {WELCOME_WORDS} from './welcome-words.mjs';
2
+
1
3
  const PLATFORM_CHROME = {
2
4
  darwin: {
3
5
  id: 'darwin',
@@ -41,9 +43,10 @@ export function setupPlatformTheme(platform) {
41
43
  return PLATFORM_CHROME[resolveSetupPlatform(platform)];
42
44
  }
43
45
 
46
+ // Welcome's title bar, in Welcome's words (src/welcome-words.mjs); the Mini has its own (src/companion-controls.mjs).
44
47
  export function renderPlatformChrome(theme = setupPlatformTheme()) {
45
- return `<div class="${theme.chromeClass}" title="Drag window">
46
- <button class="dot filled dot-close" id="closeButton" type="button" aria-label="Close setup" title="Close"></button>
48
+ return `<div class="${theme.chromeClass}" title="${WELCOME_WORDS.chrome.drag}">
49
+ <button class="dot filled dot-close" id="closeButton" type="button" aria-label="${WELCOME_WORDS.chrome.closeName}" title="${WELCOME_WORDS.chrome.close}"></button>
47
50
  <span class="dot" aria-hidden="true"></span>
48
51
  <span class="dot" aria-hidden="true"></span>
49
52
  </div>`;