@tuturuuu/ui 0.22.0 → 0.24.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 (66) hide show
  1. package/CHANGELOG.md +36 -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-sidebar-groups.test.ts +33 -0
  8. package/src/components/ui/chat/chat-sidebar-items.tsx +10 -2
  9. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
  10. package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
  11. package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
  12. package/src/components/ui/chat/chat-utils.test.ts +107 -0
  13. package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
  14. package/src/components/ui/chat/chat-workspace.tsx +66 -24
  15. package/src/components/ui/chat/hooks-conversations.ts +9 -1
  16. package/src/components/ui/chat/hooks-messages.ts +1 -0
  17. package/src/components/ui/chat/message-bubble.tsx +49 -4
  18. package/src/components/ui/chat/message-composer.tsx +29 -23
  19. package/src/components/ui/chat/message-markdown.tsx +4 -1
  20. package/src/components/ui/chat/query-keys.ts +7 -1
  21. package/src/components/ui/chat/utils.ts +85 -3
  22. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
  23. package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
  24. package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
  25. package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
  26. package/src/components/ui/custom/workspace-select.tsx +37 -38
  27. package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
  28. package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
  29. package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
  30. package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
  31. package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
  32. package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
  33. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
  34. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
  35. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
  36. package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
  37. package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
  38. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
  39. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
  40. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
  41. package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
  42. package/src/components/ui/legacy/meet/form.tsx +25 -1
  43. package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
  44. package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
  45. package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
  46. package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
  47. package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
  48. package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
  49. package/src/components/ui/legacy/meet/page.tsx +1 -0
  50. package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
  51. package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
  52. package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
  53. package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
  54. package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
  55. package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
  56. package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
  57. package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
  58. package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
  59. package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
  60. package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
  61. package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
  62. package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
  63. package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
  64. package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
  65. package/src/components/ui/nuqs-adapter.tsx +1 -0
  66. package/src/hooks/time-blocking-provider.tsx +127 -211
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import { listInventoryFinanceEntries } from '@tuturuuu/internal-api';
5
+ import { Badge } from '@tuturuuu/ui/badge';
6
+ import { Card } from '@tuturuuu/ui/card';
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from '@tuturuuu/ui/select';
14
+ import { Skeleton } from '@tuturuuu/ui/skeleton';
15
+ import { getCurrencyLocale } from '@tuturuuu/utils/currencies';
16
+ import { useTranslations } from 'next-intl';
17
+ import { useState } from 'react';
18
+
19
+ export function InventoryWalletContribution({
20
+ currency,
21
+ walletId,
22
+ wsId,
23
+ }: {
24
+ currency: string;
25
+ walletId: string;
26
+ wsId: string;
27
+ }) {
28
+ const t = useTranslations('inventory-finance-reconciliation');
29
+ const [provider, setProvider] = useState('all');
30
+ const { data, isLoading } = useQuery({
31
+ queryKey: ['inventory-wallet-contribution', wsId, walletId, provider],
32
+ queryFn: () =>
33
+ listInventoryFinanceEntries(wsId, {
34
+ currency,
35
+ limit: 1,
36
+ provider:
37
+ provider === 'all'
38
+ ? undefined
39
+ : (provider as 'polar' | 'square_pos' | 'square_terminal'),
40
+ status: 'linked',
41
+ walletId,
42
+ }),
43
+ });
44
+ if (isLoading) return <Skeleton className="h-32 w-full" />;
45
+ const values =
46
+ provider === 'all'
47
+ ? data?.summary.netSales
48
+ : data?.summary.providers.find((item) => item.provider === provider)
49
+ ?.netSales;
50
+ const net = values?.find((item) => item.currency === currency)?.amount ?? 0;
51
+
52
+ return (
53
+ <Card className="my-4 flex flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between">
54
+ <div>
55
+ <div className="flex items-center gap-2">
56
+ <h3 className="font-semibold">{t('wallet_contribution_title')}</h3>
57
+ <Badge variant="secondary">{t('linked_only')}</Badge>
58
+ </div>
59
+ <p className="text-muted-foreground text-sm">
60
+ {t('wallet_contribution_description')}
61
+ </p>
62
+ <p className="mt-2 font-bold text-2xl">
63
+ {new Intl.NumberFormat(getCurrencyLocale(currency), {
64
+ currency,
65
+ style: 'currency',
66
+ }).format(net)}
67
+ </p>
68
+ </div>
69
+ <Select value={provider} onValueChange={setProvider}>
70
+ <SelectTrigger className="w-52">
71
+ <SelectValue />
72
+ </SelectTrigger>
73
+ <SelectContent>
74
+ <SelectItem value="all">{t('all_providers')}</SelectItem>
75
+ <SelectItem value="polar">{t('provider_polar')}</SelectItem>
76
+ <SelectItem value="square_pos">{t('provider_square_pos')}</SelectItem>
77
+ <SelectItem value="square_terminal">
78
+ {t('provider_square_terminal')}
79
+ </SelectItem>
80
+ </SelectContent>
81
+ </Select>
82
+ </Card>
83
+ );
84
+ }
@@ -117,6 +117,10 @@ vi.mock('./interest', () => ({
117
117
  WalletInterestSection: () => null,
118
118
  }));
