@tuturuuu/utils 0.33.1 → 0.33.3

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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.33.3](https://github.com/tutur3u/platform/compare/utils-v0.33.2...utils-v0.33.3) (2026-10-02)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **calendar:** preserve opaque rendering cues and hydrate source colors ([33847dd](https://github.com/tutur3u/platform/commit/33847ddcd8a3cf2b5cef8ebf50ca4c1700424a27))
9
+ * **calendar:** render opaque readable event colors ([#5694](https://github.com/tutur3u/platform/issues/5694)) ([6e3d81d](https://github.com/tutur3u/platform/commit/6e3d81d6fdd6f8155f12e36ede58d31fde0ac362))
10
+ * **mobile:** harden timezone retry recovery ([264b4b8](https://github.com/tutur3u/platform/commit/264b4b89ab9e1cb4194374a3714b46832c5e486a))
11
+ * **mobile:** resolve timezone settings with verified native sessions ([#5720](https://github.com/tutur3u/platform/issues/5720)) ([5640ca2](https://github.com/tutur3u/platform/commit/5640ca22bcbfda6cd0470f39171ffa2366af779e))
12
+ * **mobile:** restore verified timezone resolution ([462c68b](https://github.com/tutur3u/platform/commit/462c68bf22b2b412dd1825c12c3558b10b028a40))
13
+ * **mobile:** retain timezone snapshots during overlapping loads ([eff42e2](https://github.com/tutur3u/platform/commit/eff42e2cf9d3159ee6fc68be1079df5f50fd92e2))
14
+
15
+ ## [0.33.2](https://github.com/tutur3u/platform/compare/utils-v0.33.1...utils-v0.33.2) (2026-10-01)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **calendar:** preserve Google color identity and inheritance ([#5657](https://github.com/tutur3u/platform/issues/5657)) ([0c2aac6](https://github.com/tutur3u/platform/commit/0c2aac6a1a2b6e9f6eb66bde1e783bf407603cff))
21
+ * **calendar:** preserve Google provider color identity and inheritance ([08f2363](https://github.com/tutur3u/platform/commit/08f236349d1dc29ad0593de8f668d5ce504f38d0))
22
+
3
23
  ## [0.33.1](https://github.com/tutur3u/platform/compare/utils-v0.33.0...utils-v0.33.1) (2026-09-29)
4
24
 
5
25
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tuturuuu/utils",
3
3
  "license": "MIT",
4
- "version": "0.33.1",
4
+ "version": "0.33.3",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -24,7 +24,7 @@
24
24
  "@tiptap/react": "3.31.3",
25
25
  "@tuturuuu/google": "0.1.1",
26
26
  "@tuturuuu/icons": "0.1.1",
27
- "@tuturuuu/internal-api": "0.47.0",
27
+ "@tuturuuu/internal-api": "0.48.0",
28
28
  "@tuturuuu/supabase": "0.5.2",
29
29
  "@upstash/ratelimit": "^2.0.8",
30
30
  "@upstash/redis": "^1.38.4",
@@ -41,11 +41,11 @@
41
41
  "server-only": "^0.0.1",
42
42
  "uuid": "^14.0.2",
43
43
  "y-prosemirror": "^1.3.7",
44
- "yjs": "^13.6.32",
44
+ "yjs": "^13.6.33",
45
45
  "zod": "^4.6.2"
46
46
  },
47
47
  "devDependencies": {
48
- "@tuturuuu/types": "0.33.0",
48
+ "@tuturuuu/types": "0.33.1",
49
49
  "@tuturuuu/typescript-config": "0.1.2",
50
50
  "@types/diff": "^8.0.0",
51
51
  "@types/node": "^26.5.1",
@@ -0,0 +1,193 @@
1
+ import { NextRequest } from 'next/server';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ limit: vi.fn(),
6
+ trust: vi.fn(),
7
+ blocked: vi.fn(),
8
+ prefixes: [] as string[],
9
+ }));
10
+ vi.mock('../required-mfa-runtime', () => ({
11
+ enforceRequiredMfaRequest: async () => null,
12
+ }));
13
+ vi.mock('@tuturuuu/supabase/next/server', () => ({
14
+ createAdminClient: vi.fn(),
15
+ }));
16
+ vi.mock('@upstash/ratelimit', () => ({
17
+ Ratelimit: class {
18
+ static slidingWindow() {
19
+ return {};
20
+ }
21
+ constructor(config: { prefix: string }) {
22
+ mocks.prefixes.push(config.prefix);
23
+ }
24
+ limit(key: string) {
25
+ return mocks.limit(key);
26
+ }
27
+ },
28
+ }));
29
+ vi.mock('@upstash/redis', () => ({
30
+ Redis: class {
31
+ static fromEnv() {
32
+ return {};
33
+ }
34
+ },
35
+ }));
36
+ vi.mock('../abuse-protection/edge', () => ({
37
+ extractIPFromRequest: () => '192.0.2.1',
38
+ isIPBlockedEdge: () => mocks.blocked(),
39
+ readEdgeAbuseProtectionControls: async () => ({
40
+ ipBlockingEnabled: true,
41
+ rateLimitsEnabled: true,
42
+ }),
43
+ }));
44
+ vi.mock('../abuse-protection/edge-trust', () => ({
45
+ getCachedTrustEntries: (keys: string[]) => mocks.trust(keys),
46
+ hasCachedIpBlockAppealRelief: async () => false,
47
+ }));
48
+ vi.mock('../request-emoji-limit', () => ({
49
+ validateRequestEmojiLimit: async () => null,
50
+ }));
51
+
52
+ // Use the actual server subject builder, rather than a separately invented hash.
53
+ import { buildAbuseRiskSubjects } from '../abuse-protection/reputation';
54
+
55
+ const path = '/api/v1/mobile-calendar/api/v1/users/calendar-settings';
56
+ const token = 'synthetic-native-session';
57
+ function verifiedKey(value = token) {
58
+ return buildAbuseRiskSubjects({
59
+ headers: { authorization: `Bearer ${value}` },
60
+ userId: 'synthetic-user',
61
+ }).find((subject) => subject.subject_type === 'session')!.subject_key;
62
+ }
63
+ async function request(authorization = `Bearer ${token}`, cookie?: string) {
64
+ const { guardApiProxyRequest } = await import('../api-proxy-guard.js');
65
+ return guardApiProxyRequest(
66
+ new NextRequest(`https://infra.test${path}`, {
67
+ headers: { authorization, ...(cookie ? { cookie } : {}) },
68
+ }),
69
+ { prefixBase: 'proxy:infra:api' }
70
+ );
71
+ }
72
+
73
+ describe('native Calendar gateway verified session parity', () => {
74
+ beforeEach(() => {
75
+ vi.resetModules();
76
+ vi.stubEnv('NODE_ENV', 'production');
77
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
78
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'synthetic');
79
+ vi.stubEnv('API_PROXY_EDGE_TRUST_ENABLED', '1');
80
+ mocks.prefixes.length = 0;
81
+ mocks.limit.mockReset().mockResolvedValue({
82
+ success: false,
83
+ limit: 60,
84
+ remaining: 0,
85
+ reset: Date.now() + 30_000,
86
+ });
87
+ mocks.blocked.mockReset().mockResolvedValue(null);
88
+ mocks.trust.mockReset().mockResolvedValue(new Map());
89
+ });
90
+ afterEach(() => vi.unstubAllEnvs());
91
+
92
+ it('allows a verified native session with available per-session budget', async () => {
93
+ const key = verifiedKey();
94
+ mocks.trust.mockResolvedValue(new Map([[key, { m: 1, verified: true }]]));
95
+ mocks.limit.mockResolvedValue({
96
+ success: true,
97
+ limit: 60,
98
+ remaining: 59,
99
+ reset: Date.now() + 30_000,
100
+ });
101
+ expect(await request()).toBeNull();
102
+ expect(mocks.limit).toHaveBeenCalledWith(key);
103
+ });
104
+
105
+ it('keeps unsupported separator whitespace aligned with server cache keys', async () => {
106
+ const header = `Bearer ${token}`;
107
+ const sessionSubject = (authorization: string) =>
108
+ buildAbuseRiskSubjects({
109
+ headers: { authorization },
110
+ userId: 'synthetic-user',
111
+ }).find((subject) => subject.subject_type === 'session');
112
+ expect(sessionSubject(`Bearer ${token}`)?.subject_key).toBe(verifiedKey());
113
+ expect(sessionSubject(header)).toBeUndefined();
114
+ mocks.trust.mockResolvedValue(
115
+ new Map([[verifiedKey(), { m: 1, verified: true }]])
116
+ );
117
+ const response = await request(header);
118
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
119
+ expect(mocks.limit).toHaveBeenCalledWith('ip:192.0.2.1');
120
+ });
121
+
122
+ it('uses exactly the authenticated server key, preserving rate limits and Retry-After', async () => {
123
+ const key = verifiedKey();
124
+ mocks.trust.mockImplementation(async (keys: string[]) => {
125
+ expect(keys).toContain(key);
126
+ expect(JSON.stringify(keys)).not.toContain(token);
127
+ return new Map([[key, { m: 1, verified: true }]]);
128
+ });
129
+ const response = await request();
130
+ expect(response?.status).toBe(429);
131
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
132
+ 'authenticated'
133
+ );
134
+ expect(response?.headers.get('Retry-After')).toBe('30');
135
+ expect(mocks.limit).toHaveBeenCalledWith(key);
136
+ expect(mocks.prefixes).toContain(
137
+ 'proxy:infra:api:default:authenticated:get:minute'
138
+ );
139
+ });
140
+
141
+ it.each([`Bearer forged`, `Bearer ${token}`, 'Bearer malformed token'])(
142
+ 'unverified or evicted credentials retain the anonymous IP budget: %s',
143
+ async (header) => {
144
+ const response = await request(header);
145
+ expect(response?.status).toBe(429);
146
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
147
+ 'anonymous'
148
+ );
149
+ expect(mocks.limit).toHaveBeenCalledWith('ip:192.0.2.1');
150
+ }
151
+ );
152
+
153
+ it('rotated credentials cannot inherit the old session cache entry', async () => {
154
+ mocks.trust.mockResolvedValue(
155
+ new Map([[verifiedKey(), { m: 1, verified: true }]])
156
+ );
157
+ const response = await request('Bearer rotated-session');
158
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
159
+ expect(mocks.limit).toHaveBeenCalledWith('ip:192.0.2.1');
160
+ });
161
+
162
+ it('uses Bearer precedence even when a different trusted cookie is present', async () => {
163
+ const { buildProxySessionSubjectKey } = await import(
164
+ '../api-proxy-guard.js'
165
+ );
166
+ const cookieKey = await buildProxySessionSubjectKey(
167
+ 'sb-test-auth-token',
168
+ 'cookie'
169
+ );
170
+ mocks.trust.mockResolvedValue(
171
+ new Map([[cookieKey, { m: 1, verified: true }]])
172
+ );
173
+ const response = await request(
174
+ 'Bearer forged',
175
+ 'sb-test-auth-token=cookie'
176
+ );
177
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
178
+ });
179
+
180
+ it('retains IP blocks even for verified native sessions', async () => {
181
+ mocks.trust.mockResolvedValue(
182
+ new Map([[verifiedKey(), { m: 1, verified: true }]])
183
+ );
184
+ mocks.blocked.mockResolvedValue({
185
+ expiresAt: new Date(Date.now() + 60_000),
186
+ });
187
+ const response = await request();
188
+ expect(response?.headers.get('X-Proxy-Block-Reason')).toBe(
189
+ 'ip-already-blocked'
190
+ );
191
+ expect(mocks.limit).not.toHaveBeenCalled();
192
+ });
193
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ calendarColorForeground,
4
+ calendarEventColors,
5
+ calendarEventStyle,
6
+ } from '../calendar-event-colors';
7
+
8
+ const metadata = (inherited: boolean, background: unknown = '#abcdef') => ({
9
+ google_color: { version: 1, inherited, background },
10
+ });
11
+
12
+ describe('effective opaque calendar rendering', () => {
13
+ it('preserves explicit provider RGB instead of the canonical FK or source RGB', () => {
14
+ expect(
15
+ calendarEventStyle({
16
+ color: 'BLUE',
17
+ _calendarColor: '#ff0000',
18
+ scheduling_metadata: metadata(false, '#00ff88'),
19
+ })
20
+ ).toEqual({
21
+ backgroundColor: '#00ff88',
22
+ borderColor: '#00ff88',
23
+ color: '#000000',
24
+ });
25
+ });
26
+ it('updates inherited events from current source RGB before historical metadata', () => {
27
+ expect(
28
+ calendarEventColors({
29
+ _calendarColor: '#ff80ab',
30
+ scheduling_metadata: metadata(true, '#00ff88'),
31
+ }).background
32
+ ).toBe('#ff80ab');
33
+ });
34
+ it('retains resolved inherited metadata when current source RGB is unavailable', () => {
35
+ expect(
36
+ calendarEventColors({
37
+ _calendarColor: 'rgba(0,0,0,.1)',
38
+ scheduling_metadata: metadata(true),
39
+ }).background
40
+ ).toBe('#abcdef');
41
+ });
42
+ it('does not infer inheritance for unknown explicit or legacy rows', () => {
43
+ for (const scheduling_metadata of [
44
+ undefined,
45
+ metadata(false, null),
46
+ metadata(false, '#ff000080'),
47
+ ]) {
48
+ expect(
49
+ calendarEventStyle({
50
+ color: 'GREEN',
51
+ _calendarColor: '#ff80ab',
52
+ scheduling_metadata,
53
+ }).backgroundColor
54
+ ).toBe('#4caf50');
55
+ }
56
+ });
57
+ it('retains legacy grey and stored grey RGB aliases', () => {
58
+ expect(calendarEventColors({ color: 'grey' }).background).toBe('#9e9e9e');
59
+ expect(calendarEventColors({ color: '#6b7280' }).background).toBe(
60
+ '#6b7280'
61
+ );
62
+ });
63
+ it('uses readable opaque text across pale and dark colors', () => {
64
+ expect(calendarColorForeground('#ffffff')).toBe('#000000');
65
+ expect(calendarColorForeground('#000000')).toBe('#ffffff');
66
+ for (const background of [
67
+ '#a4bdfc',
68
+ '#7ae7bf',
69
+ '#dbadff',
70
+ '#ff887c',
71
+ '#fbd75b',
72
+ '#ffb878',
73
+ '#46d6db',
74
+ '#e1e1e1',
75
+ '#5484ed',
76
+ '#51b749',
77
+ '#dc2127',
78
+ ...[
79
+ 'BLUE',
80
+ 'RED',
81
+ 'GREEN',
82
+ 'YELLOW',
83
+ 'PURPLE',
84
+ 'PINK',
85
+ 'ORANGE',
86
+ 'INDIGO',
87
+ 'CYAN',
88
+ 'GRAY',
89
+ 'grey',
90
+ '#6b7280',
91
+ ].map((color) => calendarEventColors({ color }).background),
92
+ ]) {
93
+ const text = calendarColorForeground(background);
94
+ const rgb = [1, 3, 5]
95
+ .map((i) => parseInt(background.slice(i, i + 2), 16) / 255)
96
+ .map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
97
+ const l = rgb[0]! * 0.2126 + rgb[1]! * 0.7152 + rgb[2]! * 0.0722;
98
+ expect(
99
+ text === '#000000' ? (l + 0.05) / 0.05 : 1.05 / (l + 0.05)
100
+ ).toBeGreaterThanOrEqual(4.5);
101
+ }
102
+ });
103
+ });
@@ -886,19 +886,6 @@ function parseCookieHeaderEdge(
886
886
  );
887
887
  }
888
888
 
889
- function getSessionAuthCookie(
890
- req: NextRequest
891
- ): { name: string; value: string } | null {
892
- const cookies = parseCookieHeaderEdge(req.headers.get('cookie'));
893
- return (
894
- cookies.find(
895
- (cookie) =>
896
- cookie.name === 'tuturuuu_app_session' ||
897
- GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN.test(cookie.name)
898
- ) ?? null
899
- );
900
- }
901
-
902
889
  export async function getProxySessionSubjectKeyFromCookieHeader(
903
890
  cookieHeader: string | null
904
891
  ): Promise<string | null> {
@@ -969,10 +956,15 @@ async function resolveProxyIdentity(
969
956
  ip: string
970
957
  ): Promise<ProxyIdentity> {
971
958
  const normalizedIp = ip && ip !== 'unknown' ? ip : null;
972
- const sessionCookie = getSessionAuthCookie(req);
973
- const sessionKey = sessionCookie
974
- ? await buildProxySessionSubjectKey(sessionCookie.name, sessionCookie.value)
975
- : null;
959
+ // Match the server's verified session cache key; header presence grants no trust.
960
+ const bearer = req.headers
961
+ .get('authorization')
962
+ ?.match(/^Bearer ([^\s]+)$/i)?.[1];
963
+ const sessionKey = bearer
964
+ ? await buildProxySessionSubjectKey('bearer', bearer)
965
+ : await getProxySessionSubjectKeyFromCookieHeader(
966
+ req.headers.get('cookie')
967
+ );
976
968
 
977
969
  return {
978
970
  cidrKey: getCidrSubjectKeyEdge(normalizedIp),
@@ -0,0 +1,62 @@
1
+ import {
2
+ opaqueGoogleColor,
3
+ readGoogleEventColor,
4
+ } from './google-calendar-colors';
5
+
6
+ /** Stable opaque fallback for first-party and legacy rows without provider RGB. */
7
+ const canonicalBackgrounds: Record<string, string> = {
8
+ BLUE: '#2196f3',
9
+ RED: '#f44336',
10
+ GREEN: '#4caf50',
11
+ YELLOW: '#ffeb3b',
12
+ PURPLE: '#9c27b0',
13
+ PINK: '#e91e63',
14
+ ORANGE: '#ff9800',
15
+ INDIGO: '#3f51b5',
16
+ CYAN: '#00bcd4',
17
+ GRAY: '#9e9e9e',
18
+ GREY: '#9e9e9e',
19
+ '#6B7280': '#6b7280',
20
+ };
21
+
22
+ type ColorEvent = {
23
+ color?: string | null;
24
+ scheduling_metadata?: unknown;
25
+ _calendarColor?: string | null;
26
+ };
27
+
28
+ /** Choose the higher contrast of black/white (at least 4.5:1 for any RGB). */
29
+ export function calendarColorForeground(background: string): string {
30
+ const channels = [1, 3, 5].map((offset) => {
31
+ const value =
32
+ Number.parseInt(background.slice(offset, offset + 2), 16) / 255;
33
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
34
+ });
35
+ const luminance =
36
+ channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722;
37
+ return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05)
38
+ ? '#000000'
39
+ : '#ffffff';
40
+ }
41
+
42
+ export function calendarEventColors(event: ColorEvent) {
43
+ const google = readGoogleEventColor(event.scheduling_metadata);
44
+ // Only known inherited intent uses the current source calendar color.
45
+ // Explicit unknown colors and legacy rows never silently become inherited.
46
+ const background =
47
+ (google?.inherited ? opaqueGoogleColor(event._calendarColor) : null) ??
48
+ google?.background ??
49
+ canonicalBackgrounds[event.color?.trim().toUpperCase() ?? 'BLUE'] ??
50
+ canonicalBackgrounds.BLUE!;
51
+ return { background, foreground: calendarColorForeground(background) };
52
+ }
53
+
54
+ /** Inline RGB wins over theme color classes without reducing fill opacity. */
55
+ export function calendarEventStyle(event: ColorEvent) {
56
+ const { background, foreground } = calendarEventColors(event);
57
+ return {
58
+ backgroundColor: background,
59
+ color: foreground,
60
+ borderColor: background,
61
+ };
62
+ }
@@ -330,7 +330,19 @@ describe('application metadata coverage', () => {
330
330
  )
331
331
  .map((entry) => resolve(entry.parentPath, entry.name));
332
332
 
333
- expect(marketingLayouts).toHaveLength(45);
333
+ // Guard concrete entry points while allowing new public pages to opt in.
334
+ // A fixed layout count rejects valid additions without testing SEO behavior.
335
+ const criticalLayouts = [
336
+ '(landing)/layout.tsx',
337
+ 'about/layout.tsx',
338
+ 'products/tasks/layout.tsx',
339
+ 'privacy/layout.tsx',
340
+ 'contributors/layout.tsx',
341
+ 'women-in-tech/layout.tsx',
342
+ ].map((pathname) =>
343
+ resolve(repoRoot, 'apps/web/src/app/[locale]/(marketing)', pathname)
344
+ );
345
+ expect(marketingLayouts).toEqual(expect.arrayContaining(criticalLayouts));
334
346
  for (const layoutPath of marketingLayouts) {
335
347
  expect(readFileSync(layoutPath, 'utf8')).toMatch(/pathname:\s*['`]/);
336
348
  }
@@ -31,11 +31,7 @@ export const isExactTuturuuuDotComEmail = (email?: string | null): boolean => {
31
31
  return /^[^\s@]+@tuturuuu\.com$/i.test(email.trim());
32
32
  };
33
33
 
34
- /** Routed QA domain. It is deliberately outside all staff-email allowlists. */
35
- export const isTuturuuuReviewEmail = (email?: string | null): boolean => {
36
- if (!email) return false;
37
- return /^[^\s@]+@tutur3u\.com$/i.test(email.trim());
38
- };
34
+ export { isTuturuuuReviewEmail } from './reviewer-domain.mjs';
39
35
 
40
36
  export const isEmail = (text: string): boolean => {
41
37
  const emailRegex =
@@ -0,0 +1 @@
1
+ export function isTuturuuuReviewEmail(email?: string | null): boolean;
@@ -0,0 +1,6 @@
1
+ /** Routed QA domain, deliberately outside all staff-email allowlists. */
2
+ export function isTuturuuuReviewEmail(email) {
3
+ return (
4
+ typeof email === 'string' && /^[^\s@]+@tutur3u\.com$/i.test(email.trim())
5
+ );
6
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ googleColorCompatibilityValue,
4
+ opaqueGoogleColor,
5
+ resolveGoogleEventColor,
6
+ } from './google-calendar-colors';
7
+
8
+ describe('Google event color resolution', () => {
9
+ it('preserves inherited custom calendar RGB instead of substituting BLUE', () => {
10
+ expect(
11
+ resolveGoogleEventColor(
12
+ { colorId: null },
13
+ {
14
+ calendarId: 'personal',
15
+ calendarBackground: '#F691B2',
16
+ calendarForeground: '#000000',
17
+ }
18
+ )
19
+ ).toEqual({
20
+ version: 1,
21
+ calendar_id: 'personal',
22
+ color_id: null,
23
+ event_label_id: null,
24
+ inherited: true,
25
+ background: '#f691b2',
26
+ foreground: '#000000',
27
+ resolution: 'calendar',
28
+ });
29
+ });
30
+ it('uses all current event definitions, independently from calendar palette IDs', () => {
31
+ for (let id = 1; id <= 11; id++) {
32
+ const key = String(id);
33
+ expect(
34
+ resolveGoogleEventColor(
35
+ { colorId: key },
36
+ {
37
+ eventColors: {
38
+ [key]: { background: '#039BE5', foreground: '#ffffff' },
39
+ },
40
+ calendarBackground: '#f691b2',
41
+ }
42
+ )
43
+ ).toMatchObject({
44
+ color_id: key,
45
+ background: '#039be5',
46
+ inherited: false,
47
+ resolution: 'event',
48
+ });
49
+ }
50
+ expect(googleColorCompatibilityValue('7')).toBe('CYAN');
51
+ expect(googleColorCompatibilityValue('11')).toBe('RED');
52
+ });
53
+ it('prioritizes calendar-scoped labels over legacy color IDs', () => {
54
+ expect(
55
+ resolveGoogleEventColor(
56
+ { eventLabelId: 'custom-label', colorId: '7' },
57
+ {
58
+ eventLabels: [{ id: 'custom-label', backgroundColor: '#a123bc' }],
59
+ eventColors: { '7': { background: '#039be5' } },
60
+ }
61
+ )
62
+ ).toMatchObject({
63
+ event_label_id: 'custom-label',
64
+ color_id: '7',
65
+ background: '#a123bc',
66
+ resolution: 'label',
67
+ });
68
+ });
69
+ it('retains unknown explicit IDs and reports unresolved instead of inherited', () => {
70
+ expect(
71
+ resolveGoogleEventColor(
72
+ { eventLabelId: 'unknown' },
73
+ { calendarBackground: '#f691b2' }
74
+ )
75
+ ).toMatchObject({
76
+ inherited: false,
77
+ event_label_id: 'unknown',
78
+ background: null,
79
+ resolution: 'unresolved',
80
+ });
81
+ });
82
+ it('accepts only opaque RGB for rendering', () => {
83
+ expect(opaqueGoogleColor('#039BE5')).toBe('#039be5');
84
+ for (const value of ['#039be580', 'red', 'url(x)', '#fff'])
85
+ expect(opaqueGoogleColor(value)).toBeNull();
86
+ });
87
+ });
@@ -0,0 +1,112 @@
1
+ import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors';
2
+
3
+ export type GoogleColorDefinition = {
4
+ background?: string | null;
5
+ foreground?: string | null;
6
+ };
7
+ export type GoogleColorContext = {
8
+ calendarId?: string;
9
+ calendarBackground?: string | null;
10
+ calendarForeground?: string | null;
11
+ eventColors?: Record<string, GoogleColorDefinition> | null;
12
+ eventLabels?: { id?: string | null; backgroundColor?: string | null }[];
13
+ };
14
+ export type GoogleEventColor = {
15
+ version: 1;
16
+ calendar_id: string | null;
17
+ color_id: string | null;
18
+ event_label_id: string | null;
19
+ inherited: boolean;
20
+ background: string | null;
21
+ foreground: string | null;
22
+ resolution: 'calendar' | 'event' | 'label' | 'unresolved';
23
+ };
24
+
25
+ // Compatibility only for the existing canonical-color FK. Rendering uses RGB.
26
+ const LEGACY_EVENT_COLORS: Record<string, SupportedColor> = {
27
+ '1': 'INDIGO',
28
+ '2': 'GREEN',
29
+ '3': 'PURPLE',
30
+ '4': 'PINK',
31
+ '5': 'YELLOW',
32
+ '6': 'ORANGE',
33
+ '7': 'CYAN',
34
+ '8': 'GRAY',
35
+ '9': 'BLUE',
36
+ '10': 'GREEN',
37
+ '11': 'RED',
38
+ };
39
+ export const GOOGLE_COLOR_IDS: Record<SupportedColor, string> = {
40
+ INDIGO: '1',
41
+ GREEN: '2',
42
+ PURPLE: '3',
43
+ PINK: '4',
44
+ YELLOW: '5',
45
+ ORANGE: '6',
46
+ CYAN: '7',
47
+ GRAY: '8',
48
+ BLUE: '9',
49
+ RED: '11',
50
+ };
51
+ export function googleColorCompatibilityValue(colorId?: string | null) {
52
+ return colorId ? (LEGACY_EVENT_COLORS[colorId] ?? 'BLUE') : 'BLUE';
53
+ }
54
+ export function opaqueGoogleColor(value: unknown): string | null {
55
+ return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
56
+ ? value.toLowerCase()
57
+ : null;
58
+ }
59
+ export function resolveGoogleEventColor(
60
+ event: { colorId?: string | null; eventLabelId?: string | null },
61
+ context: GoogleColorContext = {}
62
+ ): GoogleEventColor {
63
+ const labelId = event.eventLabelId || null;
64
+ const colorId = event.colorId && event.colorId !== '0' ? event.colorId : null;
65
+ const inherited = !labelId && !colorId;
66
+ const label = context.eventLabels?.find((entry) => entry.id === labelId);
67
+ const definition = colorId ? context.eventColors?.[colorId] : undefined;
68
+ // An unknown explicit identity must not silently become an inherited color.
69
+ const background = opaqueGoogleColor(
70
+ labelId
71
+ ? label?.backgroundColor
72
+ : colorId
73
+ ? definition?.background
74
+ : context.calendarBackground
75
+ );
76
+ return {
77
+ version: 1,
78
+ calendar_id: context.calendarId ?? null,
79
+ color_id: colorId,
80
+ event_label_id: labelId,
81
+ inherited,
82
+ background,
83
+ foreground: labelId
84
+ ? null
85
+ : opaqueGoogleColor(
86
+ colorId ? definition?.foreground : context.calendarForeground
87
+ ),
88
+ resolution: !background
89
+ ? 'unresolved'
90
+ : labelId
91
+ ? 'label'
92
+ : colorId
93
+ ? 'event'
94
+ : 'calendar',
95
+ };
96
+ }
97
+
98
+ /** Provider metadata is read defensively; client metadata is never a write command. */
99
+ export function readGoogleEventColor(
100
+ metadata: unknown
101
+ ): GoogleEventColor | null {
102
+ if (!metadata || typeof metadata !== 'object') return null;
103
+ const color = (metadata as Record<string, unknown>).google_color;
104
+ if (!color || typeof color !== 'object') return null;
105
+ const entry = color as GoogleEventColor;
106
+ if (entry.version !== 1 || typeof entry.inherited !== 'boolean') return null;
107
+ return {
108
+ ...entry,
109
+ background: opaqueGoogleColor(entry.background),
110
+ foreground: opaqueGoogleColor(entry.foreground),
111
+ };
112
+ }
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  describe('platform release metadata', () => {
9
9
  it('uses the centralized shared browser app version', () => {
10
- expect(TUTURUUU_PLATFORM_VERSION).toBe('0.63.0'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.65.0'); // x-release-please-version
11
11
  });
12
12
 
13
13
  it('preserves the source timestamp in builtAt and derives a short hash', () => {
@@ -1,6 +1,6 @@
1
1
  import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
2
2
 
3
- export const TUTURUUU_PLATFORM_VERSION = '0.63.0'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.65.0'; // x-release-please-version
4
4
 
5
5
  export type PlatformBuildMetadataInput = {
6
6
  /** ISO timestamp of the source commit used for the build, not deployment time. */