@tuturuuu/ui 0.23.0 → 0.25.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/biome.json +1 -1
  3. package/package.json +78 -78
  4. package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.test.tsx +121 -0
  5. package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.tsx +37 -4
  6. package/src/components/ui/chat/ai-message-markdown.tsx +6 -1
  7. package/src/components/ui/chat/chat-external-context-sidebar.tsx +113 -0
  8. package/src/components/ui/chat/chat-sidebar-conversation-groups.tsx +5 -1
  9. package/src/components/ui/chat/chat-sidebar-groups.test.ts +33 -0
  10. package/src/components/ui/chat/chat-sidebar-items.tsx +89 -5
  11. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
  12. package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
  13. package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
  14. package/src/components/ui/chat/chat-sidebar.tsx +3 -0
  15. package/src/components/ui/chat/chat-utils.test.ts +173 -0
  16. package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
  17. package/src/components/ui/chat/chat-workspace.tsx +105 -28
  18. package/src/components/ui/chat/hooks-conversations.ts +9 -1
  19. package/src/components/ui/chat/hooks-messages.ts +1 -0
  20. package/src/components/ui/chat/hooks-realtime.ts +93 -4
  21. package/src/components/ui/chat/message-bubble.tsx +49 -4
  22. package/src/components/ui/chat/message-composer.tsx +29 -23
  23. package/src/components/ui/chat/message-list.tsx +5 -3
  24. package/src/components/ui/chat/message-markdown.tsx +4 -1
  25. package/src/components/ui/chat/query-keys.ts +7 -1
  26. package/src/components/ui/chat/selection.ts +1 -1
  27. package/src/components/ui/chat/utils.ts +126 -3
  28. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
  29. package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
  30. package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
  31. package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
  32. package/src/components/ui/custom/workspace-select.tsx +37 -38
  33. package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
  34. package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
  35. package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
  36. package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
  37. package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
  38. package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
  39. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
  40. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
  41. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
  42. package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
  43. package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
  46. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
  47. package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
  48. package/src/components/ui/legacy/meet/form.tsx +25 -1
  49. package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
  50. package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
  51. package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
  52. package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
  53. package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
  54. package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
  55. package/src/components/ui/legacy/meet/page.tsx +1 -0
  56. package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
  57. package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
  58. package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
  59. package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
  60. package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
  61. package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
  62. package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
  63. package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
  64. package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
  65. package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
  66. package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
  67. package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
  68. package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
  69. package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
  70. package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
  71. package/src/components/ui/nuqs-adapter.tsx +1 -0
  72. package/src/hooks/time-blocking-provider.tsx +127 -211