119
119
 
120
+ vi.mock('./inventory-wallet-contribution', () => ({
121
+ InventoryWalletContribution: () => null,
122
+ }));
123
+
120
124
  vi.mock('./wallet-details-actions', () => ({
121
125
  WalletDetailsActions: (
122
126
  ...args: Parameters<typeof mocks.walletDetailsActions>
@@ -31,6 +31,7 @@ import { WalletCheckpointPanel } from '../checkpoints/wallet-checkpoint-panel';
31
31
  import { WalletIconDisplay } from '../wallet-icon-display';
32
32
  import { CreditWalletSummary } from './credit-wallet-summary';
33
33
  import { WalletInterestSection } from './interest';
34
+ import { InventoryWalletContribution } from './inventory-wallet-contribution';
34
35
  import {
35
36
  type WalletDetailsAction,
36
37
  WalletDetailsActions,
@@ -79,6 +80,7 @@ export default async function WalletDetailsPage({
79
80
  if (!resolvedWorkspace || !resolvedPermissions) notFound();
80
81
  const { withoutPermission, containsPermission } = resolvedPermissions;
81
82
  const canManageRoles = !withoutPermission('manage_workspace_roles');
83
+ const canManageFinance = containsPermission('manage_finance');
82
84
 
83
85
  // Transaction permissions
84
86
  const canUpdateTransactions = containsPermission('update_transactions');
@@ -288,6 +290,13 @@ export default async function WalletDetailsPage({
288
290
  </Card>
289
291
  </div>
290
292
  <Separator className="my-4" />
293
+ {canManageFinance && (
294
+ <InventoryWalletContribution
295
+ currency={currency}
296
+ walletId={walletId}
297
+ wsId={wsId}
298
+ />
299
+ )}
291
300
  {canManageRoles && !resolvedWorkspace.personal && (
292
301
  <>
293
302
  <WalletRoleAccessDialog wsId={wsId} walletId={walletId} />
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { createPlan } from '@tuturuuu/apis/meet/actions';
3
+ import { useMutation } from '@tanstack/react-query';
4
4
  import {
5
5
  ClipboardList,
6
6
  MapPin as MapPinIcon,
7
7
  Sparkles as SparklesIcon,
8
8
  } from '@tuturuuu/icons';
9
+ import { createMeetPlan } from '@tuturuuu/internal-api';
9
10
  import type { User } from '@tuturuuu/types';
10
11
  import type { Timezone } from '@tuturuuu/types/primitives/Timezone';
11
12
  import type { JSONContent } from '@tuturuuu/types/tiptap';
@@ -52,6 +53,7 @@ interface Props {
52
53
  startTime: number | undefined;
53
54
  endTime: number | undefined;
54
55
  timezone: Timezone | undefined;
56
+ durationMinutes: number;
55
57
  wsId?: string;
56
58
  };
57
59
  }
@@ -63,6 +65,8 @@ const FormSchema = z.object({
63
65
  end_time: z.string().optional(),
64
66
  dates: z.array(z.string()).optional(),
65
67
  is_public: z.boolean().optional(),
68
+ timezone: z.string().optional(),
69
+ duration_minutes: z.number().int().min(15).max(480),
66
70
  ws_id: z.string().optional(),
67
71
  agenda_enabled: z.boolean().optional(), // <-- Added field for agenda toggle
68
72
  agenda_content: z.custom<JSONContent>().optional(),
@@ -90,12 +94,19 @@ const convertToTimetz = (
90
94
  return `${timeStr}${offsetStr}`;
91
95
  };
92
96
 
93
- export default function CreatePlanDialog({ plan }: Props) {
97
+ export default function CreatePlanDialog({
98
+ plan,
99
+ planBasePath = '/meet/plans',
100
+ }: Props & { planBasePath?: string }) {
94
101
  const t = useTranslations('meet-together');
95
102
  const router = useRouter();
96
103
 
97
104
  const [isOpened, setIsOpened] = useState(false);
98
105
  const [creating, setCreating] = useState(false);
106
+ const createMutation = useMutation({
107
+ mutationFn: (payload: Parameters<typeof createMeetPlan>[0]) =>
108
+ createMeetPlan(payload),
109
+ });
99
110
 
100
111
  const form = useForm({
101
112
  resolver: zodResolver(FormSchema),
@@ -107,6 +118,8 @@ export default function CreatePlanDialog({ plan }: Props) {
107
118
  ?.sort((a, b) => a.getTime() - b.getTime())
108
119
  ?.map((date) => dayjs(date).format('YYYY-MM-DD')),
109
120
  is_public: true,
121
+ timezone: plan.timezone?.utc[0],
122
+ duration_minutes: plan.durationMinutes,
110
123
  ws_id: plan.wsId,
111
124
  agenda_enabled: false, // <-- Default value for agenda toggle
112
125
  agenda_content: undefined,
@@ -153,17 +166,21 @@ export default function CreatePlanDialog({ plan }: Props) {
153
166
 
154
167
  const { agenda_enabled: _, ...rest } = data;
155
168
 
156
- const result = await createPlan(rest);
157
-
158
- if (result.data) {
159
- const normalizedId = result.data.id.replace(/-/g, '');
160
- router.push(`/meet-together/plans/${normalizedId}`);
169
+ try {
170
+ const result = await createMutation.mutateAsync(
171
+ rest as Parameters<typeof createMeetPlan>[0]
172
+ );
173
+ const normalizedId = result.id.replace(/-/g, '');
174
+ router.push(`${planBasePath}/${normalizedId}`);
161
175
  router.refresh();
162
- } else {
176
+ } catch (error) {
163
177
  setCreating(false);
164
178
  toast({
165
179
  title: t('something_went_wrong'),
166
- description: result.error || t('cant_create_plan_right_now'),
180
+ description:
181
+ error instanceof Error
182
+ ? error.message
183
+ : t('cant_create_plan_right_now'),
167
184
  });
168
185
  }
169
186
  };
@@ -283,7 +300,7 @@ export default function CreatePlanDialog({ plan }: Props) {
283
300
  className="mb-0 cursor-pointer font-medium text-foreground"
284
301
  onClick={(e) => e.stopPropagation()}
285
302
  >
286
- Where TuMeet?
303
+ Where to meet?
287
304
  </FormLabel>
288
305
  </div>
289
306
  <p className="text-muted-foreground text-xs leading-relaxed">
@@ -1,7 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { deletePlan, updatePlan } from '@tuturuuu/apis/meet/actions';
3
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
4
4
  import { Pencil } from '@tuturuuu/icons';
5
+ import {
6
+ deleteMeetPlan,
7
+ type MeetPlanSnapshot,
8
+ updateMeetPlan,
9
+ } from '@tuturuuu/internal-api';
5
10
  import type { MeetTogetherPlan } from '@tuturuuu/types/primitives/MeetTogetherPlan';
6
11
  import {
7
12
  AlertDialog,
@@ -37,10 +42,11 @@ import { toast } from '@tuturuuu/ui/hooks/use-toast';
37
42
  import { Input } from '@tuturuuu/ui/input';
38
43
  import { zodResolver } from '@tuturuuu/ui/resolvers';
39
44
  import { Separator } from '@tuturuuu/ui/separator';
45
+ import { Tabs, TabsList, TabsTrigger } from '@tuturuuu/ui/tabs';
40
46
  import { parseTimeFromTimetz } from '@tuturuuu/utils/time-helper';
41
47
  import timezones from '@tuturuuu/utils/timezones';
42
48
  import dayjs from 'dayjs';
43
- import { useRouter } from 'next/navigation';
49
+ import { usePathname, useRouter } from 'next/navigation';
44
50
  import { useTranslations } from 'next-intl';
45
51
  import { useState } from 'react';
46
52
  import * as z from 'zod';
@@ -63,6 +69,8 @@ const FormSchema = z
63
69
  .array(z.string())
64
70
  .min(1, { message: 'At least one date is required' }),
65
71
  is_public: z.boolean().optional(),
72
+ timezone: z.string().optional(),
73
+ duration_minutes: z.number().int().min(15).max(480),
66
74
  })
67
75
  .refine(
68
76
  (data) => {
@@ -113,10 +121,39 @@ export default function EditPlanDialog({
113
121
  }: Props) {
114
122
  const t = useTranslations();
115
123
  const router = useRouter();
124
+ const pathname = usePathname();
125
+ const queryClient = useQueryClient();
116
126
 
117
127
  const [isOpened, setIsOpened] = useState(false);
118
128
  const [updating, setUpdating] = useState(false);
119
129
  const [deleting, setDeleting] = useState(false);
130
+ const updateMutation = useMutation({
131
+ mutationFn: (data: z.infer<typeof FormSchema>) =>
132
+ updateMeetPlan(plan.id!, data),
133
+ onMutate: async (data) => {
134
+ if (!plan.id) return {};
135
+ const queryKey = ['meet-plan', plan.id] as const;
136
+ await queryClient.cancelQueries({ queryKey });
137
+ const previous = queryClient.getQueryData<MeetPlanSnapshot>(queryKey);
138
+ if (previous) {
139
+ queryClient.setQueryData<MeetPlanSnapshot>(queryKey, {
140
+ ...previous,
141
+ plan: { ...previous.plan, ...data },
142
+ });
143
+ }
144
+ return { previous };
145
+ },
146
+ onError: (_error, _data, context) => {
147
+ if (plan.id && context?.previous)
148
+ queryClient.setQueryData(['meet-plan', plan.id], context.previous);
149
+ },
150
+ onSuccess: (snapshot) => {
151
+ if (plan.id) queryClient.setQueryData(['meet-plan', plan.id], snapshot);
152
+ },
153
+ });
154
+ const deleteMutation = useMutation({
155
+ mutationFn: () => deleteMeetPlan(plan.id!),
156
+ });
120
157
 
121
158
  // Parse plan data once for form initialization
122
159
  const parsedStartTime = plan.start_time
@@ -126,14 +163,16 @@ export default function EditPlanDialog({
126
163
  const parsedDates = plan.dates
127
164
  ? plan.dates.map((dateStr: string) => new Date(dateStr))
128
165
  : [];
129
- const parsedTimezone = plan.start_time
130
- ? (() => {
131
- const offset = parseTimezoneFromTimetz(plan.start_time);
132
- return offset !== undefined
133
- ? timezones.find((tz) => tz.offset === offset)
134
- : undefined;
135
- })()
136
- : undefined;
166
+ const parsedTimezone = plan.timezone
167
+ ? timezones.find((tz) => tz.utc.includes(plan.timezone!))
168
+ : plan.start_time
169
+ ? (() => {
170
+ const offset = parseTimezoneFromTimetz(plan.start_time);
171
+ return offset !== undefined
172
+ ? timezones.find((tz) => tz.offset === offset)
173
+ : undefined;
174
+ })()
175
+ : undefined;
137
176
 
138
177
  const form = useForm({
139
178
  resolver: zodResolver(FormSchema),
@@ -146,6 +185,8 @@ export default function EditPlanDialog({
146
185
  .sort((a: Date, b: Date) => a.getTime() - b.getTime())
147
186
  .map((date: Date) => dayjs(date).format('YYYY-MM-DD')),
148
187
  is_public: true,
188
+ timezone: plan.timezone ?? parsedTimezone?.utc[0],
189
+ duration_minutes: plan.duration_minutes ?? 60,
149
190
  },
150
191
  });
151
192
 
@@ -157,6 +198,8 @@ export default function EditPlanDialog({
157
198
  const watchedStartTime = form.watch('start_time');
158
199
  const watchedEndTime = form.watch('end_time');
159
200
  const watchedDates = form.watch('dates');
201
+ const watchedTimezone = form.watch('timezone');
202
+ const watchedDuration = form.watch('duration_minutes');
160
203
 
161
204
  // Convert form values back to component-friendly formats
162
205
  const currentStartTime = watchedStartTime
@@ -170,12 +213,13 @@ export default function EditPlanDialog({
170
213
  ? watchedDates.map((dateStr: string) => new Date(dateStr))
171
214
  : parsedDates;
172
215
  const currentTimezone = watchedStartTime
173
- ? (() => {
216
+ ? (timezones.find((tz) => tz.utc.includes(watchedTimezone ?? '')) ??
217
+ (() => {
174
218
  const offset = parseTimezoneFromTimetz(watchedStartTime);
175
219
  return offset !== undefined
176
220
  ? timezones.find((tz) => tz.offset === offset)
177
221
  : parsedTimezone;
178
- })()
222
+ })())
179
223
  : parsedTimezone;
180
224
 
181
225
  const originalDatesString = JSON.stringify(
@@ -188,7 +232,9 @@ export default function EditPlanDialog({
188
232
  plan.name !== watchedName ||
189
233
  originalDatesString !== newDatesString ||
190
234
  plan.start_time !== watchedStartTime ||
191
- plan.end_time !== watchedEndTime;
235
+ plan.end_time !== watchedEndTime ||
236
+ plan.timezone !== watchedTimezone ||
237
+ (plan.duration_minutes ?? 60) !== watchedDuration;
192
238
 
193
239
  const handleSubmit = async () => {
194
240
  if (!plan.id) {
@@ -234,20 +280,20 @@ export default function EditPlanDialog({
234
280
  return;
235
281
  }
236
282
 
237
- const result = await updatePlan(plan.id, data);
238
-
239
- if (result.data) {
283
+ try {
284
+ await updateMutation.mutateAsync(data);
240
285
  onSuccess?.();
241
286
  router.refresh();
242
287
  setUpdating(false);
243
288
  setIsOpened(false);
244
- } else {
289
+ } catch (error) {
245
290
  setUpdating(false);
246
291
  toast({
247
292
  title: t('meet-together-plan-details.something_went_wrong'),
248
293
  description:
249
- result.error ||
250
- t('meet-together-plan-details.cant_update_plan_right_now'),
294
+ error instanceof Error
295
+ ? error.message
296
+ : t('meet-together-plan-details.cant_update_plan_right_now'),
251
297
  });
252
298
  }
253
299
  };
@@ -263,16 +309,16 @@ export default function EditPlanDialog({
263
309
 
264
310
  setDeleting(true);
265
311
  try {
266
- const result = await deletePlan(plan.id);
267
-
268
- if (result.data) {
269
- router.push('/meet-together');
270
- } else {
312
+ try {
313
+ await deleteMutation.mutateAsync();
314
+ router.push(pathname.includes('/meet/plans/') ? '/meet' : '/');
315
+ } catch (error) {
271
316
  toast({
272
317
  title: t('meet-together-plan-details.something_went_wrong'),
273
318
  description:
274
- result.error ||
275
- t('meet-together-plan-details.cant_delete_plan_right_now'),
319
+ error instanceof Error
320
+ ? error.message
321
+ : t('meet-together-plan-details.cant_delete_plan_right_now'),
276
322
  });
277
323
  }
278
324
  } finally {
@@ -424,6 +470,7 @@ export default function EditPlanDialog({
424
470
  <TimezoneSelector
425
471
  value={currentTimezone}
426
472
  onValueChange={(newTimezone) => {
473
+ form.setValue('timezone', newTimezone?.utc[0]);
427
474
  // Update both start and end times with new timezone
428
475
  form.setValue(
429
476
  'start_time',
@@ -440,6 +487,23 @@ export default function EditPlanDialog({
440
487
  }}
441
488
  />
442
489
  </div>
490
+ <div className="grid w-full gap-2">
491
+ <FormLabel>{t('meet-together.duration')}</FormLabel>
492
+ <Tabs
493
+ value={String(watchedDuration)}
494
+ onValueChange={(value) =>
495
+ form.setValue('duration_minutes', Number(value))
496
+ }
497
+ >
498
+ <TabsList className="grid h-auto w-full grid-cols-4">
499
+ {[30, 45, 60, 90].map((minutes) => (
500
+ <TabsTrigger key={minutes} value={String(minutes)}>
501
+ {minutes}m
502
+ </TabsTrigger>
503
+ ))}
504
+ </TabsList>
505
+ </Tabs>
506
+ </div>
443
507
  </div>
444
508
 
445
509
  <DialogFooter>
@@ -3,6 +3,7 @@
3
3
  import type { User } from '@tuturuuu/types';
4
4
  import type { Timezone } from '@tuturuuu/types/primitives/Timezone';
5
5
  import { Separator } from '@tuturuuu/ui/separator';
6
+ import { Tabs, TabsList, TabsTrigger } from '@tuturuuu/ui/tabs';
6
7
  import timezones from '@tuturuuu/utils/timezones';
7
8
  import { useTranslations } from 'next-intl';
8
9
  import { useEffect, useState } from 'react';
@@ -14,9 +15,11 @@ import TimezoneSelector from './timezone-selector';
14
15
  export default function Form({
15
16
  wsId,
16
17
  user,
18
+ planBasePath,
17
19
  }: {
18
20
  wsId?: string;
19
21
  user: Partial<User> | null;
22
+ planBasePath: string;
20
23
  }) {
21
24
  const t = useTranslations('meet-together');
22
25
 
@@ -24,6 +27,7 @@ export default function Form({
24
27
  const [startTime, setStartTime] = useState<number | undefined>(9);
25
28
  const [endTime, setEndTime] = useState<number | undefined>(17);
26
29
  const [timezone, setTimezone] = useState<Timezone | undefined>(undefined);
30
+ const [durationMinutes, setDurationMinutes] = useState(60);
27
31
 
28
32
  useEffect(() => {
29
33
  const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -41,6 +45,7 @@ export default function Form({
41
45
  startTime,
42
46
  endTime,
43
47
  timezone,
48
+ durationMinutes,
44
49
  wsId,
45
50
  };
46
51
 
@@ -85,8 +90,27 @@ export default function Form({
85
90
  <p className="w-full font-semibold">{t('time-zone')}</p>
86
91
  <TimezoneSelector value={timezone} onValueChange={setTimezone} />
87
92
  </div>
93
+ <div className="grid w-full gap-2">
94
+ <p className="w-full font-semibold">{t('duration')}</p>
95
+ <Tabs
96
+ value={String(durationMinutes)}
97
+ onValueChange={(value) => setDurationMinutes(Number(value))}
98
+ >
99
+ <TabsList className="grid h-auto w-full grid-cols-4">
100
+ {[30, 45, 60, 90].map((minutes) => (
101
+ <TabsTrigger key={minutes} value={String(minutes)}>
102
+ {minutes}m
103
+ </TabsTrigger>
104
+ ))}
105
+ </TabsList>
106
+ </Tabs>
107
+ </div>
88
108
  <div className="flex w-full justify-center lg:justify-start">
89
- <CreatePlanDialog plan={plan} user={user} />
109
+ <CreatePlanDialog
110
+ plan={plan}
111
+ user={user}
112
+ planBasePath={planBasePath}
113
+ />
90
114
  </div>
91
115
  </div>
92
116
  </div>
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createMeetIcs } from './meet-ics';
3
+
4
+ describe('Tuturuuu Meet ICS export', () => {
5
+ it('creates one event per finalized alternative', () => {
6
+ vi.useFakeTimers();
7
+ vi.setSystemTime(new Date('2026-08-01T00:00:00.000Z'));
8
+ const ics = createMeetIcs({
9
+ planId: 'plan-1',
10
+ title: 'Design, review',
11
+ timeframes: [
12
+ {
13
+ id: 'slot-1',
14
+ plan_id: 'plan-1',
15
+ start_at: '2026-08-04T02:00:00.000Z',
16
+ end_at: '2026-08-04T03:00:00.000Z',
17
+ position: 0,
18
+ created_by: null,
19
+ created_at: '2026-08-01T00:00:00.000Z',
20
+ updated_at: '2026-08-01T00:00:00.000Z',
21
+ },
22
+ {
23
+ id: 'slot-2',
24
+ plan_id: 'plan-1',
25
+ start_at: '2026-08-05T02:00:00.000Z',
26
+ end_at: '2026-08-05T03:00:00.000Z',
27
+ position: 1,
28
+ created_by: null,
29
+ created_at: '2026-08-01T00:00:00.000Z',
30
+ updated_at: '2026-08-01T00:00:00.000Z',
31
+ },
32
+ ],
33
+ });
34
+
35
+ expect(ics.match(/BEGIN:VEVENT/gu)).toHaveLength(2);
36
+ expect(ics).toContain('SUMMARY:Design\\, review');
37
+ expect(ics).toContain('DTSTART:20260804T020000Z');
38
+ vi.useRealTimers();
39
+ });
40
+ });
@@ -0,0 +1,50 @@
1
+ import type { MeetFinalizedTimeframe } from '@tuturuuu/types/primitives/MeetTogetherPlan';
2
+
3
+ function escapeIcs(value: string) {
4
+ return value
5
+ .replaceAll('\\', '\\\\')
6
+ .replaceAll(';', '\\;')
7
+ .replaceAll(',', '\\,')
8
+ .replaceAll('\n', '\\n');
9
+ }
10
+
11
+ function toIcsTimestamp(value: string) {
12
+ return new Date(value)
13
+ .toISOString()
14
+ .replaceAll(/[-:]/gu, '')
15
+ .replace('.000', '');
16
+ }
17
+
18
+ export function createMeetIcs({
19
+ planId,
20
+ title,
21
+ description,
22
+ timeframes,
23
+ }: {
24
+ planId: string;
25
+ title: string;
26
+ description?: string | null;
27
+ timeframes: MeetFinalizedTimeframe[];
28
+ }) {
29
+ const generatedAt = toIcsTimestamp(new Date().toISOString());
30
+ const events = timeframes.flatMap((timeframe) => [
31
+ 'BEGIN:VEVENT',
32
+ `UID:${timeframe.id}@meet.tuturuuu.com`,
33
+ `DTSTAMP:${generatedAt}`,
34
+ `DTSTART:${toIcsTimestamp(timeframe.start_at)}`,
35
+ `DTEND:${toIcsTimestamp(timeframe.end_at)}`,
36
+ `SUMMARY:${escapeIcs(title)}`,
37
+ `DESCRIPTION:${escapeIcs(description ?? `Tuturuuu Meet plan ${planId}`)}`,
38
+ 'END:VEVENT',
39
+ ]);
40
+
41
+ return [
42
+ 'BEGIN:VCALENDAR',
43
+ 'VERSION:2.0',
44
+ 'PRODID:-//Tuturuuu//Meet//EN',
45
+ 'CALSCALE:GREGORIAN',
46
+ ...events,
47
+ 'END:VCALENDAR',
48
+ '',
49
+ ].join('\r\n');
50
+ }
@@ -0,0 +1,76 @@
1
+ import type { MeetTogetherPlan } from '@tuturuuu/types/primitives/MeetTogetherPlan';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { calculateMeetPlanInsights, rankMeetTimeframes } from './meet-insights';
4
+
5
+ const plan: MeetTogetherPlan = {
6
+ dates: ['2026-08-04'],
7
+ duration_minutes: 30,
8
+ start_time: '09:00:00+07',
9
+ end_time: '10:00:00+07',
10
+ };
11
+
12
+ const users = [
13
+ { id: 'user-a', display_name: 'An', is_guest: false, timeblock_count: 1 },
14
+ { id: 'user-b', display_name: 'Binh', is_guest: true, timeblock_count: 1 },
15
+ ];
16
+
17
+ describe('Tuturuuu Meet ranking and insights', () => {
18
+ it('ranks duration-aware confirmed overlap before tentative overlap', () => {
19
+ const ranked = rankMeetTimeframes({
20
+ plan,
21
+ users,
22
+ timeblocks: [
23
+ {
24
+ date: '2026-08-04',
25
+ start_time: '09:00:00+07',
26
+ end_time: '10:00:00+07',
27
+ user_id: 'user-a',
28
+ tentative: false,
29
+ },
30
+ {
31
+ date: '2026-08-04',
32
+ start_time: '09:00:00+07',
33
+ end_time: '09:30:00+07',
34
+ user_id: 'user-b',
35
+ tentative: false,
36
+ },
37
+ {
38
+ date: '2026-08-04',
39
+ start_time: '09:30:00+07',
40
+ end_time: '10:00:00+07',
41
+ user_id: 'user-b',
42
+ tentative: true,
43
+ },
44
+ ],
45
+ });
46
+
47
+ expect(ranked[0]).toMatchObject({
48
+ startMinute: 540,
49
+ endMinute: 570,
50
+ confirmedUserIds: ['user-a', 'user-b'],
51
+ confirmedPercent: 100,
52
+ });
53
+ expect(ranked.at(-1)?.weightedPercent).toBe(75);
54
+ });
55
+
56
+ it('computes response, peak, and average availability from plan data', () => {
57
+ const insights = calculateMeetPlanInsights({
58
+ plan,
59
+ users,
60
+ timeblocks: [
61
+ {
62
+ date: '2026-08-04',
63
+ start_time: '09:00:00+07',
64
+ end_time: '10:00:00+07',
65
+ user_id: 'user-a',
66
+ tentative: false,
67
+ },
68
+ ],
69
+ });
70
+
71
+ expect(insights.respondedCount).toBe(1);
72
+ expect(insights.responsePercent).toBe(50);
73
+ expect(insights.peakAttendance).toBe(1);
74
+ expect(insights.peakAttendancePercent).toBe(50);
75
+ });
76
+ });