@tuturuuu/ui 0.37.2 → 0.39.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 (126) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  11. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  12. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  13. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  14. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  15. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  16. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  17. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  18. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  19. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  20. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  21. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  22. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  23. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  24. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  25. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  26. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  27. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  28. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  29. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  30. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  31. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  33. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  34. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  35. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  36. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  37. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  38. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  39. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  40. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  41. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  42. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  43. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  44. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  46. package/src/components/ui/finance/invoices/types.ts +3 -3
  47. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  48. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  49. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  50. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  51. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  52. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  53. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  54. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  56. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  57. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  58. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  59. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
  60. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  61. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  62. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  63. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  64. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  65. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  66. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  68. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  69. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  70. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  71. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  72. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  73. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  74. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  75. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  76. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  77. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  78. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  79. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  80. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  81. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  82. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  83. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  84. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  85. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  86. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  87. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  88. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  89. package/src/components/ui/popover.tsx +3 -1
  90. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  91. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  92. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  93. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  94. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  95. package/src/components/ui/text-editor/editor.tsx +11 -3
  96. package/src/components/ui/text-editor/extensions.ts +8 -2
  97. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  98. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  99. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  100. package/src/components/ui/text-editor/markdown.ts +11 -0
  101. package/src/components/ui/text-editor/tool-bar.tsx +45 -102
  102. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  103. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  104. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  105. package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
  107. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  108. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  109. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  110. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  111. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
  112. package/src/hooks/calendar-connection-types.ts +49 -0
  113. package/src/hooks/calendar-creation-request.test.ts +62 -0
  114. package/src/hooks/calendar-creation-request.ts +34 -0
  115. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  116. package/src/hooks/calendar-event-write-payload.ts +57 -0
  117. package/src/hooks/collaboration-document-hydration.ts +35 -0
  118. package/src/hooks/notification-subscription.ts +5 -2
  119. package/src/hooks/supabase-provider.ts +76 -112
  120. package/src/hooks/use-calendar-sync.tsx +41 -62
  121. package/src/hooks/use-calendar.tsx +44 -82
  122. package/src/hooks/use-exchange-rates.ts +4 -15
  123. package/src/hooks/use-local-storage.ts +9 -11
  124. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  125. package/src/hooks/use-workspace-presence.ts +9 -9
  126. package/src/hooks/use-yjs-collaboration.ts +68 -27
@@ -3,6 +3,7 @@
3
3
  import { ArrowLeft, Eye, EyeOff, Plus, Search, Star, X } from '@tuturuuu/icons';
4
4
  import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
5
5
  import { ROOT_WORKSPACE_ID } from '@tuturuuu/utils/constants';
6
+ import { cn } from '@tuturuuu/utils/format';
6
7
  import { useTranslations } from 'next-intl';
7
8
  import { type ReactNode, useRef, useState } from 'react';
8
9
  import type { useWorkspaceVisibility } from '../../../hooks/use-workspace-visibility';
@@ -14,6 +15,7 @@ import {
14
15
  DialogTitle,
15
16
  } from '../dialog';
16
17
  import { Input } from '../input';
18
+ import { PopoverClose, PopoverContent } from '../popover';
17
19
  import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
18
20
  import { WorkspaceIcon } from './workspace-select-icon';
19
21
 
