featuredrop 1.0.1 → 1.1.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/README.md CHANGED
@@ -1,37 +1,76 @@
1
- # featuredrop
1
+ <p align="center">
2
+ <h1 align="center">featuredrop</h1>
3
+ <p align="center">
4
+ <strong>"New" badges that actually expire.</strong>
5
+ <br />
6
+ Lightweight feature discovery for SaaS sidebars, dashboards, and nav menus.
7
+ </p>
8
+ </p>
2
9
 
3
- **Lightweight feature discovery system. Show "New" badges that auto-expire.**
10
+ <p align="center">
11
+ <a href="https://www.npmjs.com/package/featuredrop"><img src="https://img.shields.io/npm/v/featuredrop?color=f59e0b&label=npm" alt="npm version"></a>
12
+ <a href="https://bundlephobia.com/package/featuredrop"><img src="https://img.shields.io/bundlephobia/minzip/featuredrop?color=22c55e&label=size" alt="bundle size"></a>
13
+ <a href="https://github.com/GLINCKER/featuredrop/actions/workflows/ci.yml"><img src="https://github.com/GLINCKER/featuredrop/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
14
+ <a href="https://github.com/GLINCKER/featuredrop/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/featuredrop?color=blue" alt="license"></a>
15
+ </p>
4
16
 
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)
17
+ <p align="center">
18
+ <a href="#quick-start">Quick Start</a> &bull;
19
+ <a href="#components">Components</a> &bull;
20
+ <a href="#react">React</a> &bull;
21
+ <a href="https://github.com/GLINCKER/featuredrop/blob/main/docs/API.md">API Docs</a> &bull;
22
+ <a href="https://github.com/GLINCKER/featuredrop/blob/main/docs/ARCHITECTURE.md">Architecture</a>
23
+ </p>
9
24
 
10
25
  ---
11
26
 
12
- ## Why featuredrop?
27
+ ## The Problem
13
28
 
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.
29
+ Every SaaS ships features. Users miss them. You need "New" badges on sidebar items, but:
15
30
 
16
- **featuredrop** solves this with a dead-simple API:
31
+ - **LaunchDarkly / PostHog** Feature flags, not discovery badges. Overkill.
32
+ - **Beamer / Headway** — External widget, vendor lock-in, $59-299/mo.
33
+ - **Joyride / Shepherd.js** — Product tours, not persistent badges.
34
+ - **DIY** — You build it, forget expiry, badges stay forever. Users stop noticing.
17
35
 
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
36
+ ## The Solution
23
37
 
24
- ## Quick Start
38
+ **featuredrop** is a free, self-hosted alternative to Beamer, Headway, and AnnounceKit. Zero deps, < 10 kB, headless-first.
25
39
 
26
- ### Install
40
+ ```
41
+ npm install featuredrop # 0 dependencies, < 2 kB core
42
+ ```
27
43
 
28
- ```bash
29
- npm install featuredrop
30
- # or
31
- pnpm add featuredrop
44
+ ```
45
+ ┌─────────────────────────────────────────┐
46
+ │ ☰ My SaaS App 🔔 │
47
+ ├──────────┬──────────────────────────────┤
48
+ │ │ │
49
+ │ Dashboard│ Welcome back, Sarah! │
50
+ │ │ │
51
+ │ Journal ●│ ┌─────────────────────┐ │
52
+ │ │ │ What's New (2) │ │
53
+ │ Analytics│ │ │ │
54
+ │ NEW │ │ ★ AI Journal │ │
55
+ │ │ │ Track decisions │ │
56
+ │ Billing │ │ with AI insights. │ │
57
+ │ │ │ [Try it →] │ │
58
+ │ Settings │ │ │ │
59
+ │ │ │ ★ Analytics v2 │ │
60
+ │ │ │ Real-time charts │ │
61
+ │ │ │ and CSV export. │ │
62
+ │ │ │ │ │
63
+ │ │ │ [Mark all as read] │ │
64
+ │ │ └─────────────────────┘ │
65
+ │ │ │
66
+ └──────────┴──────────────────────────────┘
67
+
68
+ ● = dot badge NEW = pill badge (2) = count badge
32
69
  ```
33
70
 
34
- ### 1. Define your feature manifest
71
+ ## Quick Start
72
+
73
+ **1. Define features** (just an array of objects):
35
74
 
36
75
  ```ts
37
76
  import { createManifest } from 'featuredrop'
