@startsimpli/hooks 0.4.11 → 0.4.15

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,162 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { renderHook, act, waitFor } from '@testing-library/react'
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import React from 'react'
5
+ import {
6
+ useWorkflows,
7
+ useWorkflow,
8
+ useWorkflowNodeTypes,
9
+ useCreateWorkflow,
10
+ useUpdateWorkflow,
11
+ useDeleteWorkflow,
12
+ useExecuteWorkflow,
13
+ useWorkflowExecution,
14
+ useWorkflowExecutions,
15
+ useCancelExecution,
16
+ useRetryExecution,
17
+ executionRefetchInterval,
18
+ } from '../useWorkflows'
19
+ import type { WorkflowsApi } from '@startsimpli/api'
20
+
21
+ function mkClient() {
22
+ return new QueryClient({
23
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
24
+ })
25
+ }
26
+ function wrap(qc: QueryClient) {
27
+ return ({ children }: { children: React.ReactNode }) =>
28
+ React.createElement(QueryClientProvider, { client: qc }, children)
29
+ }
30
+ function trackInvalidations(qc: QueryClient): unknown[][] {
31
+ const calls: unknown[][] = []
32
+ const orig = qc.invalidateQueries.bind(qc)
33
+ qc.invalidateQueries = ((arg: { queryKey: unknown[] }) => {
34
+ calls.push(arg.queryKey)
35
+ return orig(arg)
36
+ }) as typeof qc.invalidateQueries
37
+ return calls
38
+ }
39
+ function fakeApi(overrides: Partial<WorkflowsApi> = {}): WorkflowsApi {
40
+ return {
41
+ listWorkflows: vi.fn().mockResolvedValue({ results: [], count: 0 }),
42
+ getWorkflow: vi.fn().mockResolvedValue({ id: 'w1', name: 'Onboarding' }),
43
+ createWorkflow: vi.fn().mockResolvedValue({ id: 'w1' }),
44
+ updateWorkflow: vi.fn().mockResolvedValue({ id: 'w1', name: 'New' }),
45
+ deleteWorkflow: vi.fn().mockResolvedValue(undefined),
46
+ getNodeTypes: vi.fn().mockResolvedValue([{ slug: 'http', category: 'action', label: 'HTTP' }]),
47
+ executeWorkflow: vi.fn().mockResolvedValue({ executionId: 'e1', status: 'running' }),
48
+ getExecution: vi.fn().mockResolvedValue({ status: 'running', state: {}, nodeExecutions: [] }),
49
+ listExecutions: vi.fn().mockResolvedValue({ results: [], count: 0 }),
50
+ cancelExecution: vi.fn().mockResolvedValue({ id: 'e1', status: 'cancelled' }),
51
+ retryExecution: vi.fn().mockResolvedValue({ executionId: 'e2', status: 'pending' }),
52
+ ...overrides,
53
+ } as unknown as WorkflowsApi
54
+ }
55
+
56
+ describe('workflow query hooks', () => {
57
+ it('useWorkflows fetches the workflow list', async () => {
58
+ const api = fakeApi()
59
+ const { result } = renderHook(() => useWorkflows(api, { page: 1 }), { wrapper: wrap(mkClient()) })
60
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
61
+ expect(api.listWorkflows).toHaveBeenCalledWith({ page: 1 })
62
+ })
63
+
64
+ it('useWorkflow fetches one workflow and is disabled without an id', async () => {
65
+ const api = fakeApi()
66
+ const { result } = renderHook(() => useWorkflow(api, ''), { wrapper: wrap(mkClient()) })
67
+ expect(result.current.fetchStatus).toBe('idle')
68
+ expect(api.getWorkflow).not.toHaveBeenCalled()
69
+ })
70
+
71
+ it('useWorkflowNodeTypes fetches the node-type registry', async () => {
72
+ const api = fakeApi()
73
+ const { result } = renderHook(() => useWorkflowNodeTypes(api), { wrapper: wrap(mkClient()) })
74
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
75
+ expect(api.getNodeTypes).toHaveBeenCalled()
76
+ })
77
+
78
+ it('useWorkflowExecution fetches the execution detail and is disabled without an id', async () => {
79
+ const api = fakeApi()
80
+ const { result } = renderHook(() => useWorkflowExecution(api, ''), { wrapper: wrap(mkClient()) })
81
+ expect(result.current.fetchStatus).toBe('idle')
82
+ expect(api.getExecution).not.toHaveBeenCalled()
83
+ })
84
+
85
+ it('useWorkflowExecutions fetches executions for a workflow', async () => {
86
+ const api = fakeApi()
87
+ const { result } = renderHook(() => useWorkflowExecutions(api, 'w1', { page: 1 }), {
88
+ wrapper: wrap(mkClient()),
89
+ })
90
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
91
+ expect(api.listExecutions).toHaveBeenCalledWith('w1', { page: 1 })
92
+ })
93
+ })
94
+
95
+ describe('workflow mutation hooks invalidate the right queries', () => {
96
+ it('useCreateWorkflow invalidates the workflows list', async () => {
97
+ const qc = mkClient()
98
+ const seen = trackInvalidations(qc)
99
+ const { result } = renderHook(() => useCreateWorkflow(fakeApi()), { wrapper: wrap(qc) })
100
+ await act(async () => { result.current.mutate({ name: 'Onboarding' }) })
101
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
102
+ expect(seen).toEqual(expect.arrayContaining([['workflows', 'list']]))
103
+ })
104
+
105
+ it('useUpdateWorkflow invalidates the workflow detail', async () => {
106
+ const qc = mkClient()
107
+ const seen = trackInvalidations(qc)
108
+ const { result } = renderHook(() => useUpdateWorkflow(fakeApi(), 'w1'), { wrapper: wrap(qc) })
109
+ await act(async () => { result.current.mutate({ name: 'New' }) })
110
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
111
+ expect(seen).toEqual(expect.arrayContaining([['workflows', 'detail', 'w1']]))
112
+ })
113
+
114
+ it('useDeleteWorkflow invalidates the workflows list', async () => {
115
+ const qc = mkClient()
116
+ const seen = trackInvalidations(qc)
117
+ const { result } = renderHook(() => useDeleteWorkflow(fakeApi()), { wrapper: wrap(qc) })
118
+ await act(async () => { result.current.mutate('w1') })
119
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
120
+ expect(seen).toEqual(expect.arrayContaining([['workflows', 'list']]))
121
+ })
122
+
123
+ it('useExecuteWorkflow surfaces the executionId', async () => {
124
+ const qc = mkClient()
125
+ const api = fakeApi()
126
+ const { result } = renderHook(() => useExecuteWorkflow(api, 'w1'), { wrapper: wrap(qc) })
127
+ await act(async () => { result.current.mutate({ foo: 'bar' }) })
128
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
129
+ expect(api.executeWorkflow).toHaveBeenCalledWith('w1', { foo: 'bar' })
130
+ expect(result.current.data?.executionId).toBe('e1')
131
+ })
132
+
133
+ it('useCancelExecution invalidates the execution query key', async () => {
134
+ const qc = mkClient()
135
+ const seen = trackInvalidations(qc)
136
+ const { result } = renderHook(() => useCancelExecution(fakeApi()), { wrapper: wrap(qc) })
137
+ await act(async () => { result.current.mutate('e1') })
138
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
139
+ expect(seen).toEqual(expect.arrayContaining([['workflows', 'execution', 'e1']]))
140
+ })
141
+
142
+ it('useRetryExecution invalidates the execution query key', async () => {
143
+ const qc = mkClient()
144
+ const seen = trackInvalidations(qc)
145
+ const { result } = renderHook(() => useRetryExecution(fakeApi()), { wrapper: wrap(qc) })
146
+ await act(async () => { result.current.mutate('e1') })
147
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
148
+ expect(seen).toEqual(expect.arrayContaining([['workflows', 'execution', 'e1']]))
149
+ })
150
+ })
151
+
152
+ describe('executionRefetchInterval', () => {
153
+ it('polls while running/pending/waiting and stops when terminal', () => {
154
+ expect(executionRefetchInterval('running')).toBeGreaterThan(0)
155
+ expect(executionRefetchInterval('pending')).toBeGreaterThan(0)
156
+ expect(executionRefetchInterval('waiting')).toBeGreaterThan(0)
157
+ expect(executionRefetchInterval('completed')).toBe(false)
158
+ expect(executionRefetchInterval('failed')).toBe(false)
159
+ expect(executionRefetchInterval('cancelled')).toBe(false)
160
+ expect(executionRefetchInterval(undefined)).toBe(false)
161
+ })
162
+ })
package/src/index.ts CHANGED
@@ -68,3 +68,23 @@ export type {
68
68
  } from './useCSV'
