node-red-contrib-eskomsepush 0.0.18 → 0.1.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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm run *)",
5
+ "Bash(npx jest *)"
6
+ ]
7
+ }
8
+ }
package/README.md CHANGED
@@ -37,10 +37,10 @@ If you don't want to use API quota by searching, and you already know the id of
37
37
 
38
38
  To fetch the area id manually, make an `areas_search` API call using your API license key `token`, a word of search `text`. In the response returned by the API, copy the `id` value of the matching area.
39
39
 
40
- In the example below (on MacOS), `curl` is used to query the API and the search text value is 'ballito' (the license key token is invalid and must be replaced with a valid key). The area id value that will be used from this example is `eskmo-15-ballitokwadukuzakwazulunatal`:
40
+ In the example below (on MacOS), `curl` is used to query the API and the search text value is 'ballito' (the license key token is invalid and must be replaced with a valid key).
41
41
 
42
42
  ```
43
- % curl --location --request GET 'https://developer.sepush.co.za/business/2.0/areas_search?text=ballito' --header 'token: 2DFB82AC-46254F6E-A68B26A4-8DF1303E'
43
+ % curl --location --request GET 'https://developer.sepush.co.za/business/3.0/areas_search?text=ballito' --header 'token: 2DFB82AC-46254F6E-A68B26A4-8DF1303E'
44
44
  {
45
45
  "areas":[
46
46
  {"id":"eskmo-15-ballitokwadukuzakwazulunatal","name":"Ballito (15)","region":"Eskom Municipal, Kwadukuza, Kwazulu-Natal"},
@@ -49,6 +49,8 @@ In the example below (on MacOS), `curl` is used to query the API and the search
49
49
  }
50
50
  ```
51
51
 
52
+ > **Note:** The API returns v2-style IDs with a location suffix. When entering the area id in the node, use only the first two dash-separated parts — e.g. use `eskmo-15`, not `eskmo-15-ballitokwadukuzakwazulunatal`. The node will warn you if it detects an old-style ID.
53
+
52
54
  Then you need to fill out which status to follow. This can be either _National_ (Eskom) or _Cape Town_.
53
55
 
54
56
  If the _test_ checkbox has been selected, test data for the specified area will be fetched instead of the actual schedule. This is useful when debugging.
@@ -114,7 +116,7 @@ a matching _schedule_.
114
116
 
115
117
  ### Documentation
116
118
 
