@dappermountain/payload-plugin-achievements 0.2.5 → 0.2.7

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.
Files changed (77) hide show
  1. package/README.md +34 -8
  2. package/dist/admin/LucideIconField.d.ts +1 -1
  3. package/dist/admin/LucideIconField.d.ts.map +1 -1
  4. package/dist/admin/LucideIconField.js +139 -24
  5. package/dist/catalog.d.ts +1 -0
  6. package/dist/catalog.d.ts.map +1 -1
  7. package/dist/catalog.js +1 -1
  8. package/dist/cli/reconcile.d.ts.map +1 -1
  9. package/dist/cli/reconcile.js +1 -0
  10. package/dist/collections/MetricBalances/access/index.d.ts +4 -0
  11. package/dist/collections/MetricBalances/access/index.d.ts.map +1 -0
  12. package/dist/collections/MetricBalances/access/index.js +9 -0
  13. package/dist/collections/MetricBalances/hooks/index.d.ts +3 -0
  14. package/dist/collections/MetricBalances/hooks/index.d.ts.map +1 -0
  15. package/dist/collections/MetricBalances/hooks/index.js +1 -0
  16. package/dist/collections/MetricBalances/index.d.ts +3 -0
  17. package/dist/collections/MetricBalances/index.d.ts.map +1 -0
  18. package/dist/collections/MetricBalances/index.js +48 -0
  19. package/dist/collections/Metrics/index.d.ts.map +1 -1
  20. package/dist/collections/Metrics/index.js +64 -3
  21. package/dist/collections/fields/ruleFields.d.ts.map +1 -1
  22. package/dist/collections/fields/ruleFields.js +45 -2
  23. package/dist/collections/hooks/preventCatalogDelete.d.ts.map +1 -1
  24. package/dist/collections/hooks/preventCatalogDelete.js +10 -1
  25. package/dist/collections/index.d.ts.map +1 -1
  26. package/dist/collections/index.js +2 -0
  27. package/dist/collections/slugs.d.ts.map +1 -1
  28. package/dist/collections/slugs.js +1 -0
  29. package/dist/defaults.d.ts +4 -0
  30. package/dist/defaults.d.ts.map +1 -1
  31. package/dist/defaults.js +10 -0
  32. package/dist/endpoints/index.d.ts.map +1 -1
  33. package/dist/endpoints/index.js +4 -0
  34. package/dist/endpoints/leaderboard.d.ts +6 -0
  35. package/dist/endpoints/leaderboard.d.ts.map +1 -0
  36. package/dist/endpoints/leaderboard.js +59 -0
  37. package/dist/extensions/elapsedSinceOptions.d.ts +7 -0
  38. package/dist/extensions/elapsedSinceOptions.d.ts.map +1 -0
  39. package/dist/extensions/elapsedSinceOptions.js +16 -0
  40. package/dist/index.d.ts +4 -4
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +2 -2
  43. package/dist/plugin.d.ts.map +1 -1
  44. package/dist/plugin.js +9 -0
  45. package/dist/seed/index.d.ts +10 -0
  46. package/dist/seed/index.d.ts.map +1 -1
  47. package/dist/seed/index.js +14 -2
  48. package/dist/services/achievement/builtInRules.d.ts.map +1 -1
  49. package/dist/services/achievement/builtInRules.js +59 -21
  50. package/dist/services/achievement/duration.d.ts +6 -0
  51. package/dist/services/achievement/duration.d.ts.map +1 -0
  52. package/dist/services/achievement/duration.js +43 -0
  53. package/dist/services/achievement/getMetricLeaderboard.d.ts +30 -0
  54. package/dist/services/achievement/getMetricLeaderboard.d.ts.map +1 -0
  55. package/dist/services/achievement/getMetricLeaderboard.js +62 -0
  56. package/dist/services/achievement/index.d.ts +5 -0
  57. package/dist/services/achievement/index.d.ts.map +1 -1
  58. package/dist/services/achievement/index.js +4 -0
  59. package/dist/services/achievement/metricBalances.d.ts +42 -0
  60. package/dist/services/achievement/metricBalances.d.ts.map +1 -0
  61. package/dist/services/achievement/metricBalances.js +137 -0
  62. package/dist/services/achievement/reconcile.d.ts +1 -0
  63. package/dist/services/achievement/reconcile.d.ts.map +1 -1
  64. package/dist/services/achievement/reconcile.js +10 -0
  65. package/dist/services/achievement/recordEvent.d.ts.map +1 -1
  66. package/dist/services/achievement/recordEvent.js +22 -1
  67. package/dist/services/achievement/resolveElapsedAnchor.d.ts +15 -0
  68. package/dist/services/achievement/resolveElapsedAnchor.d.ts.map +1 -0
  69. package/dist/services/achievement/resolveElapsedAnchor.js +98 -0
  70. package/dist/services/achievement/resolveMetricValue.d.ts +32 -0
  71. package/dist/services/achievement/resolveMetricValue.d.ts.map +1 -0
  72. package/dist/services/achievement/resolveMetricValue.js +73 -0
  73. package/dist/services/achievement/validateRules.d.ts.map +1 -1
  74. package/dist/services/achievement/validateRules.js +19 -0
  75. package/dist/types.d.ts +26 -3
  76. package/dist/types.d.ts.map +1 -1
  77. package/package.json +1 -1
