@tuturuuu/ui 0.32.0 → 0.34.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 (104) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/biome.json +1 -1
  3. package/package.json +53 -51
  4. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
  5. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
  6. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
  7. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
  8. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
  9. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
  10. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  11. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  12. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  13. package/src/components/ui/commercial-comparison-style.ts +104 -0
  14. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  15. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  16. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  17. package/src/components/ui/commercial-comparison.tsx +224 -0
  18. package/src/components/ui/comparison-value.test.tsx +32 -0
  19. package/src/components/ui/comparison-value.tsx +72 -0
  20. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  21. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  22. package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
  23. package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
  24. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  25. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  26. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  27. package/src/components/ui/custom/report-preview.tsx +2 -3
  28. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  29. package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
  30. package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
  31. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  32. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  33. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  34. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  35. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
  36. package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
  37. package/src/components/ui/finance/invoices/export-data.ts +229 -0
  38. package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
  39. package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
  40. package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
  41. package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
  42. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  43. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  44. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
  45. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
  46. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
  47. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
  48. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  49. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  50. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  51. package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
  52. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  53. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  54. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  55. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  56. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  57. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  58. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  59. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  60. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  61. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  62. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  63. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  64. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  65. package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
  66. package/src/components/ui/finance/invoices/query-invalidation.ts +3 -0
  67. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  68. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  69. package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
  70. package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
  71. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  72. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  73. package/src/components/ui/finance/invoices/subscription-invoice.tsx +47 -51
  74. package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
  75. package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
  76. package/src/components/ui/finance/invoices/utils.ts +48 -106
  77. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  78. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  79. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  80. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  81. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  82. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  83. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  84. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  85. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  86. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
  87. package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
  88. package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
  89. package/src/components/ui/legacy/calendar/working-location.ts +35 -0
  90. package/src/components/ui/public-workspace-prices.tsx +13 -0
  91. package/src/components/ui/pwa-status.test.tsx +13 -2
  92. package/src/components/ui/pwa-status.tsx +15 -3
  93. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  94. package/src/components/ui/text-editor/editor.tsx +11 -3
  95. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  96. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  97. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  98. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  99. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  100. package/src/hooks/calendar-provider-sync.ts +33 -0
  101. package/src/hooks/notification-subscription.ts +74 -0
  102. package/src/hooks/use-calendar-sync.tsx +11 -14
  103. package/src/hooks/use-calendar.tsx +2 -2
  104. package/src/hooks/use-notifications.ts +5 -141
@@ -3,7 +3,8 @@
3
3
  import type { NodeViewProps } from '@tiptap/react';
4
4
  import { NodeViewContent } from '@tiptap/react';
5
5
  import { cn } from '@tuturuuu/utils/format';
6
- import { useCallback, useMemo, useState } from 'react';
6
+ import { useTranslations } from 'next-intl';
7
+ import { useCallback, useMemo, useSyncExternalStore } from 'react';
7
8
  import { DraggableNodeContainer } from './draggable-node-container';
