volleyballsimtypes 0.0.478 → 0.0.480
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/dist/cjs/src/api/index.d.ts +1 -0
- package/dist/cjs/src/data/models/tactics.d.ts +1 -0
- package/dist/cjs/src/data/transformers/rally-event-tempo.test.d.ts +1 -0
- package/dist/cjs/src/data/transformers/rally-event-tempo.test.js +40 -0
- package/dist/cjs/src/data/transformers/rally-event.d.ts +1 -0
- package/dist/cjs/src/data/transformers/rally-event.js +5 -1
- package/dist/cjs/src/data/transformers/tactics.js +8 -4
- package/dist/cjs/src/data/transformers/tactics.test.js +13 -0
- package/dist/cjs/src/service/event/schemas/set.z.d.ts +1 -0
- package/dist/cjs/src/service/event/schemas/set.z.js +4 -1
- package/dist/cjs/src/service/event/schemas/set.z.test.d.ts +1 -0
- package/dist/cjs/src/service/event/schemas/set.z.test.js +29 -0
- package/dist/cjs/src/service/event/set.d.ts +7 -0
- package/dist/cjs/src/service/event/set.js +15 -3
- package/dist/cjs/src/service/index.d.ts +1 -0
- package/dist/cjs/src/service/index.js +1 -0
- package/dist/cjs/src/service/notification/catalog/en.json +41 -0
- package/dist/cjs/src/service/notification/catalog/es.json +41 -0
- package/dist/cjs/src/service/notification/catalog/fr.json +41 -0
- package/dist/cjs/src/service/notification/catalog/index.d.ts +3 -0
- package/dist/cjs/src/service/notification/catalog/index.js +22 -0
- package/dist/cjs/src/service/notification/catalog/it.json +41 -0
- package/dist/cjs/src/service/notification/catalog/pl.json +41 -0
- package/dist/cjs/src/service/notification/catalog/pt-BR.json +41 -0
- package/dist/cjs/src/service/notification/catalog/tr.json +41 -0
- package/dist/cjs/src/service/notification/index.d.ts +5 -0
- package/dist/cjs/src/service/notification/index.js +23 -0
- package/dist/cjs/src/service/notification/notification-type.d.ts +6 -0
- package/dist/cjs/src/service/notification/notification-type.js +30 -0
- package/dist/cjs/src/service/notification/notification.test.d.ts +1 -0
- package/dist/cjs/src/service/notification/notification.test.js +128 -0
- package/dist/cjs/src/service/notification/payloads.d.ts +34 -0
- package/dist/cjs/src/service/notification/payloads.js +5 -0
- package/dist/cjs/src/service/notification/registry.d.ts +13 -0
- package/dist/cjs/src/service/notification/registry.js +112 -0
- package/dist/cjs/src/service/notification/render.d.ts +10 -0
- package/dist/cjs/src/service/notification/render.js +64 -0
- package/dist/cjs/src/service/team/offensive-preference.d.ts +2 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +8 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.js +12 -2
- package/dist/cjs/src/service/team/schemas/tactics.z.test.js +24 -0
- package/dist/cjs/src/service/team/schemas/team.z.d.ts +8 -0
- package/dist/esm/src/api/index.d.ts +1 -0
- package/dist/esm/src/data/models/tactics.d.ts +1 -0
- package/dist/esm/src/data/transformers/rally-event-tempo.test.d.ts +1 -0
- package/dist/esm/src/data/transformers/rally-event-tempo.test.js +38 -0
- package/dist/esm/src/data/transformers/rally-event.d.ts +1 -0
- package/dist/esm/src/data/transformers/rally-event.js +5 -1
- package/dist/esm/src/data/transformers/tactics.js +8 -4
- package/dist/esm/src/data/transformers/tactics.test.js +13 -0
- package/dist/esm/src/service/event/schemas/set.z.d.ts +1 -0
- package/dist/esm/src/service/event/schemas/set.z.js +4 -1
- package/dist/esm/src/service/event/schemas/set.z.test.d.ts +1 -0
- package/dist/esm/src/service/event/schemas/set.z.test.js +27 -0
- package/dist/esm/src/service/event/set.d.ts +7 -0
- package/dist/esm/src/service/event/set.js +14 -2
- package/dist/esm/src/service/index.d.ts +1 -0
- package/dist/esm/src/service/index.js +1 -0
- package/dist/esm/src/service/notification/catalog/en.json +41 -0
- package/dist/esm/src/service/notification/catalog/es.json +41 -0
- package/dist/esm/src/service/notification/catalog/fr.json +41 -0
- package/dist/esm/src/service/notification/catalog/index.d.ts +3 -0
- package/dist/esm/src/service/notification/catalog/index.js +16 -0
- package/dist/esm/src/service/notification/catalog/it.json +41 -0
- package/dist/esm/src/service/notification/catalog/pl.json +41 -0
- package/dist/esm/src/service/notification/catalog/pt-BR.json +41 -0
- package/dist/esm/src/service/notification/catalog/tr.json +41 -0
- package/dist/esm/src/service/notification/index.d.ts +5 -0
- package/dist/esm/src/service/notification/index.js +5 -0
- package/dist/esm/src/service/notification/notification-type.d.ts +6 -0
- package/dist/esm/src/service/notification/notification-type.js +26 -0
- package/dist/esm/src/service/notification/notification.test.d.ts +1 -0
- package/dist/esm/src/service/notification/notification.test.js +126 -0
- package/dist/esm/src/service/notification/payloads.d.ts +34 -0
- package/dist/esm/src/service/notification/payloads.js +4 -0
- package/dist/esm/src/service/notification/registry.d.ts +13 -0
- package/dist/esm/src/service/notification/registry.js +109 -0
- package/dist/esm/src/service/notification/render.d.ts +10 -0
- package/dist/esm/src/service/notification/render.js +58 -0
- package/dist/esm/src/service/team/offensive-preference.d.ts +2 -0
- package/dist/esm/src/service/team/schemas/tactics.z.d.ts +8 -0
- package/dist/esm/src/service/team/schemas/tactics.z.js +12 -2
- package/dist/esm/src/service/team/schemas/tactics.z.test.js +24 -0
- package/dist/esm/src/service/team/schemas/team.z.d.ts +8 -0
- package/package.json +6 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { NOTIFICATION_TYPES, NOTIFICATION_LOCALES } from './notification-type';
|
|
3
|
+
import { NOTIFICATION_REGISTRY } from './registry';
|
|
4
|
+
import { NOTIFICATION_CATALOG } from './catalog';
|
|
5
|
+
import { renderPush, notificationPath, interpolate, translateNotification } from './render';
|
|
6
|
+
function readNested(node, dotKey) {
|
|
7
|
+
let current = node;
|
|
8
|
+
for (const part of dotKey.split('.')) {
|
|
9
|
+
if (typeof current !== 'object' || current == null)
|
|
10
|
+
return undefined;
|
|
11
|
+
current = current[part];
|
|
12
|
+
}
|
|
13
|
+
return typeof current === 'string' ? current : undefined;
|
|
14
|
+
}
|
|
15
|
+
function placeholders(value) {
|
|
16
|
+
return (value.match(/\{\{(\w+)\}\}/g) ?? []).sort((a, b) => a.localeCompare(b));
|
|
17
|
+
}
|
|
18
|
+
function flattenStrings(node, prefix = '') {
|
|
19
|
+
const out = [];
|
|
20
|
+
if (typeof node !== 'object' || node == null)
|
|
21
|
+
return out;
|
|
22
|
+
for (const [key, value] of Object.entries(node)) {
|
|
23
|
+
if (typeof value === 'string')
|
|
24
|
+
out.push([prefix + key, value]);
|
|
25
|
+
else if (typeof value === 'object' && value != null)
|
|
26
|
+
out.push(...flattenStrings(value, `${prefix}${key}.`));
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
describe('notification registry + catalog completeness', () => {
|
|
31
|
+
it('has exactly one descriptor per declared type', () => {
|
|
32
|
+
expect(Object.keys(NOTIFICATION_REGISTRY).sort((a, b) => a.localeCompare(b)))
|
|
33
|
+
.toEqual([...NOTIFICATION_TYPES].sort((a, b) => a.localeCompare(b)));
|
|
34
|
+
});
|
|
35
|
+
it('resolves every descriptor catalog key to a non-empty string in all 7 locales', () => {
|
|
36
|
+
for (const type of NOTIFICATION_TYPES) {
|
|
37
|
+
for (const key of NOTIFICATION_REGISTRY[type].catalogKeys) {
|
|
38
|
+
for (const locale of NOTIFICATION_LOCALES) {
|
|
39
|
+
const value = readNested(NOTIFICATION_CATALOG[locale], key);
|
|
40
|
+
expect([locale, key, (value ?? '').length > 0]).toEqual([locale, key, true]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
it('ships an identical key set across all 7 locales (no drift)', () => {
|
|
46
|
+
const enKeys = flattenStrings(NOTIFICATION_CATALOG.en).map(([key]) => key).sort((a, b) => a.localeCompare(b));
|
|
47
|
+
for (const locale of NOTIFICATION_LOCALES) {
|
|
48
|
+
const keys = flattenStrings(NOTIFICATION_CATALOG[locale]).map(([key]) => key).sort((a, b) => a.localeCompare(b));
|
|
49
|
+
expect([locale, keys]).toEqual([locale, enKeys]);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
it('keeps placeholders identical across locales', () => {
|
|
53
|
+
for (const [key, enValue] of flattenStrings(NOTIFICATION_CATALOG.en)) {
|
|
54
|
+
const expected = placeholders(enValue);
|
|
55
|
+
for (const locale of NOTIFICATION_LOCALES) {
|
|
56
|
+
const value = readNested(NOTIFICATION_CATALOG[locale], key);
|
|
57
|
+
if (value == null)
|
|
58
|
+
continue;
|
|
59
|
+
expect([locale, key, placeholders(value)]).toEqual([locale, key, expected]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
it('contains no em or en dashes', () => {
|
|
64
|
+
for (const locale of NOTIFICATION_LOCALES) {
|
|
65
|
+
for (const [key, value] of flattenStrings(NOTIFICATION_CATALOG[locale])) {
|
|
66
|
+
expect([locale, key, value.includes('—') || value.includes('–')]).toEqual([locale, key, false]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('renderPush', () => {
|
|
72
|
+
it('renders a localized MATCH_RESULT win with a named opponent', () => {
|
|
73
|
+
const payload = { matchId: 'm1', result: 'WIN', side: 'HOME', awayTeam: { name: 'Rivals' } };
|
|
74
|
+
const en = renderPush('MATCH_RESULT', payload, 'en');
|
|
75
|
+
expect(en?.title).toBe('Match result');
|
|
76
|
+
expect(en?.body).toBe('You won against Rivals');
|
|
77
|
+
expect(en?.data.path).toBe('/match/m1');
|
|
78
|
+
expect(renderPush('MATCH_RESULT', payload, 'es')?.body).toBe('Ganaste contra Rivals');
|
|
79
|
+
});
|
|
80
|
+
it('uses the unnamed variant and root path when the opponent is unknown', () => {
|
|
81
|
+
const en = renderPush('MATCH_RESULT', { result: 'LOSS' }, 'en');
|
|
82
|
+
expect(en?.body).toBe('You lost your match');
|
|
83
|
+
expect(en?.data.path).toBe('/');
|
|
84
|
+
});
|
|
85
|
+
it('interpolates the injured player name and links to the infirmary', () => {
|
|
86
|
+
const en = renderPush('INJURY', { playerNickname: 'Ace', teamId: 't1' }, 'en');
|
|
87
|
+
expect(en?.title).toBe('Player injured');
|
|
88
|
+
expect(en?.body).toContain('Ace');
|
|
89
|
+
expect(en?.data.path).toBe('/infirmary');
|
|
90
|
+
});
|
|
91
|
+
it('uses a generic subject when the injury payload has no player name', () => {
|
|
92
|
+
const en = renderPush('INJURY', { teamId: 't1' }, 'en');
|
|
93
|
+
expect(en?.body.startsWith('A player')).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
it('returns undefined for in-app-only types (COMPENSATION)', () => {
|
|
96
|
+
expect(renderPush('COMPENSATION', { currency: 100 }, 'en')).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
it('falls back to generic copy and root path for an unknown type', () => {
|
|
99
|
+
const en = renderPush('SOMETHING_NEW', {}, 'en');
|
|
100
|
+
expect(en?.title).toBe('World Aces');
|
|
101
|
+
expect(en?.body).toBe('You have a new notification.');
|
|
102
|
+
expect(en?.data.path).toBe('/');
|
|
103
|
+
});
|
|
104
|
+
it('serializes the payload into the FCM data block', () => {
|
|
105
|
+
const en = renderPush('GRANT_RECEIVED', { pullType: 'MYTHIC' }, 'en');
|
|
106
|
+
expect(en?.data.type).toBe('GRANT_RECEIVED');
|
|
107
|
+
expect(JSON.parse(en?.data.payload ?? '{}')).toEqual({ pullType: 'MYTHIC' });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('translateNotification + interpolate + notificationPath', () => {
|
|
111
|
+
it('falls back to English then to the raw key', () => {
|
|
112
|
+
expect(translateNotification('fr', 'promotion.title')).toBe('Promotion');
|
|
113
|
+
expect(translateNotification('en', 'does.not.exist')).toBe('does.not.exist');
|
|
114
|
+
});
|
|
115
|
+
it('replaces placeholders and drops unknown ones', () => {
|
|
116
|
+
expect(interpolate('Hi {{name}}!', { name: 'X' })).toBe('Hi X!');
|
|
117
|
+
expect(interpolate('Hi {{name}}!', {})).toBe('Hi !');
|
|
118
|
+
});
|
|
119
|
+
it('derives the deep-link path per type', () => {
|
|
120
|
+
expect(notificationPath('SEASON_END', { leagueId: 'l1' })).toBe('/league/l1');
|
|
121
|
+
expect(notificationPath('SEASON_END', {})).toBe('/league');
|
|
122
|
+
expect(notificationPath('PROMOTION', { teamId: 't1' })).toBe('/team/t1');
|
|
123
|
+
expect(notificationPath('GRANT_RECEIVED', {})).toBe('/recruit');
|
|
124
|
+
expect(notificationPath('COMPENSATION', {})).toBe('/');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface MatchResultTeam {
|
|
2
|
+
id?: string;
|
|
3
|
+
name?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface MatchResultPayload {
|
|
6
|
+
matchId?: string;
|
|
7
|
+
homeTeam?: MatchResultTeam;
|
|
8
|
+
awayTeam?: MatchResultTeam;
|
|
9
|
+
result?: 'WIN' | 'LOSS';
|
|
10
|
+
side?: 'HOME' | 'AWAY';
|
|
11
|
+
}
|
|
12
|
+
export interface InjuryPayload {
|
|
13
|
+
playerId?: string;
|
|
14
|
+
playerName?: string;
|
|
15
|
+
playerNickname?: string;
|
|
16
|
+
teamId?: string;
|
|
17
|
+
matchId?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface InjuryRecoveredPayload {
|
|
20
|
+
playerId?: string;
|
|
21
|
+
playerName?: string;
|
|
22
|
+
playerNickname?: string;
|
|
23
|
+
teamId?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface SeasonEndPayload {
|
|
26
|
+
competitionId?: string;
|
|
27
|
+
leagueId?: string;
|
|
28
|
+
position?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface PromotionPayload {
|
|
31
|
+
teamId?: string;
|
|
32
|
+
amount?: number;
|
|
33
|
+
}
|
|
34
|
+
export type NotificationPayload = MatchResultPayload | InjuryPayload | InjuryRecoveredPayload | SeasonEndPayload | PromotionPayload | Record<string, unknown>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Typed payload shapes per notification type, for call-site safety in the Sim/API. The DB `Notification.payload`
|
|
2
|
+
// column is jsonb (free-form), so the registry reads payloads defensively (Record<string, unknown>); these
|
|
3
|
+
// interfaces document the intended shape and can be used by emitters when constructing the payload.
|
|
4
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type NotificationType } from './notification-type';
|
|
2
|
+
export type CatalogKeyResolver = string | ((payload: Record<string, unknown>) => string);
|
|
3
|
+
export interface NotificationDescriptor {
|
|
4
|
+
emitsPush: boolean;
|
|
5
|
+
deepLink?: (payload: Record<string, unknown>) => string;
|
|
6
|
+
pushTitleKey?: CatalogKeyResolver;
|
|
7
|
+
pushBodyKey?: CatalogKeyResolver;
|
|
8
|
+
inAppMessageKey?: CatalogKeyResolver;
|
|
9
|
+
richRender?: NotificationType;
|
|
10
|
+
params?: (payload: Record<string, unknown>) => Record<string, string>;
|
|
11
|
+
catalogKeys: string[];
|
|
12
|
+
}
|
|
13
|
+
export declare const NOTIFICATION_REGISTRY: Record<NotificationType, NotificationDescriptor>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
function readString(value) {
|
|
2
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
3
|
+
}
|
|
4
|
+
function teamPath(payload) {
|
|
5
|
+
const teamId = readString(payload.teamId);
|
|
6
|
+
return teamId != null ? `/team/${encodeURIComponent(teamId)}` : '/';
|
|
7
|
+
}
|
|
8
|
+
// MATCH_RESULT copy is recipient-relative: `result` is WIN/LOSS from the recipient's view and `side` marks
|
|
9
|
+
// which team is theirs, so the OTHER team is the opponent. When side/names are missing, do not name a winner.
|
|
10
|
+
function matchOpponent(payload) {
|
|
11
|
+
const homeTeam = typeof payload.homeTeam === 'object' && payload.homeTeam != null ? payload.homeTeam : {};
|
|
12
|
+
const awayTeam = typeof payload.awayTeam === 'object' && payload.awayTeam != null ? payload.awayTeam : {};
|
|
13
|
+
const homeName = readString(homeTeam.name);
|
|
14
|
+
const awayName = readString(awayTeam.name);
|
|
15
|
+
if (payload.side === 'HOME')
|
|
16
|
+
return awayName;
|
|
17
|
+
if (payload.side === 'AWAY')
|
|
18
|
+
return homeName;
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
function injuredName(payload) {
|
|
22
|
+
// Matches the old Sim injuredPlayerName fallback so a name-less payload never renders a subjectless sentence.
|
|
23
|
+
return readString(payload.playerNickname) ?? readString(payload.playerName) ?? 'A player';
|
|
24
|
+
}
|
|
25
|
+
export const NOTIFICATION_REGISTRY = {
|
|
26
|
+
MATCH_RESULT: {
|
|
27
|
+
emitsPush: true,
|
|
28
|
+
deepLink: (p) => {
|
|
29
|
+
const matchId = readString(p.matchId);
|
|
30
|
+
return matchId != null ? `/match/${encodeURIComponent(matchId)}` : '/';
|
|
31
|
+
},
|
|
32
|
+
pushTitleKey: 'matchResult.title',
|
|
33
|
+
pushBodyKey: (p) => {
|
|
34
|
+
const outcome = p.result === 'WIN' ? 'won' : 'lost';
|
|
35
|
+
const named = matchOpponent(p) != null ? 'Named' : 'Unnamed';
|
|
36
|
+
return `matchResult.${outcome}${named}`;
|
|
37
|
+
},
|
|
38
|
+
params: (p) => ({ opponent: matchOpponent(p) ?? '' }),
|
|
39
|
+
richRender: 'MATCH_RESULT',
|
|
40
|
+
catalogKeys: ['matchResult.title', 'matchResult.wonNamed', 'matchResult.wonUnnamed', 'matchResult.lostNamed', 'matchResult.lostUnnamed']
|
|
41
|
+
},
|
|
42
|
+
INJURY: {
|
|
43
|
+
emitsPush: true,
|
|
44
|
+
deepLink: () => '/infirmary',
|
|
45
|
+
pushTitleKey: 'injury.title',
|
|
46
|
+
pushBodyKey: 'injury.body',
|
|
47
|
+
inAppMessageKey: 'injury.body',
|
|
48
|
+
params: (p) => ({ name: injuredName(p) }),
|
|
49
|
+
catalogKeys: ['injury.title', 'injury.body']
|
|
50
|
+
},
|
|
51
|
+
INJURY_RECOVERED: {
|
|
52
|
+
emitsPush: true,
|
|
53
|
+
deepLink: teamPath,
|
|
54
|
+
pushTitleKey: 'injuryRecovered.title',
|
|
55
|
+
pushBodyKey: 'injuryRecovered.body',
|
|
56
|
+
inAppMessageKey: 'injuryRecovered.body',
|
|
57
|
+
params: (p) => ({ name: injuredName(p) }),
|
|
58
|
+
catalogKeys: ['injuryRecovered.title', 'injuryRecovered.body']
|
|
59
|
+
},
|
|
60
|
+
SEASON_END: {
|
|
61
|
+
emitsPush: true,
|
|
62
|
+
deepLink: (p) => {
|
|
63
|
+
const leagueId = readString(p.leagueId);
|
|
64
|
+
return leagueId != null ? `/league/${encodeURIComponent(leagueId)}` : '/league';
|
|
65
|
+
},
|
|
66
|
+
pushTitleKey: 'seasonEnd.title',
|
|
67
|
+
pushBodyKey: 'seasonEnd.body',
|
|
68
|
+
inAppMessageKey: 'seasonEnd.body',
|
|
69
|
+
catalogKeys: ['seasonEnd.title', 'seasonEnd.body']
|
|
70
|
+
},
|
|
71
|
+
GRANT_RECEIVED: {
|
|
72
|
+
emitsPush: true,
|
|
73
|
+
deepLink: () => '/recruit',
|
|
74
|
+
pushTitleKey: 'grant.title',
|
|
75
|
+
pushBodyKey: 'grant.body',
|
|
76
|
+
inAppMessageKey: 'grant.body',
|
|
77
|
+
catalogKeys: ['grant.title', 'grant.body']
|
|
78
|
+
},
|
|
79
|
+
PROMOTION: {
|
|
80
|
+
emitsPush: true,
|
|
81
|
+
deepLink: teamPath,
|
|
82
|
+
pushTitleKey: 'promotion.title',
|
|
83
|
+
pushBodyKey: 'promotion.body',
|
|
84
|
+
inAppMessageKey: 'promotion.body',
|
|
85
|
+
catalogKeys: ['promotion.title', 'promotion.body']
|
|
86
|
+
},
|
|
87
|
+
DAILY_LOGIN: {
|
|
88
|
+
emitsPush: true,
|
|
89
|
+
deepLink: () => '/recruit',
|
|
90
|
+
pushTitleKey: 'dailyLogin.title',
|
|
91
|
+
pushBodyKey: 'dailyLogin.body',
|
|
92
|
+
inAppMessageKey: 'dailyLogin.body',
|
|
93
|
+
catalogKeys: ['dailyLogin.title', 'dailyLogin.body']
|
|
94
|
+
},
|
|
95
|
+
WELCOME: {
|
|
96
|
+
emitsPush: true,
|
|
97
|
+
deepLink: () => '/recruit',
|
|
98
|
+
pushTitleKey: 'welcome.title',
|
|
99
|
+
pushBodyKey: 'welcome.body',
|
|
100
|
+
richRender: 'WELCOME',
|
|
101
|
+
catalogKeys: ['welcome.title', 'welcome.body']
|
|
102
|
+
},
|
|
103
|
+
// In-app only (no push); the UI keeps a bespoke renderer (amount formatting) and its own `compensation` label.
|
|
104
|
+
COMPENSATION: {
|
|
105
|
+
emitsPush: false,
|
|
106
|
+
richRender: 'COMPENSATION',
|
|
107
|
+
catalogKeys: []
|
|
108
|
+
}
|
|
109
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type NotificationLocale } from './notification-type';
|
|
2
|
+
export interface RenderedPush {
|
|
3
|
+
title: string;
|
|
4
|
+
body: string;
|
|
5
|
+
data: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
export declare function interpolate(template: string, params: Record<string, string>): string;
|
|
8
|
+
export declare function translateNotification(locale: NotificationLocale, key: string, params?: Record<string, string>): string;
|
|
9
|
+
export declare function renderPush(type: string, payload: Record<string, unknown>, locale: NotificationLocale): RenderedPush | undefined;
|
|
10
|
+
export declare function notificationPath(type: string, payload: Record<string, unknown>): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { DEFAULT_NOTIFICATION_LOCALE } from './notification-type';
|
|
2
|
+
import { NOTIFICATION_REGISTRY } from './registry';
|
|
3
|
+
import { NOTIFICATION_CATALOG } from './catalog';
|
|
4
|
+
function readNested(node, dotKey) {
|
|
5
|
+
let current = node;
|
|
6
|
+
for (const part of dotKey.split('.')) {
|
|
7
|
+
if (typeof current !== 'object' || current == null)
|
|
8
|
+
return undefined;
|
|
9
|
+
current = current[part];
|
|
10
|
+
}
|
|
11
|
+
return typeof current === 'string' ? current : undefined;
|
|
12
|
+
}
|
|
13
|
+
function lookupCatalog(locale, dotKey) {
|
|
14
|
+
return readNested(NOTIFICATION_CATALOG[locale], dotKey) ??
|
|
15
|
+
readNested(NOTIFICATION_CATALOG[DEFAULT_NOTIFICATION_LOCALE], dotKey) ??
|
|
16
|
+
dotKey;
|
|
17
|
+
}
|
|
18
|
+
function resolveKey(resolver, payload) {
|
|
19
|
+
if (resolver == null)
|
|
20
|
+
return undefined;
|
|
21
|
+
return typeof resolver === 'function' ? resolver(payload) : resolver;
|
|
22
|
+
}
|
|
23
|
+
export function interpolate(template, params) {
|
|
24
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => params[key] ?? '');
|
|
25
|
+
}
|
|
26
|
+
// Resolve one catalog key to a localized, interpolated string. Missing key/locale falls back to English,
|
|
27
|
+
// then to the raw key. Exposed so consumers can render notification copy off the shared catalog too.
|
|
28
|
+
export function translateNotification(locale, key, params = {}) {
|
|
29
|
+
return interpolate(lookupCatalog(locale, key), params);
|
|
30
|
+
}
|
|
31
|
+
// Build the localized push payload for a notification. Returns undefined for in-app-only types (emitsPush
|
|
32
|
+
// false, e.g. COMPENSATION). Unknown/future types fall back to the generic copy and the root path, matching
|
|
33
|
+
// the previous hardcoded fallback. Callers pass the recipient's locale (normalizeNotificationLocale first).
|
|
34
|
+
export function renderPush(type, payload, locale) {
|
|
35
|
+
const buildData = (path) => ({ type, payload: JSON.stringify(payload), path });
|
|
36
|
+
const descriptor = NOTIFICATION_REGISTRY[type];
|
|
37
|
+
if (descriptor == null) {
|
|
38
|
+
return {
|
|
39
|
+
title: translateNotification(locale, 'generic.title'),
|
|
40
|
+
body: translateNotification(locale, 'generic.body'),
|
|
41
|
+
data: buildData('/')
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (!descriptor.emitsPush)
|
|
45
|
+
return undefined;
|
|
46
|
+
const params = descriptor.params?.(payload) ?? {};
|
|
47
|
+
const titleKey = resolveKey(descriptor.pushTitleKey, payload) ?? 'generic.title';
|
|
48
|
+
const bodyKey = resolveKey(descriptor.pushBodyKey, payload) ?? 'generic.body';
|
|
49
|
+
return {
|
|
50
|
+
title: translateNotification(locale, titleKey, params),
|
|
51
|
+
body: translateNotification(locale, bodyKey, params),
|
|
52
|
+
data: buildData(descriptor.deepLink?.(payload) ?? '/')
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// Deep-link path for a type/payload, used for both the push data.path and the in-app click target.
|
|
56
|
+
export function notificationPath(type, payload) {
|
|
57
|
+
return NOTIFICATION_REGISTRY[type]?.deepLink?.(payload) ?? '/';
|
|
58
|
+
}
|
|
@@ -270,6 +270,10 @@ export declare const TacticsInputSchema: z.ZodObject<{
|
|
|
270
270
|
offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
271
271
|
rotation: z.ZodNumber;
|
|
272
272
|
order: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
273
|
+
tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
274
|
+
FAST: "FAST";
|
|
275
|
+
HIGH: "HIGH";
|
|
276
|
+
}>>>;
|
|
273
277
|
}, z.core.$strip>>>;
|
|
274
278
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
275
279
|
rotationSystem: z.ZodEnum<typeof RotationSystemEnum>;
|
|
@@ -278,6 +282,10 @@ export declare const TacticsInputSchema: z.ZodObject<{
|
|
|
278
282
|
offensivePreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
|
|
279
283
|
rotation: z.ZodNumber;
|
|
280
284
|
order: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
285
|
+
tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
286
|
+
FAST: "FAST";
|
|
287
|
+
HIGH: "HIGH";
|
|
288
|
+
}>>>;
|
|
281
289
|
}, z.core.$strip>>>>;
|
|
282
290
|
replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
|
283
291
|
injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
@@ -45,11 +45,15 @@ const lineupSchema = z.object({
|
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
47
|
});
|
|
48
|
+
// Per-player tempo preference (set-tempo rework): absent key = Auto.
|
|
49
|
+
const tempoPreferenceSchema = z.enum(['FAST', 'HIGH']);
|
|
48
50
|
// One rotation's ranked attacker priority. `rotation` is the main setter's rotational slot (1..6); `order` lists
|
|
49
|
-
// the attackers ranked best-first.
|
|
51
|
+
// the attackers ranked best-first. `tempos` optionally forces a per-starter tempo (Auto when absent). Cross-field
|
|
52
|
+
// checks (starters only in both order and tempos, no dupes) live in the superRefine below.
|
|
50
53
|
const offensivePreferenceSchema = z.object({
|
|
51
54
|
rotation: z.number().int().min(1).max(6),
|
|
52
|
-
order: z.array(playerInstanceSchema)
|
|
55
|
+
order: z.array(playerInstanceSchema),
|
|
56
|
+
tempos: z.record(z.string(), tempoPreferenceSchema).optional()
|
|
53
57
|
});
|
|
54
58
|
// One set's rotation-system override (per-set tactics): system + its designated setters travel together.
|
|
55
59
|
const systemSetSchema = z.object({
|
|
@@ -155,6 +159,12 @@ export const TacticsInputSchema = z.object({
|
|
|
155
159
|
ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER', path });
|
|
156
160
|
}
|
|
157
161
|
}
|
|
162
|
+
// Tempo preference keys are starter ids too (same rule as order).
|
|
163
|
+
for (const id of Object.keys(pref.tempos ?? {})) {
|
|
164
|
+
if (!starterIds.has(id)) {
|
|
165
|
+
ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_TEMPO_NOT_A_STARTER', path });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
158
168
|
}
|
|
159
169
|
};
|
|
160
170
|
validateSystem(data.rotationSystem, data.designatedSetters, ['designatedSetters']);
|
|
@@ -168,6 +168,30 @@ describe('TacticsInputSchema — per-set tactics (systemSets / offensivePreferen
|
|
|
168
168
|
expect(res.error.issues.some(i => i.message === 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER')).toBe(true);
|
|
169
169
|
}
|
|
170
170
|
});
|
|
171
|
+
it('accepts offensive preferences with a starter-keyed tempos map', () => {
|
|
172
|
+
const res = TacticsInputSchema.safeParse(baseInput({
|
|
173
|
+
offensivePreferences: [{ rotation: 1, order: [p2, p3], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
|
|
174
|
+
}));
|
|
175
|
+
expect(res.success).toBe(true);
|
|
176
|
+
if (res.success) {
|
|
177
|
+
expect(res.data.offensivePreferences[0].tempos).toEqual({ [p2.id]: 'FAST', [p3.id]: 'HIGH' });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
it('rejects a tempos map keyed by a non-starter', () => {
|
|
181
|
+
const res = TacticsInputSchema.safeParse(baseInput({
|
|
182
|
+
offensivePreferences: [{ rotation: 1, order: [p2], tempos: { [reserve.id]: 'FAST' } }]
|
|
183
|
+
}));
|
|
184
|
+
expect(res.success).toBe(false);
|
|
185
|
+
if (!res.success) {
|
|
186
|
+
expect(res.error.issues.some(i => i.message === 'OFFENSIVE_PREFERENCE_TEMPO_NOT_A_STARTER')).toBe(true);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
it('rejects an invalid tempo value', () => {
|
|
190
|
+
const res = TacticsInputSchema.safeParse(baseInput({
|
|
191
|
+
offensivePreferences: [{ rotation: 1, order: [p2], tempos: { [p2.id]: 'SLOW' } }]
|
|
192
|
+
}));
|
|
193
|
+
expect(res.success).toBe(false);
|
|
194
|
+
});
|
|
171
195
|
it('still validates the flat fields when per-set overrides are present', () => {
|
|
172
196
|
// Flat 5-1 with no setter is invalid even though every set entry is valid.
|
|
173
197
|
const res = TacticsInputSchema.safeParse(baseInput({
|
|
@@ -278,6 +278,10 @@ export declare const TeamInputSchema: z.ZodObject<{
|
|
|
278
278
|
offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
279
279
|
rotation: z.ZodNumber;
|
|
280
280
|
order: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
281
|
+
tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
282
|
+
FAST: "FAST";
|
|
283
|
+
HIGH: "HIGH";
|
|
284
|
+
}>>>;
|
|
281
285
|
}, z.core.$strip>>>;
|
|
282
286
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
283
287
|
rotationSystem: z.ZodEnum<typeof import("..").RotationSystemEnum>;
|
|
@@ -286,6 +290,10 @@ export declare const TeamInputSchema: z.ZodObject<{
|
|
|
286
290
|
offensivePreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
|
|
287
291
|
rotation: z.ZodNumber;
|
|
288
292
|
order: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
293
|
+
tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
|
|
294
|
+
FAST: "FAST";
|
|
295
|
+
HIGH: "HIGH";
|
|
296
|
+
}>>>;
|
|
289
297
|
}, z.core.$strip>>>>;
|
|
290
298
|
replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
|
291
299
|
injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "volleyballsimtypes",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.480",
|
|
4
4
|
"description": "vbsim types",
|
|
5
5
|
"main": "./dist/cjs/src/index.js",
|
|
6
6
|
"module": "./dist/esm/src/index.js",
|
|
@@ -37,6 +37,11 @@
|
|
|
37
37
|
"import": "./dist/esm/src/data/common/index.js",
|
|
38
38
|
"require": "./dist/cjs/src/data/common/index.js"
|
|
39
39
|
},
|
|
40
|
+
"./notification": {
|
|
41
|
+
"types": "./dist/esm/src/service/notification/index.d.ts",
|
|
42
|
+
"import": "./dist/esm/src/service/notification/index.js",
|
|
43
|
+
"require": "./dist/cjs/src/service/notification/index.js"
|
|
44
|
+
},
|
|
40
45
|
"./stat-config": {
|
|
41
46
|
"types": "./dist/esm/src/stat-config/index.d.ts",
|
|
42
47
|
"import": "./dist/esm/src/stat-config/index.js",
|