package/README.md CHANGED
@@ -79,7 +79,8 @@ Slugs are prefixed with `achievement-` by default so they don’t collide with y
79
79
 
80
80
  | Config key | Default slug | Purpose |
81
81
  | --- | --- | --- |
82
- | `metrics` | `achievement-metrics` | Named scores (points, streaks, …). Totals are derived by summing `metric.delta` logs. |
82
+ | `metrics` | `achievement-metrics` | Named numbers: **stored** (score totals) or **computed** (e.g. elapsed days). |
83
+ | `metricBalances` | `achievement-metric-balances` | Projection of stored metric totals per user (+ scope) for fast reads / leaderboards. |
83
84
  | `eventTypes` | `achievement-event-types` | Kinds of log entries you can count or require. |
84
85
  | `tiers` | `achievement-tiers` | Ordered ladder steps with unlock rules. |
85
86
  | `achievements` | `achievement-definitions` | Things a user can earn (with eligibility / completion rules). |
@@ -106,7 +107,7 @@ Unlock (tiers), eligibility, and completion (achievements) share the same rule t
106
107
 
107
108
  - Top-level empty list → everyone passes
108
109
  - Nested groups → AND (“all of these”) or OR (“any of these”)
109
- - Built-in leaves → `tier-at-least`, `achievement-complete`, `metric-minimum`, `event-count`
110
+ - Built-in leaves → `tier-at-least`, `achievement-complete`, `metric-minimum`, `event-count`, `elapsed-since`
110
111
  - Custom leaves → register via `extensions.ruleTypes`
111
112
 
112
113
  **Current tier** is computed by walking tiers in `rank` order and evaluating each tier’s unlock rules (`resolveCurrentTier`). Tiers with **`requiresReview`** only become current after an **approved tier request**. **Next-tier fill** uses fractional progress on the same trees (`resolveTierProgress` / `evaluateRuleProgress`):
@@ -116,9 +117,17 @@ Unlock (tiers), eligibility, and completion (achievements) share the same rule t
116
117
  | AND | Equal average of *requirement* children |
117
118
  | OR | Best child (max) |
118
119
  | `tier-at-least` | Gate — skipped in AND averages |
119
- | `event-count` / `metric-minimum` | Fraction of the target |
120
+ | `event-count` / `metric-minimum` / `elapsed-since` | Fraction of the target |
120
121
  | `achievement-complete` | `1` if granted; otherwise rolls up that achievement’s completion (or eligibility) rules |
121
122
 
123
+ **`elapsed-since`** — time since an anchor ≥ amount (unit: `days` | `hours` | `minutes`). Built-in anchors: user `createdAt`, or earliest log of an event type (`since: 'first-event'`). Hosts register more anchors via `extensions.metricAnchors` (same keys work on computed metrics).
124
+
125
+ **Stored vs computed metrics** — `kind: stored` (default) totals live in `achievement-metric-balances`, updated by `recordMetricChange` (log + balance dual-write). Logs remain the audit/rebuild source; reconcile backfills balances. `kind: computed` + `compute: elapsed` exposes a live number (e.g. days since an anchor); use `metric-minimum` against it. System seed only creates stored `points` — product computed metrics (e.g. `days`) are host-seeded after registering anchors.
126
+
127
+ **Leaderboards** — `getMetricLeaderboard` / `GET /api/achievements/leaderboard?metric=points` ranks stored balances (authenticated). Disable with `endpoints.leaderboard: false`.
128
+
129
+ **System catalog** — by default the plugin runs `seedAchievementCatalog` on Payload `onInit` (opt out with `seedSystemCatalog: false`). Hosts still seed product catalog + locales via `seedAchievements`.
130
+
122
131
  **Eligibility rules** gate who may request an achievement. **Completion rules** define what a composed achievement is made of. When `requiresReview` is false and completion rules pass, the parent can auto-grant. Cycles in nested achievement graphs are ignored.
123
132
 
124
133
  ### Access model
@@ -159,8 +168,11 @@ achievementPlugin({
159
168
  overrides?: Partial<Record<AchievementCollectionKey, AchievementCollectionOverride>>
160
169
  }
161
170
  users?: { includeJoins?: boolean }
162
- endpoints?: { me?: string | false }
163
- extensions?: { ruleTypes?: AchievementRuleType[] }
171
+ endpoints?: { me?: string | false; leaderboard?: string | false; reconcile?: string | false }
172
+ extensions?: {
173
+ ruleTypes?: AchievementRuleType[]
174
+ metricAnchors?: Record<string, AchievementMetricAnchor>
175
+ }
164
176
  })
