@tastic/profile 0.3.8 → 0.7.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.
@@ -0,0 +1,81 @@
1
+ import { ActionCreator, createActionCreator } from './createActionCreator'
2
+
3
+ // redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
4
+ // dependency on the package itself — this factory otherwise has no opinion on whether the
5
+ // consuming app uses redux-persist at all, and the string itself is a long-stable part of
6
+ // redux-persist's own public contract (unchanged across every major version). See the reducer's
7
+ // own REHYDRATE case below for why this needs handling at all.
8
+ const REHYDRATE = 'persist/REHYDRATE'
9
+
10
+ export interface SelectProfilePayload<TSeat> {
11
+ profileId: string | null
12
+ seat: TSeat
13
+ }
14
+
15
+ export interface ProfileSelectionActions<TSeat> {
16
+ clearProfile: ActionCreator<string>
17
+ select: ActionCreator<SelectProfilePayload<TSeat>>
18
+ }
19
+
20
+ export type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>
21
+
22
+ export interface ProfileSelectionSlice<TSeat extends string | number> {
23
+ actions: ProfileSelectionActions<TSeat>
24
+ reducer: (state: ProfileSelectionState<TSeat> | undefined, action: { type: string; payload?: unknown }) => ProfileSelectionState<TSeat>
25
+ }
26
+
27
+ // Which profile each seat last picked — always local, never part of the shared App Group roster
28
+ // (this package's own profiles slice, see profilesSlice.ts): who's selected on THIS device is
29
+ // naturally a per-app, per-device thing, not something a sibling @tastic game should see or
30
+ // influence. Five @tastic games (AirHockey, BoxHockey, Pong, LightCycles, Snake) each hand-rolled
31
+ // an identical Record<Seat, string | null> reducer before this factory existed — same duplication
32
+ // createProfileExtensionSlice generalized away for per-profile extensions, this is that shape for
33
+ // per-seat selection instead.
34
+ //
35
+ // `namespace` prefixes every action type (e.g. 'profileSelection/loadout/select') so an app with
36
+ // more than one independent seat-selection concern never collides between them — same convention
37
+ // createProfileExtensionSlice's own namespace argument follows.
38
+ //
39
+ // Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't
40
+ // exist at runtime), so callers pass their own `defaultState` — the same literal object (e.g.
41
+ // `{ 1: null, 2: null }`) each app already declares as its own `defaultProfileSelectionState`
42
+ // constant today. `clearProfile` walks that state's own keys rather than assuming any particular
43
+ // seat count, so it reproduces the 5 apps' current two-seat behavior without hardcoding "1"/"2".
44
+ //
45
+ // `mountKey` defaults to 'profileSelection' — the literal key every current consumer (AirHockey,
46
+ // BoxHockey, Pong, LightCycles, Snake) already mounts this reducer under in its own
47
+ // combineReducers({...}) call (e.g. `combineReducers({ ..., profileSelection })`). It has to be
48
+ // passed explicitly rather than inferred, since redux-persist's REHYDRATE payload is keyed by
49
+ // whatever name the app's own root reducer happens to use, which this factory has no way to see
50
+ // from inside its own reducer function — only worth overriding if some future consumer ever mounts
51
+ // this under a different key. Note `namespace` above is a different thing: it's the app's own name
52
+ // (e.g. 'airhockey', 'boxhockey'), used only to prefix action types, not the mount key.
53
+ export function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey = 'profileSelection'): ProfileSelectionSlice<TSeat> {
54
+ const select = createActionCreator<SelectProfilePayload<TSeat>>(`profileSelection/${namespace}/select`)
55
+ const clearProfile = createActionCreator<string>(`profileSelection/${namespace}/clearProfile`)
56
+
57
+ function reducer(state: ProfileSelectionState<TSeat> = defaultState, action: { type: string; payload?: unknown }): ProfileSelectionState<TSeat> {
58
+ // redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
59
+ // entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
60
+ // against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) —
61
+ // so a device with a blob persisted before a new seat existed would otherwise rehydrate with
62
+ // that seat's key missing entirely, and every consumer indexes into it (`state[seat]`) with no
63
+ // fallback. Spreading `defaultState` first, then the incoming persisted value, backfills exactly
64
+ // that gap — the same pattern createSeatColorsSlice's own REHYDRATE case already uses.
65
+ if (action.type === REHYDRATE) {
66
+ const persisted = (action.payload as Record<string, Partial<ProfileSelectionState<TSeat>>> | undefined)?.[mountKey]
67
+ return { ...defaultState, ...state, ...persisted }
68
+ }
69
+ if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId } as ProfileSelectionState<TSeat>
70
+ if (clearProfile.match(action)) {
71
+ const next = { ...state }
72
+ for (const seat of Object.keys(state) as TSeat[]) {
73
+ if (next[seat] === action.payload) next[seat] = null
74
+ }
75
+ return next
76
+ }
77
+ return state
78
+ }
79
+
80
+ return { actions: { clearProfile, select }, reducer }
81
+ }
@@ -0,0 +1,47 @@
1
+ import { Profile } from '../types'
2
+ import { createActionCreator } from './createActionCreator'
3
+
4
+ export interface UpdateProfilePayload {
5
+ id: string
6
+ patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>
7
+ }
8
+
9
+ // setAll: full-replace, for hydrating from resolveInitialProfiles or a remote refresh
10
+ // (useSharedProfilesSync's onRemoteChange). add/update/remove: the actual roster CRUD every
11
+ // @tastic game already hand-rolled an identical version of in its own useProfiles.tsx.
12
+ const setAll = createActionCreator<Profile[]>('profiles/setAll')
13
+ const add = createActionCreator<Profile>('profiles/add')
14
+ const update = createActionCreator<UpdateProfilePayload>('profiles/update')
15
+ const remove = createActionCreator<string>('profiles/remove')
16
+
17
+ export const profilesActions = { add, remove, setAll, update }
18
+
19
+ export function profilesReducer(state: Profile[] = [], action: { type: string }): Profile[] {
20
+ if (setAll.match(action)) return action.payload
21
+ if (add.match(action)) return [...state, action.payload]
22
+ if (update.match(action)) {
23
+ const { id, patch } = action.payload
24
+ return state.map((p) => (p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p))
25
+ }
26
+ if (remove.match(action)) return state.filter((p) => p.id !== action.payload)
27
+ return state
28
+ }
29
+
30
+ function generateProfileId(): string {
31
+ return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
32
+ }
33
+
34
+ export interface CreateProfileInput {
35
+ name: string
36
+ color: string
37
+ tag: string
38
+ }
39
+
40
+ // Builds a complete Profile (id + timestamps) ready to dispatch via profilesActions.add. Kept as
41
+ // its own plain function rather than folded into the `add` action creator, so `add` itself stays a
42
+ // simple "append this exact record" primitive (useful for rehydration/tests too, where the record
43
+ // already exists and shouldn't get a second, different id/timestamp minted for it).
44
+ export function createProfileRecord(input: CreateProfileInput): Profile {
45
+ const now = Date.now()
46
+ return { color: input.color, createdAt: now, id: generateProfileId(), name: input.name, tag: input.tag, updatedAt: now }
47
+ }
@@ -0,0 +1,26 @@
1
+ import { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
2
+ import { Profile } from '../types'
3
+
4
+ // Resolves the roster an app should start with, given whatever it already had saved in its own
5
+ // local fallback storage (however that app stores it — this never touches AsyncStorage itself,
6
+ // only the shared App Group store via loadSharedProfiles/saveSharedProfiles). Every @tastic game
7
+ // hand-rolled an identical version of this exact decision inline in its own useProfiles.tsx; this
8
+ // is that logic, extracted verbatim.
9
+ //
10
+ // - Shared store unavailable (Android/web, or an iOS build that hasn't run `expo prebuild` since
11
+ // the entitlement was added): `localFallback` IS the roster, verbatim.
12
+ // - Shared store available, and genuinely empty: either a first launch, or the first time THIS
13
+ // app has synced since the App Group entitlement was added — seed it from `localFallback` (so
14
+ // an upgrading user's own profiles don't just vanish), and mirror that seed to the shared store
15
+ // (fire-and-forget; a failed seed write just means the next successful write catches up).
16
+ // - Shared store available and non-empty: shared always wins over a stale local snapshot, never
17
+ // merged with it — avoids resurrecting a profile that was deleted from another app.
18
+ export async function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]> {
19
+ if (!isSharedProfileStoreAvailable) return localFallback
20
+ const shared = await loadSharedProfiles(groupId)
21
+ if (shared.length === 0 && localFallback.length > 0) {
22
+ saveSharedProfiles(groupId, localFallback).catch(() => {})
23
+ return localFallback
24
+ }
25
+ return shared
26
+ }
@@ -0,0 +1,96 @@
1
+ import { ActionCreator, createActionCreator } from './createActionCreator'
2
+
3
+ // redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
4
+ // dependency on the package itself — this factory otherwise has no opinion on whether the
5
+ // consuming app uses redux-persist at all, and the string itself is a long-stable part of
6
+ // redux-persist's own public contract (unchanged across every major version). See the reducer's
7
+ // own REHYDRATE case below for why this needs handling at all.
8
+ const REHYDRATE = 'persist/REHYDRATE'
9
+
10
+ export interface SetLastGuestColorPayload<TSeat> {
11
+ color: string
12
+ seat: TSeat
13
+ }
14
+
15
+ export interface SetProfileOverridePayload<TSeat> {
16
+ color: string | null
17
+ seat: TSeat
18
+ }
19
+
20
+ export interface SeatColorsActions<TSeat> {
21
+ setLastCpuColor: ActionCreator<string>
22
+ setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>
23
+ setProfileOverride: ActionCreator<SetProfileOverridePayload<TSeat>>
24
+ }
25
+
26
+ export type SeatColorsState<TSeat extends string | number> = {
27
+ lastCpuColor: string
28
+ lastGuestColor: Record<TSeat, string>
29
+ profileOverride: Record<TSeat, string | null>
30
+ }
31
+
32
+ export interface SeatColorsSlice<TSeat extends string | number> {
33
+ actions: SeatColorsActions<TSeat>
34
+ reducer: (state: SeatColorsState<TSeat> | undefined, action: { type: string; payload?: unknown }) => SeatColorsState<TSeat>
35
+ }
36
+
37
+ // Which color a seat should show right now, remembered independently for three sources that must
38
+ // never fight over one value: a guest seat's own last pick, the CPU seat's own last pick, and a
39
+ // manual recolor/clash-swap override landing on a seat that currently has a profile selected (null
40
+ // meaning "no override, track that profile's own saved color live"). Four @tastic games (AirHockey,
41
+ // BoxHockey, LightCycles, Pong) each hand-rolled an identical dedicated seatColorsSlice.ts — same
42
+ // three fields, same createSlice-based reducer, differing only in their own seat type's name
43
+ // (Player) and default-color constants — before this factory existed; this is that shape,
44
+ // generalized over the seat type exactly like createProfileSelectionSlice generalized per-seat
45
+ // profile selection.
46
+ //
47
+ // Snake has the same three fields too, but folded directly into its own catch-all gameSlice.ts
48
+ // alongside a dozen unrelated persisted settings (wrapEdges, cpuDifficulty, speedTier, ...) rather
49
+ // than in a standalone slice of their own. There's no separate `seatColors` sub-state there for this
50
+ // factory's reducer to own without restructuring gameSlice's top-level shape to nest these three
51
+ // fields under their own key first — a real state-shape migration (and a redux-persist rehydration
52
+ // concern, see gameSlice's own REHYDRATE extraReducer), not a drop-in swap. This factory
53
+ // intentionally targets the standalone-slice shape the other four apps already have; Snake staying
54
+ // hand-rolled inside gameSlice is a deliberate scope call for this pass, not an oversight.
55
+ //
56
+ // `namespace` prefixes every action type (e.g. 'seatColors/loadout/setLastGuestColor') so an app
57
+ // with more than one independent seat-colors concern never collides between them — same convention
58
+ // createProfileSelectionSlice's and createProfileExtensionSlice's own namespace arguments follow.
59
+ //
60
+ // Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't
61
+ // exist at runtime), so callers pass their own `defaultState` — same reasoning, and the same
62
+ // {1: ..., 2: ...}-shaped literal each app already declares as its own defaultSeatColorsState
63
+ // constant today, as createProfileSelectionSlice's own defaultState parameter.
64
+ //
65
+ // `mountKey` defaults to 'seatColors' — the literal key every current consumer (AirHockey,
66
+ // BoxHockey, Pong) already mounts this reducer under in its own combineReducers({...}) call (e.g.
67
+ // `combineReducers({ ..., seatColors })`). It has to be passed explicitly rather than inferred,
68
+ // since redux-persist's REHYDRATE payload is keyed by whatever name the app's own root reducer
69
+ // happens to use, which this factory has no way to see from inside its own reducer function — only
70
+ // worth overriding if some future consumer ever mounts this under a different key.
71
+ export function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey = 'seatColors'): SeatColorsSlice<TSeat> {
72
+ const setLastCpuColor = createActionCreator<string>(`seatColors/${namespace}/setLastCpuColor`)
73
+ const setLastGuestColor = createActionCreator<SetLastGuestColorPayload<TSeat>>(`seatColors/${namespace}/setLastGuestColor`)
74
+ const setProfileOverride = createActionCreator<SetProfileOverridePayload<TSeat>>(`seatColors/${namespace}/setProfileOverride`)
75
+
76
+ function reducer(state: SeatColorsState<TSeat> = defaultState, action: { type: string; payload?: unknown }): SeatColorsState<TSeat> {
77
+ // redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
78
+ // entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
79
+ // against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) —
80
+ // so a device with a blob persisted before `profileOverride` existed would otherwise rehydrate
81
+ // with `state.profileOverride === undefined`, and every consumer indexes into it (`profileOverride[seat]`)
82
+ // with no fallback, throwing. Spreading `defaultState` first, then the incoming persisted
83
+ // value, backfills exactly that gap — the same pattern Snake's own hand-rolled `gameSlice.ts`
84
+ // already uses for its equivalent (non-factory) fields via its own REHYDRATE extraReducer.
85
+ if (action.type === REHYDRATE) {
86
+ const persisted = (action.payload as Record<string, Partial<SeatColorsState<TSeat>>> | undefined)?.[mountKey]
87
+ return { ...defaultState, ...state, ...persisted }
88
+ }
89
+ if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload }
90
+ if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } as Record<TSeat, string> }
91
+ if (setProfileOverride.match(action)) return { ...state, profileOverride: { ...state.profileOverride, [action.payload.seat]: action.payload.color } as Record<TSeat, string | null> }
92
+ return state
93
+ }
94
+
95
+ return { actions: { setLastCpuColor, setLastGuestColor, setProfileOverride }, reducer }
96
+ }
@@ -0,0 +1,74 @@
1
+ import { useCallback, useEffect, useRef } from 'react'
2
+ import { AppState } from 'react-native'
3
+
4
+ import { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
5
+ import { Profile } from '../types'
6
+
7
+ export interface UseSharedProfilesSyncOptions {
8
+ groupId: string
9
+ // Called with a freshly-read shared roster whenever the app returns to the foreground — the
10
+ // shared store has no cross-process change notification, so this is what catches "edited a
11
+ // profile in a different @tastic app, then switched back to this one." Skipped while a
12
+ // syncToShared write is still in flight (see pendingWriteRef below), and never called at all
13
+ // when the shared store isn't available.
14
+ onRemoteChange: (profiles: Profile[]) => void
15
+ }
16
+
17
+ export interface UseSharedProfilesSyncResult {
18
+ // Call right after applying a roster change locally (dispatching profilesActions.setAll/add/
19
+ // update/remove) to mirror it to the shared store. No-ops when the shared store isn't available
20
+ // — the caller is responsible for its own local persistence in that case, same as before this
21
+ // hook existed. Fire-and-forget: never throws, never awaited by this hook itself.
22
+ syncToShared: (next: Profile[]) => void
23
+ }
24
+
25
+ // Bundles the two things every @tastic game needs around the shared App Group roster once
26
+ // `profiles` lives in its own Redux store: mirroring local writes out to the shared store, and
27
+ // picking up remote writes a sibling app made while this one was backgrounded. Deliberately
28
+ // separate from resolveInitialProfiles — that one's for the one-time initial load, this one's for
29
+ // the app's whole lifetime after that.
30
+ export function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult {
31
+ // True for the duration of an in-flight syncToShared write — the foreground-refresh effect below
32
+ // skips a refresh while this is true, rather than risk reading back a pre-write snapshot and
33
+ // handing the caller an onRemoteChange that reverts the write still landing. A ref, not state:
34
+ // read from inside the AppState listener's closure, which is only created once per subscription
35
+ // and would otherwise see whatever this was at mount time, forever.
36
+ const pendingWriteRef = useRef(false)
37
+ // Same reasoning, for onRemoteChange itself — an app typically passes an inline callback that
38
+ // closes over its own latest dispatch/state, so the listener must always call the current one,
39
+ // not whichever was in scope when the effect below first subscribed.
40
+ const onRemoteChangeRef = useRef(onRemoteChange)
41
+ useEffect(() => {
42
+ onRemoteChangeRef.current = onRemoteChange
43
+ }, [onRemoteChange])
44
+
45
+ useEffect(() => {
46
+ if (!isSharedProfileStoreAvailable) return
47
+ let cancelled = false
48
+ const sub = AppState.addEventListener('change', (next) => {
49
+ if (next !== 'active' || pendingWriteRef.current) return
50
+ loadSharedProfiles(groupId).then((shared) => {
51
+ if (!cancelled) onRemoteChangeRef.current(shared)
52
+ })
53
+ })
54
+ return () => {
55
+ cancelled = true
56
+ sub.remove()
57
+ }
58
+ }, [groupId])
59
+
60
+ const syncToShared = useCallback(
61
+ (next: Profile[]) => {
62
+ if (!isSharedProfileStoreAvailable) return
63
+ pendingWriteRef.current = true
64
+ saveSharedProfiles(groupId, next)
65
+ .catch(() => {})
66
+ .finally(() => {
67
+ pendingWriteRef.current = false
68
+ })
69
+ },
70
+ [groupId]
71
+ )
72
+
73
+ return { syncToShared }
74
+ }