@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.
- package/CHANGELOG.md +43 -0
- package/biome.json +1 -1
- package/package.json +78 -78
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.test.tsx +121 -0
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.tsx +37 -4
- package/src/components/ui/chat/ai-message-markdown.tsx +6 -1
- package/src/components/ui/chat/chat-external-context-sidebar.tsx +113 -0
- package/src/components/ui/chat/chat-sidebar-conversation-groups.tsx +5 -1
- package/src/components/ui/chat/chat-sidebar-groups.test.ts +33 -0
- package/src/components/ui/chat/chat-sidebar-items.tsx +89 -5
- package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
- package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
- package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
- package/src/components/ui/chat/chat-sidebar.tsx +3 -0
- package/src/components/ui/chat/chat-utils.test.ts +173 -0
- package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
- package/src/components/ui/chat/chat-workspace.tsx +105 -28
- package/src/components/ui/chat/hooks-conversations.ts +9 -1
- package/src/components/ui/chat/hooks-messages.ts +1 -0
- package/src/components/ui/chat/hooks-realtime.ts +93 -4
- package/src/components/ui/chat/message-bubble.tsx +49 -4
- package/src/components/ui/chat/message-composer.tsx +29 -23
- package/src/components/ui/chat/message-list.tsx +5 -3
- package/src/components/ui/chat/message-markdown.tsx +4 -1
- package/src/components/ui/chat/query-keys.ts +7 -1
- package/src/components/ui/chat/selection.ts +1 -1
- package/src/components/ui/chat/utils.ts +126 -3
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
- package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
- package/src/components/ui/custom/workspace-select.tsx +37 -38
- package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
- package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
- package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
- package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
- package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
- package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
- package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
- package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
- package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
- package/src/components/ui/legacy/meet/form.tsx +25 -1
- package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
- package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
- package/src/components/ui/legacy/meet/page.tsx +1 -0
- package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
- package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
- package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
- package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
- package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
- package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
- package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
- package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
- package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
- package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
- package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
- package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
- package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
- package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
- package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
- package/src/components/ui/nuqs-adapter.tsx +1 -0
- 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 {
|
|
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({
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
}
|
|
176
|
+
} catch (error) {
|
|
163
177
|
setCreating(false);
|
|
164
178
|
toast({
|
|
165
179
|
title: t('something_went_wrong'),
|
|
166
|
-
description:
|
|
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
|
|
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 {
|
|
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.
|
|
130
|
-
? (() =>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
|
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
|
+
});
|