69
69
  export { useAsyncOptions } from './useAsyncOptions'
70
70
  export type { UseAsyncOptionsResult } from './useAsyncOptions'
71
+
72
+ // Vault — environments, secrets, access keys, audit (startsim-d30.3.2)
73
+ export {
74
+ useEnvironments,
75
+ useEnvironment,
76
+ useCreateEnvironment,
77
+ useUpdateEnvironment,
78
+ useDeleteEnvironment,
79
+ useSecrets,
80
+ useCreateSecret,
81
+ useUpdateSecret,
82
+ useDeleteSecret,
83
+ useRevealSecret,
84
+ useAccessKeys,
85
+ useCreateAccessKey,
86
+ useDeleteAccessKey,
87
+ useAuditLog,
88
+ } from './useVault'
89
+ export * from './usePresentations'
90
+ export * from './useWorkflows'
@@ -0,0 +1,144 @@
1
+ import { useState, useCallback } from 'react'
2
+
3
+ export interface CSVColumnMapping {
4
+ csvColumn: string
5
+ targetField: string
6
+ }
7
+
8
+ export interface CSVPreviewResult<TField extends string = string> {
9
+ columns: string[]
10
+ sampleRows: Record<string, string>[]
11
+ suggestedMappings?: CSVColumnMapping[]
12
+ totalRows?: number
13
+ }
14
+
15
+ export interface CSVImportResult {
16
+ totalRows: number
17
+ successful: number
18
+ failed: number
19
+ errors: Array<{ row: number; message: string }>
20
+ }
21
+
22
+ export type CSVImportStep = 'upload' | 'mapping' | 'importing' | 'complete'
23
+
24
+ export interface UseCSVImportOptions<TField extends string = string> {
25
+ previewFn: (file: File) => Promise<CSVPreviewResult<TField>>
26
+ importFn: (file: File, mappings: CSVColumnMapping[]) => Promise<CSVImportResult>
27
+ onSuccess?: (result: CSVImportResult) => void
28
+ }
29
+
30
+ export interface UseCSVImportState {
31
+ step: CSVImportStep
32
+ file: File | null
33
+ preview: CSVPreviewResult | null
34
+ mappings: CSVColumnMapping[]
35
+ result: CSVImportResult | null
36
+ isLoading: boolean
37
+ error: string | null
38
+ handleFileSelect: (file: File) => Promise<void>
39
+ updateMapping: (csvColumn: string, targetField: string) => void
40
+ startImport: () => Promise<void>
41
+ reset: () => void
42
+ goBack: () => void
43
+ }
44
+
45
+ /**
46
+ * Platform-neutral CSV import state machine.
47
+ *
48
+ * The actual file reading is delegated to the caller-provided previewFn/importFn,
49
+ * so this hook never touches the DOM — `File` is used only as a type. It is safe
50
+ * on web and React Native alike, which is why it lives in the shared core rather
51
+ * than the platform-specific useCSV entries.
52
+ */
53
+ export function useCSVImport<TField extends string = string>({
54
+ previewFn,
55
+ importFn,
56
+ onSuccess,
57
+ }: UseCSVImportOptions<TField>): UseCSVImportState {
58
+ const [step, setStep] = useState<CSVImportStep>('upload')
59
+ const [file, setFile] = useState<File | null>(null)
60
+ const [preview, setPreview] = useState<CSVPreviewResult | null>(null)
61
+ const [mappings, setMappings] = useState<CSVColumnMapping[]>([])
62
+ const [result, setResult] = useState<CSVImportResult | null>(null)
63
+ const [isLoading, setIsLoading] = useState(false)
64
+ const [error, setError] = useState<string | null>(null)
65
+
66
+ const handleFileSelect = useCallback(
67
+ async (selectedFile: File) => {
68
+ setFile(selectedFile)
69
+ setError(null)
70
+ setIsLoading(true)
71
+ try {
72
+ const previewData = await previewFn(selectedFile)
73
+ setPreview(previewData)
74
+ setMappings(previewData.suggestedMappings ?? [])
75
+ setStep('mapping')
76
+ } catch {
77
+ setError('Failed to preview CSV file')
78
+ } finally {
79
+ setIsLoading(false)
80
+ }
81
+ },
82
+ [previewFn]
83
+ )
84
+
85
+ const updateMapping = useCallback((csvColumn: string, targetField: string) => {
86
+ setMappings((prev) => {
87
+ const existing = prev.find((m) => m.csvColumn === csvColumn)
88
+ if (existing) {
89
+ return prev.map((m) =>
90
+ m.csvColumn === csvColumn ? { ...m, targetField: targetField } : m
91
+ )
92
+ }
93
+ return [...prev, { csvColumn: csvColumn, targetField: targetField }]
94
+ })
95
+ }, [])
96
+
97
+ const startImport = useCallback(async () => {
98
+ if (!file) return
99
+ setStep('importing')
100
+ setError(null)
101
+ try {
102
+ const importResult = await importFn(file, mappings)
103
+ setResult(importResult)
104
+ setStep('complete')
105
+ onSuccess?.(importResult)
106
+ } catch {
107
+ setError('Failed to import CSV')
108
+ setStep('mapping')
109
+ }
110
+ }, [file, mappings, importFn, onSuccess])
111
+
112
+ const reset = useCallback(() => {
113
+ setStep('upload')
114
+ setFile(null)
115
+ setPreview(null)
116
+ setMappings([])
117
+ setResult(null)
118
+ setError(null)
119
+ }, [])
120
+
121
+ const goBack = useCallback(() => {
122
+ if (step === 'mapping') setStep('upload')
123
+ }, [step])
124
+
125
+ return {
126
+ step,
127
+ file,
128
+ preview,
129
+ mappings,
130
+ result,
131
+ isLoading,
132
+ error,
133
+ handleFileSelect,
134
+ updateMapping,
135
+ startImport,
136
+ reset,
137
+ goBack,
138
+ }
139
+ }
140
+
141
+ export interface UseCSVExportOptions {
142
+ exportFn: () => Promise<Blob | string>
143
+ filename?: string
144
+ }
@@ -0,0 +1,26 @@
1
+ import { useState, useCallback } from 'react'
2
+ import type { UseCSVExportOptions } from './useCSV-core'
3
+
4
+ // Types + the platform-neutral useCSVImport state machine live in the core.
5
+ export * from './useCSV-core'
6
+
7
+ /**
8
+ * useCSVExport — React Native stub.
9
+ *
10
+ * The web version triggers a browser file download (Blob + anchor click), which
11
+ * has no direct React Native equivalent. Rather than silently no-op, exportCSV
12
+ * fails loud so callers wire up a native save/share flow (expo-file-system +
13
+ * expo-sharing) explicitly. Metro resolves this file over useCSV.ts on native.
14
+ */
15
+ export function useCSVExport({ exportFn: _exportFn, filename: _filename }: UseCSVExportOptions) {
16
+ const [isExporting] = useState(false)
17
+
18
+ const exportCSV = useCallback(async () => {
19
+ throw new Error(
20
+ 'useCSVExport: browser-download export is web-only. On React Native, ' +
21
+ 'save the CSV with expo-file-system and share it via expo-sharing.'
22
+ )
23
+ }, [])
24
+
25
+ return { exportCSV, isExporting }
26
+ }
package/src/useCSV.ts CHANGED
@@ -1,140 +1,16 @@
1
1
  import { useState, useCallback } from 'react'
