@accounter/scraper-app 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +90 -0
  2. package/docs/plan.md +76 -0
  3. package/index.html +12 -0
  4. package/package.json +40 -0
  5. package/src/env.template +2 -0
  6. package/src/server/__tests__/accounts-routes.test.ts +133 -0
  7. package/src/server/__tests__/check-accounts.test.ts +305 -0
  8. package/src/server/__tests__/filter-payload.test.ts +193 -0
  9. package/src/server/__tests__/graphql-client.integration.test.ts +98 -0
  10. package/src/server/__tests__/graphql-client.test.ts +508 -0
  11. package/src/server/__tests__/healthz.test.ts +22 -0
  12. package/src/server/__tests__/history.test.ts +111 -0
  13. package/src/server/__tests__/otp-manager.test.ts +132 -0
  14. package/src/server/__tests__/scrape-runner.test.ts +144 -0
  15. package/src/server/__tests__/settings-routes.test.ts +117 -0
  16. package/src/server/__tests__/sources-routes.test.ts +149 -0
  17. package/src/server/__tests__/validate-payload.test.ts +193 -0
  18. package/src/server/__tests__/vault-routes.test.ts +174 -0
  19. package/src/server/__tests__/vault.test.ts +33 -0
  20. package/src/server/__tests__/websocket.test.ts +151 -0
  21. package/src/server/account-discovery.ts +49 -0
  22. package/src/server/accounts-routes.ts +74 -0
  23. package/src/server/check-accounts.ts +79 -0
  24. package/src/server/filter-payload.ts +145 -0
  25. package/src/server/graphql/client.ts +103 -0
  26. package/src/server/graphql/mutations.ts +518 -0
  27. package/src/server/history-routes.ts +11 -0
  28. package/src/server/history.ts +53 -0
  29. package/src/server/index.ts +40 -0
  30. package/src/server/otp-manager.ts +63 -0
  31. package/src/server/payload-schemas/amex.schema.ts +2 -0
  32. package/src/server/payload-schemas/cal.schema.ts +27 -0
  33. package/src/server/payload-schemas/currency-rates.schema.ts +11 -0
  34. package/src/server/payload-schemas/discount.schema.ts +26 -0
  35. package/src/server/payload-schemas/isracard.schema.ts +58 -0
  36. package/src/server/payload-schemas/max.schema.ts +27 -0
  37. package/src/server/payload-schemas/poalim-foreign.schema.ts +30 -0
  38. package/src/server/payload-schemas/poalim-ils.schema.ts +31 -0
  39. package/src/server/payload-schemas/poalim-swift.schema.ts +21 -0
  40. package/src/server/scrape-runner.ts +165 -0
  41. package/src/server/scrapers/__tests__/amex.test.ts +142 -0
  42. package/src/server/scrapers/__tests__/cal.test.ts +135 -0
  43. package/src/server/scrapers/__tests__/currency-rates.test.ts +105 -0
  44. package/src/server/scrapers/__tests__/discount.test.ts +160 -0
  45. package/src/server/scrapers/__tests__/isracard.test.ts +142 -0
  46. package/src/server/scrapers/__tests__/max.test.ts +115 -0
  47. package/src/server/scrapers/__tests__/poalim.test.ts +154 -0
  48. package/src/server/scrapers/amex.ts +63 -0
  49. package/src/server/scrapers/cal.ts +56 -0
  50. package/src/server/scrapers/currency-rates.ts +64 -0
  51. package/src/server/scrapers/discount.ts +62 -0
  52. package/src/server/scrapers/isracard.ts +68 -0
  53. package/src/server/scrapers/max.ts +32 -0
  54. package/src/server/scrapers/poalim.ts +103 -0
  55. package/src/server/settings-routes.ts +27 -0
  56. package/src/server/sources-routes.ts +182 -0
  57. package/src/server/validate-payload.ts +74 -0
  58. package/src/server/vault-routes.ts +99 -0
  59. package/src/server/vault-store.ts +42 -0
  60. package/src/server/vault.ts +216 -0
  61. package/src/server/websocket.ts +454 -0
  62. package/src/shared/source-types.ts +10 -0
  63. package/src/shared/types.ts +20 -0
  64. package/src/shared/ws-protocol.ts +177 -0
  65. package/src/test-setup.ts +6 -0
  66. package/src/ui/__tests__/accounts-tab.test.tsx +134 -0
  67. package/src/ui/__tests__/config.test.tsx +99 -0
  68. package/src/ui/__tests__/history.test.tsx +94 -0
  69. package/src/ui/__tests__/run.test.tsx +195 -0
  70. package/src/ui/__tests__/settings-tab.test.tsx +79 -0
  71. package/src/ui/__tests__/sources-tab.test.tsx +139 -0
  72. package/src/ui/__tests__/vault-setup.test.tsx +105 -0
  73. package/src/ui/__tests__/vault-unlock.test.tsx +78 -0
  74. package/src/ui/app.tsx +109 -0
  75. package/src/ui/components/error-boundary.tsx +54 -0
  76. package/src/ui/components/otp-modal.tsx +82 -0
  77. package/src/ui/components/skeleton.tsx +58 -0
  78. package/src/ui/components/task-row.tsx +241 -0
  79. package/src/ui/contexts/vault-context.tsx +77 -0
  80. package/src/ui/lib/api.ts +117 -0
  81. package/src/ui/lib/ws.ts +137 -0
  82. package/src/ui/main.tsx +9 -0
  83. package/src/ui/screens/config/accounts-tab.tsx +185 -0
  84. package/src/ui/screens/config/config.tsx +163 -0
  85. package/src/ui/screens/config/settings-tab.tsx +167 -0
  86. package/src/ui/screens/config/source-forms.tsx +518 -0
  87. package/src/ui/screens/config/source-types.ts +91 -0
  88. package/src/ui/screens/config/sources-tab.tsx +176 -0
  89. package/src/ui/screens/history.tsx +234 -0
  90. package/src/ui/screens/run.tsx +266 -0
  91. package/src/ui/screens/vault-setup.tsx +120 -0
  92. package/src/ui/screens/vault-unlock.tsx +38 -0
  93. package/tsconfig.json +15 -0
  94. package/tsup.config.ts +10 -0
  95. package/vite.config.ts +24 -0
  96. package/vitest.config.ts +7 -0