165
177
  ```
166
178
 
@@ -169,10 +181,12 @@ achievementPlugin({
169
181
  | Option | Default | Notes |
170
182
  | --- | --- | --- |
171
183
  | `enabled` | `true` | Set `false` to leave the plugin installed but inactive (no collections / endpoints merged). |
184
+ | `seedSystemCatalog` | `true` | On `onInit`, upsert system metrics + event types (`points`, `metric.delta`, …). |
172
185
  | `usersCollectionSlug` | `'users'` | Users collection the plugin relates to (grants, requests, joins). |
173
186
  | `canReview` | _(denied)_ | **Important.** Without this, users cannot manage catalogs or approve requests. Receives `(user, scopeId)` where `scopeId` may be `null`. |
174
187
  | `scope` | _(none)_ | Multi-tenant hook-up. `collection` is the relation target (e.g. `'tenants'`). `relationField` defaults to `'scope'`. Adds an optional scope field across plugin collections. |
175
188
  | `mediaCollection` | _(none)_ | When set (e.g. `'media'`), tiers get an optional upload field for ladder badge images. Icon string keys still work without this. |
189
+ | `extensions.metricAnchors` | _(none)_ | Named `(ctx) => Date | null` resolvers for computed elapsed metrics / `elapsed-since`. Keys appear in Admin `since` selects. |
176
190
 
177
191
  ### `collections`
178
192
 
@@ -217,17 +231,25 @@ achievementPlugin({
217
231
  | Option | Default | Notes |
218
232
  | --- | --- | --- |
219
233
  | `me` | `'/achievements/me'` | Registers `GET /api/achievements/me` for the signed-in user’s grants snapshot (+ derived tiers and lean requests). Pass `false` to skip. Having review access does **not** widen this endpoint to other users. |
234
+ | `leaderboard` | `'/achievements/leaderboard'` | Registers `GET /api/achievements/leaderboard?metric=<slug>` for authenticated users (stored metrics only). Pass `false` to skip. |
235
+ | `reconcile` | `'/achievements/reconcile'` | Registers `POST /api/achievements/reconcile` for reviewers. Pass `false` to skip. |
220
236
 
221
- Query params: `scope`, `limit`, `page`, `requestsLimit`.
237
+ Query params (`me`): `scope`, `limit`, `page`, `requestsLimit`. Leaderboard: `metric` (required), `scope`, `limit`, `page`.
222
238
 
223
- ### `extensions.ruleTypes`
239
+ ### `extensions.ruleTypes` / `metricAnchors`
224
240
 
225
- Register custom rule evaluators merged with the built-ins:
241
+ Register custom rule evaluators and/or date anchors:
226
242
 
227
243
  ```ts