@@ -40,254 +79,283 @@ export const FEATURES = createManifest([
40
79
  {
41
80
  id: 'ai-journal',
42
81
  label: 'AI Decision Journal',
43
- description: 'Track decisions with AI-powered insights',
82
+ description: 'Track decisions with AI-powered insights.',
44
83
  releasedAt: '2026-02-20T00:00:00Z',
45
84
  showNewUntil: '2026-03-20T00:00:00Z',
46
85
  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',
86
+ type: 'feature',
87
+ priority: 'critical',
88
+ cta: { label: 'Try it', url: '/journal' },
55
89
  },
56
90
  ])
57
91
  ```
58
92
 
59
- ### 2. Create a storage adapter
93
+ **2. Create a storage adapter:**
60
94
 
61
95
  ```ts
62
96
  import { LocalStorageAdapter } from 'featuredrop'
63
97
 
64
98
  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
- },
99
+ watermark: user.featuresSeenAt, // from your server
100
+ onDismissAll: (now) => api.markFeaturesSeen(now), // optional server sync
71
101
  })
72
102
  ```
73
103
 
74
- ### 3. Check what's new
104
+ **3. Check what's new:**
75
105
 
76
106
  ```ts
77
107
  import { getNewFeatures, hasNewFeature } from 'featuredrop'
78
108
 
79
- // Get all new features
80
109
  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
- }
110
+ hasNewFeature(FEATURES, '/journal', storage) // true/false
87
111
  ```
88
112
 
89
- ## React Integration
113
+ Works with **any framework**. Zero React dependency for vanilla use.
90
114
 
91
- ```bash
92
- # React is an optional peer dependency — only needed if you use featuredrop/react
93
- npm install featuredrop react
94
- ```
115
+ ## Components
116
+
117
+ Everything you'd expect from Beamer or Headway — but free, self-hosted, and headless-first.
95
118
 
96
- ### Wrap your app with the provider
119
+ ### Changelog Widget
120
+
121
+ The #1 feature people install these tools for. Trigger button with unread count badge, slide-out panel with rich changelog feed.
97
122
 
98
123
  ```tsx
99
- import { FeatureDropProvider } from 'featuredrop/react'
100
- import { LocalStorageAdapter } from 'featuredrop'
124
+ import { ChangelogWidget } from 'featuredrop/react'
101
125
 
102
- const storage = new LocalStorageAdapter({
103
- watermark: user.featuresSeenAt,
104
- onDismissAll: (now) => api.markFeaturesSeen(now),
105
- })
126
+ // Default: slide-out panel with all features
127
+ <ChangelogWidget />
106
128
 
107
- function App() {
108
- return (
109
- <FeatureDropProvider manifest={FEATURES} storage={storage}>
110
- <Sidebar />
111
- </FeatureDropProvider>
112
- )
113
- }
129
+ // Or modal / popover variant
130
+ <ChangelogWidget variant="modal" title="Release Notes" />
131
+
132
+ // Fully headless
133
+ <ChangelogWidget>
134
+ {({ isOpen, toggle, features, count, dismissAll }) => (
135
+ <YourCustomUI />
136
+ )}
137
+ </ChangelogWidget>
114
138
  ```
115
139
 
116
- ### Use hooks in your components
140
+ ### Spotlight Beacon
141
+
142
+ Pulsing beacon that attaches to any DOM element. Click to see feature tooltip.
117
143
 
118
144
  ```tsx
119
- import { useNewFeature, NewBadge } from 'featuredrop/react'
145
+ import { Spotlight } from 'featuredrop/react'
120
146
 
121
- function SidebarItem({ path, label }: { path: string; label: string }) {
122
- const { isNew, dismiss } = useNewFeature(path)
147
+ const ref = useRef<HTMLButtonElement>(null)
148
+ <button ref={ref}>Analytics</button>
149
+ <Spotlight featureId="analytics-v2" targetRef={ref} />
123
150
 
124
- return (
125
- <a href={path} onClick={() => isNew && dismiss()}>
126
- {label}
127
- {isNew && <NewBadge />}
128
- </a>
129
- )
130
- }
151
+ // Or with CSS selector
152
+ <Spotlight featureId="analytics-v2" targetSelector="#analytics-btn" />
131
153
  ```
132
154
 
133
- ### "What's New" panel
155
+ ### Announcement Banner
134
156
 
135
- ```tsx
136
- import { useFeatureDrop } from 'featuredrop/react'
157
+ Top-of-page or inline banner for major announcements. Auto-expires like badges.
137
158
 
