@tuturuuu/ui 0.33.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 (30) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/package.json +5 -5
  3. package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
  4. package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
  5. package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
  6. package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
  7. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
  8. package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
  9. package/src/components/ui/finance/invoices/export-data.ts +229 -0
  10. package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
  11. package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
  12. package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
  13. package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
  14. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
  15. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
  16. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
  17. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
  18. package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
  19. package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
  20. package/src/components/ui/finance/invoices/query-invalidation.ts +2 -0
  21. package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
  22. package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
  23. package/src/components/ui/finance/invoices/subscription-invoice.tsx +3 -6
  24. package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
  25. package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
  26. package/src/components/ui/finance/invoices/utils.ts +48 -106
  27. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
  28. package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
  29. package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
  30. package/src/components/ui/legacy/calendar/working-location.ts +35 -0
@@ -1,3 +1,18 @@
1
+ import {
2
+ getSubscriptionCoverageInvoiceForGroup,
3
+ isSubscriptionMonthPaidForGroup,
4
+ type SubscriptionCoverageInvoice,
5
+ } from './subscription-coverage';
6
+
7
+ export {
8
+ getSubscriptionCoverageInvoiceForGroup,
9
+ isSubscriptionMonthCoveredByInvoice,
10
+ isSubscriptionMonthPaidForGroup,
11
+ isSubscriptionRangeFullyPaidForGroups,
12
+ isSubscriptionRangePaidForGroup,
13
+ type SubscriptionCoverageInvoice,
14
+ } from './subscription-coverage';
15
+
1
16
  import type { Database } from '@tuturuuu/types';
