create-foldkit-app 0.37.0 → 0.38.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.
@@ -0,0 +1,651 @@
1
+ import {
2
+ Array,
3
+ Clock,
4
+ DateTime,
5
+ Duration,
6
+ Effect,
7
+ Match,
8
+ Option,
9
+ Schema,
10
+ Stream,
11
+ pipe,
12
+ } from 'effect'
13
+ import { AsyncData, Runtime, Subscription, Update } from 'foldkit'
14
+ import { Query } from 'foldkit/experimental'
15
+ import { Document, Html, HtmlBuilder } from 'foldkit/html'
16
+ import { defineMessageUnion } from 'foldkit/message'
17
+ import { modifyFields } from 'foldkit/struct'
18
+
19
+ import { Button, Tabs } from '@foldkit/ui'
20
+
21
+ import {
22
+ Post,
23
+ PostDetail,
24
+ Stats,
25
+ fetchPostDetail,
26
+ fetchPosts,
27
+ fetchStats,
28
+ } from './data'
29
+
30
+ const STATS_REFETCH_INTERVAL = Duration.seconds(5)
31
+
32
+ export const TABS_ID = 'api-cache-query-tabs'
33
+
34
+ const PostsData = Schema.Struct({
35
+ posts: Schema.Array(Post),
36
+ fetchedAt: Schema.Number,
37
+ })
38
+
39
+ const PostData = Schema.Struct({
40
+ post: PostDetail,
41
+ fetchedAt: Schema.Number,
42
+ })
43
+
44
+ const StatsData = Schema.Struct({ stats: Stats, fetchedAt: Schema.Number })
45
+
46
+ export const postsQuery = Query.define({
47
+ name: 'Posts',
48
+ data: PostsData,
49
+ error: Schema.String,
50
+ execute: Effect.gen(function* () {
51
+ const posts = yield* fetchPosts
52
+ const fetchedAt = yield* Clock.currentTimeMillis
53
+ return { posts, fetchedAt }
54
+ }),
55
+ })
56
+
57
+ export const statsQuery = Query.define({
58
+ name: 'Stats',
59
+ data: StatsData,
60
+ error: Schema.String,
61
+ execute: Effect.gen(function* () {
62
+ const stats = yield* fetchStats
63
+ const fetchedAt = yield* Clock.currentTimeMillis
64
+ return { stats, fetchedAt }
65
+ }),
66
+ })
67
+
68
+ export const postQuery = Query.define({
69
+ name: 'Post',
70
+ args: { postId: Schema.String },
71
+ data: PostData,
72
+ error: Schema.String,
73
+ execute: ({ postId }) =>
74
+ Effect.gen(function* () {
75
+ const post = yield* fetchPostDetail(postId)
76
+ const fetchedAt = yield* Clock.currentTimeMillis
77
+ return { post, fetchedAt }
78
+ }),
79
+ })
80
+
81
+ const Tab = Schema.Literals(['Posts', 'Stats'])
82
+ type Tab = typeof Tab.Type
83
+
84
+ export const AppTabs = Tabs.create<Tab>()
85
+
86
+ export const Model = Schema.Struct({
87
+ tabs: Tabs.Model,
88
+ activeTab: Tab,
89
+ posts: postsQuery.Model,
90
+ postDetails: postQuery.Model,
91
+ maybeSelectedPostId: Schema.Option(Schema.String),
92
+ stats: statsQuery.Model,
93
+ })
94
+ export type Model = typeof Model.Type
95
+
96
+ export const Message = defineMessageUnion({
97
+ GotTabsMessage: { message: Tabs.Message },
98
+ GotPostsMessage: { message: postsQuery.Message },
99
+ GotStatsMessage: { message: statsQuery.Message },
100
+ GotPostMessage: { message: postQuery.Message },
101
+ ClickedPost: { postId: Schema.String },
102
+ ClickedBackToPosts: {},
103
+ ClickedRefreshPosts: {},
104
+ ClickedRetryPosts: {},
105
+ ClickedRetryPost: { postId: Schema.String },
106
+ ClickedRefreshStats: {},
107
+ ClickedRetryStats: {},
108
+ TickedStatsRefreshInterval: {},
109
+ })
110
+ export type Message = typeof Message.Type
111
+
112
+ type UpdateReturn = Update.Return<Model, Message>
113
+
114
+ const posts = postsQuery.lift<Model, Message>({
115
+ parentField: 'posts',
116
+ toParentMessage: message => Message.GotPostsMessage({ message }),
117
+ })
118
+
119
+ const stats = statsQuery.lift<Model, Message>({
120
+ parentField: 'stats',
121
+ toParentMessage: message => Message.GotStatsMessage({ message }),
122
+ })
123
+
124
+ const postDetails = postQuery.lift<Model, Message>({
125
+ parentField: 'postDetails',
126
+ toParentMessage: message => Message.GotPostMessage({ message }),
127
+ })
128
+
129
+ const activateTab = (model: Model, tab: Tab): UpdateReturn => {
130
+ const modelWithActiveTab = modifyFields(model, { activeTab: () => tab })
131
+
132
+ return Match.value(tab).pipe(
133
+ Match.withReturnType<UpdateReturn>(),
134
+ Match.when('Posts', () => posts.loadIfMissing(modelWithActiveTab)),
135
+ Match.when('Stats', () => stats.loadIfMissing(modelWithActiveTab)),
136
+ Match.exhaustive,
137
+ )
138
+ }
139
+
140
+ const foldTabsOutMessage = Tabs.OutMessage.match<
141
+ Update.Step<Model, Message>,
142
+ Tabs.OutMessage<Tab>
143
+ >({
144
+ Selected:
145
+ ({ value }) =>
146
+ model =>
147
+ activateTab(model, value),
148
+ })
149
+
150
+ const foldTabs = Update.foldChild({
151
+ update: AppTabs.update,
152
+ read: (model: Model) => Option.some(model.tabs),
153
+ write: (model, nextTabs) => modifyFields(model, { tabs: () => nextTabs }),
154
+ toParentMessage: message => Message.GotTabsMessage({ message }),
155
+ foldOutMessage: foldTabsOutMessage,
156
+ })
157
+
158
+ export const update = (model: Model, message: Message) =>
159
+ Message.match<UpdateReturn>(message, {
160
+ GotTabsMessage: ({ message }) => foldTabs(model, message),
161
+ GotPostsMessage: ({ message }) => posts.fold(model, message),
162
+ GotStatsMessage: ({ message }) => stats.fold(model, message),
163
+ GotPostMessage: ({ message }) => postDetails.fold(model, message),
164
+ ClickedPost: ({ postId }) =>
165
+ postDetails.loadIfMissing(
166
+ modifyFields(model, {
167
+ maybeSelectedPostId: () => Option.some(postId),
168
+ }),
169
+ { postId },
170
+ ),
171
+ ClickedBackToPosts: () => ({
172
+ model: modifyFields(model, { maybeSelectedPostId: () => Option.none() }),
173
+ }),
174
+ ClickedRefreshPosts: () => posts.revalidateOrLoad(model),
175
+ ClickedRetryPosts: () => posts.revalidateOrLoad(model),
176
+ ClickedRetryPost: ({ postId }) =>
177
+ postDetails.revalidateOrLoad(model, { postId }),
178
+ ClickedRefreshStats: () => stats.revalidateOrLoad(model),
179
+ ClickedRetryStats: () => stats.revalidateOrLoad(model),
180
+ TickedStatsRefreshInterval: () => stats.revalidate(model),
181
+ })
182
+
183
+ export const init: Runtime.ApplicationInit<Model, Message> = () => {
184
+ const model = Model.make({
185
+ tabs: Tabs.init({ id: TABS_ID }),
186
+ activeTab: 'Posts',
187
+ posts: postsQuery.init(),
188
+ postDetails: postQuery.init(),
189
+ maybeSelectedPostId: Option.none(),
190
+ stats: statsQuery.init(),
191
+ })
192
+
193
+ return posts.revalidateOrLoad(model)
194
+ }
195
+
196
+ export const subscriptions = Subscription.make<Model, Message>()(entry => ({
197
+ revalidateStats: entry(
198
+ { isStatsRefreshActive: Schema.Boolean },
199
+ {
200
+ modelToDependencies: model => ({
201
+ isStatsRefreshActive:
202
+ model.activeTab === 'Stats' &&
203
+ AsyncData.hasData(statsQuery.read(model.stats)),
204
+ }),
205
+ dependenciesToStream: ({ isStatsRefreshActive }) =>
206
+ Stream.when(
207
+ Stream.tick(STATS_REFETCH_INTERVAL).pipe(
208
+ Stream.drop(1),
209
+ Stream.map(Message.TickedStatsRefreshInterval),
210
+ ),
211
+ Effect.sync(() => isStatsRefreshActive),
212
+ ),
213
+ },
214
+ ),
215
+ }))
216
+
217
+ // VIEW
218
+
219
+ const formatFetchedAt = (fetchedAt: number): string =>
220
+ DateTime.formatUtc(DateTime.makeUnsafe(fetchedAt), {
221
+ locale: 'en-US',
222
+ hour: 'numeric',
223
+ minute: '2-digit',
224
+ second: '2-digit',
225
+ timeZoneName: 'short',
226
+ })
227
+
228
+ const tabButtonClassName =
229
+ 'px-4 py-2 rounded-lg bg-white text-slate-600 font-semibold hover:bg-slate-50 transition cursor-pointer data-[selected]:bg-indigo-600 data-[selected]:text-white data-[selected]:hover:bg-indigo-600'
230
+
231
+ const toolbarButtonClassName =
232
+ 'px-3 py-1.5 bg-white text-slate-700 text-sm font-semibold rounded-md shadow hover:bg-slate-50 transition cursor-pointer data-[disabled]:opacity-50 data-[disabled]:cursor-default data-[disabled]:hover:bg-white'
233
+
234
+ export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
235
+ title: 'API Cache Query',
236
+ body: h.div(
237
+ [h.Class('min-h-screen bg-slate-100 flex justify-center p-6')],
238
+ [
239
+ h.div(
240
+ [h.Class('w-full max-w-2xl flex flex-col gap-6')],
241
+ [
242
+ headerView(h),
243
+ h.submodel({
244
+ slotId: TABS_ID,
245
+ model: model.tabs,
246
+ view: AppTabs.view,
247
+ viewInputs: {
248
+ tabs: Tab.literals,
249
+ selectedValue: model.activeTab,
250
+ ariaLabel: 'API cache sections',
251
+ toView: renderInfo => tabsView(model, renderInfo, h),
252
+ },
253
+ toParentMessage: message => Message.GotTabsMessage({ message }),
254
+ }),
255
+ ],
256
+ ),
257
+ ],
258
+ ),
259
+ })
260
+
261
+ const headerView = (h: HtmlBuilder<Message>): Html =>
262
+ h.header(
263
+ [h.Class('flex flex-col gap-1')],
264
+ [
265
+ h.h1([h.Class('text-3xl font-bold text-slate-900')], ['API Cache']),
266
+ h.p(
267
+ [h.Class('text-slate-600')],
268
+ [
269
+ 'Query.define owns each fetch and its retained data. The parent decides when each Query should load or refresh.',
270
+ ],
271
+ ),
272
+ ],
273
+ )
274
+
275
+ const tabsView = (
276
+ model: Model,
277
+ { tablist, tabs }: Tabs.RenderInfo<Tab>,
278
+ h: HtmlBuilder<Message>,
279
+ ): Html =>
280
+ h.div(
281
+ [h.Class('flex flex-col gap-6')],
282
+ [
283
+ h.div(
284
+ [...tablist, h.Class('flex gap-2')],
285
+ Array.map(tabs, tabInfo =>
286
+ h.keyed('button')(
287
+ tabInfo.value,
288
+ [...tabInfo.tab, h.Class(tabButtonClassName)],
289
+ [tabInfo.value],
290
+ ),
291
+ ),
292
+ ),
293
+ ...pipe(
294
+ tabs,
295
+ Array.filter(tabInfo => tabInfo.isActive),
296
+ Array.map(tabInfo =>
297
+ h.keyed('div')(
298
+ tabInfo.value,
299
+ [...tabInfo.panel, h.Class('flex flex-col gap-4')],
300
+ [
301
+ Match.value(tabInfo.value).pipe(
302
+ Match.when('Posts', () => postsTabView(model, h)),
303
+ Match.when('Stats', () => statsTabView(model, h)),
304
+ Match.exhaustive,
305
+ ),
306
+ ],
307
+ ),
308
+ ),
309
+ ),
310
+ ],
311
+ )
312
+
313
+ const postsTabView = (model: Model, h: HtmlBuilder<Message>): Html =>
314
+ Option.match(model.maybeSelectedPostId, {
315
+ onNone: () =>
316
+ h.section([h.Class('flex flex-col gap-4')], [postsListView(model, h)]),
317
+ onSome: postId =>
318
+ h.keyed('section')(
319
+ postId,
320
+ [h.Class('flex flex-col gap-4')],
321
+ [postDetailView(model, postId, h)],
322
+ ),
323
+ })
324
+
325
+ const postsListView = (model: Model, h: HtmlBuilder<Message>): Html => {
326
+ const postsAsyncData = postsQuery.read(model.posts)
327
+ const isPending = AsyncData.isPending(postsAsyncData)
328
+
329
+ return h.div(
330
+ [h.Class('flex flex-col gap-4')],
331
+ [
332
+ h.div(
333
+ [h.Class('flex items-center justify-between')],
334
+ [
335
+ h.h2([h.Class('text-xl font-bold text-slate-800')], ['Posts']),
336
+ Button.view(
337
+ {
338
+ onClick: Message.ClickedRefreshPosts(),
339
+ isDisabled: isPending,
340
+ toView: attributes =>
341
+ h.button(
342
+ [...attributes.button, h.Class(toolbarButtonClassName)],
343
+ [
344
+ AsyncData.isRefreshing(postsAsyncData)
345
+ ? 'Refreshing…'
346
+ : 'Refresh',
347
+ ],
348
+ ),
349
+ },
350
+ h,
351
+ ),
352
+ ],
353
+ ),
354
+ h.p(
355
+ [h.Class('text-sm text-slate-500')],
356
+ [
357
+ 'Open a post, then go back. The detail stays in the Query. Opening it again reads that entry and does not fetch.',
358
+ ],
359
+ ),
360
+ AsyncData.matchData(postsAsyncData, {
361
+ onEmpty: () => loadingPanel('Loading posts…', h),
362
+ onFailure: error => errorPanel(error, Message.ClickedRetryPosts(), h),
363
+ onData: ({ posts }) =>
364
+ h.div(
365
+ [h.Class('flex flex-col gap-4')],
366
+ [
367
+ ...Option.match(AsyncData.getError(postsAsyncData), {
368
+ onNone: () => [],
369
+ onSome: error => [
370
+ errorPanel(error, Message.ClickedRetryPosts(), h),
371
+ ],
372
+ }),
373
+ h.ul(
374
+ [h.Class('flex flex-col gap-2')],
375
+ postListItems(posts, model.postDetails, h),
376
+ ),
377
+ ],
378
+ ),
379
+ }),
380
+ ],
381
+ )
382
+ }
383
+
384
+ const isPostCached = (
385
+ postDetails: Model['postDetails'],
386
+ postId: string,
387
+ ): boolean => AsyncData.hasData(postQuery.read(postDetails, { postId }))
388
+
389
+ const postListItems = (
390
+ posts: ReadonlyArray<Post>,
391
+ postDetails: Model['postDetails'],
392
+ h: HtmlBuilder<Message>,
393
+ ): ReadonlyArray<Html> =>
394
+ Array.map(posts, post =>
395
+ h.keyed('li')(
396
+ post.id,
397
+ [],
398
+ [
399
+ Button.view(
400
+ {
401
+ onClick: Message.ClickedPost({ postId: post.id }),
402
+ toView: attributes =>
403
+ h.button(
404
+ [
405
+ ...attributes.button,
406
+ h.Class(
407
+ 'w-full text-left bg-white rounded-lg shadow px-4 py-3 hover:bg-slate-50 transition cursor-pointer flex items-center justify-between gap-4',
408
+ ),
409
+ ],
410
+ [
411
+ h.div(
412
+ [],
413
+ [
414
+ h.div(
415
+ [h.Class('font-semibold text-slate-800')],
416
+ [post.title],
417
+ ),
418
+ h.div(
419
+ [h.Class('text-sm text-slate-500')],
420
+ [post.excerpt],
421
+ ),
422
+ ],
423
+ ),
424
+ isPostCached(postDetails, post.id)
425
+ ? h.span(
426
+ [
427
+ h.Class(
428
+ 'shrink-0 text-xs font-semibold text-emerald-700 bg-emerald-100 rounded-full px-2 py-1',
429
+ ),
430
+ ],
431
+ ['Cached'],
432
+ )
433
+ : h.empty,
434
+ ],
435
+ ),
436
+ },
437
+ h,
438
+ ),
439
+ ],
440
+ ),
441
+ )
442
+
443
+ const postDetailView = (
444
+ model: Model,
445
+ postId: string,
446
+ h: HtmlBuilder<Message>,
447
+ ): Html => {
448
+ const postAsyncData = postQuery.read(model.postDetails, { postId })
449
+
450
+ return h.div(
451
+ [h.Class('flex flex-col gap-4')],
452
+ [
453
+ Button.view(
454
+ {
455
+ onClick: Message.ClickedBackToPosts(),
456
+ toView: attributes =>
457
+ h.button(
458
+ [
459
+ ...attributes.button,
460
+ h.Class(
461
+ 'self-start text-sm font-semibold text-indigo-600 hover:underline cursor-pointer',
462
+ ),
463
+ ],
464
+ ['Back to posts'],
465
+ ),
466
+ },
467
+ h,
468
+ ),
469
+ AsyncData.matchData(postAsyncData, {
470
+ onEmpty: () => loadingPanel('Loading post…', h),
471
+ onFailure: error =>
472
+ errorPanel(error, Message.ClickedRetryPost({ postId }), h),
473
+ onData: ({ post, fetchedAt }) =>
474
+ h.div(
475
+ [h.Class('flex flex-col gap-4')],
476
+ [
477
+ ...Option.match(AsyncData.getError(postAsyncData), {
478
+ onNone: () => [],
479
+ onSome: error => [
480
+ errorPanel(error, Message.ClickedRetryPost({ postId }), h),
481
+ ],
482
+ }),
483
+ postCard(post, fetchedAt, h),
484
+ ],
485
+ ),
486
+ }),
487
+ ],
488
+ )
489
+ }
490
+
491
+ const postCard = (
492
+ post: PostDetail,
493
+ fetchedAt: number,
494
+ h: HtmlBuilder<Message>,
495
+ ): Html =>
496
+ h.article(
497
+ [h.Class('bg-white rounded-xl shadow p-6 flex flex-col gap-3')],
498
+ [
499
+ h.h2([h.Class('text-2xl font-bold text-slate-900')], [post.title]),
500
+ h.p([h.Class('text-sm text-slate-500')], [`By ${post.author}`]),
501
+ h.p([h.Class('text-slate-700 leading-relaxed')], [post.body]),
502
+ h.p(
503
+ [h.Class('text-xs text-slate-400')],
504
+ [
505
+ `Fetched at ${formatFetchedAt(fetchedAt)}. Leaving this screen keeps the entry.`,
506
+ ],
507
+ ),
508
+ ],
509
+ )
510
+
511
+ const statsTabView = (model: Model, h: HtmlBuilder<Message>): Html => {
512
+ const statsAsyncData = statsQuery.read(model.stats)
513
+ const isPending = AsyncData.isPending(statsAsyncData)
514
+
515
+ return h.div(
516
+ [h.Class('flex flex-col gap-4')],
517
+ [
518
+ h.div(
519
+ [h.Class('flex items-center justify-between')],
520
+ [
521
+ h.h2([h.Class('text-xl font-bold text-slate-800')], ['Stats']),
522
+ Button.view(
523
+ {
524
+ onClick: Message.ClickedRefreshStats(),
525
+ isDisabled: isPending,
526
+ toView: attributes =>
527
+ h.button(
528
+ [...attributes.button, h.Class(toolbarButtonClassName)],
529
+ [isPending ? 'Refreshing…' : 'Refresh'],
530
+ ),
531
+ },
532
+ h,
533
+ ),
534
+ ],
535
+ ),
536
+ h.p(
537
+ [h.Class('text-sm text-slate-500')],
538
+ [
539
+ 'Stats refetch every 5 seconds while this tab is open. The old numbers stay on screen while the new ones load.',
540
+ ],
541
+ ),
542
+ AsyncData.matchData(statsAsyncData, {
543
+ onEmpty: () => loadingPanel('Loading stats…', h),
544
+ onFailure: error => errorPanel(error, Message.ClickedRetryStats(), h),
545
+ onData: ({ stats, fetchedAt }) =>
546
+ h.div(
547
+ [h.Class('flex flex-col gap-4')],
548
+ [
549
+ ...Option.match(AsyncData.getError(statsAsyncData), {
550
+ onNone: () => [],
551
+ onSome: error => [
552
+ errorPanel(error, Message.ClickedRetryStats(), h),
553
+ ],
554
+ }),
555
+ statsCards(
556
+ stats,
557
+ fetchedAt,
558
+ AsyncData.isRefreshing(statsAsyncData),
559
+ h,
560
+ ),
561
+ ],
562
+ ),
563
+ }),
564
+ ],
565
+ )
566
+ }
567
+
568
+ const statsCards = (
569
+ stats: Stats,
570
+ fetchedAt: number,
571
+ isRefreshing: boolean,
572
+ h: HtmlBuilder<Message>,
573
+ ): Html =>
574
+ h.div(
575
+ [h.Class('flex flex-col gap-3')],
576
+ [
577
+ h.div(
578
+ [h.Class('grid grid-cols-3 gap-4')],
579
+ [
580
+ statCard('Active users', `${stats.activeUsers}`, h),
581
+ statCard('Requests per second', `${stats.requestsPerSecond}`, h),
582
+ statCard('Cache hit rate', `${stats.cacheHitRatePercent}%`, h),
583
+ ],
584
+ ),
585
+ h.div(
586
+ [h.Class('flex items-center gap-3 text-sm text-slate-500')],
587
+ [
588
+ h.span([], [`Updated at ${formatFetchedAt(fetchedAt)}`]),
589
+ ...(isRefreshing
590
+ ? [
591
+ h.span(
592
+ [h.Class('text-indigo-600 font-semibold')],
593
+ ['Refreshing'],
594
+ ),
595
+ ]
596
+ : []),
597
+ ],
598
+ ),
599
+ ],
600
+ )
601
+
602
+ const statCard = (
603
+ label: string,
604
+ value: string,
605
+ h: HtmlBuilder<Message>,
606
+ ): Html =>
607
+ h.div(
608
+ [h.Class('bg-white rounded-xl shadow p-4 flex flex-col gap-1')],
609
+ [
610
+ h.div([h.Class('text-sm text-slate-500')], [label]),
611
+ h.div([h.Class('text-2xl font-bold text-slate-900')], [value]),
612
+ ],
613
+ )
614
+
615
+ const loadingPanel = (text: string, h: HtmlBuilder<Message>): Html =>
616
+ h.div(
617
+ [h.Class('bg-white rounded-lg shadow p-6 text-center text-slate-500')],
618
+ [text],
619
+ )
620
+
621
+ const errorPanel = (
622
+ error: string,
623
+ retryMessage: Message,
624
+ h: HtmlBuilder<Message>,
625
+ ): Html =>
626
+ h.div(
627
+ [
628
+ h.Class(
629
+ 'bg-red-50 border border-red-200 text-red-700 rounded-lg p-4 flex items-center justify-between gap-4',
630
+ ),
631
+ ],
632
+ [
633
+ h.p([], [error]),
634
+ Button.view(
635
+ {
636
+ onClick: retryMessage,
637
+ toView: attributes =>
638
+ h.button(
639
+ [
640
+ ...attributes.button,
641
+ h.Class(
642
+ 'shrink-0 px-3 py-1.5 bg-red-600 text-white text-sm font-semibold rounded-md hover:bg-red-700 transition cursor-pointer',
643
+ ),
644
+ ],
645
+ ['Retry'],
646
+ ),
647
+ },
648
+ h,
649
+ ),
650
+ ],
651
+ )