salat 4.6.2 → 4.9.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.
- package/dist/components/CitiesApp.test.js +18 -0
- package/dist/components/HelpApp.test.js +19 -0
- package/dist/components/TimesApp.js +7 -62
- package/dist/components/TimesApp.test.js +65 -0
- package/dist/hooks/useSalat.js +74 -0
- package/dist/services/utils/api.js +5 -0
- package/dist/services/utils/api.test.js +23 -0
- package/dist/services/utils/city.js +19 -0
- package/dist/services/utils/city.test.js +62 -0
- package/dist/services/utils/index.js +4 -0
- package/dist/services/utils/parser.js +20 -0
- package/dist/services/utils/parser.test.js +33 -0
- package/dist/services/utils/time.js +26 -0
- package/dist/services/utils/time.test.js +50 -0
- package/package.json +6 -4
- package/dist/services/utils.js +0 -80
- package/dist/services/utils.test.js +0 -116
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import CitiesApp from './CitiesApp.js';
|
|
5
|
+
describe('CitiesApp', () => {
|
|
6
|
+
it('should render available cities', () => {
|
|
7
|
+
const { lastFrame } = render(_jsx(CitiesApp, {}));
|
|
8
|
+
expect(lastFrame()).toContain('Available Cities in Morocco');
|
|
9
|
+
// Checking some expected cities (from data/cities.json)
|
|
10
|
+
// I'll assume Casablanca and Rabat are there
|
|
11
|
+
expect(lastFrame()).toContain('Casablanca');
|
|
12
|
+
expect(lastFrame()).toContain('Rabat');
|
|
13
|
+
});
|
|
14
|
+
it('should display the tip', () => {
|
|
15
|
+
const { lastFrame } = render(_jsx(CitiesApp, {}));
|
|
16
|
+
expect(lastFrame()).toContain('Tip: Use these names with the \'times\' command');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import HelpApp from './HelpApp.js';
|
|
5
|
+
describe('HelpApp', () => {
|
|
6
|
+
it('should render help guide', () => {
|
|
7
|
+
const { lastFrame } = render(_jsx(HelpApp, {}));
|
|
8
|
+
expect(lastFrame()).toContain('SALAT CLI GUIDE');
|
|
9
|
+
expect(lastFrame()).toContain('Usage:');
|
|
10
|
+
expect(lastFrame()).toContain('Commands:');
|
|
11
|
+
expect(lastFrame()).toContain('Options:');
|
|
12
|
+
});
|
|
13
|
+
it('should list available commands', () => {
|
|
14
|
+
const { lastFrame } = render(_jsx(HelpApp, {}));
|
|
15
|
+
expect(lastFrame()).toContain('times [city]');
|
|
16
|
+
expect(lastFrame()).toContain('guide');
|
|
17
|
+
expect(lastFrame()).toContain('cities');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -1,68 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { Box, Text, useApp } from "ink";
|
|
5
|
-
import { useEffect, useState } from "react";
|
|
6
|
-
// @ts-ignore
|
|
2
|
+
import { useSalat } from "#hooks/useSalat";
|
|
3
|
+
import { getNextPrayer, tConv24 } from "#services/utils/time";
|
|
7
4
|
import { format } from "date-fns";
|
|
8
|
-
import {
|
|
9
|
-
import citiesData from "../data/cities.json" with { type: "json" };
|
|
10
|
-
const cities = citiesData;
|
|
11
|
-
const localStorage = new LocalStorage(LOCAL_STORAGE_PATH);
|
|
5
|
+
import { Box, Text } from "ink";
|
|
12
6
|
const App = ({ cityNameArg, once }) => {
|
|
13
|
-
const {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const [resolvedCityName, setResolvedCityName] = useState("");
|
|
18
|
-
const [currentTime, setCurrentTime] = useState(new Date());
|
|
19
|
-
useEffect(() => {
|
|
20
|
-
const timer = setInterval(() => {
|
|
21
|
-
setCurrentTime(new Date());
|
|
22
|
-
}, 1000);
|
|
23
|
-
return () => clearInterval(timer);
|
|
24
|
-
}, []);
|
|
25
|
-
useEffect(() => {
|
|
26
|
-
const fetchData = async () => {
|
|
27
|
-
try {
|
|
28
|
-
const name = getCityName(cityNameArg, cities);
|
|
29
|
-
setResolvedCityName(name);
|
|
30
|
-
const storageKey = `${name.toLowerCase()}_${format(new Date(), "yyyy-MM-dd")}`;
|
|
31
|
-
let item = localStorage.getItem(storageKey);
|
|
32
|
-
// Disable localStorage for local development
|
|
33
|
-
if (process.env.NODE_ENV === "development") {
|
|
34
|
-
item = null;
|
|
35
|
-
}
|
|
36
|
-
if (item) {
|
|
37
|
-
setPrayerTimes(JSON.parse(item));
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
const cityId = getCityId(name, cities);
|
|
41
|
-
const data = await getData(cityId);
|
|
42
|
-
const prayers = parsePrayerTimesFromResponse(data);
|
|
43
|
-
setPrayerTimes(prayers);
|
|
44
|
-
localStorage.setItem(storageKey, JSON.stringify(prayers));
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
catch (err) {
|
|
48
|
-
setError(err.message || "An error occurred");
|
|
49
|
-
}
|
|
50
|
-
finally {
|
|
51
|
-
setLoading(false);
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
fetchData();
|
|
55
|
-
}, [cityNameArg]);
|
|
56
|
-
useEffect(() => {
|
|
57
|
-
if (once && !loading && (prayerTimes || error)) {
|
|
58
|
-
// Small delay to ensure render happens
|
|
59
|
-
// Ink might need a tick to flush the output to stdout
|
|
60
|
-
const timer = setTimeout(() => {
|
|
61
|
-
exit();
|
|
62
|
-
}, 100);
|
|
63
|
-
return () => clearTimeout(timer);
|
|
64
|
-
}
|
|
65
|
-
}, [once, loading, prayerTimes, error, exit]);
|
|
7
|
+
const { prayerTimes, error, loading, resolvedCityName, currentTime } = useSalat({
|
|
8
|
+
cityNameArg,
|
|
9
|
+
once,
|
|
10
|
+
});
|
|
66
11
|
if (loading) {
|
|
67
12
|
return _jsx(Text, { children: "Loading prayer times..." });
|
|
68
13
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useSalat } from '#hooks/useSalat';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import TimesApp from './TimesApp.js';
|
|
6
|
+
vi.mock('#hooks/useSalat', () => ({
|
|
7
|
+
useSalat: vi.fn()
|
|
8
|
+
}));
|
|
9
|
+
describe('TimesApp', () => {
|
|
10
|
+
it('should render loading state', () => {
|
|
11
|
+
(vi.mocked(useSalat)).mockReturnValue({
|
|
12
|
+
prayerTimes: null,
|
|
13
|
+
error: null,
|
|
14
|
+
loading: true,
|
|
15
|
+
resolvedCityName: '',
|
|
16
|
+
currentTime: new Date()
|
|
17
|
+
});
|
|
18
|
+
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
19
|
+
expect(lastFrame()).toContain('Loading prayer times...');
|
|
20
|
+
});
|
|
21
|
+
it('should render error state', () => {
|
|
22
|
+
(vi.mocked(useSalat)).mockReturnValue({
|
|
23
|
+
prayerTimes: null,
|
|
24
|
+
error: 'Failed to fetch',
|
|
25
|
+
loading: false,
|
|
26
|
+
resolvedCityName: '',
|
|
27
|
+
currentTime: new Date()
|
|
28
|
+
});
|
|
29
|
+
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
30
|
+
expect(lastFrame()).toContain('Error: Failed to fetch');
|
|
31
|
+
});
|
|
32
|
+
it('should render null prayerTimes state', () => {
|
|
33
|
+
(vi.mocked(useSalat)).mockReturnValue({
|
|
34
|
+
prayerTimes: null,
|
|
35
|
+
error: null,
|
|
36
|
+
loading: false,
|
|
37
|
+
resolvedCityName: '',
|
|
38
|
+
currentTime: new Date()
|
|
39
|
+
});
|
|
40
|
+
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
41
|
+
expect(lastFrame()).toContain('Could not fetch prayer times.');
|
|
42
|
+
});
|
|
43
|
+
it('should render prayer times', () => {
|
|
44
|
+
const mockPrayerTimes = {
|
|
45
|
+
Fajr: "05:00",
|
|
46
|
+
Chorouq: "06:30",
|
|
47
|
+
Dhuhr: "12:30",
|
|
48
|
+
Asr: "15:45",
|
|
49
|
+
Maghrib: "18:20",
|
|
50
|
+
Ishae: "19:50"
|
|
51
|
+
};
|
|
52
|
+
(vi.mocked(useSalat)).mockReturnValue({
|
|
53
|
+
prayerTimes: mockPrayerTimes,
|
|
54
|
+
error: null,
|
|
55
|
+
loading: false,
|
|
56
|
+
resolvedCityName: 'Marrakech',
|
|
57
|
+
currentTime: new Date('2026-02-07T10:00:00')
|
|
58
|
+
});
|
|
59
|
+
const { lastFrame } = render(_jsx(TimesApp, {}));
|
|
60
|
+
expect(lastFrame()).toContain('Marrakech, Morocco');
|
|
61
|
+
expect(lastFrame()).toContain('Fajr');
|
|
62
|
+
expect(lastFrame()).toContain('05:00 AM');
|
|
63
|
+
expect(lastFrame()).toContain('Next Prayer: Dhuhr');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { LOCAL_STORAGE_PATH } from "#services/constants";
|
|
2
|
+
import { useApp } from "ink";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
// @ts-ignore
|
|
5
|
+
import { getData } from "#services/utils/api";
|
|
6
|
+
import { getCityId, getCityName } from "#services/utils/city";
|
|
7
|
+
import { parsePrayerTimesFromResponse } from "#services/utils/parser";
|
|
8
|
+
import { format } from "date-fns";
|
|
9
|
+
import { LocalStorage } from "node-localstorage";
|
|
10
|
+
import citiesData from "../data/cities.json" with { type: "json" };
|
|
11
|
+
const cities = citiesData;
|
|
12
|
+
const localStorage = new LocalStorage(LOCAL_STORAGE_PATH);
|
|
13
|
+
export const useSalat = ({ cityNameArg, once }) => {
|
|
14
|
+
const { exit } = useApp();
|
|
15
|
+
const [prayerTimes, setPrayerTimes] = useState(null);
|
|
16
|
+
const [error, setError] = useState(null);
|
|
17
|
+
const [loading, setLoading] = useState(true);
|
|
18
|
+
const [resolvedCityName, setResolvedCityName] = useState("");
|
|
19
|
+
const [currentTime, setCurrentTime] = useState(new Date());
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const timer = setInterval(() => {
|
|
22
|
+
setCurrentTime(new Date());
|
|
23
|
+
}, 1000);
|
|
24
|
+
return () => clearInterval(timer);
|
|
25
|
+
}, []);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const fetchData = async () => {
|
|
28
|
+
try {
|
|
29
|
+
const name = getCityName(cityNameArg, cities);
|
|
30
|
+
setResolvedCityName(name);
|
|
31
|
+
const storageKey = `${name.toLowerCase()}_${format(new Date(), "yyyy-MM-dd")}`;
|
|
32
|
+
let item = localStorage.getItem(storageKey);
|
|
33
|
+
// Disable localStorage for local development
|
|
34
|
+
if (process.env.NODE_ENV === "development") {
|
|
35
|
+
item = null;
|
|
36
|
+
}
|
|
37
|
+
if (item) {
|
|
38
|
+
setPrayerTimes(JSON.parse(item));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const cityId = getCityId(name, cities);
|
|
42
|
+
const data = await getData(cityId);
|
|
43
|
+
const prayers = parsePrayerTimesFromResponse(data);
|
|
44
|
+
setPrayerTimes(prayers);
|
|
45
|
+
localStorage.setItem(storageKey, JSON.stringify(prayers));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
setError(err.message || "An error occurred");
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
setLoading(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
fetchData();
|
|
56
|
+
}, [cityNameArg]);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (once && !loading && (prayerTimes || error)) {
|
|
59
|
+
// Small delay to ensure render happens
|
|
60
|
+
// Ink might need a tick to flush the output to stdout
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
exit();
|
|
63
|
+
}, 100);
|
|
64
|
+
return () => clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
}, [once, loading, prayerTimes, error, exit]);
|
|
67
|
+
return {
|
|
68
|
+
prayerTimes,
|
|
69
|
+
error,
|
|
70
|
+
loading,
|
|
71
|
+
resolvedCityName,
|
|
72
|
+
currentTime,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as constants from '../constants.js';
|
|
3
|
+
import { getData } from './api.js';
|
|
4
|
+
describe('api utils', () => {
|
|
5
|
+
describe('getData', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
8
|
+
});
|
|
9
|
+
it('should fetch data from the correct URL', async () => {
|
|
10
|
+
const mockResponse = 'mock html content';
|
|
11
|
+
(vi.mocked(fetch)).mockResolvedValue({
|
|
12
|
+
text: async () => mockResponse,
|
|
13
|
+
});
|
|
14
|
+
const result = await getData(1);
|
|
15
|
+
expect(fetch).toHaveBeenCalledWith(`${constants.API_URL}?ville=1`);
|
|
16
|
+
expect(result).toBe(mockResponse);
|
|
17
|
+
});
|
|
18
|
+
it('should throw error if fetch fails', async () => {
|
|
19
|
+
(vi.mocked(fetch)).mockRejectedValue(new Error('Network error'));
|
|
20
|
+
await expect(getData(1)).rejects.toThrow('Network error');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DEFAULT_CITY, NOT_FOUND_ERROR } from "#services/constants";
|
|
2
|
+
export const getCityName = (arg, cities) => {
|
|
3
|
+
if (arg == null)
|
|
4
|
+
return DEFAULT_CITY;
|
|
5
|
+
const index = getCityIndex(arg, cities);
|
|
6
|
+
if (index === -1) {
|
|
7
|
+
console.error(NOT_FOUND_ERROR);
|
|
8
|
+
return DEFAULT_CITY;
|
|
9
|
+
}
|
|
10
|
+
return arg;
|
|
11
|
+
};
|
|
12
|
+
export const getCityId = (arg, cities) => {
|
|
13
|
+
const parsed = parseInt(arg);
|
|
14
|
+
if (parsed && cities.length >= parsed) {
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
return getCityIndex(arg, cities) + 1;
|
|
18
|
+
};
|
|
19
|
+
export const getCityIndex = (city, cities) => cities.map((e) => e.name.toLowerCase()).indexOf(city.toLowerCase());
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as constants from '../constants.js';
|
|
3
|
+
import { getCityId, getCityIndex, getCityName } from './city.js';
|
|
4
|
+
// Mock cities data
|
|
5
|
+
const mockCities = [
|
|
6
|
+
{ id: 1, name: 'Casablanca' },
|
|
7
|
+
{ id: 2, name: 'Rabat' },
|
|
8
|
+
{ id: 3, name: 'Fes' },
|
|
9
|
+
];
|
|
10
|
+
describe('city utils', () => {
|
|
11
|
+
describe('getCityName', () => {
|
|
12
|
+
it('should return the default city if no argument is provided', () => {
|
|
13
|
+
expect(getCityName(undefined, mockCities)).toBe(constants.DEFAULT_CITY);
|
|
14
|
+
});
|
|
15
|
+
it('should return the city name if it exists', () => {
|
|
16
|
+
expect(getCityName('Casablanca', mockCities)).toBe('Casablanca');
|
|
17
|
+
});
|
|
18
|
+
it('should return default city and log error if city does not exist', () => {
|
|
19
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
20
|
+
expect(getCityName('UnknownCity', mockCities)).toBe(constants.DEFAULT_CITY);
|
|
21
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(constants.NOT_FOUND_ERROR);
|
|
22
|
+
consoleErrorSpy.mockRestore();
|
|
23
|
+
});
|
|
24
|
+
it('should be case insensitive', () => {
|
|
25
|
+
expect(getCityName('casablanca', mockCities)).toBe('casablanca');
|
|
26
|
+
expect(getCityName('RaBaT', mockCities)).toBe('RaBaT');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe('getCityId', () => {
|
|
30
|
+
it('should return the ID if a number is provided as string within range', () => {
|
|
31
|
+
expect(getCityId('2', mockCities)).toBe(2);
|
|
32
|
+
});
|
|
33
|
+
it('should return ID from index if number is out of range', () => {
|
|
34
|
+
// 4 is out of range (mockCities.length is 3)
|
|
35
|
+
// But if we pass '4', getCityIndex('4', mockCities) returns -1.
|
|
36
|
+
// So it returns -1 + 1 = 0.
|
|
37
|
+
expect(getCityId('4', mockCities)).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
it('should return the ID based on index + 1 if name is provided', () => {
|
|
40
|
+
expect(getCityId('Casablanca', mockCities)).toBe(1);
|
|
41
|
+
expect(getCityId('Rabat', mockCities)).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
it('should handle case insensitivity for city names', () => {
|
|
44
|
+
expect(getCityId('fes', mockCities)).toBe(3);
|
|
45
|
+
});
|
|
46
|
+
it('should return 0 if city name is not found', () => {
|
|
47
|
+
expect(getCityId('Unknown', mockCities)).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe('getCityIndex', () => {
|
|
51
|
+
it('should return the correct index for a city name', () => {
|
|
52
|
+
expect(getCityIndex('Casablanca', mockCities)).toBe(0);
|
|
53
|
+
expect(getCityIndex('Rabat', mockCities)).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
it('should be case insensitive', () => {
|
|
56
|
+
expect(getCityIndex('casablanca', mockCities)).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
it('should return -1 if city is not found', () => {
|
|
59
|
+
expect(getCityIndex('NonExistent', mockCities)).toBe(-1);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import domino from "domino";
|
|
2
|
+
import prayersData from "../../data/prayers.json" with { type: "json" };
|
|
3
|
+
export const parsePrayerTimesFromResponse = (response) => {
|
|
4
|
+
const window = domino.createWindow(response);
|
|
5
|
+
const document = window.document;
|
|
6
|
+
const tds = document.getElementsByTagName("td");
|
|
7
|
+
const prayers = JSON.parse(JSON.stringify(prayersData));
|
|
8
|
+
let j = 0;
|
|
9
|
+
for (let i = 1; i < tds.length && j < prayers.length; i += 2) {
|
|
10
|
+
prayers[j].time = tds[i].textContent.trim();
|
|
11
|
+
j++;
|
|
12
|
+
}
|
|
13
|
+
// Transform array to object and return it
|
|
14
|
+
return prayers.reduce((acc, { prayer, time }) => {
|
|
15
|
+
if (time) {
|
|
16
|
+
acc[prayer] = time;
|
|
17
|
+
}
|
|
18
|
+
return acc;
|
|
19
|
+
}, {});
|
|
20
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parsePrayerTimesFromResponse } from './parser.js';
|
|
3
|
+
describe('parser utils', () => {
|
|
4
|
+
describe('parsePrayerTimesFromResponse', () => {
|
|
5
|
+
it('should parse prayer times correctly from HTML', () => {
|
|
6
|
+
const mockHtml = `
|
|
7
|
+
<html>
|
|
8
|
+
<body>
|
|
9
|
+
<table>
|
|
10
|
+
<tr><td>Ignored</td><td>05:30</td></tr>
|
|
11
|
+
<tr><td>Ignored</td><td>07:00</td></tr>
|
|
12
|
+
<tr><td>Ignored</td><td>12:30</td></tr>
|
|
13
|
+
<tr><td>Ignored</td><td>15:45</td></tr>
|
|
14
|
+
<tr><td>Ignored</td><td>18:20</td></tr>
|
|
15
|
+
<tr><td>Ignored</td><td>19:50</td></tr>
|
|
16
|
+
</table>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
19
|
+
`;
|
|
20
|
+
const results = parsePrayerTimesFromResponse(mockHtml);
|
|
21
|
+
expect(results.Fajr).toBe('05:30');
|
|
22
|
+
expect(results.Chorouq).toBe('07:00');
|
|
23
|
+
expect(results.Dhuhr).toBe('12:30');
|
|
24
|
+
expect(results.Asr).toBe('15:45');
|
|
25
|
+
expect(results.Maghrib).toBe('18:20');
|
|
26
|
+
expect(results.Ishae).toBe('19:50');
|
|
27
|
+
});
|
|
28
|
+
it('should handle empty or invalid HTML and return empty object', () => {
|
|
29
|
+
const results = parsePrayerTimesFromResponse('<html></html>');
|
|
30
|
+
expect(results).toEqual({});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { addDays, format, intervalToDuration, parse } from "date-fns";
|
|
2
|
+
export function tConv24(time24) {
|
|
3
|
+
const date = parse(time24, "HH:mm", new Date());
|
|
4
|
+
return format(date, "hh:mm a");
|
|
5
|
+
}
|
|
6
|
+
export function getNextPrayer(prayerTimes, now) {
|
|
7
|
+
const prayerNames = ["Fajr", "Chorouq", "Dhuhr", "Asr", "Maghrib", "Ishae"];
|
|
8
|
+
const prayersWithDates = prayerNames.map((name) => ({
|
|
9
|
+
name,
|
|
10
|
+
time: prayerTimes[name],
|
|
11
|
+
date: parse(prayerTimes[name], "HH:mm", now),
|
|
12
|
+
}));
|
|
13
|
+
const next = prayersWithDates.find((p) => p.date > now) || {
|
|
14
|
+
...prayersWithDates[0],
|
|
15
|
+
date: addDays(prayersWithDates[0].date, 1),
|
|
16
|
+
};
|
|
17
|
+
const duration = intervalToDuration({ start: now, end: next.date });
|
|
18
|
+
const timeLeft = [duration.hours, duration.minutes, duration.seconds]
|
|
19
|
+
.map((v) => String(v ?? 0).padStart(2, "0"))
|
|
20
|
+
.join(":");
|
|
21
|
+
return {
|
|
22
|
+
prayer: next.name,
|
|
23
|
+
time: next.time,
|
|
24
|
+
timeLeft,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { parseISO } from 'date-fns';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { getNextPrayer, tConv24 } from './time.js';
|
|
4
|
+
describe('time utils', () => {
|
|
5
|
+
describe('tConv24', () => {
|
|
6
|
+
it('should convert 24h time to 12h format with AM/PM', () => {
|
|
7
|
+
expect(tConv24('13:30')).toBe('01:30 PM');
|
|
8
|
+
expect(tConv24('05:15')).toBe('05:15 AM');
|
|
9
|
+
expect(tConv24('00:00')).toBe('12:00 AM');
|
|
10
|
+
expect(tConv24('12:00')).toBe('12:00 PM');
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
describe('getNextPrayer', () => {
|
|
14
|
+
const prayerTimes = {
|
|
15
|
+
Fajr: "05:30",
|
|
16
|
+
Chorouq: "07:00",
|
|
17
|
+
Dhuhr: "12:30",
|
|
18
|
+
Asr: "15:45",
|
|
19
|
+
Maghrib: "18:20",
|
|
20
|
+
Ishae: "19:50"
|
|
21
|
+
};
|
|
22
|
+
it('should return Fajr if current time is before Fajr', () => {
|
|
23
|
+
const now = parseISO('2026-02-07T04:00:00');
|
|
24
|
+
const result = getNextPrayer(prayerTimes, now);
|
|
25
|
+
expect(result.prayer).toBe('Fajr');
|
|
26
|
+
expect(result.time).toBe('05:30');
|
|
27
|
+
expect(result.timeLeft).toBe('01:30:00');
|
|
28
|
+
});
|
|
29
|
+
it('should return Dhuhr if current time is between Chorouq and Dhuhr', () => {
|
|
30
|
+
const now = parseISO('2026-02-07T10:00:00');
|
|
31
|
+
const result = getNextPrayer(prayerTimes, now);
|
|
32
|
+
expect(result.prayer).toBe('Dhuhr');
|
|
33
|
+
expect(result.time).toBe('12:30');
|
|
34
|
+
expect(result.timeLeft).toBe('02:30:00');
|
|
35
|
+
});
|
|
36
|
+
it('should return tomorrow Fajr if current time is after Ishae', () => {
|
|
37
|
+
const now = parseISO('2026-02-07T21:00:00');
|
|
38
|
+
const result = getNextPrayer(prayerTimes, now);
|
|
39
|
+
expect(result.prayer).toBe('Fajr');
|
|
40
|
+
expect(result.time).toBe('05:30');
|
|
41
|
+
expect(result.timeLeft).toBe('08:30:00');
|
|
42
|
+
});
|
|
43
|
+
it('should handle seconds correctly in timeLeft', () => {
|
|
44
|
+
const now = parseISO('2026-02-07T12:29:45');
|
|
45
|
+
const result = getNextPrayer(prayerTimes, now);
|
|
46
|
+
expect(result.prayer).toBe('Dhuhr');
|
|
47
|
+
expect(result.timeLeft).toBe('00:00:15');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "salat",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.9.1",
|
|
4
4
|
"imports": {
|
|
5
5
|
"#*": "./dist/*.js"
|
|
6
6
|
},
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
"date-fns": "^4.1.0",
|
|
20
20
|
"domino": "^2.1.6",
|
|
21
21
|
"ink": "^6.6.0",
|
|
22
|
-
"node-fetch": "^3.3.2",
|
|
23
22
|
"node-localstorage": "^3.0.5",
|
|
24
23
|
"react": "^19.2.4"
|
|
25
24
|
},
|
|
@@ -29,7 +28,9 @@
|
|
|
29
28
|
"start": "node dist/app.js",
|
|
30
29
|
"prestart": "npm run build",
|
|
31
30
|
"prepublishOnly": "npm run build",
|
|
32
|
-
"test": "vitest run"
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:ci": "vitest run --exclude tests/**",
|
|
33
|
+
"test:coverage": "vitest run --coverage"
|
|
33
34
|
},
|
|
34
35
|
"keywords": [
|
|
35
36
|
"prayers",
|
|
@@ -44,10 +45,11 @@
|
|
|
44
45
|
"license": "MIT",
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"@types/node": "^25.2.1",
|
|
47
|
-
"@types/node-fetch": "^2.6.13",
|
|
48
48
|
"@types/node-localstorage": "^1.3.3",
|
|
49
49
|
"@types/react": "^19.2.13",
|
|
50
50
|
"@types/react-dom": "^19.2.3",
|
|
51
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
52
|
+
"ink-testing-library": "^4.0.0",
|
|
51
53
|
"ts-node": "^10.9.2",
|
|
52
54
|
"typescript": "^5.9.3",
|
|
53
55
|
"vitest": "^4.0.18"
|
package/dist/services/utils.js
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { API_URL, DEFAULT_CITY, NOT_FOUND_ERROR } from "#services/constants";
|
|
2
|
-
import { addDays, differenceInSeconds, format, parse } from "date-fns";
|
|
3
|
-
import domino from "domino";
|
|
4
|
-
import fetch from "node-fetch";
|
|
5
|
-
import https from "node:https";
|
|
6
|
-
import prayersData from "../data/prayers.json" with { type: "json" };
|
|
7
|
-
const httpsAgent = new https.Agent({
|
|
8
|
-
rejectUnauthorized: false,
|
|
9
|
-
});
|
|
10
|
-
export const getCityName = (arg, cities) => {
|
|
11
|
-
if (arg == null)
|
|
12
|
-
return DEFAULT_CITY;
|
|
13
|
-
const index = getCityIndex(arg, cities);
|
|
14
|
-
if (index === -1) {
|
|
15
|
-
console.error(NOT_FOUND_ERROR);
|
|
16
|
-
return DEFAULT_CITY;
|
|
17
|
-
}
|
|
18
|
-
return arg;
|
|
19
|
-
};
|
|
20
|
-
export const getCityId = (arg, cities) => {
|
|
21
|
-
const parsed = parseInt(arg);
|
|
22
|
-
if (parsed && cities.length >= parsed) {
|
|
23
|
-
return parsed;
|
|
24
|
-
}
|
|
25
|
-
return getCityIndex(arg, cities) + 1;
|
|
26
|
-
};
|
|
27
|
-
const getCityIndex = (city, cities) => cities.map((e) => e.name.toLowerCase()).indexOf(city.toLowerCase());
|
|
28
|
-
export const getData = async (cityId) => {
|
|
29
|
-
const response = await fetch(`${API_URL}?ville=${cityId}`, {
|
|
30
|
-
agent: httpsAgent,
|
|
31
|
-
});
|
|
32
|
-
return await response.text();
|
|
33
|
-
};
|
|
34
|
-
export const parsePrayerTimesFromResponse = (response) => {
|
|
35
|
-
const window = domino.createWindow(response);
|
|
36
|
-
const document = window.document;
|
|
37
|
-
const tds = document.getElementsByTagName("td");
|
|
38
|
-
const prayers = JSON.parse(JSON.stringify(prayersData));
|
|
39
|
-
let j = 0;
|
|
40
|
-
for (let i = 1; i < tds.length && j < prayers.length; i += 2) {
|
|
41
|
-
prayers[j].time = tds[i].textContent.trim();
|
|
42
|
-
j++;
|
|
43
|
-
}
|
|
44
|
-
// Transform array to object and return it
|
|
45
|
-
return prayers.reduce((acc, { prayer, time }) => {
|
|
46
|
-
if (time) {
|
|
47
|
-
acc[prayer] = time;
|
|
48
|
-
}
|
|
49
|
-
return acc;
|
|
50
|
-
}, {});
|
|
51
|
-
};
|
|
52
|
-
export function tConv24(time24) {
|
|
53
|
-
const date = parse(time24, "HH:mm", new Date());
|
|
54
|
-
return format(date, "hh:mm a");
|
|
55
|
-
}
|
|
56
|
-
export function getNextPrayer(prayerTimes, now) {
|
|
57
|
-
const prayerNames = ["Fajr", "Chorouq", "Dhuhr", "Asr", "Maghrib", "Ishae"];
|
|
58
|
-
const prayersWithDates = prayerNames.map((name) => {
|
|
59
|
-
const time = prayerTimes[name];
|
|
60
|
-
const prayerDate = parse(time, "HH:mm", now);
|
|
61
|
-
return { name, date: prayerDate, time };
|
|
62
|
-
});
|
|
63
|
-
let next = prayersWithDates.find((p) => p.date > now);
|
|
64
|
-
if (!next) {
|
|
65
|
-
const firstPrayerName = prayerNames[0];
|
|
66
|
-
const firstTime = prayerTimes[firstPrayerName];
|
|
67
|
-
const tomorrowFajr = addDays(parse(firstTime, "HH:mm", now), 1);
|
|
68
|
-
next = { name: firstPrayerName, date: tomorrowFajr, time: firstTime };
|
|
69
|
-
}
|
|
70
|
-
const diffSec = differenceInSeconds(next.date, now);
|
|
71
|
-
const h = Math.floor(diffSec / 3600);
|
|
72
|
-
const m = Math.floor((diffSec % 3600) / 60);
|
|
73
|
-
const s = diffSec % 60;
|
|
74
|
-
const timeLeft = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
75
|
-
return {
|
|
76
|
-
prayer: next.name,
|
|
77
|
-
time: next.time,
|
|
78
|
-
timeLeft,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import * as constants from '#services/constants';
|
|
2
|
-
import { getCityId, getCityName, getNextPrayer, parsePrayerTimesFromResponse } from '#services/utils';
|
|
3
|
-
import { parseISO } from 'date-fns';
|
|
4
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
-
// Mock cities data
|
|
6
|
-
const mockCities = [
|
|
7
|
-
{ id: 1, name: 'Casablanca' },
|
|
8
|
-
{ id: 2, name: 'Rabat' },
|
|
9
|
-
{ id: 3, name: 'Fes' },
|
|
10
|
-
];
|
|
11
|
-
describe('utils', () => {
|
|
12
|
-
describe('getCityName', () => {
|
|
13
|
-
it('should return the default city if no argument is provided', () => {
|
|
14
|
-
expect(getCityName(undefined, mockCities)).toBe(constants.DEFAULT_CITY);
|
|
15
|
-
});
|
|
16
|
-
it('should return the city name if it exists', () => {
|
|
17
|
-
expect(getCityName('Casablanca', mockCities)).toBe('Casablanca');
|
|
18
|
-
});
|
|
19
|
-
it('should return default city and log error if city does not exist', () => {
|
|
20
|
-
// Spy on console.error using vitest
|
|
21
|
-
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
22
|
-
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
23
|
-
expect(getCityName('UnknownCity', mockCities)).toBe(constants.DEFAULT_CITY);
|
|
24
|
-
// We expect some error message to be logged.
|
|
25
|
-
// The actual implementation logs with chalk.red, so we just check it was called.
|
|
26
|
-
expect(consoleLogSpy.mock.calls.length + consoleErrorSpy.mock.calls.length).toBeGreaterThan(0);
|
|
27
|
-
consoleLogSpy.mockRestore();
|
|
28
|
-
consoleErrorSpy.mockRestore();
|
|
29
|
-
});
|
|
30
|
-
it('should be case insensitive', () => {
|
|
31
|
-
expect(getCityName('casablanca', mockCities)).toBe('casablanca');
|
|
32
|
-
expect(getCityName('RaBaT', mockCities)).toBe('RaBaT');
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
describe('getCityId', () => {
|
|
36
|
-
it('should return the ID if a number is provided as string', () => {
|
|
37
|
-
expect(getCityId('2', mockCities)).toBe(2);
|
|
38
|
-
});
|
|
39
|
-
it('should return the ID based on index + 1 if name is provided', () => {
|
|
40
|
-
// 'Casablanca' is at index 0, so ID should be 1
|
|
41
|
-
expect(getCityId('Casablanca', mockCities)).toBe(1);
|
|
42
|
-
// 'Rabat' is at index 1, so ID should be 2
|
|
43
|
-
expect(getCityId('Rabat', mockCities)).toBe(2);
|
|
44
|
-
});
|
|
45
|
-
it('should handle case insensitivity for city names', () => {
|
|
46
|
-
expect(getCityId('fes', mockCities)).toBe(3);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
describe('parsePrayerTimesFromResponse', () => {
|
|
50
|
-
it('should parse prayer times correctly from HTML', () => {
|
|
51
|
-
const mockHtml = `
|
|
52
|
-
<html>
|
|
53
|
-
<body>
|
|
54
|
-
<table>
|
|
55
|
-
<tr><td>Fajr</td><td>05:30</td></tr>
|
|
56
|
-
<tr><td>Chourouk</td><td>07:00</td></tr>
|
|
57
|
-
<tr><td>Dhuhr</td><td>12:30</td></tr>
|
|
58
|
-
<tr><td>Asr</td><td>15:45</td></tr>
|
|
59
|
-
<tr><td>Maghrib</td><td>18:20</td></tr>
|
|
60
|
-
<tr><td>Isha</td><td>19:50</td></tr>
|
|
61
|
-
</table>
|
|
62
|
-
</body>
|
|
63
|
-
</html>
|
|
64
|
-
`;
|
|
65
|
-
// Note: The actual implementation expects td elements in a specific order (key, value, key, value...)
|
|
66
|
-
// And it relies on prayersData keys.
|
|
67
|
-
// We need to match the structure expected by the function.
|
|
68
|
-
// The function iterates tds with i=1, i+=2. meaning it takes index 1, 3, 5... as times.
|
|
69
|
-
const results = parsePrayerTimesFromResponse(mockHtml);
|
|
70
|
-
// Based on default prayers.json keys, let's assume we expect those values.
|
|
71
|
-
// Since we mocked the HTML, if the code pushes to 'prayers' array from 'prayersData',
|
|
72
|
-
// and then updates .time property from tds.
|
|
73
|
-
// checking if it returns an object with times
|
|
74
|
-
expect(results).toHaveProperty('Fajr');
|
|
75
|
-
expect(results).toHaveProperty('Dhuhr');
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
describe('getNextPrayer', () => {
|
|
79
|
-
const prayerTimes = {
|
|
80
|
-
Fajr: "05:30",
|
|
81
|
-
Chorouq: "07:00",
|
|
82
|
-
Dhuhr: "12:30",
|
|
83
|
-
Asr: "15:45",
|
|
84
|
-
Maghrib: "18:20",
|
|
85
|
-
Ishae: "19:50"
|
|
86
|
-
};
|
|
87
|
-
it('should return Fajr if current time is before Fajr', () => {
|
|
88
|
-
const now = parseISO('2026-02-07T04:00:00');
|
|
89
|
-
const result = getNextPrayer(prayerTimes, now);
|
|
90
|
-
expect(result.prayer).toBe('Fajr');
|
|
91
|
-
expect(result.time).toBe('05:30');
|
|
92
|
-
expect(result.timeLeft).toBe('01:30:00');
|
|
93
|
-
});
|
|
94
|
-
it('should return Dhuhr if current time is between Chorouq and Dhuhr', () => {
|
|
95
|
-
const now = parseISO('2026-02-07T10:00:00');
|
|
96
|
-
const result = getNextPrayer(prayerTimes, now);
|
|
97
|
-
expect(result.prayer).toBe('Dhuhr');
|
|
98
|
-
expect(result.time).toBe('12:30');
|
|
99
|
-
expect(result.timeLeft).toBe('02:30:00');
|
|
100
|
-
});
|
|
101
|
-
it('should return tomorrow Fajr if current time is after Ishae', () => {
|
|
102
|
-
const now = parseISO('2026-02-07T21:00:00');
|
|
103
|
-
const result = getNextPrayer(prayerTimes, now);
|
|
104
|
-
expect(result.prayer).toBe('Fajr');
|
|
105
|
-
expect(result.time).toBe('05:30');
|
|
106
|
-
// From 21:00 to 05:30 next day is 8 hours and 30 minutes
|
|
107
|
-
expect(result.timeLeft).toBe('08:30:00');
|
|
108
|
-
});
|
|
109
|
-
it('should handle seconds correctly in timeLeft', () => {
|
|
110
|
-
const now = parseISO('2026-02-07T12:29:45');
|
|
111
|
-
const result = getNextPrayer(prayerTimes, now);
|
|
112
|
-
expect(result.prayer).toBe('Dhuhr');
|
|
113
|
-
expect(result.timeLeft).toBe('00:00:15');
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
});
|