8
9
  import {
9
10
  areAllMentionedTasksCompleted,
@@ -14,16 +15,31 @@ import {
14
15
  type TriStateChecked,
15
16
  useMentionedTaskStatuses,
16
17
  } from './task-item-checkbox';
18
+ import { TaskItemStatusPicker } from './task-item-status-picker';
17
19
 
18
20
  export function TaskItemCheckboxContent({
19
21
  node,
20
22
  getPos,
21
23
  editor,
22
24
  }: NodeViewProps) {
23
- const [manualOverride, setManualOverride] = useState<TriStateChecked | null>(
24
- null
25
+ const t = useTranslations('common.checklist');
26
+ const subscribe = useCallback(
27
+ (notify: () => void) => {
28
+ // setEditable emits update without changing the document/node view.
29
+ editor.on('update', notify);
30
+ editor.on('transaction', notify);
31
+ return () => {
32
+ editor.off('update', notify);
33
+ editor.off('transaction', notify);
34
+ };
35
+ },
36
+ [editor]
37
+ );
38
+ const isEditable = useSyncExternalStore(
39
+ subscribe,
40
+ () => editor.isEditable,
41
+ () => false
25
42
  );
26
-
27
43
  const taskMentionIds = useMemo(() => {
28
44
  return extractTaskMentionIds(node);
29
45
  }, [node]);
@@ -40,187 +56,185 @@ export function TaskItemCheckboxContent({
40
56
 
41
57
  const checkboxState = useMemo((): TriStateChecked => {
42
58
  return resolveCheckboxState({
43
- manualOverride,
59
+ manualOverride: null,
44
60
  nodeChecked: node.attrs.checked,
45
61
  allMentionedTasksCompleted,
46
62
  });
47
- }, [manualOverride, node.attrs.checked, allMentionedTasksCompleted]);
63
+ }, [node.attrs.checked, allMentionedTasksCompleted]);
48
64
 
49
65
  const isChecked = checkboxState === true;
50
66
  const isIndeterminate = checkboxState === 'indeterminate';
51
67
 
52
- const handleCheckboxCycle = useCallback(() => {
53
- if (!editor.isEditable) return;
54
-
55
- const pos = getPos();
56
- if (typeof pos !== 'number') return;
57
-
58
- const nextState = getNextTriState(checkboxState);
59
-
60
- setManualOverride(nextState);
61
-
62
- editor.commands.command(({ tr }) => {
63
- tr.setNodeMarkup(pos, undefined, {
64
- ...node.attrs,
65
- checked: nextState,
68
+ const setCheckboxState = useCallback(
69
+ (
70
+ checked: TriStateChecked | ((current: TriStateChecked) => TriStateChecked)
71
+ ) => {
72
+ if (!editor.isEditable) return;
73
+ const pos = getPos();
74
+ if (typeof pos !== 'number') return;
75
+ editor.commands.command(({ tr }) => {
76
+ const currentNode = tr.doc.nodeAt(pos);
77
+ if (currentNode?.type.name !== 'taskItem') return false;
78
+ // Node view props can lag behind a transaction (e.g. a picker click).
79
+ // Cycle from the document so rapid clicks never repeat a stale state.
80
+ const current = resolveCheckboxState({
81
+ manualOverride: null,
82
+ nodeChecked: currentNode.attrs.checked,
83
+ allMentionedTasksCompleted,
84
+ });
85
+ tr.setNodeMarkup(pos, undefined, {
86
+ ...currentNode.attrs,
87
+ checked: typeof checked === 'function' ? checked(current) : checked,
88
+ });
89
+ return true;
66
90
  });
67
- return true;
68
- });
69
- }, [editor, getPos, node.attrs, checkboxState]);
70
-
71
- const handleCheckboxPointerDown = useCallback(
72
- (event: React.PointerEvent<HTMLButtonElement>) => {
73
- if (!event.isPrimary || event.button !== 0) return;
74
- event.preventDefault();
75
- event.stopPropagation();
76
- handleCheckboxCycle();
77
- },
78
- [handleCheckboxCycle]
79
- );
80
-
81
- const handleCheckboxKeyDown = useCallback(
82
- (event: React.KeyboardEvent<HTMLButtonElement>) => {
83
- if (event.key !== 'Enter' && event.key !== ' ') return;
84
- event.preventDefault();
85
- event.stopPropagation();
86
- handleCheckboxCycle();
87
91
  },
88
- [handleCheckboxCycle]
92
+ [editor, getPos, allMentionedTasksCompleted]
89
93
  );
90
94
 
91
95
  return (
92
- <div className="flex items-start gap-2">
96
+ <div className="flex min-w-0 flex-1 items-start gap-2">
93
97
  <div
94
98
  className="task-list-checkbox-label flex shrink-0 select-none pt-2.5"
95
99
  contentEditable={false}
96
100
  >
97
- {/* biome-ignore lint/a11y/useSemanticElements: a button avoids native checkbox state changes while ProseMirror owns the tri-state document update */}
98
- <button
99
- type="button"
100
- role="checkbox"
101
- aria-label="Task item status"
102
- aria-checked={isIndeterminate ? 'mixed' : isChecked}
103
- aria-disabled={!editor.isEditable}
104
- onPointerDown={handleCheckboxPointerDown}
105
- onKeyDown={handleCheckboxKeyDown}
106
- className={cn(
107
- 'task-list-checkbox flex size-5 items-center justify-center',
108
- 'cursor-pointer rounded-md border-2 bg-background',
109
- 'transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:scale-95',
110
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-2',
111
- !editor.isEditable && 'cursor-default opacity-70',
112
- !completedTaskColor && [
113
- 'border-input',
114
- 'hover:scale-105 hover:border-dynamic-gray hover:bg-dynamic-gray/10',
115
- 'focus-visible:border-dynamic-gray',
116
- isChecked &&
117
- 'border-dynamic-green bg-dynamic-green/20 hover:border-dynamic-green hover:bg-dynamic-green/10 focus-visible:border-dynamic-green focus-visible:ring-dynamic-green/30',
118
- isIndeterminate &&
119
- 'border-dynamic-orange bg-dynamic-orange/20 hover:border-dynamic-orange hover:bg-dynamic-orange/10 focus-visible:border-dynamic-orange focus-visible:ring-dynamic-orange/30',
120
- ],
121
- completedTaskColor === 'red' && [
122
- 'border-dynamic-red/50',
123
- 'hover:scale-105 hover:border-dynamic-red hover:bg-dynamic-red/10',
124
- 'focus:border-dynamic-red focus:outline-none focus:ring-2 focus:ring-dynamic-red/30 focus:ring-offset-2',
125
- (isChecked || isIndeterminate) &&
126
- 'border-dynamic-red bg-dynamic-red/20',
127
- ],
128
- completedTaskColor === 'orange' && [
129
- 'border-dynamic-orange/50',
130
- 'hover:scale-105 hover:border-dynamic-orange hover:bg-dynamic-orange/10',
131
- 'focus:border-dynamic-orange focus:outline-none focus:ring-2 focus:ring-dynamic-orange/30 focus:ring-offset-2',
132
- (isChecked || isIndeterminate) &&
133
- 'border-dynamic-orange bg-dynamic-orange/20',
134
- ],
135
- completedTaskColor === 'yellow' && [
136
- 'border-dynamic-yellow/50',
137
- 'hover:scale-105 hover:border-dynamic-yellow hover:bg-dynamic-yellow/10',
138
- 'focus:border-dynamic-yellow focus:outline-none focus:ring-2 focus:ring-dynamic-yellow/30 focus:ring-offset-2',
139
- (isChecked || isIndeterminate) &&
140
- 'border-dynamic-yellow bg-dynamic-yellow/20',
141
- ],
142
- completedTaskColor === 'green' && [
143
- 'border-dynamic-green/50',
144
- 'hover:scale-105 hover:border-dynamic-green hover:bg-dynamic-green/10',
145
- 'focus:border-dynamic-green focus:outline-none focus:ring-2 focus:ring-dynamic-green/30 focus:ring-offset-2',
146
- (isChecked || isIndeterminate) &&
147
- 'border-dynamic-green bg-dynamic-green/20',
148
- ],
149
- completedTaskColor === 'cyan' && [
150
- 'border-dynamic-cyan/50',
151
- 'hover:scale-105 hover:border-dynamic-cyan hover:bg-dynamic-cyan/10',
152
- 'focus:border-dynamic-cyan focus:outline-none focus:ring-2 focus:ring-dynamic-cyan/30 focus:ring-offset-2',
153
- (isChecked || isIndeterminate) &&
154
- 'border-dynamic-cyan bg-dynamic-cyan/20',
155
- ],
156
- completedTaskColor === 'blue' && [
157
- 'border-dynamic-blue/50',
158
- 'hover:scale-105 hover:border-dynamic-blue hover:bg-dynamic-blue/10',
159
- 'focus:border-dynamic-blue focus:outline-none focus:ring-2 focus:ring-dynamic-blue/30 focus:ring-offset-2',
160
- (isChecked || isIndeterminate) &&
161
- 'border-dynamic-blue bg-dynamic-blue/20',
162
- ],
163
- completedTaskColor === 'purple' && [
164
- 'border-dynamic-purple/50',
165
- 'hover:scale-105 hover:border-dynamic-purple hover:bg-dynamic-purple/10',
166
- 'focus:border-dynamic-purple focus:outline-none focus:ring-2 focus:ring-dynamic-purple/30 focus:ring-offset-2',
167
- (isChecked || isIndeterminate) &&
168
- 'border-dynamic-purple bg-dynamic-purple/20',
169
- ],
170
- completedTaskColor === 'pink' && [
171
- 'border-dynamic-pink/50',
172
- 'hover:scale-105 hover:border-dynamic-pink hover:bg-dynamic-pink/10',
173
- 'focus:border-dynamic-pink focus:outline-none focus:ring-2 focus:ring-dynamic-pink/30 focus:ring-offset-2',
174
- (isChecked || isIndeterminate) &&
175
- 'border-dynamic-pink bg-dynamic-pink/20',
176
- ]
177
- )}
101
+ <TaskItemStatusPicker
102
+ state={checkboxState}
103
+ disabled={!isEditable}
104
+ onSelect={setCheckboxState}
178
105
  >
179
- {isChecked && (
180
- <svg
181
- className={cn(
182
- 'h-3.5 w-3.5',
183
- completedTaskColor ? 'text-foreground' : 'text-dynamic-green'
184
- )}
185
- viewBox="0 0 16 16"
186
- fill="none"
187
- xmlns="http://www.w3.org/2000/svg"
188
- aria-hidden="true"
189
- >
190
- <path
191
- d="M4 8l2.5 2.5L12 5"
192
- stroke="currentColor"
193
- strokeWidth="2"
194
- strokeLinecap="round"
195
- strokeLinejoin="round"
196
- />
197
- </svg>
198
- )}
199
- {isIndeterminate && (
200
- <svg
201
- className={cn(
202
- 'h-3.5 w-3.5',
203
- completedTaskColor ? 'text-foreground' : 'text-dynamic-orange'
204
- )}
205
- viewBox="0 0 16 16"
206
- fill="none"
207
- xmlns="http://www.w3.org/2000/svg"
208
- aria-hidden="true"
209
- >
210
- <path
211
- d="M4 8h8"
212
- stroke="currentColor"
213
- strokeWidth="2"
214
- strokeLinecap="round"
215
- />
216
- </svg>
217
- )}
218
- </button>
106
+ {/* biome-ignore lint/a11y/useSemanticElements: a button avoids native checkbox state changes while ProseMirror owns the tri-state document update */}
107
+ <button
108
+ type="button"
109
+ role="checkbox"
110
+ aria-label={t('status')}
111
+ aria-keyshortcuts="ArrowDown"
112
+ aria-checked={isIndeterminate ? 'mixed' : isChecked}
113
+ aria-disabled={!isEditable}
114
+ onMouseDown={(event) => event.preventDefault()}
115
+ onClick={(event) => {
116
+ event.stopPropagation();
117
+ setCheckboxState(getNextTriState);
118
+ }}
119
+ className={cn(
120
+ 'task-list-checkbox flex size-5 items-center justify-center',
121
+ 'cursor-pointer rounded-md border-2 bg-background',
122
+ 'transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:scale-95',
123
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-2',
124
+ !isEditable && 'cursor-default opacity-70',
125
+ !completedTaskColor && [
126
+ 'border-muted-foreground/50',
127
+ 'hover:scale-105 hover:border-dynamic-gray hover:bg-dynamic-gray/10',
128
+ 'focus-visible:border-dynamic-gray',
129
+ isChecked &&
130
+ 'border-dynamic-green bg-dynamic-green/20 hover:border-dynamic-green hover:bg-dynamic-green/10 focus-visible:border-dynamic-green focus-visible:ring-dynamic-green/30',
131
+ isIndeterminate &&
132
+ 'border-dynamic-orange bg-dynamic-orange/20 hover:border-dynamic-orange hover:bg-dynamic-orange/10 focus-visible:border-dynamic-orange focus-visible:ring-dynamic-orange/30',
133
+ ],
134
+ completedTaskColor === 'red' && [
135
+ 'border-dynamic-red/50',
136
+ 'hover:scale-105 hover:border-dynamic-red hover:bg-dynamic-red/10',
137
+ 'focus:border-dynamic-red focus:outline-none focus:ring-2 focus:ring-dynamic-red/30 focus:ring-offset-2',
138
+ (isChecked || isIndeterminate) &&
139
+ 'border-dynamic-red bg-dynamic-red/20',
140
+ ],
141
+ completedTaskColor === 'orange' && [
142
+ 'border-dynamic-orange/50',
143
+ 'hover:scale-105 hover:border-dynamic-orange hover:bg-dynamic-orange/10',
144
+ 'focus:border-dynamic-orange focus:outline-none focus:ring-2 focus:ring-dynamic-orange/30 focus:ring-offset-2',
145
+ (isChecked || isIndeterminate) &&
146
+ 'border-dynamic-orange bg-dynamic-orange/20',
147
+ ],
148
+ completedTaskColor === 'yellow' && [
149
+ 'border-dynamic-yellow/50',
150
+ 'hover:scale-105 hover:border-dynamic-yellow hover:bg-dynamic-yellow/10',
151
+ 'focus:border-dynamic-yellow focus:outline-none focus:ring-2 focus:ring-dynamic-yellow/30 focus:ring-offset-2',
152
+ (isChecked || isIndeterminate) &&
153
+ 'border-dynamic-yellow bg-dynamic-yellow/20',
154
+ ],
155
+ completedTaskColor === 'green' && [
156
+ 'border-dynamic-green/50',
157
+ 'hover:scale-105 hover:border-dynamic-green hover:bg-dynamic-green/10',
158
+ 'focus:border-dynamic-green focus:outline-none focus:ring-2 focus:ring-dynamic-green/30 focus:ring-offset-2',
159
+ (isChecked || isIndeterminate) &&
160
+ 'border-dynamic-green bg-dynamic-green/20',
161
+ ],
162
+ completedTaskColor === 'cyan' && [
163
+ 'border-dynamic-cyan/50',
164
+ 'hover:scale-105 hover:border-dynamic-cyan hover:bg-dynamic-cyan/10',
165
+ 'focus:border-dynamic-cyan focus:outline-none focus:ring-2 focus:ring-dynamic-cyan/30 focus:ring-offset-2',
166
+ (isChecked || isIndeterminate) &&
167
+ 'border-dynamic-cyan bg-dynamic-cyan/20',
168
+ ],
169
+ completedTaskColor === 'blue' && [
170
+ 'border-dynamic-blue/50',
171
+ 'hover:scale-105 hover:border-dynamic-blue hover:bg-dynamic-blue/10',
172
+ 'focus:border-dynamic-blue focus:outline-none focus:ring-2 focus:ring-dynamic-blue/30 focus:ring-offset-2',
173
+ (isChecked || isIndeterminate) &&
174
+ 'border-dynamic-blue bg-dynamic-blue/20',
175
+ ],
176
+ completedTaskColor === 'purple' && [
177
+ 'border-dynamic-purple/50',
178
+ 'hover:scale-105 hover:border-dynamic-purple hover:bg-dynamic-purple/10',
179
+ 'focus:border-dynamic-purple focus:outline-none focus:ring-2 focus:ring-dynamic-purple/30 focus:ring-offset-2',
180
+ (isChecked || isIndeterminate) &&
181
+ 'border-dynamic-purple bg-dynamic-purple/20',
182
+ ],
183
+ completedTaskColor === 'pink' && [
184
+ 'border-dynamic-pink/50',
185
+ 'hover:scale-105 hover:border-dynamic-pink hover:bg-dynamic-pink/10',
186
+ 'focus:border-dynamic-pink focus:outline-none focus:ring-2 focus:ring-dynamic-pink/30 focus:ring-offset-2',
187
+ (isChecked || isIndeterminate) &&
188
+ 'border-dynamic-pink bg-dynamic-pink/20',
189
+ ]
190
+ )}
191
+ >
192
+ {isChecked && (
193
+ <svg
194
+ className={cn(
195
+ 'h-3.5 w-3.5',
196
+ completedTaskColor ? 'text-foreground' : 'text-dynamic-green'
197
+ )}
198
+ viewBox="0 0 16 16"
199
+ fill="none"
200
+ xmlns="http://www.w3.org/2000/svg"
201
+ aria-hidden="true"
202
+ >
203
+ <path
204
+ d="M4 8l2.5 2.5L12 5"
205
+ stroke="currentColor"
206
+ strokeWidth="2"
207
+ strokeLinecap="round"
208
+ strokeLinejoin="round"
209
+ />
210
+ </svg>
211
+ )}
212
+ {isIndeterminate && (
213
+ <svg
214
+ className={cn(
215
+ 'h-3.5 w-3.5',
216
+ completedTaskColor ? 'text-foreground' : 'text-dynamic-orange'
217
+ )}
218
+ viewBox="0 0 16 16"
219
+ fill="none"
220
+ xmlns="http://www.w3.org/2000/svg"
221
+ aria-hidden="true"
222
+ >
223
+ <path
224
+ d="M4 8h8"
225
+ stroke="currentColor"
226
+ strokeWidth="2"
227
+ strokeLinecap="round"
228
+ />
229
+ </svg>
230
+ )}
231
+ </button>
232
+ </TaskItemStatusPicker>
219
233
  </div>
220
234
 
221
235
  <NodeViewContent
222
236
  className={cn(
223
- 'transition-opacity duration-150',
237
+ 'min-w-0 flex-1 cursor-text caret-foreground transition-opacity duration-150',
224
238
  isChecked && 'task-content-faded'
225
239
  )}
226
240
  />
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import { Check, Minus, Square } from '@tuturuuu/icons';
4
+ import { useTranslations } from 'next-intl';
5
+ import { type ReactNode, useEffect, useRef, useState } from 'react';
6
+ import { Popover, PopoverAnchor, PopoverContent } from '../popover';
7
+ import type { TriStateChecked } from './task-item-checkbox';
8
+
9
+ export function TaskItemStatusPicker({
10
+ children,
11
+ state,
12
+ disabled,
13
+ onSelect,
14
+ }: {
15
+ children: ReactNode;
16
+ state: TriStateChecked;
17
+ disabled: boolean;
18
+ onSelect: (state: TriStateChecked) => void;
19
+ }) {
20
+ const t = useTranslations('common.checklist');
21
+ const [open, setOpen] = useState(false);
22
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
23
+ const keyboardOpen = useRef(false);
24
+ const anchor = useRef<HTMLDivElement>(null);
25
+ const cancelTimer = () => {
26
+ if (timer.current) clearTimeout(timer.current);
27
+ timer.current = null;
28
+ };
29
+ useEffect(
30
+ () => () => {
31
+ if (timer.current) clearTimeout(timer.current);
32
+ },
33
+ []
34
+ );
35
+ useEffect(() => {
36
+ if (!disabled) return;
37
+ if (timer.current) clearTimeout(timer.current);
38
+ timer.current = null;
39
+ keyboardOpen.current = false;
40
+ setOpen(false);
41
+ }, [disabled]);
42
+ const closeSoon = () => {
43
+ cancelTimer();
44
+ if (keyboardOpen.current) return;
45
+ timer.current = setTimeout(() => setOpen(false), 200);
46
+ };
47
+ const options = [
48
+ { value: false, label: t('unchecked'), Icon: Square },
49
+ { value: 'indeterminate', label: t('in_progress'), Icon: Minus },
50
+ { value: true, label: t('completed'), Icon: Check },
51
+ ] as const;
52
+
53
+ return (
54
+ <Popover open={open && !disabled} onOpenChange={setOpen}>
55
+ <PopoverAnchor asChild>
56
+ <div
57
+ ref={anchor}
58
+ onPointerEnter={(event) => {
59
+ if (disabled || event.pointerType === 'touch') return;
60
+ cancelTimer();
61
+ keyboardOpen.current = false;
62
+ timer.current = setTimeout(() => setOpen(true), 500);
63
+ }}
64
+ onContextMenu={(event) => {
65
+ if (disabled) return;
66
+ event.preventDefault();
67
+ cancelTimer();
68
+ keyboardOpen.current = true;
69
+ setOpen(true);
70
+ }}
71
+ onPointerLeave={closeSoon}
72
+ onKeyDown={(event) => {
73
+ if (disabled || event.key !== 'ArrowDown') return;
74
+ event.preventDefault();
75
+ event.stopPropagation();
76
+ cancelTimer();
77
+ keyboardOpen.current = true;
78
+ setOpen(true);
79
+ }}
80
+ >
81
+ {children}
82
+ </div>
83
+ </PopoverAnchor>
84
+ <PopoverContent
85
+ side="top"
86
+ align="start"
87
+ sideOffset={6}
88
+ className="w-auto max-w-[calc(100vw-2rem)] p-1"
89
+ contentEditable={false}
90
+ aria-label={t('status')}
91
+ onPointerEnter={cancelTimer}
92
+ onPointerLeave={closeSoon}
93
+ onFocusCapture={cancelTimer}
94
+ onInteractOutside={() => {
95
+ keyboardOpen.current = false;
96
+ cancelTimer();
97
+ }}
98
+ onOpenAutoFocus={(event) => {
99
+ if (!keyboardOpen.current) event.preventDefault();
100
+ }}
101
+ onCloseAutoFocus={(event) => {
102
+ event.preventDefault();
103
+ if (keyboardOpen.current)
104
+ anchor.current?.querySelector('button')?.focus();
105
+ }}
106
+ >
107
+ <div className="flex items-center gap-0.5">
108
+ {options.map(({ value, label, Icon }) => (
109
+ <button
110
+ key={String(value)}
111
+ type="button"
112
+ aria-label={label}
113
+ aria-pressed={state === value}
114
+ className="flex min-h-9 items-center gap-1.5 rounded-sm px-2 text-muted-foreground text-xs hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-accent aria-pressed:text-accent-foreground"
115
+ onMouseDown={(event) => event.preventDefault()}
116
+ onClick={() => {
117
+ onSelect(value);
118
+ cancelTimer();
119
+ setOpen(false);
120
+ }}
121
+ >
122
+ <Icon className="size-3.5 shrink-0" />
123
+ {label}
124
+ </button>
125
+ ))}
126
+ </div>
127
+ </PopoverContent>
128
+ </Popover>
129
+ );
130
+ }
@@ -50,7 +50,7 @@ function renderCalendarSync({
50
50
  </QueryClientProvider>
51
51
  );
52
52
 
53
- return renderHook(() => useCalendarSync(), { wrapper });
53
+ return { ...renderHook(() => useCalendarSync(), { wrapper }), queryClient };
54
54
  }
55
55
 
56
56
  function mockCalendarFetch(
@@ -570,3 +570,22 @@ describe('CalendarSyncProvider optimistic visible events', () => {
570
570
  });
571
571
  });
572
572
  });
573
+
574
+ it('refetches invalidated event ranges even while the local range cache is fresh', async () => {
575
+ const fetchMock = mockCalendarFetch([createEvent('before')]);
576
+ vi.stubGlobal('fetch', fetchMock);
577
+ const { result, queryClient } = renderCalendarSync();
578
+ act(() => result.current.setDates(createWeekDates('2026-06-22')));
579
+ await waitFor(() =>
580
+ expect(result.current.events.map((event) => event.id)).toEqual(['before'])
581
+ );
582
+ fetchMock.mockImplementation(mockCalendarFetch([createEvent('after')]));
583
+ await act(() =>
584
+ queryClient.invalidateQueries({
585
+ queryKey: ['databaseCalendarEvents', 'workspace-1'],
586
+ })
587
+ );
588
+ await waitFor(() =>
589
+ expect(result.current.events.map((event) => event.id)).toEqual(['after'])
590
+ );
591
+ });