@@ -32,6 +34,8 @@ export function WorkspacePickerContent({
32
34
  listLoading = false,
33
35
  listError,
34
36
  onRetryList,
37
+ presentation = 'fullscreen',
38
+ onClose,
35
39
  }: {
36
40
  workspaces: InternalApiWorkspaceSummary[];
37
41
  currentId?: string;
@@ -46,10 +50,15 @@ export function WorkspacePickerContent({
46
50
  listLoading?: boolean;
47
51
  listError?: Error | null;
48
52
  onRetryList?: () => void;
53
+ presentation?: 'fullscreen' | 'dropdown';
54
+ onClose?: () => void;
49
55
  }) {
56
+ const dropdown = presentation === 'dropdown';
57
+ const Title = dropdown ? 'h2' : DialogTitle;
58
+ const Description = dropdown ? 'p' : DialogDescription;
50
59
  const t = useTranslations('common');
51
60
  const [restoring, setRestoring] = useState(restoreOnly);
52
- const [searchVisible, setSearchVisible] = useState(false);
61
+ const [searchVisible, setSearchVisible] = useState(dropdown);
53
62
  const [search, setSearch] = useState('');
54
63
  const input = useRef<HTMLInputElement>(null);
55
64
  const query = search.trim().toLocaleLowerCase();
@@ -75,13 +84,14 @@ export function WorkspacePickerContent({
75
84
  setSearchVisible(true);
76
85
  requestAnimationFrame(() => input.current?.focus());
77
86
  }
78
- return (
79
- <DialogContent
80
- presentation="fullscreen"
81
- showCloseButton={false}
82
- className="flex-col"
83
- >
84
- <div className="flex items-center gap-3 border-b p-4 pt-[max(1rem,env(safe-area-inset-top))]">
87
+ const content = (
88
+ <>
89
+ <div
90
+ className={cn(
91
+ 'flex items-center gap-3 border-b p-4',
92
+ !dropdown && 'pt-[max(1rem,env(safe-area-inset-top))]'
93
+ )}
94
+ >
85
95
  {restoring && !restoreOnly && (
86
96
  <Button
87
97
  size="icon"
@@ -96,18 +106,31 @@ export function WorkspacePickerContent({
96
106
  </Button>
97
107
  )}
98
108
  <WorkspaceIcon fallbackLogoUrl={TUTURUUU_LOGO_URL} name="Tuturuuu" />
99
- <DialogTitle className="min-w-0 flex-1">
109
+ <Title className="min-w-0 flex-1 font-semibold text-lg leading-none">
100
110
  {restoring ? t('hidden_workspaces') : t('workspaces')}
101
- </DialogTitle>
102
- <DialogClose asChild>
103
- <Button size="icon" variant="ghost" aria-label={t('close')}>
104
- <X />
105
- </Button>
106
- </DialogClose>
111
+ </Title>
112
+ {dropdown ? (
113
+ <PopoverClose asChild>
114
+ <Button
115
+ size="icon"
116
+ variant="ghost"
117
+ aria-label={t('close')}
118
+ onClick={onClose}
119
+ >
120
+ <X />
121
+ </Button>
122
+ </PopoverClose>
123
+ ) : (
124
+ <DialogClose asChild>
125
+ <Button size="icon" variant="ghost" aria-label={t('close')}>
126
+ <X />
127
+ </Button>
128
+ </DialogClose>
129
+ )}
107
130
  </div>
108
- <DialogDescription className={restoring ? 'px-4 pt-3' : 'sr-only'}>
131
+ <Description className={restoring ? 'px-4 pt-3' : 'sr-only'}>
109
132
  {restoring ? t('hidden_workspaces_description') : t('select_workspace')}
110
- </DialogDescription>
133
+ </Description>
111
134
  {searchVisible && (
112
135
  <div className="flex gap-2 px-4 pt-3">
113
136
  <Input
@@ -148,7 +171,12 @@ export function WorkspacePickerContent({
148
171
  </Button>
149
172
  </div>
150
173
  )}
151
- <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4 pb-28">
174
+ <div
175
+ className={cn(
176
+ 'min-h-0 flex-1 overflow-y-auto px-4 pt-4',
177
+ dropdown ? 'pb-3' : 'pb-28'
178
+ )}
179
+ >
152
180
  {!visibility.known || listLoading || listError ? (
153
181
  <p role="status">
154
182
  {visibility.isError || listError
@@ -161,7 +189,10 @@ export function WorkspacePickerContent({
161
189
  {results.map((workspace) => (
162
190
  <div
163
191
  key={workspace.id}
164
- className="mb-2 flex items-center gap-2 rounded-xl border p-2"
192
+ className={cn(
193
+ 'flex items-center gap-2',
194
+ dropdown ? 'mb-1 rounded-md' : 'mb-2 rounded-xl border p-2'
195
+ )}
165
196
  >
166
197
  {restoring ? (
167
198
  <div className="flex min-w-0 flex-1 items-center gap-3 p-2">
@@ -174,7 +205,10 @@ export function WorkspacePickerContent({
174
205
  ) : (
175
206
  <Button
176
207
  variant="ghost"
177
- className="h-auto min-w-0 flex-1 justify-start gap-3 whitespace-normal py-3 text-start"
208
+ className={cn(
209
+ 'h-auto min-w-0 flex-1 justify-start gap-3 whitespace-normal text-start',
210
+ dropdown ? 'py-2' : 'py-3'
211
+ )}
178
212
  aria-label={[
179
213
  workspace.name ?? workspace.id,
180
214
  workspace.tier ?? 'FREE',
@@ -268,26 +302,55 @@ export function WorkspacePickerContent({
268
302
  </Button>
269
303
  )}
270
304
  </div>
271
- <div className="absolute right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] flex gap-3 rounded-full border bg-background p-2 shadow-lg">
272
- <Button
273
- size="icon"
274
- className="rounded-full"
275
- aria-label={t('search_workspace')}
276
- onClick={openSearch}
277
- >
278
- <Search />
279
- </Button>
280
- {!restoring && onCreate && (
305
+ <div
306
+ className={
307
+ dropdown
308
+ ? restoring || !onCreate
309
+ ? 'hidden'
310
+ : 'flex gap-2 border-t p-2'
311
+ : 'absolute right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] flex gap-3 rounded-full border bg-background p-2 shadow-lg'
312
+ }
313
+ >
314
+ {!dropdown && (
281
315
  <Button
282
316
  size="icon"
283
317
  className="rounded-full"
318
+ aria-label={t('search_workspace')}
319
+ onClick={openSearch}
320
+ >
321
+ <Search />
322
+ </Button>
323
+ )}
324
+ {!restoring && onCreate && (
325
+ <Button
326
+ size={dropdown ? 'sm' : 'icon'}
327
+ className={dropdown ? 'w-full justify-start' : 'rounded-full'}
284
328
  aria-label={t('create_workspace_action')}
285
329
  onClick={onCreate}
286
330
  >
287
331
  <Plus />
332
+ {dropdown && t('create_workspace_action')}
288
333
  </Button>
289
334
  )}
290
335
  </div>
336
+ </>
337
+ );
338
+ return dropdown ? (
339
+ <PopoverContent
340
+ align="start"
341
+ aria-label={t(restoring ? 'hidden_workspaces' : 'workspaces')}
342
+ collisionPadding={8}
343
+ className="flex max-h-[min(32rem,var(--radix-popover-content-available-height))] w-80 max-w-[min(calc(100vw-1rem),var(--radix-popover-content-available-width))] flex-col overflow-hidden p-0"
344
+ >
345
+ {content}
346
+ </PopoverContent>
347
+ ) : (
348
+ <DialogContent
349
+ presentation="fullscreen"
350
+ showCloseButton={false}
351
+ className="flex-col"
352
+ >
353
+ {content}
291
354
  </DialogContent>
292
355
  );
293
356
  }
@@ -54,6 +54,7 @@ import {
54
54
  FormMessage,
55
55
  } from '../form';
56
56
  import { Input } from '../input';
57
+ import { Popover, PopoverTrigger } from '../popover';
57
58
  import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
58
59
  import { WorkspacePickerContent } from './workspace-picker-content';
59
60
  import {
@@ -93,6 +94,7 @@ export function WorkspaceSelect({
93
94
  triggerClassName,
94
95
  platformWorkspaceSetupUrl,
95
96
  cacheScope,
97
+ presentation = 'dropdown',
96
98
  }: {
97
99
  wsId: string;
98
100
  hideLeading?: boolean;
@@ -113,7 +115,11 @@ export function WorkspaceSelect({
113
115
  platformWorkspaceSetupUrl?: string;
114
116
  /** Authenticated identity used to isolate user-specific picker caches. */
115
117
  cacheScope?: string;
118
+ presentation?: 'fullscreen' | 'dropdown';
116
119
  }) {
120
+ const Picker = presentation === 'dropdown' ? Popover : Dialog;
121
+ const PickerTrigger =
122
+ presentation === 'dropdown' ? PopoverTrigger : DialogTrigger;
117
123
  const t = useTranslations();
118
124
  const locale = useLocale();
119
125
  const router = useRouter();
@@ -417,8 +423,8 @@ export function WorkspaceSelect({
417
423
  setShowNewWorkspaceDialog(open);
418
424
  }}
419
425
  >
420
- <Dialog open={open} onOpenChange={setOpen}>
421
- <DialogTrigger asChild disabled={!hasSelectableWorkspaces}>
426
+ <Picker open={open} onOpenChange={setOpen}>
427
+ <PickerTrigger asChild disabled={!hasSelectableWorkspaces}>
422
428
  <Button
423
429
  size="xs"
424
430
  variant="outline"
@@ -485,8 +491,10 @@ export function WorkspaceSelect({
485
491
  <ChevronDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
486
492
  )}
487
493
  </Button>
488
- </DialogTrigger>
494
+ </PickerTrigger>
489
495
  <WorkspacePickerContent
496
+ presentation={presentation}
497
+ onClose={() => setOpen(false)}
490
498
  workspaces={workspaces ?? []}
491
499
  currentId={workspace?.id}
492
500
  defaultId={defaultWorkspaceId}
@@ -517,7 +525,7 @@ export function WorkspaceSelect({
517
525
  </Command>
518
526
  }
519
527
  />
520
- </Dialog>
528
+ </Picker>
521
529
  <DialogContent>
522
530
  <DialogHeader>
523
531
  <DialogTitle>{t('common.create_workspace')}</DialogTitle>
@@ -0,0 +1,294 @@
1
+ import {
2
+ cleanup,
3
+ fireEvent,
4
+ render,
5
+ waitFor,
6
+ within,
7
+ } from '@testing-library/react';
8
+ import type { Database } from '@tuturuuu/types/supabase';
9
+ import { TooltipProvider } from '@tuturuuu/ui/tooltip';
10
+ import { NextIntlClientProvider } from 'next-intl';
11
+ import { afterEach, describe, expect, it } from 'vitest';
12
+ import en from '../../../../../../../apps/finance/messages/en.json';
13
+ import vi from '../../../../../../../apps/finance/messages/vi.json';
14
+ import { AttendanceCalendar } from './attendance-calendar';
15
+
16
+ type Props = Parameters<typeof AttendanceCalendar>[0];
17
+ type Occurrence = Pick<
18
+ Database['public']['Tables']['user_group_attendance']['Row'],
19
+ 'id' | 'session_id' | 'user_id' | 'group_id' | 'date' | 'status'
20
+ >;
21
+ type Summary = 'ABSENT' | 'LATE' | 'PRESENT' | 'UNKNOWN';
22
+ const date = '2026-10-08';
23
+ const groupId = '10000000-0000-4000-8000-000000000001';
24
+ const otherGroupId = '10000000-0000-4000-8000-000000000002';
25
+ const sessions: Props['sessions'] = [{ date, groupId, groupName: 'Class A' }];
26
+ const colors = { ABSENT: 'red', LATE: 'yellow', PRESENT: 'green' };
27
+ const keys = {
28
+ ABSENT: 'absent',
29
+ LATE: 'late',
30
+ PRESENT: 'present',
31
+ UNKNOWN: 'no_attendance',
32
+ } as const;
33
+
34
+ function attendance(statuses: string[]): Props['userAttendance'] {
35
+ const occurrences: Occurrence[] = statuses.map((status, index) => ({
36
+ id: `20000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
37
+ session_id: `30000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
38
+ user_id: '40000000-0000-4000-8000-000000000001',
39
+ group_id: groupId,
40
+ date,
41
+ status,
42
+ }));
43
+ return occurrences.map(({ date, status, group_id }) => ({
44
+ date,
45
+ status,
46
+ group_id,
47
+ }));
48
+ }
49
+
50
+ function mount(
51
+ locale: string,
52
+ messages: typeof en | typeof vi,
53
+ overrides: Partial<Props> = {}
54
+ ) {
55
+ const props: Props = {
56
+ userAttendance: [],
57
+ selectedMonth: '2026-10',
58
+ sessions,
59
+ locale,
60
+ ...overrides,
61
+ };
62
+ const before = JSON.stringify(props);
63
+ props.userAttendance.forEach(Object.freeze);
64
+ props.sessions.forEach(Object.freeze);
65
+ Object.freeze(props.userAttendance);
66
+ Object.freeze(props.sessions);
67
+ const view = render(
68
+ <NextIntlClientProvider locale={locale} messages={messages} timeZone="UTC">
69
+ <TooltipProvider delayDuration={0} skipDelayDuration={0}>
70
+ <AttendanceCalendar {...props} />
71
+ </TooltipProvider>
72
+ </NextIntlClientProvider>
73
+ );
74
+ const grids = view.container.querySelectorAll('.grid-cols-7');
75
+ expect(grids).toHaveLength(2);
76
+ expect(grids[0]?.children).toHaveLength(7);
77
+ expect(grids[1]?.children).toHaveLength(42);
78
+ const cell = Array.from(grids[1]?.children ?? []).find(
79
+ (child) => child.textContent === '8' && child.hasAttribute('data-slot')
80
+ );
81
+ if (!(cell instanceof HTMLElement))
82
+ throw new Error('Current-month October 8 is missing');
83
+ return {
84
+ ...view,
85
+ cell,
86
+ grid: grids[1]!,
87
+ unchanged: () => expect(JSON.stringify(props)).toBe(before),
88
+ };
89
+ }
90
+
91
+ async function hover(cell: HTMLElement) {
92
+ fireEvent.pointerMove(cell, { pointerType: 'mouse', clientX: 1, clientY: 1 });
93
+ return waitFor(() => {
94
+ const content = document.querySelector('[data-slot="tooltip-content"]');
95
+ expect(content).toBeVisible();
96
+ if (!(content instanceof HTMLElement))
97
+ throw new Error('Real tooltip portal is missing');
98
+ return content;
99
+ });
100
+ }
101
+
102
+ function groupStatus(content: HTMLElement, groupName: string) {
103
+ // Accept the visible portal itself; exclude any nested accessibility copy.
104
+ const label = Array.from(content.querySelectorAll('span')).find(
105
+ (span) =>
106
+ span.textContent === `${groupName}:` &&
107
+ (!span.closest('[role="tooltip"]') ||
108
+ span.closest('[role="tooltip"]') === content)
109
+ );
110
+ if (!label?.parentElement)
111
+ throw new Error(`Visible tooltip group ${groupName} is missing`);
112
+ return label.parentElement;
113
+ }
114
+
115
+ function assertSummary(
116
+ cell: HTMLElement,
117
+ line: HTMLElement,
118
+ messages: typeof en | typeof vi,
119
+ status: Summary
120
+ ) {
121
+ const label = messages['ws-invoices'][keys[status]];
122
+ expect.soft(line.lastElementChild).toHaveTextContent(label);
123
+ expect
124
+ .soft(line.lastElementChild)
125
+ .toHaveClass(
126
+ status === 'UNKNOWN'
127
+ ? 'text-muted-foreground'
128
+ : `text-dynamic-${colors[status]}`
129
+ );
130
+ if (status === 'UNKNOWN') {
131
+ expect
132
+ .soft(cell)
133
+ .toHaveClass('text-foreground/50', 'bg-muted/50', 'border-foreground/20');
134
+ } else {
135
+ const color = colors[status];
136
+ expect
137
+ .soft(cell)
138
+ .toHaveClass(
139
+ `text-dynamic-${color}`,
140
+ `bg-dynamic-${color}/10`,
141
+ `border-dynamic-${color}/20`
142
+ );
143
+ }
144
+ }
145
+
146
+ afterEach(cleanup);
147
+
148
+ const cases: [string, string[], Summary][] = [
149
+ ['forward PRESENT ABSENT control', ['PRESENT', 'ABSENT'], 'ABSENT'],
150
+ ['reverse ABSENT PRESENT causal regression', ['ABSENT', 'PRESENT'], 'ABSENT'],
151
+ ['PRESENT LATE', ['PRESENT', 'LATE'], 'LATE'],
152
+ ['LATE PRESENT', ['LATE', 'PRESENT'], 'LATE'],
153
+ ['ABSENT LATE', ['ABSENT', 'LATE'], 'ABSENT'],
154
+ ['LATE ABSENT', ['LATE', 'ABSENT'], 'ABSENT'],
155
+ ['triple P L A', ['PRESENT', 'LATE', 'ABSENT'], 'ABSENT'],
156
+ ['triple P A L', ['PRESENT', 'ABSENT', 'LATE'], 'ABSENT'],
157
+ ['triple L P A', ['LATE', 'PRESENT', 'ABSENT'], 'ABSENT'],
158
+ ['triple L A P', ['LATE', 'ABSENT', 'PRESENT'], 'ABSENT'],
159
+ ['triple A P L', ['ABSENT', 'PRESENT', 'LATE'], 'ABSENT'],
160
+ ['triple A L P', ['ABSENT', 'LATE', 'PRESENT'], 'ABSENT'],
161
+ ['single present', ['PRESENT'], 'PRESENT'],
162
+ ['single late', ['LATE'], 'LATE'],
163
+ ['single absent', ['ABSENT'], 'ABSENT'],
164
+ ['duplicate absent', ['ABSENT', 'ABSENT'], 'ABSENT'],
165
+ ['lowercase normalization', ['present', 'absent'], 'ABSENT'],
166
+ ['unknown then known', ['EXCUSED', 'PRESENT'], 'PRESENT'],
167
+ ['known then unknown', ['PRESENT', 'EXCUSED'], 'PRESENT'],
168
+ ['unknown then late', ['EXCUSED', 'LATE'], 'LATE'],
169
+ ['late then unknown', ['LATE', 'EXCUSED'], 'LATE'],
170
+ ['unknown then absent', ['EXCUSED', 'ABSENT'], 'ABSENT'],
171
+ ['absent then unknown', ['ABSENT', 'EXCUSED'], 'ABSENT'],
172
+ ['unknown only', ['EXCUSED'], 'UNKNOWN'],
173
+ ['empty status compatibility', [''], 'PRESENT'],
174
+ ['scheduled without attendance', [], 'UNKNOWN'],
175
+ ];
176
+
177
+ describe.each([
178
+ ['en', en],
179
+ ['vi', vi],
180
+ ] as const)('AttendanceCalendar %s', (locale, messages) => {
181
+ it.each(cases)('%s', async (_name, statuses, expected) => {
182
+ const view = mount(locale, messages, {
183
+ userAttendance: attendance(statuses),
184
+ });
185
+ const content = await hover(view.cell);
186
+ assertSummary(
187
+ view.cell,
188
+ groupStatus(content, 'Class A'),
189
+ messages,
190
+ expected
191
+ );
192
+ view.unchanged();
193
+ });
194
+
195
+ it('retains null runtime status fallback', async () => {
196
+ const rows = attendance(['']);
197
+ // Props declare string, but the existing optional-chain fallback accepts null at runtime.
198
+ Reflect.set(rows[0]!, 'status', null);
199
+ const view = mount(locale, messages, { userAttendance: rows });
200
+ const content = await hover(view.cell);
201
+ assertSummary(
202
+ view.cell,
203
+ groupStatus(content, 'Class A'),
204
+ messages,
205
+ 'PRESENT'
206
+ );
207
+ view.unchanged();
208
+ });
209
+
210
+ it.each([false, true])(
211
+ 'keeps separate group statuses, reverse=%s',
212
+ async (reverse) => {
213
+ const rows = attendance(['PRESENT', 'ABSENT']);
214
+ rows[1]!.group_id = otherGroupId;
215
+ const view = mount(locale, messages, {
216
+ userAttendance: reverse ? rows.reverse() : rows,
217
+ sessions: [
218
+ ...sessions,
219
+ { date, groupId: otherGroupId, groupName: 'Class B' },
220
+ ],
221
+ });
222
+ const content = await hover(view.cell);
223
+ expect(
224
+ within(groupStatus(content, 'Class A')).getByText(
225
+ messages['ws-invoices'].present
226
+ )
227
+ ).toHaveClass('text-dynamic-green');
228
+ assertSummary(
229
+ view.cell,
230
+ groupStatus(content, 'Class B'),
231
+ messages,
232
+ 'ABSENT'
233
+ );
234
+ view.unchanged();
235
+ }
236
+ );
237
+
238
+ it('preserves default group matching when group_id is omitted', async () => {
239
+ const rows = attendance(['PRESENT']);
240
+ delete rows[0]!.group_id;
241
+ const view = mount(locale, messages, {
242
+ userAttendance: rows,
243
+ sessions: [{ date, groupId: 'default', groupName: 'Class A' }],
244
+ });
245
+ const content = await hover(view.cell);
246
+ assertSummary(
247
+ view.cell,
248
+ groupStatus(content, 'Class A'),
249
+ messages,
250
+ 'PRESENT'
251
+ );
252
+ view.unchanged();
253
+ });
254
+
255
+ it('keeps unscheduled attendance muted with no-session tooltip', async () => {
256
+ const view = mount(locale, messages, {
257
+ userAttendance: attendance(['ABSENT']),
258
+ sessions: [],
259
+ });
260
+ const content = await hover(view.cell);
261
+ expect(view.cell).toHaveClass(
262
+ 'text-foreground/30',
263
+ 'bg-muted/30',
264
+ 'border-transparent'
265
+ );
266
+ expect(
267
+ Array.from(content.querySelectorAll('p')).find(
268
+ (p) =>
269
+ p.textContent === messages['ws-invoices'].no_session &&
270
+ (!p.closest('[role="tooltip"]') ||
271
+ p.closest('[role="tooltip"]') === content)
272
+ )
273
+ ).toBeVisible();
274
+ view.unchanged();
275
+ });
276
+
277
+ it('keeps adjacent months muted and maintains the Monday-first 42-day grid on navigation', () => {
278
+ const view = mount(locale, messages);
279
+ const first = view.grid.children[0];
280
+ expect(first).toHaveTextContent('28');
281
+ expect(first).toHaveClass(
282
+ 'text-foreground/20',
283
+ 'bg-transparent',
284
+ 'border-transparent'
285
+ );
286
+ expect(first).not.toHaveAttribute('data-slot');
287
+ view.unchanged();
288
+ view.unmount();
289
+ const next = mount(locale, messages, { selectedMonth: '2026-11' });
290
+ expect(next.grid.children[0]).toHaveTextContent('26');
291
+ expect(next.grid.children[41]).toHaveTextContent('6');
292
+ next.unchanged();
293
+ });
294
+ });
@@ -41,12 +41,20 @@ export function AttendanceCalendar({
41
41
  if (!map.has(dateKey)) {
42
42
  map.set(dateKey, new Map());
43
43
  }
44
- map
45
- .get(dateKey)!
46
- .set(
47
- attendance.group_id || 'default',
48
- attendance.status?.toUpperCase() || 'PRESENT'
49
- );
44
+ const groupStatuses = map.get(dateKey)!;
45
+ const groupId = attendance.group_id || 'default';
46
+ const status = attendance.status?.toUpperCase() || 'PRESENT';
47
+ const priority = (value: string | undefined) =>
48
+ value === 'ABSENT'
49
+ ? 3
50
+ : value === 'LATE'
51
+ ? 2
52
+ : value === 'PRESENT'
53
+ ? 1
54
+ : 0;
55
+ if (priority(status) >= priority(groupStatuses.get(groupId))) {
56
+ groupStatuses.set(groupId, status);
57
+ }
50
58
  });
51
59
  return map;
52
60
  }, [userAttendance]);