salat 3.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +8 -53
- package/dist/constants.js +5 -8
- package/dist/types.js +1 -2
- package/dist/ui.js +69 -0
- package/dist/utils.js +15 -37
- package/dist/utils.test.js +77 -0
- package/package.json +16 -8
- package/src/app.ts +8 -62
- package/src/ui.tsx +111 -0
- package/src/utils.test.ts +95 -0
- package/src/utils.ts +5 -22
- package/tests/cli.e2e.test.ts +46 -0
- package/tsconfig.json +13 -4
- package/vitest.config.ts +9 -0
package/dist/app.js
CHANGED
|
@@ -1,58 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
-
};
|
|
6
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
2
|
// Project's dependencies
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
const cities_json_1 = __importDefault(require("./data/cities.json"));
|
|
13
|
-
// Setting up localStorage
|
|
14
|
-
const node_localstorage_1 = require("node-localstorage");
|
|
15
|
-
const args = process.argv;
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import App from "./ui.js"; // Note the .js extension for ESM imports
|
|
6
|
+
// Project's setup
|
|
16
7
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
17
|
-
|
|
18
|
-
const green = (msg) => console.log(chalk_1.default.green(msg));
|
|
19
|
-
// Cast citiesData to City[] properly
|
|
20
|
-
const cities = cities_json_1.default;
|
|
21
|
-
const localStorage = new node_localstorage_1.LocalStorage(constants_1.LOCAL_STORAGE_PATH);
|
|
8
|
+
const args = process.argv;
|
|
22
9
|
const cityNameArg = args[2];
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const cityId = (0, utils_1.getCityId)(cityName, cities);
|
|
26
|
-
const main = async () => {
|
|
27
|
-
// Printing a banner ('cause I'm cool and I can do it XD)
|
|
28
|
-
green(constants_1.BANNER);
|
|
29
|
-
const storageKey = `${cityName.toLowerCase()}_${new Date().toLocaleDateString()}`;
|
|
30
|
-
let item = localStorage.getItem(storageKey);
|
|
31
|
-
// Disable localStorage for local development
|
|
32
|
-
if (process.env.NODE_ENV === "development") {
|
|
33
|
-
console.log("development mode: localStorage is disabled");
|
|
34
|
-
item = null;
|
|
35
|
-
}
|
|
36
|
-
let prayers = null;
|
|
37
|
-
if (item) {
|
|
38
|
-
prayers = JSON.parse(item);
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
try {
|
|
42
|
-
const data = await (0, utils_1.getData)(cityId);
|
|
43
|
-
prayers = (0, utils_1.parsePrayerTimesFromResponse)(data);
|
|
44
|
-
localStorage.setItem(storageKey, JSON.stringify(prayers));
|
|
45
|
-
}
|
|
46
|
-
catch (ex) {
|
|
47
|
-
//TODO: Use a more descriptive error message
|
|
48
|
-
console.error("Something went wrong!");
|
|
49
|
-
console.log(ex);
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
console.clear();
|
|
54
|
-
(0, utils_1.displayResult)(prayers, cityName);
|
|
10
|
+
const main = () => {
|
|
11
|
+
render(React.createElement(App, { cityNameArg }));
|
|
55
12
|
};
|
|
56
|
-
(
|
|
57
|
-
await main();
|
|
58
|
-
})();
|
|
13
|
+
main();
|
package/dist/constants.js
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
exports.API_URL = "https://www.habous.gov.ma/prieres/horaire-api.php";
|
|
5
|
-
exports.BANNER = ``;
|
|
6
|
-
exports.NOT_FOUND_ERROR = `
|
|
1
|
+
export const API_URL = "https://www.habous.gov.ma/prieres/horaire-api.php";
|
|
2
|
+
export const BANNER = ``;
|
|
3
|
+
export const NOT_FOUND_ERROR = `
|
|
7
4
|
Your city was not found in the list
|
|
8
5
|
Using the default city
|
|
9
6
|
----------------------
|
|
10
7
|
You may need to check the spelling
|
|
11
8
|
`;
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
export const DEFAULT_CITY = "Marrakech";
|
|
10
|
+
export const LOCAL_STORAGE_PATH = "./storage";
|
package/dist/types.js
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1
|
+
export {};
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { LOCAL_STORAGE_PATH } from "#constants";
|
|
3
|
+
import { getCityId, getCityName, getData, parsePrayerTimesFromResponse, tConv24, } from "#utils";
|
|
4
|
+
import { Box, Text, useApp } from "ink";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
// @ts-ignore
|
|
7
|
+
import { LocalStorage } from "node-localstorage";
|
|
8
|
+
import citiesData from "./data/cities.json" with { type: "json" };
|
|
9
|
+
const cities = citiesData;
|
|
10
|
+
const localStorage = new LocalStorage(LOCAL_STORAGE_PATH);
|
|
11
|
+
const App = ({ cityNameArg }) => {
|
|
12
|
+
const { exit } = useApp();
|
|
13
|
+
const [prayerTimes, setPrayerTimes] = useState(null);
|
|
14
|
+
const [error, setError] = useState(null);
|
|
15
|
+
const [loading, setLoading] = useState(true);
|
|
16
|
+
const [resolvedCityName, setResolvedCityName] = useState("");
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const fetchData = async () => {
|
|
19
|
+
try {
|
|
20
|
+
const name = getCityName(cityNameArg, cities);
|
|
21
|
+
setResolvedCityName(name);
|
|
22
|
+
const storageKey = `${name.toLowerCase()}_${new Date().toLocaleDateString()}`;
|
|
23
|
+
let item = localStorage.getItem(storageKey);
|
|
24
|
+
// Disable localStorage for local development
|
|
25
|
+
if (process.env.NODE_ENV === "development") {
|
|
26
|
+
item = null;
|
|
27
|
+
}
|
|
28
|
+
if (item) {
|
|
29
|
+
setPrayerTimes(JSON.parse(item));
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
const cityId = getCityId(name, cities);
|
|
33
|
+
const data = await getData(cityId);
|
|
34
|
+
const prayers = parsePrayerTimesFromResponse(data);
|
|
35
|
+
setPrayerTimes(prayers);
|
|
36
|
+
localStorage.setItem(storageKey, JSON.stringify(prayers));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
setError(err.message || "An error occurred");
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
setLoading(false);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
fetchData();
|
|
47
|
+
}, [cityNameArg]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!loading && (prayerTimes || error)) {
|
|
50
|
+
// Small delay to ensure render happens
|
|
51
|
+
// Ink might need a tick to flush the output to stdout
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
exit();
|
|
54
|
+
}, 100);
|
|
55
|
+
return () => clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}, [loading, prayerTimes, error, exit]);
|
|
58
|
+
if (loading) {
|
|
59
|
+
return _jsx(Text, { children: "Loading prayer times..." });
|
|
60
|
+
}
|
|
61
|
+
if (error) {
|
|
62
|
+
return _jsxs(Text, { color: "red", children: ["Error: ", error] });
|
|
63
|
+
}
|
|
64
|
+
if (!prayerTimes) {
|
|
65
|
+
return _jsx(Text, { color: "red", children: "Could not fetch prayer times." });
|
|
66
|
+
}
|
|
67
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["\uD83E\uDDED ", resolvedCityName, ", Morocco"] }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["\uD83D\uDCC5 ", new Date().toDateString()] }) }), _jsx(Box, { flexDirection: "column", children: Object.entries(prayerTimes).map(([prayer, time]) => (_jsxs(Box, { children: [_jsx(Box, { width: 10, children: _jsx(Text, { color: "white", children: prayer }) }), _jsx(Box, { marginRight: 2, children: _jsx(Text, { children: "-->" }) }), _jsx(Text, { color: "green", children: tConv24(time) })] }, prayer))) })] }));
|
|
68
|
+
};
|
|
69
|
+
export default App;
|
package/dist/utils.js
CHANGED
|
@@ -1,45 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
};
|
|
5
|
-
|
|
6
|
-
exports.displayResult = exports.parsePrayerTimesFromResponse = exports.getData = exports.getCityId = exports.getCityName = void 0;
|
|
7
|
-
const chalk_1 = __importDefault(require("chalk"));
|
|
8
|
-
const domino_1 = __importDefault(require("domino"));
|
|
9
|
-
const node_fetch_1 = __importDefault(require("node-fetch"));
|
|
10
|
-
const constants_1 = require("./constants");
|
|
11
|
-
const prayers_json_1 = __importDefault(require("./data/prayers.json"));
|
|
12
|
-
const error = (msg) => console.log(chalk_1.default.red(msg));
|
|
13
|
-
const getCityName = (arg, cities) => {
|
|
1
|
+
import { API_URL, DEFAULT_CITY, NOT_FOUND_ERROR } from "#constants";
|
|
2
|
+
import domino from "domino";
|
|
3
|
+
import fetch from "node-fetch";
|
|
4
|
+
import prayersData from "./data/prayers.json" with { type: "json" };
|
|
5
|
+
export const getCityName = (arg, cities) => {
|
|
14
6
|
if (arg == null)
|
|
15
|
-
return
|
|
7
|
+
return DEFAULT_CITY;
|
|
16
8
|
const index = getCityIndex(arg, cities);
|
|
17
9
|
if (index === -1) {
|
|
18
|
-
error(
|
|
19
|
-
return
|
|
10
|
+
console.error(NOT_FOUND_ERROR);
|
|
11
|
+
return DEFAULT_CITY;
|
|
20
12
|
}
|
|
21
13
|
return arg;
|
|
22
14
|
};
|
|
23
|
-
|
|
24
|
-
const getCityId = (arg, cities) => {
|
|
15
|
+
export const getCityId = (arg, cities) => {
|
|
25
16
|
const parsed = parseInt(arg);
|
|
26
17
|
if (parsed && cities.length >= parsed) {
|
|
27
18
|
return parsed;
|
|
28
19
|
}
|
|
29
20
|
return getCityIndex(arg, cities) + 1;
|
|
30
21
|
};
|
|
31
|
-
exports.getCityId = getCityId;
|
|
32
22
|
const getCityIndex = (city, cities) => cities.map((e) => e.name.toLowerCase()).indexOf(city.toLowerCase());
|
|
33
|
-
const getData = async (cityId) => {
|
|
34
|
-
const response = await (
|
|
23
|
+
export const getData = async (cityId) => {
|
|
24
|
+
const response = await fetch(`${API_URL}?ville=${cityId}`, {});
|
|
35
25
|
return await response.text();
|
|
36
26
|
};
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const window = domino_1.default.createWindow(response);
|
|
27
|
+
export const parsePrayerTimesFromResponse = (response) => {
|
|
28
|
+
const window = domino.createWindow(response);
|
|
40
29
|
const document = window.document;
|
|
41
30
|
const tds = document.getElementsByTagName("td");
|
|
42
|
-
const prayers = JSON.parse(JSON.stringify(
|
|
31
|
+
const prayers = JSON.parse(JSON.stringify(prayersData));
|
|
43
32
|
let j = 0;
|
|
44
33
|
for (let i = 1; i < tds.length && j < prayers.length; i += 2) {
|
|
45
34
|
prayers[j].time = tds[i].textContent.trim();
|
|
@@ -53,8 +42,7 @@ const parsePrayerTimesFromResponse = (response) => {
|
|
|
53
42
|
return acc;
|
|
54
43
|
}, {});
|
|
55
44
|
};
|
|
56
|
-
|
|
57
|
-
function tConv24(time24) {
|
|
45
|
+
export function tConv24(time24) {
|
|
58
46
|
const [hours, minutes] = time24.split(":");
|
|
59
47
|
const hour = Number(hours);
|
|
60
48
|
const formattedHour = hour % 12 || 12;
|
|
@@ -64,13 +52,3 @@ function tConv24(time24) {
|
|
|
64
52
|
const ampm = hour < 12 ? "AM" : "PM";
|
|
65
53
|
return `${formattedTime} ${ampm}`;
|
|
66
54
|
}
|
|
67
|
-
const displayResult = (prayers, city) => {
|
|
68
|
-
if (!prayers)
|
|
69
|
-
return;
|
|
70
|
-
console.log(` 🧭 ${city}, Morocco\n\n 📆 ${new Date().toDateString()}\n`);
|
|
71
|
-
Object.keys(prayers).forEach((key) => {
|
|
72
|
-
console.log(` ${chalk_1.default.cyan(key.padEnd(7, " "))} --> ${chalk_1.default.green(tConv24(prayers[key]))}`);
|
|
73
|
-
});
|
|
74
|
-
console.log("\n");
|
|
75
|
-
};
|
|
76
|
-
exports.displayResult = displayResult;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as constants from '#constants';
|
|
2
|
+
import { getCityId, getCityName, parsePrayerTimesFromResponse } from '#utils';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
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('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
|
+
// Spy on console.error using vitest
|
|
20
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
21
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
22
|
+
expect(getCityName('UnknownCity', mockCities)).toBe(constants.DEFAULT_CITY);
|
|
23
|
+
// We expect some error message to be logged.
|
|
24
|
+
// The actual implementation logs with chalk.red, so we just check it was called.
|
|
25
|
+
expect(consoleLogSpy.mock.calls.length + consoleErrorSpy.mock.calls.length).toBeGreaterThan(0);
|
|
26
|
+
consoleLogSpy.mockRestore();
|
|
27
|
+
consoleErrorSpy.mockRestore();
|
|
28
|
+
});
|
|
29
|
+
it('should be case insensitive', () => {
|
|
30
|
+
expect(getCityName('casablanca', mockCities)).toBe('casablanca');
|
|
31
|
+
expect(getCityName('RaBaT', mockCities)).toBe('RaBaT');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('getCityId', () => {
|
|
35
|
+
it('should return the ID if a number is provided as string', () => {
|
|
36
|
+
expect(getCityId('2', mockCities)).toBe(2);
|
|
37
|
+
});
|
|
38
|
+
it('should return the ID based on index + 1 if name is provided', () => {
|
|
39
|
+
// 'Casablanca' is at index 0, so ID should be 1
|
|
40
|
+
expect(getCityId('Casablanca', mockCities)).toBe(1);
|
|
41
|
+
// 'Rabat' is at index 1, so ID should be 2
|
|
42
|
+
expect(getCityId('Rabat', mockCities)).toBe(2);
|
|
43
|
+
});
|
|
44
|
+
it('should handle case insensitivity for city names', () => {
|
|
45
|
+
expect(getCityId('fes', mockCities)).toBe(3);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('parsePrayerTimesFromResponse', () => {
|
|
49
|
+
it('should parse prayer times correctly from HTML', () => {
|
|
50
|
+
const mockHtml = `
|
|
51
|
+
<html>
|
|
52
|
+
<body>
|
|
53
|
+
<table>
|
|
54
|
+
<tr><td>Fajr</td><td>05:30</td></tr>
|
|
55
|
+
<tr><td>Chourouk</td><td>07:00</td></tr>
|
|
56
|
+
<tr><td>Dhuhr</td><td>12:30</td></tr>
|
|
57
|
+
<tr><td>Asr</td><td>15:45</td></tr>
|
|
58
|
+
<tr><td>Maghrib</td><td>18:20</td></tr>
|
|
59
|
+
<tr><td>Isha</td><td>19:50</td></tr>
|
|
60
|
+
</table>
|
|
61
|
+
</body>
|
|
62
|
+
</html>
|
|
63
|
+
`;
|
|
64
|
+
// Note: The actual implementation expects td elements in a specific order (key, value, key, value...)
|
|
65
|
+
// And it relies on prayersData keys.
|
|
66
|
+
// We need to match the structure expected by the function.
|
|
67
|
+
// The function iterates tds with i=1, i+=2. meaning it takes index 1, 3, 5... as times.
|
|
68
|
+
const results = parsePrayerTimesFromResponse(mockHtml);
|
|
69
|
+
// Based on default prayers.json keys, let's assume we expect those values.
|
|
70
|
+
// Since we mocked the HTML, if the code pushes to 'prayers' array from 'prayersData',
|
|
71
|
+
// and then updates .time property from tds.
|
|
72
|
+
// checking if it returns an object with times
|
|
73
|
+
expect(results).toHaveProperty('Fajr');
|
|
74
|
+
expect(results).toHaveProperty('Dhuhr');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
package/package.json
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "salat",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"imports": {
|
|
5
|
+
"#*": "./dist/*.js"
|
|
6
|
+
},
|
|
4
7
|
"description": "Daily Moroccan prayers time, right in your console, at the tip of your fingers",
|
|
5
8
|
"homepage": "https://kafiln.github.io/salat-cli/",
|
|
6
9
|
"main": "dist/app.js",
|
|
7
10
|
"bin": {
|
|
8
11
|
"salat": "dist/app.js"
|
|
9
12
|
},
|
|
13
|
+
"type": "module",
|
|
10
14
|
"dependencies": {
|
|
11
|
-
"chalk": "^2.4.2",
|
|
12
15
|
"domino": "^2.1.6",
|
|
13
|
-
"
|
|
14
|
-
"node-
|
|
16
|
+
"ink": "^6.6.0",
|
|
17
|
+
"node-fetch": "^3.3.2",
|
|
18
|
+
"node-localstorage": "^3.0.5",
|
|
19
|
+
"react": "^19.2.4"
|
|
15
20
|
},
|
|
16
21
|
"scripts": {
|
|
17
|
-
"dev": "ts-node src/app.ts",
|
|
22
|
+
"dev": "node --no-warnings --loader ts-node/esm src/app.ts",
|
|
18
23
|
"build": "tsc",
|
|
19
24
|
"start": "node dist/app.js",
|
|
20
25
|
"prestart": "npm run build",
|
|
21
|
-
"prepublishOnly": "npm run build"
|
|
26
|
+
"prepublishOnly": "npm run build",
|
|
27
|
+
"test": "vitest run"
|
|
22
28
|
},
|
|
23
29
|
"keywords": [
|
|
24
30
|
"prayers",
|
|
@@ -35,8 +41,10 @@
|
|
|
35
41
|
"@types/node": "^25.2.1",
|
|
36
42
|
"@types/node-fetch": "^2.6.13",
|
|
37
43
|
"@types/node-localstorage": "^1.3.3",
|
|
38
|
-
"
|
|
44
|
+
"@types/react": "^19.2.13",
|
|
45
|
+
"@types/react-dom": "^19.2.3",
|
|
39
46
|
"ts-node": "^10.9.2",
|
|
40
|
-
"typescript": "^5.9.3"
|
|
47
|
+
"typescript": "^5.9.3",
|
|
48
|
+
"vitest": "^4.0.18"
|
|
41
49
|
}
|
|
42
50
|
}
|
package/src/app.ts
CHANGED
|
@@ -1,72 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// Project's dependencies
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
getCityId,
|
|
8
|
-
getCityName,
|
|
9
|
-
getData,
|
|
10
|
-
parsePrayerTimesFromResponse,
|
|
11
|
-
} from "./utils";
|
|
4
|
+
import { render } from "ink";
|
|
5
|
+
import React from "react";
|
|
6
|
+
import App from "./ui.js"; // Note the .js extension for ESM imports
|
|
12
7
|
|
|
13
|
-
// Project's
|
|
14
|
-
import { BANNER, LOCAL_STORAGE_PATH } from "./constants";
|
|
15
|
-
import citiesData from "./data/cities.json";
|
|
16
|
-
import { City, PrayerTimes } from "./types";
|
|
17
|
-
|
|
18
|
-
// Setting up localStorage
|
|
19
|
-
import { LocalStorage } from "node-localstorage";
|
|
20
|
-
|
|
21
|
-
const args = process.argv;
|
|
8
|
+
// Project's setup
|
|
22
9
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
23
10
|
|
|
24
|
-
|
|
25
|
-
const green = (msg: string) => console.log(chalk.green(msg));
|
|
26
|
-
|
|
27
|
-
// Cast citiesData to City[] properly
|
|
28
|
-
const cities: City[] = citiesData as City[];
|
|
29
|
-
|
|
30
|
-
const localStorage = new LocalStorage(LOCAL_STORAGE_PATH);
|
|
31
|
-
|
|
11
|
+
const args = process.argv;
|
|
32
12
|
const cityNameArg = args[2];
|
|
33
|
-
const cityName = getCityName(cityNameArg, cities);
|
|
34
|
-
// Convert string ID to number since getCityId returns number and getData expects number
|
|
35
|
-
const cityId = getCityId(cityName, cities);
|
|
36
|
-
|
|
37
|
-
const main = async () => {
|
|
38
|
-
// Printing a banner ('cause I'm cool and I can do it XD)
|
|
39
|
-
green(BANNER);
|
|
40
|
-
|
|
41
|
-
const storageKey = `${cityName.toLowerCase()}_${new Date().toLocaleDateString()}`;
|
|
42
|
-
let item = localStorage.getItem(storageKey);
|
|
43
|
-
|
|
44
|
-
// Disable localStorage for local development
|
|
45
|
-
if (process.env.NODE_ENV === "development") {
|
|
46
|
-
console.log("development mode: localStorage is disabled");
|
|
47
|
-
item = null;
|
|
48
|
-
}
|
|
49
|
-
let prayers: PrayerTimes | null = null;
|
|
50
|
-
|
|
51
|
-
if (item) {
|
|
52
|
-
prayers = JSON.parse(item);
|
|
53
|
-
} else {
|
|
54
|
-
try {
|
|
55
|
-
const data = await getData(cityId);
|
|
56
|
-
prayers = parsePrayerTimesFromResponse(data);
|
|
57
|
-
localStorage.setItem(storageKey, JSON.stringify(prayers));
|
|
58
|
-
} catch (ex) {
|
|
59
|
-
//TODO: Use a more descriptive error message
|
|
60
|
-
console.error("Something went wrong!");
|
|
61
|
-
console.log(ex);
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
13
|
|
|
66
|
-
|
|
67
|
-
|
|
14
|
+
const main = () => {
|
|
15
|
+
render(React.createElement(App, { cityNameArg }));
|
|
68
16
|
};
|
|
69
17
|
|
|
70
|
-
(
|
|
71
|
-
await main();
|
|
72
|
-
})();
|
|
18
|
+
main();
|
package/src/ui.tsx
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { LOCAL_STORAGE_PATH } from "#constants";
|
|
2
|
+
import { City, PrayerTimes } from "#types";
|
|
3
|
+
import {
|
|
4
|
+
getCityId,
|
|
5
|
+
getCityName,
|
|
6
|
+
getData,
|
|
7
|
+
parsePrayerTimesFromResponse,
|
|
8
|
+
tConv24,
|
|
9
|
+
} from "#utils";
|
|
10
|
+
import { Box, Text, useApp } from "ink";
|
|
11
|
+
import React, { useEffect, useState } from "react";
|
|
12
|
+
// @ts-ignore
|
|
13
|
+
import { LocalStorage } from "node-localstorage";
|
|
14
|
+
import citiesData from "./data/cities.json" with { type: "json" };
|
|
15
|
+
|
|
16
|
+
const cities: City[] = citiesData as City[];
|
|
17
|
+
const localStorage = new LocalStorage(LOCAL_STORAGE_PATH);
|
|
18
|
+
|
|
19
|
+
interface AppProps {
|
|
20
|
+
cityNameArg?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const App: React.FC<AppProps> = ({ cityNameArg }) => {
|
|
24
|
+
const { exit } = useApp();
|
|
25
|
+
const [prayerTimes, setPrayerTimes] = useState<PrayerTimes | null>(null);
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
const [loading, setLoading] = useState<boolean>(true);
|
|
28
|
+
const [resolvedCityName, setResolvedCityName] = useState<string>("");
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const fetchData = async () => {
|
|
32
|
+
try {
|
|
33
|
+
const name = getCityName(cityNameArg, cities);
|
|
34
|
+
setResolvedCityName(name);
|
|
35
|
+
|
|
36
|
+
const storageKey = `${name.toLowerCase()}_${new Date().toLocaleDateString()}`;
|
|
37
|
+
let item = localStorage.getItem(storageKey);
|
|
38
|
+
|
|
39
|
+
// Disable localStorage for local development
|
|
40
|
+
if (process.env.NODE_ENV === "development") {
|
|
41
|
+
item = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (item) {
|
|
45
|
+
setPrayerTimes(JSON.parse(item));
|
|
46
|
+
} else {
|
|
47
|
+
const cityId = getCityId(name, cities);
|
|
48
|
+
const data = await getData(cityId);
|
|
49
|
+
const prayers = parsePrayerTimesFromResponse(data);
|
|
50
|
+
setPrayerTimes(prayers);
|
|
51
|
+
localStorage.setItem(storageKey, JSON.stringify(prayers));
|
|
52
|
+
}
|
|
53
|
+
} catch (err: any) {
|
|
54
|
+
setError(err.message || "An error occurred");
|
|
55
|
+
} finally {
|
|
56
|
+
setLoading(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
fetchData();
|
|
61
|
+
}, [cityNameArg]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!loading && (prayerTimes || error)) {
|
|
65
|
+
// Small delay to ensure render happens
|
|
66
|
+
// Ink might need a tick to flush the output to stdout
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
exit();
|
|
69
|
+
}, 100);
|
|
70
|
+
return () => clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
}, [loading, prayerTimes, error, exit]);
|
|
73
|
+
|
|
74
|
+
if (loading) {
|
|
75
|
+
return <Text>Loading prayer times...</Text>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (error) {
|
|
79
|
+
return <Text color="red">Error: {error}</Text>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!prayerTimes) {
|
|
83
|
+
return <Text color="red">Could not fetch prayer times.</Text>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Box flexDirection="column" padding={1}>
|
|
88
|
+
<Box marginBottom={1}>
|
|
89
|
+
<Text>🧭 {resolvedCityName}, Morocco</Text>
|
|
90
|
+
</Box>
|
|
91
|
+
<Box marginBottom={1}>
|
|
92
|
+
<Text>📅 {new Date().toDateString()}</Text>
|
|
93
|
+
</Box>
|
|
94
|
+
<Box flexDirection="column">
|
|
95
|
+
{Object.entries(prayerTimes).map(([prayer, time]) => (
|
|
96
|
+
<Box key={prayer}>
|
|
97
|
+
<Box width={10}>
|
|
98
|
+
<Text color="white">{prayer}</Text>
|
|
99
|
+
</Box>
|
|
100
|
+
<Box marginRight={2}>
|
|
101
|
+
<Text>--></Text>
|
|
102
|
+
</Box>
|
|
103
|
+
<Text color="green">{tConv24(time)}</Text>
|
|
104
|
+
</Box>
|
|
105
|
+
))}
|
|
106
|
+
</Box>
|
|
107
|
+
</Box>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export default App;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
|
|
2
|
+
import * as constants from '#constants';
|
|
3
|
+
import { City } from '#types';
|
|
4
|
+
import { getCityId, getCityName, parsePrayerTimesFromResponse } from '#utils';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
// Mock cities data
|
|
8
|
+
const mockCities: City[] = [
|
|
9
|
+
{ id: 1, name: 'Casablanca' },
|
|
10
|
+
{ id: 2, name: 'Rabat' },
|
|
11
|
+
{ id: 3, name: 'Fes' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe('utils', () => {
|
|
15
|
+
describe('getCityName', () => {
|
|
16
|
+
it('should return the default city if no argument is provided', () => {
|
|
17
|
+
expect(getCityName(undefined, mockCities)).toBe(constants.DEFAULT_CITY);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should return the city name if it exists', () => {
|
|
21
|
+
expect(getCityName('Casablanca', mockCities)).toBe('Casablanca');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should return default city and log error if city does not exist', () => {
|
|
25
|
+
// Spy on console.error using vitest
|
|
26
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
27
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
28
|
+
|
|
29
|
+
expect(getCityName('UnknownCity', mockCities)).toBe(constants.DEFAULT_CITY);
|
|
30
|
+
|
|
31
|
+
// We expect some error message to be logged.
|
|
32
|
+
// The actual implementation logs with chalk.red, so we just check it was called.
|
|
33
|
+
expect(consoleLogSpy.mock.calls.length + consoleErrorSpy.mock.calls.length).toBeGreaterThan(0);
|
|
34
|
+
|
|
35
|
+
consoleLogSpy.mockRestore();
|
|
36
|
+
consoleErrorSpy.mockRestore();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should be case insensitive', () => {
|
|
40
|
+
expect(getCityName('casablanca', mockCities)).toBe('casablanca');
|
|
41
|
+
expect(getCityName('RaBaT', mockCities)).toBe('RaBaT');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('getCityId', () => {
|
|
46
|
+
it('should return the ID if a number is provided as string', () => {
|
|
47
|
+
expect(getCityId('2', mockCities)).toBe(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should return the ID based on index + 1 if name is provided', () => {
|
|
51
|
+
// 'Casablanca' is at index 0, so ID should be 1
|
|
52
|
+
expect(getCityId('Casablanca', mockCities)).toBe(1);
|
|
53
|
+
// 'Rabat' is at index 1, so ID should be 2
|
|
54
|
+
expect(getCityId('Rabat', mockCities)).toBe(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should handle case insensitivity for city names', () => {
|
|
58
|
+
expect(getCityId('fes', mockCities)).toBe(3);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('parsePrayerTimesFromResponse', () => {
|
|
63
|
+
it('should parse prayer times correctly from HTML', () => {
|
|
64
|
+
const mockHtml = `
|
|
65
|
+
<html>
|
|
66
|
+
<body>
|
|
67
|
+
<table>
|
|
68
|
+
<tr><td>Fajr</td><td>05:30</td></tr>
|
|
69
|
+
<tr><td>Chourouk</td><td>07:00</td></tr>
|
|
70
|
+
<tr><td>Dhuhr</td><td>12:30</td></tr>
|
|
71
|
+
<tr><td>Asr</td><td>15:45</td></tr>
|
|
72
|
+
<tr><td>Maghrib</td><td>18:20</td></tr>
|
|
73
|
+
<tr><td>Isha</td><td>19:50</td></tr>
|
|
74
|
+
</table>
|
|
75
|
+
</body>
|
|
76
|
+
</html>
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
// Note: The actual implementation expects td elements in a specific order (key, value, key, value...)
|
|
80
|
+
// And it relies on prayersData keys.
|
|
81
|
+
// We need to match the structure expected by the function.
|
|
82
|
+
// The function iterates tds with i=1, i+=2. meaning it takes index 1, 3, 5... as times.
|
|
83
|
+
|
|
84
|
+
const results = parsePrayerTimesFromResponse(mockHtml);
|
|
85
|
+
|
|
86
|
+
// Based on default prayers.json keys, let's assume we expect those values.
|
|
87
|
+
// Since we mocked the HTML, if the code pushes to 'prayers' array from 'prayersData',
|
|
88
|
+
// and then updates .time property from tds.
|
|
89
|
+
|
|
90
|
+
// checking if it returns an object with times
|
|
91
|
+
expect(results).toHaveProperty('Fajr');
|
|
92
|
+
expect(results).toHaveProperty('Dhuhr');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
package/src/utils.ts
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { API_URL, DEFAULT_CITY, NOT_FOUND_ERROR } from "#constants";
|
|
2
|
+
import { City, PrayerDef, PrayerTimes } from "#types";
|
|
2
3
|
import domino from "domino";
|
|
3
4
|
import fetch from "node-fetch";
|
|
4
|
-
import
|
|
5
|
-
import prayersData from "./data/prayers.json";
|
|
6
|
-
import { City, PrayerDef, PrayerTimes } from "./types";
|
|
5
|
+
import prayersData from "./data/prayers.json" with { type: "json" };
|
|
7
6
|
|
|
8
|
-
const error = (msg: string) => console.log(chalk.red(msg));
|
|
9
7
|
|
|
10
8
|
export const getCityName = (arg: string | undefined, cities: City[]): string => {
|
|
11
9
|
if (arg == null) return DEFAULT_CITY;
|
|
12
10
|
const index = getCityIndex(arg, cities);
|
|
13
11
|
if (index === -1) {
|
|
14
|
-
error(NOT_FOUND_ERROR);
|
|
12
|
+
console.error(NOT_FOUND_ERROR);
|
|
15
13
|
return DEFAULT_CITY;
|
|
16
14
|
}
|
|
17
15
|
return arg;
|
|
@@ -58,7 +56,7 @@ export const parsePrayerTimesFromResponse = (response: string): PrayerTimes => {
|
|
|
58
56
|
}, {} as PrayerTimes);
|
|
59
57
|
};
|
|
60
58
|
|
|
61
|
-
function tConv24(time24: string): string {
|
|
59
|
+
export function tConv24(time24: string): string {
|
|
62
60
|
const [hours, minutes] = time24.split(":");
|
|
63
61
|
const hour = Number(hours);
|
|
64
62
|
const formattedHour = hour % 12 || 12;
|
|
@@ -69,18 +67,3 @@ function tConv24(time24: string): string {
|
|
|
69
67
|
return `${formattedTime} ${ampm}`;
|
|
70
68
|
}
|
|
71
69
|
|
|
72
|
-
export const displayResult = (
|
|
73
|
-
prayers: PrayerTimes | undefined | null,
|
|
74
|
-
city: string
|
|
75
|
-
) => {
|
|
76
|
-
if (!prayers) return;
|
|
77
|
-
console.log(` 🧭 ${city}, Morocco\n\n 📆 ${new Date().toDateString()}\n`);
|
|
78
|
-
Object.keys(prayers).forEach((key) => {
|
|
79
|
-
console.log(
|
|
80
|
-
` ${chalk.cyan(key.padEnd(7, " "))} --> ${chalk.green(
|
|
81
|
-
tConv24(prayers[key])
|
|
82
|
-
)}`
|
|
83
|
-
);
|
|
84
|
-
});
|
|
85
|
-
console.log("\n");
|
|
86
|
-
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
|
|
2
|
+
import { exec } from 'child_process';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import util from 'util';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { dirname } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
|
|
13
|
+
const execPromise = util.promisify(exec);
|
|
14
|
+
const appPath = path.join(__dirname, '../src/app.ts');
|
|
15
|
+
|
|
16
|
+
describe('CLI E2E', () => {
|
|
17
|
+
it('should run and display prayer times for default city', async () => {
|
|
18
|
+
// Running via ts-node to avoid build step dependency in tests
|
|
19
|
+
// using npx ts-node might be slower but works without pre-build
|
|
20
|
+
const { stdout, stderr } = await execPromise(`node --no-warnings --loader ts-node/esm ${appPath}`);
|
|
21
|
+
|
|
22
|
+
expect(stderr).toBe('');
|
|
23
|
+
expect(stdout).toContain('Marrakech'); // Default city
|
|
24
|
+
expect(stdout).toContain('Fajr');
|
|
25
|
+
expect(stdout).toContain('Isha');
|
|
26
|
+
}, 10000); // increase timeout for CLI execution
|
|
27
|
+
|
|
28
|
+
it('should run and display prayer times for a specific city', async () => {
|
|
29
|
+
const { stdout } = await execPromise(`node --no-warnings --loader ts-node/esm ${appPath} "Rabat"`);
|
|
30
|
+
|
|
31
|
+
expect(stdout).toContain('Rabat');
|
|
32
|
+
expect(stdout).toContain('Fajr');
|
|
33
|
+
}, 10000);
|
|
34
|
+
|
|
35
|
+
it('should handle invalid city gracefully', async () => {
|
|
36
|
+
const { stdout } = await execPromise(`node --no-warnings --loader ts-node/esm ${appPath} "InvalidCity"`);
|
|
37
|
+
|
|
38
|
+
// As per utils.ts, it returns default city (Casablanca) and logs error (NOT_FOUND_ERROR)
|
|
39
|
+
// Note: The error is logged to console.log, so it might appear in stdout or just be visible.
|
|
40
|
+
// utils.ts: error(NOT_FOUND_ERROR) -> console.log(chalk.red(msg))
|
|
41
|
+
|
|
42
|
+
expect(stdout).toContain('Marrakech');
|
|
43
|
+
// We might want to check for the error message if it's printed to stdout
|
|
44
|
+
// BUT utils.ts uses console.log for error as well.
|
|
45
|
+
}, 10000);
|
|
46
|
+
});
|
package/tsconfig.json
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
|
-
"target": "
|
|
4
|
-
"module": "
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
5
|
"rootDir": "./src",
|
|
6
6
|
"outDir": "./dist",
|
|
7
7
|
"esModuleInterop": true,
|
|
8
8
|
"forceConsistentCasingInFileNames": true,
|
|
9
9
|
"strict": true,
|
|
10
10
|
"skipLibCheck": true,
|
|
11
|
-
"
|
|
11
|
+
"moduleResolution": "NodeNext",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"baseUrl": "./",
|
|
14
|
+
"paths": {
|
|
15
|
+
"#*": ["./src/*.ts"]
|
|
16
|
+
},
|
|
17
|
+
"jsx": "react-jsx"
|
|
12
18
|
},
|
|
13
19
|
"include": ["src/**/*"],
|
|
14
|
-
"exclude": ["node_modules"]
|
|
20
|
+
"exclude": ["node_modules"],
|
|
21
|
+
"ts-node": {
|
|
22
|
+
"esm": true
|
|
23
|
+
}
|
|
15
24
|
}
|
package/vitest.config.ts
ADDED