featuredrop 1.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Glincker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,293 @@
1
+ # featuredrop
2
+
3
+ **Lightweight feature discovery system. Show "New" badges that auto-expire.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/featuredrop)](https://www.npmjs.com/package/featuredrop)
6
+ [![license](https://img.shields.io/npm/l/featuredrop)](https://github.com/GLINCKER/featuredrop/blob/main/LICENSE)
7
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/featuredrop)](https://bundlephobia.com/package/featuredrop)
8
+ [![CI](https://github.com/GLINCKER/featuredrop/actions/workflows/ci.yml/badge.svg)](https://github.com/GLINCKER/featuredrop/actions/workflows/ci.yml)
9
+
10
+ ---
11
+
12
+ ## Why featuredrop?
13
+
14
+ Every SaaS needs "New" badges on sidebar items when features ship. But most solutions are either too complex (LaunchDarkly), too coupled (Beamer), or don't actually expire.
15
+
16
+ **featuredrop** solves this with a dead-simple API:
17
+
18
+ - Define features in a manifest (just an array of objects)
19
+ - Badges auto-expire based on time windows
20
+ - Users can dismiss individually or "mark all as seen"
21
+ - Works with any framework — React bindings included
22
+ - Zero dependencies, < 2 kB minzipped
23
+
24
+ ## Quick Start
25
+
26
+ ### Install
27
+
28
+ ```bash
29
+ npm install featuredrop
30
+ # or
31
+ pnpm add featuredrop
32
+ ```
33
+
34
+ ### 1. Define your feature manifest
35
+
36
+ ```ts
37
+ import { createManifest } from 'featuredrop'
38
+
39
+ export const FEATURES = createManifest([
40
+ {
41
+ id: 'ai-journal',
42
+ label: 'AI Decision Journal',
43
+ description: 'Track decisions with AI-powered insights',
44
+ releasedAt: '2026-02-20T00:00:00Z',
45
+ showNewUntil: '2026-03-20T00:00:00Z',
46
+ sidebarKey: '/journal',
47
+ category: 'ai',
48
+ },
49
+ {
50
+ id: 'analytics-v2',
51
+ label: 'Analytics Dashboard v2',
52
+ releasedAt: '2026-02-25T00:00:00Z',
53
+ showNewUntil: '2026-03-25T00:00:00Z',
54
+ sidebarKey: '/analytics',
55
+ },
56
+ ])
57
+ ```
58
+
59
+ ### 2. Create a storage adapter
60
+
61
+ ```ts
62
+ import { LocalStorageAdapter } from 'featuredrop'
63
+
64
+ const storage = new LocalStorageAdapter({
65
+ // Server-side watermark from user profile (e.g. user.featuresSeenAt)
66
+ watermark: user.featuresSeenAt,
67
+ // Optional: callback when user clicks "Mark all as seen"
68
+ onDismissAll: async (now) => {
69
+ await api.updateUser({ featuresSeenAt: now.toISOString() })
70
+ },
71
+ })
72
+ ```
73
+
74
+ ### 3. Check what's new
75
+
76
+ ```ts
77
+ import { getNewFeatures, hasNewFeature } from 'featuredrop'
78
+
79
+ // Get all new features
80
+ const newFeatures = getNewFeatures(FEATURES, storage)
81
+ console.log(`${newFeatures.length} new features!`)
82
+
83
+ // Check a specific sidebar item
84
+ if (hasNewFeature(FEATURES, '/journal', storage)) {
85
+ showBadge('/journal')
86
+ }
87
+ ```
88
+
89
+ ## React Integration
90
+
91
+ ```bash
92
+ # React is an optional peer dependency — only needed if you use featuredrop/react
93
+ npm install featuredrop react
94
+ ```
95
+
96
+ ### Wrap your app with the provider
97
+
98
+ ```tsx
99
+ import { FeatureDropProvider } from 'featuredrop/react'
100
+ import { LocalStorageAdapter } from 'featuredrop'
101
+
102
+ const storage = new LocalStorageAdapter({
103
+ watermark: user.featuresSeenAt,
104
+ onDismissAll: (now) => api.markFeaturesSeen(now),
105
+ })
106
+
107
+ function App() {
108
+ return (
109
+ <FeatureDropProvider manifest={FEATURES} storage={storage}>
110
+ <Sidebar />
111
+ </FeatureDropProvider>
112
+ )
113
+ }
114
+ ```
115
+
116
+ ### Use hooks in your components
117
+
118
+ ```tsx
119
+ import { useNewFeature, NewBadge } from 'featuredrop/react'
120
+
121
+ function SidebarItem({ path, label }: { path: string; label: string }) {
122
+ const { isNew, dismiss } = useNewFeature(path)
123
+
124
+ return (
125
+ <a href={path} onClick={() => isNew && dismiss()}>
126
+ {label}
127
+ {isNew && <NewBadge />}
128
+ </a>
129
+ )
130
+ }
131
+ ```
132
+
133
+ ### "What's New" panel
134
+
135
+ ```tsx
136
+ import { useFeatureDrop } from 'featuredrop/react'
137
+
138
+ function WhatsNew() {
139
+ const { newFeatures, newCount, dismissAll } = useFeatureDrop()
140
+
141
+ return (
142
+ <div>
143
+ <h2>What's New ({newCount})</h2>
144
+ {newFeatures.map(f => (
145
+ <div key={f.id}>
146
+ <h3>{f.label}</h3>
147
+ <p>{f.description}</p>
148
+ </div>
149
+ ))}
150
+ <button onClick={dismissAll}>Mark all as seen</button>
151
+ </div>
152
+ )
153
+ }
154
+ ```
155
+
156
+ ## How It Works
157
+
158
+ ```
159
+ Feature Manifest (static) Storage Adapter
160
+ ┌─────────────────────┐ ┌──────────────────────┐
161
+ │ id: "ai-journal" │ │ watermark: server │
162
+ │ releasedAt: Feb 20 │ │ dismissed: localStorage│
163
+ │ showNewUntil: Mar 20│ └──────────┬───────────┘
164
+ └─────────┬───────────┘ │
165
+ │ │
166
+ ▼ ▼
167
+ ┌─────────────────────────────────────┐
168
+ │ isNew(feature) │
169
+ │ │
170
+ │ 1. Not dismissed? ✓ │
171
+ │ 2. Before showNewUntil? ✓ │
172
+ │ 3. Released after watermark? ✓ │
173
+ │ │
174
+ │ → Show "New" badge │
175
+ └─────────────────────────────────────┘
176
+ ```
177
+
178
+ **Three-check algorithm:**
179
+
180
+ 1. **Dismissed?** — Has the user clicked to dismiss this specific feature? (client-side, per-device)
181
+ 2. **Expired?** — Is the current time past `showNewUntil`? (automatic, no user action needed)
182
+ 3. **After watermark?** — Was the feature released after the user's "features seen at" timestamp? (server-side, cross-device)
183
+
184
+ This hybrid approach means:
185
+ - New users see all recent features (no watermark = everything is new)
186
+ - Returning users only see features released since their last visit
187
+ - Individual dismissals are instant (localStorage, no server call)
188
+ - "Mark all as seen" syncs across devices (server watermark update)
189
+
190
+ ## API Reference
191
+
192
+ ### Core Functions
193
+
194
+ | Function | Description |
195
+ |----------|-------------|
196
+ | `isNew(feature, watermark, dismissedIds, now?)` | Check if a single feature is "new" |
197
+ | `getNewFeatures(manifest, storage, now?)` | Get all currently new features |
198
+ | `getNewFeatureCount(manifest, storage, now?)` | Get count of new features |
199
+ | `hasNewFeature(manifest, sidebarKey, storage, now?)` | Check if a sidebar key has new features |
200
+
201
+ ### Helpers
202
+
203
+ | Function | Description |
204
+ |----------|-------------|
205
+ | `createManifest(entries)` | Create a frozen, typed manifest |
206
+ | `getFeatureById(manifest, id)` | Find a feature by ID |
207
+ | `getNewFeaturesByCategory(manifest, category, storage, now?)` | Filter new features by category |
208
+
209
+ ### Adapters
210
+
211
+ | Adapter | Description |
212
+ |---------|-------------|
213
+ | `LocalStorageAdapter` | Browser localStorage + server watermark |
214
+ | `MemoryAdapter` | In-memory (testing, SSR) |
215
+
216
+ ### React (`featuredrop/react`)
217
+
218
+ | Export | Description |
219
+ |--------|-------------|
220
+ | `FeatureDropProvider` | Context provider — wraps your app |
221
+ | `useFeatureDrop()` | Full context: `{ newFeatures, newCount, isNew, dismiss, dismissAll }` |
222
+ | `useNewFeature(key)` | Single item: `{ isNew, feature, dismiss }` |
223
+ | `useNewCount()` | Just the count number |
224
+ | `NewBadge` | Headless badge: `variant="pill" \| "dot" \| "count"` |
225
+
226
+ ### NewBadge Styling
227
+
228
+ Zero CSS framework dependency. Style via CSS custom properties:
229
+
230
+ ```css
231
+ /* In your global CSS or CSS-in-JS */
232
+ [data-featuredrop] {
233
+ --featuredrop-color: #b45309;
234
+ --featuredrop-bg: rgba(245, 158, 11, 0.15);
235
+ --featuredrop-font-size: 10px;
236
+ --featuredrop-dot-size: 8px;
237
+ --featuredrop-glow: rgba(245, 158, 11, 0.6);
238
+ --featuredrop-count-size: 18px;
239
+ --featuredrop-count-color: white;
240
+ --featuredrop-count-bg: #f59e0b;
241
+ }
242
+ ```
243
+
244
+ ## Custom Storage Adapter
245
+
246
+ Implement the `StorageAdapter` interface for your persistence layer:
247
+
248
+ ```ts
249
+ import type { StorageAdapter } from 'featuredrop'
250
+
251
+ class RedisAdapter implements StorageAdapter {
252
+ getWatermark(): string | null {
253
+ return this.cache.get('watermark')
254
+ }
255
+
256
+ getDismissedIds(): ReadonlySet<string> {
257
+ return new Set(this.cache.get('dismissed') ?? [])
258
+ }
259
+
260
+ dismiss(id: string): void {
261
+ this.cache.append('dismissed', id)
262
+ }
263
+
264
+ async dismissAll(now: Date): Promise<void> {
265
+ await this.cache.set('watermark', now.toISOString())
266
+ await this.cache.delete('dismissed')
267
+ }
268
+ }
269
+ ```
270
+
271
+ ## Comparison
272
+
273
+ | Feature | featuredrop | LaunchDarkly | Beamer | Joyride |
274
+ |---------|------------|-------------|--------|---------|
275
+ | Auto-expiring badges | Yes | No | No | No |
276
+ | Zero dependencies | Yes | No | No | No |
277
+ | Framework agnostic | Yes | Yes | No | No |
278
+ | React bindings | Yes | Yes | No | Yes |
279
+ | Server watermark | Yes | N/A | Yes | No |
280
+ | Per-feature dismiss | Yes | N/A | No | No |
281
+ | < 2 kB bundle | Yes | No | No | No |
282
+ | TypeScript | Yes | Yes | No | Partial |
283
+ | Free & OSS | Yes | No | Freemium | Yes |
284
+
285
+ ## License
286
+
287
+ MIT - [Glincker](https://glincker.com)
288
+
289
+ ---
290
+
291
+ <p align="center">
292
+ <strong>A <a href="https://glincker.com">GLINCKER</a> Open Source Project</strong>
293
+ </p>
package/dist/index.cjs ADDED
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ // src/core.ts
4
+ function isNew(feature, watermark, dismissedIds, now = /* @__PURE__ */ new Date()) {
5
+ if (dismissedIds.has(feature.id)) return false;
6
+ const nowMs = now.getTime();
7
+ const showUntilMs = new Date(feature.showNewUntil).getTime();
8
+ if (nowMs >= showUntilMs) return false;
9
+ if (watermark) {
10
+ const watermarkMs = new Date(watermark).getTime();
11
+ const releasedMs = new Date(feature.releasedAt).getTime();
12
+ if (releasedMs <= watermarkMs) return false;
13
+ }
14
+ return true;
15
+ }
16
+ function getNewFeatures(manifest, storage, now = /* @__PURE__ */ new Date()) {
17
+ const watermark = storage.getWatermark();
18
+ const dismissedIds = storage.getDismissedIds();
19
+ return manifest.filter((f) => isNew(f, watermark, dismissedIds, now));
20
+ }
21
+ function getNewFeatureCount(manifest, storage, now = /* @__PURE__ */ new Date()) {
22
+ return getNewFeatures(manifest, storage, now).length;
23
+ }
24
+ function hasNewFeature(manifest, sidebarKey, storage, now = /* @__PURE__ */ new Date()) {
25
+ const watermark = storage.getWatermark();
26
+ const dismissedIds = storage.getDismissedIds();
27
+ return manifest.some(
28
+ (f) => f.sidebarKey === sidebarKey && isNew(f, watermark, dismissedIds, now)
29
+ );
30
+ }
31
+
32
+ // src/helpers.ts
33
+ function createManifest(entries) {
34
+ return Object.freeze([...entries]);
35
+ }
36
+ function getFeatureById(manifest, id) {
37
+ return manifest.find((f) => f.id === id);
38
+ }
39
+ function getNewFeaturesByCategory(manifest, category, storage, now = /* @__PURE__ */ new Date()) {
40
+ const watermark = storage.getWatermark();
41
+ const dismissedIds = storage.getDismissedIds();
42
+ return manifest.filter(
43
+ (f) => f.category === category && isNew(f, watermark, dismissedIds, now)
44
+ );
45
+ }
46
+
47
+ // src/adapters/local-storage.ts
48
+ var DISMISSED_SUFFIX = ":dismissed";
49
+ var LocalStorageAdapter = class {
50
+ prefix;
51
+ watermarkValue;
52
+ onDismissAllCallback;
53
+ dismissedKey;
54
+ constructor(options = {}) {
55
+ this.prefix = options.prefix ?? "featuredrop";
56
+ this.watermarkValue = options.watermark ?? null;
57
+ this.onDismissAllCallback = options.onDismissAll;
58
+ this.dismissedKey = `${this.prefix}${DISMISSED_SUFFIX}`;
59
+ }
60
+ getWatermark() {
61
+ return this.watermarkValue;
62
+ }
63
+ getDismissedIds() {
64
+ try {
65
+ if (typeof window === "undefined") return /* @__PURE__ */ new Set();
66
+ const raw = localStorage.getItem(this.dismissedKey);
67
+ if (!raw) return /* @__PURE__ */ new Set();
68
+ const parsed = JSON.parse(raw);
69
+ if (Array.isArray(parsed)) return new Set(parsed);
70
+ return /* @__PURE__ */ new Set();
71
+ } catch {
72
+ return /* @__PURE__ */ new Set();
73
+ }
74
+ }
75
+ dismiss(id) {
76
+ try {
77
+ if (typeof window === "undefined") return;
78
+ const raw = localStorage.getItem(this.dismissedKey);
79
+ const existing = raw ? JSON.parse(raw) : [];
80
+ if (!existing.includes(id)) {
81
+ existing.push(id);
82
+ localStorage.setItem(this.dismissedKey, JSON.stringify(existing));
83
+ }
84
+ } catch {
85
+ }
86
+ }
87
+ async dismissAll(now) {
88
+ try {
89
+ if (typeof window !== "undefined") {
90
+ localStorage.removeItem(this.dismissedKey);
91
+ }
92
+ } catch {
93
+ }
94
+ if (this.onDismissAllCallback) {
95
+ await this.onDismissAllCallback(now);
96
+ }
97
+ }
98
+ };
99
+
100
+ // src/adapters/memory.ts
101
+ var MemoryAdapter = class {
102
+ watermark;
103
+ dismissed;
104
+ constructor(options = {}) {
105
+ this.watermark = options.watermark ?? null;
106
+ this.dismissed = /* @__PURE__ */ new Set();
107
+ }
108
+ getWatermark() {
109
+ return this.watermark;
110
+ }
111
+ getDismissedIds() {
112
+ return this.dismissed;
113
+ }
114
+ dismiss(id) {
115
+ this.dismissed.add(id);
116
+ }
117
+ async dismissAll(now) {
118
+ this.watermark = now.toISOString();
119
+ this.dismissed.clear();
120
+ }
121
+ };
122
+
123
+ exports.LocalStorageAdapter = LocalStorageAdapter;
124
+ exports.MemoryAdapter = MemoryAdapter;
125
+ exports.createManifest = createManifest;
126
+ exports.getFeatureById = getFeatureById;
127
+ exports.getNewFeatureCount = getNewFeatureCount;
128
+ exports.getNewFeatures = getNewFeatures;
129
+ exports.getNewFeaturesByCategory = getNewFeaturesByCategory;
130
+ exports.hasNewFeature = hasNewFeature;
131
+ exports.isNew = isNew;
132
+ //# sourceMappingURL=index.cjs.map
133
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core.ts","../src/helpers.ts","../src/adapters/local-storage.ts","../src/adapters/memory.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type { FeatureEntry, FeatureManifest, StorageAdapter } from \"./types\";\n\n/**\n * Check if a single feature should show as \"new\".\n *\n * A feature is \"new\" when ALL of these are true:\n * 1. Current time is before `showNewUntil`\n * 2. Feature was released after the watermark (or no watermark exists)\n * 3. Feature has not been individually dismissed\n */\nexport function isNew(\n feature: FeatureEntry,\n watermark: string | null,\n dismissedIds: ReadonlySet<string>,\n now: Date = new Date(),\n): boolean {\n // Already dismissed by the user on this device\n if (dismissedIds.has(feature.id)) return false;\n\n const nowMs = now.getTime();\n const showUntilMs = new Date(feature.showNewUntil).getTime();\n\n // Past the display window\n if (nowMs >= showUntilMs) return false;\n\n // If there's a watermark, feature must have been released after it\n if (watermark) {\n const watermarkMs = new Date(watermark).getTime();\n const releasedMs = new Date(feature.releasedAt).getTime();\n if (releasedMs <= watermarkMs) return false;\n }\n\n return true;\n}\n\n/**\n * Get all features that are currently \"new\" for this user.\n */\nexport function getNewFeatures(\n manifest: FeatureManifest,\n storage: StorageAdapter,\n now: Date = new Date(),\n): FeatureEntry[] {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.filter((f) => isNew(f, watermark, dismissedIds, now));\n}\n\n/**\n * Get the count of new features.\n */\nexport function getNewFeatureCount(\n manifest: FeatureManifest,\n storage: StorageAdapter,\n now: Date = new Date(),\n): number {\n return getNewFeatures(manifest, storage, now).length;\n}\n\n/**\n * Check if a specific sidebar key has a new feature.\n */\nexport function hasNewFeature(\n manifest: FeatureManifest,\n sidebarKey: string,\n storage: StorageAdapter,\n now: Date = new Date(),\n): boolean {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.some(\n (f) => f.sidebarKey === sidebarKey && isNew(f, watermark, dismissedIds, now),\n );\n}\n","import type { FeatureEntry, FeatureManifest, StorageAdapter } from \"./types\";\nimport { isNew } from \"./core\";\n\n/**\n * Create a frozen feature manifest from an array of entries.\n * Ensures the manifest is immutable at runtime.\n */\nexport function createManifest(\n entries: FeatureEntry[],\n): FeatureManifest {\n return Object.freeze([...entries]);\n}\n\n/**\n * Find a feature by its ID in the manifest.\n * Returns `undefined` if not found.\n */\nexport function getFeatureById(\n manifest: FeatureManifest,\n id: string,\n): FeatureEntry | undefined {\n return manifest.find((f) => f.id === id);\n}\n\n/**\n * Get all new features in a specific category.\n */\nexport function getNewFeaturesByCategory(\n manifest: FeatureManifest,\n category: string,\n storage: StorageAdapter,\n now: Date = new Date(),\n): FeatureEntry[] {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.filter(\n (f) => f.category === category && isNew(f, watermark, dismissedIds, now),\n );\n}\n","import type { StorageAdapter } from \"../types\";\n\nexport interface LocalStorageAdapterOptions {\n /** Key prefix for localStorage entries. Default: \"featuredrop\" */\n prefix?: string;\n /** Server-side watermark (ISO string). Typically from user profile. */\n watermark?: string | null;\n /** Callback when dismissAll is called. Use for server-side watermark updates. */\n onDismissAll?: (now: Date) => Promise<void>;\n}\n\nconst DISMISSED_SUFFIX = \":dismissed\";\n\n/**\n * localStorage-based storage adapter.\n *\n * Architecture:\n * - **Watermark** comes from the server (passed at construction time)\n * - **Per-feature dismissals** are stored in localStorage (zero server writes)\n * - **dismissAll()** optionally calls a server callback, then clears localStorage\n *\n * Gracefully handles SSR environments where `window`/`localStorage` is unavailable.\n */\nexport class LocalStorageAdapter implements StorageAdapter {\n private readonly prefix: string;\n private readonly watermarkValue: string | null;\n private readonly onDismissAllCallback?: (now: Date) => Promise<void>;\n private readonly dismissedKey: string;\n\n constructor(options: LocalStorageAdapterOptions = {}) {\n this.prefix = options.prefix ?? \"featuredrop\";\n this.watermarkValue = options.watermark ?? null;\n this.onDismissAllCallback = options.onDismissAll;\n this.dismissedKey = `${this.prefix}${DISMISSED_SUFFIX}`;\n }\n\n getWatermark(): string | null {\n return this.watermarkValue;\n }\n\n getDismissedIds(): ReadonlySet<string> {\n try {\n if (typeof window === \"undefined\") return new Set();\n const raw = localStorage.getItem(this.dismissedKey);\n if (!raw) return new Set();\n const parsed: unknown = JSON.parse(raw);\n if (Array.isArray(parsed)) return new Set(parsed as string[]);\n return new Set();\n } catch {\n return new Set();\n }\n }\n\n dismiss(id: string): void {\n try {\n if (typeof window === \"undefined\") return;\n const raw = localStorage.getItem(this.dismissedKey);\n const existing: string[] = raw ? (JSON.parse(raw) as string[]) : [];\n if (!existing.includes(id)) {\n existing.push(id);\n localStorage.setItem(this.dismissedKey, JSON.stringify(existing));\n }\n } catch {\n // localStorage unavailable — silent fail\n }\n }\n\n async dismissAll(now: Date): Promise<void> {\n try {\n if (typeof window !== \"undefined\") {\n localStorage.removeItem(this.dismissedKey);\n }\n } catch {\n // localStorage unavailable — silent fail\n }\n\n if (this.onDismissAllCallback) {\n await this.onDismissAllCallback(now);\n }\n }\n}\n","import type { StorageAdapter } from \"../types\";\n\n/**\n * In-memory storage adapter.\n *\n * Useful for:\n * - Testing (no side effects)\n * - Server-side rendering (no `window`/`localStorage`)\n * - Environments without persistent storage\n */\nexport class MemoryAdapter implements StorageAdapter {\n private watermark: string | null;\n private dismissed: Set<string>;\n\n constructor(options: { watermark?: string | null } = {}) {\n this.watermark = options.watermark ?? null;\n this.dismissed = new Set();\n }\n\n getWatermark(): string | null {\n return this.watermark;\n }\n\n getDismissedIds(): ReadonlySet<string> {\n return this.dismissed;\n }\n\n dismiss(id: string): void {\n this.dismissed.add(id);\n }\n\n async dismissAll(now: Date): Promise<void> {\n this.watermark = now.toISOString();\n this.dismissed.clear();\n }\n}\n"]}
@@ -0,0 +1,131 @@
1
+ /** A single feature entry in the manifest */
2
+ interface FeatureEntry {
3
+ /** Unique identifier for the feature */
4
+ id: string;
5
+ /** Human-readable label (e.g. "Decision Journal") */
6
+ label: string;
7
+ /** Optional longer description */
8
+ description?: string;
9
+ /** ISO date when this feature was released */
10
+ releasedAt: string;
11
+ /** ISO date after which the "new" badge should stop showing */
12
+ showNewUntil: string;
13
+ /** Optional key to match navigation items (e.g. "/journal", "settings") */
14
+ sidebarKey?: string;
15
+ /** Optional grouping category (e.g. "ai", "billing", "core") */
16
+ category?: string;
17
+ /** Optional URL to link to (e.g. docs page, changelog entry) */
18
+ url?: string;
19
+ /** Optional version string when this feature shipped */
20
+ version?: string;
21
+ /** Optional arbitrary metadata */
22
+ meta?: Record<string, unknown>;
23
+ }
24
+ /** The full feature manifest — an array of feature entries */
25
+ type FeatureManifest = readonly FeatureEntry[];
26
+ /**
27
+ * Storage adapter interface — implement for your persistence layer.
28
+ *
29
+ * The adapter bridges two data sources:
30
+ * - **Watermark**: a server-side timestamp ("features seen at")
31
+ * - **Dismissed IDs**: client-side per-feature dismissals
32
+ */
33
+ interface StorageAdapter {
34
+ /** Get the user's "features seen at" watermark (ISO string or null) */
35
+ getWatermark(): string | null;
36
+ /** Get the set of individually dismissed feature IDs */
37
+ getDismissedIds(): ReadonlySet<string>;
38
+ /** Dismiss a single feature by ID */
39
+ dismiss(id: string): void;
40
+ /** Dismiss all features — sets watermark to `now` and clears dismissals */
41
+ dismissAll(now: Date): Promise<void>;
42
+ }
43
+
44
+ /**
45
+ * Check if a single feature should show as "new".
46
+ *
47
+ * A feature is "new" when ALL of these are true:
48
+ * 1. Current time is before `showNewUntil`
49
+ * 2. Feature was released after the watermark (or no watermark exists)
50
+ * 3. Feature has not been individually dismissed
51
+ */
52
+ declare function isNew(feature: FeatureEntry, watermark: string | null, dismissedIds: ReadonlySet<string>, now?: Date): boolean;
53
+ /**
54
+ * Get all features that are currently "new" for this user.
55
+ */
56
+ declare function getNewFeatures(manifest: FeatureManifest, storage: StorageAdapter, now?: Date): FeatureEntry[];
57
+ /**
58
+ * Get the count of new features.
59
+ */
60
+ declare function getNewFeatureCount(manifest: FeatureManifest, storage: StorageAdapter, now?: Date): number;
61
+ /**
62
+ * Check if a specific sidebar key has a new feature.
63
+ */
64
+ declare function hasNewFeature(manifest: FeatureManifest, sidebarKey: string, storage: StorageAdapter, now?: Date): boolean;
65
+
66
+ /**
67
+ * Create a frozen feature manifest from an array of entries.
68
+ * Ensures the manifest is immutable at runtime.
69
+ */
70
+ declare function createManifest(entries: FeatureEntry[]): FeatureManifest;
71
+ /**
72
+ * Find a feature by its ID in the manifest.
73
+ * Returns `undefined` if not found.
74
+ */
75
+ declare function getFeatureById(manifest: FeatureManifest, id: string): FeatureEntry | undefined;
76
+ /**
77
+ * Get all new features in a specific category.
78
+ */
79
+ declare function getNewFeaturesByCategory(manifest: FeatureManifest, category: string, storage: StorageAdapter, now?: Date): FeatureEntry[];
80
+
81
+ interface LocalStorageAdapterOptions {
82
+ /** Key prefix for localStorage entries. Default: "featuredrop" */
83
+ prefix?: string;
84
+ /** Server-side watermark (ISO string). Typically from user profile. */
85
+ watermark?: string | null;
86
+ /** Callback when dismissAll is called. Use for server-side watermark updates. */
87
+ onDismissAll?: (now: Date) => Promise<void>;
88
+ }
89
+ /**
90
+ * localStorage-based storage adapter.
91
+ *
92
+ * Architecture:
93
+ * - **Watermark** comes from the server (passed at construction time)
94
+ * - **Per-feature dismissals** are stored in localStorage (zero server writes)
95
+ * - **dismissAll()** optionally calls a server callback, then clears localStorage
96
+ *
97
+ * Gracefully handles SSR environments where `window`/`localStorage` is unavailable.
98
+ */
99
+ declare class LocalStorageAdapter implements StorageAdapter {
100
+ private readonly prefix;
101
+ private readonly watermarkValue;
102
+ private readonly onDismissAllCallback?;
103
+ private readonly dismissedKey;
104
+ constructor(options?: LocalStorageAdapterOptions);
105
+ getWatermark(): string | null;
106
+ getDismissedIds(): ReadonlySet<string>;
107
+ dismiss(id: string): void;
108
+ dismissAll(now: Date): Promise<void>;
109
+ }
110
+
111
+ /**
112
+ * In-memory storage adapter.
113
+ *
114
+ * Useful for:
115
+ * - Testing (no side effects)
116
+ * - Server-side rendering (no `window`/`localStorage`)
117
+ * - Environments without persistent storage
118
+ */
119
+ declare class MemoryAdapter implements StorageAdapter {
120
+ private watermark;
121
+ private dismissed;
122
+ constructor(options?: {
123
+ watermark?: string | null;
124
+ });
125
+ getWatermark(): string | null;
126
+ getDismissedIds(): ReadonlySet<string>;
127
+ dismiss(id: string): void;
128
+ dismissAll(now: Date): Promise<void>;
129
+ }
130
+
131
+ export { type FeatureEntry, type FeatureManifest, LocalStorageAdapter, type LocalStorageAdapterOptions, MemoryAdapter, type StorageAdapter, createManifest, getFeatureById, getNewFeatureCount, getNewFeatures, getNewFeaturesByCategory, hasNewFeature, isNew };