117
- Documentation for the API can be found [here](https://documenter.getpostman.com/view/1296288/UzQuNk3E)
119
+ Documentation for the API can be found [here](https://developer.sepush.co.za/business/3.0/)
118
120
 
119
121
  When quota has been exceeded:
120
122
 
package/jest.config.js ADDED
@@ -0,0 +1,9 @@
1
+ /** @type {import('jest').Config} */
2
+ module.exports = {
3
+ testEnvironment: 'node',
4
+ testMatch: ['**/src/__tests__/**/*.test.ts'],
5
+ collectCoverageFrom: ['src/lib/**/*.ts'],
6
+ transform: {
7
+ '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }]
8
+ }
9
+ }
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "node-red-contrib-eskomsepush",
3
- "version": "0.0.18",
3
+ "version": "0.1.1",
4
4
  "description": "Node-RED interface for the Eskomsepush API",
5
- "main": "index.js",
6
5
  "scripts": {
7
- "test": "standard --fix"
6
+ "build": "tsc && cp src/nodes/eskomsepush.html dist/nodes/ && cp -r src/nodes/icons dist/nodes/",
7
+ "prepare": "npm run build",
8
+ "test": "jest",
9
+ "test:coverage": "jest --coverage",
10
+ "clean": "rm -rf dist"
8
11
  },
9
12
  "keywords": [
10
13
  "node-red",
@@ -19,7 +22,7 @@
19
22
  "license": "MIT",
20
23
  "node-red": {
21
24
  "nodes": {
22
- "eskomsepush": "./src/nodes/eskomsepush.js"
25
+ "eskomsepush": "./dist/nodes/eskomsepush.js"
23
26
  },
24
27
  "version": ">=3.0.2"
25
28
  },
@@ -28,12 +31,16 @@
28
31
  "url": "https://github.com/dirkjanfaber/node-red-contrib-eskomsepush"
29
32
  },
30
33
  "dependencies": {
31
- "axios": "^1.3.5"
34
+ "axios": "^1.17.0"
32
35
  },
33
36
  "devDependencies": {
34
- "standard": "^17.0.0"
37
+ "@types/jest": "^29.5.14",
38
+ "@types/node": "^25.9.3",
39
+ "jest": "^29.7.0",
40
+ "ts-jest": "^29.4.11",
41
+ "typescript": "^6.0.3"
35
42
  },
36
43
  "engines": {
37
- "node": ">=14.17.4"
44
+ "node": ">=18.0.0"
38
45
  }
39
46
  }
@@ -0,0 +1,103 @@
1
+ import axios from 'axios'
2
+ import { fetchAllowance, fetchArea, fetchStatus, migrateAreaId, searchAreas } from '../lib/api'
3
+
4
+ jest.mock('axios')
5
+ const mockedAxios = axios as jest.Mocked<typeof axios>
6
+
7
+ const V3_BASE = 'https://developer.sepush.co.za/business/3.0'
8
+ const TOKEN = 'TEST-TOKEN-1234'
9
+
10
+ beforeEach(() => jest.clearAllMocks())
11
+
12
+ describe('migrateAreaId', () => {
13
+ it('strips the location suffix from a v2-style ID', () => {
14
+ expect(migrateAreaId('eskde-10-fourways')).toEqual({ id: 'eskde-10', migrated: true })
15
+ })
16
+
17
+ it('leaves a v3-style ID unchanged', () => {
18
+ expect(migrateAreaId('eskde-10')).toEqual({ id: 'eskde-10', migrated: false })
19
+ })
20
+
21
+ it('handles long location suffixes', () => {
22
+ expect(migrateAreaId('eskmo-15-ballitokwadukuzakwazulunatal')).toEqual({
23
+ id: 'eskmo-15',
24
+ migrated: true
25
+ })
26
+ })
27
+ })
28
+
29
+ describe('fetchAllowance', () => {
30
+ it('calls the v3 api_allowance endpoint', async () => {
31
+ const payload = { allowance: { count: 10, limit: 50, type: 'free' } }
32
+ mockedAxios.get.mockResolvedValueOnce({ data: payload })
33
+
34
+ const result = await fetchAllowance(TOKEN)
35
+
36
+ expect(mockedAxios.get).toHaveBeenCalledWith(
37
+ `${V3_BASE}/api_allowance`,
38
+ expect.objectContaining({ headers: { token: TOKEN } })
39
+ )
40
+ expect(result).toEqual(payload)
41
+ })
42
+ })
43
+
44
+ describe('fetchStatus', () => {
45
+ it('calls the v3 status endpoint', async () => {
46
+ const payload = { status: { eskom: { stage: '2', name: 'National', next_stages: [], stage_updated: '' } } }
47
+ mockedAxios.get.mockResolvedValueOnce({ data: payload })
48
+
49
+ const result = await fetchStatus(TOKEN)
50
+
51
+ expect(mockedAxios.get).toHaveBeenCalledWith(
52
+ `${V3_BASE}/status`,
53
+ expect.objectContaining({ headers: { token: TOKEN } })
54
+ )
55
+ expect(result).toEqual(payload)
56
+ })
57
+ })
58
+
59
+ describe('fetchArea', () => {
60
+ it('calls the v3 area endpoint with the given area ID', async () => {
61
+ const payload = { events: [], info: { name: 'Test', region: 'Test' }, schedule: { days: [], source: '' } }
62
+ mockedAxios.get.mockResolvedValueOnce({ data: payload })
63
+
64
+ await fetchArea(TOKEN, 'eskde-10')
65
+
66
+ expect(mockedAxios.get).toHaveBeenCalledWith(
67
+ `${V3_BASE}/area`,
68
+ expect.objectContaining({
69
+ headers: { token: TOKEN },
70
+ params: { id: 'eskde-10' }
71
+ })
72
+ )
73
+ })
74
+
75
+ it('appends test=current when testMode is true', async () => {
76
+ mockedAxios.get.mockResolvedValueOnce({ data: {} })
77
+
78
+ await fetchArea(TOKEN, 'eskde-10', true)
79
+
80
+ expect(mockedAxios.get).toHaveBeenCalledWith(
81
+ `${V3_BASE}/area`,
82
+ expect.objectContaining({ params: { id: 'eskde-10', test: 'current' } })
83
+ )
84
+ })
85
+ })
86
+
87
+ describe('searchAreas', () => {
88
+ it('calls the v3 areas_search endpoint with text param', async () => {
89
+ const payload = { areas: [{ id: 'eskde-10', name: 'Fourways 2', region: 'Eskom Direct' }] }
90
+ mockedAxios.get.mockResolvedValueOnce({ data: payload })
91
+
92
+ const result = await searchAreas(TOKEN, 'fourways')
93
+
94
+ expect(mockedAxios.get).toHaveBeenCalledWith(
95
+ `${V3_BASE}/areas_search`,
96
+ expect.objectContaining({
97
+ headers: { token: TOKEN },
98
+ params: { text: 'fourways' }
99
+ })
100
+ )
101
+ expect(result).toEqual(payload)
102
+ })
103
+ })
@@ -0,0 +1,262 @@
1
+ import { calculateCalc, calculateSleepTime, getMinutesToAPIReset } from '../lib/loadshedding'
2
+ import type { AreaInfo, StatusInfo } from '../lib/types'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+
8
+ function makeArea(
9
+ stageSlots: string[][],
10
+ tomorrowSlots: string[][] = stageSlots,
11
+ events: AreaInfo['events'] = []
12
+ ): AreaInfo {
13
+ return {
14
+ events,
15
+ info: { name: 'Test Area', region: 'Test Region' },
16
+ schedule: {
17
+ days: [
18
+ { date: '2024-01-15', name: 'Monday', stages: stageSlots },
19
+ { date: '2024-01-16', name: 'Tuesday', stages: tomorrowSlots }
20
+ ],
21
+ source: 'test'
22
+ }
23
+ }
24
+ }
25
+
26
+ function makeStatus(stage: string, key = 'eskom'): StatusInfo {
27
+ return { status: { [key]: { stage, name: 'National', next_stages: [], stage_updated: '' } } }
28
+ }
29
+
30
+ /** Local Date at a specific clock time on 2024-01-15 */
31
+ function at(h: number, m = 0): Date {
32
+ return new Date(2024, 0, 15, h, m, 0)
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // getMinutesToAPIReset
37
+ // ---------------------------------------------------------------------------
38
+
39
+ describe('getMinutesToAPIReset', () => {
40
+ it('returns 60 minutes when it is 01:00', () => {
41
+ expect(getMinutesToAPIReset(at(1))).toBe(60)
42
+ })
43
+
44
+ it('returns 23 * 60 minutes when it is 03:00 (reset already passed today)', () => {
45
+ expect(getMinutesToAPIReset(at(3))).toBe(23 * 60)
46
+ })
47
+
48
+ it('returns 0 minutes at exactly 02:00', () => {
49
+ expect(getMinutesToAPIReset(at(2))).toBe(0)
50
+ })
51
+ })
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // calculateSleepTime
55
+ // ---------------------------------------------------------------------------
56
+
57
+ describe('calculateSleepTime', () => {
58
+ it('returns 60 when all allowance is consumed', () => {
59
+ expect(calculateSleepTime(50, 50, 0, 120)).toBe(60)
60
+ })
61
+
62
+ it('returns 60 when remaining is negative due to buffer', () => {
63
+ expect(calculateSleepTime(50, 48, 5, 120)).toBe(60)
64
+ })
65
+
66
+ it('enforces a minimum of 10 minutes', () => {
67
+ // remaining=50, minutesToReset=1 → round(1/ceil(50/2)) = round(1/25) = 0 → min 10
68
+ expect(calculateSleepTime(50, 0, 0, 1)).toBe(10)
69
+ })
70
+
71
+ it('distributes remaining calls evenly over minutesToReset', () => {
72
+ // remaining=20, minutesToReset=120 → round(120/ceil(20/2)) = round(120/10) = 12
73
+ expect(calculateSleepTime(50, 30, 0, 120)).toBe(12)
74
+ })
75
+
76
+ it('applies buffer when calculating remaining calls', () => {
77
+ // remaining=15 (50-30-5), minutesToReset=120 → round(120/ceil(15/2)) = round(120/8) = 15
78
+ expect(calculateSleepTime(50, 30, 5, 120)).toBe(15)
79
+ })
80
+ })
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // calculateCalc
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe('calculateCalc', () => {
87
+ const SLEEPTIME = 60
88
+
89
+ describe('stage 0 (no loadshedding)', () => {
90
+ it('returns inactive with no next period', () => {
91
+ const area = makeArea([[], []])
92
+ const status = makeStatus('0')
93
+ const result = calculateCalc(area, status, 'eskom', SLEEPTIME, at(13))
94
+
95
+ expect(result.active).toBe(false)
96
+ expect(result.next).toBeUndefined()
97
+ })
98
+ })
99
+
100
+ describe('schedule-based loadshedding', () => {
101
+ // Stage 2 slots on 2024-01-15: 04:00–06:30 and 12:00–14:30
102
+ // Stage 1 slots are in index 0, stage 2 in index 1
103
+ const stageSlots = [
104
+ ['20:00-22:30'], // stage 1
105
+ ['04:00-06:30', '12:00-14:30'] // stage 2
106
+ ]
107
+ const tomorrowSlots = [
108
+ ['00:00-02:30'],
109
+ ['00:00-02:30', '08:00-10:30']
110
+ ]
111
+
112
+ it('detects an active schedule slot', () => {
113
+ const area = makeArea(stageSlots, tomorrowSlots)
114
+ const result = calculateCalc(area, makeStatus('2'), 'eskom', SLEEPTIME, at(13))
115
+
116
+ expect(result.active).toBe(true)
117
+ expect(result.type).toBe('schedule')
118
+ expect(result.stage).toBe('2')
119
+ expect(result.duration).toBe(2.5 * 3600)
120
+ expect(result.islong).toBe(false)
121
+ })
122
+
123
+ it('finds the next slot later today when not active', () => {
124
+ const area = makeArea(stageSlots, tomorrowSlots)
125
+ const result = calculateCalc(area, makeStatus('2'), 'eskom', SLEEPTIME, at(8))
126
+
127
+ expect(result.active).toBe(false)
128
+ expect(result.next?.type).toBe('schedule')
129
+ expect(result.next?.duration).toBe(2.5 * 3600)
130
+ expect(result.next?.islong).toBe(false)
131
+ // secondstostatechange should be positive (time until 12:00)
132
+ expect(result.secondstostatechange).toBeGreaterThan(0)
133
+ })
134
+
135
+ it('falls back to the first slot tomorrow when today has no more slots', () => {
136
+ const area = makeArea(stageSlots, tomorrowSlots)
137
+ const result = calculateCalc(area, makeStatus('2'), 'eskom', SLEEPTIME, at(23))
138
+
139
+ expect(result.active).toBe(false)
140
+ expect(result.next?.type).toBe('schedule')
141
+ // The next start should be on 2024-01-16 (tomorrow)
142
+ expect(new Date(result.next!.start).getDate()).toBe(16)
143
+ })
144
+
145
+ it('marks islong true for a 4-hour-plus slot', () => {
146
+ const longSlots = [
147
+ ['12:00-16:00'] // stage 1, exactly 4 h
148
+ ]
149
+ const area = makeArea(longSlots)
150
+ const result = calculateCalc(area, makeStatus('1'), 'eskom', SLEEPTIME, at(10))
151
+
152
+ expect(result.next?.islong).toBe(true)
153
+ })
154
+
155
+ it('handles midnight-crossing slots', () => {
156
+ const midnightSlots = [
157
+ ['22:00-00:30'] // stage 1, crosses midnight
158
+ ]
159
+ const area = makeArea(midnightSlots, midnightSlots)
160
+ const result = calculateCalc(area, makeStatus('1'), 'eskom', SLEEPTIME, at(23))
161
+
162
+ expect(result.active).toBe(true)
163
+ expect(result.type).toBe('schedule')
164
+ })
165
+
166
+ it('sets secondstostatechange to time until shedding ends when active', () => {
167
+ const area = makeArea(stageSlots, tomorrowSlots)
168
+ const result = calculateCalc(area, makeStatus('2'), 'eskom', SLEEPTIME, at(13))
169
+
170
+ // 14:30 − 13:00 = 90 minutes = 5400 seconds
171
+ expect(result.secondstostatechange).toBeCloseTo(5400, -1)
172
+ })
173
+
174
+ it('marks isHigherStage true when the next event has a higher stage than current', () => {
175
+ // We need the schedule loop to NOT overwrite calc.next, so we use an area
176
+ // whose stages array has only 1 element — stage 2 (index 1) is out of bounds → break.
177
+ const eventStartMs = Date.UTC(2024, 0, 15, 20, 0)
178
+ const eventEndMs = Date.UTC(2024, 0, 15, 22, 30)
179
+ const events: AreaInfo['events'] = [{
180
+ start: new Date(eventStartMs).toISOString(),
181
+ end: new Date(eventEndMs).toISOString(),
182
+ note: 'Stage 4 Loadshedding'
183
+ }]
184
+ const area = makeArea([['20:00-22:30']], [['00:00-02:30']], events)
185
+ const result = calculateCalc(area, makeStatus('2'), 'eskom', SLEEPTIME, at(10))
186
+
187
+ expect(result.next?.type).toBe('event')
188
+ expect(result.next?.stage).toBe('4')
189
+ expect(result.next?.isHigherStage).toBe(true)
190
+ })
191
+ })
192
+
193
+ describe('event-based loadshedding', () => {
194
+ // Events use ISO timestamps with timezone offset — Date.parse handles them in UTC
195
+ const eventStartMs = Date.UTC(2024, 0, 15, 10, 0) // 10:00 UTC
196
+ const eventEndMs = Date.UTC(2024, 0, 15, 12, 30) // 12:30 UTC
197
+ const events: AreaInfo['events'] = [{
198
+ start: new Date(eventStartMs).toISOString(),
199
+ end: new Date(eventEndMs).toISOString(),
200
+ note: 'Stage 2 Loadshedding'
201
+ }]
202
+
203
+ it('detects an active event', () => {
204
+ const area = makeArea([[]], [[]], events)
205
+ const nowUTC = new Date(Date.UTC(2024, 0, 15, 11, 0)) // 11:00 UTC, inside event
206
+
207
+ const result = calculateCalc(area, makeStatus('0'), 'eskom', SLEEPTIME, nowUTC)
208
+
209
+ expect(result.active).toBe(true)
210
+ expect(result.type).toBe('event')
211
+ expect(result.stage).toBe('2') // stage overridden from event note
212
+ })
213
+
214
+ it('finds an upcoming event when not yet started', () => {
215
+ const area = makeArea([[]], [[]], events)
216
+ const nowUTC = new Date(Date.UTC(2024, 0, 15, 8, 0)) // 08:00 UTC, before event
217
+
218
+ const result = calculateCalc(area, makeStatus('0'), 'eskom', SLEEPTIME, nowUTC)
219
+
220
+ expect(result.active).toBe(false)
221
+ expect(result.next?.type).toBe('event')
222
+ expect(result.next?.duration).toBe(2.5 * 3600)
223
+ })
224
+
225
+ it('ignores a past event', () => {
226
+ const area = makeArea([[]], [[]], events)
227
+ const nowUTC = new Date(Date.UTC(2024, 0, 15, 13, 0)) // 13:00 UTC, after event
228
+
229
+ const result = calculateCalc(area, makeStatus('0'), 'eskom', SLEEPTIME, nowUTC)
230
+
231
+ expect(result.active).toBe(false)
232
+ expect(result.next).toBeUndefined()
233
+ })
234
+
235
+ it('does not crash when event note has no stage pattern', () => {
236
+ const bareEvents: AreaInfo['events'] = [{
237
+ start: new Date(eventStartMs).toISOString(),
238
+ end: new Date(eventEndMs).toISOString(),
239
+ note: 'Unplanned outage'
240
+ }]
241
+ const area = makeArea([[]], [[]], bareEvents)
242
+ const nowUTC = new Date(Date.UTC(2024, 0, 15, 11, 0))
243
+
244
+ expect(() => calculateCalc(area, makeStatus('1'), 'eskom', SLEEPTIME, nowUTC)).not.toThrow()
245
+ })
246
+ })
247
+
248
+ describe('statusselect', () => {
249
+ it('reads the stage from the correct statusselect key', () => {
250
+ const statusInfo: StatusInfo = {
251
+ status: {
252
+ eskom: { stage: '1', name: 'National', next_stages: [], stage_updated: '' },
253
+ capetown: { stage: '3', name: 'Cape Town', next_stages: [], stage_updated: '' }
254
+ }
255
+ }
256
+ const area = makeArea([['20:00-22:30'], [], ['12:00-14:30', '20:00-22:30']])
257
+ const result = calculateCalc(area, statusInfo, 'capetown', SLEEPTIME, at(8))
258
+
259
+ expect(result.stage).toBe('3')
260
+ })
261
+ })
262
+ })
package/src/lib/api.ts ADDED
@@ -0,0 +1,45 @@
1
+ import axios from 'axios'
2
+ import type { AllowanceInfo, AreaInfo, AreasSearchResult, StatusInfo } from './types'
3
+
4
+ const API_BASE = 'https://developer.sepush.co.za/business/3.0'
5
+
6
+ /** Detects a v2-style area ID (e.g. eskde-10-fourways) and returns the v3 equivalent (eskde-10). */
7
+ export function migrateAreaId(areaId: string): { id: string; migrated: boolean } {
8
+ const parts = areaId.split('-')
9
+ if (parts.length > 2) {
10
+ return { id: parts.slice(0, 2).join('-'), migrated: true }
11
+ }
12
+ return { id: areaId, migrated: false }
13
+ }
14
+
15
+ export async function fetchAllowance(token: string): Promise<AllowanceInfo> {
16
+ const { data } = await axios.get<AllowanceInfo>(`${API_BASE}/api_allowance`, {
17
+ headers: { token }
18
+ })
19
+ return data
20
+ }
21
+
22
+ export async function fetchStatus(token: string): Promise<StatusInfo> {
23
+ const { data } = await axios.get<StatusInfo>(`${API_BASE}/status`, {
24
+ headers: { token }
25
+ })
26
+ return data
27
+ }
28
+
29
+ export async function fetchArea(token: string, areaId: string, testMode = false): Promise<AreaInfo> {
30
+ const params: Record<string, string> = { id: areaId }
31
+ if (testMode) params.test = 'current'
32
+ const { data } = await axios.get<AreaInfo>(`${API_BASE}/area`, {
33
+ headers: { token },
34
+ params
35
+ })
36
+ return data
37
+ }
38
+
39
+ export async function searchAreas(token: string, query: string): Promise<AreasSearchResult> {
40
+ const { data } = await axios.get<AreasSearchResult>(`${API_BASE}/areas_search`, {
41
+ headers: { token },
42
+ params: { text: query }
43
+ })
44
+ return data
45
+ }
@@ -0,0 +1,131 @@
1
+ import type { AreaInfo, CalcResult, StatusInfo } from './types'
2
+
3
+ export function getMinutesToAPIReset(now: Date = new Date()): number {
4
+ const resetTime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 2, 0, 0)
5
+ if (now > resetTime) {
6
+ resetTime.setDate(resetTime.getDate() + 1)
7
+ }
8
+ return Math.floor((resetTime.getTime() - now.getTime()) / 60_000)
9
+ }
10
+
11
+ export function calculateSleepTime(
12
+ limit: number,
13
+ count: number,
14
+ buffer: number,
15
+ minutesToReset: number
16
+ ): number {
17
+ const remaining = limit - buffer - count
18
+ if (remaining <= 0) return 60
19
+ const sleeptime = Math.round(minutesToReset / Math.ceil(remaining / 2))
20
+ return Math.max(sleeptime, 10)
21
+ }
22
+
23
+ function parseScheduleTime(date: string, time: string): number {
24
+ const [year, month, day] = date.split('-').map(Number)
25
+ const [hour, minute] = time.split(':').map(Number)
26
+ return new Date(year, month - 1, day, hour, minute).getTime()
27
+ }
28
+
29
+ export function calculateCalc(
30
+ areaInfo: AreaInfo,
31
+ statusInfo: StatusInfo,
32
+ statusselect: string,
33
+ sleeptime: number,
34
+ now: Date = new Date()
35
+ ): CalcResult {
36
+ const statusStage = statusInfo.status[statusselect]?.stage ?? '0'
37
+ const calc: CalcResult = {
38
+ sleeptime,
39
+ stage: statusStage,
40
+ active: false
41
+ }
42
+
43
+ // Check events first — an active event can override the effective stage
44
+ if (areaInfo.events.length > 0) {
45
+ const firstEvent = areaInfo.events[0]
46
+ const eventStart = Date.parse(firstEvent.start)
47
+ const eventEnd = Date.parse(firstEvent.end)
48
+
49
+ if (now.getTime() >= eventStart && now.getTime() < eventEnd) {
50
+ calc.type = 'event'
51
+ calc.active = true
52
+ const stageMatch = firstEvent.note.match(/Stage (\d+)/i)
53
+ if (stageMatch) calc.stage = stageMatch[1]
54
+ calc.start = eventStart
55
+ calc.end = eventEnd
56
+ } else if (now.getTime() < eventStart) {
57
+ const stageMatch = firstEvent.note.match(/Stage (\d+)/i)
58
+ calc.next = {
59
+ type: 'event',
60
+ start: eventStart,
61
+ end: eventEnd,
62
+ duration: 0, // computed in post-loop block
63
+ islong: false, // computed in post-loop block
64
+ stage: stageMatch ? stageMatch[1] : statusStage
65
+ }
66
+ }
67
+ }
68
+
69
+ // Check scheduled downtime — uses local time (schedule times have no timezone info)
70
+ const effectiveStageNum = parseInt(String(calc.stage), 10)
71
+ let breakLoop = false
72
+
73
+ for (const day of areaInfo.schedule.days) {
74
+ const stageIndex = effectiveStageNum - 1
75
+
76
+ if (stageIndex < 0 || stageIndex >= day.stages.length) {
77
+ break // Stage 0 or out of range → no scheduled loadshedding
78
+ }
79
+
80
+ const slots = day.stages[stageIndex]
81
+ if (!Array.isArray(slots)) break
82
+
83
+ for (const slot of slots) {
84
+ const [startStr, endStr] = slot.split('-')
85
+ let schedStart = parseScheduleTime(day.date, startStr)
86
+ let schedEnd = parseScheduleTime(day.date, endStr)
87
+
88
+ // Handle slots that cross midnight (e.g. "22:00-00:30")
89
+ if (schedEnd < schedStart) {
90
+ schedEnd += 24 * 60 * 60 * 1000
91
+ }
92
+
93
+ if (now.getTime() < schedEnd) {
94
+ breakLoop = true
95
+ if (now.getTime() >= schedStart) {
96
+ calc.active = true
97
+ calc.type = 'schedule'
98
+ calc.start = schedStart
99
+ calc.end = schedEnd
100
+ } else {
101
+ calc.next = {
102
+ type: 'schedule',
103
+ start: schedStart,
104
+ end: schedEnd,
105
+ duration: 0, // computed below
106
+ islong: false, // computed below
107
+ stage: calc.stage
108
+ }
109
+ }
110
+ break
111
+ }
112
+ }
113
+ if (breakLoop) break
114
+ }
115
+
116
+ // Compute derived fields — next block runs first so active block can overwrite secondstostatechange
117
+ if (calc.next) {
118
+ calc.next.duration = (calc.next.end - calc.next.start) / 1000
119
+ calc.next.islong = calc.next.duration >= 4 * 3600
120
+ calc.secondstostatechange = Math.floor((calc.next.start - now.getTime()) / 1000)
121
+ calc.next.isHigherStage = Number(calc.next.stage) > Number(calc.stage)
122
+ }
123
+
124
+ if (calc.active && calc.start !== undefined && calc.end !== undefined) {
125
+ calc.duration = (calc.end - calc.start) / 1000
126
+ calc.islong = calc.duration >= 4 * 3600
127
+ calc.secondstostatechange = Math.floor((calc.end - now.getTime()) / 1000)
128
+ }
129
+
130
+ return calc
131
+ }