2
-
3
- export interface CSVColumnMapping {
4
- csvColumn: string
5
- targetField: string
6
- }
7
-
8
- export interface CSVPreviewResult<TField extends string = string> {
9
- columns: string[]
10
- sampleRows: Record<string, string>[]
11
- suggestedMappings?: CSVColumnMapping[]
12
- totalRows?: number
13
- }
14
-
15
- export interface CSVImportResult {
16
- totalRows: number
17
- successful: number
18
- failed: number
19
- errors: Array<{ row: number; message: string }>
20
- }
21
-
22
- export type CSVImportStep = 'upload' | 'mapping' | 'importing' | 'complete'
23
-
24
- export interface UseCSVImportOptions<TField extends string = string> {
25
- previewFn: (file: File) => Promise<CSVPreviewResult<TField>>
26
- importFn: (file: File, mappings: CSVColumnMapping[]) => Promise<CSVImportResult>
27
- onSuccess?: (result: CSVImportResult) => void
28
- }
29
-
30
- export interface UseCSVImportState {
31
- step: CSVImportStep
32
- file: File | null
33
- preview: CSVPreviewResult | null
34
- mappings: CSVColumnMapping[]
35
- result: CSVImportResult | null
36
- isLoading: boolean
37
- error: string | null
38
- handleFileSelect: (file: File) => Promise<void>
39
- updateMapping: (csvColumn: string, targetField: string) => void
40
- startImport: () => Promise<void>
41
- reset: () => void
42
- goBack: () => void
43
- }
44
-
45
- export function useCSVImport<TField extends string = string>({
46
- previewFn,
47
- importFn,
48
- onSuccess,
49
- }: UseCSVImportOptions<TField>): UseCSVImportState {
50
- const [step, setStep] = useState<CSVImportStep>('upload')
51
- const [file, setFile] = useState<File | null>(null)
52
- const [preview, setPreview] = useState<CSVPreviewResult | null>(null)
53
- const [mappings, setMappings] = useState<CSVColumnMapping[]>([])
54
- const [result, setResult] = useState<CSVImportResult | null>(null)
55
- const [isLoading, setIsLoading] = useState(false)
56
- const [error, setError] = useState<string | null>(null)
57
-
58
- const handleFileSelect = useCallback(
59
- async (selectedFile: File) => {
60
- setFile(selectedFile)
61
- setError(null)
62
- setIsLoading(true)
63
- try {
64
- const previewData = await previewFn(selectedFile)
65
- setPreview(previewData)
66
- setMappings(previewData.suggestedMappings ?? [])
67
- setStep('mapping')
68
- } catch {
69
- setError('Failed to preview CSV file')
70
- } finally {
71
- setIsLoading(false)
72
- }
73
- },
74
- [previewFn]
75
- )
76
-
77
- const updateMapping = useCallback((csvColumn: string, targetField: string) => {
78
- setMappings((prev) => {
79
- const existing = prev.find((m) => m.csvColumn === csvColumn)
80
- if (existing) {
81
- return prev.map((m) =>
82
- m.csvColumn === csvColumn ? { ...m, targetField: targetField } : m
83
- )
84
- }
85
- return [...prev, { csvColumn: csvColumn, targetField: targetField }]
86
- })
87
- }, [])
88
-
89
- const startImport = useCallback(async () => {
90
- if (!file) return
91
- setStep('importing')
92
- setError(null)
93
- try {
94
- const importResult = await importFn(file, mappings)
95
- setResult(importResult)
96
- setStep('complete')
97
- onSuccess?.(importResult)
98
- } catch {
99
- setError('Failed to import CSV')
100
- setStep('mapping')
101
- }
102
- }, [file, mappings, importFn, onSuccess])
103
-
104
- const reset = useCallback(() => {
105
- setStep('upload')
106
- setFile(null)
107
- setPreview(null)
108
- setMappings([])
109
- setResult(null)
110
- setError(null)
111
- }, [])
112
-
113
- const goBack = useCallback(() => {
114
- if (step === 'mapping') setStep('upload')
115
- }, [step])
116
-
117
- return {
118
- step,
119
- file,
120
- preview,
121
- mappings,
122
- result,
123
- isLoading,
124
- error,
125
- handleFileSelect,
126
- updateMapping,
127
- startImport,
128
- reset,
129
- goBack,
130
- }
131
- }
132
-
133
- export interface UseCSVExportOptions {
134
- exportFn: () => Promise<Blob | string>
135
- filename?: string
136
- }
137
-
2
+ import type { UseCSVExportOptions } from './useCSV-core'
3
+
4
+ // Types + the platform-neutral useCSVImport state machine live in the core.
5
+ export * from './useCSV-core'
6
+
7
+ /**
8
+ * useCSVExport web implementation.
9
+ *
10
+ * Builds a Blob and triggers a browser download via an anchor click. This is
11
+ * inherently DOM-bound; React Native resolves useCSV.native.ts instead (which
12
+ * throws, since downloading a file has no direct RN equivalent).
13
+ */
138
14
  export function useCSVExport({ exportFn, filename = 'export.csv' }: UseCSVExportOptions) {
139
15
  const [isExporting, setIsExporting] = useState(false)
140
16
 
@@ -0,0 +1,120 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
+ import type {
3
+ PresentationsApi,
4
+ DeckInput,
5
+ DeckListParams,
6
+ DeckStatus,
7
+ GenerateDeckInput,
8
+ RegenerateSlideInput,
9
+ SlideInput,
10
+ } from '@startsimpli/api'
11
+
12
+ /** TanStack Query hooks over PresentationsApi (epic startsim-3ks).
13
+ *
14
+ * Each hook takes the PresentationsApi instance (e.g. `api.presentations`),
15
+ * matching the useVault / useMessages convention. Generation + compile are
16
+ * async on the backend, so the deck detail and the lightweight status query
17
+ * are invalidated together and the status query self-polls while in flight.
18
+ */
19
+
20
+ const DECKS_KEY = ['presentations', 'decks'] as const
21
+ const deckKey = (id: string) => ['presentations', 'deck', id]
22
+ const deckStatusKey = (id: string) => ['presentations', 'deck-status', id]
23
+
24
+ const POLL_MS = 1500
25
+
26
+ /** Refetch interval for the status query: poll while work is in flight. */
27
+ export function deckStatusRefetchInterval(status: DeckStatus | undefined): number | false {
28
+ return status === 'generating' || status === 'draft' ? POLL_MS : false
29
+ }
30
+
31
+ function invalidateDeck(qc: ReturnType<typeof useQueryClient>, id: string) {
32
+ qc.invalidateQueries({ queryKey: deckKey(id) })
33
+ qc.invalidateQueries({ queryKey: deckStatusKey(id) })
34
+ qc.invalidateQueries({ queryKey: DECKS_KEY })
35
+ }
36
+
37
+ // --- Decks ---
38
+ export function useDecks(api: PresentationsApi, params?: DeckListParams) {
39
+ return useQuery({
40
+ queryKey: ['presentations', 'decks', params],
41
+ queryFn: () => api.listDecks(params),
42
+ })
43
+ }
44
+
45
+ export function useDeck(api: PresentationsApi, id: string) {
46
+ return useQuery({
47
+ queryKey: deckKey(id),
48
+ queryFn: () => api.getDeck(id),
49
+ enabled: !!id,
50
+ })
51
+ }
52
+
53
+ /** Self-polling per-slide generation status; stops when ready/error. */
54
+ export function useDeckStatus(api: PresentationsApi, id: string, enabled = true) {
55
+ return useQuery({
56
+ queryKey: deckStatusKey(id),
57
+ queryFn: () => api.getDeckStatus(id),
58
+ enabled: enabled && !!id,
59
+ refetchInterval: (query) => deckStatusRefetchInterval(query.state.data?.status),
60
+ })
61
+ }
62
+
63
+ export function useCreateDeck(api: PresentationsApi) {
64
+ const qc = useQueryClient()
65
+ return useMutation({
66
+ mutationFn: (data: DeckInput) => api.createDeck(data),
67
+ onSuccess: () => qc.invalidateQueries({ queryKey: DECKS_KEY }),
68
+ })
69
+ }
70
+
71
+ export function useUpdateDeck(api: PresentationsApi, id: string) {
72
+ const qc = useQueryClient()
73
+ return useMutation({
74
+ mutationFn: (data: Partial<DeckInput>) => api.updateDeck(id, data),
75
+ onSuccess: () => invalidateDeck(qc, id),
76
+ })
77
+ }
78
+
79
+ export function useDeleteDeck(api: PresentationsApi) {
80
+ const qc = useQueryClient()
81
+ return useMutation({
82
+ mutationFn: (id: string) => api.deleteDeck(id),
83
+ onSuccess: () => qc.invalidateQueries({ queryKey: DECKS_KEY }),
84
+ })
85
+ }
86
+
87
+ // --- Generation pipeline ---
88
+ export function useGenerateDeck(api: PresentationsApi, id: string) {
89
+ const qc = useQueryClient()
90
+ return useMutation({
91
+ mutationFn: (data?: GenerateDeckInput) => api.generateDeck(id, data),
92
+ onSuccess: () => invalidateDeck(qc, id),
93
+ })
94
+ }
95
+
96
+ export function useRegenerateSlide(api: PresentationsApi, id: string) {
97
+ const qc = useQueryClient()
98
+ return useMutation({
99
+ mutationFn: ({ slideNumber, ...data }: { slideNumber: number } & RegenerateSlideInput) =>
100
+ api.regenerateSlide(id, slideNumber, data),
101
+ onSuccess: () => invalidateDeck(qc, id),
102
+ })
103
+ }
104
+
105
+ export function useUpdateSlide(api: PresentationsApi, id: string) {
106
+ const qc = useQueryClient()
107
+ return useMutation({
108
+ mutationFn: ({ slideNumber, ...data }: { slideNumber: number } & SlideInput) =>
109
+ api.updateSlide(id, slideNumber, data),
110
+ onSuccess: () => invalidateDeck(qc, id),
111
+ })
112
+ }
113
+
114
+ export function useCompileDeck(api: PresentationsApi, id: string) {
115
+ const qc = useQueryClient()
116
+ return useMutation({
117
+ mutationFn: () => api.compileDeck(id),
118
+ onSuccess: () => invalidateDeck(qc, id),
119
+ })
120
+ }
@@ -0,0 +1,56 @@
1
+ import { useState, useCallback, useEffect } from 'react'
2
+
3
+ interface StoredItem<T> {
4
+ item: T
5
+ viewedAt: number
6
+ }
7
+
8
+ /**
9
+ * useRecentlyViewed — React Native implementation.
10
+ *
11
+ * Web persists to localStorage. React Native has no synchronous storage, so this
12
+ * keeps an in-memory store keyed by storageKey at module scope — which mirrors
13
+ * localStorage's app-wide sharing within a session but does NOT survive an app
14
+ * restart. Persistence across launches needs an async backend (AsyncStorage);
15
+ * tracked as a follow-up (startsim-sw1.5 notes). Metro resolves this file over
16
+ * useRecentlyViewed.ts on native builds.
17
+ */
18
+ const memoryStore = new Map<string, StoredItem<unknown>[]>()
19
+
20
+ export function useRecentlyViewed<T extends { id: string }>(
21
+ storageKey: string,
22
+ maxItems: number = 5
23
+ ) {
24
+ const [items, setItems] = useState<StoredItem<T>[]>(
25
+ () => (memoryStore.get(storageKey) as StoredItem<T>[]) ?? []
26
+ )
27
+
28
+ // Re-sync if the key changes.
29
+ useEffect(() => {
30
+ setItems((memoryStore.get(storageKey) as StoredItem<T>[]) ?? [])
31
+ }, [storageKey])
32
+
33
+ const trackView = useCallback(
34
+ (item: T) => {
35
+ setItems((prev) => {
36
+ const deduped = prev.filter((entry) => entry.item.id !== item.id)
37
+ const updated = [{ item, viewedAt: Date.now() }, ...deduped].slice(0, maxItems)
38
+ memoryStore.set(storageKey, updated as StoredItem<unknown>[])
39
+ return updated
40
+ })
41
+ },
42
+ [maxItems, storageKey]
43
+ )
44
+
45
+ const clear = useCallback(() => {
46
+ setItems([])
47
+ memoryStore.delete(storageKey)
48
+ }, [storageKey])
49
+
50
+ return {
51
+ items: items.map((entry) => entry.item),
52
+ timestamps: items.map((entry) => ({ id: entry.item.id, viewedAt: entry.viewedAt })),
53
+ trackView,
54
+ clear,
55
+ }
56
+ }