@@ -0,0 +1,463 @@
1
+ 'use client';
2
+
3
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
4
+ import {
5
+ CalendarCheck,
6
+ Check,
7
+ ExternalLink,
8
+ LockKeyhole,
9
+ RotateCcw,
10
+ } from '@tuturuuu/icons';
11
+ import {
12
+ finalizeMeetPlan,
13
+ type MeetPlanSnapshot,
14
+ reopenMeetPlan,
15
+ } from '@tuturuuu/internal-api';
16
+ import type {
17
+ MeetFinalizedTimeframe,
18
+ MeetTogetherPlan,
19
+ PlanUser,
20
+ } from '@tuturuuu/types/primitives/MeetTogetherPlan';
21
+ import type { Timeblock } from '@tuturuuu/types/primitives/Timeblock';
22
+ import { Badge } from '@tuturuuu/ui/badge';
23
+ import { Button } from '@tuturuuu/ui/button';
24
+ import {
25
+ Dialog,
26
+ DialogContent,
27
+ DialogDescription,
28
+ DialogFooter,
29
+ DialogHeader,
30
+ DialogTitle,
31
+ DialogTrigger,
32
+ } from '@tuturuuu/ui/dialog';
33
+ import { useTimeBlocking } from '@tuturuuu/ui/hooks/time-blocking-provider';
34
+ import { useToast } from '@tuturuuu/ui/hooks/use-toast';
35
+ import {
36
+ Select,
37
+ SelectContent,
38
+ SelectItem,
39
+ SelectTrigger,
40
+ SelectValue,
41
+ } from '@tuturuuu/ui/select';
42
+ import { Slider } from '@tuturuuu/ui/slider';
43
+ import { Switch } from '@tuturuuu/ui/switch';
44
+ import { cn } from '@tuturuuu/utils/format';
45
+ import dayjs from 'dayjs';
46
+ import { useTranslations } from 'next-intl';
47
+ import { useMemo, useState } from 'react';
48
+ import { createMeetIcs } from '../lib/meet-ics';
49
+ import {
50
+ formatMinuteOfDay,
51
+ type MeetRankedTimeframe,
52
+ rankMeetTimeframes,
53
+ } from '../lib/meet-insights';
54
+ import { candidateToAbsoluteRange } from '../lib/meet-timezone';
55
+ import { meetPlanQueryKey } from './meet-query';
56
+
57
+ function candidateKey(candidate: MeetRankedTimeframe) {
58
+ return `${candidate.date}:${candidate.startMinute}`;
59
+ }
60
+
61
+ export function MeetSuggestionsPanel({
62
+ plan,
63
+ users,
64
+ timeblocks,
65
+ finalizedTimeframes,
66
+ isCreator,
67
+ }: {
68
+ plan: MeetTogetherPlan;
69
+ users: PlanUser[];
70
+ timeblocks: Timeblock[];
71
+ finalizedTimeframes: MeetFinalizedTimeframe[];
72
+ isCreator: boolean;
73
+ }) {
74
+ const t = useTranslations('meet-together-plan-details');
75
+ const { toast } = useToast();
76
+ const queryClient = useQueryClient();
77
+ const { filteredUserIds } = useTimeBlocking();
78
+ const [minimumOverlap, setMinimumOverlap] = useState(0);
79
+ const [includeWeekends, setIncludeWeekends] = useState(true);
80
+ const [includeTentative, setIncludeTentative] = useState(true);
81
+ const [selected, setSelected] = useState<string[]>([]);
82
+ const [calendarDestination, setCalendarDestination] = useState('personal');
83
+ const [calendarRange, setCalendarRange] = useState('all');
84
+ const rankedUsers = useMemo(
85
+ () =>
86
+ filteredUserIds.length
87
+ ? users.filter((user) => user.id && filteredUserIds.includes(user.id))
88
+ : users,
89
+ [filteredUserIds, users]
90
+ );
91
+ const ranked = useMemo(
92
+ () =>
93
+ rankMeetTimeframes({
94
+ plan,
95
+ users: rankedUsers,
96
+ timeblocks,
97
+ includeTentative,
98
+ }),
99
+ [includeTentative, plan, rankedUsers, timeblocks]
100
+ );
101
+ const visible = ranked.filter((candidate) => {
102
+ const isWeekend = [0, 6].includes(dayjs(candidate.date).day());
103
+ return (
104
+ candidate.confirmedPercent >= minimumOverlap &&
105
+ (includeWeekends || !isWeekend) &&
106
+ candidateToAbsoluteRange(candidate, plan) !== null
107
+ );
108
+ });
109
+ const queryKey = plan.id ? meetPlanQueryKey(plan.id) : ['meet-plan'];
110
+
111
+ const mutation = useMutation({
112
+ mutationFn: async (action: 'finalize' | 'reopen') => {
113
+ if (!plan.id) throw new Error('Plan id is required');
114
+ if (action === 'reopen') return reopenMeetPlan(plan.id);
115
+ const timeframes = selected.flatMap((key) => {
116
+ const candidate = ranked.find((item) => candidateKey(item) === key);
117
+ const timeframe = candidate
118
+ ? candidateToAbsoluteRange(candidate, plan)
119
+ : null;
120
+ return timeframe ? [timeframe] : [];
121
+ });
122
+ return finalizeMeetPlan(plan.id, { timeframes });
123
+ },
124
+ onMutate: async (action) => {
125
+ await queryClient.cancelQueries({ queryKey });
126
+ const previous = queryClient.getQueryData<MeetPlanSnapshot>(queryKey);
127
+ if (previous) {
128
+ queryClient.setQueryData<MeetPlanSnapshot>(queryKey, {
129
+ ...previous,
130
+ plan: { ...previous.plan, is_confirmed: action === 'finalize' },
131
+ });
132
+ }
133
+ return { previous };
134
+ },
135
+ onError: (_error, _action, context) => {
136
+ if (context?.previous)
137
+ queryClient.setQueryData(queryKey, context.previous);
138
+ toast({
139
+ title: t('finalization_failed'),
140
+ description: t('finalization_failed_description'),
141
+ variant: 'destructive',
142
+ });
143
+ },
144
+ onSuccess: (snapshot) => {
145
+ queryClient.setQueryData(queryKey, snapshot);
146
+ setSelected([]);
147
+ },
148
+ });
149
+
150
+ const downloadIcs = () => {
151
+ if (!plan.id) return;
152
+ const content = createMeetIcs({
153
+ planId: plan.id,
154
+ title: plan.name || t('tuturuuu_meet'),
155
+ description: plan.description,
156
+ timeframes: finalizedTimeframes,
157
+ });
158
+ const href = URL.createObjectURL(
159
+ new Blob([content], { type: 'text/calendar' })
160
+ );
161
+ const link = document.createElement('a');
162
+ link.href = href;
163
+ link.download = `${plan.name || 'tuturuuu-meet'}.ics`;
164
+ link.click();
165
+ URL.revokeObjectURL(href);
166
+ };
167
+
168
+ const openCalendarHandoff = () => {
169
+ if (!plan.id) return;
170
+ const timeframes =
171
+ calendarRange === 'all'
172
+ ? finalizedTimeframes
173
+ : finalizedTimeframes.filter(
174
+ (timeframe) => timeframe.id === calendarRange
175
+ );
176
+ const query = new URLSearchParams({
177
+ meetPlanId: plan.id,
178
+ ranges: JSON.stringify(
179
+ timeframes.map(({ start_at, end_at }) => ({
180
+ startAt: start_at,
181
+ endAt: end_at,
182
+ }))
183
+ ),
184
+ title: plan.name || t('tuturuuu_meet'),
185
+ });
186
+ window.open(
187
+ `https://calendar.tuturuuu.com/${calendarDestination}?${query}`,
188
+ '_blank',
189
+ 'noopener,noreferrer'
190
+ );
191
+ };
192
+
193
+ if (plan.is_confirmed) {
194
+ return (
195
+ <section className="space-y-4">
196
+ <div className="rounded-2xl border bg-primary/[0.04] p-5">
197
+ <div className="flex flex-wrap items-start justify-between gap-4">
198
+ <div>
199
+ <Badge className="mb-3 gap-1">
200
+ <LockKeyhole className="h-3 w-3" /> {t('finalized')}
201
+ </Badge>
202
+ <h3 className="font-semibold text-lg">
203
+ {t('locked_meeting_alternatives')}
204
+ </h3>
205
+ <p className="text-muted-foreground text-sm">
206
+ {t('locked_meeting_alternatives_description')}
207
+ </p>
208
+ </div>
209
+ <div className="flex flex-wrap gap-2">
210
+ <Button variant="outline" onClick={downloadIcs}>
211
+ <CalendarCheck className="mr-2 h-4 w-4" />{' '}
212
+ {t('download_calendar_file')}
213
+ </Button>
214
+ <Dialog>
215
+ <DialogTrigger asChild>
216
+ <Button variant="outline">
217
+ <ExternalLink className="mr-2 h-4 w-4" />{' '}
218
+ {t('open_in_calendar')}
219
+ </Button>
220
+ </DialogTrigger>
221
+ <DialogContent>
222
+ <DialogHeader>
223
+ <DialogTitle>{t('calendar_handoff_title')}</DialogTitle>
224
+ <DialogDescription>
225
+ {t('calendar_handoff_description')}
226
+ </DialogDescription>
227
+ </DialogHeader>
228
+ <div className="grid gap-4 sm:grid-cols-2">
229
+ <label className="grid gap-2 text-sm">
230
+ {t('calendar_destination')}
231
+ <Select
232
+ value={calendarDestination}
233
+ onValueChange={setCalendarDestination}
234
+ >
235
+ <SelectTrigger>
236
+ <SelectValue />
237
+ </SelectTrigger>
238
+ <SelectContent>
239
+ <SelectItem value="personal">
240
+ {t('personal_calendar')}
241
+ </SelectItem>
242
+ <SelectItem value="internal">
243
+ {t('workspace_calendar')}
244
+ </SelectItem>
245
+ </SelectContent>
246
+ </Select>
247
+ </label>
248
+ <label className="grid gap-2 text-sm">
249
+ {t('calendar_ranges')}
250
+ <Select
251
+ value={calendarRange}
252
+ onValueChange={setCalendarRange}
253
+ >
254
+ <SelectTrigger>
255
+ <SelectValue />
256
+ </SelectTrigger>
257
+ <SelectContent>
258
+ <SelectItem value="all">
259
+ {t('all_finalized_ranges')}
260
+ </SelectItem>
261
+ {finalizedTimeframes.map((timeframe, index) => (
262
+ <SelectItem key={timeframe.id} value={timeframe.id}>
263
+ {t('alternative_number', { number: index + 1 })}
264
+ </SelectItem>
265
+ ))}
266
+ </SelectContent>
267
+ </Select>
268
+ </label>
269
+ </div>
270
+ <DialogFooter>
271
+ <Button onClick={openCalendarHandoff}>
272
+ {t('continue_to_calendar')}
273
+ </Button>
274
+ </DialogFooter>
275
+ </DialogContent>
276
+ </Dialog>
277
+ </div>
278
+ </div>
279
+ <div className="mt-5 grid gap-2 md:grid-cols-2">
280
+ {finalizedTimeframes.map((timeframe, index) => (
281
+ <div
282
+ key={timeframe.id}
283
+ className="rounded-xl border bg-background p-4"
284
+ >
285
+ <span className="text-muted-foreground text-xs">
286
+ {t('alternative_number', { number: index + 1 })}
287
+ </span>
288
+ <p className="mt-1 font-medium tabular-nums">
289
+ {dayjs(timeframe.start_at).format('ddd, MMM D · HH:mm')}–
290
+ {dayjs(timeframe.end_at).format('HH:mm')}
291
+ </p>
292
+ </div>
293
+ ))}
294
+ </div>
295
+ </div>
296
+ {isCreator && (
297
+ <Dialog>
298
+ <DialogTrigger asChild>
299
+ <Button variant="ghost">
300
+ <RotateCcw className="mr-2 h-4 w-4" /> {t('reopen_plan')}
301
+ </Button>
302
+ </DialogTrigger>
303
+ <DialogContent>
304
+ <DialogHeader>
305
+ <DialogTitle>{t('reopen_availability_title')}</DialogTitle>
306
+ <DialogDescription>
307
+ {t('reopen_availability_description')}
308
+ </DialogDescription>
309
+ </DialogHeader>
310
+ <DialogFooter>
311
+ <Button
312
+ onClick={() => mutation.mutate('reopen')}
313
+ disabled={mutation.isPending}
314
+ >
315
+ {t('reopen_plan')}
316
+ </Button>
317
+ </DialogFooter>
318
+ </DialogContent>
319
+ </Dialog>
320
+ )}
321
+ </section>
322
+ );
323
+ }
324
+
325
+ return (
326
+ <section className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_17rem]">
327
+ <div className="space-y-3">
328
+ {visible.slice(0, 12).map((candidate, index) => {
329
+ const key = candidateKey(candidate);
330
+ const active = selected.includes(key);
331
+ return (
332
+ <button
333
+ key={key}
334
+ type="button"
335
+ onClick={() => {
336
+ if (selected.includes(key)) {
337
+ setSelected((current) =>
338
+ current.filter((item) => item !== key)
339
+ );
340
+ return;
341
+ }
342
+ const overlaps = selected.some((selectedKey) => {
343
+ const selectedCandidate = ranked.find(
344
+ (item) => candidateKey(item) === selectedKey
345
+ );
346
+ return Boolean(
347
+ selectedCandidate &&
348
+ selectedCandidate.date === candidate.date &&
349
+ selectedCandidate.startMinute < candidate.endMinute &&
350
+ candidate.startMinute < selectedCandidate.endMinute
351
+ );
352
+ });
353
+ if (overlaps) {
354
+ toast({
355
+ title: t('overlapping_timeframes'),
356
+ description: t('overlapping_timeframes_description'),
357
+ variant: 'destructive',
358
+ });
359
+ return;
360
+ }
361
+ setSelected((current) => [...current, key]);
362
+ }}
363
+ className={cn(
364
+ 'flex w-full items-center gap-4 rounded-2xl border p-4 text-left transition duration-200 active:scale-[0.995]',
365
+ active
366
+ ? 'border-primary bg-primary/[0.06]'
367
+ : 'bg-background hover:border-primary/40'
368
+ )}
369
+ >
370
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted font-semibold text-xs tabular-nums">
371
+ {active ? <Check className="h-4 w-4" /> : `#${index + 1}`}
372
+ </span>
373
+ <span className="min-w-0 flex-1">
374
+ <span className="block font-semibold tabular-nums">
375
+ {dayjs(candidate.date).format('ddd, MMM D')} ·{' '}
376
+ {formatMinuteOfDay(candidate.startMinute)}–
377
+ {formatMinuteOfDay(candidate.endMinute)}
378
+ </span>
379
+ <span className="mt-1 block text-muted-foreground text-xs">
380
+ {t('recommendation_coverage', {
381
+ confirmed: candidate.confirmedUserIds.length,
382
+ tentative: candidate.tentativeUserIds.length,
383
+ unavailable: candidate.unavailableUserIds.length,
384
+ })}
385
+ </span>
386
+ </span>
387
+ <Badge
388
+ variant={
389
+ candidate.confirmedPercent === 100 ? 'default' : 'secondary'
390
+ }
391
+ className="tabular-nums"
392
+ >
393
+ {Math.round(candidate.confirmedPercent)}%
394
+ </Badge>
395
+ </button>
396
+ );
397
+ })}
398
+ </div>
399
+
400
+ {visible.length === 0 && (
401
+ <div className="rounded-2xl border border-dashed p-8 text-center text-muted-foreground text-sm">
402
+ {t('no_matching_suggestions')}
403
+ </div>
404
+ )}
405
+
406
+ <aside className="h-fit space-y-5 rounded-2xl border bg-muted/20 p-4 lg:sticky lg:top-4">
407
+ <div>
408
+ <div className="mb-2 flex items-center justify-between text-sm">
409
+ <span>{t('minimum_confirmed')}</span>
410
+ <span className="tabular-nums">{minimumOverlap}%</span>
411
+ </div>
412
+ <Slider
413
+ value={[minimumOverlap]}
414
+ min={0}
415
+ max={100}
416
+ step={10}
417
+ onValueChange={([value]) => setMinimumOverlap(value ?? 0)}
418
+ />
419
+ </div>
420
+ <label className="flex items-center justify-between gap-4 text-sm">
421
+ {t('include_weekends')}
422
+ <Switch
423
+ checked={includeWeekends}
424
+ onCheckedChange={setIncludeWeekends}
425
+ />
426
+ </label>
427
+ <label className="flex items-center justify-between gap-4 text-sm">
428
+ {t('include_tentative')}
429
+ <Switch
430
+ checked={includeTentative}
431
+ onCheckedChange={setIncludeTentative}
432
+ />
433
+ </label>
434
+ {isCreator && (
435
+ <Dialog>
436
+ <DialogTrigger asChild>
437
+ <Button className="w-full" disabled={selected.length === 0}>
438
+ <LockKeyhole className="mr-2 h-4 w-4" />{' '}
439
+ {t('finalize_timeframes', { count: selected.length })}
440
+ </Button>
441
+ </DialogTrigger>
442
+ <DialogContent>
443
+ <DialogHeader>
444
+ <DialogTitle>{t('finalize_selected_title')}</DialogTitle>
445
+ <DialogDescription>
446
+ {t('finalize_selected_description')}
447
+ </DialogDescription>
448
+ </DialogHeader>
449
+ <DialogFooter>
450
+ <Button
451
+ onClick={() => mutation.mutate('finalize')}
452
+ disabled={mutation.isPending}
453
+ >
454
+ {t('finalize_plan')}
455
+ </Button>
456
+ </DialogFooter>
457
+ </DialogContent>
458
+ </Dialog>
459
+ )}
460
+ </aside>
461
+ </section>
462
+ );
463
+ }
@@ -5,13 +5,13 @@ import type {
5
5
  PollOption,
6
6
  UserVoteWithUserInfo,
7
7
  } from '@tuturuuu/types/primitives/Poll';