138
- function WhatsNew() {
139
- const { newFeatures, newCount, dismissAll } = useFeatureDrop()
159
+ ```tsx
160
+ import { Banner } from 'featuredrop/react'
140
161
 
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
- }
162
+ <Banner featureId="v2-launch" variant="announcement" />
163
+ <Banner featureId="breaking-change" variant="warning" />
164
+ <Banner featureId="security-fix" variant="info" position="fixed" />
154
165
  ```
155
166
 
156
- ## How It Works
167
+ ### Toast Notifications
168
+
169
+ Brief popup notifications for new features. Auto-dismiss, stackable, configurable position.
170
+
171
+ ```tsx
172
+ import { Toast } from 'featuredrop/react'
173
+
174
+ <Toast position="bottom-right" maxVisible={3} />
157
175
 
176
+ // Specific features only
177
+ <Toast featureIds={["ai-journal"]} autoDismissMs={5000} />
158
178
  ```
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
- └─────────────────────────────────────┘
179
+
180
+ ### NewBadge
181
+
182
+ Headless badge component with variants. Zero CSS framework dependency.
183
+
184
+ ```tsx
185
+ import { NewBadge } from 'featuredrop/react'
186
+
187
+ <NewBadge /> // "New" pill
188
+ <NewBadge variant="dot" /> // Pulsing dot
189
+ <NewBadge variant="count" count={3} /> // Count badge
176
190
  ```
177
191
 
178
- **Three-check algorithm:**
192
+ ### Tab Title Notification
179
193
 
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)
194
+ Updates the browser tab title with unread count. Restores when all read.
183
195
 
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)
196
+ ```tsx
197
+ import { useTabNotification } from 'featuredrop/react'
189
198
 
190
- ## API Reference
199
+ useTabNotification() // "(3) My App"
200
+ useTabNotification({ template: "[{count} new] {title}", flash: true })
201
+ ```
191
202
 
192
- ### Core Functions
203
+ ## React
193
204
 
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 |
205
+ ```bash
206
+ npm install featuredrop react # react is an optional peer dep
207
+ ```
200
208
 
201
- ### Helpers
209
+ **Wrap your app:**
202
210
 
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 |
211
+ ```tsx
212
+ import { FeatureDropProvider } from 'featuredrop/react'
208
213
 
209
- ### Adapters
214
+ <FeatureDropProvider manifest={FEATURES} storage={storage}>
215
+ <App />
216
+ </FeatureDropProvider>
217
+ ```
210
218
 
211
- | Adapter | Description |
212
- |---------|-------------|
213
- | `LocalStorageAdapter` | Browser localStorage + server watermark |
214
- | `MemoryAdapter` | In-memory (testing, SSR) |
219
+ **Add badges to your sidebar:**
215
220
 
216
- ### React (`featuredrop/react`)
221
+ ```tsx
222
+ import { useNewFeature, NewBadge } from 'featuredrop/react'
217
223
 
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;
224
+ function SidebarItem({ path, label }: { path: string; label: string }) {
225
+ const { isNew, dismiss } = useNewFeature(path)
226
+ return (
227
+ <a href={path} onClick={() => isNew && dismiss()}>
228
+ {label}
229
+ {isNew && <NewBadge />}
230
+ </a>
231
+ )
241
232
  }
242
233
  ```
243
234
 
244
- ## Custom Storage Adapter
235
+ **Or drop in the full changelog widget:**
245
236
 