228
244
  achievementPlugin({
229
245
  canReview,
230
246
  extensions: {
247
+ metricAnchors: {
248
+ 'membership-granted': async ({ payload, userId, scopeId }) => {
249
+ // return a Date from host data, or null
250
+ return null
251
+ },
252
+ },
231
253
  ruleTypes: [
232
254
  {
233
255
  type: 'host.custom-check',
@@ -251,6 +273,8 @@ Use these from hooks, jobs, and trusted endpoints — not from the public client
251
273
  import {
252
274
  recordLog,
253
275
  recordMetricChange,
276
+ getMetricLeaderboard,
277
+ resolveMetricValue,
254
278
  grantAchievement,
255
279
  reconcileProgression,
256
280
  reconcileUserProgression,
@@ -263,6 +287,7 @@ import {
263
287
 
264
288
  await recordLog({ payload, userId, scopeId, type: 'host.custom-kind', actorId })
265
289
  await recordMetricChange({ payload, userId, scopeId, metric: 'points', change: 10 })
290
+ const board = await getMetricLeaderboard({ payload, metric: 'points', scopeId, limit: 20 })
266
291
  const tier = await resolveCurrentTier({ payload, userId, scopeId })
267
292
 
268
293
  // Repair users whose grants predate side-effect hooks (idempotent)
@@ -306,6 +331,7 @@ Turning **Requires review** off on a definition or tier **approves pending reque
306
331
  | Goal | Request |
307
332
  | --- | --- |
308
333
  | Current user snapshot | `GET /api/achievements/me?scope=<scopeId>&limit=10&page=1` |
334
+ | Stored metric leaderboard | `GET /api/achievements/leaderboard?metric=points&scope=<scopeId>&limit=20&page=1` |
309
335
  | Repair progression | `POST /api/achievements/reconcile` (reviewer; optional `{ userId, scopeId, achievementId\|achievementSlug, tierId\|tierSlug, limit }`) |
310
336
  | List grants | `GET /api/achievement-grants?where[user][equals]=<userId>` |
311
337
  | Log history | `GET /api/achievement-logs?where[user][equals]=<userId>` |
@@ -1,6 +1,6 @@
1
1
  import type { TextFieldClientComponent } from 'payload';
2
2
  /**
3
- * Searchable Lucide icon picker. Stores the kebab-case Lucide name (e.g. `sparkles`).
3
+ * Emoji-picker style Lucide field: searchable icon grid, stores kebab-case names.
4
4
  */
5
5
  export declare const LucideIconField: TextFieldClientComponent;
6
6
  //# sourceMappingURL=LucideIconField.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"LucideIconField.d.ts","sourceRoot":"","sources":["../../src/admin/LucideIconField.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,wBAAwB,EAAgB,MAAM,SAAS,CAAA;AA4BrE;;GAEG;AACH,eAAO,MAAM,eAAe,EAAE,wBA4D7B,CAAA"}
1
+ {"version":3,"file":"LucideIconField.d.ts","sourceRoot":"","sources":["../../src/admin/LucideIconField.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,SAAS,CAAA;AAoCvD;;GAEG;AACH,eAAO,MAAM,eAAe,EAAE,wBAsO7B,CAAA"}
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { FieldDescription, FieldError, FieldLabel, SelectInput, useField } from '@payloadcms/ui';
3
+ import { FieldDescription, FieldError, FieldLabel, useField } from '@payloadcms/ui';
4
4
  import { icons } from 'lucide-react';
5
- import { useMemo } from 'react';
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
6
  function toKebabCase(pascal) {
7
7
  return pascal
8
8
  .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
@@ -16,15 +16,17 @@ function toPascalCase(kebab) {
16
16
  .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
17
17
  .join('');
18
18
  }
19
- const ICON_OPTIONS = Object.keys(icons)
19
+ const ALL_ICONS = Object.keys(icons)
20
20
  .filter((key) => /^[A-Z]/.test(key))
21
- .map((pascal) => {
22
- const value = toKebabCase(pascal);
23
- return { label: value, value };
24
- })
25
- .sort((a, b) => a.value.localeCompare(b.value));
21
+ .map((pascal) => ({
22
+ name: toKebabCase(pascal),
23
+ Icon: icons[pascal],
24
+ }))
25
+ .sort((a, b) => a.name.localeCompare(b.name));
26
+ const MAX_VISIBLE = 180;
27
+ const CELL = 36;
26
28
  /**
27
- * Searchable Lucide icon picker. Stores the kebab-case Lucide name (e.g. `sparkles`).
29
+ * Emoji-picker style Lucide field: searchable icon grid, stores kebab-case names.
28
30
  */
29
31
  export const LucideIconField = (props) => {
30
32
  const { field, path: pathFromProps, readOnly } = props;
@@ -33,24 +35,137 @@ export const LucideIconField = (props) => {
33
35
  path,
34
36
  });
35
37
  const selected = typeof value === 'string' && value.length > 0 ? value : undefined;
36
- const PreviewIcon = selected
38
+ const SelectedIcon = selected
37
39
  ? icons[toPascalCase(selected)]
38
40
  : undefined;
39
- const options = useMemo(() => ICON_OPTIONS, []);
40
- return (_jsxs("div", { className: "field-type", children: [_jsx(FieldLabel, { label: field?.label ?? 'Icon', path: path, required: Boolean(field?.required) }), _jsxs("div", { style: { alignItems: 'center', display: 'flex', gap: '0.75rem' }, children: [_jsx("div", { "aria-hidden": true, style: {
41
+ const [open, setOpen] = useState(false);
42
+ const [query, setQuery] = useState('');
43
+ const rootRef = useRef(null);
44
+ const searchRef = useRef(null);
45
+ const filtered = useMemo(() => {
46
+ const q = query.trim().toLowerCase();
47
+ const list = q
48
+ ? ALL_ICONS.filter((entry) => entry.name.includes(q))
49
+ : ALL_ICONS;
50
+ return list.slice(0, MAX_VISIBLE);
51
+ }, [query]);
52
+ const totalMatches = useMemo(() => {
53
+ const q = query.trim().toLowerCase();
54
+ if (!q)
55
+ return ALL_ICONS.length;
56
+ return ALL_ICONS.filter((entry) => entry.name.includes(q)).length;
57
+ }, [query]);
58
+ useEffect(() => {
59
+ if (!open)
60
+ return;
61
+ const onPointerDown = (event) => {
62
+ if (!rootRef.current?.contains(event.target)) {
63
+ setOpen(false);
64
+ }
65
+ };
66
+ const onKeyDown = (event) => {
67
+ if (event.key === 'Escape')
68
+ setOpen(false);
69
+ };
70
+ document.addEventListener('mousedown', onPointerDown);
71
+ document.addEventListener('keydown', onKeyDown);
72
+ queueMicrotask(() => searchRef.current?.focus());
73
+ return () => {
74
+ document.removeEventListener('mousedown', onPointerDown);
75
+ document.removeEventListener('keydown', onKeyDown);
76
+ };
77
+ }, [open]);
78
+ const pick = useCallback((name) => {
79
+ setValue(name);
80
+ setOpen(false);
81
+ setQuery('');
82
+ }, [setValue]);
83
+ const clear = useCallback(() => {
84
+ setValue(null);
85
+ setQuery('');
86
+ }, [setValue]);
87
+ return (_jsxs("div", { className: "field-type", ref: rootRef, children: [_jsx(FieldLabel, { label: field?.label ?? 'Icon', path: path, required: Boolean(field?.required) }), _jsxs("div", { style: { alignItems: 'center', display: 'flex', gap: '0.5rem', position: 'relative' }, children: [_jsxs("button", { "aria-expanded": open, "aria-haspopup": "dialog", disabled: Boolean(readOnly), onClick: () => setOpen((prev) => !prev), style: {
41
88
  alignItems: 'center',
89
+ background: 'var(--theme-input-bg)',
42
90
  border: '1px solid var(--theme-elevation-150)',
43
- borderRadius: 6,
44
- display: 'flex',
45
- flexShrink: 0,
91
+ borderRadius: 'var(--style-radius-s, 4px)',
92
+ color: 'var(--theme-text)',
93
+ cursor: readOnly ? 'default' : 'pointer',
94
+ display: 'inline-flex',
95
+ gap: '0.5rem',
96
+ minHeight: 40,
97
+ minWidth: 160,
98
+ padding: '0 0.75rem',
99
+ }, type: "button", children: [_jsx("span", { "aria-hidden": true, style: {
100
+ alignItems: 'center',
101
+ display: 'inline-flex',
102
+ height: 22,
103
+ justifyContent: 'center',
104
+ width: 22,
105
+ }, children: SelectedIcon ? (_jsx(SelectedIcon, { size: 18 })) : (_jsx("span", { style: { color: 'var(--theme-elevation-400)', fontSize: 12 }, children: "\u2014" })) }), _jsx("span", { style: { fontSize: 13, opacity: selected ? 1 : 0.55 }, children: selected ?? 'Choose icon…' })] }), selected && !readOnly ? (_jsx("button", { onClick: clear, style: {
106
+ background: 'transparent',
107
+ border: '1px solid var(--theme-elevation-150)',
108
+ borderRadius: 'var(--style-radius-s, 4px)',
109
+ color: 'var(--theme-elevation-600)',
110
+ cursor: 'pointer',
111
+ fontSize: 12,
46
112
  height: 40,
47
- justifyContent: 'center',
48
- width: 40,
49
- }, children: PreviewIcon ? (_jsx(PreviewIcon, { size: 20 })) : (_jsx("span", { style: { color: 'var(--theme-elevation-400)', fontSize: 12 }, children: "\u2014" })) }), _jsx("div", { style: { flex: 1, minWidth: 0 }, children: _jsx(SelectInput, { isClearable: true, name: path, onChange: (option) => {
50
- if (Array.isArray(option)) {
51
- setValue(option[0]?.value ?? null);
52
- return;
53
- }
54
- setValue(option?.value ?? null);
55
- }, options: options, path: path, readOnly: Boolean(readOnly), value: selected }) })] }), _jsx(FieldDescription, { description: field?.admin?.description, path: path }), _jsx(FieldError, { message: errorMessage, path: path, showError: showError })] }));
113
+ padding: '0 0.65rem',
114
+ }, type: "button", children: "Clear" })) : null, open && !readOnly ? (_jsxs("div", { role: "dialog", style: {
115
+ background: 'var(--theme-elevation-0)',
116
+ border: '1px solid var(--theme-elevation-150)',
117
+ borderRadius: 'var(--style-radius-m, 6px)',
118
+ boxShadow: '0 8px 28px rgba(0,0,0,0.18)',
119
+ display: 'flex',
120
+ flexDirection: 'column',
121
+ left: 0,
122
+ maxWidth: 'min(360px, 100vw)',
123
+ padding: '0.65rem',
124
+ position: 'absolute',
125
+ top: 'calc(100% + 6px)',
126
+ width: 360,
127
+ zIndex: 40,
128
+ }, children: [_jsx("input", { "aria-label": "Search Lucide icons", onChange: (event) => setQuery(event.target.value), placeholder: "Search icons\u2026", ref: searchRef, style: {
129
+ background: 'var(--theme-input-bg)',
130
+ border: '1px solid var(--theme-elevation-150)',
131
+ borderRadius: 'var(--style-radius-s, 4px)',
132
+ color: 'var(--theme-text)',
133
+ fontSize: 13,
134
+ marginBottom: '0.5rem',
135
+ padding: '0.45rem 0.6rem',
136
+ width: '100%',
137
+ }, type: "search", value: query }), _jsx("div", { style: {
138
+ display: 'grid',
139
+ gap: 4,
140
+ gridTemplateColumns: `repeat(auto-fill, minmax(${CELL}px, 1fr))`,
141
+ maxHeight: 260,
142
+ overflowY: 'auto',
143
+ paddingBottom: 4,
144
+ }, children: filtered.map(({ name, Icon }) => {
145
+ const isActive = name === selected;
146
+ return (_jsx("button", { onClick: () => pick(name), style: {
147
+ alignItems: 'center',
148
+ aspectRatio: '1',
149
+ background: isActive
150
+ ? 'var(--theme-success-100, var(--theme-elevation-100))'
151
+ : 'transparent',
152
+ border: isActive
153
+ ? '1px solid var(--theme-success-500, var(--theme-elevation-400))'
154
+ : '1px solid transparent',
155
+ borderRadius: 6,
156
+ color: 'var(--theme-text)',
157
+ cursor: 'pointer',
158
+ display: 'inline-flex',
159
+ justifyContent: 'center',
160
+ padding: 0,
161
+ }, title: name, type: "button", children: _jsx(Icon, { size: 18 }) }, name));
162
+ }) }), _jsx("div", { style: {
163
+ color: 'var(--theme-elevation-500)',
164
+ fontSize: 11,
165
+ marginTop: 6,
166
+ }, children: totalMatches === 0
167
+ ? 'No icons match.'
168
+ : totalMatches > MAX_VISIBLE
169
+ ? `Showing ${MAX_VISIBLE} of ${totalMatches} — refine search`
170
+ : `${totalMatches} icon${totalMatches === 1 ? '' : 's'}` })] })) : null] }), _jsx(FieldDescription, { description: field?.admin?.description, path: path }), _jsx(FieldError, { message: errorMessage, path: path, showError: showError })] }));
56
171
  };
package/dist/catalog.d.ts CHANGED
@@ -15,6 +15,7 @@ export declare const ENGINE_EVENT_TYPES: readonly [{
15
15
  export declare const ENGINE_METRICS: readonly [{
16
16
  readonly slug: "points";
17
17
  readonly name: "Points";
18
+ readonly kind: "stored";
18
19
  }];
19
20
  export type EngineEventTypeSlug = (typeof ENGINE_EVENT_TYPES)[number]['slug'];
20
21
  export type EngineMetricSlug = (typeof ENGINE_METRICS)[number]['slug'];
@@ -1 +1 @@
1
- {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;EAKrB,CAAA;AAEV,eAAO,MAAM,cAAc;;;EAAgD,CAAA;AAE3E,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAA;AAC7E,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAA;AAEtE,eAAO,MAAM,+BAA+B,EAAE,gBAA2B,CAAA;AAEzE,+DAA+D;AAC/D,eAAO,MAAM,0BAA0B,UAAkC,CAAA"}
1
+ {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;EAKrB,CAAA;AAEV,eAAO,MAAM,cAAc;;;;EAAyE,CAAA;AAEpG,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAA;AAC7E,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAA;AAEtE,eAAO,MAAM,+BAA+B,EAAE,gBAA2B,CAAA;AAEzE,+DAA+D;AAC/D,eAAO,MAAM,0BAA0B,UAAkC,CAAA"}
package/dist/catalog.js CHANGED
@@ -4,7 +4,7 @@ export const ENGINE_EVENT_TYPES = [
4
4
  { slug: 'metric.delta', name: 'Metric delta', requiresMetric: true },
5
5
  { slug: 'tier.changed', name: 'Tier changed' },
6
6
  ];
7
- export const ENGINE_METRICS = [{ slug: 'points', name: 'Points' }];
7
+ export const ENGINE_METRICS = [{ slug: 'points', name: 'Points', kind: 'stored' }];
8
8
  export const DEFAULT_ACHIEVEMENT_METRIC_SLUG = 'points';
9
9
  /** @deprecated Use {@link DEFAULT_ACHIEVEMENT_METRIC_SLUG}. */
10
10
  export const DEFAULT_ACHIEVEMENT_METRIC = DEFAULT_ACHIEVEMENT_METRIC_SLUG;
@@ -1 +1 @@
1
- {"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../src/cli/reconcile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,SAAS,CAAA;AAE1D,OAAO,EAEL,KAAK,wBAAwB,EAC7B,KAAK,0BAA0B,EAChC,MAAM,sCAAsC,CAAA;AAE7C,MAAM,MAAM,mBAAmB,GAAG,IAAI,CAAC,wBAAwB,EAAE,SAAS,GAAG,KAAK,CAAC,CAAA;AA2BnF,sFAAsF;AACtF,wBAAgB,qBAAqB,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,mBAAmB,CAUjG;AAED,wBAAgB,qBAAqB,IAAI,IAAI,CAc5C;AAED;;;GAGG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE;IAC1C,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAAA;IAClD,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;IACf,OAAO,CAAC,EAAE,mBAAmB,CAAA;IAC7B,kFAAkF;IAClF,IAAI,CAAC,EAAE,OAAO,CAAA;CACf,GAAG,OAAO,CAAC,0BAA0B,CAAC,CA2BtC"}
1
+ {"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../src/cli/reconcile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,SAAS,CAAA;AAE1D,OAAO,EAEL,KAAK,wBAAwB,EAC7B,KAAK,0BAA0B,EAChC,MAAM,sCAAsC,CAAA;AAE7C,MAAM,MAAM,mBAAmB,GAAG,IAAI,CAAC,wBAAwB,EAAE,SAAS,GAAG,KAAK,CAAC,CAAA;AA2BnF,sFAAsF;AACtF,wBAAgB,qBAAqB,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,mBAAmB,CAUjG;AAED,wBAAgB,qBAAqB,IAAI,IAAI,CAc5C;AAED;;;GAGG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE;IAC1C,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAAA;IAClD,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;IACf,OAAO,CAAC,EAAE,mBAAmB,CAAA;IAC7B,kFAAkF;IAClF,IAAI,CAAC,EAAE,OAAO,CAAA;CACf,GAAG,OAAO,CAAC,0BAA0B,CAAC,CA4BtC"}
@@ -70,6 +70,7 @@ export async function runReconcileCli(args) {
70
70
  achievementRequestsEnsured: 0,
71
71
  tierRequestsEnsured: 0,
72
72
  tierLogsWritten: 0,
73
+ metricBalancesRebuilt: 0,
73
74
  };
74
75
  }
75
76
  const filters = args.filters ?? parseReconcileCliArgs(argv);
@@ -0,0 +1,4 @@
1
+ import type { CollectionConfig } from 'payload';
2
+ /** Projection rows — writes only via trusted helpers (`overrideAccess`). */
3
+ export declare const access: NonNullable<CollectionConfig['access']>;
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/collections/MetricBalances/access/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAK/C,4EAA4E;AAC5E,eAAO,MAAM,MAAM,EAAE,WAAW,CAAC,gBAAgB,CAAC,QAAQ,CAAC,CAK1D,CAAA"}
@@ -0,0 +1,9 @@
1
+ import { requireOne } from '../../../access/index.js';
2
+ import { isReviewer } from '../../../access/roles/index.js';
3
+ /** Projection rows — writes only via trusted helpers (`overrideAccess`). */
4
+ export const access = {
5
+ read: requireOne(isReviewer('metricBalances')),
6
+ create: () => false,
7
+ update: () => false,
8
+ delete: requireOne(isReviewer('metricBalances')),
9
+ };
@@ -0,0 +1,3 @@
1
+ import type { CollectionConfig } from 'payload';
2
+ export declare const hooks: CollectionConfig['hooks'];
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/collections/MetricBalances/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE/C,eAAO,MAAM,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAAM,CAAA"}
@@ -0,0 +1 @@
1
+ export const hooks = {};
@@ -0,0 +1,3 @@
1
+ import type { CollectionConfig } from 'payload';
2
+ export declare function buildMetricBalancesCollection(): CollectionConfig;
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/MetricBalances/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAa/C,wBAAgB,6BAA6B,IAAI,gBAAgB,CA4ChE"}
@@ -0,0 +1,48 @@
1
+ import { collectionAdmin, collectionOf, columnsWithOptionalScope, optionalFields, slugOf, } from '../helpers.js';
2
+ import { scopeField, userField } from '../fields/index.js';
3
+ import { access } from './access/index.js';
4
+ import { hooks } from './hooks/index.js';
5
+ export function buildMetricBalancesCollection() {
6
+ return {
7
+ slug: slugOf('metricBalances'),
8
+ access: { ...access },
9
+ hooks,
10
+ admin: collectionAdmin({
11
+ useAsTitle: 'key',
12
+ defaultColumns: columnsWithOptionalScope(['metric', 'user', 'value', 'scope', 'updatedAt']),
13
+ description: 'Stored metric totals per user (and scope). Updated by recordMetricChange; repair with reconcile. Not for computed metrics.',
14
+ }),
15
+ fields: [
16
+ userField(),
17
+ ...optionalFields(scopeField()),
18
+ {
19
+ name: 'metric',
20
+ type: 'relationship',
21
+ relationTo: collectionOf('metrics'),
22
+ required: true,
23
+ index: true,
24
+ },
25
+ {
26
+ name: 'value',
27
+ type: 'number',
28
+ required: true,
29
+ defaultValue: 0,
30
+ index: true,
31
+ admin: {
32
+ description: 'Current total (sum of metric.delta changes).',
33
+ },
34
+ },
35
+ {
36
+ name: 'key',
37
+ type: 'text',
38
+ required: true,
39
+ unique: true,
40
+ index: true,
41
+ admin: {
42
+ readOnly: true,
43
+ description: 'Stable unique key: user:scope:metric.',
44
+ },
45
+ },
46
+ ],
47
+ };
48
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/Metrics/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAO/C,wBAAgB,sBAAsB,IAAI,gBAAgB,CA8BzD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/collections/Metrics/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAS/C,wBAAgB,sBAAsB,IAAI,gBAAgB,CA2FzD"}
@@ -1,5 +1,7 @@
1
- import { collectionAdmin, columnsWithOptionalScope, optionalFields, slugOf } from '../helpers.js';
1
+ import { collectionAdmin, collectionOf, columnsWithOptionalScope, optionalFields, slugOf } from '../helpers.js';
2
2
  import { localizedNameField, scopeField } from '../fields/index.js';
3
+ import { catalogFilterOptions } from '../fields/catalogFilter.js';
4
+ import { elapsedSinceSelectOptions } from '../../extensions/elapsedSinceOptions.js';
3
5
  import { access } from './access/index.js';
4
6
  import { hooks } from './hooks/index.js';
5
7
  export function buildMetricsCollection() {
@@ -9,8 +11,8 @@ export function buildMetricsCollection() {
9
11
  hooks,
10
12
  admin: collectionAdmin({
11
13
  useAsTitle: 'name',
12
- defaultColumns: columnsWithOptionalScope(['name', 'slug', 'system', 'scope']),
13
- description: 'Scores and totals you track for someone — like points or a streak.',
14
+ defaultColumns: columnsWithOptionalScope(['name', 'slug', 'kind', 'system', 'scope']),
15
+ description: 'Named numbers. Stored metrics are score totals from log deltas; computed metrics are derived at evaluation time.',
14
16
  }),
15
17
  fields: [
16
18
  ...optionalFields(scopeField()),
@@ -21,6 +23,65 @@ export function buildMetricsCollection() {
21
23
  { name: 'slug', type: 'text', required: true, unique: true, index: true, admin: { width: '50%' } },
22
24
  ],
23
25
  },
26
+ {
27
+ name: 'kind',
28
+ type: 'select',
29
+ required: true,
30
+ defaultValue: 'stored',
31
+ options: [
32
+ { label: 'Stored (log deltas)', value: 'stored' },
33
+ { label: 'Computed', value: 'computed' },
34
+ ],
35
+ admin: {
36
+ description: 'Stored = sum of metric.delta logs (via balances). Computed = derived value.',
37
+ },
38
+ },
39
+ {
40
+ name: 'compute',
41
+ type: 'select',
42
+ defaultValue: 'elapsed',
43
+ options: [{ label: 'Elapsed time', value: 'elapsed' }],
44
+ admin: {
45
+ condition: (_, sibling) => sibling?.kind === 'computed',
46
+ description: 'How this number is derived.',
47
+ },
48
+ },
49
+ {
50
+ name: 'unit',
51
+ type: 'select',
52
+ defaultValue: 'days',
53
+ options: [
54
+ { label: 'Days', value: 'days' },
55
+ { label: 'Hours', value: 'hours' },
56
+ { label: 'Minutes', value: 'minutes' },
57
+ ],
58
+ admin: {
59
+ condition: (_, sibling) => sibling?.kind === 'computed' && sibling?.compute === 'elapsed',
60
+ description: 'Unit of the exposed number (e.g. whole days since the anchor).',
61
+ },
62
+ },
63
+ {
64
+ name: 'since',
65
+ type: 'select',
66
+ defaultValue: 'user-created-at',
67
+ options: elapsedSinceSelectOptions(),
68
+ admin: {
69
+ condition: (_, sibling) => sibling?.kind === 'computed' && sibling?.compute === 'elapsed',
70
+ description: 'Which timestamp starts the clock.',
71
+ },
72
+ },
73
+ {
74
+ name: 'eventType',
75
+ type: 'relationship',
76
+ relationTo: collectionOf('eventTypes'),
77
+ filterOptions: catalogFilterOptions,
78
+ admin: {
79
+ condition: (_, sibling) => sibling?.kind === 'computed' &&
80
+ sibling?.compute === 'elapsed' &&
81
+ sibling?.since === 'first-event',
82
+ description: 'Event type whose first log starts the clock.',
83
+ },
84
+ },
24
85
  {
25
86
  name: 'system',
26
87
  type: 'checkbox',
@@ -1 +1 @@
1
- {"version":3,"file":"ruleFields.d.ts","sourceRoot":"","sources":["../../../src/collections/fields/ruleFields.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAqGpC;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,KAAK,EAAE,CAqB7C;AAED,mDAAmD;AACnD,wBAAgB,kBAAkB,IAAI,KAAK,EAAE,CAE5C"}
1
+ {"version":3,"file":"ruleFields.d.ts","sourceRoot":"","sources":["../../../src/collections/fields/ruleFields.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAkJpC;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,KAAK,EAAE,CAqB7C;AAED,mDAAmD;AACnD,wBAAgB,kBAAkB,IAAI,KAAK,EAAE,CAE5C"}
@@ -1,5 +1,6 @@
1
1
  import { catalogFilterOptions } from './catalogFilter.js';
2
2
  import { collectionOf } from '../helpers.js';
3
+ import { elapsedSinceSelectOptions } from '../../extensions/elapsedSinceOptions.js';
3
4
  function combinatorField(visibleWhenGroup) {
4
5
  return {
5
6
  name: 'combinator',
@@ -25,6 +26,7 @@ function ruleTypeSelect(includeGroup) {
25
26
  { label: 'Achievement complete', value: 'achievement-complete' },
26
27
  { label: 'Metric minimum', value: 'metric-minimum' },
27
28
  { label: 'Event count', value: 'event-count' },
29
+ { label: 'Elapsed since', value: 'elapsed-since' },
28
30
  ...(includeGroup ? [{ label: 'Group', value: 'group' }] : []),
29
31
  ],
30
32
  };
@@ -75,8 +77,9 @@ function ruleParamFields() {
75
77
  relationTo: collectionOf('eventTypes'),
76
78
  filterOptions: catalogFilterOptions,
77
79
  admin: {
78
- condition: (_, sibling) => sibling?.type === 'event-count',
79
- description: 'Which event type to count.',
80
+ condition: (_, sibling) => sibling?.type === 'event-count' ||
81
+ (sibling?.type === 'elapsed-since' && sibling?.since === 'first-event'),
82
+ description: 'Event type to count, or (for elapsed since) the type whose first log starts the clock.',
80
83
  },
81
84
  },
82
85
  {
@@ -87,6 +90,46 @@ function ruleParamFields() {
87
90
  description: 'How many times it needs to have happened.',
88
91
  },
89
92
  },
93
+ {
94
+ name: 'since',
95
+ type: 'select',
96
+ defaultValue: 'user-created-at',
97
+ options: elapsedSinceSelectOptions(),
98
+ admin: {
99
+ condition: (_, sibling) => sibling?.type === 'elapsed-since',
100
+ description: 'Which timestamp starts the clock.',
101
+ },
102
+ },
103
+ {
104
+ type: 'row',
105
+ admin: {
106
+ condition: (_, sibling) => sibling?.type === 'elapsed-since',
107
+ },
108
+ fields: [
109
+ {
110
+ name: 'amount',
111
+ type: 'number',
112
+ admin: {
113
+ width: '50%',
114
+ description: 'How much time must pass.',
115
+ },
116
+ },
117
+ {
118
+ name: 'unit',
119
+ type: 'select',
120
+ defaultValue: 'days',
121
+ options: [
122
+ { label: 'Days', value: 'days' },
123
+ { label: 'Hours', value: 'hours' },
124
+ { label: 'Minutes', value: 'minutes' },
125
+ ],
126
+ admin: {
127
+ width: '50%',
128
+ description: 'Unit for the amount.',
129
+ },
130
+ },
131
+ ],
132
+ },
90
133
  ];
91
134
  }
92
135
  function buildRuleLeafFields() {
@@ -1 +1 @@
1
- {"version":3,"file":"preventCatalogDelete.d.ts","sourceRoot":"","sources":["../../../src/collections/hooks/preventCatalogDelete.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,SAAS,CAAA;AA6CzD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE;IACzC,IAAI,EAAE,WAAW,GAAG,QAAQ,CAAA;CAC7B,GAAG,0BAA0B,CAqD7B"}
1
+ {"version":3,"file":"preventCatalogDelete.d.ts","sourceRoot":"","sources":["../../../src/collections/hooks/preventCatalogDelete.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,SAAS,CAAA;AA6CzD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE;IACzC,IAAI,EAAE,WAAW,GAAG,QAAQ,CAAA;CAC7B,GAAG,0BAA0B,CA+D7B"}