8
- import { TimeBlockingProvider } from '@tuturuuu/ui/hooks/time-blocking-provider';
8
+ import type { User as PlatformUser } from '@tuturuuu/types/primitives/User';
9
9
  import { getPlan } from '@tuturuuu/utils/plan-helpers';
10
10
  import { getCurrentUser } from '@tuturuuu/utils/user-helper';
11
11
  import 'dayjs/locale/vi';
12
12
  import { notFound } from 'next/navigation';
13
13
  import { Suspense } from 'react';
14
- import PlanDetailsClient from './plan-details-client';
14
+ import { MeetPlanLiveRoot } from './meet-plan-live-root';
15
15
 
16
16
  interface Props {
17
17
  params: Promise<{
@@ -28,7 +28,7 @@ export default async function MeetTogetherPlanDetailsPage({
28
28
  }: Props) {
29
29
  const { planId } = await params;
30
30
 
31
- const platformUser = await getCurrentUser();
31
+ const platformUser = (await getCurrentUser()) as PlatformUser | null;
32
32
  const plan = await getPlan(planId, { actorUserId });
33
33
 
34
34
  if (!plan) return notFound();
@@ -39,32 +39,72 @@ export default async function MeetTogetherPlanDetailsPage({
39
39
  const users: PlanUser[] = await getUsers(canonicalPlanId);
40
40
  const polls = await getPollsForPlan(canonicalPlanId);
41
41
  const timeblocks = await getTimeBlocks(canonicalPlanId);
42
+ const finalizedTimeframes = await getFinalizedTimeframes(canonicalPlanId);
43
+
44
+ const resolvedPlatformUser =
45
+ platformUser ??
46
+ (actorUserId
47
+ ? ({ id: actorUserId, is_guest: false } as PlatformUser)
48
+ : null);
42
49
 
43
50
  return (
44
51
  <div className="flex min-h-screen w-full flex-col items-center">
45
52
  <Suspense fallback={null}>
46
53
  {plan.id && (
47
- <TimeBlockingProvider
48
- key={`${platformUser?.id || 'guest'}-${plan.id}`}
49
- platformUser={platformUser}
50
- plan={plan}
51
- users={users}
52
- timeblocks={timeblocks}
53
- >
54
- <PlanDetailsClient
55
- plan={plan}
56
- polls={polls}
57
- users={users}
58
- timeblocks={timeblocks}
59
- baseUrl={baseUrl}
60
- />
61
- </TimeBlockingProvider>
54
+ <MeetPlanLiveRoot
55
+ platformUser={resolvedPlatformUser}
56
+ baseUrl={baseUrl}
57
+ initialSnapshot={{
58
+ plan,
59
+ polls,
60
+ users,
61
+ timeblocks,
62
+ finalizedTimeframes,
63
+ viewer: {
64
+ id: resolvedPlatformUser?.id ?? null,
65
+ isCreator: resolvedPlatformUser?.id === plan.creator_id,
66
+ },
67
+ revision:
68
+ finalizedTimeframes.at(-1)?.updated_at ??
69
+ plan.finalized_at ??
70
+ plan.created_at ??
71
+ plan.id,
72
+ }}
73
+ />
62
74
  )}
63
75
  </Suspense>
64
76
  </div>
65
77
  );
66
78
  }
67
79
 
80
+ async function getFinalizedTimeframes(planId: string) {
81
+ const admin = await createAdminClient();
82
+ const query = (
83
+ admin as unknown as {
84
+ from(table: string): {
85
+ select(columns: string): {
86
+ eq(
87
+ column: string,
88
+ value: string
89
+ ): {
90
+ order(column: string): PromiseLike<{
91
+ data: unknown[] | null;
92
+ error: unknown;
93
+ }>;
94
+ };
95
+ };
96
+ };
97
+ }
98
+ )
99
+ .from('meet_together_finalized_timeframes')
100
+ .select('*')
101
+ .eq('plan_id', planId)
102
+ .order('position');
103
+ const { data, error } = await query;
104
+ if (error) return [];
105
+ return data as import('@tuturuuu/types/primitives/MeetTogetherPlan').MeetFinalizedTimeframe[];
106
+ }
107
+
68
108
  async function getUsers(planId: string) {
69
109
  const sbAdmin = await createAdminClient();
70
110