246
- Implement the `StorageAdapter` interface for your persistence layer:
237
+ ```tsx
238
+ import { ChangelogWidget } from 'featuredrop/react'
247
239
 
248
- ```ts
249
- import type { StorageAdapter } from 'featuredrop'
240
+ <ChangelogWidget variant="panel" />
241
+ ```
242
+
243
+ **Hooks & Components:**
244
+
245
+ | Export | What it does |
246
+ |--------|-------------|
247
+ | `useFeatureDrop()` | Full context: features, count, dismiss, dismissAll |
248
+ | `useNewFeature(key)` | Single nav item: `{ isNew, feature, dismiss }` |
249
+ | `useNewCount()` | Just the badge count |
250
+ | `useTabNotification()` | Updates browser tab title with count |
251
+ | `<NewBadge />` | Headless badge: `pill`, `dot`, or `count` variant |
252
+ | `<ChangelogWidget />` | Full changelog feed with trigger button |
253
+ | `<Spotlight />` | Pulsing beacon attached to DOM elements |
254
+ | `<Banner />` | Announcement banner with variants |
255
+ | `<Toast />` | Stackable toast notifications |
256
+
257
+ **Analytics integration:**
250
258
 
251
- class RedisAdapter implements StorageAdapter {
252
- getWatermark(): string | null {
253
- return this.cache.get('watermark')
254
- }
259
+ ```tsx
260
+ <FeatureDropProvider
261
+ manifest={FEATURES}
262
+ storage={storage}
263
+ analytics={{
264
+ onFeatureSeen: (f) => posthog.capture('feature_seen', { id: f.id }),
265
+ onFeatureDismissed: (f) => posthog.capture('feature_dismissed', { id: f.id }),
266
+ onFeatureClicked: (f) => posthog.capture('feature_clicked', { id: f.id }),
267
+ onWidgetOpened: () => posthog.capture('changelog_opened'),
268
+ }}
269
+ >
270
+ ```
255
271
 
256
- getDismissedIds(): ReadonlySet<string> {
257
- return new Set(this.cache.get('dismissed') ?? [])
258
- }
272
+ All components accept an optional `analytics` prop for component-level tracking too.
259
273
 
260
- dismiss(id: string): void {
261
- this.cache.append('dismissed', id)
262
- }
274
+ ## How It Works
263
275
 
264
- async dismissAll(now: Date): Promise<void> {
265
- await this.cache.set('watermark', now.toISOString())
266
- await this.cache.delete('dismissed')
267
- }
268
- }
269
276
  ```