@@ -0,0 +1,176 @@
1
+ import { useCallback, useEffect, useState, type ReactElement } from 'react';
2
+ import { SkeletonRow } from '../../components/skeleton.js';
3
+ import { createSource, deleteSource, getSources, updateSource } from '../../lib/api.js';
4
+ import { SourceForm } from './source-forms.js';
5
+ import { SOURCE_LABELS, type SourceConfig, type SourceType } from './source-types.js';
6
+
7
+ type DialogState =
8
+ | { mode: 'add'; sourceType: SourceType }
9
+ | { mode: 'edit'; source: SourceConfig }
10
+ | { mode: 'confirm-delete'; source: SourceConfig }
11
+ | null;
12
+
13
+ export function SourcesTab(): ReactElement {
14
+ const [sources, setSources] = useState<SourceConfig[]>([]);
15
+ const [loading, setLoading] = useState(true);
16
+ const [dialog, setDialog] = useState<DialogState>(null);
17
+ const [error, setError] = useState<string | null>(null);
18
+ const [addType, setAddType] = useState<SourceType>('poalim');
19
+
20
+ const load = useCallback(async () => {
21
+ setLoading(true);
22
+ try {
23
+ const list = await getSources<SourceConfig>();
24
+ setSources(list);
25
+ } catch {
26
+ setError('Failed to load sources');
27
+ } finally {
28
+ setLoading(false);
29
+ }
30
+ }, []);
31
+
32
+ useEffect(() => {
33
+ void load();
34
+ }, [load]);
35
+
36
+ async function handleAdd(data: Omit<SourceConfig, 'id' | 'type'>) {
37
+ try {
38
+ const list = await createSource<SourceConfig>({
39
+ ...data,
40
+ type: (dialog as { mode: 'add'; sourceType: SourceType }).sourceType,
41
+ });
42
+ setSources(list);
43
+ setDialog(null);
44
+ } catch {
45
+ setError('Failed to add source');
46
+ }
47
+ }
48
+
49
+ async function handleEdit(data: Omit<SourceConfig, 'id' | 'type'>) {
50
+ const source = (dialog as { mode: 'edit'; source: SourceConfig }).source;
51
+ try {
52
+ const list = await updateSource<SourceConfig>(source.id, data);
53
+ setSources(list);
54
+ setDialog(null);
55
+ } catch {
56
+ setError('Failed to update source');
57
+ }
58
+ }
59
+
60
+ async function handleDelete(id: string) {
61
+ try {
62
+ const list = await deleteSource<SourceConfig>(id);
63
+ setSources(list);
64
+ setDialog(null);
65
+ } catch {
66
+ setError('Failed to delete source');
67
+ }
68
+ }
69
+
70
+ function displayName(s: SourceConfig): string {
71
+ if (s.nickname) return s.nickname;
72
+ if (s.type === 'poalim') return `Poalim (${s.userCode})`;
73
+ if (s.type === 'discount') return `Discount (${s.ID})`;
74
+ if (s.type === 'isracard' || s.type === 'amex')
75
+ return `${SOURCE_LABELS[s.type]} (${s.ownerId})`;
76
+ if (s.type === 'cal' || s.type === 'max') return `${SOURCE_LABELS[s.type]} (${s.username})`;
77
+ return '';
78
+ }
79
+
80
+ return (
81
+ <div>
82
+ <h2>Sources</h2>
83
+
84
+ {error && (
85
+ <p role="alert" style={{ color: 'red' }}>
86
+ {error}
87
+ </p>
88
+ )}
89
+
90
+ {loading ? (
91
+ <div aria-busy="true">
92
+ <SkeletonRow width="60%" />
93
+ <SkeletonRow width="75%" />
94
+ <SkeletonRow width="50%" />
95
+ </div>
96
+ ) : sources.length === 0 && !dialog ? (
97
+ <p>No sources configured yet.</p>
98
+ ) : null}
99
+
100
+ <ul style={{ listStyle: 'none', padding: 0 }}>
101
+ {sources.map(s => (
102
+ <li
103
+ key={s.id}
104
+ style={{
105
+ display: 'flex',
106
+ alignItems: 'center',
107
+ gap: 8,
108
+ padding: '6px 0',
109
+ borderBottom: '1px solid #eee',
110
+ }}
111
+ >
112
+ <span style={{ flex: 1 }}>
113
+ <strong>{SOURCE_LABELS[s.type]}</strong> — {displayName(s)}
114
+ </span>
115
+ <button onClick={() => setDialog({ mode: 'edit', source: s })}>Edit</button>
116
+ <button onClick={() => setDialog({ mode: 'confirm-delete', source: s })}>Delete</button>
117
+ </li>
118
+ ))}
119
+ </ul>
120
+
121
+ {!dialog && (
122
+ <div style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'center' }}>
123
+ <select
124
+ aria-label="Source type"
125
+ value={addType}
126
+ onChange={e => setAddType(e.target.value as SourceType)}
127
+ >
128
+ {(Object.keys(SOURCE_LABELS) as SourceType[]).map(t => (
129
+ <option key={t} value={t}>
130
+ {SOURCE_LABELS[t]}
131
+ </option>
132
+ ))}
133
+ </select>
134
+ <button onClick={() => setDialog({ mode: 'add', sourceType: addType })}>
135
+ Add Source
136
+ </button>
137
+ </div>
138
+ )}
139
+
140
+ {dialog && (
141
+ <div style={{ marginTop: 16, padding: 16, border: '1px solid #ccc', borderRadius: 4 }}>
142
+ {dialog.mode === 'confirm-delete' ? (
143
+ <>
144
+ <h3 style={{ marginTop: 0 }}>Delete source?</h3>
145
+ <p>
146
+ This will permanently remove{' '}
147
+ <strong>
148
+ {SOURCE_LABELS[dialog.source.type]} — {displayName(dialog.source)}
149
+ </strong>{' '}
150
+ and its stored credentials.
151
+ </p>
152
+ <div style={{ display: 'flex', gap: 8 }}>
153
+ <button onClick={() => void handleDelete(dialog.source.id)}>Confirm delete</button>
154
+ <button onClick={() => setDialog(null)}>Cancel</button>
155
+ </div>
156
+ </>
157
+ ) : (
158
+ <>
159
+ <h3 style={{ marginTop: 0 }}>
160
+ {dialog.mode === 'add'
161
+ ? `Add ${SOURCE_LABELS[dialog.sourceType]}`
162
+ : `Edit ${SOURCE_LABELS[dialog.source.type]}`}
163
+ </h3>
164
+ <SourceForm
165
+ sourceType={dialog.mode === 'add' ? dialog.sourceType : dialog.source.type}
166
+ initial={dialog.mode === 'edit' ? dialog.source : undefined}
167
+ onSave={dialog.mode === 'add' ? handleAdd : handleEdit}
168
+ onCancel={() => setDialog(null)}
169
+ />
170
+ </>
171
+ )}
172
+ </div>
173
+ )}
174
+ </div>
175
+ );
176
+ }
@@ -0,0 +1,234 @@
1
+ import { useCallback, useEffect, useState, type ReactElement } from 'react';
2
+ import type { RunRecord, SourceRunRecord } from '../../shared/types.js';
3
+ import { SkeletonTable } from '../components/skeleton.js';
4
+ import { getHistory } from '../lib/api.js';
5
+
6
+ function formatDateTime(iso: string): string {
7
+ return new Date(iso).toLocaleString();
8
+ }
9
+
10
+ export function formatDuration(startedAt: string, completedAt: string): string {
11
+ const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
12
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
13
+ const minutes = Math.floor(totalSeconds / 60);
14
+ const seconds = totalSeconds % 60;
15
+ return `${minutes}m ${seconds}s`;
16
+ }
17
+
18
+ const SOURCE_STATUS_STYLE: Record<string, { background: string; color: string }> = {
19
+ done: { background: '#dcfce7', color: '#15803d' },
20
+ error: { background: '#fee2e2', color: '#b91c1c' },
21
+ blocked: { background: '#fef9c3', color: '#854d0e' },
22
+ };
23
+
24
+ const SOURCE_STATUS_LABEL: Record<string, string> = {
25
+ done: 'done',
26
+ error: 'error',
27
+ blocked: 'blocked',
28
+ };
29
+
30
+ function SourceRow({ src }: { src: SourceRunRecord }): ReactElement {
31
+ const style = SOURCE_STATUS_STYLE[src.status] ?? SOURCE_STATUS_STYLE['done']!;
32
+ const [expanded, setExpanded] = useState(false);
33
+ return (
34
+ <>
35
+ <tr>
36
+ <td key={src.sourceId} style={{ padding: '4px 8px', color: '#555' }}>
37
+ {src.nickname || src.sourceId}
38
+ </td>
39
+ <td style={{ padding: '4px 8px', color: '#555' }}>{src.sourceType}</td>
40
+ <td style={{ padding: '4px 8px' }}>
41
+ <span
42
+ style={{
43
+ ...style,
44
+ padding: '1px 7px',
45
+ borderRadius: 10,
46
+ fontSize: '0.78em',
47
+ fontWeight: 600,
48
+ }}
49
+ >
50
+ {SOURCE_STATUS_LABEL[src.status] ?? src.status}
51
+ </span>
52
+ </td>
53
+ <td style={{ padding: '4px 8px', textAlign: 'right' }}>{src.inserted}</td>
54
+ <td style={{ padding: '4px 8px', textAlign: 'right' }}>{src.skipped}</td>
55
+ <td style={{ padding: '4px 8px', color: '#b91c1c' }}>
56
+ {src.status === 'blocked' ? (
57
+ <button
58
+ type="button"
59
+ onClick={() => setExpanded(e => !e)}
60
+ style={{
61
+ background: 'none',
62
+ border: 'none',
63
+ cursor: 'pointer',
64
+ color: '#854d0e',
65
+ fontSize: '0.85em',
66
+ padding: 0,
67
+ }}
68
+ >
69
+ {expanded ? '▲' : '▼'} {src.blockedAccounts?.length ?? 0} unknown
70
+ </button>
71
+ ) : (
72
+ (src.error ?? '—')
73
+ )}
74
+ </td>
75
+ </tr>
76
+ {expanded && src.blockedAccounts && src.blockedAccounts.length > 0 && (
77
+ <tr>
78
+ <td
79
+ colSpan={6}
80
+ style={{ padding: '2px 8px 8px 24px', color: '#854d0e', fontSize: '0.82em' }}
81
+ >
82
+ {src.blockedAccounts.join(', ')}
83
+ </td>
84
+ </tr>
85
+ )}
86
+ </>
87
+ );
88
+ }
89
+
90
+ function RunRow({ record }: { record: RunRecord }): ReactElement {
91
+ const [expanded, setExpanded] = useState(false);
92
+
93
+ return (
94
+ <>
95
+ <tr
96
+ onClick={() => setExpanded(e => !e)}
97
+ onKeyDown={e => {
98
+ if (e.key === 'Enter' || e.key === ' ') {
99
+ e.preventDefault();
100
+ setExpanded(prev => !prev);
101
+ }
102
+ }}
103
+ tabIndex={0}
104
+ role="button"
105
+ style={{ cursor: 'pointer', background: expanded ? '#f9fafb' : undefined }}
106
+ aria-expanded={expanded}
107
+ >
108
+ <td style={{ padding: '8px 10px' }}>{formatDateTime(record.startedAt)}</td>
109
+ <td style={{ padding: '8px 10px' }}>
110
+ {formatDuration(record.startedAt, record.completedAt)}
111
+ </td>
112
+ <td style={{ padding: '8px 10px', textAlign: 'right' }}>{record.sources.length}</td>
113
+ <td style={{ padding: '8px 10px', textAlign: 'right' }}>{record.totalInserted}</td>
114
+ <td style={{ padding: '8px 10px', textAlign: 'right' }}>{record.totalSkipped}</td>
115
+ <td
116
+ style={{
117
+ padding: '8px 10px',
118
+ textAlign: 'right',
119
+ color: record.errorCount > 0 ? '#b91c1c' : undefined,
120
+ fontWeight: record.errorCount > 0 ? 600 : undefined,
121
+ }}
122
+ >
123
+ {record.errorCount}
124
+ </td>
125
+ <td style={{ padding: '8px 10px', color: '#888', fontSize: '0.85em' }}>
126
+ {expanded ? '▲' : '▼'}
127
+ </td>
128
+ </tr>
129
+
130
+ {expanded && record.sources.length > 0 && (
131
+ <tr>
132
+ <td colSpan={7} style={{ padding: '0 16px 12px' }}>
133
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.88em' }}>
134
+ <thead>
135
+ <tr style={{ color: '#888', textAlign: 'left' }}>
136
+ <th style={{ padding: '4px 8px', fontWeight: 500 }}>Source</th>
137
+ <th style={{ padding: '4px 8px', fontWeight: 500 }}>Type</th>
138
+ <th style={{ padding: '4px 8px', fontWeight: 500 }}>Status</th>
139
+ <th style={{ padding: '4px 8px', fontWeight: 500, textAlign: 'right' }}>New</th>
140
+ <th style={{ padding: '4px 8px', fontWeight: 500, textAlign: 'right' }}>
141
+ Skipped
142
+ </th>
143
+ <th style={{ padding: '4px 8px', fontWeight: 500 }}>Error</th>
144
+ </tr>
145
+ </thead>
146
+ <tbody>
147
+ {record.sources.map(src => (
148
+ <SourceRow key={src.sourceId} src={src} />
149
+ ))}
150
+ </tbody>
151
+ </table>
152
+ </td>
153
+ </tr>
154
+ )}
155
+
156
+ {expanded && record.sources.length === 0 && (
157
+ <tr>
158
+ <td colSpan={7} style={{ padding: '4px 16px 12px', color: '#aaa', fontSize: '0.88em' }}>
159
+ No per-source breakdown available.
160
+ </td>
161
+ </tr>
162
+ )}
163
+ </>
164
+ );
165
+ }
166
+
167
+ export function History(): ReactElement {
168
+ const [records, setRecords] = useState<RunRecord[]>([]);
169
+ const [loading, setLoading] = useState(true);
170
+ const [error, setError] = useState<string | null>(null);
171
+
172
+ const load = useCallback(() => {
173
+ setLoading(true);
174
+ setError(null);
175
+ getHistory()
176
+ .then(setRecords)
177
+ .catch(err => setError(err instanceof Error ? err.message : String(err)))
178
+ .finally(() => setLoading(false));
179
+ }, []);
180
+
181
+ useEffect(() => {
182
+ load();
183
+ }, [load]);
184
+
185
+ return (
186
+ <div>
187
+ <div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
188
+ <h2 style={{ margin: 0 }}>Scrape History</h2>
189
+ <button
190
+ type="button"
191
+ onClick={load}
192
+ disabled={loading}
193
+ style={{
194
+ padding: '4px 14px',
195
+ cursor: loading ? 'default' : 'pointer',
196
+ opacity: loading ? 0.7 : 1,
197
+ }}
198
+ >
199
+ Refresh
200
+ </button>
201
+ </div>
202
+
203
+ {loading ? (
204
+ <SkeletonTable rows={4} cols={7} />
205
+ ) : error ? (
206
+ <p style={{ color: '#b91c1c' }}>{error}</p>
207
+ ) : records.length === 0 ? (
208
+ <p style={{ color: '#888' }}>No scrape runs recorded yet.</p>
209
+ ) : (
210
+ <table
211
+ style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.93em' }}
212
+ aria-label="Scrape history"
213
+ >
214
+ <thead>
215
+ <tr style={{ borderBottom: '2px solid #e5e7eb', color: '#374151', textAlign: 'left' }}>
216
+ <th style={{ padding: '8px 10px', fontWeight: 600 }}>Date / Time</th>
217
+ <th style={{ padding: '8px 10px', fontWeight: 600 }}>Duration</th>
218
+ <th style={{ padding: '8px 10px', fontWeight: 600, textAlign: 'right' }}>Sources</th>
219
+ <th style={{ padding: '8px 10px', fontWeight: 600, textAlign: 'right' }}>New</th>
220
+ <th style={{ padding: '8px 10px', fontWeight: 600, textAlign: 'right' }}>Skipped</th>
221
+ <th style={{ padding: '8px 10px', fontWeight: 600, textAlign: 'right' }}>Errors</th>
222
+ <th style={{ padding: '8px 10px' }} />
223
+ </tr>
224
+ </thead>
225
+ <tbody>
226
+ {records.map(r => (
227
+ <RunRow key={r.id} record={r} />
228
+ ))}
229
+ </tbody>
230
+ </table>
231
+ )}
232
+ </div>
233
+ );
234
+ }
@@ -0,0 +1,266 @@
1
+ import { ChangeEvent, useEffect, useState, type ReactElement } from 'react';
2
+ import { Settings } from '../../server/vault.js';
3
+ import { OtpModal } from '../components/otp-modal.js';
4
+ import { SkeletonRow } from '../components/skeleton.js';
5
+ import { TaskRow } from '../components/task-row.js';
6
+ import { getSources, loadSettings, saveSettings } from '../lib/api.js';
7
+ import type { UseRunSocketResult } from '../lib/ws.js';
8
+ import type { SourceConfig } from './config/source-types.js';
9
+ import { SOURCE_LABELS } from './config/source-types.js';
10
+
11
+ function nickname(src: SourceConfig): string {
12
+ if (src.nickname) return `${SOURCE_LABELS[src.type]}: ${src.nickname}`;
13
+ return `${SOURCE_LABELS[src.type]} (${src.id.slice(0, 6)})`;
14
+ }
15
+
16
+ type RunProps = UseRunSocketResult & { onNavigateAccounts?: () => void; isVisible?: boolean };
17
+
18
+ export function Run({
19
+ send,
20
+ taskStates,
21
+ runStatus,
22
+ summary,
23
+ onNavigateAccounts,
24
+ isVisible = true,
25
+ }: RunProps): ReactElement {
26
+ const [sources, setSources] = useState<SourceConfig[]>([]);
27
+ const [sourcesLoading, setSourcesLoading] = useState(true);
28
+ const [selected, setSelected] = useState<Set<string>>(new Set());
29
+ const [months, setMonths] = useState(3);
30
+ const [useCustomRange, setUseCustomRange] = useState(false);
31
+ const [dateFrom, setDateFrom] = useState('');
32
+ const [dateTo, setDateTo] = useState('');
33
+ const [error, setError] = useState<string | null>(null);
34
+ const [shouldFetchRates, setShouldFetchRates] = useState(false);
35
+
36
+ useEffect(() => {
37
+ loadSettings()
38
+ .then(s => setShouldFetchRates(s.fetchBankOfIsraelRates))
39
+ .catch(() => setError('Failed to load settings'));
40
+ }, []);
41
+
42
+ useEffect(() => {
43
+ if (!isVisible || selected.size > 0) return;
44
+ setSourcesLoading(true);
45
+ getSources<SourceConfig>()
46
+ .then(srcs => {
47
+ setSources(
48
+ srcs.sort(
49
+ (a, b) =>
50
+ SOURCE_LABELS[a.type].localeCompare(SOURCE_LABELS[b.type]) ||
51
+ (a.nickname || a.id).localeCompare(b.nickname || b.id),
52
+ ),
53
+ );
54
+ setSelected(new Set(srcs.map(s => s.id)));
55
+ })
56
+ .finally(() => setSourcesLoading(false));
57
+ }, [isVisible]);
58
+
59
+ function toggleSource(id: string) {
60
+ setSelected(prev => {
61
+ const next = new Set(prev);
62
+ if (next.has(id)) next.delete(id);
63
+ else next.add(id);
64
+ return next;
65
+ });
66
+ }
67
+
68
+ async function autoSave(patch: Partial<Settings>) {
69
+ try {
70
+ const updated = await saveSettings(patch);
71
+ setShouldFetchRates(updated.fetchBankOfIsraelRates);
72
+ } catch {
73
+ setError('Failed to toggle currency rates setting');
74
+ }
75
+ }
76
+
77
+ const handleToggleRates = (e: ChangeEvent<HTMLInputElement>) => {
78
+ const value = e.target.checked;
79
+ setShouldFetchRates(value);
80
+ void autoSave({ fetchBankOfIsraelRates: value });
81
+ };
82
+
83
+ function handleRun() {
84
+ const sourceIds = [...selected];
85
+ const msg = {
86
+ type: 'run-start' as const,
87
+ sourceIds,
88
+ dateFrom: useCustomRange
89
+ ? dateFrom || undefined
90
+ : new Date(new Date().getFullYear(), new Date().getMonth() - months, new Date().getDate())
91
+ .toISOString()
92
+ .split('T')[0],
93
+ dateTo: useCustomRange ? dateTo || undefined : undefined,
94
+ };
95
+ send(msg);
96
+ }
97
+
98
+ // Find any task waiting for OTP
99
+ const otpEntry = [...taskStates.entries()].find(([, s]) => s.status === 'otp-required');
100
+
101
+ return (
102
+ <div>
103
+ <h2 style={{ margin: '0 0 16px' }}>Run Scrapers</h2>
104
+
105
+ {error && (
106
+ <p role="alert" style={{ color: 'red' }}>
107
+ {error}
108
+ </p>
109
+ )}
110
+
111
+ {/* Source checklist */}
112
+ <section style={{ marginBottom: 20 }}>
113
+ <h3 style={{ margin: '0 0 10px', fontSize: '1em' }}>Sources</h3>
114
+ {sourcesLoading ? (
115
+ <div aria-busy="true">
116
+ <SkeletonRow width="55%" />
117
+ <SkeletonRow width="70%" />
118
+ <SkeletonRow width="45%" />
119
+ </div>
120
+ ) : sources.length === 0 ? (
121
+ <p style={{ color: '#888' }}>No sources configured.</p>
122
+ ) : (
123
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
124
+ {sources.map(src => (
125
+ <label
126
+ key={src.id}
127
+ style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
128
+ >
129
+ <input
130
+ type="checkbox"
131
+ checked={selected.has(src.id)}
132
+ onChange={() => toggleSource(src.id)}
133
+ />
134
+ {nickname(src)}
135
+ </label>
136
+ ))}
137
+
138
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
139
+ <input type="checkbox" checked={shouldFetchRates} onChange={handleToggleRates} />
140
+ Currency Rates (Bank of Israel)
141
+ </label>
142
+ </div>
143
+ )}
144
+ </section>
145
+
146
+ {/* Date range */}
147
+ <section style={{ marginBottom: 20 }}>
148
+ <h3 style={{ margin: '0 0 10px', fontSize: '1em' }}>Date Range</h3>
149
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
150
+ <input
151
+ type="checkbox"
152
+ checked={useCustomRange}
153
+ onChange={e => setUseCustomRange(e.target.checked)}
154
+ />
155
+ Use custom date range
156
+ </label>
157
+ {useCustomRange ? (
158
+ <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
159
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
160
+ From
161
+ <input
162
+ type="date"
163
+ value={dateFrom}
164
+ onChange={e => setDateFrom(e.target.value)}
165
+ aria-label="date from"
166
+ />
167
+ </label>
168
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
169
+ To
170
+ <input
171
+ type="date"
172
+ value={dateTo}
173
+ onChange={e => setDateTo(e.target.value)}
174
+ aria-label="date to"
175
+ />
176
+ </label>
177
+ </div>
178
+ ) : (
179
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
180
+ Last{' '}
181
+ <input
182
+ type="number"
183
+ min={1}
184
+ max={24}
185
+ value={months}
186
+ onChange={e => setMonths(Number(e.target.value))}
187
+ style={{ width: 60, padding: '4px 6px' }}
188
+ aria-label="months"
189
+ />{' '}
190
+ months
191
+ </label>
192
+ )}
193
+ </section>
194
+
195
+ {/* Run button */}
196
+ <button
197
+ type="button"
198
+ onClick={handleRun}
199
+ disabled={runStatus === 'running' || selected.size === 0}
200
+ style={{
201
+ padding: '8px 24px',
202
+ background: runStatus === 'running' ? '#9ca3af' : '#2563eb',
203
+ color: '#fff',
204
+ border: 'none',
205
+ borderRadius: 6,
206
+ cursor: runStatus === 'running' || selected.size === 0 ? 'default' : 'pointer',
207
+ fontSize: '1em',
208
+ marginBottom: 24,
209
+ }}
210
+ >
211
+ {runStatus === 'running' ? 'Running…' : 'Run'}
212
+ </button>
213
+
214
+ {/* Task list */}
215
+ {taskStates.size > 0 && (
216
+ <section style={{ marginBottom: 20 }}>
217
+ <h3 style={{ margin: '0 0 10px', fontSize: '1em' }}>Tasks</h3>
218
+ {[...taskStates.entries()].map(([sourceId, state]) => {
219
+ const src = sources.find(s => s.id === sourceId);
220
+ return (
221
+ <TaskRow
222
+ key={sourceId}
223
+ sourceId={sourceId}
224
+ nickname={src ? nickname(src) : sourceId}
225
+ state={state}
226
+ onNavigateAccounts={onNavigateAccounts}
227
+ />
228
+ );
229
+ })}
230
+ </section>
231
+ )}
232
+
233
+ {/* Summary panel */}
234
+ {runStatus === 'complete' && summary && (
235
+ <section
236
+ aria-label="Run summary"
237
+ style={{
238
+ background: '#f0fdf4',
239
+ border: '1px solid #bbf7d0',
240
+ borderRadius: 8,
241
+ padding: 16,
242
+ }}
243
+ >
244
+ <h3 style={{ margin: '0 0 8px', fontSize: '1em' }}>Run Complete</h3>
245
+ <div style={{ display: 'flex', gap: 24 }}>
246
+ <span>↑ {summary.totalInserted} new</span>
247
+ <span>↷ {summary.totalSkipped} skipped</span>
248
+ {summary.errors > 0 && (
249
+ <span style={{ color: '#b91c1c' }}>✕ {summary.errors} errors</span>
250
+ )}
251
+ </div>
252
+ </section>
253
+ )}
254
+
255
+ {/* OTP modal */}
256
+ {otpEntry && (
257
+ <OtpModal
258
+ sourceId={otpEntry[0]}
259
+ onSubmit={msg => {
260
+ send(msg);
261
+ }}
262
+ />
263
+ )}
264
+ </div>
265
+ );
266
+ }