2
17
  import {
3
18
  formatMonthLabel,
@@ -48,12 +63,6 @@ export type UserGroup = {
48
63
  workspace_user_groups: WorkspaceUserGroup | null;
49
64
  };
50
65
 
51
- export type SubscriptionCoverageInvoice = {
52
- created_at?: string | null;
53
- group_id?: string;
54
- valid_until?: string | null;
55
- };
56
-
57
66
  export type BillableSession = {
58
67
  date: string;
59
68
  groupId: string;
@@ -114,99 +123,6 @@ export const resolveSubscriptionInvoiceCategoryId = ({
114
123
  return defaultCategoryId ?? '';
115
124
  };
116
125
 
117
- const getComparableTimestamp = (value: string | null | undefined): number => {
118
- if (!value) return 0;
119
- const timestamp = new Date(value).getTime();
120
- return Number.isNaN(timestamp) ? 0 : timestamp;
121
- };
122
-
123
- const getComparableValidUntilTimestamp = (
124
- invoice: SubscriptionCoverageInvoice
125
- ): number => {
126
- const validUntil = parseLocalCalendarDate(invoice.valid_until);
127
- return Number.isNaN(validUntil.getTime()) ? 0 : validUntil.getTime();
128
- };
129
-
130
- export const getSubscriptionCoverageInvoiceForGroup = (
131
- latestInvoices: SubscriptionCoverageInvoice[],
132
- groupId: string
133
- ): SubscriptionCoverageInvoice | undefined =>
134
- latestInvoices
135
- .filter((invoice) => invoice.group_id === groupId)
136
- .filter((invoice) => getComparableValidUntilTimestamp(invoice) > 0)
137
- .sort((a, b) => {
138
- const validUntilDiff =
139
- getComparableValidUntilTimestamp(b) -
140
- getComparableValidUntilTimestamp(a);
141
- if (validUntilDiff !== 0) return validUntilDiff;
142
-
143
- return (
144
- getComparableTimestamp(b.created_at) -
145
- getComparableTimestamp(a.created_at)
146
- );
147
- })[0];
148
-
149
- export const isSubscriptionMonthCoveredByInvoice = (
150
- selectedMonth: string,
151
- invoice: SubscriptionCoverageInvoice | null | undefined
152
- ): boolean => {
153
- if (!invoice?.valid_until) return false;
154
-
155
- const selectedMonthStart = getMonthStartDate(selectedMonth);
156
- const validUntilMonthStart = getMonthStartDate(invoice.valid_until);
157
-
158
- if (
159
- Number.isNaN(selectedMonthStart.getTime()) ||
160
- Number.isNaN(validUntilMonthStart.getTime())
161
- ) {
162
- return false;
163
- }
164
-
165
- return selectedMonthStart < validUntilMonthStart;
166
- };
167
-
168
- export const isSubscriptionMonthPaidForGroup = (
169
- groupId: string,
170
- selectedMonth: string,
171
- latestInvoices: SubscriptionCoverageInvoice[]
172
- ): boolean =>
173
- isSubscriptionMonthCoveredByInvoice(
174
- selectedMonth,
175
- getSubscriptionCoverageInvoiceForGroup(latestInvoices, groupId)
176
- );
177
-
178
- export const isSubscriptionRangePaidForGroup = (
179
- groupId: string,
180
- selectedMonth: string,
181
- prepaidMonthCount: number,
182
- latestInvoices: SubscriptionCoverageInvoice[]
183
- ): boolean => {
184
- const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
185
- if (coverageMonths.length === 0) return false;
186
-
187
- return coverageMonths.every((month) =>
188
- isSubscriptionMonthPaidForGroup(groupId, month, latestInvoices)
189
- );
190
- };
191
-
192
- export const isSubscriptionRangeFullyPaidForGroups = (
193
- groupIds: string[],
194
- selectedMonth: string,
195
- prepaidMonthCount: number,
196
- latestInvoices: SubscriptionCoverageInvoice[]
197
- ): boolean => {
198
- if (groupIds.length === 0) return false;
199
-
200
- return groupIds.every((groupId) =>
201
- isSubscriptionRangePaidForGroup(
202
- groupId,
203
- selectedMonth,
204
- prepaidMonthCount,
205
- latestInvoices
206
- )
207
- );
208
- };
209
-
210
126
  export const getAttendanceStats = (
211
127
  attendance: AttendanceRecord[]
212
128
  ): AttendanceStats => {
@@ -251,7 +167,7 @@ const getGroupValidUntilDate = (
251
167
  groupId: string
252
168
  ): Date | null => {
253
169
  const latestInvoice = getSubscriptionCoverageInvoiceForGroup(
254
- latestInvoices,
170
+ latestInvoices.filter((invoice) => invoice.covered_months == null),
255
171
  groupId
256
172
  );
257
173
  if (!latestInvoice?.valid_until) return null;
@@ -285,7 +201,11 @@ export const getBillableSessionsForGroups = (
285
201
  (candidate) => candidate.workspace_user_groups?.id === groupId
286
202
  )?.workspace_user_groups;
287
203
 
288
- if (!group?.sessions?.length) return [];
204
+ if (
205
+ !group?.sessions?.length ||
206
+ isSubscriptionMonthPaidForGroup(groupId, selectedMonth, latestInvoices)
207
+ )
208
+ return [];
289
209
 
290
210
  const validUntil = getGroupValidUntilDate(latestInvoices, groupId);
291
211
 
@@ -330,7 +250,12 @@ export const getBillableAttendanceRecords = (
330
250
 
331
251
  return attendance.filter((record) => {
332
252
  const groupId = record.group_id;
333
- if (!groupId || !groupIds.includes(groupId)) return false;
253
+ if (
254
+ !groupId ||
255
+ !groupIds.includes(groupId) ||
256
+ isSubscriptionMonthPaidForGroup(groupId, selectedMonth, latestInvoices)
257
+ )
258
+ return false;
334
259
 
335
260
  const attendanceDate = parseLocalCalendarDate(record.date);
336
261
  if (!isDateInMonth(attendanceDate, selectedMonth)) return false;
@@ -668,9 +593,10 @@ const EXPIRING_SOON_DAYS = 14;
668
593
  export type GroupPaymentStatus = 'active' | 'expiringSoon' | 'expired';
669
594
  export function getGroupPaymentStatus(
670
595
  group: WorkspaceUserGroup | null,
671
- latestInvoice:
672
- | { valid_until?: string | null; created_at?: string | null }
673
- | undefined
596
+ latestInvoice: SubscriptionCoverageInvoice | undefined,
597
+ coverageInvoices: SubscriptionCoverageInvoice[] = latestInvoice
598
+ ? [latestInvoice]
599
+ : []
674
600
  ): GroupPaymentStatus {
675
601
  const today = new Date();
676
602
  today.setHours(0, 0, 0, 0);
@@ -685,9 +611,25 @@ export function getGroupPaymentStatus(
685
611
  if (today > endDate) return 'expired';
686
612
  }
687
613
 
688
- const validUntil = latestInvoice?.valid_until
614
+ let validUntil = latestInvoice?.valid_until
689
615
  ? parseLocalCalendarDate(latestInvoice.valid_until)
690
616
  : null;
617
+ if (coverageInvoices.some((invoice) => invoice.covered_months != null)) {
618
+ const cursor = new Date(today.getFullYear(), today.getMonth(), 1);
619
+ const month = () =>
620
+ `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}`;
621
+ if (!isSubscriptionMonthPaidForGroup(group.id, month(), coverageInvoices))
622
+ return 'expired';
623
+ // Walk contiguous paid months so a later payment cannot conceal a gap.
624
+ for (
625
+ let i = 0;
626
+ i < 1200 &&
627
+ isSubscriptionMonthPaidForGroup(group.id, month(), coverageInvoices);
628
+ i++
629
+ )
630
+ cursor.setMonth(cursor.getMonth() + 1);
631
+ validUntil = cursor;
632
+ }
691
633
  if (validUntil) {
692
634
  validUntil.setHours(0, 0, 0, 0);
693
635
  const daysUntilExpiry = Math.ceil(
@@ -12,8 +12,9 @@ import timezone from 'dayjs/plugin/timezone';
12
12
  import React, { useCallback, useMemo, useRef, useState } from 'react';
13
13
  import { MIN_COLUMN_WIDTH } from './config';
14
14
  import { CalendarEventProviderIcon } from './event-provider-display';
15
- import { getLocationType, LocationTimeline } from './location-timeline';
15
+ import { LocationTimeline } from './location-timeline';
16
16
  import { useCalendarSettings } from './settings/settings-context';
17
+ import { getEventLocationType } from './working-location';
17
18
 
18
19
  dayjs.extend(isBetween);
19
20
  dayjs.extend(isSameOrAfter);
@@ -22,7 +23,6 @@ dayjs.extend(timezone);
22
23
 
23
24
  const MAX_EVENTS_DISPLAY = 2;
24
25
 
25
- // Define types for better type safety
26
26
  interface EventSpan {
27
27
  event: CalendarEvent;
28
28
  startIndex: number;
@@ -490,11 +490,11 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
490
490
  // Location events will be rendered separately as a compact timeline strip
491
491
  // Extract location spans before filtering them out for row assignment
492
492
  const locationSpansFromMerge = mergedTempSpans
493
- .filter((span) => getLocationType(span.event.title ?? '') !== null)
493
+ .filter((span) => getEventLocationType(span.event) !== null)
494
494
  .map((span) => ({ ...span, row: 0 })); // Give them row 0 as placeholder
495
495
 
496
496
  const nonLocationSpans = mergedTempSpans.filter(
497
- (span) => getLocationType(span.event.title ?? '') === null
497
+ (span) => getEventLocationType(span.event) === null
498
498
  );
499
499
 
500
500
  // Sort: 1) by span length (longer events first for better packing), 2) by start date
@@ -628,7 +628,7 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
628
628
  // Get non-location spans for regular event display
629
629
  const regularSpans = useMemo(() => {
630
630
  return eventLayout.spans.filter(
631
- (span) => getLocationType(span.event.title ?? '') === null
631
+ (span) => getEventLocationType(span.event) === null
632
632
  );
633
633
  }, [eventLayout.spans]);
634
634
 
@@ -22,6 +22,7 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
22
22
  import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
23
23
  import timezone from 'dayjs/plugin/timezone';
24
24
  import { useCallback, useEffect, useState } from 'react';
25
+ import { getEventLocationType } from './working-location';
25
26
 
26
27
  dayjs.extend(timezone);
27
28
  dayjs.extend(isBetween);
@@ -47,19 +48,6 @@ interface EventSpan {
47
48
  mergedEventIds?: string[];
48
49
  }
49
50
 
50
- type LocationType = 'home' | 'office' | 'school' | 'custom' | null;
51
-
52
- // Helper to determine location type
53
- export const getLocationType = (title: string): LocationType => {
54
- const normalizedTitle = title.toLowerCase().trim();
55
- if (normalizedTitle === 'home') return 'home';
56
- if (normalizedTitle === 'office' || normalizedTitle === 'work')
57
- return 'office';
58
- if (normalizedTitle === 'school') return 'school';
59
- if (title.startsWith('📍') || title.startsWith('Location: ')) return 'custom';
60
- return null;
61
- };
62
-
63
51
  // Location colors using dynamic theme tokens
64
52
  const locationStyles = {
65
53
  home: {
@@ -156,7 +144,7 @@ const LocationPill = ({
156
144
  );
157
145
 
158
146
  const { event, startIndex, span } = eventSpan;
159
- const locationType = getLocationType(event.title ?? '');
147
+ const locationType = getEventLocationType(event);
160
148
  const isHome = locationType === 'home';
161
149
  const isOffice = locationType === 'office';
162
150
  const isSchool = locationType === 'school';
@@ -0,0 +1,47 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { getEventLocationType } from './working-location';
4
+
5
+ const event = (overrides: Partial<CalendarEvent>): CalendarEvent => ({
6
+ id: 'event',
7
+ start_at: '2026-09-25T00:00:00Z',
8
+ end_at: '2026-09-26T00:00:00Z',
9
+ ...overrides,
10
+ });
11
+
12
+ describe('Google working locations', () => {
13
+ it('uses provider metadata even when the title differs', () => {
14
+ expect(
15
+ getEventLocationType(
16
+ event({
17
+ title: 'Remote today',
18
+ scheduling_metadata: {
19
+ google_event_type: 'workingLocation',
20
+ google_working_location_type: 'homeOffice',
21
+ },
22
+ })
23
+ )
24
+ ).toBe('home');
25
+ });
26
+
27
+ it('keeps legacy Home and Office location events visible', () => {
28
+ expect(getEventLocationType(event({ title: 'Home' }))).toBe('home');
29
+ expect(getEventLocationType(event({ title: 'Office' }))).toBe('office');
30
+ expect(getEventLocationType(event({ title: 'School' }))).toBe('school');
31
+ });
32
+
33
+ it('displays custom Google School locations distinctly', () => {
34
+ expect(
35
+ getEventLocationType(
36
+ event({
37
+ title: 'Campus',
38
+ scheduling_metadata: {
39
+ google_event_type: 'workingLocation',
40
+ google_working_location_type: 'customLocation',
41
+ google_working_location_label: 'School',
42
+ },
43
+ })
44
+ )
45
+ ).toBe('school');
46
+ });
47
+ });
@@ -0,0 +1,35 @@
1
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
+
3
+ export type LocationType = 'home' | 'office' | 'school' | 'custom' | null;
4
+
5
+ export function getLocationType(title: string): LocationType {
6
+ const normalizedTitle = title.toLowerCase().trim();
7
+ if (normalizedTitle === 'home') return 'home';
8
+ if (normalizedTitle === 'office' || normalizedTitle === 'work')
9
+ return 'office';
10
+ if (normalizedTitle === 'school') return 'school';
11
+ if (title.startsWith('📍') || title.startsWith('Location: ')) return 'custom';
12
+ return null;
13
+ }
14
+
15
+ export function getEventLocationType(event: CalendarEvent): LocationType {
16
+ if (event.scheduling_metadata?.google_event_type === 'workingLocation') {
17
+ switch (event.scheduling_metadata.google_working_location_type) {
18
+ case 'homeOffice':
19
+ return 'home';
20
+ case 'officeLocation':
21
+ return 'office';
22
+ case 'customLocation': {
23
+ const label = event.scheduling_metadata.google_working_location_label;
24
+ return (typeof label === 'string' &&
25
+ label.trim().toLowerCase() === 'school') ||
26
+ event.title?.trim().toLowerCase() === 'school'
27
+ ? 'school'
28
+ : 'custom';
29
+ }
30
+ default:
31
+ return 'custom';
32
+ }
33
+ }
34
+ return getLocationType(event.title ?? '');
35
+ }