277
+ Manifest (static) Storage (runtime)
278
+ ┌───────────────────┐ ┌──────────────────────┐
279
+ │ releasedAt: Feb 20 │ │ watermark ← server │
280
+ │ showNewUntil: Mar 20│ │ dismissed ← localStorage│
281
+ └────────┬──────────┘ └──────────┬───────────┘
282
+ │ │
283
+ └──────────┐ ┌────────────────┘
284
+ ▼ ▼
285
+ ┌───────────────┐
286
+ │ isNew() │
287
+ │ │
288
+ │ !dismissed │
289
+ │ !expired │
290
+ │ afterWatermark│
291
+ │ afterPublishAt│
292
+ └───────┬───────┘
293
+
294
+ true / false
295
+ ```
296
+
297
+ New users see everything (no watermark). Returning users only see features shipped since their last visit. Individual dismissals are instant (localStorage). "Mark all seen" syncs across devices (one server write).
298
+
299
+ **Scheduled publishing**: Set `publishAt` to hide entries until a specific date — ship code now, reveal later.
300
+
301
+ **Priority sorting**: Critical features surface first in widgets and toasts. Priority levels: `critical`, `normal`, `low`.
302
+
303
+ **Entry types**: `feature`, `improvement`, `fix`, `breaking` — each with default icon/color in built-in components.
304
+
305
+ Read the full [Architecture doc](docs/ARCHITECTURE.md) for cross-device sync flow and custom adapter patterns.
270
306
 
271
307
  ## Comparison
272
308
 
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 |
309
+ | | featuredrop | Beamer | Headway | AnnounceKit | Canny |
310
+ |---|:---:|:---:|:---:|:---:|:---:|
311
+ | **Price** | **Free** | $59-399/mo | $49-249/mo | $79-299/mo | $79+ |
312
+ | Auto-expiring badges | Yes | - | - | - | - |
313
+ | Changelog widget | Yes | Yes | Yes | Yes | Yes |
314
+ | Spotlight/beacon | Yes | - | - | - | - |
315
+ | Toast notifications | Yes | - | - | - | - |
316
+ | Announcement banner | Yes | - | - | - | - |
317
+ | Tab title notification | Yes | - | - | - | - |
318
+ | Zero dependencies | Yes | - | - | - | - |
319
+ | Framework agnostic | Yes | - | - | - | - |
320
+ | React bindings | Yes | - | - | - | - |
321
+ | Headless mode | Yes | - | - | - | - |
322
+ | Cross-device sync | Yes | Yes | Yes | Yes | Yes |
323
+ | Per-feature dismiss | Yes | - | - | - | - |
324
+ | Scheduled publishing | Yes | Yes | Yes | Yes | - |
325
+ | Priority levels | Yes | - | - | - | - |
326
+ | Analytics callbacks | Yes | Built-in | Built-in | Built-in | Built-in |
327
+ | < 10 kB minzipped | Yes | - | - | - | - |
328
+ | Self-hosted | Yes | - | - | - | - |
329
+ | Open source | Yes | - | - | - | - |
330
+
331
+ ## Framework Support
332
+
333
+ | Framework | Status | Import |
334
+ |-----------|--------|--------|
335
+ | React / Next.js | Stable | `featuredrop/react` |
336
+ | Vanilla JS | Stable | `featuredrop` |
337
+ | Vue 3 | Planned | `featuredrop/vue` |
338
+ | Svelte 5 | Planned | `featuredrop/svelte` |
339
+
340
+ ## Documentation
341
+
342
+ - [API Reference](docs/API.md) — All functions, adapters, hooks, components
343
+ - [Architecture](docs/ARCHITECTURE.md) — Three-check algorithm, cross-device sync, custom adapters
344
+ - [Next.js Example](examples/nextjs/) — Full App Router integration
345
+ - [Vanilla Example](examples/vanilla/) — Plain HTML, zero build step
346
+
347
+ ## Contributing
348
+
349
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, commit conventions, and how releases work.
284
350
 
285
351
  ## License
286
352
 
287
- MIT - [Glincker](https://glincker.com)
353
+ MIT &copy; [Glincker](https://glincker.com)
288
354
 
289
355
  ---
290
356
 
291
357
  <p align="center">
292
- <strong>A <a href="https://glincker.com">GLINCKER</a> Open Source Project</strong>
358
+ <sub>Built and battle-tested at <a href="https://askverdict.ai">AskVerdict</a>.</sub>
359
+ <br />
360
+ <strong>A <a href="https://glincker.com">GLINCKER</a> open source project.</strong>
293
361
  </p>
package/dist/index.cjs CHANGED
@@ -4,6 +4,10 @@
4
4
  function isNew(feature, watermark, dismissedIds, now = /* @__PURE__ */ new Date()) {
5
5
  if (dismissedIds.has(feature.id)) return false;
6
6
  const nowMs = now.getTime();
7
+ if (feature.publishAt) {
8
+ const publishMs = new Date(feature.publishAt).getTime();
9
+ if (nowMs < publishMs) return false;
10
+ }
7
11
  const showUntilMs = new Date(feature.showNewUntil).getTime();
8
12
  if (nowMs >= showUntilMs) return false;
9
13
  if (watermark) {
@@ -28,6 +32,15 @@ function hasNewFeature(manifest, sidebarKey, storage, now = /* @__PURE__ */ new
28
32
  (f) => f.sidebarKey === sidebarKey && isNew(f, watermark, dismissedIds, now)
29
33
  );
30
34
  }
35
+ function getNewFeaturesSorted(manifest, storage, now = /* @__PURE__ */ new Date()) {
36
+ const priorityOrder = { critical: 0, normal: 1, low: 2 };
37
+ return getNewFeatures(manifest, storage, now).sort((a, b) => {
38
+ const pa = priorityOrder[a.priority ?? "normal"];
39
+ const pb = priorityOrder[b.priority ?? "normal"];
40
+ if (pa !== pb) return pa - pb;
41
+ return new Date(b.releasedAt).getTime() - new Date(a.releasedAt).getTime();
42
+ });
43
+ }
31
44
 
32
45
  // src/helpers.ts
33
46
  function createManifest(entries) {
@@ -127,6 +140,7 @@ exports.getFeatureById = getFeatureById;
127
140
  exports.getNewFeatureCount = getNewFeatureCount;
128
141
  exports.getNewFeatures = getNewFeatures;
129
142
  exports.getNewFeaturesByCategory = getNewFeaturesByCategory;
143
+ exports.getNewFeaturesSorted = getNewFeaturesSorted;
130
144
  exports.hasNewFeature = hasNewFeature;
131
145
  exports.isNew = isNew;
132
146
  //# sourceMappingURL=index.cjs.map
@@ -1 +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"]}
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 * 4. If `publishAt` is set, current time must be after it (scheduled publishing)\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\n // Scheduled publishing — hidden until publishAt\n if (feature.publishAt) {\n const publishMs = new Date(feature.publishAt).getTime();\n if (nowMs < publishMs) return false;\n }\n\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\n/**\n * Get all features sorted by priority (critical first) then by release date (newest first).\n */\nexport function getNewFeaturesSorted(\n manifest: FeatureManifest,\n storage: StorageAdapter,\n now: Date = new Date(),\n): FeatureEntry[] {\n const priorityOrder = { critical: 0, normal: 1, low: 2 };\n return getNewFeatures(manifest, storage, now).sort((a, b) => {\n const pa = priorityOrder[a.priority ?? \"normal\"];\n const pb = priorityOrder[b.priority ?? \"normal\"];\n if (pa !== pb) return pa - pb;\n return new Date(b.releasedAt).getTime() - new Date(a.releasedAt).getTime();\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"]}